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

Editor: rebuild the plan library around a solved document, and bend turnouts onto curves

The library was a heap of half-built services - fitting, parallelism, niwelety,
an SVG writer - that never met each other. In their place is one authored
Document and one solver that lays it whole: tracks as element chains, turnouts
placed on them, and a Solution that is rebuilt from scratch every time and never
fed back into the document. What cannot be laid comes back as a diagnostic with
the number in it rather than as silence or a fitted-away error.

Turnouts are dimensioned the way the drawings dimension them. The blade follows
Koc's rys. 5.9 - the curve is theoretically tangent to the opornica at PR, the
blade starts at a with a planed nose of d, and A is where the rail stands u
thick - and a + d derived that way lands on his tablica 5.1. Bending follows
5.3.6: it rotates the triangle OBC about the centre of the turnout, so the
tangent t and the crossing angle stand and both radii follow from them. That is
Koc's (5.7) and (5.8) without coding either, and it puts a symmetrically bent
Rz 49E1-190-1:9 on the catalogue's Rlus 49E1-380,292-1:9. A rozjazd standing on
a curve is bent onto it; one on a track drawn straight can carry a bend of its
own. The szyny laczace take up the difference, because the switch and the frog
are only bent, not re-cut.

The plan panel draws all of it: the catalogue drawing of a type with its
construction points, the theoretical arc dashed where no rail runs, and the
blade nose as the straight it is cut as. Dragging a straight now stops where it
would pull the track out from under a turnout.

File format goes to m0s 6. Versions 3 to 5 still open.
This commit is contained in:
maj00r
2026-08-29 19:32:46 +02:00
parent 8cd42980e6
commit 2376e4867b
97 changed files with 8608 additions and 9792 deletions

View File

@@ -513,6 +513,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})

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

View File

@@ -34,15 +34,24 @@ Turnout {
PR : Pose // początek rozjazdu: styczny do toru zasadniczego PR : Pose // początek rozjazdu: styczny do toru zasadniczego
KR : Pose // koniec (krzyżownica): kierunek = tor zasadniczy obrócony o alfa 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 crossing_angle : alfa = atan(1/n) // radiany
blade_angle : beta // kąt nagięcia iglicy (kąt zwrotnicowy) pieces : [Piece...] // geometria toru zwrotnego, PR -> KR, wprost z katalogu
pre_blade_length : d // odcinek przediglicowy: prosty, PR -> ostrze iglicy blade : { u, w } // grubosc scietego ostrza, szerokosc glowki szyny
through_axis : [Bezier...] // tor zasadniczy przez rozjazd (zwykle prosty) through_axis : [Bezier...] // tor zasadniczy przez rozjazd (zwykle prosty)
diverging_axis: [Bezier...] // przediglicowy -> iglica(prosta @ beta) -> luk -> prosta -> KR 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 Pose { x, y, hx, hy } // pozycja + jednostkowa styczna
Bezier { p0, p1, p2, p3 } // kubiczna, punkty kontrolne w XY (metry) Bezier { p0, p1, p2, p3 } // kubiczna, punkty kontrolne w XY (metry)
``` ```
@@ -51,27 +60,46 @@ Tor zasadniczy nie jest dzielony — `through_axis` jest podany dla kompletnośc
(runtime często ma już tor główny). Rozjazd wnosi przede wszystkim (runtime często ma już tor główny). Rozjazd wnosi przede wszystkim
`diverging_axis` + osprzęt + podrozjazdnice spinające oba tory. `diverging_axis` + osprzęt + podrozjazdnice spinające oba tory.
## Początek odnogi: odcinek przediglicowy + iglica ## Początek odnogi: lista odcinków, nie jeden kształt
Odnoga **nie zaczyna się łukiem**. To zwrotnica sieczna z iglicą prostą: 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:
1. **odcinek przediglicowy** — prosta w kierunku toru zasadniczego (kąt 0), od - **zwrotnica sieczna z iglicą prostą** — `[przediglicowy] [iglica prosta @ beta]
`PR` do ostrza iglicy. Tu rozjazd jest jeszcze równoległy do toru głównego; [łuk o alfa − beta] [prosta krzyżownicowa]`,
tu siedzi napęd. - **zwrotnica z iglicą łukową styczną do opornicy** — `[iglica łuk R od PR]
2. **iglica** — **prosta odcięta pod kątem `beta`** („prosta dogięta do [łuk R] [prosta krzyżownicowa]`, `beta = 0` wszędzie: odnoga odchodzi stycznie,
prostego"). Odnoga odchodzi od toru zasadniczego dopiero tutaj, o kąt nagięcia bez załamania kierunku.
iglicy `beta`.
3. **łuk** — promień `R`, skręca od `beta` do kąta krzyżownicy `alfa`, czyli o
**`alfa − beta`** (nie o całe `alfa`).
4. **prosta krzyżownicowa** — pod kątem `alfa`, do `KR`.
Stąd `diverging_axis` = `[przediglicowy] [iglica @ beta] [łuk] [prosta] `. Iglice **Ścięte ostrze** (Koc, rys. 5.9). Iglicy nie da się zaostrzyć do zera. Łuk
łukowe (styczne, `beta ≈ 0`, ostrze wtopione w łuk) to inny wariant — na razie iglicy jest **teoretycznie styczny do opornicy w PR**, ale sama iglica zaczyna
modelujemy iglicę prostą. 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.
**Model:** zespół iglic kotwiony w ostrzu (`PR` + `pre_blade_length` wzdłuż toru Wszystko wychodzi z jednej drogi — do zadanego luzu między iglicą a opornicą:
głównego), zorientowany wzdłuż toru zasadniczego, nadaje `beta`; długość iglicy
jest po stronie modelu. ```
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 ## Bézier — definicja i próbkowanie
@@ -167,10 +195,11 @@ animacji przełożenia — do ustalenia z modelarzem. Pozycja bazowa: `PR`.
długość i osie w postaci polilinii. Do kontraktu trzeba dołożyć: długość i osie w postaci polilinii. Do kontraktu trzeba dołożyć:
- **`crossing_mark` i `rail_profile`** w danych eksportu, - **`crossing_mark` i `rail_profile`** w danych eksportu,
- **`blade_angle` (beta) i `pre_blade_length`** — dziś `lay_turnout` kładzie łuk - ~~`blade_angle` i `pre_blade_length`~~ — zrobione inaczej: `lay_turnout` kładzie
styczny od `PR` (beta = 0, brak przediglicowego). Trzeba: odcinek przediglicowy **listę odcinków** z typu, więc przediglicowy, iglica (prosta pod `beta` albo
(prosty), iglicę prostą pod `beta`, łuk o `alfa − beta`, prostą do `KR`, łukowa styczna), łuk i prosta krzyżownicowa to po prostu jej pozycje,
- te dwa parametry **w szablonach** (`turnout_preset`) per typ, - **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 - osie jako **Béziery** (dziś polilinie) — konwersja łuk/klotoida → kubiczne wg
powyższego, powyższego,
- jawne oznaczenie „to rozjazd" (nie zwykły łuk), z `PR`/`KR`/`alfa`. - jawne oznaczenie „to rozjazd" (nie zwykły łuk), z `PR`/`KR`/`alfa`.

File diff suppressed because it is too large Load Diff

View File

@@ -12,19 +12,27 @@ http://mozilla.org/MPL/2.0/.
#include "application/uilayer.h" #include "application/uilayer.h"
#include "editor/orthophoto.h" #include "editor/orthophoto.h"
#include "maj0sted/app/basket_draft.hpp" #include "maj0sted/editor/layout.hpp"
#include "maj0sted/app/editor_document.hpp" #include "maj0sted/editor/model.hpp"
#include "maj0sted/app/straight_constraints.hpp" #include "maj0sted/editor/render.hpp"
#include "maj0sted/editor/solution.hpp"
#include "maj0sted/io/scn_export.hpp" #include "maj0sted/io/scn_export.hpp"
#include "maj0sted/editor/editor.hpp"
#include "maj0sted/editor/ribbon.hpp"
#include <string>
#include <vector> #include <vector>
// track layout tool: straights laid down by hand on the scenery itself and the curves fitted into // track layout tool. the drawing happens in the viewport, which the tool puts into a top-down
// the gaps between them. the panel holds only the controls - the drawing happens in the viewport, // orthographic plan view; the panel holds only the controls. none of the geometry is computed here:
// which the tool puts into a top-down orthographic plan view. none of the geometry is computed // maj0sted solves the whole document and the panel paints what comes back.
// 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 class plan_panel : public ui_panel
{ {
@@ -35,131 +43,133 @@ class plan_panel : public ui_panel
void render_contents() override; void render_contents() override;
private: private:
// methods 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_toolbar();
void render_draft(); void render_elements();
void render_gaps(); void render_turnouts();
void render_compound(maj0sted::editor::GapFit &Fit, std::size_t const Gap, bool &Dirty); void render_turnout_type(maj0sted::editor::TurnoutType &Type_);
void fit_compound_to_guides(); // the catalogue drawing of the chosen type, in its own window: the diverging path piece by
void clear_fit_guides(); // piece, both tracks' rails, and the points a drawing is dimensioned from
void render_template_window();
void render_diagnostics();
void render_storage(); void render_storage();
void export_scn(); void export_scn();
void seed_catalogue();
void render_newmap_dialog(); void render_newmap_dialog();
void render_location_dialog(); void render_location_dialog();
void start_map(bool const Georeferenced, double const Originx, double const Originy);
// scene
void handle_scene(); void handle_scene();
void draw_on_scene(); void draw_on_scene();
void solve();
void apply_constraints();
maj0sted::editor::NiweletaSpec *current_niweleta();
maj0sted::editor::NiweletaSpec const *current_niweleta() const;
void align_fits();
bool fit_applied(std::size_t const Gap) const;
// independent anchored straights: each endpoint is its own handle
maj0sted::editor::StraightSpec const *handle_straight(std::size_t const Niw, std::size_t const Index) const;
void append_vertex(double const X, double const Y);
void drop_last_vertex();
void delete_selected_straight();
void delete_current_niweleta();
void clear_selection();
// hit tests (screen pixels). niw=-1 means "current niweleta only"
bool hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const;
bool hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const;
// nearest niweleta by its solved axis (curves included), for a forgiving double-click select
int nearest_niweleta_axis(ImVec2 const &Mouse, float const Tolerance) const;
// nearest station (arc length) on a through niweleta's axis to a plan point; for placing/moving switches
bool station_on_through(std::size_t const Through, double const Wx, double const Wy, double &OutStation) const;
// compound basket: draggable ends of entry/exit KP, free arcs and between-KP
bool hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const;
void drag_compound_station(double const X, double const Y);
// arc-first draft: continue from a straight end, click appends luk/KP by length
void begin_draft();
void discard_draft();
void start_draft_from_end(std::size_t const Str, int const End);
void append_draft_at(double const X, double const Y);
void undo_draft_fragment();
bool finish_draft();
bool reopen_draft_from_gap(std::size_t const Gap);
void rebuild_draft_preview();
bool draft_tip(double &OutX, double &OutY) const;
bool draft_end_pose(double &OutX, double &OutY, double &OutAz) const;
bool hit_draft_station(ImVec2 const &Mouse, int &OutKind, int &OutArc) const;
void drag_draft_station(double const X, double const Y);
bool anchor_draft_to_gap(std::size_t const Gap);
// switches (rozjazdy): a branch leaves a through niweleta without splitting it. clicking the
// through track drops a switch there and starts a branch niweleta pinned to its frog
void add_switch_on(std::size_t const Through, double const Wx, double const Wy);
void render_switches();
void go_to_plan(); 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;
// 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 // members
maj0sted::app::EditorDocument m_document; Document m_document;
std::vector<maj0sted::editor::NiweletaPolys> m_solved; Solution m_solution;
std::vector<maj0sted::editor::JunctionGeom> m_junctions; std::vector<maj0sted::editor::PlanPolyline> m_rails;
std::size_t m_niweleta{0};
// switch placement: 0 idle, 1 waiting for a click on the through track TrackId m_track{};
int m_pick_switch{0}; ElementId m_sel_element{};
int m_sw_side{0}; // 0 left, 1 right TurnoutId m_sel_turnout{};
double m_sw_crossing{9.0}; // skos 1:n
double m_sw_radius{190.0}; // internal arc radius (metres)
double m_sw_length{0.0}; // catalogue length from the chosen template (0 = just the curve)
double m_sw_pre_blade{0.0}; // odcinek przediglicowy from the template, metres
double m_sw_blade_angle{0.0}; // kąt nagięcia iglicy (beta) from the template, radians
double m_sw_blade_length{0.0};// the straight blade from the template, metres
int m_drag_junction{-1}; // index of the switch being slid along its through track, or -1
int m_sel_straight{-1}; // a press on the ground does whatever is under it: picks up what is drawn there, or carries the
int m_sel_end{-1}; // current track on to there. every transient errand (placing a turnout, picking the element to
bool m_dragging{false}; // run parallel to) is started from its own button, and Esc drops back out of it
// captured at drag start so Shift keeps the angle while the length changes bool m_dragging_turnout{false};
double m_drag_fixed_x{0.0};
double m_drag_fixed_y{0.0};
double m_drag_ux{1.0};
double m_drag_uy{0.0};
// compound station drag: kind 0=entry KP end, 1=free-arc end, 2=between-KP end, 3=exit KP start // grabbing a straight by one of its ends: which one, and the far end it swings about. the far
bool m_dragging_cmp{false}; // end is captured once, at the grab, because re-fitting the arc behind it moves it
int m_cmp_gap{-1}; bool m_dragging_straight{false};
int m_cmp_kind{-1}; std::size_t m_drag_index{0};
int m_cmp_arc{-1}; int m_drag_end{0};
double m_pivot_x{0.0};
double m_pivot_y{0.0};
bool m_drawing{true}; // 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}; bool m_pending{false};
double m_pendingx{0.0}; double m_startx{0.0};
double m_pendingy{0.0}; double m_starty{0.0};
// 0 = idle, 1 = picking parallel reference, 2 = picking skew reference // 0 idle, 1 waiting for a click on the through track
int m_pick_rel{0}; int m_pick_turnout{0};
// after a reference is chosen, ask for the numeric parameter // 0 idle, 1 waiting for the element the selected one is to be held parallel to
bool m_ask_rel_param{false}; int m_pick_parallel{0};
double m_ask_rel_value{4.5};
// compound: gap being edited; pick mode accumulates guide points, then Fit runs the solver
int m_sel_gap{-1};
bool m_pick_compound_point{false};
struct FitGuide
{
double x{0.0};
double y{0.0};
};
std::vector<FitGuide> m_fit_guides;
maj0sted::app::BasketDraft m_draft; // what the next appended element is made of
std::vector<maj0sted::editor::PlanPolyline> m_draft_polys; double m_new_radius{300.0};
// 0 idle, 1 pick straight end to continue from double m_new_length{100.0};
int m_draft_place{0}; int m_new_hand{1};
// next click appends: 0 = luk, 1 = KP (wej/między), 2 = KP wyjście (kończy: prosta + kotwica) // the radius a corner is rounded with when laying by clicking
int m_draft_append_kind{0}; double m_corner_radius{300.0};
double m_draft_r{300.0}; // miedzytorze a new hold is given, metres
double m_draft_finish_straight{100.0}; double m_new_offset{4.75};
// 0 idle, 1 click a straight to choose the gap to anchor into
int m_anchor_pick{0}; int m_turnout_type{0};
bool m_dragging_draft{false}; // which piece of the chosen type's geometry list is open for editing; -1 = none
int m_draft_kind{-1}; int m_sel_piece{-1};
int m_draft_arc{-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]{}; char m_namebuf[128]{};
std::string m_status; std::string m_status;
char m_path[256]{"editor/plan.m0s"}; char m_path[256]{"editor/plan.m0s"};
char m_scn_path[256]{"scenery/plan_export.scn"}; char m_scn_path[256]{"scenery/plan_export.scn"};

View File

@@ -17,6 +17,8 @@ http://mozilla.org/MPL/2.0/.
#include <windows.h> #include <windows.h>
#include <winhttp.h> #include <winhttp.h>
#pragma comment(lib, "winhttp.lib") #pragma comment(lib, "winhttp.lib")
#else
#include <curl/curl.h>
#endif #endif
namespace editor namespace editor
@@ -127,9 +129,64 @@ bool http_get(std::string const &Url, std::vector<std::uint8_t> &Bytes, std::str
#else #else
bool http_get(std::string const &, std::vector<std::uint8_t> &, std::string &) // 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)
{ {
return false; // no native transport on this platform yet 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 #endif

View File

@@ -10,27 +10,74 @@ http://mozilla.org/MPL/2.0/.
#include "stdafx.h" #include "stdafx.h"
#include "editor/turnouts.h" #include "editor/turnouts.h"
#include "maj0sted/domain/geometry/turnout.hpp"
#include <cmath>
namespace editor 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() std::vector<turnout_preset> const &turnout_presets()
{ {
// the standard PKP rozjazdy zwyczajne (49E1 / 60E1). crossing mark, radius and catalogue length // the standard PKP rozjazdy zwyczajne. u and d come from Koc, tablica 5.1 (iglice
// (PR→KR along the through track). the crossing angles check out as atan(1/n); the lengths follow // styczne do opornicy o scietym ostrzu); the rear tangents from the catalogue drawings:
// the type tables (metres). the diverging leaves not tangent but at the blade angle (beta) after a // rys. 5.13 gives Rz 49E1-190-1:9 as 27138 = 10523 + 16615 (krzyzownica prosta) and
// straight pre-blade lead, then arcs by (alfa - beta) to the frog. // 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
// blade_angle, pre_blade and blade_length are NOT yet from Id-1 - left at 0, which lays a tangent // 1200-1:18,5. TODO: 1:7,5 R190 i 1:14 R500 czekaja na wlasne u/d i styczne z Id-1
// arc straight from PR. Fill them per type when the catalogue figures are in; the geometry
// (domain::lay_turnout) already lays the pre-blade lead, the blade break and the shortened arc.
static std::vector<turnout_preset> const presets{ static std::vector<turnout_preset> const presets{
{"Rz 1:7,5 R190", 7.5, 190.0, 25.222, 0.0, 0.0, 0.0}, curved_tangent("Rz 1:7,5 R190", 7.5, 190.0, 0.0049, 0.1250),
{"Rz 1:9 R190", 9.0, 190.0, 27.138, 0.0, 0.0, 0.0}, curved_tangent("Rz 1:9 R190", 9.0, 190.0, 0.0049, 0.1250, 16.615),
{"Rz 1:9 R300", 9.0, 300.0, 33.230, 0.0, 0.0, 0.0}, curved_tangent("Rz 1:9 R300", 9.0, 300.0, 0.0044, 0.1250),
{"Rz 1:12 R500", 12.0, 500.0, 41.594, 0.0, 0.0, 0.0}, curved_tangent("Rz 1:12 R500", 12.0, 500.0, 0.0053, 0.1250),
{"Rz 1:14 R500", 14.0, 500.0, 42.371, 0.0, 0.0, 0.0}, curved_tangent("Rz 1:14 R500", 14.0, 500.0, 0.0053, 0.1250, 24.544),
{"Rz 1:14 R760", 14.0, 760.0, 54.216, 0.0, 0.0, 0.0}, curved_tangent("Rz 1:14 R760", 14.0, 760.0, 0.0050, 0.1250),
{"Rz 1:18,5 R1200", 18.5, 1200.0, 64.818, 0.0, 0.0, 0.0}}; curved_tangent("Rz 1:18,5 R1200", 18.5, 1200.0, 0.0050, 0.1304)};
return presets; return presets;
} }

View File

@@ -15,23 +15,33 @@ http://mozilla.org/MPL/2.0/.
namespace editor namespace editor
{ {
// a standard turnout template: its designation and the defining pair a catalogue entry is built on - // one piece of a preset's diverging path, in catalogue order from PR. mirrors
// the crossing mark 1:n (skos) and the radius of the diverging arc. the actual length the switch // maj0sted::editor::TurnoutPieceSpec so this header stays free of the library
// works out to is computed geometrically from these (arc = R * atan(1/n)), shown next to it; the struct turnout_preset_piece
// exact catalogue length can be pinned later if the Id-1 tables call for a different figure {
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 struct turnout_preset
{ {
std::string name; std::string name;
double crossing_n; // skos 1:n double crossing_n; // skos 1:n
double radius; // metres double length; // catalogue length: a + b, the two tangent legs of the drawing, metres
double length; // catalogue length PR->KR along the through track, metres double tip_thickness; // grubosc iglicy na koncu zestruganego dzioba (u), metres
double blade_angle; // kąt nagięcia iglicy (beta), radians; the diverging leaves the straight here double nose; // dziob iglicy: zestrugana dlugosc od ostrza (d), metres
double pre_blade; // odcinek przediglicowy: straight lead PR -> blade tip, metres double railtop_width; // szerokosc glowki szyny (w), metres
double blade_length; // the straight blade itself, tip -> heel where the arc starts, metres std::vector<turnout_preset_piece> pieces;
}; };
// typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard; // typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised
// confirm against Id-1 before treating the derived length as the catalogue length // standard; confirm the piece lengths and the blade against Id-1
std::vector<turnout_preset> const &turnout_presets(); std::vector<turnout_preset> const &turnout_presets();
} // namespace editor } // namespace editor

View File

@@ -2,14 +2,13 @@ cmake_minimum_required(VERSION 3.20)
project(maj0sted project(maj0sted
VERSION 0.1.0 VERSION 0.1.0
DESCRIPTION "Domain-Driven Design library for a railway track-layout editor" DESCRIPTION "Model and solver for a railway track-layout editor"
LANGUAGES CXX) LANGUAGES CXX)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Options # Options
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
option(MAJ0STED_BUILD_TESTS "Build unit tests" ON) option(MAJ0STED_BUILD_TESTS "Build unit tests" ON)
option(MAJ0STED_BUILD_EXAMPLES "Build usage examples" ON)
option(MAJ0STED_WARNINGS_AS_ERRORS "Treat warnings as errors" OFF) option(MAJ0STED_WARNINGS_AS_ERRORS "Treat warnings as errors" OFF)
# Only drive tests/examples/install defaults when built on its own, not when # Only drive tests/examples/install defaults when built on its own, not when
@@ -24,26 +23,17 @@ endif()
# The static library # The static library
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
add_library(maj0sted STATIC add_library(maj0sted STATIC
src/domain/map_project.cpp
src/domain/horizontal_alignment.cpp
src/domain/vertical_alignment.cpp
src/domain/niweleta.cpp
src/domain/parallelism_service.cpp
src/domain/fitting_service.cpp
src/domain/gap_fitter.cpp
src/domain/turnout.cpp src/domain/turnout.cpp
src/io/project_serializer.cpp src/editor/model.cpp
src/io/svg_writer.cpp src/editor/solution.cpp
src/io/scn_export.cpp src/editor/chain.cpp
src/render/plan_sampler.cpp src/editor/sketch.cpp
src/render/rail_renderer.cpp src/editor/layout.cpp
src/editor/solve.cpp src/editor/render.cpp
src/editor/editor.cpp
src/editor/ribbon.cpp
src/editor/tile_cache.cpp src/editor/tile_cache.cpp
src/app/editor_document.cpp src/render/rail_renderer.cpp
src/app/straight_constraints.cpp src/io/document_io.cpp
src/app/basket_draft.cpp src/io/scn_export.cpp
) )
add_library(maj0sted::maj0sted ALIAS maj0sted) add_library(maj0sted::maj0sted ALIAS maj0sted)
@@ -77,13 +67,6 @@ if(MAJ0STED_BUILD_TESTS AND MAJ0STED_STANDALONE)
add_subdirectory(tests) add_subdirectory(tests)
endif() endif()
# ----------------------------------------------------------------------------
# Examples
# ----------------------------------------------------------------------------
if(MAJ0STED_BUILD_EXAMPLES AND MAJ0STED_STANDALONE)
add_subdirectory(examples)
endif()
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Install / package # Install / package
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------

View File

@@ -1,5 +0,0 @@
add_executable(maj0sted_example basic_editor.cpp)
target_link_libraries(maj0sted_example PRIVATE maj0sted::maj0sted)
add_executable(maj0sted_plan_to_svg plan_to_svg.cpp)
target_link_libraries(maj0sted_plan_to_svg PRIVATE maj0sted::maj0sted)

View File

@@ -1,20 +0,0 @@
// Minimal walkthrough: create a map project and inspect its CRS.
#include <cstdio>
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
int main() {
std::printf("maj0sted %s\n\n", MAJ0STED_VERSION_STRING);
const MapProject project; // defaults to EPSG:2180
std::printf("Map project CRS: EPSG:%d\n", project.crs().epsg());
const CartesianPosition position{500000.0, 300000.0};
std::printf("Sample position: x=%.1f m, y=%.1f m\n", position.x(),
position.y());
return 0;
}

View File

@@ -1,58 +0,0 @@
// A simple visual check tool: build a niweleta, fit a compound curve with
// transition curves between the two straights, apply it, and write the plan to
// an SVG file you can open in a browser.
//
// Usage: plan_to_svg [output.svg] (default: plan.svg)
//
// The reusable pieces are render::sample() (geometry -> polylines) and
// io::to_svg(). A different GUI would call render::sample() and paint itself.
#include <cstdio>
#include <fstream>
#include <string>
#include "maj0sted/io/svg_writer.hpp"
#include "maj0sted/maj0sted.hpp"
#include "maj0sted/render/plan_sampler.hpp"
using namespace maj0sted::domain;
int main(int argc, char** argv) {
MapProject project;
// Two straights forming a 90-degree corner.
Niweleta niweleta{NiweletaId{1}, "Demo"};
const StraightId entry =
niweleta.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{300.0, 0.0});
const StraightId exit = niweleta.add_straight(
CartesianPosition{300.0, 0.0}, CartesianPosition{300.0, 300.0},
JointContinuity::AzimuthBreak);
// A compound curve R120 -> R80 with clothoids at the ends and between arcs.
const CompoundFitParameters params{
.entry = entry,
.exit = exit,
.entry_transition = Length::from_metres(30.0),
.exit_transition = Length::from_metres(30.0),
.arcs = {CompoundArc{Radius::from_metres(120.0), Length::from_metres(60.0),
Length::from_metres(25.0)},
CompoundArc{Radius::from_metres(80.0)}}};
const FitResult fit = FittingService::fit_compound(niweleta, params);
niweleta.apply_fit(fit);
project.add_niweleta(std::move(niweleta));
const maj0sted::render::Scene scene = maj0sted::render::sample(project);
const std::string svg = maj0sted::io::to_svg(scene);
const std::string path = (argc > 1) ? argv[1] : "plan.svg";
std::ofstream out(path);
if (!out) {
std::fprintf(stderr, "cannot write %s\n", path.c_str());
return 1;
}
out << svg;
std::printf("wrote %s (%zu polylines) — open it in a browser\n", path.c_str(),
scene.polylines.size());
return 0;
}

View File

@@ -1,58 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include "maj0sted/editor/editor.hpp"
#include "maj0sted/editor/ribbon.hpp"
#include "maj0sted/editor/solve.hpp"
namespace maj0sted::app {
struct BasketDraft {
bool active{false};
double x0{0.0};
double y0{0.0};
double az0{0.0};
int dir{1};
double entry_t{0.0};
double exit_t{0.0};
std::vector<maj0sted::editor::CompoundArcSpec> arcs;
double preview_next_r{300.0};
int attach_str{-1};
int attach_end{-1};
int reedit_gap{-1};
maj0sted::editor::GapFit reedit_backup{};
};
[[nodiscard]] maj0sted::editor::RibbonRequest draft_to_ribbon(const BasketDraft& draft);
[[nodiscard]] std::vector<maj0sted::editor::PlanPolyline> preview_basket_draft(
BasketDraft& draft, double fallback_next_r = 300.0);
void start_basket_draft_from_end(BasketDraft& draft,
const maj0sted::editor::StraightSpec& straight,
int straight_index, int end, double default_r = 300.0);
[[nodiscard]] bool reopen_basket_draft(BasketDraft& draft,
maj0sted::editor::NiweletaSpec& niweleta,
std::size_t gap,
const maj0sted::editor::NiweletaPolys* solved);
void discard_basket_draft(BasketDraft& draft, maj0sted::editor::NiweletaSpec* niweleta);
[[nodiscard]] bool anchor_basket_draft(BasketDraft& draft,
maj0sted::editor::NiweletaSpec& niweleta,
std::size_t gap);
struct FinishBasketDraftResult {
bool ok{false};
std::string status;
};
[[nodiscard]] FinishBasketDraftResult finish_basket_draft(
BasketDraft& draft, maj0sted::editor::NiweletaSpec& niweleta,
std::vector<maj0sted::editor::NiweletaSpec>& all_niwelety, int niweleta_index,
double finish_straight_m);
} // namespace maj0sted::app

View File

@@ -1,34 +0,0 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "maj0sted/domain/map_project.hpp"
#include "maj0sted/editor/editor.hpp"
namespace maj0sted::app {
struct EditorDocument {
std::vector<maj0sted::editor::NiweletaSpec> niwelety;
std::vector<maj0sted::editor::Junction> junctions;
double view_x{0.0};
double view_y{0.0};
double view_extent{0.0};
bool origin_set{false};
bool georeferenced{false};
double origin_x{0.0};
double origin_y{0.0};
};
[[nodiscard]] maj0sted::domain::MapProject to_map_project(const EditorDocument& document);
[[nodiscard]] EditorDocument from_map_project(const maj0sted::domain::MapProject& project);
[[nodiscard]] std::string serialize_document(const EditorDocument& document);
[[nodiscard]] EditorDocument deserialize_document(const std::string& text);
[[nodiscard]] std::string default_project_path();
bool save_project(const EditorDocument& document,
const std::string& path = default_project_path());
[[nodiscard]] std::optional<EditorDocument> load_project(
const std::string& path = default_project_path());
} // namespace maj0sted::app

View File

@@ -1,12 +0,0 @@
#pragma once
#include <vector>
#include "maj0sted/editor/editor.hpp"
namespace maj0sted::app {
void apply_straight_constraints(std::vector<maj0sted::editor::NiweletaSpec>& niwelety);
void align_gap_fits(maj0sted::editor::NiweletaSpec& niweleta);
} // namespace maj0sted::app

View File

@@ -1,55 +0,0 @@
#pragma once
#include <optional>
#include <vector>
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
#include "maj0sted/domain/value_objects/straight_id.hpp"
namespace maj0sted::domain {
/// Input parameters for fitting a curve between two straights of one niweleta.
///
/// These map directly onto a GUI form: the user picks the entry and exit
/// straights, types a radius, and optionally a (symmetric) transition-curve
/// length. The turn direction is NOT an input — it follows from the geometry of
/// the two straights (their deflection angle).
///
/// * transition_length absent -> a plain circular arc is fitted;
/// * transition_length present -> a symmetric klotoida-arc-klotoida is fitted,
/// with that length on both ends.
struct FitParameters {
StraightId entry{}; ///< pierwsza (wejściowa) prosta
StraightId exit{}; ///< druga (wyjściowa) prosta
Radius radius; ///< promień łuku kołowego
std::optional<Length> transition_length{}; ///< symetryczna klotoida; brak = sam łuk
};
/// One arc of a compound curve.
struct CompoundArc {
Radius radius; ///< promień łuku
std::optional<Length> length{}; ///< długość łuku; wymagana OPRÓCZ ostatniego
std::optional<Length> transition_to_next{}; ///< klotoida do następnego łuku (ignorowana dla ostatniego)
};
/// Input parameters for fitting a compound curve — a sequence of one or more
/// circular arcs (of different neighbouring radii, same direction) with optional
/// transition curves (klotoidy) between the arcs and at the ends — between two
/// straights. This single shape covers both a "łuk + klotoidy" (one arc eased by
/// symmetric clothoids) and a "łuk koszowy" (two or more arcs).
///
/// Both straight lines stay put (position and azimuth). Each arc except the last
/// carries its length, which fixes its central angle (`δ = L / R`); the last (or
/// only) arc takes whatever deflection remains after the other arcs and every
/// transition curve. The two straights are re-trimmed in length so the whole
/// chain closes.
struct CompoundFitParameters {
StraightId entry{}; ///< pierwsza (wejściowa) prosta
StraightId exit{}; ///< druga (wyjściowa) prosta
std::optional<Length> entry_transition{}; ///< klotoida prosta -> pierwszy łuk
std::optional<Length> exit_transition{}; ///< klotoida ostatni łuk -> prosta
std::vector<CompoundArc> arcs{}; ///< >= 1 łuk; sąsiednie promienie różne
};
} // namespace maj0sted::domain

View File

@@ -1,25 +0,0 @@
#pragma once
#include <vector>
#include "maj0sted/domain/geometry/plan_element.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
namespace maj0sted::domain {
/// Result of fitting a curve between two straights.
///
/// The two straights keep their identity but are trimmed back to the tangent
/// points; @c curve is the klotoida?/łuk/klotoida? to splice between them. The
/// caller (or an aggregate command) replaces the original entry/exit straights
/// with @c entry, then @c curve, then @c exit.
struct FitResult {
Straight entry; ///< trimmed entry straight (ends at tangent_in)
std::vector<PlanElement> curve; ///< klotoida? / łuk / klotoida?
Straight exit; ///< trimmed exit straight (starts at tangent_out)
CartesianPosition tangent_in; ///< entry -> curve tangent point
CartesianPosition tangent_out; ///< curve -> exit tangent point
TurnDirection direction; ///< derived turn direction
};
} // namespace maj0sted::domain

View File

@@ -1,69 +0,0 @@
#pragma once
#include <optional>
#include "maj0sted/domain/fitting/fit_parameters.hpp"
#include "maj0sted/domain/fitting/fit_result.hpp"
#include "maj0sted/domain/niweleta.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
namespace maj0sted::domain {
/// Domain Service: fits a curve between two straights of a single niweleta.
class FittingService {
public:
// --- Overloads that fit two standalone straights (no niweleta) ---
/// Rounds the corner between two straight lines with an arc (and optional
/// symmetric transition curves). Works for any deflection strictly between
/// 0° and 180°. For a 180° reversal use fit_uturn instead.
static FitResult fit_between_straights(
const Straight& entry, const Straight& exit, Radius radius,
std::optional<Length> transition = std::nullopt);
/// Fits a compound curve between two standalone straight lines.
static FitResult fit_compound(const Straight& entry, const Straight& exit,
const CompoundFitParameters& params);
/// Connects two (anti-)parallel straights with a 180° reversal ("nawrót").
/// The radius is derived from the perpendicular offset between the two lines
/// (a plain arc uses R = offset / 2). Optional symmetric transition curves
/// are solved for numerically.
/// @throws std::invalid_argument if the straights are not anti-parallel or
/// their offset is zero.
static FitResult fit_uturn(const Straight& entry, const Straight& exit,
std::optional<Length> transition = std::nullopt);
/// Fits an S-curve made of two tangent circular arcs with the same radius
/// and opposite turn directions. Supports non-parallel straights and
/// same-direction parallel straights with a non-zero lateral offset.
/// Tangent points are required to lie on both finite straight segments.
/// @throws std::invalid_argument if no equal-radius reverse curve fits.
static FitResult fit_reverse(const Straight& entry, const Straight& exit,
Radius radius);
/// Computes the curve (and the trimmed straights) that connects the two
/// straights named in @p params, both belonging to @p niweleta.
///
/// @throws std::invalid_argument if a straight is missing, the two are the
/// same, the straights are parallel/collinear, or the transition
/// curves are too long for the chosen radius and deflection.
static FitResult fit_between_straights(const Niweleta& niweleta,
const FitParameters& params);
/// Computes a compound curve ("łuk koszowy") — a chain of N arcs of
/// different neighbouring radii, same direction — between the two straights
/// named in @p params. Both straight lines stay fixed and are re-trimmed in
/// length; each arc's length sets its central angle, and the last arc takes
/// the remaining deflection.
///
/// @throws std::invalid_argument if a straight is missing, the two are the
/// same, there are no arcs, neighbouring radii are equal, a non-last
/// arc has no length, the straights are parallel/collinear, or the
/// split lengths exceed the total deflection.
static FitResult fit_compound(const Niweleta& niweleta,
const CompoundFitParameters& params);
};
} // namespace maj0sted::domain

View File

@@ -1,74 +0,0 @@
#pragma once
#include <optional>
#include <vector>
#include "maj0sted/domain/fitting/fit_result.hpp"
#include "maj0sted/domain/geometry/plan_element.hpp" // Straight
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
namespace maj0sted::domain {
/// How the gap between two independent, finite straights should be connected.
/// A pure domain concept: the editor's numeric fit modes map onto it, but this
/// enum carries no UI knowledge. A plain circular arc (Arc) and a compound curve
/// (Compound) are the only kinds — a "łuk + klotoidy" is just a Compound with a
/// single arc eased by symmetric clothoids.
enum class GapConnectionMode { Arc, Compound };
/// One arc of a compound connection.
struct CompoundGapArc {
double radius{0.0}; ///< arc radius (metres)
double length{0.0}; ///< fixed arc length (metres); ignored for the last arc
double transition_to_next{0.0}; ///< clothoid to the next arc (metres, 0 = none)
};
/// Extra parameters for a compound connection: entry/exit easing clothoids plus a
/// sequence of one or more arcs. A single arc yields "łuk + klotoidy"; two or
/// more yield a "łuk koszowy". The last arc absorbs the remaining deflection, so
/// its @c length / @c transition_to_next are ignored.
struct CompoundGapParameters {
double entry_transition{0.0};
double exit_transition{0.0};
std::vector<CompoundGapArc> arcs{};
};
/// A request to connect two finite straights with a curve.
struct GapConnectionRequest {
GapConnectionMode mode{GapConnectionMode::Arc};
double radius{0.0}; ///< plain-arc radius (metres)
CompoundGapParameters compound{}; ///< compound only
};
/// The connection actually produced.
struct GapConnection {
FitResult fit; ///< trimmed straights + spliced curve
double applied_radius{0.0}; ///< radius truly used (may be reduced to fit)
};
/// Domain policy connecting two independent, finite straights for the editor.
///
/// This is the single source of truth for the fitting business rules:
/// * a 180° reversal is detected from the geometry and handled as a nawrót,
/// never surfaced as a user-chosen mode;
/// * a plain arc uses the requested radius verbatim whenever it fits; the
/// radius is NEVER enlarged. A tighter-than-needed arc fits by extending the
/// straights toward the corner; only a radius too large to land on the
/// straights is reduced, to the largest one that fits. No other curve type is
/// ever substituted;
/// * a compound curve (one arc eased by clothoids, or a multi-arc basket) is
/// built as specified;
/// * a tangent point may extend a straight toward the corner, but must not cross
/// behind its outer, anchored end.
///
/// Returns nullopt when nothing admissible fits (the caller then leaves the gap
/// unconnected). Never throws.
class GapFitter {
public:
[[nodiscard]] static std::optional<GapConnection> connect(
const Straight& entry, const Straight& exit,
const GapConnectionRequest& request);
};
} // namespace maj0sted::domain

View File

@@ -1,60 +0,0 @@
#pragma once
#include <cstddef>
#include <utility>
#include <vector>
#include "maj0sted/domain/geometry/plan_element.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
namespace maj0sted::domain {
/// How two consecutive plan elements meet.
enum class JointContinuity {
/// Uniform azimuth ("styczność") — the rule for every joint.
Tangent,
/// An explicitly marked azimuth discontinuity ("załamanie"). Allowed only
/// between two straights that touch at different azimuths.
AzimuthBreak,
};
/// "Niweleta w planie": a directional (ordered) list of plan elements.
///
/// Straights are anchored in XY; the curves float and are positioned by later
/// fitting. The list enforces the connection rules between consecutive elements:
///
/// * as a rule consecutive elements meet under a uniform azimuth (Tangent);
/// * two straights may meet at different azimuths only if the joint is
/// explicitly marked as an AzimuthBreak;
/// * two circular arcs with the same radius and the same direction may not
/// be adjacent (it would be a single arc).
///
/// Joints that involve a floating curve cannot be fully checked yet and are
/// accepted pending the fitting step.
class HorizontalAlignment {
public:
/// Appends an element. The first element ignores @p joint; every later
/// element is validated against the current last element.
/// @throws std::logic_error if the joint violates a connection rule.
void append(PlanElement element, JointContinuity joint = JointContinuity::Tangent);
[[nodiscard]] const std::vector<PlanElement>& elements() const noexcept {
return elements_;
}
/// Joint between element @p index and @p index + 1 (index < size() - 1).
[[nodiscard]] JointContinuity joint(std::size_t index) const {
return joints_.at(index);
}
[[nodiscard]] bool empty() const noexcept { return elements_.empty(); }
[[nodiscard]] std::size_t size() const noexcept { return elements_.size(); }
/// Sum of the known element lengths; arcs without a length are skipped.
[[nodiscard]] Length total_length() const;
private:
std::vector<PlanElement> elements_;
std::vector<JointContinuity> joints_; // size() == elements_.size() - 1
};
} // namespace maj0sted::domain

View File

@@ -1,127 +0,0 @@
#pragma once
#include <cmath>
#include <optional>
#include <variant>
#include "maj0sted/domain/value_objects/azimuth.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
#include "maj0sted/domain/value_objects/straight_id.hpp"
namespace maj0sted::domain {
/// Direction of a horizontal turn: left ("w lewo") or right ("w prawo").
enum class TurnDirection { Left, Right };
/// Krzywa w planie: odcinek prosty.
///
/// A straight is *anchored* by both of its XY endpoints; its azimuth and length
/// are computed from them. During fitting a straight keeps its supporting line
/// (position + azimuth) fixed — only its length may change as an endpoint slides
/// along that line. The curves between straights are positioned as a result of
/// that fitting, not the other way round.
///
/// A straight also carries an optional identity (StraightId). References between
/// straight segments — e.g. parallelism — point at that id directly. Straights
/// used purely as geometry may stay anonymous (null id).
class Straight {
public:
/// Anonymous straight (null id).
/// @throws std::invalid_argument if @p start and @p end coincide (a
/// zero-length straight has no defined azimuth).
Straight(CartesianPosition start, CartesianPosition end)
: Straight{StraightId{}, start, end} {}
/// Identified straight.
/// @throws std::invalid_argument if @p start and @p end coincide.
Straight(StraightId id, CartesianPosition start, CartesianPosition end)
: id_{id},
start_{start},
end_{end},
azimuth_{Azimuth::of_direction(start, end)},
length_{Length::from_metres(distance(start, end))} {}
[[nodiscard]] StraightId id() const noexcept { return id_; }
[[nodiscard]] CartesianPosition start() const noexcept { return start_; }
[[nodiscard]] CartesianPosition end() const noexcept { return end_; }
[[nodiscard]] Azimuth azimuth() const noexcept { return azimuth_; }
[[nodiscard]] Length length() const noexcept { return length_; }
private:
static double distance(CartesianPosition a, CartesianPosition b) noexcept {
const double dx = b.x() - a.x();
const double dy = b.y() - a.y();
return std::sqrt(dx * dx + dy * dy);
}
StraightId id_;
CartesianPosition start_;
CartesianPosition end_;
Azimuth azimuth_;
Length length_;
};
/// Krzywa w planie: łuk kołowy o stałym promieniu.
///
/// A floating element: it is defined by its radius and turn direction. Its
/// length is not essential and is therefore optional — typically it becomes
/// known only as a resultant of fitting between the anchored straights.
class CircularArc {
public:
CircularArc(Radius radius, TurnDirection direction,
std::optional<Length> length = std::nullopt) noexcept
: radius_{radius}, direction_{direction}, length_{length} {}
[[nodiscard]] Radius radius() const noexcept { return radius_; }
[[nodiscard]] TurnDirection direction() const noexcept { return direction_; }
[[nodiscard]] std::optional<Length> length() const noexcept { return length_; }
private:
Radius radius_;
TurnDirection direction_;
std::optional<Length> length_;
};
/// Krzywa w planie: krzywa przejściowa (klotoida).
///
/// A floating element defined by its length, the radii at each end and its turn
/// direction. A missing radius (nullopt) denotes an infinite radius — a tangent
/// to a straight — so one type covers straight->arc, arc->arc and arc->straight.
class TransitionCurve {
public:
TransitionCurve(Length length, std::optional<Radius> start_radius,
std::optional<Radius> end_radius, TurnDirection direction) noexcept
: length_{length},
start_radius_{start_radius},
end_radius_{end_radius},
direction_{direction} {}
[[nodiscard]] Length length() const noexcept { return length_; }
[[nodiscard]] std::optional<Radius> start_radius() const noexcept { return start_radius_; }
[[nodiscard]] std::optional<Radius> end_radius() const noexcept { return end_radius_; }
[[nodiscard]] TurnDirection direction() const noexcept { return direction_; }
private:
Length length_;
std::optional<Radius> start_radius_;
std::optional<Radius> end_radius_;
TurnDirection direction_;
};
/// Any single element of the horizontal alignment.
using PlanElement = std::variant<Straight, CircularArc, TransitionCurve>;
/// Length of a plan element, if known. A circular arc may not carry one.
[[nodiscard]] inline std::optional<Length> length_of(const PlanElement& element) {
if (const auto* straight = std::get_if<Straight>(&element)) {
return straight->length();
}
if (const auto* transition = std::get_if<TransitionCurve>(&element)) {
return transition->length();
}
return std::get<CircularArc>(element).length();
}
} // namespace maj0sted::domain

View File

@@ -1,53 +0,0 @@
#pragma once
#include <variant>
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
namespace maj0sted::domain {
/// Sense of a vertical curve: a crest ("wypukły" — hilltop) or a sag
/// ("wklęsły" — valley).
enum class VerticalCurvature { Crest, Sag };
/// Krzywa w profilu: pochylenie o stałym nachyleniu, wyrażonym w promilach (‰).
/// Wartość dodatnia oznacza wznoszenie, ujemna — opadanie.
class Grade {
public:
Grade(Length length, double slope_permille) noexcept
: length_{length}, slope_permille_{slope_permille} {}
[[nodiscard]] Length length() const noexcept { return length_; }
[[nodiscard]] double slope_permille() const noexcept { return slope_permille_; }
private:
Length length_;
double slope_permille_;
};
/// Krzywa w profilu: łuk pionowy o stałym promieniu, łączący dwa pochylenia.
class VerticalCurve {
public:
VerticalCurve(Length length, Radius radius, VerticalCurvature curvature) noexcept
: length_{length}, radius_{radius}, curvature_{curvature} {}
[[nodiscard]] Length length() const noexcept { return length_; }
[[nodiscard]] Radius radius() const noexcept { return radius_; }
[[nodiscard]] VerticalCurvature curvature() const noexcept { return curvature_; }
private:
Length length_;
Radius radius_;
VerticalCurvature curvature_;
};
/// Any single element of the vertical alignment.
using ProfileElement = std::variant<Grade, VerticalCurve>;
/// Length of a profile element, whatever its concrete kind.
[[nodiscard]] inline Length length_of(const ProfileElement& element) {
return std::visit([](const auto& e) { return e.length(); }, element);
}
} // namespace maj0sted::domain

View File

@@ -9,6 +9,7 @@ namespace maj0sted::domain {
/// The crossing mark of a turnout — the skos 1:n. It is the tangent of the angle /// 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 /// 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. /// 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 { class CrossingMark {
public: public:
explicit CrossingMark(double denominator) noexcept : denominator_{denominator} {} explicit CrossingMark(double denominator) noexcept : denominator_{denominator} {}
@@ -24,75 +25,155 @@ private:
/// Which way the branch leaves the through track. /// Which way the branch leaves the through track.
enum class DivergeSide { Left, Right }; enum class DivergeSide { Left, Right };
/// One arc of a turnout's diverging curve: its radius and — unless it is the /// Which part of the turnout a piece of the diverging path is. The role names the
/// closing arc — its fixed length and the clothoid easing it into the next arc. /// piece in switch terms; it does not constrain its shape — a blade may be straight
struct TurnoutArc { /// (iglica prosta sieczna) or an arc (iglica łukowa styczna).
double radius{0.0}; enum class TurnoutPart {
double length{0.0}; ///< fixed length; ignored for the closing arc PreBlade, ///< odcinek przediglicowy: PR to the blade tip, along the through track
double transition_to_next{0.0}; ///< clothoid to the next arc (0 = none) 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
}; };
/// The shape of a turnout's diverging curve. One arc is the everyday turnout; two /// One authored piece of the diverging path, in order from PR. Every length is
/// or more make a łuk koszowy. Whatever the shape, the curve turns through exactly /// explicit — the catalogue says it, and nothing here is solved for. A piece is a
/// the crossing angle — the closing (last) arc spends whatever deflection the /// straight when both radii are zero, an arc when they are equal, a clothoid
/// earlier arcs and clothoids leave. /// otherwise; radii are unsigned, the hand comes from DivergeSide.
struct DivergingCurve { struct TurnoutPiece {
std::vector<TurnoutArc> arcs; ///< at least one; the last one closes the turn TurnoutPart part{TurnoutPart::Curve};
double entry_transition{0.0}; ///< clothoid from the through track into the first arc double length{0.0};
double exit_transition{0.0}; ///< clothoid from the last arc back toward tangent 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};
}; };
/// A turnout (rozjazd), described the way a catalogue does: a crossing mark, the /// Iglica — not the shape of the axis (that is a TurnoutPiece), but the two
/// side it opens to, the free diverging curve inside it, and the length it spans /// dimensions of the blade rail itself that place its real tip. A blade cannot be
/// from its start (początek rozjazdu) to its end (koniec rozjazdu) measured along /// ground to nothing, so its tip is cut back to a thickness and the point where
/// the through track. The curve turns to the crossing angle and a straight frog /// the axis leaves the stock rail is only a theoretical one.
/// rail carries the track on to that length. 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 { struct Turnout {
CrossingMark mark; CrossingMark mark;
DivergeSide side{DivergeSide::Left}; DivergeSide side{DivergeSide::Left};
DivergingCurve curve; std::vector<TurnoutPiece> path; ///< PR -> KR, in order; nothing implicit
double length{0.0}; ///< catalogue length along the through track; 0 = a tangent-length rail /// Catalogue length: a + b, the two tangent legs of the drawing — PR to the
/// Odcinek przediglicowy: the straight lead from the switch start to the blade /// theoretical crossing point along the through track, and that point to KR
/// tip, still along the through track. Zero for a curve tangent from the start. /// along the diverging one. This is the L the type tables give (Rz 190-1:9:
double pre_blade{0.0}; /// a 13,834 + b 13,304 = 27,138), not the projection on the through track.
/// Kąt nagięcia iglicy (beta), radians: the heading break at the blade tip. The double length{0.0};
/// diverging track leaves the through track here, not tangentially, and the Blade blade{};
/// curve then turns through only the remaining `alfa - beta`. Zero for a
/// tangential (curved-blade) turnout.
double blade_angle{0.0};
/// The straight blade itself, from its tip to its heel where the curve begins.
/// Zero starts the curve at the tip.
double blade_length{0.0};
}; };
/// One laid segment of the diverging path, as the constant/linear curvature the /// One laid segment of the diverging path: a piece with its curvature signed for
/// renderer and the fitter already share (see segment_layout): a straight when /// the hand it diverges to, ready for layout_segment.
/// both curvatures are zero, an arc when they are equal, a clothoid otherwise.
struct TurnoutSegment { struct TurnoutSegment {
double turn_in{0.0}; ///< signed heading break applied before laying this segment (the blade) double turn_in{0.0}; ///< signed heading break applied before laying this segment
double k0{0.0}; double k0{0.0};
double k1{0.0}; double k1{0.0};
double length{0.0}; double length{0.0};
TurnoutPart part{TurnoutPart::Curve};
}; };
/// What a turnout works out to when laid from its start pose. /// What a turnout works out to when laid from its start pose.
struct TurnoutGeometry { struct TurnoutGeometry {
bool valid{false}; bool valid{false};
std::vector<TurnoutSegment> path; ///< diverging segments from the switch start to the frog std::vector<TurnoutSegment> path; ///< diverging segments from the switch start to the frog
geometry::Pose frog{}; ///< the end (koniec rozjazdu): position + heading at the crossing angle 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_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 tangent_back{0.0}; ///< theoretical tangent length, crossing point to the frog
double diverging_length{0.0}; ///< total length of the diverging path (curve + frog rail) 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};
}; };
/// Lays a turnout forward from @p start — the switch start (początek rozjazdu), /// How far along an axis of radius @p radius, leaving the stock rail at angle
/// tangent to the through track: the pre-blade straight, the blade breaking away /// @p angle, the blade stands @p offset away from it: `R·(acos(cos β − offset/R1) − β)`
/// by `blade_angle`, the curve turning through what is left of the crossing angle, /// for a curved blade, `offset / sin β` for a straight one. The gap opens on the
/// then the frog rail out to the catalogue length. Never throws; returns /// blade's own curve `R1 = R + s/2`, but the answer is a station on the axis, which
/// `valid == false` when the parameters admit no geometry: a non-positive radius, /// is what every length here is measured in.
/// a blade angle that is negative or already reaches the crossing angle, or a ///
/// curve whose fixed parts overshoot the remaining angle before the closing arc. /// This is the relation the catalogue's own numbers obey. A drawing dimensions the
[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, /// blade on the rail rather than on the axis, so what Koc's Tablica 5.1 gives as
const Turnout& turnout); /// `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 } // namespace maj0sted::domain

View File

@@ -1,31 +0,0 @@
#pragma once
#include <cstddef>
#include <utility>
#include <vector>
#include "maj0sted/domain/geometry/profile_element.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
namespace maj0sted::domain {
/// Geometria w profilu (rzut pionowy toru): uporządkowany ciąg pochyleń i łuków
/// pionowych, gdzie każdy element zaczyna się tam, gdzie kończy się poprzedni.
class VerticalAlignment {
public:
void append(ProfileElement element) { elements_.push_back(std::move(element)); }
[[nodiscard]] const std::vector<ProfileElement>& elements() const noexcept {
return elements_;
}
[[nodiscard]] bool empty() const noexcept { return elements_.empty(); }
[[nodiscard]] std::size_t size() const noexcept { return elements_.size(); }
/// Sumaryczna długość toru w profilu.
[[nodiscard]] Length total_length() const;
private:
std::vector<ProfileElement> elements_;
};
} // namespace maj0sted::domain

View File

@@ -1,32 +0,0 @@
#pragma once
#include <vector>
#include "maj0sted/domain/niweleta.hpp"
#include "maj0sted/domain/value_objects/crs.hpp"
namespace maj0sted::domain {
/// The map project ("projekt mapy").
///
/// It defines the Cartesian coordinate reference system in which every position
/// in the project is expressed (by default EPSG:2180, metres) and owns the
/// niwelety that make up the layout.
class MapProject {
public:
/// Creates a project using @p crs, or EPSG:2180 by default.
explicit MapProject(Crs crs = Crs::default_crs());
[[nodiscard]] Crs crs() const noexcept { return crs_; }
void add_niweleta(Niweleta niweleta);
[[nodiscard]] const std::vector<Niweleta>& niwelety() const noexcept {
return niwelety_;
}
private:
Crs crs_;
std::vector<Niweleta> niwelety_;
};
} // namespace maj0sted::domain

View File

@@ -1,70 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include "maj0sted/domain/fitting/fit_result.hpp"
#include "maj0sted/domain/geometry/horizontal_alignment.hpp"
#include "maj0sted/domain/geometry/vertical_alignment.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/niweleta_id.hpp"
#include "maj0sted/domain/value_objects/straight_id.hpp"
namespace maj0sted::domain {
/// Niweleta — jednolity odcinek toru złożony z wielu krzywych w planie i w
/// profilu.
///
/// Aggregate Root: to granica spójności całego odcinka. Geometria w planie i w
/// profilu opisują ten sam fizyczny tor, więc ich sumaryczne długości muszą być
/// zgodne — pilnuje tego lengths_consistent(). Krzywe dokłada się wyłącznie
/// przez metody agregatu, który utrzymuje ich kolejność.
class Niweleta {
public:
explicit Niweleta(NiweletaId id, std::string name = {});
[[nodiscard]] NiweletaId id() const noexcept { return id_; }
[[nodiscard]] const std::string& name() const noexcept { return name_; }
/// Dodaje prostą na końcu planu, nadając jej automatycznie StraightId,
/// który zwraca (do późniejszych referencji: równoległość, fitowanie).
StraightId add_straight(CartesianPosition start, CartesianPosition end,
JointContinuity joint = JointContinuity::Tangent);
/// Dokłada krzywą na końcu geometrii w planie, łącząc ją z poprzednim
/// elementem zadanym stykiem (domyślnie stycznym). Jeśli elementem jest
/// prosta bez tożsamości, niweleta nadaje jej StraightId.
void add_plan_element(PlanElement element,
JointContinuity joint = JointContinuity::Tangent);
/// Wpina wynik fitowania w plan: podmienia dwie sąsiednie proste (entry,
/// exit z @p result) na entry(przyciętą) → curve... → exit(przyciętą),
/// stykami stycznymi (Tangent). Proste zachowują swoje StraightId.
/// @throws std::invalid_argument jeśli proste nie zostaną znalezione lub nie
/// są sąsiednie (entry bezpośrednio przed exit).
void apply_fit(const FitResult& result);
/// Dokłada krzywą na końcu geometrii w profilu.
void add_profile_element(ProfileElement element);
[[nodiscard]] const HorizontalAlignment& plan() const noexcept { return plan_; }
[[nodiscard]] const VerticalAlignment& profile() const noexcept { return profile_; }
[[nodiscard]] Length plan_length() const { return plan_.total_length(); }
[[nodiscard]] Length profile_length() const { return profile_.total_length(); }
/// Czy plan i profil opisują tor tej samej długości (z zadaną tolerancją)?
[[nodiscard]] bool lengths_consistent(double tolerance_m = 1e-6) const;
private:
StraightId next_straight_id() noexcept;
NiweletaId id_;
std::string name_;
HorizontalAlignment plan_;
VerticalAlignment profile_;
std::uint64_t straight_seq_{0};
};
} // namespace maj0sted::domain

View File

@@ -1,39 +0,0 @@
#pragma once
#include "maj0sted/domain/geometry/plan_element.hpp"
#include "maj0sted/domain/parallelism/track_parallelism.hpp"
#include "maj0sted/domain/value_objects/track_offset.hpp"
namespace maj0sted::domain {
/// Domain Service: creates and reasons about parallelism between straight
/// segments.
class ParallelismService {
public:
/// Marks @p origin as the origin of a parallel relation with @p parallel,
/// separated by @p offset. Both straights must be identified.
/// @throws std::invalid_argument if a straight has no id or the two are the
/// same straight.
static TrackParallelism mark_parallel(const Straight& origin,
const Straight& parallel,
TrackOffset offset);
/// Are two straights parallel as lines (same or opposite azimuth)?
static bool are_parallel(const Straight& a, const Straight& b,
double tolerance_rad = 1e-9);
/// The line the parallel straight lies on: @p origin shifted sideways by
/// @p offset, perpendicular to its direction of travel. Same length and
/// azimuth as the origin; the returned straight is anonymous.
static Straight derive_parallel_line(const Straight& origin, TrackOffset offset);
/// Perpendicular distance (>= 0) from @p other's start point to the
/// (infinite) line carrying @p origin.
static double perpendicular_distance(const Straight& origin, const Straight& other);
[[nodiscard]] static Straight project_onto_offset_line(const Straight& origin,
double offset_m,
const Straight& segment);
};
} // namespace maj0sted::domain

View File

@@ -1,38 +0,0 @@
#pragma once
#include <stdexcept>
#include "maj0sted/domain/value_objects/straight_id.hpp"
#include "maj0sted/domain/value_objects/track_offset.hpp"
namespace maj0sted::domain {
/// A reference-based relation between two straight segments, identified by their
/// StraightId. @c origin is the authoritative reference track; @c parallel runs
/// alongside it at @c offset (a perpendicular distance on a given side).
///
/// Invariants: both straights must be identified (non-null id), and a straight
/// cannot be parallel to itself.
class TrackParallelism {
public:
TrackParallelism(StraightId origin, StraightId parallel, TrackOffset offset)
: origin_{origin}, parallel_{parallel}, offset_{offset} {
if (origin.is_null() || parallel.is_null()) {
throw std::invalid_argument{"Parallelism requires identified straights"};
}
if (origin == parallel) {
throw std::invalid_argument{"A straight cannot be parallel to itself"};
}
}
[[nodiscard]] StraightId origin() const noexcept { return origin_; }
[[nodiscard]] StraightId parallel() const noexcept { return parallel_; }
[[nodiscard]] TrackOffset offset() const noexcept { return offset_; }
private:
StraightId origin_;
StraightId parallel_;
TrackOffset offset_;
};
} // namespace maj0sted::domain

View File

@@ -1,67 +0,0 @@
#pragma once
#include <cmath>
#include <compare>
#include <numbers>
#include <stdexcept>
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
namespace maj0sted::domain {
/// Value Object: a horizontal azimuth — the direction of travel in the plan,
/// measured clockwise from north (the +y / northing axis) and normalised to
/// [0, 2*pi) radians.
class Azimuth {
public:
static Azimuth from_radians(double radians) noexcept {
return Azimuth{normalise(radians)};
}
static Azimuth from_degrees(double degrees) noexcept {
return from_radians(degrees * std::numbers::pi / 180.0);
}
/// Azimuth of the direction from @p from to @p to.
/// @throws std::invalid_argument if the two points coincide.
static Azimuth of_direction(CartesianPosition from, CartesianPosition to) {
const double d_east = to.x() - from.x();
const double d_north = to.y() - from.y();
if (d_east == 0.0 && d_north == 0.0) {
throw std::invalid_argument{"Azimuth is undefined for coincident points"};
}
return from_radians(std::atan2(d_east, d_north));
}
[[nodiscard]] double radians() const noexcept { return radians_; }
[[nodiscard]] double degrees() const noexcept {
return radians_ * 180.0 / std::numbers::pi;
}
/// Smallest absolute angular difference to @p other, in [0, pi].
[[nodiscard]] double angular_distance(Azimuth other) const noexcept {
double difference = std::fabs(radians_ - other.radians_);
if (difference > std::numbers::pi) {
difference = 2.0 * std::numbers::pi - difference;
}
return difference;
}
friend auto operator<=>(const Azimuth&, const Azimuth&) noexcept = default;
private:
explicit Azimuth(double radians) noexcept : radians_{radians} {}
static double normalise(double radians) noexcept {
const double two_pi = 2.0 * std::numbers::pi;
double result = std::fmod(radians, two_pi);
if (result < 0.0) {
result += two_pi;
}
return result;
}
double radians_;
};
} // namespace maj0sted::domain

View File

@@ -1,27 +0,0 @@
#pragma once
#include <compare>
namespace maj0sted::domain {
/// Value Object: a position in a Cartesian coordinate reference system.
///
/// Coordinates are expressed in the units of the project's CRS. For the default
/// EPSG:2180 these are metres, with @c x as easting and @c y as northing.
class CartesianPosition {
public:
constexpr CartesianPosition() noexcept = default;
constexpr CartesianPosition(double x, double y) noexcept : x_{x}, y_{y} {}
[[nodiscard]] constexpr double x() const noexcept { return x_; }
[[nodiscard]] constexpr double y() const noexcept { return y_; }
friend constexpr bool operator==(const CartesianPosition&,
const CartesianPosition&) noexcept = default;
private:
double x_{0.0};
double y_{0.0};
};
} // namespace maj0sted::domain

View File

@@ -1,29 +0,0 @@
#pragma once
#include <compare>
namespace maj0sted::domain {
/// Value Object: a Coordinate Reference System, identified by its EPSG code.
///
/// The default for a map project is EPSG:2180 (PUWG 1992 / Poland CS92), a
/// Cartesian, metre-based projected system.
class Crs {
public:
// EPSG code of the default map-project CRS.
static constexpr int kDefaultEpsg = 2180;
explicit constexpr Crs(int epsg) noexcept : epsg_{epsg} {}
/// The default CRS: EPSG:2180.
static constexpr Crs default_crs() noexcept { return Crs{kDefaultEpsg}; }
[[nodiscard]] constexpr int epsg() const noexcept { return epsg_; }
friend constexpr auto operator<=>(const Crs&, const Crs&) noexcept = default;
private:
int epsg_;
};
} // namespace maj0sted::domain

View File

@@ -1,37 +0,0 @@
#pragma once
#include <compare>
#include <stdexcept>
namespace maj0sted::domain {
/// Value Object: a non-negative length, in metres (the unit of the default
/// EPSG:2180 CRS). Lengths add and compare by value.
class Length {
public:
constexpr Length() noexcept = default;
static constexpr Length from_metres(double metres) {
if (metres < 0.0) {
throw std::invalid_argument{"Length cannot be negative"};
}
return Length{metres};
}
static constexpr Length zero() noexcept { return Length{}; }
[[nodiscard]] constexpr double metres() const noexcept { return metres_; }
[[nodiscard]] constexpr Length operator+(Length other) const noexcept {
return Length{metres_ + other.metres_};
}
friend constexpr auto operator<=>(const Length&, const Length&) noexcept = default;
private:
explicit constexpr Length(double metres) noexcept : metres_{metres} {}
double metres_{0.0};
};
} // namespace maj0sted::domain

View File

@@ -1,36 +0,0 @@
#pragma once
#include <compare>
#include <cstddef>
#include <cstdint>
#include <functional>
namespace maj0sted::domain {
/// Value Object: identity of a Niweleta aggregate. Cross-aggregate references
/// (e.g. parallelism) point at niwelety by this id, never by pointer. Value 0
/// denotes "no identity".
class NiweletaId {
public:
using value_type = std::uint64_t;
constexpr NiweletaId() noexcept = default;
explicit constexpr NiweletaId(value_type value) noexcept : value_{value} {}
[[nodiscard]] constexpr value_type value() const noexcept { return value_; }
[[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; }
friend constexpr auto operator<=>(const NiweletaId&, const NiweletaId&) noexcept = default;
private:
value_type value_{0};
};
} // namespace maj0sted::domain
template <>
struct std::hash<maj0sted::domain::NiweletaId> {
std::size_t operator()(const maj0sted::domain::NiweletaId& id) const noexcept {
return std::hash<std::uint64_t>{}(id.value());
}
};

View File

@@ -1,30 +0,0 @@
#pragma once
#include <compare>
#include <stdexcept>
namespace maj0sted::domain {
/// Value Object: a radius of curvature, in metres. Always strictly positive —
/// the turn direction (plan) or curve sense (profile) is carried separately by
/// the geometric element, so the radius itself is just a magnitude.
class Radius {
public:
static constexpr Radius from_metres(double metres) {
if (!(metres > 0.0)) {
throw std::invalid_argument{"Radius must be positive"};
}
return Radius{metres};
}
[[nodiscard]] constexpr double metres() const noexcept { return metres_; }
friend constexpr auto operator<=>(const Radius&, const Radius&) noexcept = default;
private:
explicit constexpr Radius(double metres) noexcept : metres_{metres} {}
double metres_;
};
} // namespace maj0sted::domain

View File

@@ -1,36 +0,0 @@
#pragma once
#include <compare>
#include <cstddef>
#include <cstdint>
#include <functional>
namespace maj0sted::domain {
/// Value Object: identity of a straight segment. A straight that takes part in
/// references (e.g. parallelism) carries a non-null id; anonymous straights used
/// purely as geometry may leave it null. Value 0 denotes "no identity".
class StraightId {
public:
using value_type = std::uint64_t;
constexpr StraightId() noexcept = default;
explicit constexpr StraightId(value_type value) noexcept : value_{value} {}
[[nodiscard]] constexpr value_type value() const noexcept { return value_; }
[[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; }
friend constexpr auto operator<=>(const StraightId&, const StraightId&) noexcept = default;
private:
value_type value_{0};
};
} // namespace maj0sted::domain
template <>
struct std::hash<maj0sted::domain::StraightId> {
std::size_t operator()(const maj0sted::domain::StraightId& id) const noexcept {
return std::hash<std::uint64_t>{}(id.value());
}
};

View File

@@ -1,31 +0,0 @@
#pragma once
#include <stdexcept>
#include "maj0sted/domain/value_objects/length.hpp"
namespace maj0sted::domain {
/// Which side a parallel track runs on, relative to the origin straight's
/// direction of travel: left ("po lewej") or right ("po prawej").
enum class Side { Left, Right };
/// Value Object: the sideways offset of a parallel track from its origin — a
/// strictly positive perpendicular distance plus the side it lies on.
class TrackOffset {
public:
TrackOffset(Length distance, Side side) : distance_{distance}, side_{side} {
if (!(distance.metres() > 0.0)) {
throw std::invalid_argument{"Track offset distance must be positive"};
}
}
[[nodiscard]] Length distance() const noexcept { return distance_; }
[[nodiscard]] Side side() const noexcept { return side_; }
private:
Length distance_;
Side side_;
};
} // 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

@@ -1,139 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline
namespace maj0sted::editor {
/// An anchored straight segment (two XY endpoints).
struct StraightSpec {
double x1{0.0}, y1{0.0}, x2{0.0}, y2{0.0};
/// Internal tangent guide used to solve a terminal curve. It participates
/// in fitting but is not rendered as track or exposed as an editor handle.
bool hidden{false};
/// Live constraint the host reapplies before each solve (same model as the
/// web editor's `par` / `skew`). 0 = none, 1 = parallel offset from another
/// straight, 2 = skew 1:n from another straight.
int rel_kind{0};
int rel_niw{-1}; ///< index of the reference niweleta
int rel_str{-1}; ///< index of the reference straight
double rel_offset{0.0}; ///< parallel: signed metres (+ = left of travel)
double rel_cot{0.0}; ///< skew: n of a 1:n turnout
int rel_side{1}; ///< skew: +1 left / -1 right of the reference
double rel_length{0.0}; ///< skew: length of the dependent arm
};
/// One arc of a compound fit: its radius, its fixed length (ignored for the last
/// arc, which absorbs the remaining deflection) and an optional clothoid to the
/// next arc.
struct CompoundArcSpec {
double radius{0.0};
double length{0.0};
double transition_to_next{0.0};
};
/// A fit in the gap between straight @c gap and straight @c gap + 1.
struct GapFit {
int gap{0};
int mode{0}; ///< 0 none, 1 arc, 2 legacy arc+clothoids, 3 compound, 4 U-turn
double radius{0.0}; ///< plain arc (mode 1)
double transition{0.0}; ///< legacy mode-2 symmetric clothoid
// Unified compound (mode 3): end clothoids plus one or more arcs.
double entry_t{0.0}, exit_t{0.0};
std::vector<CompoundArcSpec> arcs{};
// Legacy fixed two-arc fields, kept so old saved projects still deserialize;
// superseded by @c arcs. Used only when @c arcs is empty.
double r1{0.0}, arc1_len{0.0}, between{0.0}, r2{0.0};
};
/// A niweleta as edited in the GUI: independent anchored straights plus fits
/// connecting consecutive straights. Because the straights are independent, a
/// gap can be a normal corner (straights meet at an intersection) OR a 180°
/// reversal (parallel, offset straights) — no 180° limitation.
struct NiweletaSpec {
std::string name;
std::vector<StraightSpec> straights;
std::vector<GapFit> fits;
};
struct NiweletaPolys {
std::vector<PlanPolyline> polylines;
/// Centreline segments after adjacent fits trim the input straights. Kept
/// index-aligned with NiweletaSpec::straights for editor hit handles.
std::vector<StraightSpec> rendered_straights;
/// The authoritative fit set after solving: exactly the requested fits that
/// actually succeeded. A requested fit that could not be produced (bad
/// parameters or geometry) is simply absent here — the library, not the host,
/// decides that a failed fit becomes "no fit". The host mirrors this list.
std::vector<GapFit> applied_fits;
/// The solved axis as one polyline, in order, with no duplicate join points.
/// Lets a station along the niweleta be resolved to a pose (see pose_at).
std::vector<PlanPoint> centreline;
};
/// Builds each niweleta, fits every requested gap, and returns the sampled plan
/// per niweleta. Never throws: gaps whose parameters don't admit a fit are left
/// unconnected (the two straights simply stay as drawn).
[[nodiscard]] std::vector<NiweletaPolys> solve_project(
const std::vector<NiweletaSpec>& niwelety);
/// A switch (rozjazd): the point where a branch leaves a through niweleta. It is
/// a relation, not a niweleta — the through track is not split. The switch is
/// described the surveyor's way: a crossing mark 1:n (skos) that fixes the angle
/// the branch leaves at, and an interior curve that is free to be anything — a
/// single arc, or a compound basket (łuk koszowy). @c station marks the switch's
/// theoretical point (środek rozjazdu, where the through and diverging tangent
/// lines cross); the curve is fitted around it and the branch niweleta is pinned
/// to the frog it ends on. The theoretical tangent lengths follow from the curve
/// (see JunctionGeom) — they are read off, not set.
struct Junction {
int through{-1}; ///< index of the through niweleta (not modified)
double station{0.0}; ///< theoretical point: arc length along the through centreline
int side{0}; ///< 0 = diverges left, 1 = diverges right
bool facing{true}; ///< true opens toward increasing station, false back
double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n)
double length{0.0}; ///< catalogue length (PR→KR along the through track); 0 = just the curve
double pre_blade{0.0}; ///< odcinek przediglicowy: straight lead PR → blade tip, metres
double blade_angle{0.0}; ///< kąt nagięcia iglicy (beta), radians; 0 = curve tangent from PR
double blade_length{0.0};///< the straight blade, tip → heel where the curve starts, metres
GapFit curve; ///< the internal diverging curve: arc / compound / basket
int branch{-1}; ///< index of the branch niweleta, pinned to the frog
};
/// The solved geometry of one switch: the switch point where the branch leaves
/// the through track, the frog it ends on, the diverging curve between them and
/// the theoretical tangent lengths that curve works out to.
struct JunctionGeom {
bool valid{false};
double px{0.0}, py{0.0}; ///< switch point (diverging leaves the through here)
double fx{0.0}, fy{0.0}; ///< frog: where the branch niweleta is pinned
double fhx{0.0}, fhy{0.0}; ///< unit heading the branch leaves the frog on
double tangent_front{0.0}; ///< theoretical tangent length before the curve
double tangent_back{0.0}; ///< theoretical tangent length after the curve
double length{0.0}; ///< diverging curve length (switch point to frog), metres
/// The diverging curve, as rails ready to draw.
std::vector<PlanPolyline> polylines;
};
/// A whole layout solved at once: the niwelety plus every switch. Returned by
/// solve_layout, which additionally pins each branch's start to its frog.
struct LayoutSolution {
std::vector<NiweletaPolys> niwelety;
std::vector<JunctionGeom> junctions; ///< index-aligned with the input switches
};
/// Solves @p niwelety together with @p junctions: each branch niweleta's start is
/// pinned to its switch's frog before solving, and every switch's diverging arc
/// is computed from the through niweleta's centreline. Never throws; a switch
/// whose through/branch is out of range or whose geometry is degenerate comes
/// back with valid == false. Takes @p niwelety by value because it repins them.
[[nodiscard]] LayoutSolution solve_layout(std::vector<NiweletaSpec> niwelety,
const std::vector<Junction>& junctions);
} // 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,217 @@
#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;
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

@@ -1,32 +0,0 @@
#pragma once
#include <vector>
#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline
namespace maj0sted::editor {
/// One element of an alignment "ribbon" (element chain), defined intrinsically.
struct RibbonElement {
int kind{0}; ///< 0 = line, 1 = arc, 2 = spiral (clothoid)
double length{0.0}; ///< line / spiral length (arc length is derived)
double radius{0.0}; ///< arc radius; spiral end radius (0 = straight / infinite)
double angle{0.0}; ///< arc central angle (radians)
double radius_start{0.0}; ///< spiral start radius (0 = infinite)
int dir{1}; ///< +1 left, -1 right (arc / spiral)
};
/// A whole alignment defined as a start pose plus an ordered element chain,
/// laid out head-to-tail. Handles any arc angle (including 180°+, loops) because
/// nothing depends on tangent intersection.
struct RibbonRequest {
double x0{0.0};
double y0{0.0};
double az0{0.0}; ///< start azimuth (radians, clockwise from north)
std::vector<RibbonElement> elements;
};
/// Lays the ribbon out and returns one polyline per element, ready to draw.
[[nodiscard]] std::vector<PlanPolyline> solve_ribbon(const RibbonRequest& request);
} // namespace maj0sted::editor

View File

@@ -0,0 +1,118 @@
#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
};
/// 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

@@ -1,55 +0,0 @@
#pragma once
#include <vector>
namespace maj0sted::editor {
/// A request from the GUI: three draggable vertices (entry straight A->B, exit
/// straight B->C) plus the fit parameters. Plain doubles, a flat DTO across the
/// boundary between the editor UI and this solver.
struct SolveRequest {
double ax{0.0}, ay{0.0}; ///< vertex A (entry start)
double bx{0.0}, by{0.0}; ///< vertex B (corner: entry end = exit start)
double cx{0.0}, cy{0.0}; ///< vertex C (exit end)
int mode{0}; ///< 0 = arc, 1 = arc + symmetric transitions, 2 = compound (łuk koszowy)
double radius{0.0}; ///< modes 0/1: arc radius
double transition{0.0}; ///< mode 1: symmetric transition-curve length
// Mode 2 (compound):
double r1{0.0}; ///< first arc radius
double arc1_len{0.0}; ///< first arc length (split place)
double between{0.0}; ///< transition length between the two arcs
double r2{0.0}; ///< second arc radius
double entry_t{0.0}; ///< entry transition length
double exit_t{0.0}; ///< exit transition length
};
/// A point in the CRS.
struct PlanPoint {
double x{0.0};
double y{0.0};
};
/// One polyline; @c kind is render::ElementKind (0 straight, 1 arc, 2 transition).
struct PlanPolyline {
int kind{0};
/// Logical owner used by map hit-testing. Exactly one is non-negative.
int straight_index{-1};
int gap{-1};
/// Position inside a fitted curve chain (arc/clothoid/arc...).
int element_index{-1};
double length{0.0};
/// Zero denotes an infinite radius (straight end of a transition).
double radius_start{0.0};
double radius_end{0.0};
std::vector<PlanPoint> points;
};
/// Builds the niweleta, runs the requested fit (falling back to the bare
/// straights if the parameters do not admit a fit) and returns the sampled plan
/// as polylines ready to draw.
[[nodiscard]] std::vector<PlanPolyline> solve_scene(const SolveRequest& request);
} // 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

@@ -1,19 +0,0 @@
#pragma once
#include <string>
#include "maj0sted/domain/map_project.hpp"
namespace maj0sted::io {
/// Serializes a whole map project (CRS + niwelety, each with its plan and
/// profile) to a self-contained textual format. The format is line-oriented and
/// dependency-free; @c deserialize round-trips it exactly.
[[nodiscard]] std::string serialize(const maj0sted::domain::MapProject& project);
/// Rebuilds a map project from text produced by @c serialize.
/// @throws std::runtime_error on malformed input; std::invalid_argument if the
/// reconstructed geometry violates a domain invariant.
[[nodiscard]] maj0sted::domain::MapProject deserialize(const std::string& text);
} // namespace maj0sted::io

View File

@@ -2,9 +2,8 @@
#include <ostream> #include <ostream>
#include <string> #include <string>
#include <vector>
#include "maj0sted/editor/editor.hpp" #include "maj0sted/editor/solution.hpp"
namespace maj0sted::io { namespace maj0sted::io {
@@ -22,12 +21,18 @@ struct ScnExportResult {
double origin_north{0.0}; double origin_north{0.0};
}; };
[[nodiscard]] ScnExportResult export_scn( /// Writes the solved layout as MaSzyna scenery. The axis comes straight out of
const std::vector<maj0sted::editor::NiweletaPolys>& solved, /// the solution: the exporter used to average the two drawn rails back together
const ScnExportOptions& options, std::ostream& out); /// to recover it, which put the whole geometry through the renderer and back.
///
/// 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::Solution& solution,
const ScnExportOptions& options,
std::ostream& out);
[[nodiscard]] ScnExportOptions resolve_scn_origin( [[nodiscard]] ScnExportOptions resolve_scn_origin(const editor::Solution& solution,
const std::vector<maj0sted::editor::NiweletaPolys>& solved, ScnExportOptions options);
ScnExportOptions options);
} // namespace maj0sted::io } // namespace maj0sted::io

View File

@@ -1,20 +0,0 @@
#pragma once
#include <string>
#include "maj0sted/render/scene.hpp"
namespace maj0sted::io {
struct SvgOptions {
double width_px = 900.0; ///< target drawing width
double margin_px = 24.0; ///< padding around the drawing
double stroke_px = 2.0; ///< line width
};
/// Renders a scene to a standalone SVG document (north up). Straights, arcs and
/// transition curves are drawn in distinct colours. Dependency-free — just a
/// simple viewer; a real GUI would consume render::Scene directly.
[[nodiscard]] std::string to_svg(const render::Scene& scene, const SvgOptions& options = {});
} // namespace maj0sted::io

View File

@@ -1,39 +1,27 @@
#pragma once #pragma once
// maj0sted — a Domain-Driven Design library for a railway track-layout editor // maj0sted — the model behind a railway track-layout editor ("edytor
// ("edytor torowiska"). Include this single header to pull in the whole public // torowiska"). One header pulls in the whole public surface.
// domain model. //
// 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" #include "maj0sted/version.hpp"
// Value Objects // The authored model, and what solving it produces.
#include "maj0sted/domain/value_objects/azimuth.hpp" #include "maj0sted/editor/model.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp" #include "maj0sted/editor/solution.hpp"
#include "maj0sted/domain/value_objects/crs.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/niweleta_id.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
#include "maj0sted/domain/value_objects/straight_id.hpp"
#include "maj0sted/domain/value_objects/track_offset.hpp"
// Geometry — krzywe w planie i w profilu // Solving: one track's chain, and a whole document in dependency order.
#include "maj0sted/domain/geometry/horizontal_alignment.hpp" #include "maj0sted/editor/chain.hpp"
#include "maj0sted/domain/geometry/plan_element.hpp" #include "maj0sted/editor/layout.hpp"
#include "maj0sted/domain/geometry/profile_element.hpp"
#include "maj0sted/domain/geometry/vertical_alignment.hpp"
// Aggregates // Geometry primitives shared by the solver and the renderer.
#include "maj0sted/domain/map_project.hpp" #include "maj0sted/domain/geometry/segment_layout.hpp"
#include "maj0sted/domain/niweleta.hpp" #include "maj0sted/domain/geometry/turnout.hpp"
// Parallelism — równoległość torów // Presentation and IO.
#include "maj0sted/domain/parallelism/parallelism_service.hpp" #include "maj0sted/editor/render.hpp"
#include "maj0sted/domain/parallelism/track_parallelism.hpp" #include "maj0sted/io/document_io.hpp"
#include "maj0sted/io/scn_export.hpp"
// Fitting — dopasowanie krzywej między prostymi
#include "maj0sted/domain/fitting/fit_parameters.hpp"
#include "maj0sted/domain/fitting/fit_result.hpp"
#include "maj0sted/domain/fitting/fitting_service.hpp"
// IO — serializacja projektu
#include "maj0sted/io/project_serializer.hpp"

View File

@@ -1,18 +0,0 @@
#pragma once
#include "maj0sted/domain/geometry/horizontal_alignment.hpp"
#include "maj0sted/domain/map_project.hpp"
#include "maj0sted/render/scene.hpp"
namespace maj0sted::render {
/// Samples a plan into absolute polylines in the CRS. Straights are
/// authoritative for position; curves (arcs, clothoids) are laid out continuing
/// from the previous element's end and heading. This is the reusable bridge
/// between the domain model and any renderer.
[[nodiscard]] Scene sample(const maj0sted::domain::HorizontalAlignment& plan);
/// Samples the plans of every niweleta in the project into one scene.
[[nodiscard]] Scene sample(const maj0sted::domain::MapProject& project);
} // namespace maj0sted::render

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstdint>
#include <vector> #include <vector>
#include "maj0sted/render/track_renderer.hpp" #include "maj0sted/render/track_renderer.hpp"
@@ -12,9 +13,9 @@ namespace maj0sted::render {
struct RailPolyline { struct RailPolyline {
ElementKind kind{ElementKind::Straight}; ElementKind kind{ElementKind::Straight};
std::vector<Point> points; std::vector<Point> points;
int straight_index{-1}; std::uint32_t element_id{0};
int gap{-1}; std::uint32_t owner_id{0};
int element_index{-1}; bool from_turnout{false};
double length{0.0}; double length{0.0};
double radius_start{0.0}; double radius_start{0.0};
double radius_end{0.0}; double radius_end{0.0};

View File

@@ -1,7 +1,5 @@
#pragma once #pragma once
#include <vector>
namespace maj0sted::render { namespace maj0sted::render {
/// A point in the project's CRS (metres for EPSG:2180). /// A point in the project's CRS (metres for EPSG:2180).
@@ -13,21 +11,4 @@ struct Point {
/// Which kind of plan element a polyline came from (lets a GUI colour it). /// Which kind of plan element a polyline came from (lets a GUI colour it).
enum class ElementKind { Straight, Arc, Transition }; enum class ElementKind { Straight, Arc, Transition };
/// A polyline approximating one plan element, in CRS coordinates.
struct Polyline {
ElementKind kind;
std::vector<Point> points;
};
/// A rendering-agnostic view of one or more plans: polylines plus a bounding box.
/// Any GUI can draw this — the library computes the geometry, the GUI only paints.
struct Scene {
std::vector<Polyline> polylines;
double min_x{0.0};
double min_y{0.0};
double max_x{0.0};
double max_y{0.0};
bool empty{true};
};
} // namespace maj0sted::render } // namespace maj0sted::render

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstdint>
#include <vector> #include <vector>
#include "maj0sted/render/scene.hpp" // Point, ElementKind #include "maj0sted/render/scene.hpp" // Point, ElementKind
@@ -12,12 +13,12 @@ namespace maj0sted::render {
/// hands to a renderer. /// hands to a renderer.
struct CentrelineElement { struct CentrelineElement {
ElementKind kind{ElementKind::Straight}; ElementKind kind{ElementKind::Straight};
std::vector<Point> points; ///< sampled axis in CRS metres std::vector<Point> points; ///< sampled axis in CRS metres
int straight_index{-1}; ///< owning straight, or -1 for a curve element std::uint32_t element_id{0}; ///< the authored element this came from
int gap{-1}; ///< owning gap, or -1 for a straight std::uint32_t owner_id{0}; ///< the track or turnout it belongs to
int element_index{-1}; ///< position within a gap's curve chain bool from_turnout{false}; ///< true when owner_id names a turnout
double length{0.0}; ///< element length (metres) double length{0.0}; ///< element length (metres)
double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end) double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end)
double radius_end{0.0}; double radius_end{0.0};
}; };

View File

@@ -1,389 +0,0 @@
#include "maj0sted/app/basket_draft.hpp"
#include <algorithm>
#include <cmath>
#include "maj0sted/app/straight_constraints.hpp"
namespace maj0sted::app {
namespace {
using maj0sted::editor::CompoundArcSpec;
using maj0sted::editor::GapFit;
using maj0sted::editor::NiweletaPolys;
using maj0sted::editor::NiweletaSpec;
using maj0sted::editor::RibbonElement;
using maj0sted::editor::RibbonRequest;
using maj0sted::editor::StraightSpec;
using maj0sted::editor::PlanPolyline;
void sanitize_draft_arcs(BasketDraft& draft) {
for (auto& arc : draft.arcs) {
if (!(arc.radius >= 30.0)) {
arc.radius = 30.0;
}
if (!(arc.length > 0.0)) {
arc.length = 5.0;
}
if (arc.transition_to_next < 0.0) {
arc.transition_to_next = 0.0;
}
}
if (draft.entry_t < 0.0) {
draft.entry_t = 0.0;
}
if (draft.exit_t < 0.0) {
draft.exit_t = 0.0;
}
}
bool draft_end_pose(const BasketDraft& draft,
const std::vector<PlanPolyline>& polys, double& out_x,
double& out_y, double& out_az) {
if (!draft.active) {
return false;
}
out_az = draft.az0;
if (!polys.empty() && !polys.back().points.empty()) {
const auto& pts = polys.back().points;
out_x = pts.back().x;
out_y = pts.back().y;
if (pts.size() >= 2) {
out_az = std::atan2(pts.back().x - pts[pts.size() - 2].x,
pts.back().y - pts[pts.size() - 2].y);
}
return true;
}
out_x = draft.x0;
out_y = draft.y0;
return true;
}
} // namespace
RibbonRequest draft_to_ribbon(const BasketDraft& draft) {
RibbonRequest req;
req.x0 = draft.x0;
req.y0 = draft.y0;
req.az0 = draft.az0;
if (draft.arcs.empty()) {
return req;
}
const int dir = draft.dir >= 0 ? 1 : -1;
if (draft.entry_t > 1e-6) {
RibbonElement el;
el.kind = 2;
el.length = draft.entry_t;
el.radius_start = 0.0;
el.radius = draft.arcs.front().radius;
el.dir = dir;
req.elements.push_back(el);
}
for (std::size_t i = 0; i < draft.arcs.size(); ++i) {
const auto& arc = draft.arcs[i];
const double r = std::max(30.0, arc.radius);
const double len = std::max(1e-3, arc.length);
RibbonElement a;
a.kind = 1;
a.radius = r;
a.angle = len / r;
a.dir = dir;
req.elements.push_back(a);
if (arc.transition_to_next > 1e-6) {
RibbonElement kp;
kp.kind = 2;
kp.length = arc.transition_to_next;
kp.radius_start = r;
if (i + 1 < draft.arcs.size()) {
kp.radius = std::max(30.0, draft.arcs[i + 1].radius);
} else {
kp.radius = std::max(30.0, draft.preview_next_r);
}
kp.dir = dir;
req.elements.push_back(kp);
}
}
if (draft.exit_t > 1e-6) {
RibbonElement el;
el.kind = 2;
el.length = draft.exit_t;
el.radius_start = draft.arcs.back().radius;
el.radius = 0.0;
el.dir = dir;
req.elements.push_back(el);
}
return req;
}
std::vector<PlanPolyline> preview_basket_draft(BasketDraft& draft,
double fallback_next_r) {
if (!draft.active || draft.arcs.empty()) {
return {};
}
sanitize_draft_arcs(draft);
draft.preview_next_r = std::max(30.0, fallback_next_r);
return maj0sted::editor::solve_ribbon(draft_to_ribbon(draft));
}
void start_basket_draft_from_end(BasketDraft& draft, const StraightSpec& straight,
int straight_index, int end, double default_r) {
draft = {};
draft.active = true;
draft.attach_str = straight_index;
draft.attach_end = end;
draft.dir = 1;
draft.preview_next_r = default_r;
draft.reedit_gap = -1;
draft.reedit_backup = {};
if (end == 1) {
draft.x0 = straight.x2;
draft.y0 = straight.y2;
draft.az0 = std::atan2(straight.x2 - straight.x1, straight.y2 - straight.y1);
} else {
draft.x0 = straight.x1;
draft.y0 = straight.y1;
draft.az0 = std::atan2(straight.x1 - straight.x2, straight.y1 - straight.y2);
}
draft.arcs.push_back({600.0, 60.0, 40.0});
draft.arcs.push_back({std::max(30.0, default_r), 80.0, 0.0});
}
bool reopen_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta,
std::size_t gap, const NiweletaPolys* solved) {
if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) {
return false;
}
const auto& fit = niweleta.fits[gap];
if (fit.mode != 3 || fit.arcs.empty()) {
return false;
}
std::vector<double> solved_arc_lengths;
if (solved != nullptr) {
for (const auto& poly : solved->polylines) {
if (poly.gap != static_cast<int>(gap) || poly.kind != 1 ||
poly.element_index < 0) {
continue;
}
bool seen = false;
for (const auto& other : solved->polylines) {
if (&other == &poly) {
break;
}
if (other.gap == poly.gap &&
other.element_index == poly.element_index) {
seen = true;
break;
}
}
if (!seen) {
solved_arc_lengths.push_back(std::max(5.0, poly.length));
}
}
}
const auto& entry = niweleta.straights[gap];
const auto& exit = niweleta.straights[gap + 1];
const double edx = entry.x2 - entry.x1;
const double edy = entry.y2 - entry.y1;
const double xdx = exit.x2 - exit.x1;
const double xdy = exit.y2 - exit.y1;
const double turn = edx * xdy - edy * xdx;
draft = {};
draft.active = true;
draft.reedit_backup = fit;
draft.reedit_gap = static_cast<int>(gap);
draft.attach_str = static_cast<int>(gap);
draft.attach_end = 1;
draft.x0 = entry.x2;
draft.y0 = entry.y2;
draft.az0 = std::atan2(edx, edy);
draft.dir = turn >= 0.0 ? 1 : -1;
draft.entry_t = std::max(0.0, fit.entry_t);
draft.exit_t = std::max(0.0, fit.exit_t);
draft.arcs = fit.arcs;
for (std::size_t i = 0; i < draft.arcs.size(); ++i) {
if (!(draft.arcs[i].radius >= 30.0)) {
draft.arcs[i].radius = 30.0;
}
if (draft.arcs[i].length <= 1e-6) {
draft.arcs[i].length =
i < solved_arc_lengths.size() ? solved_arc_lengths[i] : 60.0;
}
if (draft.arcs[i].transition_to_next < 0.0) {
draft.arcs[i].transition_to_next = 0.0;
}
}
if (!draft.arcs.empty()) {
draft.arcs.back().transition_to_next = 0.0;
draft.preview_next_r = draft.arcs.back().radius;
}
niweleta.fits[gap] = {};
niweleta.fits[gap].gap = static_cast<int>(gap);
niweleta.fits[gap].mode = 0;
return true;
}
void discard_basket_draft(BasketDraft& draft, NiweletaSpec* niweleta) {
if (draft.reedit_gap >= 0 && niweleta != nullptr &&
static_cast<std::size_t>(draft.reedit_gap) < niweleta->fits.size()) {
niweleta->fits[static_cast<std::size_t>(draft.reedit_gap)] =
draft.reedit_backup;
}
draft = {};
}
bool anchor_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta,
std::size_t gap) {
if (!draft.active || draft.arcs.empty() || niweleta.straights.size() < 2) {
return false;
}
if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) {
return false;
}
const GapFit previous = niweleta.fits[gap];
const BasketDraft draft_backup = draft;
GapFit fit{};
fit.gap = static_cast<int>(gap);
fit.mode = 3;
fit.entry_t = std::max(0.0, draft.entry_t);
fit.exit_t = std::max(0.0, draft.exit_t);
fit.arcs = draft.arcs;
for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) {
if (!(fit.arcs[i].radius >= 30.0)) {
fit.arcs[i].radius = 30.0;
}
if (!(fit.arcs[i].length > 0.0)) {
fit.arcs[i].length = 40.0;
}
}
fit.arcs.back().length = 0.0;
fit.arcs.back().transition_to_next = 0.0;
if (!(fit.arcs.back().radius >= 30.0)) {
fit.arcs.back().radius = 30.0;
}
for (std::size_t i = 1; i < fit.arcs.size(); ++i) {
if (std::abs(fit.arcs[i].radius - fit.arcs[i - 1].radius) < 1.0) {
fit.arcs[i].radius = std::max(30.0, fit.arcs[i - 1].radius * 0.75);
}
}
niweleta.fits[gap] = fit;
niweleta.straights[gap].rel_kind = 0;
niweleta.straights[gap + 1].rel_kind = 0;
const auto probe = maj0sted::editor::solve_project(
std::vector<NiweletaSpec>{niweleta});
bool applied = false;
if (!probe.empty()) {
for (const auto& af : probe.front().applied_fits) {
if (af.gap == static_cast<int>(gap) && af.mode == 3) {
applied = true;
break;
}
}
}
if (!applied) {
niweleta.fits[gap] = previous;
draft = draft_backup;
return false;
}
draft = {};
return true;
}
FinishBasketDraftResult finish_basket_draft(
BasketDraft& draft, NiweletaSpec& niweleta,
std::vector<NiweletaSpec>& all_niwelety, int niweleta_index,
double finish_straight_m) {
FinishBasketDraftResult result;
if (!draft.active || draft.arcs.empty() || draft.attach_str < 0 ||
static_cast<std::size_t>(draft.attach_str) >= niweleta.straights.size()) {
result.status = "brak łuku";
return result;
}
if (draft.reedit_gap >= 0) {
const auto gap = static_cast<std::size_t>(draft.reedit_gap);
const GapFit backup = draft.reedit_backup;
niweleta.fits[gap] = backup;
if (!anchor_basket_draft(draft, niweleta, gap)) {
draft.reedit_gap = static_cast<int>(gap);
draft.reedit_backup = backup;
result.status = "nie da się (kierunek / L+KP)";
return result;
}
result.ok = true;
result.status = "luka " + std::to_string(gap);
return result;
}
auto polys = preview_basket_draft(draft);
double tipx = 0.0, tipy = 0.0, tipaz = 0.0;
if (!draft_end_pose(draft, polys, tipx, tipy, tipaz)) {
result.status = "brak końca kosza";
return result;
}
const NiweletaSpec backup = niweleta;
const BasketDraft draft_backup = draft;
const int attach = draft.attach_str;
auto& entry = niweleta.straights[static_cast<std::size_t>(attach)];
if (draft.attach_end == 0) {
std::swap(entry.x1, entry.x2);
std::swap(entry.y1, entry.y2);
draft.attach_end = 1;
}
entry.x2 = draft.x0;
entry.y2 = draft.y0;
entry.rel_kind = 0;
const double sl = std::max(20.0, finish_straight_m);
StraightSpec exit_st{};
exit_st.x1 = tipx;
exit_st.y1 = tipy;
exit_st.x2 = tipx + std::sin(tipaz) * sl;
exit_st.y2 = tipy + std::cos(tipaz) * sl;
const auto insert_at = static_cast<std::size_t>(attach + 1);
for (auto& fit : niweleta.fits) {
if (fit.gap >= attach) {
++fit.gap;
}
}
auto bump_rel = [&](int delta) {
for (std::size_t n = 0; n < all_niwelety.size(); ++n) {
for (auto& st : all_niwelety[n].straights) {
if (st.rel_kind != 0 && st.rel_niw == niweleta_index &&
st.rel_str >= static_cast<int>(insert_at)) {
st.rel_str += delta;
}
}
}
};
bump_rel(+1);
niweleta.straights.insert(
niweleta.straights.begin() + static_cast<std::ptrdiff_t>(insert_at),
exit_st);
align_gap_fits(niweleta);
if (!anchor_basket_draft(draft, niweleta, static_cast<std::size_t>(attach))) {
bump_rel(-1);
niweleta = backup;
draft = draft_backup;
result.status = "nie da się (kierunek / L+KP)";
return result;
}
result.ok = true;
result.status = "kosz + " + std::to_string(static_cast<int>(sl)) + " m";
return result;
}
} // namespace maj0sted::app

View File

@@ -1,520 +0,0 @@
#include "maj0sted/app/editor_document.hpp"
#include <cctype>
#include <charconv>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <variant>
#include <vector>
#include "maj0sted/io/project_serializer.hpp"
#include "maj0sted/maj0sted.hpp"
namespace maj0sted::app {
using namespace maj0sted::domain;
using maj0sted::editor::GapFit;
using maj0sted::editor::NiweletaSpec;
using maj0sted::editor::StraightSpec;
namespace {
// Shortest round-trippable, locale-independent double — matches maj0sted::io.
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);
}
std::vector<std::string> tokenize(const std::string& line) {
std::vector<std::string> tokens;
std::size_t i = 0;
const std::size_t n = line.size();
while (i < n) {
while (i < n && std::isspace(static_cast<unsigned char>(line[i]))) ++i;
std::size_t j = i;
while (j < n && !std::isspace(static_cast<unsigned char>(line[j]))) ++j;
if (j > i) tokens.push_back(line.substr(i, j - i));
i = j;
}
return tokens;
}
double to_double(const std::string& text) {
double value{};
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{}) {
throw std::runtime_error{"maj0sted: bad number '" + text + "'"};
}
return value;
}
long to_long(const std::string& text) {
long value{};
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{}) {
throw std::runtime_error{"maj0sted: bad integer '" + text + "'"};
}
return value;
}
// Placeholder turn direction for the resolved-domain view: derived from the two
// straights' headings (sign of the cross product).
TurnDirection gap_direction(const StraightSpec& a, const StraightSpec& b) noexcept {
const double ax = a.x2 - a.x1, ay = a.y2 - a.y1;
const double bx = b.x2 - b.x1, by = b.y2 - b.y1;
return (ax * by - ay * bx) >= 0.0 ? TurnDirection::Left : TurnDirection::Right;
}
double gap_radius(const GapFit* fit) noexcept {
if (fit != nullptr) {
if (fit->radius > 0.0) return fit->radius;
if (!fit->arcs.empty() && fit->arcs.front().radius > 0.0) {
return fit->arcs.front().radius;
}
if (fit->r1 > 0.0) return fit->r1;
}
return 300.0; // benign placeholder; arcs carry no length in this view
}
} // namespace
MapProject to_map_project(const EditorDocument& document) {
MapProject project{Crs::default_crs()};
for (std::size_t index = 0; index < document.niwelety.size(); ++index) {
const NiweletaSpec& spec = document.niwelety[index];
try {
Niweleta niweleta{NiweletaId{static_cast<std::uint64_t>(index + 1)},
spec.name};
for (std::size_t i = 0; i < spec.straights.size(); ++i) {
const StraightSpec& s = spec.straights[i];
if (i > 0) {
// Keep two anchored straights from being adjacent (which the
// alignment forbids) by threading a floating placeholder arc.
const GapFit* fit = nullptr;
for (const auto& f : spec.fits) {
if (static_cast<std::size_t>(f.gap) == i - 1 && f.mode > 0) {
fit = &f;
break;
}
}
niweleta.add_plan_element(
CircularArc{Radius::from_metres(gap_radius(fit)),
gap_direction(spec.straights[i - 1], s)});
}
niweleta.add_plan_element(
Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}});
}
project.add_niweleta(std::move(niweleta));
} catch (...) {
// Degenerate/inconsistent niweleta: keep the (named) shell so the
// project still lists it; the editor section restores the details.
project.add_niweleta(Niweleta{
NiweletaId{static_cast<std::uint64_t>(index + 1)}, spec.name});
}
}
return project;
}
EditorDocument from_map_project(const MapProject& project) {
EditorDocument document;
for (const auto& niweleta : project.niwelety()) {
NiweletaSpec spec;
spec.name = niweleta.name();
for (const auto& element : niweleta.plan().elements()) {
if (const auto* straight = std::get_if<Straight>(&element)) {
spec.straights.push_back(StraightSpec{
straight->start().x(), straight->start().y(),
straight->end().x(), straight->end().y()});
}
}
document.niwelety.push_back(std::move(spec));
}
return document;
}
std::string serialize_document(const EditorDocument& document) {
// Reuse the domain serializer for a real, inspectable maj0sted project...
std::string out = maj0sted::io::serialize(to_map_project(document));
// ...then the lossless editor section (straights + fit parameters).
out += "editor " + std::to_string(document.niwelety.size()) + "\n";
if (document.view_extent > 0.0) {
out += "eview " + num(document.view_x) + " " + num(document.view_y) + " " +
num(document.view_extent) + "\n";
}
if (document.origin_set) {
out += "eorigin " + (document.georeferenced ? std::string{"1"} : "0") + " " +
num(document.origin_x) + " " + num(document.origin_y) + "\n";
}
for (const auto& spec : document.niwelety) {
out += "eniw " + std::to_string(spec.straights.size()) + " " +
std::to_string(spec.fits.size()) + "\n";
out += "ename " + spec.name + "\n";
for (const auto& s : spec.straights) {
out += "estr " + num(s.x1) + " " + num(s.y1) + " " + num(s.x2) + " " +
num(s.y2) + " " + (s.hidden ? "1" : "0") + "\n";
}
for (std::size_t i = 0; i < spec.straights.size(); ++i) {
const auto& s = spec.straights[i];
if (s.rel_kind == 1) {
out += "epar " + std::to_string(i) + " " +
std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) +
" " + num(s.rel_offset) + "\n";
} else if (s.rel_kind == 2) {
out += "eskew " + std::to_string(i) + " " +
std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) +
" " + num(s.rel_cot) + " " + std::to_string(s.rel_side) + " " +
num(s.rel_length) + "\n";
}
}
for (const auto& f : spec.fits) {
// efit2: gap mode radius transition entry_t exit_t <n> [r len trans]*
// The variable arc list supersedes the legacy fixed two-arc 'efit'.
out += "efit2 " + std::to_string(f.gap) + " " +
std::to_string(f.mode) + " " + num(f.radius) + " " +
num(f.transition) + " " + num(f.entry_t) + " " + num(f.exit_t) +
" " + std::to_string(f.arcs.size());
for (const auto& a : f.arcs) {
out += " " + num(a.radius) + " " + num(a.length) + " " +
num(a.transition_to_next);
}
out += "\n";
}
}
// switches: through/station/side/facing/skew/branch, then the internal curve
// inline in the same shape as an efit2 tail (mode radius trans entry exit <n> …)
out += "ejuncs " + std::to_string(document.junctions.size()) + "\n";
for (const auto& j : document.junctions) {
out += "ejunc " + std::to_string(j.through) + " " + num(j.station) + " " +
std::to_string(j.side) + " " + (j.facing ? std::string{"1"} : "0") +
" " + num(j.crossing_n) + " " + std::to_string(j.branch) + " " +
std::to_string(j.curve.mode) + " " + num(j.curve.radius) + " " +
num(j.curve.transition) + " " + num(j.curve.entry_t) + " " +
num(j.curve.exit_t) + " " + std::to_string(j.curve.arcs.size());
for (const auto& a : j.curve.arcs) {
out += " " + num(a.radius) + " " + num(a.length) + " " +
num(a.transition_to_next);
}
// catalogue length trails the arcs, so files written before it still parse (length stays 0)
out += " " + num(j.length);
out += "\n";
}
return out;
}
EditorDocument deserialize_document(const std::string& text) {
std::vector<std::string> lines;
{
std::istringstream in(text);
std::string line;
while (std::getline(in, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
lines.push_back(line);
}
}
// Locate the editor section (the domain block before it is interop-only).
std::size_t cursor = lines.size();
for (std::size_t i = 0; i < lines.size(); ++i) {
const auto tokens = tokenize(lines[i]);
if (!tokens.empty() && tokens[0] == "editor") {
cursor = i;
break;
}
}
if (cursor >= lines.size()) {
throw std::runtime_error{"maj0sted: missing 'editor' section"};
}
const auto next = [&]() -> const std::string& {
while (cursor < lines.size()) {
const std::string& l = lines[cursor++];
bool only_ws = true;
for (const char c : l) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) return l;
}
throw std::runtime_error{"maj0sted: unexpected end of editor section"};
};
EditorDocument document;
const auto header = tokenize(next());
if (header.size() < 2 || header[0] != "editor") {
throw std::runtime_error{"maj0sted: expected 'editor <count>'"};
}
const long niweleta_count = to_long(header[1]);
// Optional camera/zoom line (absent in older files).
{
std::size_t peek = cursor;
while (peek < lines.size()) {
bool only_ws = true;
for (const char c : lines[peek]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) {
break;
}
++peek;
}
if (peek < lines.size()) {
const auto view_tok = tokenize(lines[peek]);
if (view_tok.size() >= 4 && view_tok[0] == "eview") {
next();
document.view_x = to_double(view_tok[1]);
document.view_y = to_double(view_tok[2]);
document.view_extent = to_double(view_tok[3]);
}
}
// Optional local-zero line (absent in older files). May follow eview.
peek = cursor;
while (peek < lines.size()) {
bool only_ws = true;
for (const char c : lines[peek]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) {
break;
}
++peek;
}
if (peek < lines.size()) {
const auto origin_tok = tokenize(lines[peek]);
if (origin_tok.size() >= 4 && origin_tok[0] == "eorigin") {
next();
document.origin_set = true;
document.georeferenced = to_long(origin_tok[1]) != 0;
document.origin_x = to_double(origin_tok[2]);
document.origin_y = to_double(origin_tok[3]);
}
}
}
for (long n = 0; n < niweleta_count; ++n) {
const auto eniw = tokenize(next());
if (eniw.size() < 3 || eniw[0] != "eniw") {
throw std::runtime_error{"maj0sted: expected 'eniw <straights> <fits>'"};
}
const long straight_count = to_long(eniw[1]);
const long fit_count = to_long(eniw[2]);
NiweletaSpec spec;
const std::string& name_line = next();
if (name_line.rfind("ename", 0) != 0) {
throw std::runtime_error{"maj0sted: expected 'ename'"};
}
spec.name = name_line.size() > 6 ? name_line.substr(6) : std::string{};
for (long i = 0; i < straight_count; ++i) {
const auto e = tokenize(next());
if (e.size() < 5 || e[0] != "estr") {
throw std::runtime_error{"maj0sted: bad 'estr' line"};
}
spec.straights.push_back(
StraightSpec{to_double(e[1]), to_double(e[2]), to_double(e[3]),
to_double(e[4]), e.size() >= 6 && to_long(e[5]) != 0});
}
// Optional parallel/skew lines sit between straights and fits; older files omit them.
auto apply_relation = [&](const std::vector<std::string>& e) -> bool {
if (e.empty()) return false;
if (e[0] == "epar" && e.size() >= 5) {
const long idx = to_long(e[1]);
if (idx < 0 || static_cast<std::size_t>(idx) >= spec.straights.size()) {
throw std::runtime_error{"maj0sted: bad 'epar' index"};
}
auto& s = spec.straights[static_cast<std::size_t>(idx)];
s.rel_kind = 1;
s.rel_niw = static_cast<int>(to_long(e[2]));
s.rel_str = static_cast<int>(to_long(e[3]));
s.rel_offset = to_double(e[4]);
return true;
}
if (e[0] == "eskew" && e.size() >= 7) {
const long idx = to_long(e[1]);
if (idx < 0 || static_cast<std::size_t>(idx) >= spec.straights.size()) {
throw std::runtime_error{"maj0sted: bad 'eskew' index"};
}
auto& s = spec.straights[static_cast<std::size_t>(idx)];
s.rel_kind = 2;
s.rel_niw = static_cast<int>(to_long(e[2]));
s.rel_str = static_cast<int>(to_long(e[3]));
s.rel_cot = to_double(e[4]);
s.rel_side = static_cast<int>(to_long(e[5]));
s.rel_length = to_double(e[6]);
return true;
}
return false;
};
auto peek_tokens = [&]() -> std::vector<std::string> {
std::size_t i = cursor;
while (i < lines.size()) {
bool only_ws = true;
for (const char c : lines[i]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) {
return tokenize(lines[i]);
}
++i;
}
return {};
};
while (apply_relation(peek_tokens())) {
next(); // consume the relation line
}
for (long i = 0; i < fit_count; ++i) {
const auto e = tokenize(next());
GapFit f;
if (!e.empty() && e[0] == "efit2") {
// New variable-arc format.
if (e.size() < 8) {
throw std::runtime_error{"maj0sted: bad 'efit2' line"};
}
f.gap = static_cast<int>(to_long(e[1]));
f.mode = static_cast<int>(to_long(e[2]));
f.radius = to_double(e[3]);
f.transition = to_double(e[4]);
f.entry_t = to_double(e[5]);
f.exit_t = to_double(e[6]);
const long arc_count = to_long(e[7]);
for (long a = 0; a < arc_count; ++a) {
const std::size_t base = 8 + static_cast<std::size_t>(a) * 3;
if (base + 2 >= e.size()) {
throw std::runtime_error{"maj0sted: truncated 'efit2' arcs"};
}
f.arcs.push_back(maj0sted::editor::CompoundArcSpec{
to_double(e[base]), to_double(e[base + 1]),
to_double(e[base + 2])});
}
} else if (!e.empty() && e[0] == "efit" && e.size() >= 11) {
// Legacy fixed two-arc format; arcs stay empty and to_request
// falls back to the r1/r2 scalar fields.
f.gap = static_cast<int>(to_long(e[1]));
f.mode = static_cast<int>(to_long(e[2]));
f.radius = to_double(e[3]);
f.transition = to_double(e[4]);
f.r1 = to_double(e[5]);
f.arc1_len = to_double(e[6]);
f.between = to_double(e[7]);
f.r2 = to_double(e[8]);
f.entry_t = to_double(e[9]);
f.exit_t = to_double(e[10]);
} else {
throw std::runtime_error{"maj0sted: bad 'efit'/'efit2' line"};
}
spec.fits.push_back(f);
}
document.niwelety.push_back(std::move(spec));
}
// Optional switches section (absent in older files); mirrors the eview/eorigin peek.
{
std::size_t peek = cursor;
while (peek < lines.size()) {
bool only_ws = true;
for (const char c : lines[peek]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) break;
++peek;
}
if (peek < lines.size()) {
const auto head = tokenize(lines[peek]);
if (head.size() >= 2 && head[0] == "ejuncs") {
next(); // consume the 'ejuncs <count>' header
const long junction_count = to_long(head[1]);
for (long j = 0; j < junction_count; ++j) {
const auto e = tokenize(next());
if (e.size() < 12 || e[0] != "ejunc") {
throw std::runtime_error{"maj0sted: bad 'ejunc' line"};
}
maj0sted::editor::Junction junction;
junction.through = static_cast<int>(to_long(e[1]));
junction.station = to_double(e[2]);
junction.side = static_cast<int>(to_long(e[3]));
junction.facing = to_long(e[4]) != 0;
junction.crossing_n = to_double(e[5]);
junction.branch = static_cast<int>(to_long(e[6]));
junction.curve.mode = static_cast<int>(to_long(e[7]));
junction.curve.radius = to_double(e[8]);
junction.curve.transition = to_double(e[9]);
junction.curve.entry_t = to_double(e[10]);
junction.curve.exit_t = to_double(e[11]);
const long arc_count = e.size() >= 13 ? to_long(e[12]) : 0;
for (long a = 0; a < arc_count; ++a) {
const std::size_t base = 13 + static_cast<std::size_t>(a) * 3;
if (base + 2 >= e.size()) {
throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"};
}
junction.curve.arcs.push_back(maj0sted::editor::CompoundArcSpec{
to_double(e[base]), to_double(e[base + 1]),
to_double(e[base + 2])});
}
// optional catalogue length after the arcs (absent in the earliest files)
const std::size_t after_arcs = 13 + static_cast<std::size_t>(arc_count) * 3;
if (e.size() > after_arcs) {
junction.length = to_double(e[after_arcs]);
}
document.junctions.push_back(std::move(junction));
}
}
}
}
return document;
}
std::string default_project_path() {
// Fallback only; the editor passes its own path (see plan_panel). Relative, so
// it lands under the working directory rather than at a filesystem root.
return "editor/project.m0s";
}
bool save_project(const EditorDocument& document, const std::string& path) {
try {
const std::filesystem::path file{path};
if (file.has_parent_path()) {
std::filesystem::create_directories(file.parent_path());
}
std::ofstream out(file, std::ios::binary | std::ios::trunc);
if (!out) return false;
out << serialize_document(document);
return static_cast<bool>(out);
} catch (...) {
return false;
}
}
std::optional<EditorDocument> load_project(const std::string& path) {
try {
const std::filesystem::path file{path};
if (!std::filesystem::exists(file)) return std::nullopt;
std::ifstream in(file, std::ios::binary);
if (!in) return std::nullopt;
std::ostringstream buffer;
buffer << in.rdbuf();
return deserialize_document(buffer.str());
} catch (...) {
return std::nullopt;
}
}
} // namespace maj0sted::app

View File

@@ -1,112 +0,0 @@
#include "maj0sted/app/straight_constraints.hpp"
#include <algorithm>
#include <cmath>
#include "maj0sted/domain/geometry/plan_element.hpp"
#include "maj0sted/domain/parallelism/parallelism_service.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
namespace maj0sted::app {
namespace {
using maj0sted::domain::CartesianPosition;
using maj0sted::domain::ParallelismService;
using maj0sted::domain::Straight;
using maj0sted::editor::NiweletaSpec;
using maj0sted::editor::StraightSpec;
Straight to_straight(const StraightSpec& s) {
return Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}};
}
} // namespace
void align_gap_fits(NiweletaSpec& niweleta) {
const auto gaps = niweleta.straights.empty() ? std::size_t{0}
: niweleta.straights.size() - 1;
std::vector<maj0sted::editor::GapFit> slots(gaps);
for (std::size_t gap = 0; gap < gaps; ++gap) {
slots[gap].gap = static_cast<int>(gap);
}
for (const auto& fit : niweleta.fits) {
if (fit.gap >= 0 && static_cast<std::size_t>(fit.gap) < gaps) {
slots[static_cast<std::size_t>(fit.gap)] = fit;
}
}
niweleta.fits = std::move(slots);
}
void apply_straight_constraints(std::vector<NiweletaSpec>& niwelety) {
for (int pass = 0; pass < 3; ++pass) {
bool changed = false;
for (auto& niweleta : niwelety) {
for (auto& st : niweleta.straights) {
if (st.rel_kind == 0 || st.rel_niw < 0 || st.rel_str < 0) {
continue;
}
if (static_cast<std::size_t>(st.rel_niw) >= niwelety.size()) {
continue;
}
const auto& ref_niw = niwelety[static_cast<std::size_t>(st.rel_niw)];
if (static_cast<std::size_t>(st.rel_str) >= ref_niw.straights.size()) {
continue;
}
const auto& ref = ref_niw.straights[static_cast<std::size_t>(st.rel_str)];
if (&ref == &st) {
continue;
}
const double dx = ref.x2 - ref.x1;
const double dy = ref.y2 - ref.y1;
const double len = std::hypot(dx, dy);
if (len < 1e-6) {
continue;
}
double x1 = st.x1, y1 = st.y1, x2 = st.x2, y2 = st.y2;
if (st.rel_kind == 1) {
const Straight projected = ParallelismService::project_onto_offset_line(
to_straight(ref), st.rel_offset, to_straight(st));
x1 = projected.start().x();
y1 = projected.start().y();
x2 = projected.end().x();
y2 = projected.end().y();
} else if (st.rel_kind == 2) {
const double a =
std::atan2(1.0, std::max(1e-9, st.rel_cot)) *
(st.rel_side < 0 ? -1.0 : 1.0);
const double ux = dx / len;
const double uy = dy / len;
const double ca = std::cos(a);
const double sa = std::sin(a);
const double rx = ux * ca - uy * sa;
const double ry = ux * sa + uy * ca;
double L = std::hypot(st.x2 - st.x1, st.y2 - st.y1);
if (L < 1.0) {
L = st.rel_length > 0.0 ? st.rel_length : len;
}
st.rel_length = L;
x1 = st.x1;
y1 = st.y1;
x2 = st.x1 + rx * L;
y2 = st.y1 + ry * L;
}
if (std::abs(st.x1 - x1) > 1e-9 || std::abs(st.y1 - y1) > 1e-9 ||
std::abs(st.x2 - x2) > 1e-9 || std::abs(st.y2 - y2) > 1e-9) {
st.x1 = x1;
st.y1 = y1;
st.x2 = x2;
st.y2 = y2;
changed = true;
}
}
}
if (!changed) {
break;
}
}
}
} // namespace maj0sted::app

View File

@@ -1,582 +0,0 @@
#include "maj0sted/domain/fitting/fitting_service.hpp"
#include <algorithm>
#include <cmath>
#include <numbers>
#include <numeric>
#include <stdexcept>
#include <utility>
#include <variant>
#include <vector>
#include "maj0sted/domain/geometry/segment_layout.hpp"
namespace maj0sted::domain {
namespace {
constexpr double kEpsilon = 1e-9;
struct Vec {
double east;
double north;
};
// Unit direction of travel for an azimuth (east=x, north=y).
Vec direction_of(Azimuth azimuth) noexcept {
return Vec{std::sin(azimuth.radians()), std::cos(azimuth.radians())};
}
const Straight& find_straight(const Niweleta& niweleta, StraightId id) {
for (const auto& element : niweleta.plan().elements()) {
if (const auto* straight = std::get_if<Straight>(&element)) {
if (!straight->id().is_null() && straight->id() == id) {
return *straight;
}
}
}
throw std::invalid_argument{"Straight with the given id not found in the niweleta"};
}
// A segment of the chain: signed curvature k0 -> k1 over `length`.
struct Seg {
double k0;
double k1;
double length;
};
// Result of closing a chain onto the exit straight: the two tangent points that
// the entry/exit straights are trimmed to.
struct ChainClose {
CartesianPosition tangent_in;
CartesianPosition tangent_out;
};
double straight_length(const Straight& straight) noexcept {
return straight.length().metres();
}
double parameter_on(const Straight& straight, CartesianPosition point) noexcept {
const Vec direction = direction_of(straight.azimuth());
return (point.x() - straight.start().x()) * direction.east +
(point.y() - straight.start().y()) * direction.north;
}
bool valid_tangent_points(const Straight& entry, const Straight& exit,
const ChainClose& close) noexcept {
constexpr double tolerance = 1e-6;
const double entry_parameter = parameter_on(entry, close.tangent_in);
const double exit_parameter = parameter_on(exit, close.tangent_out);
// A zero-length trimmed straight cannot be represented by Straight.
return entry_parameter > tolerance &&
entry_parameter <= straight_length(entry) + tolerance &&
exit_parameter >= -tolerance &&
exit_parameter < straight_length(exit) - tolerance;
}
// Lays out `segments` head-to-tail from entry.start() heading along the entry
// azimuth, then slides the whole rigid chain along the entry line until its end
// lands on the exit line. Uses the SAME layout as the renderer, so the fitted
// tangent points coincide exactly with the drawn curve.
ChainClose solve_chain(const Straight& entry, const Straight& exit,
const std::vector<Seg>& segments) {
const Vec d1 = direction_of(entry.azimuth());
const Vec d2 = direction_of(exit.azimuth());
geometry::Pose pose{entry.start().x(), entry.start().y(), d1.east, d1.north};
for (const auto& s : segments) {
pose = geometry::layout_segment(s.k0, s.k1, s.length, pose, nullptr);
}
const Vec exit_normal{-d2.north, d2.east};
const double denominator = d1.east * exit_normal.east + d1.north * exit_normal.north;
if (std::abs(denominator) < kEpsilon) {
throw std::invalid_argument{"Straights are parallel; nothing to fit"};
}
const double offset = (pose.x - exit.start().x()) * exit_normal.east +
(pose.y - exit.start().y()) * exit_normal.north;
const double shift = -offset / denominator;
return ChainClose{
CartesianPosition{entry.start().x() + shift * d1.east,
entry.start().y() + shift * d1.north},
CartesianPosition{pose.x + shift * d1.east, pose.y + shift * d1.north}};
}
} // namespace
FitResult FittingService::fit_between_straights(const Niweleta& niweleta,
const FitParameters& params) {
if (params.entry.is_null() || params.exit.is_null()) {
throw std::invalid_argument{"Both straights must be identified"};
}
if (params.entry == params.exit) {
throw std::invalid_argument{"Fitting needs two different straights"};
}
return fit_between_straights(find_straight(niweleta, params.entry),
find_straight(niweleta, params.exit), params.radius,
params.transition_length);
}
// ---------------------------------------------------------------------------
// Arc fit (tryb łuku)
//
// Rounds the corner between two supporting lines with a circular arc, optionally
// eased by a symmetric pair of clothoids (spiral–arc–spiral). The straights are
// treated as infinite lines: this routine finds the tangent points on those
// lines and returns the two straights trimmed to them. Whether those tangent
// points fall on the drawn finite segments is a separate, higher-level policy
// (see domain::GapFitter) — not this service's concern.
//
// The geometry is produced by laying the arc chain out with the SAME sampler the
// renderer uses (geometry::layout_segment via solve_chain) and sliding it onto
// the exit line. That guarantees the returned tangent points coincide with the
// drawn curve to the last bit, even for tight radii, so no lateral gap appears.
// ---------------------------------------------------------------------------
FitResult FittingService::fit_between_straights(const Straight& entry,
const Straight& exit,
Radius radius_value,
std::optional<Length> transition_value) {
const double radius = radius_value.metres(); // Radius guarantees radius > 0
double transition = 0.0;
if (transition_value) {
transition = transition_value->metres();
if (!(transition > 0.0)) {
throw std::invalid_argument{"Transition length must be positive"};
}
}
// Signed turn from the entry heading to the exit heading, in (-pi, pi]. Its
// sign is the turn direction; its magnitude is the total deflection the curve
// must absorb.
const Vec d1 = direction_of(entry.azimuth());
const Vec d2 = direction_of(exit.azimuth());
const double deflection = std::atan2(d1.east * d2.north - d1.north * d2.east,
d1.east * d2.east + d1.north * d2.north);
const double total_turn = std::abs(deflection);
if (total_turn < kEpsilon || std::abs(total_turn - std::numbers::pi) < kEpsilon) {
throw std::invalid_argument{
"Straights are parallel, collinear or anti-parallel; no arc fits"};
}
const TurnDirection direction =
deflection > 0.0 ? TurnDirection::Left : TurnDirection::Right;
const double curvature = (deflection > 0.0 ? 1.0 : -1.0) / radius;
// A clothoid of length L run into a radius R bends the heading by L / (2R);
// the two symmetric spirals therefore consume L / R of the turn, and the
// circular arc between them takes whatever remains.
const double arc_turn = total_turn - transition / radius;
if (arc_turn < -kEpsilon) {
throw std::invalid_argument{
"Transition curves are too long for this radius and deflection"};
}
const double arc_length = radius * std::max(arc_turn, 0.0);
// Chain the elements head-to-tail — clothoid in / arc / clothoid out — using
// one definition for both the closure solve and the returned plan elements.
std::vector<Seg> chain;
std::vector<PlanElement> curve;
chain.reserve(3);
curve.reserve(3);
if (transition > 0.0) {
chain.push_back({0.0, curvature, transition});
curve.push_back(TransitionCurve{Length::from_metres(transition),
std::nullopt, radius_value, direction});
}
chain.push_back({curvature, curvature, arc_length});
curve.push_back(
CircularArc{radius_value, direction, Length::from_metres(arc_length)});
if (transition > 0.0) {
chain.push_back({curvature, 0.0, transition});
curve.push_back(TransitionCurve{Length::from_metres(transition),
radius_value, std::nullopt, direction});
}
const ChainClose close = solve_chain(entry, exit, chain);
return FitResult{
Straight{entry.id(), entry.start(), close.tangent_in},
std::move(curve),
Straight{exit.id(), close.tangent_out, exit.end()},
close.tangent_in,
close.tangent_out,
direction,
};
}
FitResult FittingService::fit_uturn(const Straight& entry, const Straight& exit,
std::optional<Length> transition) {
const Vec d1 = direction_of(entry.azimuth());
const Vec d2 = direction_of(exit.azimuth());
const double dot = d1.east * d2.east + d1.north * d2.north;
if (dot > -1.0 + 1e-6) {
throw std::invalid_argument{"A U-turn needs two anti-parallel straights"};
}
if (transition && !(transition->metres() > 0.0)) {
throw std::invalid_argument{"Transition length must be positive"};
}
// Signed perpendicular offset from the entry line to the exit line.
const Vec left{-d1.north, d1.east};
const double offset = (exit.start().x() - entry.start().x()) * left.east +
(exit.start().y() - entry.start().y()) * left.north;
if (std::abs(offset) < kEpsilon) {
throw std::invalid_argument{"U-turn straights are collinear (zero offset)"};
}
const double sign = (offset > 0.0) ? 1.0 : -1.0; // turn toward the exit line
const TurnDirection direction =
(offset > 0.0) ? TurnDirection::Left : TurnDirection::Right;
// Plain 180° arc spans 2R across the offset, so R = |offset| / 2. (Transition
// curves would change the span; solved numerically — not yet supported here.)
const double radius = std::abs(offset) / 2.0;
const Radius radius_vo = Radius::from_metres(radius);
const double arc_length = radius * std::numbers::pi;
// Start the reversal at the entry straight's end; the 180° arc lands on the
// exit line at the diametrically opposite point.
const CartesianPosition tangent_in = entry.end();
const CartesianPosition tangent_out{tangent_in.x() + std::abs(offset) * sign * left.east,
tangent_in.y() + std::abs(offset) * sign * left.north};
std::vector<PlanElement> curve;
curve.push_back(CircularArc{radius_vo, direction, Length::from_metres(arc_length)});
(void)transition; // clothoid U-turn: follow-up
return FitResult{
Straight{entry.id(), entry.start(), tangent_in},
std::move(curve),
Straight{exit.id(), tangent_out, exit.end()},
tangent_in,
tangent_out,
direction,
};
}
FitResult FittingService::fit_reverse(const Straight& entry, const Straight& exit,
Radius radius_vo) {
constexpr double parallel_tolerance = 1e-6;
constexpr double angle_epsilon = 1e-5;
const double radius = radius_vo.metres();
const Vec d1 = direction_of(entry.azimuth());
const Vec d2 = direction_of(exit.azimuth());
const double cross = d1.east * d2.north - d1.north * d2.east;
const double dot = d1.east * d2.east + d1.north * d2.north;
const auto direction = [](double sign) {
return sign > 0.0 ? TurnDirection::Left : TurnDirection::Right;
};
const auto opposite = [](TurnDirection value) {
return value == TurnDirection::Left ? TurnDirection::Right
: TurnDirection::Left;
};
const auto result_from = [&](const ChainClose& close, double first_sign,
double first_angle, double second_angle) {
const TurnDirection first_direction = direction(first_sign);
std::vector<PlanElement> curve;
curve.push_back(CircularArc{radius_vo, first_direction,
Length::from_metres(radius * first_angle)});
curve.push_back(CircularArc{radius_vo, opposite(first_direction),
Length::from_metres(radius * second_angle)});
return FitResult{
Straight{entry.id(), entry.start(), close.tangent_in},
std::move(curve),
Straight{exit.id(), close.tangent_out, exit.end()},
close.tangent_in,
close.tangent_out,
first_direction,
};
};
// Same-direction parallel lines are the canonical S-curve case. Their
// lateral displacement fixes the common arc angle for a requested radius.
if (std::abs(cross) <= parallel_tolerance) {
if (dot < 0.0) {
throw std::invalid_argument{
"Anti-parallel straights require a U-turn, not a reverse curve"};
}
const Vec left{-d1.north, d1.east};
const double offset =
(exit.start().x() - entry.start().x()) * left.east +
(exit.start().y() - entry.start().y()) * left.north;
if (std::abs(offset) <= kEpsilon) {
throw std::invalid_argument{
"Collinear straights do not need a reverse curve"};
}
const double cosine = 1.0 - std::abs(offset) / (2.0 * radius);
if (cosine < -1.0 || cosine >= 1.0) {
throw std::invalid_argument{
"Requested radius cannot span the parallel-line offset"};
}
const double angle = std::acos(std::clamp(cosine, -1.0, 1.0));
if (angle <= angle_epsilon) {
throw std::invalid_argument{"Reverse-curve angle is too small"};
}
const double first_sign = offset > 0.0 ? 1.0 : -1.0;
const std::vector<Seg> segments{
{first_sign / radius, first_sign / radius, radius * angle},
{-first_sign / radius, -first_sign / radius, radius * angle},
};
geometry::Pose pose{entry.start().x(), entry.start().y(), d1.east, d1.north};
for (const auto& segment : segments) {
pose = geometry::layout_segment(segment.k0, segment.k1, segment.length,
pose, nullptr);
}
const double exit_at_zero =
(pose.x - exit.start().x()) * d2.east +
(pose.y - exit.start().y()) * d2.north;
constexpr double length_epsilon = 1e-6;
const double lower = std::max(length_epsilon, -exit_at_zero);
const double upper =
std::min(straight_length(entry),
straight_length(exit) - length_epsilon - exit_at_zero);
if (lower > upper) {
throw std::invalid_argument{
"Reverse curve does not fit on the finite parallel straights"};
}
// Prefer tangent points near entry.end() and exit.start().
const double unconstrained =
0.5 * (straight_length(entry) - exit_at_zero);
const double shift = std::clamp(unconstrained, lower, upper);
const ChainClose close{
CartesianPosition{entry.start().x() + shift * d1.east,
entry.start().y() + shift * d1.north},
CartesianPosition{pose.x + shift * d1.east,
pose.y + shift * d1.north},
};
if (!valid_tangent_points(entry, exit, close)) {
throw std::invalid_argument{
"Reverse-curve tangent points lie outside the parallel straights"};
}
return result_from(close, first_sign, angle, angle);
}
const double deflection = std::atan2(cross, dot);
const double delta = std::abs(deflection);
if (std::abs(delta - std::numbers::pi) <= parallel_tolerance) {
throw std::invalid_argument{
"Anti-parallel straights require a U-turn, not a reverse curve"};
}
const double extra_max = std::numbers::pi - delta - angle_epsilon;
if (extra_max <= angle_epsilon) {
throw std::invalid_argument{"Not enough angle for a reverse curve"};
}
std::optional<FitResult> best;
double best_length = 0.0;
constexpr int samples = 720;
for (const double first_sign : {1.0, -1.0}) {
const double signed_delta = deflection / first_sign;
const auto attempt = [&](double extra) -> std::optional<FitResult> {
const double first_angle =
signed_delta >= 0.0 ? extra + signed_delta : extra;
const double second_angle =
signed_delta >= 0.0 ? extra : extra - signed_delta;
if (first_angle <= angle_epsilon || second_angle <= angle_epsilon ||
first_angle >= std::numbers::pi ||
second_angle >= std::numbers::pi) {
return std::nullopt;
}
const std::vector<Seg> segments{
{first_sign / radius, first_sign / radius, radius * first_angle},
{-first_sign / radius, -first_sign / radius, radius * second_angle},
};
try {
const ChainClose close = solve_chain(entry, exit, segments);
if (!valid_tangent_points(entry, exit, close)) return std::nullopt;
return result_from(close, first_sign, first_angle, second_angle);
} catch (...) {
return std::nullopt;
}
};
double previous = angle_epsilon;
for (int sample = 1; sample <= samples; ++sample) {
const double extra =
angle_epsilon +
(extra_max - angle_epsilon) * static_cast<double>(sample) /
static_cast<double>(samples);
auto candidate = attempt(extra);
if (!candidate) {
previous = extra;
continue;
}
// Refine the first feasible angle boundary to avoid grid-shaped
// jumps while dragging a straight in the editor.
double low = previous;
double high = extra;
std::optional<FitResult> refined = std::move(candidate);
for (int iteration = 0; iteration < 48; ++iteration) {
const double middle = std::midpoint(low, high);
if (auto middle_result = attempt(middle)) {
high = middle;
refined = std::move(middle_result);
} else {
low = middle;
}
}
const double total_length = radius * (delta + 2.0 * high);
if (!best || total_length < best_length) {
best = std::move(refined);
best_length = total_length;
}
break;
}
}
if (!best) {
throw std::invalid_argument{
"No equal-radius reverse curve fits the finite straights"};
}
return *best;
}
FitResult FittingService::fit_compound(const Niweleta& niweleta,
const CompoundFitParameters& params) {
if (params.entry.is_null() || params.exit.is_null()) {
throw std::invalid_argument{"Both straights must be identified"};
}
if (params.entry == params.exit) {
throw std::invalid_argument{"Fitting needs two different straights"};
}
return fit_compound(find_straight(niweleta, params.entry),
find_straight(niweleta, params.exit), params);
}
FitResult FittingService::fit_compound(const Straight& entry, const Straight& exit,
const CompoundFitParameters& params) {
if (params.arcs.empty()) {
throw std::invalid_argument{"A compound curve needs at least one arc"};
}
for (std::size_t k = 1; k < params.arcs.size(); ++k) {
if (params.arcs[k].radius == params.arcs[k - 1].radius) {
throw std::invalid_argument{
"Adjacent compound arcs must have different radii"};
}
}
const Vec d1 = direction_of(entry.azimuth());
const Vec d2 = direction_of(exit.azimuth());
const double cross = d1.east * d2.north - d1.north * d2.east;
const double dot = d1.east * d2.east + d1.north * d2.north;
const double deflection = std::atan2(cross, dot);
const double delta = std::abs(deflection);
if (delta < kEpsilon || std::abs(delta - std::numbers::pi) < kEpsilon) {
throw std::invalid_argument{
"Straights are parallel or collinear; nothing to fit"};
}
const double sign = (deflection >= 0.0) ? 1.0 : -1.0;
const TurnDirection direction =
(deflection > 0.0) ? TurnDirection::Left : TurnDirection::Right;
const std::size_t arc_count = params.arcs.size();
const auto curvature = [&](std::size_t k) {
return sign / params.arcs[k].radius.metres();
};
const auto require_positive = [](const std::optional<Length>& length,
const char* message) -> double {
const double value = length->metres();
if (value <= kEpsilon) throw std::invalid_argument{message};
return value;
};
// Heading (deflection) consumed by everything except the last arc's angle:
// the fixed-length arcs plus every transition curve. The last arc absorbs
// whatever remains.
double consumed = 0.0;
std::vector<double> arc_angle(arc_count, 0.0);
for (std::size_t k = 0; k + 1 < arc_count; ++k) {
if (!params.arcs[k].length) {
throw std::invalid_argument{"Every arc except the last needs a length"};
}
const double angle =
require_positive(params.arcs[k].length, "Arc length must be positive") /
params.arcs[k].radius.metres();
arc_angle[k] = angle;
consumed += angle;
}
// Transition heading = mean curvature * length = (1/Ra + 1/Rb)/2 * L.
const double entry_len =
params.entry_transition
? require_positive(params.entry_transition, "Transition length must be positive")
: 0.0;
if (entry_len > 0.0) {
consumed += 0.5 * (1.0 / params.arcs.front().radius.metres()) * entry_len;
}
const double exit_len =
params.exit_transition
? require_positive(params.exit_transition, "Transition length must be positive")
: 0.0;
if (exit_len > 0.0) {
consumed += 0.5 * (1.0 / params.arcs.back().radius.metres()) * exit_len;
}
std::vector<double> between(arc_count, 0.0);
for (std::size_t k = 0; k + 1 < arc_count; ++k) {
if (params.arcs[k].transition_to_next) {
const double length = require_positive(params.arcs[k].transition_to_next,
"Transition length must be positive");
between[k] = length;
consumed += 0.5 *
(1.0 / params.arcs[k].radius.metres() +
1.0 / params.arcs[k + 1].radius.metres()) *
length;
}
}
arc_angle[arc_count - 1] = delta - consumed;
if (arc_angle[arc_count - 1] <= kEpsilon) {
throw std::invalid_argument{
"Arc lengths and transitions exceed the total deflection"};
}
// Build the chain of segments: entry KP?, arc, (KP?, arc)..., exit KP?.
std::vector<Seg> segments;
if (entry_len > 0.0) segments.push_back({0.0, curvature(0), entry_len});
for (std::size_t k = 0; k < arc_count; ++k) {
segments.push_back({curvature(k), curvature(k),
params.arcs[k].radius.metres() * arc_angle[k]});
if (k + 1 < arc_count && between[k] > 0.0) {
segments.push_back({curvature(k), curvature(k + 1), between[k]});
}
}
if (exit_len > 0.0) segments.push_back({curvature(arc_count - 1), 0.0, exit_len});
const ChainClose close = solve_chain(entry, exit, segments);
const CartesianPosition tangent_in = close.tangent_in;
const CartesianPosition tangent_out = close.tangent_out;
// Build the plan elements to splice in, mirroring the segment chain.
std::vector<PlanElement> curve;
if (entry_len > 0.0) {
curve.push_back(TransitionCurve{Length::from_metres(entry_len), std::nullopt,
params.arcs.front().radius, direction});
}
for (std::size_t k = 0; k < arc_count; ++k) {
curve.push_back(
CircularArc{params.arcs[k].radius, direction,
Length::from_metres(params.arcs[k].radius.metres() * arc_angle[k])});
if (k + 1 < arc_count && between[k] > 0.0) {
curve.push_back(TransitionCurve{Length::from_metres(between[k]),
params.arcs[k].radius,
params.arcs[k + 1].radius, direction});
}
}
if (exit_len > 0.0) {
curve.push_back(TransitionCurve{Length::from_metres(exit_len),
params.arcs.back().radius, std::nullopt, direction});
}
return FitResult{
Straight{entry.id(), entry.start(), tangent_in},
std::move(curve),
Straight{exit.id(), tangent_out, exit.end()},
tangent_in,
tangent_out,
direction,
};
}
} // namespace maj0sted::domain

View File

@@ -1,215 +0,0 @@
#include "maj0sted/domain/fitting/gap_fitter.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <numeric>
#include <optional>
#include <utility>
#include <variant>
#include <vector>
#include "maj0sted/domain/fitting/fit_parameters.hpp"
#include "maj0sted/domain/fitting/fitting_service.hpp"
namespace maj0sted::domain {
namespace {
std::optional<Length> optional_length(double metres) {
if (metres > 0.0) return Length::from_metres(metres);
return std::nullopt;
}
// The two straights point in (nearly) opposite directions: the connection is a
// 180° reversal ("nawrót"), not an ordinary corner. Matches fit_uturn's own
// anti-parallel threshold.
bool is_reversal(const Straight& entry, const Straight& exit) {
const double cos_between =
std::cos(entry.azimuth().radians() - exit.azimuth().radians());
return cos_between <= -1.0 + 1e-6;
}
// A tangent point may extend the drawn straight *toward the corner* as far as the
// geometry needs (filling the gap between the finite straight and the corner), but
// must never cross behind the outer, anchored end — that would flip the straight
// around and fling it off-screen. So we only bound the outer side: the point must
// lie on the ray from @p outer through @p corner (t >= 0), with no upper limit.
bool tangent_ok(CartesianPosition p, CartesianPosition outer,
CartesianPosition corner) {
const double dx = corner.x() - outer.x();
const double dy = corner.y() - outer.y();
const double len2 = dx * dx + dy * dy;
if (len2 <= 0.0) return false;
const double t = ((p.x() - outer.x()) * dx + (p.y() - outer.y()) * dy) / len2;
return t >= -1e-3;
}
double deflection(const Straight& entry, const Straight& exit) {
const double a1 = entry.azimuth().radians();
const double a2 = exit.azimuth().radians();
const double d1x = std::sin(a1), d1y = std::cos(a1);
const double d2x = std::sin(a2), d2y = std::cos(a2);
return std::abs(std::atan2(d1x * d2y - d1y * d2x, d1x * d2x + d1y * d2y));
}
// The single place the "lands on both finite straights" invariant is checked.
std::optional<FitResult> attempt_arc(const Straight& entry, const Straight& exit,
double radius,
std::optional<Length> transition) {
if (!(radius > 0.0)) return std::nullopt;
try {
FitResult fit = FittingService::fit_between_straights(
entry, exit, Radius::from_metres(radius), transition);
// The corner is at entry.end() and exit.start() (that is where the two
// straights meet through the fit); the anchored outer ends are the
// opposite endpoints.
if (tangent_ok(fit.tangent_in, entry.start(), entry.end()) &&
tangent_ok(fit.tangent_out, exit.end(), exit.start())) {
return fit;
}
} catch (...) {
}
return std::nullopt;
}
// Smallest radius the geometry admits: two symmetric clothoids consume the whole
// deflection at R = transition / delta, below which the arc angle turns
// negative; a plain arc has only a tiny numerical floor.
double smallest_radius(const Straight& entry, const Straight& exit,
std::optional<Length> transition) {
constexpr double kTiny = 1e-3;
if (!transition) return kTiny;
const double delta = deflection(entry, exit);
if (delta < 1e-3) return kTiny;
return std::max(kTiny, transition->metres() / delta) * (1.0 + 1e-10);
}
// Bisects a fitting radius toward its non-fitting boundary and returns the fit
// at the fitting end. @p fitting fits, @p missing does not.
std::optional<FitResult> refine(const Straight& entry, const Straight& exit,
std::optional<Length> transition, double fitting,
double missing, double& used_radius) {
for (int step = 0; step < 60; ++step) {
const double mid = std::midpoint(fitting, missing);
if (attempt_arc(entry, exit, mid, transition)) {
fitting = mid;
} else {
missing = mid;
}
}
used_radius = fitting;
return attempt_arc(entry, exit, fitting, transition);
}
// The largest radius <= @p requested_radius whose arc fits both straights, or
// nullopt when none does. A fit NEVER enlarges the user's radius: if the request
// is too large to land on the (extendable) straights, we only ever reduce it to
// the largest radius that does. A radius that is smaller than the request already
// fits (a tighter arc just extends the straights toward the corner), so it is
// used verbatim upstream and this reduction path is not even reached.
std::optional<FitResult> fit_nearest_arc(const Straight& entry,
const Straight& exit,
double requested_radius,
std::optional<Length> transition,
double& used_radius) {
const double floor = smallest_radius(entry, exit, transition);
constexpr int kSamples = 4096;
// Scan DOWN from the request for the largest radius that fits, then bisect.
if (requested_radius > floor) {
const double span = requested_radius - floor;
double missing = requested_radius;
for (int i = 1; i <= kSamples; ++i) {
const double r = requested_radius - span * i / kSamples;
if (attempt_arc(entry, exit, r, transition)) {
return refine(entry, exit, transition, r, missing, used_radius);
}
missing = r;
}
}
return std::nullopt;
}
std::optional<GapConnection> connect_uturn(const Straight& entry,
const Straight& exit,
std::optional<Length> transition,
double requested_radius) {
try {
FitResult fit = FittingService::fit_uturn(entry, exit, transition);
double applied = requested_radius;
for (const auto& element : fit.curve) {
if (const auto* arc = std::get_if<CircularArc>(&element)) {
applied = arc->radius().metres();
break;
}
}
return GapConnection{std::move(fit), applied};
} catch (...) {
return std::nullopt;
}
}
std::optional<GapConnection> connect_compound(const Straight& entry,
const Straight& exit,
const CompoundGapParameters& c) {
if (c.arcs.empty()) return std::nullopt;
try {
// Every arc except the last carries its fixed length (which fixes its
// angle) and an optional clothoid to the next; the last arc absorbs the
// remaining deflection. One arc => "łuk + klotoidy", two or more => a
// "łuk koszowy".
std::vector<CompoundArc> arcs;
arcs.reserve(c.arcs.size());
for (std::size_t i = 0; i < c.arcs.size(); ++i) {
const auto& a = c.arcs[i];
if (i + 1 == c.arcs.size()) {
arcs.push_back(CompoundArc{Radius::from_metres(a.radius)});
} else {
arcs.push_back(CompoundArc{Radius::from_metres(a.radius),
Length::from_metres(a.length),
optional_length(a.transition_to_next)});
}
}
FitResult fit = FittingService::fit_compound(
entry, exit,
CompoundFitParameters{
.entry_transition = optional_length(c.entry_transition),
.exit_transition = optional_length(c.exit_transition),
.arcs = std::move(arcs)});
return GapConnection{std::move(fit), c.arcs.front().radius};
} catch (...) {
return std::nullopt;
}
}
} // namespace
std::optional<GapConnection> GapFitter::connect(
const Straight& entry, const Straight& exit,
const GapConnectionRequest& request) {
// A compound curve is taken as requested (its arcs/clothoids are explicit).
if (request.mode == GapConnectionMode::Compound) {
return connect_compound(entry, exit, request.compound);
}
// Plain arc. A 180° reversal is geometry-driven and handled transparently.
if (is_reversal(entry, exit)) {
return connect_uturn(entry, exit, std::nullopt, request.radius);
}
// Use the requested radius verbatim when it fits (a tighter arc fits by
// extending the straights toward the corner). Only when the request is too
// large to land on them do we reduce it to the largest radius that fits — the
// radius is never enlarged.
if (auto fit = attempt_arc(entry, exit, request.radius, std::nullopt)) {
return GapConnection{std::move(*fit), request.radius};
}
double applied = request.radius;
if (auto fit = fit_nearest_arc(entry, exit, request.radius, std::nullopt, applied)) {
return GapConnection{std::move(*fit), applied};
}
return std::nullopt;
}
} // namespace maj0sted::domain

View File

@@ -1,95 +0,0 @@
#include "maj0sted/domain/geometry/horizontal_alignment.hpp"
#include <cmath>
#include <stdexcept>
#include <variant>
namespace maj0sted::domain {
namespace {
// Two anchored endpoints closer than this are treated as touching.
constexpr double kPositionToleranceM = 1e-6;
// Azimuths differing by less than this are treated as equal.
constexpr double kAzimuthToleranceRad = 1e-9;
double distance(CartesianPosition a, CartesianPosition b) noexcept {
const double dx = b.x() - a.x();
const double dy = b.y() - a.y();
return std::sqrt(dx * dx + dy * dy);
}
bool same_arc(const CircularArc& a, const CircularArc& b) noexcept {
return a.direction() == b.direction() && a.radius() == b.radius();
}
void validate_joint(const PlanElement& previous, const PlanElement& current,
JointContinuity joint) {
// Rule: two circular arcs of the same radius and direction must not meet.
if (const auto* a = std::get_if<CircularArc>(&previous)) {
if (const auto* b = std::get_if<CircularArc>(&current)) {
if (same_arc(*a, *b)) {
throw std::logic_error{
"Adjacent circular arcs with the same radius and direction "
"are not allowed"};
}
}
}
const auto* previous_straight = std::get_if<Straight>(&previous);
const auto* current_straight = std::get_if<Straight>(&current);
if (joint == JointContinuity::AzimuthBreak) {
if (previous_straight == nullptr || current_straight == nullptr) {
throw std::logic_error{
"An azimuth break is only allowed between two straights"};
}
if (distance(previous_straight->end(), current_straight->start()) >
kPositionToleranceM) {
throw std::logic_error{"Straights at an azimuth break must touch"};
}
if (previous_straight->azimuth().angular_distance(
current_straight->azimuth()) <= kAzimuthToleranceRad) {
throw std::logic_error{
"An azimuth break requires the two straights to have different "
"azimuths"};
}
return;
}
// Tangent joint. Only straight-to-straight can be verified now; joints that
// involve a floating curve are checked after fitting.
if (previous_straight != nullptr && current_straight != nullptr) {
if (distance(previous_straight->end(), current_straight->start()) >
kPositionToleranceM) {
throw std::logic_error{"Tangent straights must touch"};
}
if (previous_straight->azimuth().angular_distance(
current_straight->azimuth()) > kAzimuthToleranceRad) {
throw std::logic_error{
"Adjacent straights with different azimuths must be marked as an "
"azimuth break"};
}
}
}
} // namespace
void HorizontalAlignment::append(PlanElement element, JointContinuity joint) {
if (!elements_.empty()) {
validate_joint(elements_.back(), element, joint);
joints_.push_back(joint);
}
elements_.push_back(std::move(element));
}
Length HorizontalAlignment::total_length() const {
Length total = Length::zero();
for (const auto& element : elements_) {
if (const auto element_length = length_of(element)) {
total = total + *element_length;
}
}
return total;
}
} // namespace maj0sted::domain

View File

@@ -1,13 +0,0 @@
#include "maj0sted/domain/map_project.hpp"
#include <utility>
namespace maj0sted::domain {
MapProject::MapProject(Crs crs) : crs_{crs} {}
void MapProject::add_niweleta(Niweleta niweleta) {
niwelety_.push_back(std::move(niweleta));
}
} // namespace maj0sted::domain

View File

@@ -1,90 +0,0 @@
#include "maj0sted/domain/niweleta.hpp"
#include <cmath>
#include <cstddef>
#include <stdexcept>
#include <utility>
#include <variant>
namespace maj0sted::domain {
Niweleta::Niweleta(NiweletaId id, std::string name)
: id_{id}, name_{std::move(name)} {}
StraightId Niweleta::next_straight_id() noexcept { return StraightId{++straight_seq_}; }
StraightId Niweleta::add_straight(CartesianPosition start, CartesianPosition end,
JointContinuity joint) {
const StraightId id = next_straight_id();
plan_.append(Straight{id, start, end}, joint);
return id;
}
void Niweleta::add_plan_element(PlanElement element, JointContinuity joint) {
if (auto* straight = std::get_if<Straight>(&element); straight != nullptr) {
if (straight->id().is_null()) {
// A straight added without an identity gets one from the niweleta.
element = Straight{next_straight_id(), straight->start(), straight->end()};
} else if (straight->id().value() > straight_seq_) {
// Keep the counter ahead of any explicitly supplied id (e.g. on load).
straight_seq_ = straight->id().value();
}
}
plan_.append(std::move(element), joint);
}
void Niweleta::apply_fit(const FitResult& result) {
const StraightId entry_id = result.entry.id();
const StraightId exit_id = result.exit.id();
if (entry_id.is_null() || exit_id.is_null()) {
throw std::invalid_argument{"apply_fit: trimmed straights must be identified"};
}
const auto& elements = plan_.elements();
const auto index_of = [&](StraightId id) -> std::size_t {
for (std::size_t k = 0; k < elements.size(); ++k) {
const auto* straight = std::get_if<Straight>(&elements[k]);
if (straight != nullptr && !straight->id().is_null() &&
straight->id() == id) {
return k;
}
}
throw std::invalid_argument{"apply_fit: straight not found in plan"};
};
const std::size_t i = index_of(entry_id);
const std::size_t j = index_of(exit_id);
if (j != i + 1) {
throw std::invalid_argument{
"apply_fit: the two straights must be adjacent (entry then exit)"};
}
// Rebuild the plan, re-validating every joint via append().
HorizontalAlignment rebuilt;
const auto add = [&](PlanElement element, JointContinuity joint) {
rebuilt.append(std::move(element), joint);
};
for (std::size_t k = 0; k < i; ++k) {
add(elements[k], k == 0 ? JointContinuity::Tangent : plan_.joint(k - 1));
}
add(result.entry, i == 0 ? JointContinuity::Tangent : plan_.joint(i - 1));
for (const auto& curve_element : result.curve) {
add(curve_element, JointContinuity::Tangent);
}
add(result.exit, JointContinuity::Tangent);
for (std::size_t k = j + 1; k < elements.size(); ++k) {
add(elements[k], plan_.joint(k - 1));
}
plan_ = std::move(rebuilt);
}
void Niweleta::add_profile_element(ProfileElement element) {
profile_.append(std::move(element));
}
bool Niweleta::lengths_consistent(double tolerance_m) const {
return std::abs(plan_length().metres() - profile_length().metres()) <= tolerance_m;
}
} // namespace maj0sted::domain

View File

@@ -1,83 +0,0 @@
#include "maj0sted/domain/parallelism/parallelism_service.hpp"
#include <cmath>
#include <numbers>
namespace maj0sted::domain {
namespace {
// Unit left-normal of a straight's direction of travel, in (east=x, north=y).
// Direction d = (sin az, cos az); rotating it +90 deg (counter-clockwise) gives
// the left normal (-cos az, sin az).
struct Vec {
double east;
double north;
};
Vec left_normal(Azimuth azimuth) noexcept {
return Vec{-std::cos(azimuth.radians()), std::sin(azimuth.radians())};
}
} // namespace
TrackParallelism ParallelismService::mark_parallel(const Straight& origin,
const Straight& parallel,
TrackOffset offset) {
// The invariants (both identified, distinct) are enforced by TrackParallelism.
return TrackParallelism{origin.id(), parallel.id(), offset};
}
bool ParallelismService::are_parallel(const Straight& a, const Straight& b,
double tolerance_rad) {
const double difference = a.azimuth().angular_distance(b.azimuth()); // [0, pi]
return difference <= tolerance_rad ||
std::abs(difference - std::numbers::pi) <= tolerance_rad;
}
Straight ParallelismService::derive_parallel_line(const Straight& origin,
TrackOffset offset) {
const Vec normal = left_normal(origin.azimuth());
const double sign = (offset.side() == Side::Left) ? 1.0 : -1.0;
const double shift = offset.distance().metres() * sign;
const CartesianPosition start{origin.start().x() + normal.east * shift,
origin.start().y() + normal.north * shift};
const CartesianPosition end{origin.end().x() + normal.east * shift,
origin.end().y() + normal.north * shift};
return Straight{start, end};
}
double ParallelismService::perpendicular_distance(const Straight& origin,
const Straight& other) {
const Vec normal = left_normal(origin.azimuth());
const double d_east = other.start().x() - origin.start().x();
const double d_north = other.start().y() - origin.start().y();
return std::abs(d_east * normal.east + d_north * normal.north);
}
Straight ParallelismService::project_onto_offset_line(const Straight& origin,
double offset_m,
const Straight& segment) {
const double az = origin.azimuth().radians();
const double ux = std::sin(az);
const double uy = std::cos(az);
const Vec normal = left_normal(origin.azimuth());
const double ox = origin.start().x() + normal.east * offset_m;
const double oy = origin.start().y() + normal.north * offset_m;
auto station = [&](double x, double y) {
return (x - ox) * ux + (y - oy) * uy;
};
double t1 = station(segment.start().x(), segment.start().y());
double t2 = station(segment.end().x(), segment.end().y());
if (std::abs(t2 - t1) < 1.0) {
const double tm = 0.5 * (t1 + t2);
const double half = (t2 >= t1) ? 0.5 : -0.5;
t1 = tm - half;
t2 = tm + half;
}
return Straight{CartesianPosition{ox + ux * t1, oy + uy * t1},
CartesianPosition{ox + ux * t2, oy + uy * t2}};
}
} // namespace maj0sted::domain

View File

@@ -1,6 +1,9 @@
#include "maj0sted/domain/geometry/turnout.hpp" #include "maj0sted/domain/geometry/turnout.hpp"
#include <algorithm>
#include <cmath> #include <cmath>
#include <numbers>
#include <vector>
namespace maj0sted::domain { namespace maj0sted::domain {
@@ -19,50 +22,6 @@ double deflection(const TurnoutSegment& segment) {
return 0.5 * (segment.k0 + segment.k1) * segment.length; return 0.5 * (segment.k0 + segment.k1) * segment.length;
} }
// Builds the diverging curve as signed-curvature segments that together turn
// through exactly @p total_angle. Every part but the closing arc is fixed; the
// closing arc's length is chosen to spend whatever deflection is left. Returns
// false when a radius is non-positive or the fixed parts overshoot the angle.
bool build_curve(const DivergingCurve& curve, double total_angle, double sign,
std::vector<TurnoutSegment>& out) {
if (curve.arcs.empty()) return false;
const double first_radius = curve.arcs.front().radius;
const double last_radius = curve.arcs.back().radius;
if (first_radius <= 0.0 || last_radius <= 0.0) return false;
double consumed = 0.0; // deflection of everything before the closing arc
if (curve.entry_transition > 0.0) {
out.push_back({0.0, 0.0, sign / first_radius, curve.entry_transition});
consumed += std::abs(deflection(out.back()));
}
// every arc but the last contributes its fixed length, eased to the next
for (std::size_t i = 0; i + 1 < curve.arcs.size(); ++i) {
const double radius = curve.arcs[i].radius;
if (radius <= 0.0) return false;
out.push_back({0.0, sign / radius, sign / radius, curve.arcs[i].length});
consumed += std::abs(deflection(out.back()));
const double next_radius = curve.arcs[i + 1].radius;
if (curve.arcs[i].transition_to_next > 0.0 && next_radius > 0.0) {
out.push_back({0.0, sign / radius, sign / next_radius,
curve.arcs[i].transition_to_next});
consumed += std::abs(deflection(out.back()));
}
}
const double exit_deflection = 0.5 * curve.exit_transition / last_radius;
const double closing = total_angle - consumed - exit_deflection;
if (closing <= 0.0) return false; // the fixed parts already reach the crossing angle
out.push_back({0.0, sign / last_radius, sign / last_radius, closing * last_radius});
if (curve.exit_transition > 0.0) {
out.push_back({0.0, sign / last_radius, 0.0, curve.exit_transition});
}
return true;
}
// Turns @p pose in place by @p angle (a heading break, no travel). // Turns @p pose in place by @p angle (a heading break, no travel).
Pose turn(const Pose& pose, double angle) { Pose turn(const Pose& pose, double angle) {
if (angle == 0.0) return pose; if (angle == 0.0) return pose;
@@ -91,62 +50,264 @@ void tangent_lengths(const Pose& a, const Pose& b, double& front, double& back)
back = std::hypot(cx - b.x, cy - b.y); back = std::hypot(cx - b.x, cy - b.y);
} }
} // namespace // 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);
}
TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout) { // The pose @p station metres along @p path. Stations are the same whichever frame
TurnoutGeometry geometry; // the path is measured in, because bending moves curvature and never length.
Pose pose_at_station(const std::vector<TurnoutSegment>& path, const Pose& start,
const double angle = turnout.mark.angle(); double station) {
if (angle <= 0.0) return geometry; Pose pose = start;
const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0; double walked = 0.0;
for (const auto& segment : path) {
const double blade = turnout.blade_angle; const Pose from = turn(pose, segment.turn_in);
if (blade < 0.0 || blade >= angle) return geometry; // the blade may not reach the frog on its own if (station <= walked + segment.length) {
return layout_segment(segment.k0, segment.k1, station - walked, from, nullptr);
// odcinek przediglicowy: still along the through track, the blade tip sits at its end }
if (turnout.pre_blade > 0.0) { walked += segment.length;
geometry.path.push_back(TurnoutSegment{0.0, 0.0, 0.0, turnout.pre_blade}); pose = layout_segment(segment.k0, segment.k1, segment.length, from, nullptr);
} }
// the blade breaks away by beta and runs straight to its heel; with no blade return pose;
// length the break still stands and the curve starts at the tip }
if (turnout.blade_length > 0.0) {
geometry.path.push_back(TurnoutSegment{sign * blade, 0.0, 0.0, turnout.blade_length}); // 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;
}
} }
// the curve only has `alfa - beta` left to turn through tangent_out = tangent;
std::vector<TurnoutSegment> curve_path; curvature_out = curvature;
if (!build_curve(turnout.curve, angle - blade, sign, curve_path)) { beta_out = beta;
geometry.path.clear(); return true;
return geometry; }
}
if (turnout.blade_length <= 0.0) curve_path.front().turn_in += sign * blade;
geometry.path.insert(geometry.path.end(), curve_path.begin(), curve_path.end());
// walk the curve to the frog direction (the crossing angle) // 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; Pose pose = start;
for (const auto& segment : geometry.path) { for (const auto& segment : geometry.path) {
pose = advance(segment, pose); 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});
} }
// a turnout always runs on past the curve as a straight frog rail. its length double tangent = 0.0;
// takes the switch to its catalogue length, measured along the through track; double beta = 0.0;
// without a catalogue length a tangent-length rail keeps a frog rail present. if (bend != 0.0) {
const double through_projection = double curvature = 0.0;
(pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy; if (!bend_path(geometry.path, turnout.mark.angle(), sign, bend, tangent, curvature,
const double cos_angle = pose.hx * start.hx + pose.hy * start.hy; beta)) {
double rail = turnout.length > 0.0 && cos_angle > 1e-6 return geometry;
? (turnout.length - through_projection) / cos_angle }
: turnout.curve.arcs.back().radius * std::tan(angle * 0.5); geometry.bend = bend;
if (rail > 0.0) { geometry.bend_angle = beta;
const TurnoutSegment frog_rail{0.0, 0.0, 0.0, rail}; geometry.diverging_curvature = curvature;
geometry.path.push_back(frog_rail); }
pose = advance(frog_rail, pose);
// 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.valid = true;
geometry.frog = pose; geometry.frog = pose;
for (const auto& segment : geometry.path) geometry.diverging_length += segment.length;
tangent_lengths(start, pose, geometry.tangent_front, geometry.tangent_back); 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; return geometry;
} }

View File

@@ -1,13 +0,0 @@
#include "maj0sted/domain/geometry/vertical_alignment.hpp"
namespace maj0sted::domain {
Length VerticalAlignment::total_length() const {
Length total = Length::zero();
for (const auto& element : elements_) {
total = total + length_of(element);
}
return total;
}
} // 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

@@ -1,434 +0,0 @@
#include "maj0sted/editor/editor.hpp"
#include <cmath>
#include <cstdint>
#include <optional>
#include <utility>
#include <variant>
#include <vector>
#include "maj0sted/domain/fitting/gap_fitter.hpp"
#include "maj0sted/domain/geometry/segment_layout.hpp"
#include "maj0sted/domain/geometry/turnout.hpp"
#include "maj0sted/maj0sted.hpp"
#include "maj0sted/render/rail_renderer.hpp"
#include "maj0sted/render/track_renderer.hpp"
// This file is orchestration only: it turns the editor's request DTOs into
// domain calls, samples the solved geometry into rendering-neutral centreline
// elements, and drives a render::TrackRenderer to produce the drawable output.
// All fitting business logic lives in maj0sted::domain::GapFitter; all rail
// geometry lives in maj0sted::render::RailRenderer. Nothing here decides how a
// curve is fitted or how it is drawn.
namespace maj0sted::editor {
using namespace maj0sted::domain;
using maj0sted::domain::geometry::layout_segment;
using maj0sted::domain::geometry::Pose;
using maj0sted::domain::geometry::XY;
namespace {
std::optional<Straight> make_straight(const StraightSpec& s, std::uint64_t id) {
try {
return Straight{StraightId{id}, CartesianPosition{s.x1, s.y1},
CartesianPosition{s.x2, s.y2}};
} catch (...) {
return std::nullopt; // degenerate (zero length)
}
}
// Maps an editor gap-fit DTO onto a domain connection request. Returns nullopt
// for "no fit" (mode 0) or an unknown mode.
std::optional<GapConnectionRequest> to_request(const GapFit& fit) {
GapConnectionRequest request;
switch (fit.mode) {
case 1: // plain arc
// Legacy explicit nawrót (mode 4): treated as a plain-arc request; a
// genuine 180° reversal is then detected from the geometry in GapFitter.
case 4:
request.mode = GapConnectionMode::Arc;
request.radius = fit.radius;
return request;
case 2: // legacy "łuk + klotoidy" -> compound with a single eased arc
request.mode = GapConnectionMode::Compound;
request.compound.entry_transition = fit.transition;
request.compound.exit_transition = fit.transition;
request.compound.arcs.push_back(CompoundGapArc{fit.radius, 0.0, 0.0});
return request;
case 3: // unified compound: any number of arcs eased by clothoids
request.mode = GapConnectionMode::Compound;
request.compound.entry_transition = fit.entry_t;
request.compound.exit_transition = fit.exit_t;
if (!fit.arcs.empty()) {
for (const auto& a : fit.arcs) {
request.compound.arcs.push_back(
CompoundGapArc{a.radius, a.length, a.transition_to_next});
}
} else if (fit.r2 > 0.0) { // legacy two-arc basket
request.compound.arcs.push_back(
CompoundGapArc{fit.r1, fit.arc1_len, fit.between});
request.compound.arcs.push_back(CompoundGapArc{fit.r2, 0.0, 0.0});
} else { // legacy single arc
request.compound.arcs.push_back(CompoundGapArc{fit.r1, 0.0, 0.0});
}
return request;
default:
return std::nullopt;
}
}
double radius_of(double curvature) {
return std::abs(curvature) > 1e-12 ? 1.0 / std::abs(curvature) : 0.0;
}
// Describes one domain plan element as sampling parameters for layout_segment,
// returning its render kind. A circular arc has constant curvature; a clothoid
// varies linearly; a straight has zero curvature.
render::ElementKind sample_params(const PlanElement& element, double& k0, double& k1,
double& length) {
if (const auto* arc = std::get_if<CircularArc>(&element)) {
const double sign = arc->direction() == TurnDirection::Left ? 1.0 : -1.0;
k0 = k1 = sign / arc->radius().metres();
length = arc->length() ? arc->length()->metres() : 0.0;
return render::ElementKind::Arc;
}
const auto& transition = std::get<TransitionCurve>(element);
const double sign = transition.direction() == TurnDirection::Left ? 1.0 : -1.0;
k0 = transition.start_radius() ? sign / transition.start_radius()->metres() : 0.0;
k1 = transition.end_radius() ? sign / transition.end_radius()->metres() : 0.0;
length = transition.length().metres();
return render::ElementKind::Transition;
}
// Samples the solved niweleta (trimmed straights + fitted curves) into
// rendering-neutral centreline elements, and records the trimmed straights.
std::vector<render::CentrelineElement> sample_centrelines(
const NiweletaSpec& spec, const std::vector<std::optional<Straight>>& straights,
const std::vector<std::optional<FitResult>>& fits,
std::vector<StraightSpec>& rendered_straights) {
std::vector<render::CentrelineElement> axis;
const std::size_t n = straights.size();
for (std::size_t i = 0; i < n; ++i) {
if (!straights[i]) continue;
// Trim the straight to the tangent points of its adjacent fits.
CartesianPosition a = straights[i]->start();
CartesianPosition b = straights[i]->end();
if (i > 0 && fits[i - 1]) a = fits[i - 1]->tangent_out;
if (i < fits.size() && fits[i]) b = fits[i]->tangent_in;
rendered_straights[i] =
StraightSpec{a.x(), a.y(), b.x(), b.y(), spec.straights[i].hidden};
if (!spec.straights[i].hidden) {
render::CentrelineElement straight_element;
straight_element.kind = render::ElementKind::Straight;
straight_element.straight_index = static_cast<int>(i);
straight_element.length = std::hypot(b.x() - a.x(), b.y() - a.y());
straight_element.points = {render::Point{a.x(), a.y()},
render::Point{b.x(), b.y()}};
axis.push_back(std::move(straight_element));
}
if (i < fits.size() && fits[i]) {
Pose pose{fits[i]->tangent_in.x(), fits[i]->tangent_in.y(),
std::sin(straights[i]->azimuth().radians()),
std::cos(straights[i]->azimuth().radians())};
int element_index = 0;
for (const auto& element : fits[i]->curve) {
double k0 = 0.0, k1 = 0.0, length = 0.0;
render::CentrelineElement curve_element;
curve_element.kind = sample_params(element, k0, k1, length);
std::vector<XY> pts;
pose = layout_segment(k0, k1, length, pose, &pts);
curve_element.points.reserve(pts.size());
for (const auto& p : pts) {
curve_element.points.push_back(render::Point{p.x, p.y});
}
curve_element.gap = static_cast<int>(i);
curve_element.element_index = element_index++;
curve_element.length = length;
curve_element.radius_start = radius_of(k0);
curve_element.radius_end = radius_of(k1);
axis.push_back(std::move(curve_element));
}
}
}
return axis;
}
// Renders the centreline elements to PlanPolylines through the renderer
// interface. A different render::TrackRenderer could be dropped in here without
// touching anything above.
std::vector<PlanPolyline> render_rails(
const std::vector<render::CentrelineElement>& axis) {
render::RailRenderer rail_renderer;
render::TrackRenderer& renderer = rail_renderer;
for (const auto& element : axis) renderer.add(element);
std::vector<PlanPolyline> polylines;
polylines.reserve(rail_renderer.rails().size());
for (const auto& rail : rail_renderer.rails()) {
PlanPolyline poly;
poly.kind = static_cast<int>(rail.kind);
poly.straight_index = rail.straight_index;
poly.gap = rail.gap;
poly.element_index = rail.element_index;
poly.length = rail.length;
poly.radius_start = rail.radius_start;
poly.radius_end = rail.radius_end;
poly.points.reserve(rail.points.size());
for (const auto& p : rail.points) poly.points.push_back(PlanPoint{p.x, p.y});
polylines.push_back(std::move(poly));
}
return polylines;
}
NiweletaPolys build(const NiweletaSpec& spec) {
const std::size_t n = spec.straights.size();
std::vector<std::optional<Straight>> straights(n);
for (std::size_t i = 0; i < n; ++i) {
straights[i] = make_straight(spec.straights[i], i + 1);
}
NiweletaPolys result;
result.rendered_straights = spec.straights;
// 1. Business: connect each requested gap. The domain decides the curve type
// and the radius actually used; a gap that admits no fit is simply left
// out (the library, not the host, turns a failed fit into "no fit"). Only
// fits that succeeded are echoed in applied_fits for the host to mirror.
std::vector<std::optional<FitResult>> fits(n == 0 ? 0 : n - 1);
for (const auto& gap : spec.fits) {
const int g = gap.gap;
if (g < 0 || static_cast<std::size_t>(g) + 1 >= n) continue;
if (!straights[g] || !straights[g + 1]) continue;
const auto request = to_request(gap);
if (!request) continue;
const auto connection =
GapFitter::connect(*straights[g], *straights[g + 1], *request);
if (!connection) continue;
fits[g] = connection->fit;
GapFit applied = gap;
applied.radius = connection->applied_radius;
result.applied_fits.push_back(applied);
}
// 2. Geometry: sample trimmed straights and fitted curves into a neutral,
// renderer-agnostic centreline.
const auto axis =
sample_centrelines(spec, straights, fits, result.rendered_straights);
// Flatten the axis into one polyline for station lookups, dropping the join
// points each element repeats at its start.
for (const auto& element : axis) {
for (const auto& p : element.points) {
if (!result.centreline.empty()) {
const auto& last = result.centreline.back();
if (std::hypot(p.x - last.x, p.y - last.y) < 1e-9) continue;
}
result.centreline.push_back(PlanPoint{p.x, p.y});
}
}
// 3. Presentation: hand the centreline to a renderer.
result.polylines = render_rails(axis);
return result;
}
// The pose (position + unit heading) at arc length @p station along a centreline
// polyline. Clamped to the ends; heading comes from the segment the station falls
// on. Returns false only for a centreline too short to have a direction.
bool pose_at(const std::vector<PlanPoint>& centreline, double station, Pose& out) {
if (centreline.size() < 2) return false;
double travelled = 0.0;
for (std::size_t i = 0; i + 1 < centreline.size(); ++i) {
const auto& a = centreline[i];
const auto& b = centreline[i + 1];
const double dx = b.x - a.x;
const double dy = b.y - a.y;
const double seg = std::hypot(dx, dy);
if (seg < 1e-12) continue;
// the last segment also catches any station beyond the centreline's end
if (station <= travelled + seg || i + 2 == centreline.size()) {
const double t = std::clamp((station - travelled) / seg, 0.0, 1.0);
out = Pose{a.x + dx * t, a.y + dy * t, dx / seg, dy / seg};
return true;
}
travelled += seg;
}
return false;
}
// Translates the editor's switch DTO into a domain Turnout: a plain-arc curve
// (mode 1) or a basket of arcs eased by clothoids (mode 3). All the turnout
// business — turning to the crossing angle, closing the last arc, the frog rail —
// lives in the domain; nothing here decides geometry.
domain::Turnout to_turnout(const Junction& junction) {
domain::DivergingCurve curve;
if (junction.curve.mode == 3 && junction.curve.arcs.size() >= 2) {
for (const auto& arc : junction.curve.arcs) {
curve.arcs.push_back(
domain::TurnoutArc{arc.radius, arc.length, arc.transition_to_next});
}
curve.entry_transition = junction.curve.entry_t;
curve.exit_transition = junction.curve.exit_t;
} else {
curve.arcs.push_back(domain::TurnoutArc{junction.curve.radius, 0.0, 0.0});
}
return domain::Turnout{
domain::CrossingMark{junction.crossing_n},
junction.side == 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right,
std::move(curve), junction.length,
junction.pre_blade, junction.blade_angle,
junction.blade_length};
}
// The drawable geometry of one switch. @p station is the switch's start on the
// through track (początek rozjazdu) — the point the user clicks. The diverging
// path and frog come from the domain; here we only place them on the through
// track and sample them into rails.
JunctionGeom solve_junction(const Junction& junction,
const std::vector<NiweletaPolys>& solved) {
JunctionGeom geom;
if (junction.through < 0 ||
static_cast<std::size_t>(junction.through) >= solved.size()) {
return geom;
}
Pose start{};
if (!pose_at(solved[junction.through].centreline, junction.station, start)) {
return geom;
}
// a trailing switch opens against the running direction: flip the heading
if (!junction.facing) {
start.hx = -start.hx;
start.hy = -start.hy;
}
const auto turnout = domain::lay_turnout(start, to_turnout(junction));
if (!turnout.valid) return geom;
// sample the domain segments into drawable rails
std::vector<render::CentrelineElement> axis;
Pose pose = start;
for (const auto& segment : turnout.path) {
std::vector<XY> pts;
render::CentrelineElement element;
element.kind =
std::abs(segment.k1 - segment.k0) >= 1e-12 ? render::ElementKind::Transition
: std::abs(segment.k0) >= 1e-12 ? render::ElementKind::Arc
: render::ElementKind::Straight;
// the blade's heading break happens in place, before the segment is laid
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 = layout_segment(segment.k0, segment.k1, segment.length, pose, &pts);
element.points.reserve(pts.size());
for (const auto& p : pts) element.points.push_back(render::Point{p.x, p.y});
element.length = segment.length;
element.radius_start = radius_of(segment.k0);
element.radius_end = radius_of(segment.k1);
axis.push_back(std::move(element));
}
geom.valid = true;
geom.px = start.x;
geom.py = start.y;
geom.fx = turnout.frog.x;
geom.fy = turnout.frog.y;
geom.fhx = turnout.frog.hx;
geom.fhy = turnout.frog.hy;
geom.tangent_front = turnout.tangent_front;
geom.tangent_back = turnout.tangent_back;
geom.length = turnout.diverging_length;
geom.polylines = render_rails(axis);
return geom;
}
// Rigidly repositions a whole branch niweleta so its leaving straight starts at
// the frog and runs along the frog heading. The branch becomes tangent to the
// switch curve however it was drawn, and — crucially — every straight appended
// behind the leaving straight rides along under the SAME rigid motion, so the
// shape of those fragments (and the fits between them) is preserved. Rewriting
// only the first straight would snap it to the frog while the appended fragments
// stayed where they were drawn, so their gap fits would be solved against a
// straight that had jumped out from under them.
void pin_branch_to_frog(NiweletaSpec& branch, const JunctionGeom& frog) {
if (branch.straights.empty()) return;
// The leaving straight fixes the rigid motion: its start maps to the frog and
// its drawn direction rotates onto the frog heading. A leaving straight too
// short to have a direction contributes no rotation.
const StraightSpec& leaving = branch.straights.front();
const double anchor_x = leaving.x1;
const double anchor_y = leaving.y1;
const double dx = leaving.x2 - leaving.x1;
const double dy = leaving.y2 - leaving.y1;
const double drawn_length = std::hypot(dx, dy);
double cos_r = 1.0;
double sin_r = 0.0;
if (drawn_length > 1e-9) {
const double hx = dx / drawn_length;
const double hy = dy / drawn_length;
cos_r = hx * frog.fhx + hy * frog.fhy; // drawn direction · frog heading
sin_r = hx * frog.fhy - hy * frog.fhx; // drawn direction × frog heading
}
const auto move = [&](double& x, double& y) {
const double vx = x - anchor_x;
const double vy = y - anchor_y;
x = frog.fx + cos_r * vx - sin_r * vy;
y = frog.fy + sin_r * vx + cos_r * vy;
};
for (auto& straight : branch.straights) {
move(straight.x1, straight.y1);
move(straight.x2, straight.y2);
}
}
} // namespace
std::vector<NiweletaPolys> solve_project(const std::vector<NiweletaSpec>& niwelety) {
std::vector<NiweletaPolys> result;
result.reserve(niwelety.size());
for (const auto& spec : niwelety) result.push_back(build(spec));
return result;
}
LayoutSolution solve_layout(std::vector<NiweletaSpec> niwelety,
const std::vector<Junction>& junctions) {
// Pass 1: solve unpinned, to read the through centrelines the frogs sit on.
auto solved = solve_project(niwelety);
// Pin each branch to its frog. The whole branch is moved rigidly so its
// leaving straight starts at the frog and runs along the frog heading (tangent
// to the switch curve), carrying every fragment appended behind it along
// unchanged — so any fit in the branch still meets the switch tangentially.
for (const auto& junction : junctions) {
const auto geom = solve_junction(junction, solved);
if (!geom.valid) continue;
if (junction.branch < 0 ||
static_cast<std::size_t>(junction.branch) >= niwelety.size()) {
continue;
}
pin_branch_to_frog(niwelety[junction.branch], geom);
}
// Pass 2: solve with the branches pinned, then read the switches off the
// final centrelines (the through track may itself be a pinned branch).
LayoutSolution out;
out.niwelety = solve_project(niwelety);
out.junctions.reserve(junctions.size());
for (const auto& junction : junctions) {
out.junctions.push_back(solve_junction(junction, out.niwelety));
}
return out;
}
} // namespace maj0sted::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

@@ -1,55 +0,0 @@
#include "maj0sted/editor/ribbon.hpp"
#include <cmath>
#include "maj0sted/domain/geometry/segment_layout.hpp"
namespace maj0sted::editor {
using maj0sted::domain::geometry::layout_segment;
using maj0sted::domain::geometry::Pose;
using maj0sted::domain::geometry::XY;
std::vector<PlanPolyline> solve_ribbon(const RibbonRequest& request) {
std::vector<PlanPolyline> result;
result.reserve(request.elements.size());
Pose pose{request.x0, request.y0, std::sin(request.az0), std::cos(request.az0)};
for (const auto& element : request.elements) {
double k0 = 0.0;
double k1 = 0.0;
double length = 0.0;
int kind = element.kind;
if (element.kind == 1) { // arc
const double sign = element.dir >= 0 ? 1.0 : -1.0;
const double k = element.radius > 0.0 ? sign / element.radius : 0.0;
k0 = k;
k1 = k;
length = element.radius * element.angle;
} else if (element.kind == 2) { // spiral (clothoid)
const double sign = element.dir >= 0 ? 1.0 : -1.0;
k0 = element.radius_start > 0.0 ? sign / element.radius_start : 0.0;
k1 = element.radius > 0.0 ? sign / element.radius : 0.0;
length = element.length;
} else { // line
kind = 0;
length = element.length;
}
std::vector<XY> points;
pose = layout_segment(k0, k1, length, pose, &points);
PlanPolyline polyline;
polyline.kind = kind;
polyline.points.reserve(points.size());
for (const auto& p : points) {
polyline.points.push_back(PlanPoint{p.x, p.y});
}
result.push_back(std::move(polyline));
}
return result;
}
} // namespace maj0sted::editor

View File

@@ -0,0 +1,481 @@
#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;
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;
}
const 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);
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 (has_head) {
// A branch pinned to a turnout's port and starting mid-curve: its first
// straight is wherever the frog and that curve put it, so there is nothing
// here that may move.
why = "tor jest przypięty do rozjazdu i zaczyna się łukiem — nie ma czego dopasować "
"bez odrywania go od krzyżownicy";
return false;
} else if (std::hypot(begins.x - skeleton.start_x, begins.y - skeleton.start_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

@@ -1,98 +0,0 @@
#include "maj0sted/editor/solve.hpp"
#include <optional>
#include "maj0sted/maj0sted.hpp"
#include "maj0sted/render/plan_sampler.hpp"
namespace maj0sted::editor {
using namespace maj0sted::domain;
namespace {
std::optional<Length> optional_length(double metres) {
if (metres > 0.0) return Length::from_metres(metres);
return std::nullopt;
}
FitResult make_fit(const Niweleta& niweleta, StraightId entry, StraightId exit,
const SolveRequest& request) {
if (request.mode == 2) {
return FittingService::fit_compound(
niweleta,
CompoundFitParameters{
.entry = entry,
.exit = exit,
.entry_transition = optional_length(request.entry_t),
.exit_transition = optional_length(request.exit_t),
.arcs = {CompoundArc{Radius::from_metres(request.r1),
Length::from_metres(request.arc1_len),
optional_length(request.between)},
CompoundArc{Radius::from_metres(request.r2)}}});
}
return FittingService::fit_between_straights(
niweleta,
FitParameters{.entry = entry,
.exit = exit,
.radius = Radius::from_metres(request.radius),
.transition_length = request.mode == 1
? optional_length(request.transition)
: std::nullopt});
}
std::vector<PlanPolyline> to_polylines(const render::Scene& scene) {
std::vector<PlanPolyline> result;
result.reserve(scene.polylines.size());
for (const auto& polyline : scene.polylines) {
PlanPolyline out;
out.kind = static_cast<int>(polyline.kind);
out.points.reserve(polyline.points.size());
for (const auto& p : polyline.points) {
out.points.push_back(PlanPoint{p.x, p.y});
}
result.push_back(std::move(out));
}
return result;
}
} // namespace
std::vector<PlanPolyline> solve_scene(const SolveRequest& request) {
Niweleta niweleta{NiweletaId{1}};
const CartesianPosition a{request.ax, request.ay};
const CartesianPosition b{request.bx, request.by};
const CartesianPosition c{request.cx, request.cy};
StraightId entry;
try {
entry = niweleta.add_straight(a, b);
} catch (...) {
return {}; // degenerate entry straight
}
bool has_exit = false;
StraightId exit;
try {
exit = niweleta.add_straight(b, c, JointContinuity::AzimuthBreak);
has_exit = true;
} catch (...) {
try {
exit = niweleta.add_straight(b, c, JointContinuity::Tangent);
has_exit = true;
} catch (...) {
}
}
if (has_exit) {
try {
niweleta.apply_fit(make_fit(niweleta, entry, exit, request));
} catch (...) {
// Parameters don't admit a fit — show the bare straights instead.
}
}
return to_polylines(render::sample(niweleta.plan()));
}
} // namespace maj0sted::editor

View File

@@ -0,0 +1,544 @@
#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 = 6;
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";
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 != 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";
}
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

@@ -1,291 +0,0 @@
#include "maj0sted/io/project_serializer.hpp"
#include <cctype>
#include <charconv>
#include <cstdint>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <system_error>
#include <variant>
#include <vector>
#include "maj0sted/maj0sted.hpp"
namespace maj0sted::io {
using namespace maj0sted::domain;
namespace {
template <class... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
template <class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
// --- writing -------------------------------------------------------------
// Shortest round-trippable, locale-independent representation of a double.
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);
}
char dir_char(TurnDirection direction) {
return direction == TurnDirection::Left ? 'L' : 'R';
}
char joint_char(JointContinuity joint) {
return joint == JointContinuity::AzimuthBreak ? 'B' : 'T';
}
char curvature_char(VerticalCurvature curvature) {
return curvature == VerticalCurvature::Crest ? 'C' : 'S';
}
std::string optional_metres(const std::optional<Length>& length) {
return length ? num(length->metres()) : std::string{"-"};
}
std::string optional_metres(const std::optional<Radius>& radius) {
return radius ? num(radius->metres()) : std::string{"-"};
}
// --- reading -------------------------------------------------------------
std::vector<std::string> tokenize(const std::string& line) {
std::vector<std::string> tokens;
std::size_t i = 0;
const std::size_t n = line.size();
while (i < n) {
while (i < n && std::isspace(static_cast<unsigned char>(line[i]))) ++i;
std::size_t j = i;
while (j < n && !std::isspace(static_cast<unsigned char>(line[j]))) ++j;
if (j > i) tokens.push_back(line.substr(i, j - i));
i = j;
}
return tokens;
}
double to_double(const std::string& text) {
double value{};
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{}) {
throw std::runtime_error{"maj0sted: bad number '" + text + "'"};
}
return value;
}
std::uint64_t to_u64(const std::string& text) {
std::uint64_t value{};
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{}) {
throw std::runtime_error{"maj0sted: bad integer '" + text + "'"};
}
return value;
}
TurnDirection to_direction(const std::string& text) {
if (text == "L") return TurnDirection::Left;
if (text == "R") return TurnDirection::Right;
throw std::runtime_error{"maj0sted: bad direction '" + text + "'"};
}
JointContinuity to_joint(const std::string& text) {
if (text == "T") return JointContinuity::Tangent;
if (text == "B") return JointContinuity::AzimuthBreak;
throw std::runtime_error{"maj0sted: bad joint '" + text + "'"};
}
VerticalCurvature to_curvature(const std::string& text) {
if (text == "C") return VerticalCurvature::Crest;
if (text == "S") return VerticalCurvature::Sag;
throw std::runtime_error{"maj0sted: bad curvature '" + text + "'"};
}
std::optional<Length> to_optional_length(const std::string& text) {
if (text == "-") return std::nullopt;
return Length::from_metres(to_double(text));
}
std::optional<Radius> to_optional_radius(const std::string& text) {
if (text == "-") return std::nullopt;
return Radius::from_metres(to_double(text));
}
} // namespace
std::string serialize(const MapProject& project) {
std::string out;
out += "maj0sted 1\n";
out += "crs " + std::to_string(project.crs().epsg()) + "\n";
out += "niwelety " + std::to_string(project.niwelety().size()) + "\n";
for (const auto& niweleta : project.niwelety()) {
out += "niweleta " + std::to_string(niweleta.id().value()) + "\n";
out += "name " + niweleta.name() + "\n";
const auto& plan = niweleta.plan();
out += "plan " + std::to_string(plan.size()) + "\n";
for (std::size_t k = 0; k < plan.elements().size(); ++k) {
const char joint = (k == 0) ? 'T' : joint_char(plan.joint(k - 1));
std::visit(
overloaded{
[&](const Straight& s) {
out += "straight " + std::to_string(s.id().value()) + " " +
num(s.start().x()) + " " + num(s.start().y()) + " " +
num(s.end().x()) + " " + num(s.end().y()) + " " + joint +
"\n";
},
[&](const CircularArc& a) {
out += "arc " + num(a.radius().metres()) + " " +
dir_char(a.direction()) + " " + optional_metres(a.length()) +
" " + joint + "\n";
},
[&](const TransitionCurve& t) {
out += "transition " + num(t.length().metres()) + " " +
optional_metres(t.start_radius()) + " " +
optional_metres(t.end_radius()) + " " +
dir_char(t.direction()) + " " + joint + "\n";
},
},
plan.elements()[k]);
}
const auto& profile = niweleta.profile();
out += "profile " + std::to_string(profile.size()) + "\n";
for (const auto& element : profile.elements()) {
std::visit(
overloaded{
[&](const Grade& g) {
out += "grade " + num(g.length().metres()) + " " +
num(g.slope_permille()) + "\n";
},
[&](const VerticalCurve& v) {
out += "vcurve " + num(v.length().metres()) + " " +
num(v.radius().metres()) + " " +
curvature_char(v.curvature()) + "\n";
},
},
element);
}
}
return out;
}
MapProject deserialize(const std::string& text) {
// Split into non-blank lines, stripping any trailing '\r'.
std::vector<std::string> lines;
{
std::istringstream in(text);
std::string line;
while (std::getline(in, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
bool only_ws = true;
for (const char c : line) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) lines.push_back(line);
}
}
std::size_t cursor = 0;
const auto next_line = [&]() -> const std::string& {
if (cursor >= lines.size()) {
throw std::runtime_error{"maj0sted: unexpected end of data"};
}
return lines[cursor++];
};
const auto expect = [&](const std::vector<std::string>& tokens, const char* tag,
std::size_t min_size) {
if (tokens.size() < min_size || tokens[0] != tag) {
throw std::runtime_error{std::string{"maj0sted: expected '"} + tag + "'"};
}
};
{
const auto header = tokenize(next_line());
expect(header, "maj0sted", 2);
}
const auto crs_line = tokenize(next_line());
expect(crs_line, "crs", 2);
const Crs crs{static_cast<int>(to_u64(crs_line[1]))};
const auto count_line = tokenize(next_line());
expect(count_line, "niwelety", 2);
const std::size_t niweleta_count = static_cast<std::size_t>(to_u64(count_line[1]));
MapProject project{crs};
for (std::size_t index = 0; index < niweleta_count; ++index) {
const auto header = tokenize(next_line());
expect(header, "niweleta", 2);
const NiweletaId id{to_u64(header[1])};
const std::string& name_line = next_line();
if (name_line.rfind("name", 0) != 0) {
throw std::runtime_error{"maj0sted: expected 'name'"};
}
std::string name = name_line.size() > 5 ? name_line.substr(5) : std::string{};
Niweleta niweleta{id, std::move(name)};
const auto plan_line = tokenize(next_line());
expect(plan_line, "plan", 2);
const std::size_t plan_count = static_cast<std::size_t>(to_u64(plan_line[1]));
for (std::size_t k = 0; k < plan_count; ++k) {
const auto e = tokenize(next_line());
if (e.empty()) throw std::runtime_error{"maj0sted: empty plan element"};
if (e[0] == "straight") {
expect(e, "straight", 7);
niweleta.add_plan_element(
Straight{StraightId{to_u64(e[1])},
CartesianPosition{to_double(e[2]), to_double(e[3])},
CartesianPosition{to_double(e[4]), to_double(e[5])}},
to_joint(e[6]));
} else if (e[0] == "arc") {
expect(e, "arc", 5);
niweleta.add_plan_element(
CircularArc{Radius::from_metres(to_double(e[1])), to_direction(e[2]),
to_optional_length(e[3])},
to_joint(e[4]));
} else if (e[0] == "transition") {
expect(e, "transition", 6);
niweleta.add_plan_element(
TransitionCurve{Length::from_metres(to_double(e[1])),
to_optional_radius(e[2]), to_optional_radius(e[3]),
to_direction(e[4])},
to_joint(e[5]));
} else {
throw std::runtime_error{"maj0sted: unknown plan element '" + e[0] + "'"};
}
}
const auto profile_line = tokenize(next_line());
expect(profile_line, "profile", 2);
const std::size_t profile_count =
static_cast<std::size_t>(to_u64(profile_line[1]));
for (std::size_t k = 0; k < profile_count; ++k) {
const auto e = tokenize(next_line());
if (e.empty()) throw std::runtime_error{"maj0sted: empty profile element"};
if (e[0] == "grade") {
expect(e, "grade", 3);
niweleta.add_profile_element(
Grade{Length::from_metres(to_double(e[1])), to_double(e[2])});
} else if (e[0] == "vcurve") {
expect(e, "vcurve", 4);
niweleta.add_profile_element(
VerticalCurve{Length::from_metres(to_double(e[1])),
Radius::from_metres(to_double(e[2])),
to_curvature(e[3])});
} else {
throw std::runtime_error{"maj0sted: unknown profile element '" + e[0] +
"'"};
}
}
project.add_niweleta(std::move(niweleta));
}
return project;
}
} // namespace maj0sted::io

View File

@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstdint>
#include <numbers> #include <numbers>
#include <sstream> #include <sstream>
#include <vector> #include <vector>
@@ -9,15 +10,13 @@
namespace maj0sted::io { namespace maj0sted::io {
namespace { namespace {
using maj0sted::editor::NiweletaPolys;
using maj0sted::editor::PlanPoint; using maj0sted::editor::PlanPoint;
using maj0sted::editor::PlanPolyline; using maj0sted::editor::SolvedElement;
using maj0sted::editor::SolvedTrack;
struct AxisElement { struct AxisElement {
int kind{0}; int kind{0};
int straight_index{-1}; std::uint32_t element_id{0};
int gap{-1};
int element_index{-1};
double length{0.0}; double length{0.0};
double radius_start{0.0}; double radius_start{0.0};
double radius_end{0.0}; double radius_end{0.0};
@@ -30,49 +29,25 @@ struct WorldXYZ {
double z{0.0}; double z{0.0};
}; };
std::vector<AxisElement> recover_axis(const NiweletaPolys& solved) { /// The solved elements, in the shape the emitter below wants. Nothing is
/// recovered or averaged: the solution already holds the axis.
std::vector<AxisElement> axis_of(const SolvedTrack& track) {
const auto radius_of = [](double k) {
return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0;
};
std::vector<AxisElement> out; std::vector<AxisElement> out;
const auto& polys = solved.polylines; out.reserve(track.elements.size());
for (std::size_t i = 0; i < polys.size();) { for (const SolvedElement& element : track.elements) {
const auto& a = polys[i]; AxisElement axis;
if (a.points.size() < 2) { axis.kind = element.kind == maj0sted::editor::Kind::Line ? 0
++i; : element.kind == maj0sted::editor::Kind::Arc ? 1
continue; : 2;
} axis.element_id = static_cast<std::uint32_t>(element.id);
if (i + 1 < polys.size()) { axis.length = element.length;
const auto& b = polys[i + 1]; axis.radius_start = radius_of(element.k0);
if (b.straight_index == a.straight_index && b.gap == a.gap && axis.radius_end = radius_of(element.k1);
b.element_index == a.element_index && axis.points = element.points;
b.points.size() == a.points.size()) { out.push_back(std::move(axis));
AxisElement el;
el.kind = a.kind;
el.straight_index = a.straight_index;
el.gap = a.gap;
el.element_index = a.element_index;
el.length = a.length;
el.radius_start = a.radius_start;
el.radius_end = a.radius_end;
el.points.resize(a.points.size());
for (std::size_t k = 0; k < a.points.size(); ++k) {
el.points[k] = {(a.points[k].x + b.points[k].x) * 0.5,
(a.points[k].y + b.points[k].y) * 0.5};
}
out.push_back(std::move(el));
i += 2;
continue;
}
}
AxisElement el;
el.kind = a.kind;
el.straight_index = a.straight_index;
el.gap = a.gap;
el.element_index = a.element_index;
el.length = a.length;
el.radius_start = a.radius_start;
el.radius_end = a.radius_end;
el.points = a.points;
out.push_back(std::move(el));
++i;
} }
return out; return out;
} }
@@ -215,29 +190,29 @@ void write_scn_track(std::ostream& out, const std::string& name, const WorldXYZ&
} // namespace } // namespace
ScnExportOptions resolve_scn_origin(const std::vector<NiweletaPolys>& solved, ScnExportOptions resolve_scn_origin(const maj0sted::editor::Solution& solution,
ScnExportOptions options) { ScnExportOptions options) {
if (std::abs(options.origin_east) >= 1.0 || if (std::abs(options.origin_east) >= 1.0 ||
std::abs(options.origin_north) >= 1.0) { std::abs(options.origin_north) >= 1.0) {
return options; return options;
} }
for (const auto& s : solved) { for (const auto& track : solution.tracks) {
const auto axis = recover_axis(s); if (track.elements.empty() || track.elements.front().points.empty()) {
if (!axis.empty() && !axis.front().points.empty()) { continue;
const auto& p = axis.front().points.front();
if (std::abs(p.x) > 10000.0 || std::abs(p.y) > 10000.0) {
options.origin_east = p.x;
options.origin_north = p.y;
}
break;
} }
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; return options;
} }
ScnExportResult export_scn(const std::vector<NiweletaPolys>& solved, ScnExportResult export_scn(const maj0sted::editor::Solution& solution,
const ScnExportOptions& options, std::ostream& out) { const ScnExportOptions& options, std::ostream& out) {
const auto opt = resolve_scn_origin(solved, options); const auto opt = resolve_scn_origin(solution, options);
ScnExportResult result; ScnExportResult result;
result.origin_east = opt.origin_east; result.origin_east = opt.origin_east;
result.origin_north = opt.origin_north; result.origin_north = opt.origin_north;
@@ -248,8 +223,9 @@ ScnExportResult export_scn(const std::vector<NiweletaPolys>& solved,
const double kRailY = opt.rail_y; const double kRailY = opt.rail_y;
const double kMaxArcAngle = opt.max_arc_angle; const double kMaxArcAngle = opt.max_arc_angle;
for (std::size_t n = 0; n < solved.size(); ++n) { for (const auto& track : solution.tracks) {
const auto axis = recover_axis(solved[n]); const auto track_id = static_cast<std::uint32_t>(track.id);
const auto axis = axis_of(track);
for (std::size_t ei = 0; ei < axis.size(); ++ei) { for (std::size_t ei = 0; ei < axis.size(); ++ei) {
const auto& el = axis[ei]; const auto& el = axis[ei];
if (el.points.size() < 2) { if (el.points.size() < 2) {
@@ -315,13 +291,8 @@ ScnExportResult export_scn(const std::vector<NiweletaPolys>& solved,
} }
std::ostringstream name; std::ostringstream name;
name << "plan_n" << n; name << "plan_t" << track_id << "_e" << el.element_id << '_' << tag
if (el.straight_index >= 0) { << '_' << result.tracks;
name << "_s" << el.straight_index;
} else {
name << "_g" << el.gap << "_e" << el.element_index;
}
name << '_' << tag << '_' << result.tracks;
const auto track_name = name.str(); const auto track_name = name.str();
if (result.first_track_name.empty()) { if (result.first_track_name.empty()) {
result.first_track_name = track_name; result.first_track_name = track_name;

View File

@@ -1,63 +0,0 @@
#include "maj0sted/io/svg_writer.hpp"
#include <charconv>
#include <string>
namespace maj0sted::io {
namespace {
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);
}
const char* colour(render::ElementKind kind) {
switch (kind) {
case render::ElementKind::Straight:
return "#222222";
case render::ElementKind::Arc:
return "#1f77b4";
case render::ElementKind::Transition:
return "#ff7f0e";
}
return "#000000";
}
} // namespace
std::string to_svg(const render::Scene& scene, const SvgOptions& options) {
double world_w = scene.max_x - scene.min_x;
double world_h = scene.max_y - scene.min_y;
if (world_w <= 0.0) world_w = 1.0;
if (world_h <= 0.0) world_h = 1.0;
const double scale = (options.width_px - 2.0 * options.margin_px) / world_w;
const double height_px = 2.0 * options.margin_px + world_h * scale;
// World -> SVG. North is up, so the y axis is flipped.
const auto sx = [&](double x) { return options.margin_px + (x - scene.min_x) * scale; };
const auto sy = [&](double y) { return options.margin_px + (scene.max_y - y) * scale; };
std::string out;
out += "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"" + num(options.width_px) +
"\" height=\"" + num(height_px) + "\" viewBox=\"0 0 " + num(options.width_px) +
" " + num(height_px) + "\">\n";
out += "<rect width=\"100%\" height=\"100%\" fill=\"#ffffff\"/>\n";
for (const auto& polyline : scene.polylines) {
out += std::string("<polyline fill=\"none\" stroke=\"") + colour(polyline.kind) +
"\" stroke-width=\"" + num(options.stroke_px) +
"\" stroke-linejoin=\"round\" points=\"";
for (const auto& p : polyline.points) {
out += num(sx(p.x)) + "," + num(sy(p.y)) + " ";
}
out += "\"/>\n";
}
out += "</svg>\n";
return out;
}
} // namespace maj0sted::io

View File

@@ -1,116 +0,0 @@
#include "maj0sted/render/plan_sampler.hpp"
#include <algorithm>
#include <cmath>
#include <variant>
#include "maj0sted/domain/geometry/segment_layout.hpp"
#include "maj0sted/maj0sted.hpp"
namespace maj0sted::render {
using namespace maj0sted::domain;
namespace {
template <class... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
template <class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
struct Vec {
double east;
double north;
};
Vec direction_of(Azimuth azimuth) noexcept {
return Vec{std::sin(azimuth.radians()), std::cos(azimuth.radians())};
}
// Samples a segment of linearly varying signed curvature (arc when k0 == k1,
// clothoid otherwise), starting at @p pos heading @p heading. Appends points to
// @p out and updates @p pos / @p heading to the segment's end.
// Samples a segment via the shared layout (identical to the fitting solver, so
// the drawn curve coincides exactly with the fitted tangent points).
void sample_segment(double k0, double k1, double length, Vec& pos, Vec& heading,
std::vector<Point>& out) {
geometry::Pose start{pos.east, pos.north, heading.east, heading.north};
std::vector<geometry::XY> points;
const geometry::Pose end = geometry::layout_segment(k0, k1, length, start, &points);
for (const auto& p : points) out.push_back({p.x, p.y});
pos = Vec{end.x, end.y};
heading = Vec{end.hx, end.hy};
}
void expand(Scene& scene, const Point& p) {
if (scene.empty) {
scene.min_x = scene.max_x = p.x;
scene.min_y = scene.max_y = p.y;
scene.empty = false;
return;
}
scene.min_x = std::min(scene.min_x, p.x);
scene.min_y = std::min(scene.min_y, p.y);
scene.max_x = std::max(scene.max_x, p.x);
scene.max_y = std::max(scene.max_y, p.y);
}
void sample_into(const HorizontalAlignment& plan, Scene& scene) {
Vec pos{0.0, 0.0};
Vec heading{1.0, 0.0};
for (const auto& element : plan.elements()) {
Polyline polyline;
std::visit(
overloaded{
[&](const Straight& s) {
polyline.kind = ElementKind::Straight;
polyline.points = {{s.start().x(), s.start().y()},
{s.end().x(), s.end().y()}};
pos = Vec{s.end().x(), s.end().y()};
heading = direction_of(s.azimuth());
},
[&](const CircularArc& a) {
polyline.kind = ElementKind::Arc;
const double sign = a.direction() == TurnDirection::Left ? 1.0 : -1.0;
const double k = sign / a.radius().metres();
const double length = a.length() ? a.length()->metres() : 0.0;
sample_segment(k, k, length, pos, heading, polyline.points);
},
[&](const TransitionCurve& t) {
polyline.kind = ElementKind::Transition;
const double sign = t.direction() == TurnDirection::Left ? 1.0 : -1.0;
const double k0 =
t.start_radius() ? sign / t.start_radius()->metres() : 0.0;
const double k1 =
t.end_radius() ? sign / t.end_radius()->metres() : 0.0;
sample_segment(k0, k1, t.length().metres(), pos, heading,
polyline.points);
},
},
element);
for (const auto& p : polyline.points) expand(scene, p);
scene.polylines.push_back(std::move(polyline));
}
}
} // namespace
Scene sample(const HorizontalAlignment& plan) {
Scene scene;
sample_into(plan, scene);
return scene;
}
Scene sample(const MapProject& project) {
Scene scene;
for (const auto& niweleta : project.niwelety()) {
sample_into(niweleta.plan(), scene);
}
return scene;
}
} // namespace maj0sted::render

View File

@@ -46,9 +46,9 @@ void RailRenderer::add(const CentrelineElement& element) {
for (const double side : {half_gauge_, -half_gauge_}) { for (const double side : {half_gauge_, -half_gauge_}) {
RailPolyline rail; RailPolyline rail;
rail.kind = element.kind; rail.kind = element.kind;
rail.straight_index = element.straight_index; rail.element_id = element.element_id;
rail.gap = element.gap; rail.owner_id = element.owner_id;
rail.element_index = element.element_index; rail.from_turnout = element.from_turnout;
rail.length = element.length; rail.length = element.length;
rail.radius_start = element.radius_start; rail.radius_start = element.radius_start;
rail.radius_end = element.radius_end; rail.radius_end = element.radius_end;

View File

@@ -1,54 +1,35 @@
add_executable(maj0sted_test_map_project test_map_project.cpp) add_executable(maj0sted_test_chain test_chain.cpp)
target_link_libraries(maj0sted_test_map_project PRIVATE maj0sted::maj0sted) target_link_libraries(maj0sted_test_chain PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_map_project PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(maj0sted_test_chain PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME map_project COMMAND maj0sted_test_map_project) add_test(NAME chain COMMAND maj0sted_test_chain)
add_executable(maj0sted_test_niweleta test_niweleta.cpp) add_executable(maj0sted_test_hold test_hold.cpp)
target_link_libraries(maj0sted_test_niweleta PRIVATE maj0sted::maj0sted) target_link_libraries(maj0sted_test_hold PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_niweleta PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(maj0sted_test_hold PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME niweleta COMMAND maj0sted_test_niweleta) add_test(NAME hold COMMAND maj0sted_test_hold)
add_executable(maj0sted_test_plan_alignment test_plan_alignment.cpp) add_executable(maj0sted_test_lines test_lines.cpp)
target_link_libraries(maj0sted_test_plan_alignment PRIVATE maj0sted::maj0sted) target_link_libraries(maj0sted_test_lines PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_plan_alignment PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(maj0sted_test_lines PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME plan_alignment COMMAND maj0sted_test_plan_alignment) add_test(NAME lines COMMAND maj0sted_test_lines)
add_executable(maj0sted_test_parallelism test_parallelism.cpp) add_executable(maj0sted_test_graph test_graph.cpp)
target_link_libraries(maj0sted_test_parallelism PRIVATE maj0sted::maj0sted) target_link_libraries(maj0sted_test_graph PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_parallelism PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(maj0sted_test_graph PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME parallelism COMMAND maj0sted_test_parallelism) add_test(NAME graph COMMAND maj0sted_test_graph)
add_executable(maj0sted_test_fitting test_fitting.cpp) add_executable(maj0sted_test_format test_format.cpp)
target_link_libraries(maj0sted_test_fitting PRIVATE maj0sted::maj0sted) target_link_libraries(maj0sted_test_format PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_fitting PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(maj0sted_test_format PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME fitting COMMAND maj0sted_test_fitting) add_test(NAME format COMMAND maj0sted_test_format)
add_executable(maj0sted_test_serialization test_serialization.cpp) add_executable(maj0sted_test_turnout test_turnout.cpp)
target_link_libraries(maj0sted_test_serialization PRIVATE maj0sted::maj0sted) target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_serialization PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME serialization COMMAND maj0sted_test_serialization) add_test(NAME turnout COMMAND maj0sted_test_turnout)
add_executable(maj0sted_test_render test_render.cpp)
target_link_libraries(maj0sted_test_render PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_render PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME render COMMAND maj0sted_test_render)
add_executable(maj0sted_test_editor test_editor.cpp)
target_link_libraries(maj0sted_test_editor PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_editor PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME editor COMMAND maj0sted_test_editor)
add_executable(maj0sted_test_tile_cache test_tile_cache.cpp) add_executable(maj0sted_test_tile_cache test_tile_cache.cpp)
target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted) target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME tile_cache COMMAND maj0sted_test_tile_cache) add_test(NAME tile_cache COMMAND maj0sted_test_tile_cache)
add_executable(maj0sted_test_document test_document.cpp)
target_link_libraries(maj0sted_test_document PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_document PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME document COMMAND maj0sted_test_document)
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)

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

@@ -1,144 +0,0 @@
#include <filesystem>
#include <string>
#include "check.hpp"
#include "maj0sted/app/editor_document.hpp"
using namespace maj0sted::app;
using maj0sted::editor::GapFit;
using maj0sted::editor::NiweletaSpec;
using maj0sted::editor::StraightSpec;
namespace {
EditorDocument sample_document() {
EditorDocument doc;
NiweletaSpec a;
a.name = "Niweleta 1";
a.straights = {StraightSpec{0.0, 0.0, 120.0, 0.0},
StraightSpec{120.0, 0.0, 120.0, 120.0, true}};
GapFit fit;
fit.gap = 0;
fit.mode = 2; // arc + transitions
fit.radius = 40.0;
fit.transition = 12.5;
a.fits = {fit};
NiweletaSpec b;
b.name = "Bocznica z spacją";
b.straights = {StraightSpec{500000.25, 300000.75, 500200.0, 300000.0}};
b.straights[0].rel_kind = 1;
b.straights[0].rel_niw = 0;
b.straights[0].rel_str = 0;
b.straights[0].rel_offset = -4.5;
doc.niwelety = {a, b};
doc.view_x = 500100.5;
doc.view_y = 300050.25;
doc.view_extent = 420.0;
doc.origin_set = true;
doc.georeferenced = true;
doc.origin_x = 500000.0;
doc.origin_y = 300000.0;
return doc;
}
bool same(const StraightSpec& x, const StraightSpec& y) {
return x.x1 == y.x1 && x.y1 == y.y1 && x.x2 == y.x2 && x.y2 == y.y2 &&
x.hidden == y.hidden && x.rel_kind == y.rel_kind &&
x.rel_niw == y.rel_niw && x.rel_str == y.rel_str &&
x.rel_offset == y.rel_offset && x.rel_cot == y.rel_cot &&
x.rel_side == y.rel_side && x.rel_length == y.rel_length;
}
bool same(const GapFit& x, const GapFit& y) {
return x.gap == y.gap && x.mode == y.mode && x.radius == y.radius &&
x.transition == y.transition && x.r1 == y.r1 && x.arc1_len == y.arc1_len &&
x.between == y.between && x.r2 == y.r2 && x.entry_t == y.entry_t &&
x.exit_t == y.exit_t;
}
void check_equal(const EditorDocument& x, const EditorDocument& y) {
CHECK(x.view_x == y.view_x);
CHECK(x.view_y == y.view_y);
CHECK(x.view_extent == y.view_extent);
CHECK(x.origin_set == y.origin_set);
CHECK(x.georeferenced == y.georeferenced);
CHECK(x.origin_x == y.origin_x);
CHECK(x.origin_y == y.origin_y);
CHECK(x.niwelety.size() == y.niwelety.size());
if (x.niwelety.size() != y.niwelety.size()) return;
for (std::size_t n = 0; n < x.niwelety.size(); ++n) {
const auto& a = x.niwelety[n];
const auto& b = y.niwelety[n];
CHECK(a.name == b.name);
CHECK(a.straights.size() == b.straights.size());
for (std::size_t i = 0; i < a.straights.size() && i < b.straights.size(); ++i) {
CHECK(same(a.straights[i], b.straights[i]));
}
CHECK(a.fits.size() == b.fits.size());
for (std::size_t i = 0; i < a.fits.size() && i < b.fits.size(); ++i) {
CHECK(same(a.fits[i], b.fits[i]));
}
}
}
void serialize_round_trips_exactly() {
const EditorDocument doc = sample_document();
const std::string text = serialize_document(doc);
const EditorDocument back = deserialize_document(text);
check_equal(doc, back);
// The file really embeds a maj0sted project (reuses io::serialize).
CHECK(text.rfind("maj0sted", 0) == 0);
CHECK(text.find("\neditor 2\n") != std::string::npos);
// Serialization is stable (byte-for-byte) across a round trip.
CHECK(serialize_document(back) == text);
}
void embedded_project_is_valid_and_readable() {
const EditorDocument doc = sample_document();
// to_map_project keeps one niweleta per editor niweleta and the straights.
const auto project = to_map_project(doc);
CHECK(project.niwelety().size() == 2);
const EditorDocument imported = from_map_project(project);
CHECK(imported.niwelety.size() == 2);
CHECK(imported.niwelety[0].straights.size() == 2);
CHECK(imported.niwelety[0].name == "Niweleta 1");
}
void save_then_load_from_disk() {
const EditorDocument doc = sample_document();
const std::filesystem::path dir =
std::filesystem::temp_directory_path() / "maj0sted_test";
std::filesystem::remove_all(dir);
const std::string path = (dir / "sub" / "proj.m0s").string();
CHECK(save_project(doc, path)); // creates parent dirs
CHECK(std::filesystem::exists(path));
const auto loaded = load_project(path);
CHECK(loaded.has_value());
if (loaded) check_equal(doc, *loaded);
std::filesystem::remove_all(dir);
}
void load_missing_file_is_nullopt() {
const std::string path =
(std::filesystem::temp_directory_path() / "maj0sted_nope_12345.m0s").string();
std::filesystem::remove(path);
CHECK(!load_project(path).has_value());
}
} // namespace
int main() {
RUN(serialize_round_trips_exactly);
RUN(embedded_project_is_valid_and_readable);
RUN(save_then_load_from_disk);
RUN(load_missing_file_is_nullopt);
return REPORT();
}

View File

@@ -1,271 +0,0 @@
#include <cmath>
#include <numbers>
#include "check.hpp"
#include "maj0sted/editor/editor.hpp"
#include "maj0sted/editor/solve.hpp"
using namespace maj0sted::editor;
namespace {
bool has_kind(const std::vector<PlanPolyline>& polylines, int kind) {
for (const auto& p : polylines) {
if (p.kind == kind) return true;
}
return false;
}
void arc_with_transitions_produces_full_chain() {
SolveRequest r{};
r.ax = 0.0; r.ay = 0.0;
r.bx = 300.0; r.by = 0.0;
r.cx = 300.0; r.cy = 300.0;
r.mode = 1;
r.radius = 80.0;
r.transition = 20.0;
const auto polylines = solve_scene(r);
CHECK(!polylines.empty());
CHECK(has_kind(polylines, 0)); // straight
CHECK(has_kind(polylines, 1)); // arc
CHECK(has_kind(polylines, 2)); // transition
// Each polyline has at least two points.
for (const auto& p : polylines) CHECK(p.points.size() >= 2);
}
void compound_mode_produces_two_arcs() {
SolveRequest r{};
r.ax = 0.0; r.ay = 0.0;
r.bx = 300.0; r.by = 0.0;
r.cx = 300.0; r.cy = 300.0;
r.mode = 2;
r.r1 = 120.0; r.arc1_len = 60.0; r.between = 25.0; r.r2 = 80.0;
r.entry_t = 30.0; r.exit_t = 30.0;
const auto polylines = solve_scene(r);
int arcs = 0;
for (const auto& p : polylines) {
if (p.kind == 1) ++arcs;
}
CHECK(arcs == 2);
CHECK(has_kind(polylines, 2)); // transitions present
}
void degenerate_input_falls_back_to_straights() {
// Collinear vertices -> no fit; still returns the straights, never throws.
SolveRequest r{};
r.ax = 0.0; r.ay = 0.0;
r.bx = 100.0; r.by = 0.0;
r.cx = 200.0; r.cy = 0.0;
r.mode = 0;
r.radius = 50.0;
const auto polylines = solve_scene(r);
CHECK(!polylines.empty());
CHECK(has_kind(polylines, 0)); // straights
CHECK(!has_kind(polylines, 1)); // no arc
}
NiweletaSpec short_corner(double radius, double transition = 0.0) {
NiweletaSpec spec;
spec.straights = {
StraightSpec{0.0, 0.0, 10.0, 0.0},
StraightSpec{10.0, 0.0, 10.0, 10.0},
};
spec.fits = {GapFit{.gap = 0,
.mode = transition > 0.0 ? 2 : 1,
.radius = radius,
.transition = transition}};
return spec;
}
void editor_clamps_oversized_arc_to_fitting_radius() {
const auto solved = solve_project({short_corner(60.0)});
CHECK(solved.size() == 1);
CHECK(solved[0].applied_fits.size() == 1);
const double applied = solved[0].applied_fits[0].radius;
CHECK(applied > 9.9);
CHECK(applied < 10.1);
}
void editor_leaves_gap_unfitted_when_radius_below_floor() {
// A radius too small for the requested transitions is NEVER enlarged (the
// fitting rule only ever reduces). When nothing at or below the request fits,
// the gap is simply left unconnected.
NiweletaSpec spec;
spec.straights = {
StraightSpec{0.0, 0.0, 100.0, 0.0},
StraightSpec{100.0, 0.0, 100.0, 100.0},
};
spec.fits = {GapFit{.gap = 0, .mode = 2, .radius = 1.0, .transition = 20.0}};
const auto solved = solve_project({spec});
CHECK(solved.size() == 1);
CHECK(solved[0].applied_fits.empty());
CHECK(!has_kind(solved[0].polylines, 1)); // no arc rendered
}
void editor_emits_two_rails_at_standard_gauge() {
const auto solved = solve_project({short_corner(5.0)});
CHECK(solved[0].polylines.size() == 6);
CHECK(solved[0].rendered_straights.size() == 2);
CHECK(std::abs(solved[0].rendered_straights[0].x2 - 5.0) < 1e-6);
CHECK(std::abs(solved[0].rendered_straights[1].y1 - 5.0) < 1e-6);
const auto& left = solved[0].polylines[0].points;
const auto& right = solved[0].polylines[1].points;
CHECK(left.size() == right.size());
CHECK(std::abs(std::hypot(left[0].x - right[0].x, left[0].y - right[0].y) -
1.5) < 1e-9);
CHECK(solved[0].polylines[0].straight_index == 0);
CHECK(solved[0].polylines[0].gap == -1);
CHECK(std::abs(solved[0].polylines[0].length - 5.0) < 1e-9);
const PlanPolyline* arc = nullptr;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 1) {
arc = &polyline;
break;
}
}
CHECK(arc != nullptr);
CHECK(arc->straight_index == -1);
CHECK(arc->gap == 0);
CHECK(arc->element_index == 0);
CHECK(std::abs(arc->radius_start - 5.0) < 1e-9);
CHECK(std::abs(arc->radius_end - 5.0) < 1e-9);
CHECK(std::abs(arc->length - 5.0 * std::numbers::pi / 2.0) < 1e-9);
}
void editor_exposes_transition_radius_range() {
const auto solved = solve_project({short_corner(5.0, 2.0)});
const PlanPolyline* transition = nullptr;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 2) {
transition = &polyline;
break;
}
}
CHECK(transition != nullptr);
CHECK(transition->gap == 0);
CHECK(std::abs(transition->length - 2.0) < 1e-9);
CHECK(transition->radius_start == 0.0);
CHECK(std::abs(transition->radius_end - 5.0) < 1e-9);
}
void editor_leaves_parallel_offset_unfitted() {
// Two parallel, laterally offset straights admit no plain arc (a single arc
// cannot connect parallels) and reverse S-curves are never substituted, so the
// gap stays unconnected rather than inventing a curve.
NiweletaSpec spec;
spec.straights = {
StraightSpec{0.0, 0.0, 200.0, 0.0},
StraightSpec{0.0, 20.0, 200.0, 20.0},
};
spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 50.0}};
const auto solved = solve_project({spec});
CHECK(solved[0].applied_fits.empty());
CHECK(!has_kind(solved[0].polylines, 1)); // no arc, no reverse pair
}
void editor_honours_requested_radius_via_single_arc() {
// A requested radius that lands on the straights (extending them toward the
// corner if needed) is used verbatim as a single arc — never enlarged and
// never swapped for a reverse pair.
const double heading = std::numbers::pi / 6.0;
const double tangent_x = 86.9615242271;
const double tangent_y = 25.9807621135;
const double dx = std::cos(heading);
const double dy = std::sin(heading);
NiweletaSpec spec;
spec.straights = {
StraightSpec{0.0, 0.0, 100.0, 0.0},
StraightSpec{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy,
tangent_x + 70.0 * dx, tangent_y + 70.0 * dy},
};
spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 30.0}};
const auto solved = solve_project({spec});
CHECK(solved[0].applied_fits.size() == 1);
CHECK(std::abs(solved[0].applied_fits[0].radius - 30.0) < 1e-9);
int arc_rails = 0;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 1) ++arc_rails;
}
CHECK(arc_rails == 2); // a single arc (two rails), not a reverse pair
}
void hidden_tangent_guide_fits_curve_without_rendering_track() {
NiweletaSpec spec = short_corner(5.0);
spec.straights[1].hidden = true;
const auto solved = solve_project({spec});
CHECK(solved[0].applied_fits.size() == 1);
CHECK(solved[0].rendered_straights[1].hidden);
int straight_rails = 0;
int arc_rails = 0;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 0) ++straight_rails;
if (polyline.kind == 1) ++arc_rails;
}
CHECK(straight_rails == 2); // only the visible entry straight
CHECK(arc_rails == 2);
}
void branch_rides_rigidly_when_pinned_to_frog() {
// Through track along +x; a switch diverges left at station 100.
NiweletaSpec through;
through.straights = {StraightSpec{0.0, 0.0, 200.0, 0.0}};
// Branch drawn far away as an L: a leaving straight plus one appended straight
// joined by an arc. Pinning must move the WHOLE branch rigidly onto the frog,
// so the appended fragment rides along instead of being left ~1.4 km away
// (the pre-fix behaviour, which only relocated the leaving straight).
NiweletaSpec branch;
branch.straights = {StraightSpec{1000.0, 1000.0, 1090.0, 1000.0},
StraightSpec{1100.0, 1000.0, 1100.0, 1090.0}};
branch.fits = {GapFit{.gap = 0, .mode = 1, .radius = 20.0}};
Junction j{};
j.through = 0;
j.station = 100.0;
j.side = 0; // diverge left
j.facing = true;
j.crossing_n = 9.0;
j.curve.mode = 1;
j.curve.radius = 190.0;
j.branch = 1;
const auto solution = solve_layout({through, branch}, {j});
CHECK(solution.junctions.size() == 1);
CHECK(solution.junctions[0].valid);
CHECK(solution.niwelety.size() == 2);
const auto& g = solution.junctions[0];
const auto& branch_solved = solution.niwelety[1];
CHECK(!branch_solved.centreline.empty());
// The branch starts exactly at the frog...
CHECK(std::hypot(branch_solved.centreline.front().x - g.fx,
branch_solved.centreline.front().y - g.fy) < 1e-6);
// ...and the appended fragment rode along: nothing sits far from the frog.
for (const auto& p : branch_solved.centreline) {
CHECK(std::hypot(p.x - g.fx, p.y - g.fy) < 300.0);
}
}
} // namespace
int main() {
RUN(arc_with_transitions_produces_full_chain);
RUN(compound_mode_produces_two_arcs);
RUN(degenerate_input_falls_back_to_straights);
RUN(editor_clamps_oversized_arc_to_fitting_radius);
RUN(editor_leaves_gap_unfitted_when_radius_below_floor);
RUN(editor_emits_two_rails_at_standard_gauge);
RUN(editor_exposes_transition_radius_range);
RUN(editor_leaves_parallel_offset_unfitted);
RUN(editor_honours_requested_radius_via_single_arc);
RUN(hidden_tangent_guide_fits_curve_without_rendering_track);
RUN(branch_rides_rigidly_when_pinned_to_frog);
return REPORT();
}

View File

@@ -1,346 +0,0 @@
#include <cmath>
#include <numbers>
#include <variant>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
// A 90-degree left corner: entry heads east into (100,0), exit leaves north.
Niweleta make_corner() {
Niweleta n{NiweletaId{1}, "Corner"};
n.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}});
n.add_plan_element(Straight{StraightId{2}, CartesianPosition{100.0, 0.0},
CartesianPosition{100.0, 100.0}},
JointContinuity::AzimuthBreak);
return n;
}
void fit_plain_arc() {
const Niweleta corner = make_corner();
const FitParameters params{.entry = StraightId{1},
.exit = StraightId{2},
.radius = Radius::from_metres(50.0),
.transition_length = std::nullopt};
const FitResult fit = FittingService::fit_between_straights(corner, params);
CHECK(fit.direction == TurnDirection::Left);
// Tangent length = R*tan(45) = 50 -> tangent points at (50,0) and (100,50).
CHECK(std::abs(fit.tangent_in.x() - 50.0) < 1e-6);
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
CHECK(std::abs(fit.tangent_out.y() - 50.0) < 1e-6);
// The trimmed straights keep their ids and outer endpoints.
CHECK(fit.entry.id() == StraightId{1});
CHECK(std::abs(fit.entry.end().x() - 50.0) < 1e-6);
CHECK(fit.exit.id() == StraightId{2});
CHECK(std::abs(fit.exit.start().y() - 50.0) < 1e-6);
CHECK(std::abs(fit.exit.end().y() - 100.0) < 1e-6);
// A single circular arc, length R*delta = 50*pi/2.
CHECK(fit.curve.size() == 1);
const auto* arc = std::get_if<CircularArc>(&fit.curve.front());
CHECK(arc != nullptr);
CHECK(arc->direction() == TurnDirection::Left);
CHECK(arc->length().has_value());
CHECK(std::abs(arc->length().value().metres() - 50.0 * std::numbers::pi / 2.0) < 1e-6);
}
void fit_with_symmetric_transitions() {
const Niweleta corner = make_corner();
const FitParameters params{.entry = StraightId{1},
.exit = StraightId{2},
.radius = Radius::from_metres(50.0),
.transition_length = Length::from_metres(10.0)};
const FitResult fit = FittingService::fit_between_straights(corner, params);
// klotoida -> łuk -> klotoida
CHECK(fit.curve.size() == 3);
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[0]));
CHECK(std::holds_alternative<CircularArc>(fit.curve[1]));
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[2]));
// Entry transition runs from an infinite radius (straight) into R.
const auto& entry_kp = std::get<TransitionCurve>(fit.curve[0]);
CHECK(!entry_kp.start_radius().has_value());
CHECK(entry_kp.end_radius().has_value());
CHECK(entry_kp.length().metres() == 10.0);
// Arc central angle = delta - 2*tau, tau = Lk/(2R) = 0.1 -> arc len = 50*(pi/2 - 0.2).
const auto& arc = std::get<CircularArc>(fit.curve[1]);
const double expected = 50.0 * (std::numbers::pi / 2.0 - 0.2);
CHECK(arc.length().has_value());
CHECK(std::abs(arc.length().value().metres() - expected) < 1e-6);
// Entry tangent point from the exact clothoid layout (not the L^2/24R
// approximation, which was ~1.8 mm off and caused a visible lateral gap).
CHECK(std::abs(fit.tangent_in.x() - 44.9185491305) < 1e-6);
}
void invalid_inputs_are_rejected() {
const Niweleta corner = make_corner();
const Radius r = Radius::from_metres(50.0);
// Same straight.
CHECK_THROWS(FittingService::fit_between_straights(
corner, FitParameters{StraightId{1}, StraightId{1}, r, std::nullopt}));
// Unknown id.
CHECK_THROWS(FittingService::fit_between_straights(
corner, FitParameters{StraightId{1}, StraightId{99}, r, std::nullopt}));
// Collinear straights -> nothing to fit.
Niweleta line{NiweletaId{2}};
line.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{50.0, 0.0}});
line.add_plan_element(Straight{StraightId{2}, CartesianPosition{50.0, 0.0},
CartesianPosition{100.0, 0.0}});
CHECK_THROWS(FittingService::fit_between_straights(
line, FitParameters{StraightId{1}, StraightId{2}, r, std::nullopt}));
// Transitions too long for this radius/deflection.
CHECK_THROWS(FittingService::fit_between_straights(
corner, FitParameters{StraightId{1}, StraightId{2}, r,
Length::from_metres(200.0)}));
}
void fit_compound_curve_two_arcs() {
const Niweleta corner = make_corner(); // 90-degree left corner, IP at (100,0)
// First arc delta1 = 45 deg -> length = R1 * pi/4 = 60 * pi/4; last arc computed.
const CompoundFitParameters params{
.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {CompoundArc{Radius::from_metres(60.0),
Length::from_metres(60.0 * std::numbers::pi / 4.0)},
CompoundArc{Radius::from_metres(40.0), std::nullopt}}};
const FitResult fit = FittingService::fit_compound(corner, params);
CHECK(fit.direction == TurnDirection::Left);
CHECK(fit.curve.size() == 2);
const auto& arc1 = std::get<CircularArc>(fit.curve[0]);
const auto& arc2 = std::get<CircularArc>(fit.curve[1]);
CHECK(arc1.radius().metres() == 60.0);
CHECK(arc2.radius().metres() == 40.0);
// Same hand-computed geometry as before (rigid-chain solver reproduces it).
CHECK(std::abs(arc1.length().value().metres() - 60.0 * std::numbers::pi / 4.0) < 1e-6);
CHECK(std::abs(arc2.length().value().metres() - 40.0 * std::numbers::pi / 4.0) < 1e-6);
CHECK(std::abs(fit.tangent_in.x() - 45.85786) < 1e-4);
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-4);
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-4);
CHECK(std::abs(fit.tangent_out.y() - 45.85786) < 1e-4);
}
void fit_compound_curve_three_arcs() {
const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100
// 30 + 30 + (remaining 30) degrees, radii 80 / 50 / 30.
const CompoundFitParameters params{
.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {CompoundArc{Radius::from_metres(80.0),
Length::from_metres(80.0 * std::numbers::pi / 6.0)},
CompoundArc{Radius::from_metres(50.0),
Length::from_metres(50.0 * std::numbers::pi / 6.0)},
CompoundArc{Radius::from_metres(30.0), std::nullopt}}};
const FitResult fit = FittingService::fit_compound(corner, params);
CHECK(fit.curve.size() == 3);
CHECK(std::get<CircularArc>(fit.curve[0]).radius().metres() == 80.0);
CHECK(std::get<CircularArc>(fit.curve[1]).radius().metres() == 50.0);
CHECK(std::get<CircularArc>(fit.curve[2]).radius().metres() == 30.0);
// Last arc takes the remaining 30 deg -> length = 30 * pi/6.
CHECK(std::abs(std::get<CircularArc>(fit.curve[2]).length().value().metres() -
30.0 * std::numbers::pi / 6.0) < 1e-6);
// The chain closes onto both fixed lines: tangent_in on y=0, tangent_out on x=100.
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
CHECK(fit.direction == TurnDirection::Left);
}
void fit_compound_with_transitions() {
const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100
const CompoundFitParameters params{
.entry = StraightId{1},
.exit = StraightId{2},
.entry_transition = Length::from_metres(10.0),
.exit_transition = Length::from_metres(10.0),
.arcs = {CompoundArc{Radius::from_metres(60.0), Length::from_metres(20.0),
Length::from_metres(8.0)}, // arc(60), KP 60->40 of 8 m
CompoundArc{Radius::from_metres(40.0), std::nullopt, std::nullopt}}};
const FitResult fit = FittingService::fit_compound(corner, params);
// Sequence: KP -> arc(60) -> KP(60->40) -> arc(40) -> KP.
CHECK(fit.curve.size() == 5);
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[0]));
CHECK(std::holds_alternative<CircularArc>(fit.curve[1]));
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[2]));
CHECK(std::holds_alternative<CircularArc>(fit.curve[3]));
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[4]));
// Entry transition: straight (inf) -> R60.
const auto& kp_in = std::get<TransitionCurve>(fit.curve[0]);
CHECK(!kp_in.start_radius().has_value());
CHECK(kp_in.end_radius().value().metres() == 60.0);
CHECK(kp_in.length().metres() == 10.0);
// Between transition: R60 -> R40.
const auto& kp_mid = std::get<TransitionCurve>(fit.curve[2]);
CHECK(kp_mid.start_radius().value().metres() == 60.0);
CHECK(kp_mid.end_radius().value().metres() == 40.0);
CHECK(kp_mid.length().metres() == 8.0);
// Exit transition: R40 -> straight (inf).
const auto& kp_out = std::get<TransitionCurve>(fit.curve[4]);
CHECK(kp_out.start_radius().value().metres() == 40.0);
CHECK(!kp_out.end_radius().has_value());
// The chain still closes onto both fixed lines.
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
CHECK(fit.direction == TurnDirection::Left);
}
void compound_invalid_inputs_are_rejected() {
const Niweleta corner = make_corner();
const auto arc = [](double r, double len) {
return CompoundArc{Radius::from_metres(r), Length::from_metres(len)};
};
const auto last = [](double r) { return CompoundArc{Radius::from_metres(r)}; };
// Equal neighbouring radii.
CHECK_THROWS(FittingService::fit_compound(
corner, CompoundFitParameters{.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {arc(50.0, 10.0), last(50.0)}}));
// Split lengths exceed the total deflection.
CHECK_THROWS(FittingService::fit_compound(
corner, CompoundFitParameters{.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {arc(60.0, 1000.0), last(40.0)}}));
// A non-last arc without a length.
CHECK_THROWS(FittingService::fit_compound(
corner, CompoundFitParameters{.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {last(60.0), last(40.0)}}));
}
void fit_equal_radius_reverse_curve_between_parallel_straights() {
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{200.0, 0.0}};
const Straight exit{StraightId{2}, CartesianPosition{0.0, 20.0},
CartesianPosition{200.0, 20.0}};
const FitResult fit =
FittingService::fit_reverse(entry, exit, Radius::from_metres(50.0));
CHECK(fit.curve.size() == 2);
const auto& first = std::get<CircularArc>(fit.curve[0]);
const auto& second = std::get<CircularArc>(fit.curve[1]);
CHECK(first.radius() == second.radius());
CHECK(first.radius() == Radius::from_metres(50.0));
CHECK(first.direction() != second.direction());
CHECK(first.length().has_value());
CHECK(second.length().has_value());
CHECK(std::abs(first.length()->metres() - second.length()->metres()) < 1e-9);
CHECK(std::abs(fit.tangent_in.y()) < 1e-9);
CHECK(std::abs(fit.tangent_out.y() - 20.0) < 1e-6);
CHECK(fit.tangent_in.x() > 0.0 && fit.tangent_in.x() <= 200.0);
CHECK(fit.tangent_out.x() >= 0.0 && fit.tangent_out.x() < 200.0);
}
void reverse_curve_rejects_collinear_and_antiparallel_straights() {
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
const Straight collinear{StraightId{2}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
const Straight antiparallel{StraightId{3}, CartesianPosition{100.0, 20.0},
CartesianPosition{0.0, 20.0}};
const Radius radius = Radius::from_metres(30.0);
CHECK_THROWS(FittingService::fit_reverse(entry, collinear, radius));
CHECK_THROWS(FittingService::fit_reverse(entry, antiparallel, radius));
}
void fit_reverse_curve_between_nonparallel_straights() {
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
// This exit line is constructed around the end pose of a R=30 m chain:
// left 60 degrees followed by right 30 degrees.
const double heading = std::numbers::pi / 6.0;
const double tangent_x = 86.9615242271;
const double tangent_y = 25.9807621135;
const double dx = std::cos(heading);
const double dy = std::sin(heading);
const Straight exit{
StraightId{2},
CartesianPosition{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy},
CartesianPosition{tangent_x + 70.0 * dx, tangent_y + 70.0 * dy}};
const FitResult fit =
FittingService::fit_reverse(entry, exit, Radius::from_metres(30.0));
CHECK(fit.curve.size() == 2);
const auto& first = std::get<CircularArc>(fit.curve[0]);
const auto& second = std::get<CircularArc>(fit.curve[1]);
CHECK(first.direction() != second.direction());
CHECK(first.radius() == second.radius());
CHECK(std::abs(fit.tangent_in.y()) < 1e-6);
const double exit_cross =
(fit.tangent_out.x() - exit.start().x()) * dy -
(fit.tangent_out.y() - exit.start().y()) * dx;
CHECK(std::abs(exit_cross) < 1e-5);
}
void apply_fit_replaces_straights_in_plan() {
Niweleta corner = make_corner(); // straight(1) --break--> straight(2)
const FitParameters params{.entry = StraightId{1},
.exit = StraightId{2},
.radius = Radius::from_metres(50.0),
.transition_length = std::nullopt};
const FitResult fit = FittingService::fit_between_straights(corner, params);
corner.apply_fit(fit);
// Plan is now: straight(1) -> arc -> straight(2), tangent joints, no break.
CHECK(corner.plan().size() == 3);
CHECK(std::holds_alternative<Straight>(corner.plan().elements()[0]));
CHECK(std::holds_alternative<CircularArc>(corner.plan().elements()[1]));
CHECK(std::holds_alternative<Straight>(corner.plan().elements()[2]));
CHECK(corner.plan().joint(0) == JointContinuity::Tangent);
CHECK(corner.plan().joint(1) == JointContinuity::Tangent);
// Straights keep their ids and are trimmed to the tangent points.
const auto& first = std::get<Straight>(corner.plan().elements()[0]);
const auto& last = std::get<Straight>(corner.plan().elements()[2]);
CHECK(first.id() == StraightId{1});
CHECK(last.id() == StraightId{2});
CHECK(std::abs(first.end().x() - 50.0) < 1e-6);
CHECK(std::abs(last.start().y() - 50.0) < 1e-6);
}
} // namespace
int main() {
RUN(fit_plain_arc);
RUN(fit_with_symmetric_transitions);
RUN(invalid_inputs_are_rejected);
RUN(fit_compound_curve_two_arcs);
RUN(fit_compound_curve_three_arcs);
RUN(fit_compound_with_transitions);
RUN(compound_invalid_inputs_are_rejected);
RUN(fit_equal_radius_reverse_curve_between_parallel_straights);
RUN(reverse_curve_rejects_collinear_and_antiparallel_straights);
RUN(fit_reverse_curve_between_nonparallel_straights);
RUN(apply_fit_replaces_straights_in_plan);
return REPORT();
}

View File

@@ -0,0 +1,279 @@
// 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;
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);
// 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 7\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 6") != 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 6") != 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,549 @@
// 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));
}
/// 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_joint_only_ever_looks_forward);
RUN(touching_straights_are_reported);
return REPORT();
}

View File

@@ -1,25 +0,0 @@
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
int main() {
// A map project defaults to the Cartesian CRS EPSG:2180.
const MapProject project;
CHECK(project.crs() == Crs::default_crs());
CHECK(project.crs().epsg() == 2180);
// An explicit CRS is honoured.
const MapProject wgs84{Crs{4326}};
CHECK(wgs84.crs().epsg() == 4326);
// Cartesian position value object.
const CartesianPosition p{100.5, 200.25};
CHECK(p.x() == 100.5);
CHECK(p.y() == 200.25);
CHECK(CartesianPosition{1.0, 2.0} == CartesianPosition{1.0, 2.0});
CHECK(CartesianPosition{1.0, 2.0} != CartesianPosition{2.0, 1.0});
CHECK(CartesianPosition{}.x() == 0.0);
return REPORT();
}

View File

@@ -1,110 +0,0 @@
#include <variant>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
void plan_and_profile_element_lengths() {
const PlanElement straight =
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
const PlanElement arc = CircularArc{Radius::from_metres(300.0),
TurnDirection::Left,
Length::from_metres(50.0)};
const PlanElement transition = TransitionCurve{
Length::from_metres(30.0), std::nullopt, Radius::from_metres(300.0),
TurnDirection::Left};
CHECK(length_of(straight).value().metres() == 100.0);
CHECK(length_of(arc).value().metres() == 50.0);
CHECK(length_of(transition).value().metres() == 30.0);
const ProfileElement grade = Grade{Length::from_metres(120.0), 5.0};
const ProfileElement vcurve = VerticalCurve{
Length::from_metres(60.0), Radius::from_metres(4000.0),
VerticalCurvature::Crest};
CHECK(length_of(grade).metres() == 120.0);
CHECK(length_of(vcurve).metres() == 60.0);
}
void value_objects_guard_invariants() {
CHECK_THROWS(Length::from_metres(-1.0));
CHECK_THROWS(Radius::from_metres(0.0));
CHECK_THROWS(Radius::from_metres(-5.0));
}
void niweleta_aggregates_plan_and_profile() {
Niweleta n{NiweletaId{1}, "Odcinek 1"};
CHECK(n.name() == "Odcinek 1");
CHECK(n.plan().empty());
CHECK(n.profile().empty());
// Plan: prosta -> klotoida -> łuk -> klotoida -> prosta (razem 180 m)
n.add_plan_element(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
n.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt,
Radius::from_metres(300.0),
TurnDirection::Right});
n.add_plan_element(CircularArc{Radius::from_metres(300.0),
TurnDirection::Right,
Length::from_metres(20.0)});
n.add_plan_element(TransitionCurve{Length::from_metres(30.0),
Radius::from_metres(300.0), std::nullopt,
TurnDirection::Right});
n.add_plan_element(
Straight{CartesianPosition{100.0, 0.0}, CartesianPosition{150.0, 0.0}});
// Profil: pochylenie -> łuk pionowy -> pochylenie (razem 180 m)
n.add_profile_element(Grade{Length::from_metres(80.0), 3.0});
n.add_profile_element(VerticalCurve{Length::from_metres(40.0),
Radius::from_metres(5000.0),
VerticalCurvature::Sag});
n.add_profile_element(Grade{Length::from_metres(60.0), -2.0});
CHECK(n.plan().size() == 5);
CHECK(n.profile().size() == 3);
CHECK(n.plan_length().metres() == 180.0);
CHECK(n.profile_length().metres() == 180.0);
CHECK(n.lengths_consistent());
}
void inconsistent_lengths_are_detected() {
Niweleta n{NiweletaId{2}};
n.add_plan_element(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}});
n.add_profile_element(Grade{Length::from_metres(90.0), 0.0});
CHECK(!n.lengths_consistent());
CHECK(n.plan_length().metres() == 100.0);
CHECK(n.profile_length().metres() == 90.0);
}
void niweleta_assigns_straight_ids() {
Niweleta n{NiweletaId{3}};
// add_straight mints and returns fresh ids.
const StraightId a =
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0});
const StraightId b =
n.add_straight(CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0});
CHECK(!a.is_null());
CHECK(!b.is_null());
CHECK(a != b);
// An anonymous straight added via add_plan_element is identified too.
Niweleta m{NiweletaId{4}};
m.add_plan_element(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
const auto& element = m.plan().elements().front();
CHECK(!std::get<Straight>(element).id().is_null());
}
} // namespace
int main() {
RUN(plan_and_profile_element_lengths);
RUN(value_objects_guard_invariants);
RUN(niweleta_aggregates_plan_and_profile);
RUN(inconsistent_lengths_are_detected);
RUN(niweleta_assigns_straight_ids);
return REPORT();
}

View File

@@ -1,85 +0,0 @@
#include <cmath>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
void mark_parallel_references_two_straights() {
// Two identified straights (typically from different niwelety).
const Straight origin{StraightId{10}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
const Straight parallel{StraightId{20}, CartesianPosition{0.0, 5.0},
CartesianPosition{100.0, 5.0}};
const TrackOffset offset{Length::from_metres(5.0), Side::Left};
const auto relation = ParallelismService::mark_parallel(origin, parallel, offset);
CHECK(relation.origin() == StraightId{10});
CHECK(relation.parallel() == StraightId{20});
CHECK(relation.offset().distance().metres() == 5.0);
CHECK(relation.offset().side() == Side::Left);
}
void marking_requires_identified_distinct_straights() {
const Straight anonymous{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
const Straight a{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
const Straight b{StraightId{2}, CartesianPosition{0.0, 5.0},
CartesianPosition{100.0, 5.0}};
const TrackOffset offset{Length::from_metres(5.0), Side::Left};
// A straight without an id cannot be referenced.
CHECK_THROWS(ParallelismService::mark_parallel(anonymous, b, offset));
CHECK_THROWS(ParallelismService::mark_parallel(a, anonymous, offset));
// A straight cannot be parallel to itself (same id).
const Straight a_again{StraightId{1}, CartesianPosition{0.0, 10.0},
CartesianPosition{100.0, 10.0}};
CHECK_THROWS(ParallelismService::mark_parallel(a, a_again, offset));
}
void offset_must_be_positive() {
CHECK_THROWS(TrackOffset(Length::from_metres(0.0), Side::Left));
}
void parallelism_geometry() {
const Straight origin{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
const Straight same_direction{CartesianPosition{0.0, 5.0},
CartesianPosition{100.0, 5.0}};
const Straight opposite{CartesianPosition{100.0, 5.0}, CartesianPosition{0.0, 5.0}};
const Straight perpendicular{CartesianPosition{0.0, 0.0},
CartesianPosition{0.0, 50.0}};
CHECK(ParallelismService::are_parallel(origin, same_direction));
CHECK(ParallelismService::are_parallel(origin, opposite)); // anti-parallel lines
CHECK(!ParallelismService::are_parallel(origin, perpendicular));
// Derive a line 5 m to the LEFT of an east-going origin -> to the north (y = +5).
const Straight left = ParallelismService::derive_parallel_line(
origin, TrackOffset{Length::from_metres(5.0), Side::Left});
CHECK(std::abs(left.start().x() - 0.0) < 1e-9);
CHECK(std::abs(left.start().y() - 5.0) < 1e-9);
CHECK(std::abs(left.end().y() - 5.0) < 1e-9);
CHECK(ParallelismService::are_parallel(origin, left));
// To the RIGHT -> to the south (y = -5).
const Straight right = ParallelismService::derive_parallel_line(
origin, TrackOffset{Length::from_metres(5.0), Side::Right});
CHECK(std::abs(right.start().y() + 5.0) < 1e-9);
CHECK(std::abs(ParallelismService::perpendicular_distance(origin, same_direction) -
5.0) < 1e-9);
}
} // namespace
int main() {
RUN(mark_parallel_references_two_straights);
RUN(marking_requires_identified_distinct_straights);
RUN(offset_must_be_positive);
RUN(parallelism_geometry);
return REPORT();
}

View File

@@ -1,132 +0,0 @@
#include <cmath>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
void straight_computes_azimuth_and_length() {
// Going east (+x): azimuth = 90 deg (clockwise from north).
const Straight east{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
CHECK(std::abs(east.length().metres() - 100.0) < 1e-9);
CHECK(std::abs(east.azimuth().degrees() - 90.0) < 1e-9);
// Going north (+y): azimuth = 0 deg.
const Straight north{CartesianPosition{0.0, 0.0}, CartesianPosition{0.0, 50.0}};
CHECK(std::abs(north.azimuth().degrees() - 0.0) < 1e-9);
CHECK(std::abs(north.length().metres() - 50.0) < 1e-9);
// A zero-length straight has no azimuth and is rejected.
CHECK_THROWS(Straight(CartesianPosition{1.0, 1.0}, CartesianPosition{1.0, 1.0}));
}
void arc_carries_radius_direction_and_optional_length() {
const CircularArc without_length{Radius::from_metres(300.0), TurnDirection::Left};
CHECK(!without_length.length().has_value());
const CircularArc with_length{Radius::from_metres(300.0), TurnDirection::Left,
Length::from_metres(45.0)};
CHECK(with_length.length().has_value());
CHECK(with_length.length().value().metres() == 45.0);
}
void adjacent_same_arcs_are_rejected() {
HorizontalAlignment same;
same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
// same radius + same direction -> forbidden
CHECK_THROWS(
same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}));
HorizontalAlignment opposite;
opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
// same radius, different direction -> allowed (S-curve)
opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Right});
CHECK(opposite.size() == 2);
HorizontalAlignment compound;
compound.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
// same direction, different radius -> allowed (compound curve)
compound.append(CircularArc{Radius::from_metres(500.0), TurnDirection::Left});
CHECK(compound.size() == 2);
}
void straight_to_straight_tangent_requires_equal_azimuth() {
// Collinear straights, same azimuth, touching -> tangent OK.
HorizontalAlignment collinear;
collinear.append(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
collinear.append(
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}});
CHECK(collinear.size() == 2);
CHECK(collinear.joint(0) == JointContinuity::Tangent);
// Touching but different azimuth with a Tangent joint -> must be a break.
HorizontalAlignment unmarked;
unmarked.append(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
CHECK_THROWS(unmarked.append(
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}}));
// Non-touching tangent straights -> invalid.
HorizontalAlignment gap;
gap.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
CHECK_THROWS(gap.append(
Straight{CartesianPosition{60.0, 0.0}, CartesianPosition{100.0, 0.0}}));
}
void azimuth_break_between_straights_must_be_marked() {
// Corner marked as AzimuthBreak -> OK.
HorizontalAlignment kink;
kink.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
kink.append(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}},
JointContinuity::AzimuthBreak);
CHECK(kink.size() == 2);
CHECK(kink.joint(0) == JointContinuity::AzimuthBreak);
// AzimuthBreak with equal azimuth is pointless -> invalid.
HorizontalAlignment straight_break;
straight_break.append(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
CHECK_THROWS(straight_break.append(
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}},
JointContinuity::AzimuthBreak));
// AzimuthBreak is only allowed between two straights.
HorizontalAlignment arc_break;
arc_break.append(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
CHECK_THROWS(arc_break.append(
CircularArc{Radius::from_metres(300.0), TurnDirection::Left},
JointContinuity::AzimuthBreak));
}
void straight_curve_joints_are_accepted_pending_fit() {
// prosta -> klotoida -> łuk -> klotoida -> prosta
HorizontalAlignment plan;
plan.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
plan.append(TransitionCurve{Length::from_metres(30.0), std::nullopt,
Radius::from_metres(300.0), TurnDirection::Left});
plan.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left,
Length::from_metres(40.0)});
plan.append(TransitionCurve{Length::from_metres(30.0),
Radius::from_metres(300.0), std::nullopt,
TurnDirection::Left});
plan.append(Straight{CartesianPosition{200.0, 0.0}, CartesianPosition{260.0, 0.0}});
CHECK(plan.size() == 5);
// Known lengths: 50 + 30 + 40 + 30 + 60.
CHECK(plan.total_length().metres() == 210.0);
}
} // namespace
int main() {
RUN(straight_computes_azimuth_and_length);
RUN(arc_carries_radius_direction_and_optional_length);
RUN(adjacent_same_arcs_are_rejected);
RUN(straight_to_straight_tangent_requires_equal_azimuth);
RUN(azimuth_break_between_straights_must_be_marked);
RUN(straight_curve_joints_are_accepted_pending_fit);
return REPORT();
}

View File

@@ -1,78 +0,0 @@
#include <cmath>
#include "check.hpp"
#include "maj0sted/io/svg_writer.hpp"
#include "maj0sted/maj0sted.hpp"
#include "maj0sted/render/plan_sampler.hpp"
using namespace maj0sted::domain;
namespace {
void samples_one_polyline_per_element() {
Niweleta n{NiweletaId{1}};
const StraightId a =
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0},
CartesianPosition{100.0, 100.0},
JointContinuity::AzimuthBreak);
const FitResult fit = FittingService::fit_between_straights(
n, FitParameters{.entry = a,
.exit = b,
.radius = Radius::from_metres(40.0),
.transition_length = Length::from_metres(10.0)});
n.apply_fit(fit); // straight -> KP -> arc -> KP -> straight (5 elements)
const auto scene = maj0sted::render::sample(n.plan());
CHECK(scene.polylines.size() == n.plan().size());
CHECK(scene.polylines.size() == 5);
CHECK(!scene.empty);
// First polyline is the (straight) entry, drawn from its anchored endpoints.
const auto& first = scene.polylines.front();
CHECK(first.kind == maj0sted::render::ElementKind::Straight);
CHECK(first.points.size() == 2);
CHECK(std::abs(first.points.front().x - 0.0) < 1e-9);
// Curves are sampled into many points.
CHECK(scene.polylines[2].kind == maj0sted::render::ElementKind::Arc);
CHECK(scene.polylines[2].points.size() > 2);
}
void curve_geometry_is_continuous() {
// The sampled points should join up (each element starts where the previous
// ended) for a properly fitted, tangent-continuous plan.
Niweleta n{NiweletaId{2}};
const StraightId a =
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0},
CartesianPosition{100.0, 100.0},
JointContinuity::AzimuthBreak);
n.apply_fit(FittingService::fit_between_straights(
n, FitParameters{.entry = a, .exit = b, .radius = Radius::from_metres(40.0)}));
const auto scene = maj0sted::render::sample(n.plan());
for (std::size_t i = 1; i < scene.polylines.size(); ++i) {
const auto& prev_end = scene.polylines[i - 1].points.back();
const auto& next_start = scene.polylines[i].points.front();
CHECK(std::abs(prev_end.x - next_start.x) < 1e-6);
CHECK(std::abs(prev_end.y - next_start.y) < 1e-6);
}
}
void svg_is_produced() {
Niweleta n{NiweletaId{3}};
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0});
const auto svg = maj0sted::io::to_svg(maj0sted::render::sample(n.plan()));
CHECK(svg.rfind("<svg", 0) == 0);
CHECK(svg.find("<polyline") != std::string::npos);
}
} // namespace
int main() {
RUN(samples_one_polyline_per_element);
RUN(curve_geometry_is_continuous);
RUN(svg_is_produced);
return REPORT();
}

View File

@@ -1,92 +0,0 @@
#include <variant>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
MapProject make_project() {
MapProject project{Crs{2180}};
Niweleta a{NiweletaId{5}, "Odcinek A"};
const StraightId s1 =
a.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
(void)s1;
a.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt,
Radius::from_metres(300.0), TurnDirection::Right});
a.add_plan_element(CircularArc{Radius::from_metres(300.0), TurnDirection::Right,
Length::from_metres(40.0)});
a.add_profile_element(Grade{Length::from_metres(80.0), 3.0});
a.add_profile_element(VerticalCurve{Length::from_metres(40.0),
Radius::from_metres(5000.0),
VerticalCurvature::Sag});
project.add_niweleta(std::move(a));
// A second niweleta with an azimuth break, to exercise joints.
Niweleta b{NiweletaId{9}, "Odcinek B z załamaniem"};
b.add_plan_element(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
b.add_plan_element(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}},
JointContinuity::AzimuthBreak);
project.add_niweleta(std::move(b));
return project;
}
void round_trip_is_stable() {
const MapProject project = make_project();
const std::string text = maj0sted::io::serialize(project);
const MapProject loaded = maj0sted::io::deserialize(text);
// Re-serializing the loaded project yields byte-identical text.
CHECK(maj0sted::io::serialize(loaded) == text);
CHECK(loaded.crs().epsg() == 2180);
CHECK(loaded.niwelety().size() == 2);
}
void niweleta_and_geometry_survive() {
const MapProject loaded = maj0sted::io::deserialize(maj0sted::io::serialize(make_project()));
const Niweleta& a = loaded.niwelety()[0];
CHECK(a.id() == NiweletaId{5});
CHECK(a.name() == "Odcinek A");
CHECK(a.plan().size() == 3);
CHECK(a.profile().size() == 2);
// Straight kept its id and geometry.
const auto& straight = std::get<Straight>(a.plan().elements()[0]);
CHECK(straight.id() == StraightId{1});
CHECK(straight.length().metres() == 100.0);
// Arc kept radius, direction and length.
const auto& arc = std::get<CircularArc>(a.plan().elements()[2]);
CHECK(arc.radius().metres() == 300.0);
CHECK(arc.direction() == TurnDirection::Right);
CHECK(arc.length().value().metres() == 40.0);
// Profile survived.
const auto& grade = std::get<Grade>(a.profile().elements()[0]);
CHECK(grade.slope_permille() == 3.0);
// Name with spaces and the azimuth-break joint survived on the 2nd niweleta.
const Niweleta& b = loaded.niwelety()[1];
CHECK(b.name() == "Odcinek B z załamaniem");
CHECK(b.plan().joint(0) == JointContinuity::AzimuthBreak);
}
void malformed_input_throws() {
CHECK_THROWS(maj0sted::io::deserialize("not a maj0sted file"));
CHECK_THROWS(maj0sted::io::deserialize("maj0sted 1\ncrs 2180\nniwelety 3\n"));
}
} // namespace
int main() {
RUN(round_trip_is_stable);
RUN(niweleta_and_geometry_survive);
RUN(malformed_input_throws);
return REPORT();
}

View File

@@ -1,3 +1,11 @@
// 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 <cmath>
#include <numbers> #include <numbers>
@@ -9,12 +17,54 @@ using maj0sted::domain::geometry::Pose;
namespace { 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 rz_1_9_r190() {
Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, DivergingCurve{}, 27.138}; Turnout turnout{CrossingMark{9.0},
turnout.curve.arcs.push_back(TurnoutArc{190.0, 0.0, 0.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; 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. // 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_after(const TurnoutGeometry& geometry, std::size_t index) {
Pose pose{0.0, 0.0, 1.0, 0.0}; Pose pose{0.0, 0.0, 1.0, 0.0};
@@ -32,99 +82,365 @@ Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) {
return pose; return pose;
} }
void pre_blade_runs_along_the_through_track() { void a_tangential_blade_never_breaks_away() {
auto turnout = rz_1_9_r190(); const auto geometry = laid(rz_1_9_r190());
turnout.pre_blade = 3.5;
turnout.blade_angle = 0.02;
turnout.blade_length = 6.0;
const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout);
CHECK(geometry.valid); CHECK(geometry.valid);
for (const auto& segment : geometry.path) {
// the lead is straight, on the through track, and the blade tip sits at its end CHECK(segment.turn_in == 0.0);
const Pose tip = pose_after(geometry, 0); }
CHECK(std::abs(tip.x - 3.5) < 1e-9); // the curve is there from PR: the odcinek przediglicowy is already on it, the
CHECK(std::abs(tip.y) < 1e-12); // vehicle is simply still on the through track
CHECK(std::abs(tip.hy) < 1e-12); 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 breaks away by beta exactly, and stays straight over its length // the blade begins where its piece does, a from PR
const Pose heel = pose_after(geometry, 1); CHECK(geometry.has_blade);
CHECK(std::abs(std::atan2(heel.hy, heel.hx) - 0.02) < 1e-9); CHECK(std::abs(geometry.blade_station - kLead) < 1e-12);
CHECK(std::abs(std::hypot(heel.x - tip.x, heel.y - tip.y) - 6.0) < 1e-9); 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 the_frog_still_lands_on_the_crossing_angle() { void a_planed_nose_reaches_its_thickness_where_the_table_says() {
const double alfa = std::atan(1.0 / 9.0); const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
auto tangential = rz_1_9_r190(); const Pose start{kStart[0], kStart[1], kStart[2], kStart[3]};
auto bladed = rz_1_9_r190(); // A: where the blade stands u away from the stock rail, and where the tangent
bladed.pre_blade = 3.5; // there is the kąt przylegania — a couple of dozen minutes
bladed.blade_angle = 0.02; CHECK(std::abs(gap_at(start, geometry.blade_nose_end) - kTipThickness) < 1e-9);
bladed.blade_length = 6.0; 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);
for (const auto& turnout : {tangential, bladed}) { // the planing runs from the ostrze to full railhead width
const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); CHECK(std::abs(geometry.planing_end - blade_run_to_offset(kRadius, 0.0, kRailtop)) <
CHECK(geometry.valid); 1e-12);
// the curve spends only alfa - beta, so the frog heading is alfa either way CHECK(std::abs(gap_at(start, geometry.blade_planed) - kRailtop) < 1e-9);
CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - alfa) < 1e-9); CHECK(std::abs(geometry.planing_length - (geometry.planing_end - kLead)) < 1e-12);
// and the catalogue length is still measured along the through track CHECK(geometry.planing_end > geometry.nose_end);
CHECK(std::abs(geometry.frog.x - 27.138) < 1e-6); }
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_blade_reaching_the_crossing_angle_is_rejected() { void a_straight_blade_is_planed_by_its_break() {
auto turnout = rz_1_9_r190(); // iglica prosta sieczna: the same two dimensions, taken off the break angle
turnout.blade_angle = std::atan(1.0 / 9.0); // nothing left for the curve to turn const double beta = 0.02;
CHECK(!lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout).valid); 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});
turnout.blade_angle = -0.01; const auto geometry = laid(turnout);
CHECK(!lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout).valid);
}
void a_bladeless_curve_still_starts_tangent() {
const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, rz_1_9_r190());
CHECK(geometry.valid); CHECK(geometry.valid);
for (const auto& segment : geometry.path) CHECK(segment.turn_in == 0.0); // 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);
} }
// With no blade length the break still stands: the curve simply starts at the tip. void a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start() {
void a_zero_length_blade_keeps_its_break() {
auto turnout = rz_1_9_r190(); auto turnout = rz_1_9_r190();
turnout.pre_blade = 3.5; turnout.blade = Blade{0.0, 0.0, 0.0};
turnout.blade_angle = 0.02; const auto geometry = laid(turnout);
const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout);
CHECK(geometry.valid); CHECK(geometry.valid);
CHECK(geometry.path.size() >= 2); CHECK(geometry.has_blade);
CHECK(std::abs(geometry.path[1].turn_in - 0.02) < 1e-12); CHECK(std::abs(geometry.blade_station - kLead) < 1e-12);
CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - CHECK(geometry.nose_end == 0.0);
std::atan(1.0 / 9.0)) < 1e-9); CHECK(geometry.planing_length == 0.0);
CHECK(geometry.blade_residual == 0.0);
} }
// A right-hand turnout is the mirror image: the blade breaks the other way. void a_catalogue_list_closes_on_its_own_numbers() {
void a_right_hand_blade_breaks_the_other_way() { 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(); auto turnout = rz_1_9_r190();
turnout.side = DivergeSide::Right; turnout.side = DivergeSide::Right;
turnout.pre_blade = 3.5; const auto geometry = laid(turnout);
turnout.blade_angle = 0.02;
turnout.blade_length = 6.0;
const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout);
CHECK(geometry.valid); CHECK(geometry.valid);
CHECK(std::abs(std::atan2(pose_after(geometry, 1).hy, pose_after(geometry, 1).hx) + CHECK(std::abs(geometry.path[1].k0 + 1.0 / kRadius) < 1e-12);
0.02) < 1e-9); CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + kAlfa19) < 1e-9);
CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + CHECK(std::abs(geometry.through_length - left.through_length) < 1e-9);
std::atan(1.0 / 9.0)) < 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 } // namespace
int main() { int main() {
RUN(pre_blade_runs_along_the_through_track); RUN(a_tangential_blade_never_breaks_away);
RUN(the_frog_still_lands_on_the_crossing_angle); RUN(a_planed_nose_reaches_its_thickness_where_the_table_says);
RUN(a_blade_reaching_the_crossing_angle_is_rejected); RUN(a_lead_derived_from_u_and_d_lands_on_the_table);
RUN(a_bladeless_curve_still_starts_tangent); RUN(a_straight_blade_is_planed_by_its_break);
RUN(a_zero_length_blade_keeps_its_break); RUN(a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start);
RUN(a_right_hand_blade_breaks_the_other_way); 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(); return REPORT();
} }