From 2376e4867b6341f3dbcf59ac81f4c49f045d0972 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sat, 29 Aug 2026 19:32:46 +0200 Subject: [PATCH] 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. --- CMakeLists.txt | 7 + NIWELETA_REDESIGN.md | 223 + TURNOUT_REDESIGN.md | 147 + TURNOUT_SPEC.md | 77 +- application/planpanel.cpp | 5934 ++++++++--------- application/planpanel.h | 240 +- editor/orthophoto.cpp | 61 +- editor/turnouts.cpp | 77 +- editor/turnouts.h | 34 +- maj0sted/CMakeLists.txt | 37 +- maj0sted/examples/CMakeLists.txt | 5 - maj0sted/examples/basic_editor.cpp | 20 - maj0sted/examples/plan_to_svg.cpp | 58 - .../include/maj0sted/app/basket_draft.hpp | 58 - .../include/maj0sted/app/editor_document.hpp | 34 - .../maj0sted/app/straight_constraints.hpp | 12 - .../domain/fitting/fit_parameters.hpp | 55 - .../maj0sted/domain/fitting/fit_result.hpp | 25 - .../domain/fitting/fitting_service.hpp | 69 - .../maj0sted/domain/fitting/gap_fitter.hpp | 74 - .../domain/geometry/horizontal_alignment.hpp | 60 - .../maj0sted/domain/geometry/plan_element.hpp | 127 - .../domain/geometry/profile_element.hpp | 53 - .../maj0sted/domain/geometry/turnout.hpp | 175 +- .../domain/geometry/vertical_alignment.hpp | 31 - .../include/maj0sted/domain/map_project.hpp | 32 - maj0sted/include/maj0sted/domain/niweleta.hpp | 70 - .../parallelism/parallelism_service.hpp | 39 - .../domain/parallelism/track_parallelism.hpp | 38 - .../maj0sted/domain/value_objects/azimuth.hpp | 67 - .../value_objects/cartesian_position.hpp | 27 - .../maj0sted/domain/value_objects/crs.hpp | 29 - .../maj0sted/domain/value_objects/length.hpp | 37 - .../domain/value_objects/niweleta_id.hpp | 36 - .../maj0sted/domain/value_objects/radius.hpp | 30 - .../domain/value_objects/straight_id.hpp | 36 - .../domain/value_objects/track_offset.hpp | 31 - maj0sted/include/maj0sted/editor/chain.hpp | 31 + maj0sted/include/maj0sted/editor/editor.hpp | 139 - maj0sted/include/maj0sted/editor/layout.hpp | 37 + maj0sted/include/maj0sted/editor/model.hpp | 217 + maj0sted/include/maj0sted/editor/render.hpp | 35 + maj0sted/include/maj0sted/editor/ribbon.hpp | 32 - maj0sted/include/maj0sted/editor/sketch.hpp | 118 + maj0sted/include/maj0sted/editor/solution.hpp | 169 + maj0sted/include/maj0sted/editor/solve.hpp | 55 - maj0sted/include/maj0sted/io/document_io.hpp | 31 + .../maj0sted/io/project_serializer.hpp | 19 - maj0sted/include/maj0sted/io/scn_export.hpp | 21 +- maj0sted/include/maj0sted/io/svg_writer.hpp | 20 - maj0sted/include/maj0sted/maj0sted.hpp | 50 +- .../include/maj0sted/render/plan_sampler.hpp | 18 - .../include/maj0sted/render/rail_renderer.hpp | 7 +- maj0sted/include/maj0sted/render/scene.hpp | 19 - .../maj0sted/render/track_renderer.hpp | 13 +- maj0sted/src/app/basket_draft.cpp | 389 -- maj0sted/src/app/editor_document.cpp | 520 -- maj0sted/src/app/straight_constraints.cpp | 112 - maj0sted/src/domain/fitting_service.cpp | 582 -- maj0sted/src/domain/gap_fitter.cpp | 215 - maj0sted/src/domain/horizontal_alignment.cpp | 95 - maj0sted/src/domain/map_project.cpp | 13 - maj0sted/src/domain/niweleta.cpp | 90 - maj0sted/src/domain/parallelism_service.cpp | 83 - maj0sted/src/domain/turnout.cpp | 333 +- maj0sted/src/domain/vertical_alignment.cpp | 13 - maj0sted/src/editor/chain.cpp | 141 + maj0sted/src/editor/editor.cpp | 434 -- maj0sted/src/editor/layout.cpp | 706 ++ maj0sted/src/editor/model.cpp | 74 + maj0sted/src/editor/render.cpp | 122 + maj0sted/src/editor/ribbon.cpp | 55 - maj0sted/src/editor/sketch.cpp | 481 ++ maj0sted/src/editor/solution.cpp | 118 + maj0sted/src/editor/solve.cpp | 98 - maj0sted/src/io/document_io.cpp | 544 ++ maj0sted/src/io/project_serializer.cpp | 291 - maj0sted/src/io/scn_export.cpp | 107 +- maj0sted/src/io/svg_writer.cpp | 63 - maj0sted/src/render/plan_sampler.cpp | 116 - maj0sted/src/render/rail_renderer.cpp | 6 +- maj0sted/tests/CMakeLists.txt | 67 +- maj0sted/tests/test_chain.cpp | 230 + maj0sted/tests/test_document.cpp | 144 - maj0sted/tests/test_editor.cpp | 271 - maj0sted/tests/test_fitting.cpp | 346 - maj0sted/tests/test_format.cpp | 279 + maj0sted/tests/test_graph.cpp | 225 + maj0sted/tests/test_hold.cpp | 310 + maj0sted/tests/test_lines.cpp | 549 ++ maj0sted/tests/test_map_project.cpp | 25 - maj0sted/tests/test_niweleta.cpp | 110 - maj0sted/tests/test_parallelism.cpp | 85 - maj0sted/tests/test_plan_alignment.cpp | 132 - maj0sted/tests/test_render.cpp | 78 - maj0sted/tests/test_serialization.cpp | 92 - maj0sted/tests/test_turnout.cpp | 460 +- 97 files changed, 8608 insertions(+), 9792 deletions(-) create mode 100644 NIWELETA_REDESIGN.md create mode 100644 TURNOUT_REDESIGN.md delete mode 100644 maj0sted/examples/CMakeLists.txt delete mode 100644 maj0sted/examples/basic_editor.cpp delete mode 100644 maj0sted/examples/plan_to_svg.cpp delete mode 100644 maj0sted/include/maj0sted/app/basket_draft.hpp delete mode 100644 maj0sted/include/maj0sted/app/editor_document.hpp delete mode 100644 maj0sted/include/maj0sted/app/straight_constraints.hpp delete mode 100644 maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp delete mode 100644 maj0sted/include/maj0sted/domain/fitting/fit_result.hpp delete mode 100644 maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp delete mode 100644 maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp delete mode 100644 maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp delete mode 100644 maj0sted/include/maj0sted/domain/geometry/plan_element.hpp delete mode 100644 maj0sted/include/maj0sted/domain/geometry/profile_element.hpp delete mode 100644 maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp delete mode 100644 maj0sted/include/maj0sted/domain/map_project.hpp delete mode 100644 maj0sted/include/maj0sted/domain/niweleta.hpp delete mode 100644 maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp delete mode 100644 maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/crs.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/length.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/radius.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp create mode 100644 maj0sted/include/maj0sted/editor/chain.hpp delete mode 100644 maj0sted/include/maj0sted/editor/editor.hpp create mode 100644 maj0sted/include/maj0sted/editor/layout.hpp create mode 100644 maj0sted/include/maj0sted/editor/model.hpp create mode 100644 maj0sted/include/maj0sted/editor/render.hpp delete mode 100644 maj0sted/include/maj0sted/editor/ribbon.hpp create mode 100644 maj0sted/include/maj0sted/editor/sketch.hpp create mode 100644 maj0sted/include/maj0sted/editor/solution.hpp delete mode 100644 maj0sted/include/maj0sted/editor/solve.hpp create mode 100644 maj0sted/include/maj0sted/io/document_io.hpp delete mode 100644 maj0sted/include/maj0sted/io/project_serializer.hpp delete mode 100644 maj0sted/include/maj0sted/io/svg_writer.hpp delete mode 100644 maj0sted/include/maj0sted/render/plan_sampler.hpp delete mode 100644 maj0sted/src/app/basket_draft.cpp delete mode 100644 maj0sted/src/app/editor_document.cpp delete mode 100644 maj0sted/src/app/straight_constraints.cpp delete mode 100644 maj0sted/src/domain/fitting_service.cpp delete mode 100644 maj0sted/src/domain/gap_fitter.cpp delete mode 100644 maj0sted/src/domain/horizontal_alignment.cpp delete mode 100644 maj0sted/src/domain/map_project.cpp delete mode 100644 maj0sted/src/domain/niweleta.cpp delete mode 100644 maj0sted/src/domain/parallelism_service.cpp delete mode 100644 maj0sted/src/domain/vertical_alignment.cpp create mode 100644 maj0sted/src/editor/chain.cpp delete mode 100644 maj0sted/src/editor/editor.cpp create mode 100644 maj0sted/src/editor/layout.cpp create mode 100644 maj0sted/src/editor/model.cpp create mode 100644 maj0sted/src/editor/render.cpp delete mode 100644 maj0sted/src/editor/ribbon.cpp create mode 100644 maj0sted/src/editor/sketch.cpp create mode 100644 maj0sted/src/editor/solution.cpp delete mode 100644 maj0sted/src/editor/solve.cpp create mode 100644 maj0sted/src/io/document_io.cpp delete mode 100644 maj0sted/src/io/project_serializer.cpp delete mode 100644 maj0sted/src/io/svg_writer.cpp delete mode 100644 maj0sted/src/render/plan_sampler.cpp create mode 100644 maj0sted/tests/test_chain.cpp delete mode 100644 maj0sted/tests/test_document.cpp delete mode 100644 maj0sted/tests/test_editor.cpp delete mode 100644 maj0sted/tests/test_fitting.cpp create mode 100644 maj0sted/tests/test_format.cpp create mode 100644 maj0sted/tests/test_graph.cpp create mode 100644 maj0sted/tests/test_hold.cpp create mode 100644 maj0sted/tests/test_lines.cpp delete mode 100644 maj0sted/tests/test_map_project.cpp delete mode 100644 maj0sted/tests/test_niweleta.cpp delete mode 100644 maj0sted/tests/test_parallelism.cpp delete mode 100644 maj0sted/tests/test_plan_alignment.cpp delete mode 100644 maj0sted/tests/test_render.cpp delete mode 100644 maj0sted/tests/test_serialization.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e7ab124..12ee6ab0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -513,6 +513,13 @@ endif() find_package(Threads REQUIRED) target_link_libraries(${PROJECT_NAME} Threads::Threads) +# the editor's map imagery talks to a WMS over https. windows has WinHttp in the platform sdk; +# everywhere else libcurl is the transport (see editor/orthophoto.cpp) +if (NOT WIN32) + find_package(CURL REQUIRED) + target_link_libraries(${PROJECT_NAME} CURL::libcurl) +endif() + find_package(GLM REQUIRED) target_include_directories(${PROJECT_NAME} PRIVATE ${GLM_INCLUDE_DIR}) diff --git a/NIWELETA_REDESIGN.md b/NIWELETA_REDESIGN.md new file mode 100644 index 00000000..47cfd1b3 --- /dev/null +++ b/NIWELETA_REDESIGN.md @@ -0,0 +1,223 @@ +# Odcinki niwelety — jak to powinno być zrobione + +Dokument projektowy, nie opis stanu. Siostrzany do `TURNOUT_REDESIGN.md`, który +w §2.2 i §3.2 nazwał ten problem i odłożył go. To jest ten odłożony krok. + +Teza: model nie umie powiedzieć rzeczy najprostszej — **„tu zaczyna się łuk”**. +Wszystko inne jest skutkiem. + +## 1. Co jest dziś + +Fakty, nie opinie: + +- **Krzywa nie jest bytem.** Gap *i* jest zawsze między prostą *i* a *i+1*. + Twardo założone w `align_gap_fits` (przepisuje fity na gęstą tablicę + `straights-1`), w `editor.cpp` (`fits(n-1)`, prosta przycinana wyłącznie przez + dwa sąsiadujące fity), w `basket_draft.cpp` i w `planpanel.cpp`. +- Stąd **wyjątki**: `hidden` prosta-prowadnica, tryb 5, i `BasketDraft` — + czwarta reprezentacja ciągu (obok `NiweletaSpec`, `FitResult` i `RibbonRequest`), + która przy commicie musi **dorobić sztuczną 20-metrową prostą wyjściową**, żeby + kosz miał na czym stanąć. +- **Referencje pozycyjne.** `GapFit::gap`, `rel_niw`, `rel_str`, `frog_of`, + `Junction::through/branch` to indeksy w wektorze; wstawienie prostej wymaga + ręcznego przenumerowania w dwóch pętlach. Mocne `StraightId`/`NiweletaId` + istnieją w `domain/value_objects/` i **nie są używane**. +- **Dwa źródła prawdy o współrzędnych.** Dokument trzyma to, co narysowane; + solver zwraca `rendered_straights` i `pinned`, a host musi wpisać `pinned` z + powrotem do dokumentu — inaczej „dokument i rysunek się rozjeżdżają”. +- **Solver zjada intencję.** Nieudany fit wraca jako `nullopt` (każdy `throw` + z `FittingService` jest połykany przez `catch (...)`) i znika z `applied_fits`. + Bez powodu, bez liczby. `basket_draft` musi robić próbne `solve_project` + i sprawdzać, czy jego fit „przeżył”. +- **Brak porządku rozwiązywania.** Stała pętla 3 przebiegów relaksacji + i sztywne dwa przejścia w `solve_layout`. Rozjazd na odnodze rozjazdu nie ma + prawa się zbiec. +- **Druga, martwa warstwa.** `domain::Niweleta` + `HorizontalAlignment` + + `MapProject` + cały profil: host nie zna ani jednego z tych typów, + `to_map_project` fabrykuje placeholderowe łuki R=300 tylko po to, żeby przejść + walidację złącz, a reader i tak przewija ten blok bez czytania. +- `Junction::pre_blade/blade_angle/blade_length` nie są zapisywane ani czytane — + po `load` zerują się. + +## 2. Jak to robią inni + +- **IFC 4.3** — `IfcAlignmentHorizontal/Vertical/Cant`, każdy jako ciąg + `IfcAlignmentSegment`: `StartDistAlong`, `SegmentLength`, promień na początku + i na końcu, typ `LINE|CIRCULARARC|CLOTHOID`. Styczność jest **regułą walidacji + między segmentami**, nie własnością reprezentacji. +- **LandXML** — `CoordGeom` = `Line|Curve|Spiral`; profil osobno (`ProfAlign`: + `PVI|CircCurve|ParaCurve`). Poziom i pion to dwa niezależne ciągi nad wspólną + stacją. +- **Bentley OpenRoads** — dwa tryby autorstwa nad **jednym** modelem elementów: + *by PI* (wierzchołki i promienie — nasze „proste i luki”) oraz *by Element* + (dokładanie odcinków — nasz `BasketDraft`). Element niesie *rules*: styczny do + X, przez punkt P, offset od Y. To jest brakujące ogniwo. +- **Civil 3D Rail, „Diverted Profiles”** — geometria odnogi jest **wyprowadzana** + z toru zasadniczego w rozjeździe, nie autorowana niezależnie i doklejana. +- **railML** — wszystko parametryzowane pozycją wzdłuż toru, nigdy indeksem + elementu. + +Wniosek wspólny: **odcinek (k0, k1, L) jest jednostką, stacja jest parametrem, +styczność jest własnością reprezentacji, a „jak ten odcinek jest wyznaczony” jest +osobnym, jawnym polem.** Kernel do tego już mamy: `geometry::layout_segment`. + +## 3. Model docelowy + +Płasko, w `maj0sted::editor`, Id jako mocne typy. + +### 3.1. Odcinek jest bytem + +```cpp +enum class Kind { Line, Arc, Clothoid }; + +using Determination = std::variant< + FixedLength, // długość podana wprost + ToLine, // biegnij aż dojdziesz do tej prostej kierunkowej + ToPoint, // przez ten punkt + Remainder // pochłoń resztę odchylenia +>; + +struct Element { ElementId id; Kind kind; double radius; int hand; Determination det; }; +``` + +Odcinek zaczyna się tam, gdzie kończy się poprzedni — **pozą**, nie +współrzędnymi. Styczność przestaje być regułą do sprawdzania i staje się +własnością zapisu. Znikają `JointContinuity`, `validate_joint`, +`pin_branch_to_frog`, `rendered_straights` i cały dryf. + +### 3.2. Prosta kierunkowa oddzielona od toru + +```cpp +struct ConstructionLine { LineId id; double x1,y1,x2,y2; Binding binding; }; +``` + +Dziś `StraightSpec` jest jednocześnie prostą kierunkową i kawałkiem toru — to +jest źródło dwóch prawd o współrzędnych. Prosta kierunkowa nigdy nie jest torem; +torem jest `Element{Line, ToLine{id}}`. `hidden` przestaje być potrzebne: +prowadnica to linia, na której nie leży żaden element. + +### 3.3. Kotwica i porty — graf zamiast przypinania + +```cpp +struct Track { TrackId id; std::string name; Anchor anchor; std::vector elements; }; +using Anchor = std::variant; +``` + +`frog_of` → `AtPort`. Znikają `place_frog_straight`, `LayoutSolution::pinned` +i zapis solvera do dokumentu. + +### 3.4. Rozjazd: typ + umiejscowienie + +`TURNOUT_REDESIGN.md` §3.3, teraz odblokowane — krzyżownica jest portem: + +```cpp +struct TurnoutType { name, crossing_n, radius, length, pre_blade, blade_angle, blade_length }; +struct TurnoutPlacement { id, type, on, station, hand, facing }; +``` + +Naprawia przy okazji niezapisywane liczby iglicy: idą do katalogu, egzemplarz +trzyma nazwę typu. + +### 3.5. Solve — czysta funkcja, po Id, z diagnostyką + +```cpp +struct Solution { map; map; vector; }; +Solution solve(const Document&); +``` + +1. **Nic nie wraca do dokumentu.** Host rysuje `Solution` i w nią trafia. +2. **Nieudany fit to diagnostyka, nie cisza.** Geometria do tego odcinka zostaje + położona, łańcuch urywa się i jest rysowany jako kikut, a użytkownik dostaje + powód i liczbę. +3. **Kolejność z DAG-u.** Zależności (kotwice, `ToLine`, bindingi, rozjazdy) dają + graf; sortowanie topologiczne zastępuje 3 przebiegi relaksacji i sztywne dwa + przejścia. Cykl → diagnostyka, nie śmieci. + +### 3.6. Dwa tryby autorstwa nad jednym ciągiem + +To jest właściwy zysk. Dokładanie odcinek po odcinku i fitowanie przestają być +dwoma światami (`BasketDraft` kontra `GapFit`) i stają się dwoma sposobami +wypełnienia pola `Determination`. + +**Odcinek po odcinku** (`FixedLength`, `ToPoint`) — start z kotwicy, każdy +kolejny odcinek styczny do pozy końcowej poprzednika. Trzy rzeczy dziś +niewyrażalne wychodzą same: + +- **łuk wprost z krzyżownicy** — bez prostej KR, bez prowadnicy; +- **łuk po łuku** bez prostej między nimi; +- **ciąg, który się nie domyka** — bez sztucznej prostej wyjściowej. + +Każdy dołożony odcinek idzie od razu do dokumentu, nie do bufora szkicu. Cofanie +to usunięcie ostatniego elementu. Podgląd przestaje kłamać: dziś `draft_to_ribbon` +liczy kąt każdego łuku jako `L/R`, a `fit_compound` daje ostatniemu łukowi resztę +odchylenia — to są **różne krzywe**. + +**Fitowanie** (`ToLine`, `Remainder`) — między dwoma odcinkami `ToLine` łańcuch +z dokładnie jednym `Remainder`: + +| chcesz | łańcuch | +|---|---| +| sam łuk (tryb 1) | `Arc{R, Remainder}` | +| łuk z klotoidami (tryb 2) | `Clot{R,L}` → `Arc{R, Remainder}` → `Clot{∞,L}` | +| łuk koszowy (tryb 3) | `Arc{R₁,L₁}` → `Clot` → … → `Arc{Rₙ, Remainder}` | +| nawrót 180° (tryb 4) | ten sam łańcuch — nawrót wynika z geometrii | +| S-krzywa | `Arc{R,+1,L}` → `Arc{R,-1, Remainder}` | + +Tryby `GapFit` 0–4 znikają: to były nazwy dla pięciu kształtów łańcucha. + +**Mieszanie w jednym ciągu** — dziś nie ma tego w ogóle, bo wszystko między +dwiema prostymi musi być jednym `GapFit`: + +``` +anchor port turnout=3 KR +elem 11 arc 190 -1 len 40 ← odcinek po odcinku, prosto z krzyżownicy +elem 12 clot 600 -1 len 30 +elem 13 arc 600 -1 rem ← domknięcie fitem… +elem 14 arc 400 1 rem ← …dwoma łukami, bo start jest sztywny… +elem 15 line 0 0 toline 7 ← …na prostą kierunkową 7 +``` + +**Stopnie swobody zamiast wyjątków.** Domknięcie to dwa równania: wylądować na +prostej i być do niej stycznym. Łańcuch wchodzący *wzdłuż prostej* może się po +niej przesuwać — to jeden stopień swobody — więc potrzebuje dokładnie **jednego** +`Remainder`; to jest każdy dzisiejszy fit. Łańcuch wychodzący ze **sztywnej pozy** +(kotwica, zwykle krzyżownica) przesuwać się nie może, więc potrzebuje **dwóch**. +Łańcuch, który nie domyka się na nic — **zera**. + +Solver to liczy i mówi wprost: za mało → „przewiązany, brakuje N stopni swobody”; +za dużo → „niedookreślony, podaj długość jednego z łuków”; `Remainder ≤ 0` → ile +odchylenia było do rozdania i ile zużyły odcinki o zadanej długości. + +Uwaga na marginesie: odchylenie bierzemy z krótkiej gałęzi `atan2`, więc łuk +domykający nigdy nie skręci dalej, niż narożnik naprawdę się załamuje. Jazda +dookoła — nawrót powyżej 180°, pętla — jest czymś, co się **kładzie** zadanymi +długościami, nie czymś, co fit ma po cichu za nas wybrać. + +### 3.7. Format `.m0s` v2 + +Jawny numer wersji zamiast przemianowywania tagów, wszystko po Id, bez doklejania +tokenów na koniec linii. Bez zgodności wstecz. + +## 4. Co zostaje bez zmian + +Warto powiedzieć wprost, bo to nie jest przepisywanie wszystkiego: + +- `geometry::layout_segment` — jedyny ewaluator odcinka, zostaje. +- `domain::lay_turnout`, `Turnout`, `TurnoutGeometry` — zostają. +- Matematyka fitowania (`solve_chain`, `fit_compound`) — zmienia się interfejs + (powód zamiast `throw`/`nullopt`), nie liczby. +- `ParallelismService::project_onto_offset_line` — jako `Binding::Parallel`. +- `RailRenderer`, `TrackRenderer`, kafle, georeferencja, eksport `.scn` — bez + związku z modelem. + +Do wyrzucenia jest **klej**: przypinanie, prowadnice, tryby jako wyjątki, +`BasketDraft` i martwa warstwa agregatowa. + +## 5. Poza zakresem + +**Niweleta w profilu (pion).** `VerticalAlignment`, `ProfileElement`, `Grade`, +`VerticalCurve` istnieją, ale nic ich nie liczy, `.m0s` ich nie niesie, a +`scn_export` i tak wpisuje `y=0.2` i `roll=0` dla każdego węzła — mimo że format +`.scn` niesie `z` na obu końcach segmentu i `roll1`/`roll2` interpolowane +liniowo. Lecą jako martwy kod i wracają jako osobny temat: ten sam kształt +ciągu, o wymiar niżej, nad tą samą stacją. diff --git a/TURNOUT_REDESIGN.md b/TURNOUT_REDESIGN.md new file mode 100644 index 00000000..c0e2fc7f --- /dev/null +++ b/TURNOUT_REDESIGN.md @@ -0,0 +1,147 @@ +# Rozjazdy — jak to powinno być zrobione + +Dokument projektowy, nie opis stanu. `TURNOUT_SPEC.md` mówi, co edytor oddaje +runtime'owi; ten mówi, **jak edytor powinien trzymać rozjazd u siebie**, bo obecny +model się nie broni i każda kolejna funkcja jest w nim przypadkiem szczególnym. + +## 1. Co jest dziś + +Fakty, nie opinie: + +- `NiweletaSpec` = lista **prostych zakotwiczonych w XY** + `fits` w lukach między + kolejnymi prostymi. Krzywa nie jest bytem — jest wynikiem wpasowania w lukę. +- `Junction` (rozjazd) to **relacja** na niwelecie zasadniczej: stacja + liczby + katalogowe. Kładzie własną ścieżkę odgałęzienia (`lay_turnout`), ale ta ścieżka + **nie należy do żadnej niwelety** — jest rysowana osobno z `JunctionGeom`. +- Odnoga to osobna niweleta, **przypinana sztywno** do krzyżownicy przy każdym + rozwiązaniu (`pin_branch_to_frog`). +- Równolegle istnieje `BasketDraft` (395 linii) — drugi, własny mechanizm + budowania krzywych, nie przechodzący przez model luk. +- `Junction` niesie **jednocześnie** liczby katalogowe (skos, R, długość, iglica) + i swobodną krzywą wewnętrzną (`GapFit curve` z dowolnym koszem). +- Rozjazdy są serializowane **pozycyjnie i ręcznie**. `pre_blade`, `blade_angle` + i `blade_length` nie są zapisywane w ogóle — doszły do modelu i wypadły z pliku. + +## 2. Cztery przyczyny źródłowe + +**2.1. Geometria rozjazdu leży poza grafem toru.** +Tor zasadniczy nie jest dzielony (słusznie), ale odgałęzienie rozjazdu nie należy +do niczego, a odnoga jest osobnym bytem. Ciągłość między krzyżownicą a odnogą nie +wynika z konstrukcji, tylko z **doklejania jej z powrotem przy każdym solve**. +Stąd błąd „odnoga uciekła 1,4 km", stąd konieczność oddawania `pinned` do +dokumentu, stąd szkic startujący ze starego miejsca. + +**2.2. Krzywa jest obywatelem drugiej kategorii.** +Model brzmi „proste i luki między nimi". Wszystko, co nie jest łukiem między +dwiema prostymi, wymaga wyjątku: ukryta prosta-prowadnica, tryb 5, `BasketDraft`. +Dlatego „dodaj łuk wprost z krzyżownicy" było trudne — w danych **nie ma jak +powiedzieć „element: łuk, zaczyna się tutaj"**. + +**2.3. Dwa źródła prawdy o współrzędnych.** +Dokument trzyma to, co narysowane; solver trzyma przypięte i przycięte +(`rendered_straights`). GUI czyta raz jedno, raz drugie. Stąd łuk startujący z +azymutem 0 i cała rodzina błędów „to nie jest tam, gdzie jest narysowane". + +**2.4. Typ katalogowy zmieszany z egzemplarzem.** +„Rz 1:9 R190" ma geometrię **określoną przez typ**. Pozwalanie, by egzemplarz +miał dowolny łuk koszowy w środku, jest bez sensu — a jednocześnie to, co +naprawdę jest swobodne (dokąd biegnie tor za krzyżownicą), było najtrudniejsze do +edycji. Skutek uboczny: każda liczba katalogowa to kolejne pole doklejane do +formatu pliku. + +## 3. Model docelowy + +### 3.1. KR jest prostą *(zrobione)* + +Najprostsze możliwe ujęcie, i to ono wygrało: **koniec rozjazdu to zwyczajna +prosta**, tylko inaczej związana niż narysowana ręcznie. + +``` +StraightSpec { + ... + int frog_of; // indeks rozjazdu, którego KR opuszcza ta prosta; -1 = zwykła +} +``` + +Początek i kierunek pisze solver z krzyżownicy, długość zostaje jej własna. +I to wszystko — **łuk za rozjazdem przestaje istnieć jako pojęcie**. Jest fitem w +luce między dwiema prostymi, jak każdy inny: to samo combo, ten sam promień, ten +sam kosz, ten sam komunikat „does not fit". + +Styczność w krzyżownicy nie jest pilnowana, tylko **wynika**: prosta z KR leży na +stycznej, a fit między prostymi jest styczny z definicji. + +Skasowało to: przypinanie całej odnogi, tryb 5, ukrytą kotwicę, `LeadArc`, +`StartPose` i cały `pose_fitter`. Odnoga przestała być bytem specjalnym — jest +niweletą, której pierwsza prosta ma jedną więź więcej. + +Efekt uboczny, ważniejszy niż czystość: **to, co narysowane, zostaje tam, gdzie +narysowane**. Wcześniej cała odnoga jechała sztywno za krzyżownicą. + +### 3.2. Element *(do zrobienia, ale już nie dla rozjazdów)* + +„Proste i luki" nadal wymaga wyjątku wszędzie tam, gdzie krzywa ma istnieć sama +z siebie — stąd `BasketDraft`. Docelowo prosta, łuk i klotoida są równorzędnymi +elementami ciągu, a to, co dziś robi szkic kosza, jest zwykłym dokładaniem +elementów. Rozjazdów to już nie dotyczy: 3.1 załatwiło je bez tego. + +### 3.3. Rozjazd jako urządzenie typu *(do zrobienia)* + +Rozdzielić dwie rzeczy, dziś zlepione w `Junction`: + +``` +TurnoutType { // katalog, dane z Id-1, niezmienne + name, crossing_n, radius, length, + pre_blade, blade_angle, blade_length, rail_profile +} + +TurnoutPlacement { // to, co edytuje użytkownik + type : TurnoutTypeRef // po nazwie, nie przez kopiowanie liczb + on : AlignmentRef, station, hand : L|P, facing +} +``` + +**Strefa rozjazdu jest nienaruszalna, bo jest egzemplarzem typu.** Nie da się jej +edytować, bo nie ma czego — liczby należą do typu. Rozjazd nietypowy to własny +typ, nie dowolność w egzemplarzu. + +Serializacja przestaje puchnąć: egzemplarz zapisuje `type` po nazwie plus +umiejscowienie. Nowa liczba katalogowa (jak dzisiejsze `blade_angle`, które wciąż +nie trafia do pliku) idzie do katalogu i **nie dotyka formatu**. + +## 4. Co to naprawia — konkretnie + +| dzisiejszy problem | dlaczego znika | +|---|---| +| odnoga ucieka / dryfuje od krzyżownicy | start odnogi jest referencją do portu | +| szkic z azymutem 0 | nie ma drugiego źródła współrzędnych | +| „robi się sztywna prosta" za rozjazdem | pierwszy element odnogi może być łukiem | +| łuk wymaga dwóch prostych | element powstaje z pozy i punktu | +| kosz w środku „Rz 1:9" | geometria wewnętrzna należy do typu | +| liczby iglicy nie zapisują się | egzemplarz odwołuje się do typu po nazwie | +| `BasketDraft` obok modelu | kosz to ciąg elementów-łuków, nic osobnego | + +## 5. Etapy + +1. ~~Odnoga przestaje być przypinana~~ — **zrobione** przez 3.1: `frog_of`. + Zniknęły `pin_branch_to_frog`, tryb 5, ukryta kotwica, `LeadArc` i + `pose_fitter`. +2. **Typ oddzielony od umiejscowienia** (3.3). Katalog Id-1 jako dane; egzemplarz + trzyma nazwę typu. Naprawia przy okazji serializację liczb iglicy. +3. **Elementy pierwszej kategorii** (3.2) i wycofanie `BasketDraft` — 395 linii + równoległego świata. + +## 6. Co zostaje bez zmian + +Warto powiedzieć wprost, bo to nie jest przepisywanie wszystkiego: + +- `domain::lay_turnout` i cała geometria rozjazdu — dobre, zostaje. +- `TurnoutPart` i punkty konstrukcyjne (`JunctionMark`) — zostają. +- `fit_arc_from_pose`, `fit_arc_from_pose_through` — to **są** właściwe prymitywy + nowego modelu, przechodzą nietknięte. +- `GapFitter` i fitowanie między prostymi — zostaje, jako jeden ze sposobów + wyznaczania elementu swobodnego. +- Rysowanie szyn, kafle, georeferencja — bez związku. + +Do wyrzucenia jest przede wszystkim **klej**: przypinanie, ukryte prowadnice, +tryb 5 jako wyjątek i `BasketDraft`. diff --git a/TURNOUT_SPEC.md b/TURNOUT_SPEC.md index adbafbab..939ccf8d 100644 --- a/TURNOUT_SPEC.md +++ b/TURNOUT_SPEC.md @@ -34,15 +34,24 @@ Turnout { PR : Pose // początek rozjazdu: styczny do toru zasadniczego KR : Pose // koniec (krzyżownica): kierunek = tor zasadniczy obrócony o alfa + length : L = a + b // katalogowa: styczna przednia + tylna + // (Rz 190-1:9: 13,834 + 13,304 = 27,138) + // koniec rozjazdu na torze zasadniczym to + // rzut KR na prostą, krótszy od L crossing_angle : alfa = atan(1/n) // radiany - blade_angle : beta // kąt nagięcia iglicy (kąt zwrotnicowy) - pre_blade_length : d // odcinek przediglicowy: prosty, PR -> ostrze iglicy + pieces : [Piece...] // geometria toru zwrotnego, PR -> KR, wprost z katalogu + blade : { u, w } // grubosc scietego ostrza, szerokosc glowki szyny through_axis : [Bezier...] // tor zasadniczy przez rozjazd (zwykle prosty) - diverging_axis: [Bezier...] // 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 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 `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 - `PR` do ostrza iglicy. Tu rozjazd jest jeszcze równoległy do toru głównego; - tu siedzi napęd. -2. **iglica** — **prosta odcięta pod kątem `beta`** („prosta dogięta do - prostego"). Odnoga odchodzi od toru zasadniczego dopiero tutaj, o kąt nagięcia - iglicy `beta`. -3. **łuk** — promień `R`, skręca od `beta` do kąta krzyżownicy `alfa`, czyli o - **`alfa − beta`** (nie o całe `alfa`). -4. **prosta krzyżownicowa** — pod kątem `alfa`, do `KR`. +- **zwrotnica sieczna z iglicą prostą** — `[przediglicowy] [iglica prosta @ beta] + [łuk o alfa − beta] [prosta krzyżownicowa]`, +- **zwrotnica z iglicą łukową styczną do opornicy** — `[iglica łuk R od PR] + [łuk R] [prosta krzyżownicowa]`, `beta = 0` wszędzie: odnoga odchodzi stycznie, + bez załamania kierunku. -Stąd `diverging_axis` = `[przediglicowy] [iglica @ beta] [łuk] [prosta] `. Iglice -łukowe (styczne, `beta ≈ 0`, ostrze wtopione w łuk) to inny wariant — na razie -modelujemy iglicę prostą. +**Ścięte ostrze** (Koc, rys. 5.9). Iglicy nie da się zaostrzyć do zera. Łuk +iglicy jest **teoretycznie styczny do opornicy w PR**, ale sama iglica zaczyna +się dopiero tam, gdzie jest z czego — w *ostrzu*, w odległości `a` od PR — a jej +zestrugany dziób o długości `d` dochodzi do punktu **A**, gdzie iglica ma już +grubość `u`. To, co leży między PR a ostrzem, to odcinek przediglicowy: krzywa +już tam jest, pojazd jeszcze jedzie po torze zasadniczym. -**Model:** zespół iglic kotwiony w ostrzu (`PR` + `pre_blade_length` wzdłuż toru -głównego), zorientowany wzdłuż toru zasadniczego, nadaje `beta`; długość iglicy -jest po stronie modelu. +Wszystko wychodzi z jednej drogi — do zadanego luzu między iglicą a opornicą: + +``` +s(y) = R * (acos(cos beta - y/R1) - beta) // iglica łukowa, R1 = R + s/2 +s(y) = y / sin beta // iglica prosta +a + d = s(u) struganie = s(w) - a +``` + +Luz otwiera się na **łuku samej iglicy** (`R1 = R + s/2`, Koc 5.2), ale `s(y)` +jest stacją **po osi** — promień szyny zamienia luz na kąt, nie mierzy długości. +Rysunek wymiaruje iglicę po szynie, więc katalogowe `a + d` to `s(u)` przeskalowane +o `R1/R`; na tablicy 5.1 zgadza się do dziesiątej części milimetra przy +1200–1:18,5 i do sześciu przy 190–1:9. Dlatego typ niesie tylko `u`, `d` i `w`, +a `a` się **wyprowadza**. + +(Templotowe `pl = w / tan beta` to to samo struganie rzutowane na opornicę.) + +**Model:** zespół iglic kotwiony w ostrzu, zorientowany wzdłuż osi iglicy w tym +punkcie; edytor podaje `PR`, ostrze, koniec dzioba `A` i koniec strugania jako +znaczniki (`kind` 0, 1, 7, 8). ## Bézier — definicja i próbkowanie @@ -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ć: - **`crossing_mark` i `rail_profile`** w danych eksportu, -- **`blade_angle` (beta) i `pre_blade_length`** — dziś `lay_turnout` kładzie łuk - styczny od `PR` (beta = 0, brak przediglicowego). Trzeba: odcinek przediglicowy - (prosty), iglicę prostą pod `beta`, łuk o `alfa − beta`, prostą do `KR`, -- te dwa parametry **w szablonach** (`turnout_preset`) per typ, +- ~~`blade_angle` i `pre_blade_length`~~ — zrobione inaczej: `lay_turnout` kładzie + **listę odcinków** z typu, więc przediglicowy, iglica (prosta pod `beta` albo + łukowa styczna), łuk i prosta krzyżownicowa to po prostu jej pozycje, +- **wymiary Id-1 w szablonach** (`turnout_preset`): długości iglic i odcinków + przediglicowych, `u` per typ — dziś liczone z odsunięcia pięty, - osie jako **Béziery** (dziś polilinie) — konwersja łuk/klotoida → kubiczne wg powyższego, - jawne oznaczenie „to rozjazd" (nie zwykły łuk), z `PR`/`KR`/`alfa`. diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 6a91492e..de68271f 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -16,19 +16,26 @@ http://mozilla.org/MPL/2.0/. #include "rendering/renderer.h" #include "utilities/Globals.h" -#include "maj0sted/editor/ribbon.hpp" +#include "maj0sted/editor/sketch.hpp" +#include "maj0sted/io/document_io.hpp" #include #include #include -#include +#include #include -#include -#include namespace { +using maj0sted::editor::Element; +using maj0sted::editor::ElementId; +using maj0sted::editor::Free; +using maj0sted::editor::Kind; +using maj0sted::editor::Parallel; +using maj0sted::editor::TrackId; +using maj0sted::editor::TurnoutId; + // the plan works in EPSG:2180, the scenery in the engine's world space around its own zero. the // georeference says which point of the projection that zero stands for; a fictional scenery leaves // it at the origin, so the two frames differ only in the naming of the axes - easting runs along @@ -38,6 +45,170 @@ glm::dvec3 plan_to_world(double const X, double const Y) return {X - Global.scenery_origin.x, 0.0, -(Y - Global.scenery_origin.y)}; } +// the construction points of a turnout, by TurnoutMark::kind. the map and the template drawing name +// them from this one list +char const *const mark_names[]{"PR", "ostrze", "pieta iglicy", "styk", "koniec luku", + "KR", "punkt teoretyczny", "dziob u", "koniec strugania", "koniec rozjazdu"}; + +// an angle the way a turnout drawing gives it. the kat przylegania is a couple of dozen minutes, so +// degrees alone say nothing +std::string angle_text(double const Radians) +{ + auto const total{std::abs(Radians) * 180.0 / std::numbers::pi}; + auto const degrees{static_cast(total)}; + auto const minutes{(total - degrees) * 60.0}; + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%s%d st %02d' %05.2f\"", Radians < 0.0 ? "-" : "", degrees, static_cast(minutes), + (minutes - static_cast(minutes)) * 60.0); + return buffer; +} + +// What a bend works out to, said the way a catalogue says it: both radii and the beta the tor +// zasadniczy turns through. Jednostronny is both tracks bent the same way, dwustronny the opposite. +std::string bend_text(double const Bend_, double const Diverging_, double const Angle_) +{ + if (Bend_ == 0.0) + { + return "rozjazd zwyczajny: tor zasadniczy prosty"; + } + auto const way{[](double const Curvature_) { return Curvature_ > 0.0 ? "w lewo" : "w prawo"; }}; + char text[256]; + if (Diverging_ == 0.0) + { + std::snprintf(text, sizeof(text), "rozjazd lukowy dwustronny: zasadniczy R %.3f m %s, zwrotny prosty, beta %s", std::abs(1.0 / Bend_), way(Bend_), + angle_text(Angle_).c_str()); + } + else + { + std::snprintf(text, sizeof(text), "rozjazd lukowy %s: zasadniczy R %.3f m %s, zwrotny R %.3f m %s, beta %s", + Diverging_ * Bend_ > 0.0 ? "jednostronny" : "dwustronny", std::abs(1.0 / Bend_), way(Bend_), std::abs(1.0 / Diverging_), + way(Diverging_), angle_text(Angle_).c_str()); + } + return text; +} + +// The two limits Koc puts on bending an ordinary turnout. Reported, never enforced: the drawing is +// the user's, and a warning that says the number is worth more than a control that refuses it +char const *bend_limit(double const Diverging_) +{ + if (Diverging_ == 0.0) + { + return nullptr; + } + auto const radius{std::abs(1.0 / Diverging_)}; + if (radius < 190.0) + { + return "tor zwrotny ponizej 190 m - najmniejszego promienia rozjazdu"; + } + if (radius < 214.0) + { + return "tor zwrotny ponizej 214 m - potrzebne poszerzenie toru zwrotnego"; + } + return nullptr; +} + +// a dashed polyline in screen space, for the lines a drawing means as theoretical. the dash carries +// across the joints between segments so a curve built of several pieces reads as one line +void dashed_polyline(ImDrawList *Drawlist_, std::vector const &Points_, ImU32 const Colour_, float const Thickness_, float const Dash_ = 7.0f, + float const Gap_ = 5.0f) +{ + // the view magnifies to millimetres, so a line can run for millions of pixels: what falls outside + // the clip rect is stepped over rather than drawn, or the draw list runs out of 16-bit indices + auto const low{Drawlist_->GetClipRectMin()}; + auto const high{Drawlist_->GetClipRectMax()}; + auto const period{Dash_ + Gap_}; + auto carried{0.0f}; + auto budget{400}; + for (std::size_t i = 1; i < Points_.size(); ++i) + { + auto const from{Points_[i - 1]}; + auto const dx{Points_[i].x - from.x}; + auto const dy{Points_[i].y - from.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len < 1e-4f) + { + continue; + } + + // the stretch of this piece that the clip rect actually shows, as a distance along it + auto first{0.0f}; + auto last{len}; + auto visible{true}; + float const edge[]{-dx, dx, -dy, dy}; + float const room[]{from.x - low.x + 8.0f, high.x - from.x + 8.0f, from.y - low.y + 8.0f, high.y - from.y + 8.0f}; + for (int side = 0; side < 4 && visible; ++side) + { + if (std::abs(edge[side]) < 1e-6f) + { + visible = room[side] >= 0.0f; + } + else if (auto const cut{room[side] / edge[side] * len}; edge[side] < 0.0f) + { + first = std::max(first, cut); + } + else + { + last = std::min(last, cut); + } + } + if (false == visible || first >= last) + { + carried = std::fmod(carried + len, period); + continue; + } + + auto at{std::max(0.0f, first - std::fmod(carried + first, period))}; + while (at < last && budget > 0) + { + --budget; + auto const phase{std::fmod(carried + at, period)}; + if (phase < Dash_) + { + auto const to{std::min(at + Dash_ - phase, len)}; + Drawlist_->AddLine(ImVec2(from.x + dx * at / len, from.y + dy * at / len), ImVec2(from.x + dx * to / len, from.y + dy * to / len), Colour_, + Thickness_); + at = to; + } + else + { + at += period - phase; + } + } + carried = std::fmod(carried + len, period); + } +} + +editor::turnout_preset const *find_preset(std::string const &Name_) +{ + for (auto const &preset : editor::turnout_presets()) + { + if (preset.name == Name_) + { + return &preset; + } + } + return nullptr; +} + +// the catalogue figure written onto a document's own type. a document keeps its own copy of the +// catalogue - that is what lets an instance name its type and nothing more - so this is the one +// place the preset's numbers cross over, and it has to carry all of them +void adopt_preset(editor::turnout_preset const &Preset_, maj0sted::editor::TurnoutType &Type_) +{ + Type_.name = Preset_.name; + Type_.crossing_n = Preset_.crossing_n; + Type_.length = Preset_.length; + Type_.blade.tip_thickness = Preset_.tip_thickness; + Type_.blade.nose = Preset_.nose; + Type_.blade.railtop_width = Preset_.railtop_width; + Type_.pieces.clear(); + for (auto const &piece : Preset_.pieces) + { + Type_.pieces.push_back( + maj0sted::editor::TurnoutPieceSpec{piece.part, piece.length, piece.radius_start, piece.radius_end, piece.turn_in}); + } +} + void world_to_plan(glm::dvec3 const &World, double &X, double &Y) { X = World.x + Global.scenery_origin.x; @@ -64,8 +235,7 @@ bool world_to_screen(glm::dvec3 const &World, ImVec2 &Screen) } // ground under the cursor in the top-down plan view. depth picking is useless here: empty scenery -// has nothing to hit, so the last (or zero) offset would pin every click to the same spot and the -// solver would drop the resulting zero-length straight +// has nothing to hit, so the last (or zero) offset would pin every click to the same spot glm::dvec3 ortho_cursor_world() { auto const width = std::max(1, Global.window_size.x); @@ -78,418 +248,59 @@ glm::dvec3 ortho_cursor_world() return {Global.pCamera.Pos.x + ndcx * halfwidth, 0.0, Global.pCamera.Pos.z - ndcy * halfheight}; } -ImU32 element_colour(int const Kind, bool const Active) +ImU32 element_colour(Kind const Kind_, bool const Active) { - auto const alpha{Active ? 255 : 210}; - switch (Kind) + auto const alpha{Active ? 255 : 200}; + switch (Kind_) { - case 1: // arc + case Kind::Arc: return IM_COL32(255, 190, 90, alpha); - case 2: // transition curve + case Kind::Clothoid: return IM_COL32(140, 245, 150, alpha); - default: // straight + default: return IM_COL32(125, 200, 255, alpha); } } -// one centreline element recovered from a left/right rail pair -// dual rails from a centreline, same 1.5 m gauge as maj0sted::render::RailRenderer -std::vector rails_from_centreline(std::vector const &Centre, double const Gauge = 1.5) +char const *kind_name(Kind const Kind_) { - std::vector out; - auto const half{Gauge * 0.5}; - for (auto const &poly : Centre) + switch (Kind_) { - if (poly.points.size() < 2) - { - continue; - } - for (double const side : {half, -half}) - { - maj0sted::editor::PlanPolyline rail; - rail.kind = poly.kind; - rail.length = poly.length; - rail.radius_start = poly.radius_start; - rail.radius_end = poly.radius_end; - rail.points.resize(poly.points.size()); - auto const m{poly.points.size()}; - for (std::size_t i = 0; i < m; ++i) - { - double dx{1.0}; - double dy{0.0}; - if (i == 0) - { - dx = poly.points[1].x - poly.points[0].x; - dy = poly.points[1].y - poly.points[0].y; - } - else if (i + 1 == m) - { - dx = poly.points[i].x - poly.points[i - 1].x; - dy = poly.points[i].y - poly.points[i - 1].y; - } - else - { - dx = poly.points[i + 1].x - poly.points[i - 1].x; - dy = poly.points[i + 1].y - poly.points[i - 1].y; - } - auto const len{std::hypot(dx, dy)}; - auto const nx{len > 0.0 ? -dy / len : 0.0}; - auto const ny{len > 0.0 ? dx / len : 0.0}; - rail.points[i] = {poly.points[i].x + nx * side, poly.points[i].y + ny * side}; - } - out.push_back(std::move(rail)); - } - } - return out; -} - -void draw_label(ImDrawList *Drawlist, ImVec2 const &Anchor, char const *Text, ImU32 const Colour) -{ - auto const size{ImGui::CalcTextSize(Text)}; - ImVec2 const pad{4.0f, 2.0f}; - ImVec2 const origin{Anchor.x + 6.0f, Anchor.y - size.y * 0.5f}; - Drawlist->AddRectFilled({origin.x - pad.x, origin.y - pad.y}, {origin.x + size.x + pad.x, origin.y + size.y + pad.y}, IM_COL32(15, 20, 32, 210), 3.0f); - Drawlist->AddRect({origin.x - pad.x, origin.y - pad.y}, {origin.x + size.x + pad.x, origin.y + size.y + pad.y}, Colour, 3.0f); - Drawlist->AddText(origin, Colour, Text); -} - -// leader from the curve midpoint outward, then a boxed label (R= / KP L=) -void draw_curve_callout(ImDrawList *Drawlist, std::vector const &Screen, char const *Text, ImU32 const Colour) -{ - if (Screen.size() < 2) - { - return; - } - auto const mid{Screen[Screen.size() / 2]}; - auto const i{static_cast(Screen.size() / 2)}; - auto const a{Screen[std::max(0, i - 1)]}; - auto const c{Screen[std::min(static_cast(Screen.size()) - 1, i + 1)]}; - auto ox{-(c.y - a.y)}; - auto oy{c.x - a.x}; - auto const len{std::hypot(ox, oy)}; - if (len > 1e-3f) - { - ox /= len; - oy /= len; - } - else - { - ox = 0.0f; - oy = -1.0f; - } - constexpr float kLead = 22.0f; - ImVec2 const tip{mid.x + ox * kLead, mid.y + oy * kLead}; - Drawlist->AddLine(mid, tip, Colour, 1.2f); - draw_label(Drawlist, tip, Text, Colour); -} - -// short perpendicular tick across the track — marks an arc end / radius break -void draw_station_tick(ImDrawList *Drawlist, ImVec2 const &AlongA, ImVec2 const &AlongB, ImU32 const Colour, float const Half = 8.0f) -{ - auto tx{AlongB.x - AlongA.x}; - auto ty{AlongB.y - AlongA.y}; - auto const tl{std::hypot(tx, ty)}; - if (tl < 1e-3f) - { - return; - } - tx /= tl; - ty /= tl; - ImVec2 const mid{(AlongA.x + AlongB.x) * 0.5f, (AlongA.y + AlongB.y) * 0.5f}; - ImVec2 const px{-ty * Half, tx * Half}; - Drawlist->AddLine({mid.x - px.x, mid.y - px.y}, {mid.x + px.x, mid.y + px.y}, Colour, 2.0f); -} - -void draw_arc_end_tick(ImDrawList *Drawlist, std::vector const &Screen, bool const AtStart, ImU32 const Colour) -{ - if (Screen.size() < 2) - { - return; - } - if (AtStart) - { - draw_station_tick(Drawlist, Screen[0], Screen[1], Colour); - } - else - { - draw_station_tick(Drawlist, Screen[Screen.size() - 2], Screen[Screen.size() - 1], Colour); + case Kind::Arc: + return "Luk"; + case Kind::Clothoid: + return "Krzywa przejsciowa"; + default: + return "Prosta"; } } -double point_segment_distance(double const Px, double const Py, double const Ax, double const Ay, double const Bx, double const By) +double distance_point_segment(ImVec2 const &Point, ImVec2 const &A, ImVec2 const &B) { - auto const dx{Bx - Ax}; - auto const dy{By - Ay}; + auto const dx{B.x - A.x}; + auto const dy{B.y - A.y}; auto const len2{dx * dx + dy * dy}; - if (len2 < 1e-18) + if (len2 <= 0.0f) { - return std::hypot(Px - Ax, Py - Ay); + return std::hypot(Point.x - A.x, Point.y - A.y); } - auto const t{std::clamp(((Px - Ax) * dx + (Py - Ay) * dy) / len2, 0.0, 1.0)}; - return std::hypot(Px - (Ax + t * dx), Py - (Ay + t * dy)); -} - -// distance from (X,Y) to the gap track: curve elements plus the two bounding straights -double distance_to_gap_track(maj0sted::editor::NiweletaPolys const &Solved, int const Gap, double const X, double const Y) -{ - auto best{std::numeric_limits::infinity()}; - for (auto const &poly : Solved.polylines) - { - auto const on_curve{poly.gap == Gap}; - auto const on_straight{poly.straight_index == Gap || poly.straight_index == Gap + 1}; - if ((false == on_curve && false == on_straight) || poly.points.size() < 2) - { - continue; - } - for (std::size_t i = 1; i < poly.points.size(); ++i) - { - best = std::min(best, point_segment_distance(X, Y, poly.points[i - 1].x, poly.points[i - 1].y, poly.points[i].x, poly.points[i].y)); - } - } - return best; -} - -struct GapSample -{ - double x{0.0}; - double y{0.0}; - double along{0.0}; -}; - -// centreline of a fitted gap, sampled in element order -std::vector sample_gap_centreline(maj0sted::editor::NiweletaPolys const &Solved, int const Gap) -{ - std::map by_el; - for (auto const &poly : Solved.polylines) - { - if (poly.gap != Gap || poly.element_index < 0 || poly.points.size() < 2) - { - continue; - } - if (by_el.find(poly.element_index) == by_el.end()) - { - by_el.emplace(poly.element_index, &poly); - } - } - std::vector out; - double along{0.0}; - for (auto const &[idx, poly] : by_el) - { - (void)idx; - for (std::size_t i = 0; i < poly->points.size(); ++i) - { - auto const &p{poly->points[i]}; - if (false == out.empty()) - { - auto const &prev{out.back()}; - if (std::hypot(p.x - prev.x, p.y - prev.y) < 1e-9) - { - continue; - } - along += std::hypot(p.x - prev.x, p.y - prev.y); - } - out.push_back({p.x, p.y, along}); - } - } - return out; -} - -bool project_on_gap(std::vector const &Samples, double const X, double const Y, double &OutStation, double &OutX, double &OutY) -{ - if (Samples.size() < 2) - { - return false; - } - auto best_d{std::numeric_limits::infinity()}; - double best_s{0.0}; - double best_x{Samples.front().x}; - double best_y{Samples.front().y}; - for (std::size_t i = 1; i < Samples.size(); ++i) - { - auto const &a{Samples[i - 1]}; - auto const &b{Samples[i]}; - auto const dx{b.x - a.x}; - auto const dy{b.y - a.y}; - auto const len2{dx * dx + dy * dy}; - auto t{0.0}; - if (len2 > 1e-18) - { - t = std::clamp(((X - a.x) * dx + (Y - a.y) * dy) / len2, 0.0, 1.0); - } - auto const px{a.x + t * dx}; - auto const py{a.y + t * dy}; - auto const d{std::hypot(X - px, Y - py)}; - if (d < best_d) - { - best_d = d; - best_s = a.along + t * (b.along - a.along); - best_x = px; - best_y = py; - } - } - OutStation = best_s; - OutX = best_x; - OutY = best_y; - return std::isfinite(best_d); -} - -bool point_at_station(std::vector const &Samples, double Station, double &OutX, double &OutY) -{ - if (Samples.empty()) - { - return false; - } - if (Station <= Samples.front().along) - { - OutX = Samples.front().x; - OutY = Samples.front().y; - return true; - } - if (Station >= Samples.back().along) - { - OutX = Samples.back().x; - OutY = Samples.back().y; - return true; - } - for (std::size_t i = 1; i < Samples.size(); ++i) - { - auto const &a{Samples[i - 1]}; - auto const &b{Samples[i]}; - if (Station > b.along) - { - continue; - } - auto const span{std::max(1e-12, b.along - a.along)}; - auto const t{(Station - a.along) / span}; - OutX = a.x + (b.x - a.x) * t; - OutY = a.y + (b.y - a.y) * t; - return true; - } - return false; -} - -// kind: 0 entry KP end, 1 free-arc end, 2 between-KP end, 3 exit KP start -struct CmpStationHandle -{ - int gap{0}; - int kind{0}; - int arc{0}; - double x{0.0}; - double y{0.0}; -}; - -std::vector compound_station_handles(maj0sted::editor::GapFit const &Fit, maj0sted::editor::NiweletaPolys const &Solved, int const Gap) -{ - std::vector handles; - if (Fit.mode != 3 || Fit.arcs.empty()) - { - return handles; - } - auto const samples{sample_gap_centreline(Solved, Gap)}; - if (samples.size() < 2) - { - return handles; - } - auto const total{samples.back().along}; - - auto place = [&](int const Kind, int const Arc, double const Station) { - double x{0.0}; - double y{0.0}; - if (point_at_station(samples, std::clamp(Station, 0.0, total), x, y)) - { - handles.push_back({Gap, Kind, Arc, x, y}); - } - }; - - auto s{0.0}; - place(0, 0, std::max(0.0, Fit.entry_t)); // entry KP end (at 0 when no entry KP yet) - s = std::max(0.0, Fit.entry_t); - for (std::size_t i = 0; i + 1 < Fit.arcs.size(); ++i) - { - s += std::max(0.0, Fit.arcs[i].length); - place(1, static_cast(i), s); // free circular-arc end - auto const kp{std::max(0.0, Fit.arcs[i].transition_to_next)}; - if (kp > 1e-6) - { - s += kp; - place(2, static_cast(i), s); // between-KP end - } - } - // exit KP start: measured from the end of the solved curve - place(3, 0, std::max(0.0, total - std::max(0.0, Fit.exit_t))); - return handles; -} - -std::vector sample_draft_centreline(std::vector const &Polys) -{ - std::vector out; - double along{0.0}; - for (auto const &poly : Polys) - { - for (auto const &p : poly.points) - { - if (false == out.empty()) - { - auto const &prev{out.back()}; - if (std::hypot(p.x - prev.x, p.y - prev.y) < 1e-9) - { - continue; - } - along += std::hypot(p.x - prev.x, p.y - prev.y); - } - out.push_back({p.x, p.y, along}); - } - } - return out; -} - -// draft stations: every arc has L (including the last), unlike GapFit -std::vector draft_station_handles(maj0sted::app::BasketDraft const &Draft, std::vector const &Polys) -{ - std::vector handles; - if (false == Draft.active || Draft.arcs.empty()) - { - return handles; - } - auto const samples{sample_draft_centreline(Polys)}; - if (samples.size() < 2) - { - return handles; - } - auto const total{samples.back().along}; - auto place = [&](int const Kind, int const Arc, double const Station) { - double x{0.0}; - double y{0.0}; - if (point_at_station(samples, std::clamp(Station, 0.0, total), x, y)) - { - handles.push_back({-1, Kind, Arc, x, y}); - } - }; - auto s{std::max(0.0, Draft.entry_t)}; - place(0, 0, s); - for (std::size_t i = 0; i < Draft.arcs.size(); ++i) - { - s += std::max(0.0, Draft.arcs[i].length); - place(1, static_cast(i), s); - auto const kp{std::max(0.0, Draft.arcs[i].transition_to_next)}; - if (i + 1 < Draft.arcs.size() && kp > 1e-6) - { - s += kp; - place(2, static_cast(i), s); - } - } - place(3, 0, std::max(0.0, total - std::max(0.0, Draft.exit_t))); - return handles; + auto t{((Point.x - A.x) * dx + (Point.y - A.y) * dy) / len2}; + t = std::clamp(t, 0.0f, 1.0f); + return std::hypot(Point.x - (A.x + dx * t), Point.y - (A.y + dy * t)); } } // namespace +// --------------------------------------------------------------------------- +// lifecycle +// --------------------------------------------------------------------------- + plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) { - size_min = {420, 240}; + size_min = {440, 260}; size_max = {900, 900}; - m_document.niwelety.push_back({"niweleta 1", {}, {}}); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety.front().name.c_str()); + seed_catalogue(); + new_track(); solve(); } @@ -506,14 +317,2404 @@ void plan_panel::render_contents() draw_on_scene(); render_toolbar(); - render_draft(); - render_switches(); - render_gaps(); + render_elements(); + render_turnouts(); + render_diagnostics(); render_storage(); render_newmap_dialog(); render_location_dialog(); + render_template_window(); } +void plan_panel::seed_catalogue() +{ + // the catalogue is data: an instance names its type, so a new figure goes in here and never + // touches the file format for placements. + // + // a plan saved before the iglica became a piece of its own keeps a type of that very name with + // no blade in it at all - two odcinki, luk i krzyzownicowa. that is not a figure anyone + // authored, it is what the old writer could say, so it is brought up to the current catalogue + // rather than left to lay a switch that has no ostrze. a type someone really did edit keeps its + // iglica and is left alone + auto refreshed{0}; + for (auto const &preset : editor::turnout_presets()) + { + auto existing{std::find_if(m_document.turnout_types.begin(), m_document.turnout_types.end(), + [&](auto const &Type_) { return Type_.name == preset.name; })}; + if (existing == m_document.turnout_types.end()) + { + maj0sted::editor::TurnoutType type; + adopt_preset(preset, type); + m_document.turnout_types.push_back(std::move(type)); + continue; + } + auto const has_blade{std::any_of(existing->pieces.begin(), existing->pieces.end(), [](auto const &Piece_) { return Piece_.part == 1; })}; + if (false == has_blade) + { + adopt_preset(preset, *existing); + ++refreshed; + } + } + if (refreshed > 0) + { + m_status = std::to_string(refreshed) + " typ(ow) rozjazdu bylo bez iglicy - wziete z katalogu na nowo"; + } +} + +// --------------------------------------------------------------------------- +// document +// --------------------------------------------------------------------------- + +void plan_panel::solve() +{ + m_solution = maj0sted::editor::solve(m_document); + m_rails = maj0sted::editor::render_rails(m_solution); +} + +plan_panel::Track *plan_panel::current_track() +{ + return maj0sted::editor::find_track(m_document, m_track); +} + +plan_panel::Track const *plan_panel::current_track() const +{ + return maj0sted::editor::find_track(m_document, m_track); +} + +void plan_panel::new_track() +{ + Track track; + track.id = maj0sted::editor::mint_track(m_document); + track.name = "tor " + std::to_string(m_document.tracks.size() + 1); + m_document.tracks.push_back(track); + m_track = track.id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", track.name.c_str()); + m_pending = false; + clear_selection(); +} + +void plan_panel::delete_current_track() +{ + auto const it{std::find_if(m_document.tracks.begin(), m_document.tracks.end(), [&](Track const &Track_) { return Track_.id == m_track; })}; + if (it == m_document.tracks.end()) + { + return; + } + // a turnout standing on it, and anything anchored to that turnout, would be left dangling + auto const gone{it->id}; + m_document.tracks.erase(it); + for (auto turnout{m_document.turnouts.begin()}; turnout != m_document.turnouts.end();) + { + turnout = (turnout->on == gone) ? m_document.turnouts.erase(turnout) : turnout + 1; + } + if (m_document.tracks.empty()) + { + new_track(); + } + else + { + m_track = m_document.tracks.front().id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.tracks.front().name.c_str()); + } + m_pending = false; + clear_selection(); + solve(); +} + +void plan_panel::clear_selection() +{ + m_sel_element = ElementId::none; + m_sel_turnout = TurnoutId::none; + m_dragging_turnout = false; +} + +void plan_panel::append_point_to(double const X, double const Y) +{ + auto *track{current_track()}; + if (track == nullptr) + { + return; + } + + // an empty track has no direction yet, so the first click only says where it starts + if (track->elements.empty()) + { + if (false == m_pending) + { + m_pending = true; + m_startx = X; + m_starty = Y; + m_status = "kliknij drugi raz, zeby polozyc pierwsza prosta"; + return; + } + auto const dx{X - m_startx}; + auto const dy{Y - m_starty}; + auto const length{std::hypot(dx, dy)}; + if (length < 1.0) + { + m_status = "za krotka prosta"; + return; + } + track->anchor = maj0sted::editor::AtPose{m_startx, m_starty, std::atan2(dx, dy)}; + track->elements.push_back(maj0sted::editor::make_line(m_document, length)); + m_pending = false; + solve(); + m_status.clear(); + return; + } + + // the track ends on the last click, so that end is the corner this one turns at + auto const *solved{maj0sted::editor::find_track(m_solution, track->id)}; + if (solved == nullptr || solved->elements.empty()) + { + m_status = "tor nie jest polozony - popraw najpierw to, co nie wyszlo"; + return; + } + auto const corner_pose{solved->end}; + + auto const dx{X - corner_pose.x}; + auto const dy{Y - corner_pose.y}; + auto const reach{std::hypot(dx, dy)}; + if (reach < 1.0) + { + m_status = "za krotki odcinek"; + return; + } + + auto const corner{maj0sted::editor::fit_corner(corner_pose.hx, corner_pose.hy, dx / reach, dy / reach, m_corner_radius)}; + if (false == corner.ok) + { + // straight on: nothing to round. two straights end to end are one straight, so the one + // already there simply gets longer + track->elements.back().length += reach; + solve(); + m_status = "prosto dalej: przedluzona ostatnia prosta do " + to_string(track->elements.back().length, 1) + " m"; + return; + } + + // the arc eats R*tan(kat/2) off the leg behind the corner and off the one in front. neither may + // go negative, which is the whole of "naroznik za ostry na tym promieniu" + auto &previous{track->elements.back()}; + if (previous.kind != Kind::Line || previous.length <= corner.tangent + 0.5) + { + m_status = "naroznik za ostry na R=" + to_string(m_corner_radius, 0) + " m: potrzeba " + to_string(corner.tangent, 1) + " m stycznej, a przed nim jest " + + to_string(previous.kind == Kind::Line ? previous.length : 0.0, 1) + " m prostej"; + return; + } + if (reach <= corner.tangent + 0.5) + { + m_status = "klikniete za blisko naroznika: potrzeba " + to_string(corner.tangent, 1) + " m stycznej, a jest " + to_string(reach, 1) + " m"; + return; + } + + previous.length -= corner.tangent; + track->elements.push_back(maj0sted::editor::make_arc(m_document, m_corner_radius, corner.hand, corner.arc_length)); + track->elements.push_back(maj0sted::editor::make_line(m_document, reach - corner.tangent)); + solve(); + m_status.clear(); +} + +void plan_panel::drop_last_vertex() +{ + auto *track{current_track()}; + if (track == nullptr || track->elements.empty()) + { + m_pending = false; + return; + } + if (track->elements.back().kind != Kind::Line) + { + m_status = "ostatni odcinek nie jest prosta - zdejmij go w liscie"; + return; + } + + track->elements.pop_back(); + // and the arc that rounded the corner in front of it, whose tangent goes back onto the straight + // it was taken from. what is left ends on the click before this one + if (false == track->elements.empty() && track->elements.back().kind == Kind::Arc) + { + auto const &arc{track->elements.back()}; + auto const tangent{arc.radius > 0.0 ? arc.radius * std::tan(0.5 * arc.length / arc.radius) : 0.0}; + track->elements.pop_back(); + if (false == track->elements.empty() && track->elements.back().kind == Kind::Line) + { + track->elements.back().length += tangent; + } + } + + clear_selection(); + solve(); +} + +void plan_panel::append_element(Kind const Kind_) +{ + auto *track{current_track()}; + if (track == nullptr) + { + return; + } + + // two straights end to end are one straight - there would be no corner between them and nothing + // to grab hold of, so the one already there just gets longer + if (Kind_ == Kind::Line && false == track->elements.empty() && track->elements.back().kind == Kind::Line) + { + track->elements.back().length += m_new_length; + m_sel_element = track->elements.back().id; + solve(); + m_status = "prosta doklejona do poprzedniej: " + to_string(track->elements.back().length, 1) + " m"; + return; + } + + Element element; + element.id = maj0sted::editor::mint_element(m_document); + element.kind = Kind_; + element.radius = Kind_ == Kind::Line ? 0.0 : m_new_radius; + element.hand = Kind_ == Kind::Line ? 0 : m_new_hand; + element.length = m_new_length; + + // what the chain is already curving at decides the joint, not what happens to be typed in the + // boxes: an arc after a transition curve is the arc that curve eases into, so it takes its + // radius and its hand. a transition after an arc turns the same way the arc does + std::string inherited; + if (false == track->elements.empty()) + { + auto const &previous{track->elements.back()}; + auto const curving{previous.kind != Kind::Line && previous.radius > 0.0}; + if (Kind_ == Kind::Arc && previous.kind == Kind::Clothoid && curving) + { + element.radius = previous.radius; + element.hand = previous.hand; + inherited = " (R i skret z krzywej przejsciowej)"; + } + else if (Kind_ == Kind::Arc && previous.kind == Kind::Clothoid) + { + // the transition was running out into a straight; now it runs into this arc, so that + // is what it eases to + track->elements.back().radius = element.radius; + track->elements.back().hand = element.hand; + inherited = " (krzywa przejsciowa dostala jego R i skret)"; + } + else if (Kind_ == Kind::Clothoid && previous.kind == Kind::Arc) + { + // a transition ends on whatever follows it, and for now nothing does: it runs out + // into a straight. dokladajac za nia luk, przejmie jego promien + element.radius = 0.0; + element.hand = previous.hand; + inherited = " (wybiega w prosta)"; + } + } + + track->elements.push_back(element); + m_sel_element = element.id; + solve(); + m_status = std::string{"dolozono: "} + kind_name(Kind_) + inherited; +} + +void plan_panel::drop_last_element() +{ + auto *track{current_track()}; + if (track == nullptr || track->elements.empty()) + { + return; + } + track->elements.pop_back(); + clear_selection(); + solve(); +} + +void plan_panel::delete_selected_element() +{ + if (m_sel_element == ElementId::none) + { + return; + } + for (auto &track : m_document.tracks) + { + auto const it{std::find_if(track.elements.begin(), track.elements.end(), [&](Element const &Element_) { return Element_.id == m_sel_element; })}; + if (it != track.elements.end()) + { + track.elements.erase(it); + break; + } + } + m_sel_element = ElementId::none; + solve(); +} + +bool plan_panel::refit_current_track(std::string &Why) +{ + auto *track{current_track()}; + // the solution still holds the geometry from before the change, which is exactly what the + // straights are supposed to go on standing on + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + if (track == nullptr || solved == nullptr) + { + Why = "tor nie jest polozony"; + return false; + } + + maj0sted::editor::Skeleton skeleton; + if (false == maj0sted::editor::skeleton_of(*track, *solved, skeleton, Why)) + { + return false; + } + return maj0sted::editor::lay_along_skeleton(*track, skeleton, Why); +} + +void plan_panel::drag_straight_to(double const X, double const Y) +{ + auto *track{current_track()}; + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + if (track == nullptr || solved == nullptr || m_drag_index >= track->elements.size()) + { + return; + } + + maj0sted::editor::Skeleton skeleton; + std::string why; + if (false == maj0sted::editor::skeleton_of(*track, *solved, skeleton, why)) + { + m_status = why; + return; + } + + // which of the skeleton's lines the grabbed straight is: the straights in order, which is not + // every second element once corners hold baskets or transition curves + std::size_t line_index{0}; + for (std::size_t i = 0; i < m_drag_index; ++i) + { + if (track->elements[i].kind == Kind::Line) + { + ++line_index; + } + } + if (line_index >= skeleton.lines.size()) + { + return; + } + + // the grabbed straight swings onto the line through its far end and the cursor. every other + // line stays exactly as it was, which is what keeps their azimuths to the millimetre + auto const dx{X - m_pivot_x}; + auto const dy{Y - m_pivot_y}; + auto const reach{std::hypot(dx, dy)}; + if (reach < 1.0) + { + return; + } + // grabbing the end it runs out to swings it forward from the far end; grabbing the end it + // starts on swings it backward, so the direction of travel is the other way round + auto const sign{m_drag_end == 1 ? 1.0 : -1.0}; + auto &line{skeleton.lines[line_index]}; + line.px = m_pivot_x; + line.py = m_pivot_y; + line.dx = sign * dx / reach; + line.dy = sign * dy / reach; + + // the free ends of the skeleton stay where they are, except the one being dragged + if (line_index == 0 && m_drag_end == 0) + { + skeleton.start_x = X; + skeleton.start_y = Y; + } + if (line_index + 1 == skeleton.lines.size() && m_drag_end == 1) + { + skeleton.end_x = X; + skeleton.end_y = Y; + } + + // nothing is written until the whole chain works out, so a bad drag leaves the track alone + Track candidate{*track}; + if (false == maj0sted::editor::lay_along_skeleton(candidate, skeleton, why)) + { + m_status = why; + return; + } + // the chain laying out is not enough: shortening a straight can pull it out from under a rozjazd + // standing on it, and a rozjazd half on an arc is not a rozjazd. the drag stops at that instead + auto const off_straight{[](maj0sted::editor::Solution const &Solution_) { + std::size_t count{0}; + for (auto const &diagnostic : Solution_.diagnostics) + { + count += diagnostic.code == maj0sted::editor::Code::StationOffTrack ? 1 : 0; + } + return count; + }}; + + auto const was{off_straight(m_solution)}; + Track const before{*track}; + *track = std::move(candidate); + solve(); + if (off_straight(m_solution) > was) + { + for (auto const &diagnostic : m_solution.diagnostics) + { + if (diagnostic.code == maj0sted::editor::Code::StationOffTrack) + { + m_status = "dalej nie: " + diagnostic.text; + break; + } + } + *track = before; + solve(); + return; + } + m_status.clear(); +} + +void plan_panel::place_turnout_at(TrackId const On, double const Wx, double const Wy) +{ + double station{0.0}; + if (false == station_on(On, Wx, Wy, station)) + { + m_status = "nie ma na czym postawic rozjazdu"; + return; + } + if (m_document.turnout_types.empty()) + { + m_status = "katalog rozjazdow jest pusty"; + return; + } + auto const type{static_cast(std::clamp(m_turnout_type, 0, static_cast(m_document.turnout_types.size()) - 1))}; + + maj0sted::editor::TurnoutPlacement placement; + placement.id = maj0sted::editor::mint_turnout(m_document); + placement.type = m_document.turnout_types[type].name; + placement.on = On; + placement.station = station; + placement.hand = m_new_hand; + m_document.turnouts.push_back(placement); + + // a switch is worth nothing without somewhere for the branch to go, so one starts at its frog + Track branch; + branch.id = maj0sted::editor::mint_track(m_document); + branch.name = "odnoga " + std::to_string(m_document.tracks.size() + 1); + branch.anchor = maj0sted::editor::AtPort{placement.id, maj0sted::editor::Port::Frog}; + m_document.tracks.push_back(branch); + + m_track = branch.id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", branch.name.c_str()); + m_sel_turnout = placement.id; + m_pending = false; + solve(); + m_status = "rozjazd " + placement.type + " na " + to_string(station, 1) + " m; odnoga zaczyna sie na krzyzownicy"; +} + +// --------------------------------------------------------------------------- +// hit tests +// --------------------------------------------------------------------------- + +bool plan_panel::hit_straight_end(ImVec2 const &Mouse, std::size_t &OutIndex, int &OutEnd) const +{ + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + auto const *track{current_track()}; + if (solved == nullptr || track == nullptr) + { + return false; + } + + constexpr float tolerance{10.0f}; + auto best{tolerance}; + auto found{false}; + for (std::size_t i = 0; i < solved->elements.size() && i < track->elements.size(); ++i) + { + if (track->elements[i].kind != Kind::Line) + { + continue; + } + // a branch starts on its turnout's frog and cannot be pulled off it + auto const pinned{i == 0 && false == std::holds_alternative(track->anchor)}; + for (int end = 0; end < 2; ++end) + { + if (end == 0 && pinned) + { + continue; + } + auto const &pose{end == 0 ? solved->elements[i].start : solved->elements[i].end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const distance{static_cast(std::hypot(Mouse.x - screen.x, Mouse.y - screen.y))}; + if (distance < best) + { + best = distance; + OutIndex = i; + OutEnd = end; + found = true; + } + } + } + return found; +} + +bool plan_panel::hit_element(ImVec2 const &Mouse, ElementId &OutElement, TrackId &OutTrack) const +{ + constexpr float tolerance{8.0f}; + auto best{tolerance}; + auto found{false}; + for (auto const &rail : m_rails) + { + if (rail.element == ElementId::none || rail.points.size() < 2) + { + continue; + } + for (std::size_t i = 1; i < rail.points.size(); ++i) + { + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(rail.points[i - 1].x, rail.points[i - 1].y), a) || + false == world_to_screen(plan_to_world(rail.points[i].x, rail.points[i].y), b)) + { + continue; + } + auto const distance{static_cast(distance_point_segment(Mouse, a, b))}; + if (distance < best) + { + best = distance; + OutElement = rail.element; + OutTrack = rail.track; + found = true; + } + } + } + return found; +} + +bool plan_panel::hit_turnout(ImVec2 const &Mouse, TurnoutId &OutTurnout) const +{ + constexpr float tolerance{10.0f}; + auto best{tolerance}; + auto found{false}; + for (auto const &turnout : m_solution.turnouts) + { + if (false == turnout.valid) + { + continue; + } + for (auto const &pose : {turnout.start, turnout.frog}) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const distance{std::hypot(Mouse.x - screen.x, Mouse.y - screen.y)}; + if (distance < best) + { + best = distance; + OutTurnout = turnout.id; + found = true; + } + } + } + return found; +} + +bool plan_panel::station_on(TrackId const Track_, double const Wx, double const Wy, double &OutStation) const +{ + auto const *track{maj0sted::editor::find_track(m_solution, Track_)}; + if (track == nullptr || track->centreline.size() < 2) + { + return false; + } + + auto best{std::numeric_limits::max()}; + double walked{0.0}; + for (std::size_t i = 1; i < track->centreline.size(); ++i) + { + auto const &a{track->centreline[i - 1]}; + auto const &b{track->centreline[i]}; + auto const dx{b.x - a.x}; + auto const dy{b.y - a.y}; + auto const len2{dx * dx + dy * dy}; + auto const step{std::sqrt(len2)}; + if (step <= 0.0) + { + continue; + } + auto t{((Wx - a.x) * dx + (Wy - a.y) * dy) / len2}; + t = std::clamp(t, 0.0, 1.0); + auto const distance{std::hypot(Wx - (a.x + dx * t), Wy - (a.y + dy * t))}; + if (distance < best) + { + best = distance; + OutStation = walked + step * t; + } + walked += step; + } + return best < std::numeric_limits::max(); +} + +plan_panel::TrackId plan_panel::nearest_track_axis(ImVec2 const &Mouse, float const Tolerance) const +{ + auto best{Tolerance}; + auto found{TrackId::none}; + for (auto const &track : m_solution.tracks) + { + for (std::size_t i = 1; i < track.centreline.size(); ++i) + { + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(track.centreline[i - 1].x, track.centreline[i - 1].y), a) || + false == world_to_screen(plan_to_world(track.centreline[i].x, track.centreline[i].y), b)) + { + continue; + } + auto const distance{static_cast(distance_point_segment(Mouse, a, b))}; + if (distance < best) + { + best = distance; + found = track.id; + } + } + } + return found; +} + +// --------------------------------------------------------------------------- +// the scene +// --------------------------------------------------------------------------- + +void plan_panel::handle_scene() +{ + auto const &io{ImGui::GetIO()}; + if (io.WantCaptureMouse && false == m_dragging_turnout) + { + return; + } + + auto const mouse{io.MousePos}; + auto const world{ortho_cursor_world()}; + double wx{0.0}; + double wy{0.0}; + world_to_plan(world, wx, wy); + + // Esc drops every transient errand back to plain selection + // this ImGui indexes IsKeyPressed by the backend's own key codes; ImGuiKey_ names are slots in + // io.KeyMap, so the mapping has to be gone through rather than passed straight in + auto const &keymap{ImGui::GetIO().KeyMap}; + if (keymap[ImGuiKey_Escape] >= 0 && ImGui::IsKeyPressed(keymap[ImGuiKey_Escape])) + { + m_pick_turnout = 0; + m_pick_parallel = 0; + m_pending = false; + clear_selection(); + m_status.clear(); + } + + // --- transient errands ------------------------------------------------ + if (m_pick_turnout == 1 && ImGui::IsMouseClicked(0)) + { + auto const on{nearest_track_axis(mouse, 14.0f)}; + if (on != TrackId::none) + { + place_turnout_at(on, wx, wy); + m_pick_turnout = 0; + } + else + { + m_status = "kliknij tor, na ktorym ma stanac rozjazd"; + } + return; + } + if (m_pick_parallel != 0 && ImGui::IsMouseClicked(0)) + { + ElementId reference{ElementId::none}; + TrackId owner{TrackId::none}; + auto *track{current_track()}; + if (track != nullptr && hit_element(mouse, reference, owner) && owner != m_track) + { + auto const it{std::find_if(track->elements.begin(), track->elements.end(), [&](Element const &Element_) { return Element_.id == m_sel_element; })}; + if (it != track->elements.end()) + { + it->hold = Parallel{reference, m_new_offset}; + m_status = "trzyma rownoleglosc, odstep " + to_string(m_new_offset, 2) + " m"; + solve(); + } + } + else + { + m_status = "wskaz odcinek innego toru"; + } + m_pick_parallel = 0; + return; + } + + // --- ctrl: grabbing a straight by one of its ends --------------------- + // this is the one thing a modifier is needed for: the ends of a straight sit on the track, so + // a plain click there has to stay a plain click + if (io.KeyCtrl && ImGui::IsMouseClicked(0)) + { + std::size_t index{0}; + int end{0}; + if (hit_straight_end(mouse, index, end)) + { + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + auto const *track{current_track()}; + if (solved != nullptr && index < solved->elements.size()) + { + // the far end is captured now: re-fitting the arc behind it will move it + auto const &pivot{end == 1 ? solved->elements[index].start : solved->elements[index].end}; + m_pivot_x = pivot.x; + m_pivot_y = pivot.y; + m_drag_index = index; + m_drag_end = end; + m_dragging_straight = true; + m_sel_element = track->elements[index].id; + if (track != nullptr && std::holds_alternative(track->elements[index].hold)) + { + m_status = "ten odcinek trzyma rownoleglosc - po puszczeniu tor i tak zostanie przystawiony"; + } + } + return; + } + } + if (m_dragging_straight) + { + if (ImGui::IsMouseDown(0)) + { + drag_straight_to(wx, wy); + } + else + { + m_dragging_straight = false; + } + return; + } + + // --- one click, whatever is under it ---------------------------------- + // clicking something that is already drawn picks it up; clicking bare ground carries the + // current track on to there. there is nothing to switch between: what you clicked says what + // you meant + if (ImGui::IsMouseClicked(0)) + { + TurnoutId turnout{TurnoutId::none}; + ElementId element{ElementId::none}; + TrackId track{TrackId::none}; + + if (hit_turnout(mouse, turnout)) + { + m_sel_turnout = turnout; + m_dragging_turnout = true; + } + else if (hit_element(mouse, element, track)) + { + m_sel_element = element; + m_track = track; + auto const *chosen{maj0sted::editor::find_track(m_document, track)}; + if (chosen != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", chosen->name.c_str()); + } + } + else + { + append_point_to(wx, wy); + } + } + + // right-click takes back the last click, which is the only thing it does + if (ImGui::IsMouseClicked(1)) + { + drop_last_vertex(); + } + + if (ImGui::IsMouseDown(0) && m_dragging_turnout && m_sel_turnout != TurnoutId::none) + { + auto *placement{maj0sted::editor::find_turnout(m_document, m_sel_turnout)}; + if (placement != nullptr) + { + double station{0.0}; + if (station_on(placement->on, wx, wy, station)) + { + placement->station = station; + solve(); + } + } + } + + if (ImGui::IsMouseReleased(0)) + { + m_dragging_turnout = false; + } + + // double-clicking anywhere on a track makes it the one being edited + if (ImGui::IsMouseDoubleClicked(0)) + { + auto const track{nearest_track_axis(mouse, 16.0f)}; + if (track != TrackId::none) + { + m_track = track; + auto const *chosen{maj0sted::editor::find_track(m_document, track)}; + if (chosen != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", chosen->name.c_str()); + m_status = "edytowany: " + chosen->name; + } + } + } +} + +void plan_panel::draw_on_scene() +{ + // the background list paints over the rendered scenery but under every window, so the controls + // stay readable on top of the drawing + auto *drawlist{ImGui::GetBackgroundDrawList()}; + + // orthophoto under everything else. only a georeferenced map knows where on the ground it sits, + // so a fictional one gets no imagery + if (m_showortho && Global.scenery_georeferenced) + { + double planx{0.0}; + double plany{0.0}; + world_to_plan(Global.pCamera.Pos, planx, plany); + + // yaw is pinned north-up, so the screen is an axis-aligned box in plan space + auto const display{ImGui::GetIO().DisplaySize}; + auto const halfheight{static_cast(Global.editor_ortho_extent)}; + auto const halfwidth{halfheight * std::max(1.0f, display.x) / std::max(1.0f, display.y)}; + maj0sted::editor::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; + + auto const draw_box = [&](maj0sted::editor::TileBBox const &Box, unsigned int const Texture) { + ImVec2 corners[4]; + auto const ok = world_to_screen(plan_to_world(Box.min_x, Box.max_y), corners[0]) && world_to_screen(plan_to_world(Box.max_x, Box.max_y), corners[1]) && + world_to_screen(plan_to_world(Box.max_x, Box.min_y), corners[2]) && world_to_screen(plan_to_world(Box.min_x, Box.min_y), corners[3]); + if (false == ok) + { + return; + } + // north-west corner first, and the texture's first row (v=0) is its northern edge + drawlist->AddImageQuad(reinterpret_cast(static_cast(Texture)), corners[0], corners[1], corners[2], corners[3], ImVec2(0.0f, 0.0f), ImVec2(1.0f, 0.0f), + ImVec2(1.0f, 1.0f), ImVec2(0.0f, 1.0f)); + }; + + // once the view is wider than a kilometre the 100 m orto cells are the wrong scale: + // switch to a 1 km topo grid that can cover the whole screen + constexpr double kTopoPreviewMetres = 1000.0; + auto const span{std::max(halfwidth, halfheight) * 2.0}; + if (span > kTopoPreviewMetres) + { + for (auto const &tile : m_topo.collect(view, 80)) + { + draw_box(tile.box, tile.texture); + } + } + else + { + for (auto const &tile : m_ortho.collect(view)) + { + draw_box(tile.box, tile.texture); + } + } + } + + // the track itself + std::vector points; + for (auto const &rail : m_rails) + { + if (rail.points.size() < 2) + { + continue; + } + auto const active{rail.track == m_track || rail.turnout != TurnoutId::none}; + points.clear(); + points.reserve(rail.points.size()); + for (auto const &point : rail.points) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) + { + continue; + } + points.push_back(screen); + } + if (points.size() < 2) + { + continue; + } + auto colour{element_colour(rail.kind, active)}; + auto width{2.5f}; + if (rail.turnout != TurnoutId::none) + { + // every element of a rozjazd is drawn as itself: the pre-blade lead runs along + // the through track, the blade is the piece that leaves the opornica, then the + // curve and the straight frog rail + switch (rail.part) + { + case 0: + // the pre-blade lead lies exactly on the through track, so it has to be + // drawn wide and in a colour of its own or it simply is not there + colour = IM_COL32(140, 240, 150, 255); // przediglicowy + width = 4.5f; + break; + case 1: + colour = IM_COL32(120, 235, 255, 245); // iglica + break; + case 3: + colour = IM_COL32(255, 200, 110, 235); // prosta krzyzownicowa + break; + default: + colour = IM_COL32(255, 140, 200, 230); // luk + break; + } + } + else if (rail.element == m_sel_element && m_sel_element != ElementId::none) + { + colour = IM_COL32(255, 255, 255, 245); + } + drawlist->AddPolyline(points.data(), static_cast(points.size()), colour, false, width); + } + + // turnouts: PR, the frog, and the construction points a drawing is dimensioned from + for (auto const &turnout : m_solution.turnouts) + { + if (false == turnout.valid) + { + continue; + } + auto const selected{turnout.id == m_sel_turnout}; + + // the points that sit on the straight - PR, the blade tip and the koniec rozjazdu - + // are drawn the way a catalogue drawing marks them: a tick across the track, not a + // dot lost among the rails the turnout runs along + auto const tick{[&](maj0sted::editor::TurnoutMark const &Mark_, ImU32 const Colour_, float const Half_) { + ImVec2 centre; + if (false == world_to_screen(plan_to_world(Mark_.x, Mark_.y), centre)) + { + return; + } + ImVec2 along; + if (false == world_to_screen(plan_to_world(Mark_.x + turnout.start.hx, Mark_.y + turnout.start.hy), along)) + { + return; + } + auto dx{along.x - centre.x}; + auto dy{along.y - centre.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len < 1e-3f) + { + return; + } + dx /= len; + dy /= len; + drawlist->AddLine(ImVec2(centre.x + dy * Half_, centre.y - dx * Half_), ImVec2(centre.x - dy * Half_, centre.y + dx * Half_), Colour_, 2.0f); + }}; + + for (auto const &mark : turnout.marks) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(mark.x, mark.y), screen)) + { + continue; + } + + // PR and KR carry the switch - both on the diverging path and, for the + // koniec rozjazdu, on the straight it stands on. the ostrze is the other + // point worth finding on the drawing, so it gets a colour of its own + auto const primary{mark.kind == 0 || mark.kind == 5 || mark.kind == 9}; + auto const blade{mark.kind == 1 || mark.kind == 7 || mark.kind == 8}; + drawlist->AddCircleFilled(screen, primary ? (selected ? 6.0f : 5.0f) : (blade ? 4.0f : 3.0f), + primary ? IM_COL32(255, 210, 90, 240) : (blade ? IM_COL32(120, 235, 255, 230) : IM_COL32(255, 150, 210, 190))); + } + for (auto const &mark : turnout.marks) + { + switch (mark.kind) + { + case 0: // PR + case 9: // koniec rozjazdu na torze zasadniczym + tick(mark, IM_COL32(255, 210, 90, 245), selected ? 14.0f : 11.0f); + break; + case 1: // ostrze iglicy: koniec odcinka przediglicowego + tick(mark, IM_COL32(120, 235, 255, 245), 9.0f); + break; + case 7: // A: koniec zestruganego dzioba, iglica ma tu grubosc u + tick(mark, IM_COL32(140, 240, 150, 245), 7.0f); + break; + default: + break; + } + } + + // the blade's own geometry lives in the first few metres of the switch, so at any + // zoom that shows the whole rozjazd its points land on top of one another. named + // on leader lines fanned out to the side they can at least be told apart + if (selected) + { + auto ordered{turnout.marks}; + std::sort(ordered.begin(), ordered.end(), + [](auto const &Left_, auto const &Right_) { return Left_.station < Right_.station; }); + + auto level{0}; + for (auto const &mark : ordered) + { + if (mark.kind < 0 || mark.kind >= static_cast(sizeof(mark_names) / sizeof(mark_names[0]))) + { + continue; + } + ImVec2 centre; + ImVec2 along; + if (false == world_to_screen(plan_to_world(mark.x, mark.y), centre) || + false == world_to_screen(plan_to_world(mark.x + turnout.start.hx, mark.y + turnout.start.hy), along)) + { + continue; + } + auto dx{along.x - centre.x}; + auto dy{along.y - centre.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len < 1e-3f) + { + continue; + } + dx /= len; + dy /= len; + + // alternate sides and step further out on each pass, so the leaders fan + // instead of stacking on one line + auto const side{(level % 2) == 0 ? 1.0f : -1.0f}; + auto const reach{(30.0f + 17.0f * static_cast(level / 2)) * side}; + ImVec2 const anchor(centre.x + dy * reach, centre.y - dx * reach); + + char label[96]; + if (mark.station > 0.0) + { + std::snprintf(label, sizeof(label), "%s %.3f", mark_names[mark.kind], mark.station); + } + else + { + std::snprintf(label, sizeof(label), "%s", mark_names[mark.kind]); + } + auto const size{ImGui::CalcTextSize(label)}; + ImVec2 const corner(anchor.x - size.x * 0.5f, anchor.y - size.y * 0.5f); + + drawlist->AddLine(centre, anchor, IM_COL32(200, 200, 200, 130), 1.0f); + drawlist->AddRectFilled(ImVec2(corner.x - 3.0f, corner.y - 2.0f), ImVec2(corner.x + size.x + 3.0f, corner.y + size.y + 2.0f), + IM_COL32(15, 20, 30, 205), 3.0f); + drawlist->AddText(corner, IM_COL32(235, 240, 250, 245), label); + ++level; + } + } + } + + // where a chain was cut short, say so on the spot rather than only in the list + for (auto const &track : m_solution.tracks) + { + if (track.complete || track.elements.empty()) + { + continue; + } + ImVec2 screen; + if (world_to_screen(plan_to_world(track.end.x, track.end.y), screen)) + { + drawlist->AddCircle(screen, 9.0f, IM_COL32(255, 110, 110, 230), 0, 2.0f); + drawlist->AddLine(ImVec2(screen.x - 6.0f, screen.y - 6.0f), ImVec2(screen.x + 6.0f, screen.y + 6.0f), IM_COL32(255, 110, 110, 230), 2.0f); + drawlist->AddLine(ImVec2(screen.x - 6.0f, screen.y + 6.0f), ImVec2(screen.x + 6.0f, screen.y - 6.0f), IM_COL32(255, 110, 110, 230), 2.0f); + } + } + + // the ends of the edited track's straights, while ctrl says they are what you are after + if (ImGui::GetIO().KeyCtrl) + { + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + auto const *track{current_track()}; + if (solved != nullptr && track != nullptr) + { + for (std::size_t i = 0; i < solved->elements.size() && i < track->elements.size(); ++i) + { + if (track->elements[i].kind != Kind::Line) + { + continue; + } + auto const pinned{i == 0 && false == std::holds_alternative(track->anchor)}; + for (int end = 0; end < 2; ++end) + { + if (end == 0 && pinned) + { + continue; + } + auto const &pose{end == 0 ? solved->elements[i].start : solved->elements[i].end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const grabbed{m_dragging_straight && i == m_drag_index && end == m_drag_end}; + auto const size{grabbed ? 5.0f : 4.0f}; + drawlist->AddRectFilled(ImVec2(screen.x - size, screen.y - size), ImVec2(screen.x + size, screen.y + size), + grabbed ? IM_COL32(255, 255, 255, 245) : IM_COL32(255, 240, 140, 200)); + } + } + } + } + + // where the run of clicks starts, before there is any track to show for it + if (m_pending) + { + ImVec2 screen; + if (world_to_screen(plan_to_world(m_startx, m_starty), screen)) + { + drawlist->AddCircle(screen, 6.0f, IM_COL32(255, 240, 140, 230), 0, 2.0f); + } + } +} + +void plan_panel::go_to_plan() +{ + auto minx{std::numeric_limits::max()}; + auto miny{std::numeric_limits::max()}; + auto maxx{std::numeric_limits::lowest()}; + auto maxy{std::numeric_limits::lowest()}; + + for (auto const &track : m_solution.tracks) + { + for (auto const &point : track.centreline) + { + minx = std::min(minx, point.x); + maxx = std::max(maxx, point.x); + miny = std::min(miny, point.y); + maxy = std::max(maxy, point.y); + } + } + if (minx > maxx) + { + return; // nothing drawn yet, so there is nowhere to go + } + + auto &camera{editor_mode::get_camera()}; + auto const centre{plan_to_world((minx + maxx) * 0.5, (miny + maxy) * 0.5)}; + camera.Pos.x = centre.x; + camera.Pos.z = centre.z; + + // show the whole drawing with a little room around it + auto const span{std::max({maxx - minx, maxy - miny, 50.0})}; + Global.editor_ortho_extent = std::clamp(static_cast(span * 0.6), 5.0f, 20000.0f); +} + +// --------------------------------------------------------------------------- +// panels +// --------------------------------------------------------------------------- + +void plan_panel::render_toolbar() +{ + ImGui::Text("widok z gory, %.0f m w poprzek", Global.editor_ortho_extent * 2.0f); + ImGui::TextDisabled("klik w teren prowadzi tor, klik w odcinek go wybiera, dwuklik zmienia edytowany tor"); + ImGui::TextDisabled("Ctrl + LPM na krancu prostej: przeciagniecie; luki obok dopasuja sie same"); + if (Global.scenery_georeferenced) + { + ImGui::Text("zero mapy: %.0f, %.0f (EPSG:2180)", Global.scenery_origin.x, Global.scenery_origin.y); + } + else + { + ImGui::TextDisabled("mapa fikcyjna, bez georeferencji"); + } + + if (ImGui::Button("Nowa mapa")) + { + // opened from the same stack level the modal is drawn on, which is render_contents + ImGui::OpenPopup("New map"); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("wybor miejsca: mapa fikcyjna albo punkt w Polsce, na ktorym staje zero scenerii"); + } + if (Global.scenery_georeferenced) + { + ImGui::SameLine(); + ImGui::Checkbox("Ortofoto", &m_showortho); + if (m_showortho) + { + auto const pending{static_cast(m_ortho.pending() + m_topo.pending())}; + if (pending > 0) + { + ImGui::SameLine(); + ImGui::TextDisabled("(%d kafli w drodze)", pending); + } + } + } + ImGui::Separator(); + + // how a corner gets rounded when the track is carried on by clicking. a kink is not track, so + // the turn from where the track heads now to where the click is goes round an arc of this + // radius; it eats R*tan(kat/2) off the leg behind it and off the one in front + ImGui::SetNextItemWidth(120.0f); + ImGui::InputDouble("R w narozniku [m]", &m_corner_radius, 10.0, 100.0, "%.0f"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("klikniecie w pusty teren prowadzi tor dalej; zwrot miedzy dotychczasowym kierunkiem\n" + "a klikietym punktem zostaje zaokraglony lukiem o tym promieniu.\n" + "im wiekszy R, tym dluzsza styczna R*tan(kat/2) sciagana z obu prostych\n" + "- dlatego ostry naroznik tuz przy koncu toru sie nie miesci.\n\n" + "prawy klik cofa ostatnie klikniecie"); + } + ImGui::SameLine(); + ImGui::TextDisabled("prawy klik cofa klikniecie"); + + // which track is being edited + auto *track{current_track()}; + if (ImGui::BeginCombo("Edytowany", track != nullptr ? track->name.c_str() : "-")) + { + for (auto const &candidate : m_document.tracks) + { + if (ImGui::Selectable(candidate.name.c_str(), candidate.id == m_track)) + { + m_track = candidate.id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", candidate.name.c_str()); + clear_selection(); + m_pending = false; + } + } + ImGui::EndCombo(); + } + if (track != nullptr) + { + ImGui::SetNextItemWidth(240.0f); + if (ImGui::InputText("Nazwa", m_namebuf, sizeof(m_namebuf))) + { + track->name = m_namebuf; + } + } + + if (ImGui::Button("Nowy tor")) + { + new_track(); + solve(); + } + ImGui::SameLine(); + if (m_document.tracks.size() > 1 && ImGui::Button("Usun tor")) + { + delete_current_track(); + } + ImGui::SameLine(); + if (ImGui::Button("Pokaz calosc")) + { + go_to_plan(); + } + + ImGui::Separator(); +} + +void plan_panel::render_elements() +{ + auto *track{current_track()}; + if (track == nullptr) + { + return; + } + + if (false == ImGui::CollapsingHeader("Odcinki", ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + // what a new element is made of. pick the numbers, then say which of the three it is: there is + // nothing else to an element, and nothing here is a mode of its own + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("dl. [m]", &m_new_length, 5.0, 50.0, "%.1f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("R [m]", &m_new_radius, 10.0, 100.0, "%.1f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + { + int hand{m_new_hand >= 0 ? 0 : 1}; + if (ImGui::Combo("skret", &hand, "w lewo\0w prawo\0")) + { + m_new_hand = hand == 0 ? 1 : -1; + } + } + + if (ImGui::Button("+ prosta")) + { + append_element(Kind::Line); + } + ImGui::SameLine(); + if (ImGui::Button("+ luk")) + { + append_element(Kind::Arc); + } + ImGui::SameLine(); + if (ImGui::Button("+ krzywa przejsciowa")) + { + append_element(Kind::Clothoid); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("R jest promieniem na jej koncu; na poczatku bierze sie z poprzedniego odcinka.\n0 = wybiega w prosta"); + } + ImGui::SameLine(); + if (ImGui::Button("Cofnij") && false == track->elements.empty()) + { + drop_last_element(); + } + + ImGui::Separator(); + + if (track->elements.empty()) + { + ImGui::TextDisabled("pusty tor. dolóz odcinek ponizej, albo klikaj po mapie: pierwsze dwa klikniecia kladzie prosta."); + return; + } + + auto dirty{false}; + // a change of shape - a radius, a hand, the length of a transition - belongs to its own corner + // and to nothing else, so the track is re-laid along the lines its straights already stood on. + // a change of length is what it says it is: the element gets longer, and what follows moves + auto refit{false}; + auto const *solved{maj0sted::editor::find_track(m_solution, track->id)}; + + for (std::size_t i = 0; i < track->elements.size(); ++i) + { + auto &element{track->elements[i]}; + ImGui::PushID(static_cast(i)); + + auto const selected{element.id == m_sel_element}; + auto const *solved_element{solved != nullptr && i < solved->elements.size() ? &solved->elements[i] : nullptr}; + auto const held{std::holds_alternative(element.hold)}; + + // the row says the whole element: what it is, how long, and how it turns. a held one says + // so too, because its shape is not its own + char label[200]; + switch (element.kind) + { + case Kind::Arc: + std::snprintf(label, sizeof(label), "%zu. Luk R=%.0f %s %.1f m (%.2f st.)%s%s", i + 1, element.radius, element.hand >= 0 ? "w lewo" : "w prawo", element.length, + element.radius > 0.0 ? element.length / element.radius * 180.0 / std::numbers::pi : 0.0, held ? " [rownolegly]" : "", + solved_element != nullptr ? "" : " (nie polozony)"); + break; + case Kind::Clothoid: + std::snprintf(label, sizeof(label), "%zu. Krzywa przejsciowa %.1f m (do R=%.0f %s)%s", i + 1, element.length, element.radius, element.hand >= 0 ? "w lewo" : "w prawo", + solved_element != nullptr ? "" : " (nie polozony)"); + break; + default: + std::snprintf(label, sizeof(label), "%zu. Prosta %.1f m%s%s", i + 1, element.length, held ? " [rownolegla]" : "", + solved_element != nullptr ? "" : " (nie polozony)"); + break; + } + if (ImGui::Selectable(label, selected)) + { + m_sel_element = element.id; + } + + if (selected) + { + ImGui::Indent(); + + int kind{element.kind == Kind::Line ? 0 : (element.kind == Kind::Arc ? 1 : 2)}; + ImGui::SetNextItemWidth(180.0f); + if (ImGui::Combo("rodzaj", &kind, "prosta\0luk\0krzywa przejsciowa\0")) + { + element.kind = kind == 0 ? Kind::Line : (kind == 1 ? Kind::Arc : Kind::Clothoid); + dirty = true; + } + + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("dlugosc [m]", &element.length, 5.0, 50.0, "%.2f")) + { + dirty = true; + // a transition's length is authored, and what it takes out of the corner is the + // corner's business - so this one is absorbed rather than pushed down the track + refit = element.kind == Kind::Clothoid; + } + + if (element.kind != Kind::Line) + { + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble(element.kind == Kind::Clothoid ? "R na koncu [m]" : "promien [m]", &element.radius, 10.0, 100.0, "%.2f")) + { + maj0sted::editor::sync_joint(*track, i); + dirty = true; + refit = true; + } + if (element.kind == Kind::Clothoid && ImGui::IsItemHovered()) + { + ImGui::SetTooltip("0 = krzywa wybiegajaca w prosta; promien na poczatku bierze sie z poprzedniego odcinka"); + } + ImGui::SetNextItemWidth(120.0f); + int hand{element.hand >= 0 ? 0 : 1}; + if (ImGui::Combo("skret", &hand, "w lewo\0w prawo\0")) + { + element.hand = hand == 0 ? 1 : -1; + maj0sted::editor::sync_joint(*track, i); + dirty = true; + refit = true; + } + if (element.kind == Kind::Arc && element.radius > 0.0) + { + auto turn{element.length / element.radius * 180.0 / std::numbers::pi}; + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("kat zwrotu [st.]", &turn, 1.0, 10.0, "%.3f")) + { + element.length = std::max(0.0, turn) * std::numbers::pi / 180.0 * element.radius; + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("ten sam luk, powiedziany katem zamiast dlugoscia"); + } + } + } + + // TODO: kontrolki rownoleglosci (trzymanie miedzytorza) chwilowo schowane z gui - + // samo Parallel/hold i solver zostaja, wraca tu przycisk i pole odstepu + + if (solved_element != nullptr) + { + ImGui::TextDisabled("polozony: %.2f m, R %.1f -> %.1f", solved_element->length, std::abs(solved_element->k0) > 1e-9 ? 1.0 / std::abs(solved_element->k0) : 0.0, + std::abs(solved_element->k1) > 1e-9 ? 1.0 / std::abs(solved_element->k1) : 0.0); + } + + if (ImGui::SmallButton("usun odcinek")) + { + ImGui::Unindent(); + ImGui::PopID(); + delete_selected_element(); + return; + } + + ImGui::Unindent(); + } + + ImGui::PopID(); + } + + if (refit) + { + // the change stands either way - it is what was asked for. what a failed fit costs is that + // the rest of the track follows along instead of staying put, and that is worth saying + std::string why; + if (refit_current_track(why)) + { + m_status = "dopasowano naroznik; reszta toru bez zmian"; + } + else + { + m_status = why + " - dalsza czesc toru poszla za zmiana"; + } + } + if (dirty) + { + solve(); + } +} + +// the geometry of a turnout is the type's, and the type is a list: przediglicowy, iglica, luk, +// prosta krzyzownicowa, in the order they are laid from PR. every length is the catalogue's - +// nothing here is fitted, so the skos and the catalogue length are checked against what the list +// works out to and the difference is said out loud +void plan_panel::render_turnout_type(maj0sted::editor::TurnoutType &Type_) +{ + auto dirty{false}; + + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("skos 1:n", &Type_.crossing_n, 0.5, 1.0, "%.3f")) + { + dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("dl. kat. a+b [m]", &Type_.length, 0.1, 1.0, "%.3f")) + { + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("dlugosc katalogowa to suma stycznych a + b, tak jak na rysunku typu"); + } + + static char const *const part_names[]{"przediglicowy", "iglica", "luk", "krzyzownicowa"}; + for (std::size_t i = 0; i < Type_.pieces.size(); ++i) + { + auto &piece{Type_.pieces[i]}; + ImGui::PushID(static_cast(2000 + i)); + piece.part = std::clamp(piece.part, 0, 3); + + char label[200]; + if (piece.radius_start <= 0.0 && piece.radius_end <= 0.0) + { + std::snprintf(label, sizeof(label), "%zu. %s prosta %.3f m", i + 1, part_names[piece.part], piece.length); + } + else if (std::abs(piece.radius_start - piece.radius_end) < 1e-9) + { + std::snprintf(label, sizeof(label), "%zu. %s luk R %.0f %.3f m", i + 1, part_names[piece.part], piece.radius_start, piece.length); + } + else + { + std::snprintf(label, sizeof(label), "%zu. %s R %.0f -> %.0f %.3f m", i + 1, part_names[piece.part], piece.radius_start, piece.radius_end, + piece.length); + } + if (ImGui::Selectable(label, static_cast(i) == m_sel_piece)) + { + m_sel_piece = static_cast(i); + } + if (static_cast(i) == m_sel_piece) + { + ImGui::Indent(); + ImGui::SetNextItemWidth(150.0f); + if (ImGui::Combo("rola", &piece.part, "przediglicowy\0iglica\0luk\0krzyzownicowa\0")) + { + dirty = true; + } + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("dlugosc [m]", &piece.length, 0.1, 1.0, "%.3f")) + { + dirty = true; + } + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("R poczatek [m]", &piece.radius_start, 10.0, 100.0, "%.2f")) + { + dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("R koniec [m]", &piece.radius_end, 10.0, 100.0, "%.2f")) + { + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("0 = prosta; rowne promienie = luk, rozne = krzywa przejsciowa"); + } + auto turn_in{piece.turn_in * 180.0 / std::numbers::pi}; + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("nagiecie [st.]", &turn_in, 0.05, 0.5, "%.4f")) + { + piece.turn_in = std::max(0.0, turn_in) * std::numbers::pi / 180.0; + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("kat nagiecia iglicy (beta): 0 = iglica styczna do opornicy"); + } + if (ImGui::SmallButton("usun odcinek")) + { + Type_.pieces.erase(Type_.pieces.begin() + static_cast(i)); + m_sel_piece = -1; + ImGui::Unindent(); + ImGui::PopID(); + solve(); + return; + } + ImGui::Unindent(); + } + ImGui::PopID(); + } + if (ImGui::SmallButton("dodaj odcinek")) + { + Type_.pieces.push_back(maj0sted::editor::TurnoutPieceSpec{2, 1.0, 0.0, 0.0, 0.0}); + m_sel_piece = static_cast(Type_.pieces.size()) - 1; + dirty = true; + } + + // the blade rail itself: a tip cannot be ground to nothing, so it is cut back to a thickness + // and the point where the blade leaves the opornica is only a theoretical one + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("ostrze u [m]", &Type_.blade.tip_thickness, 0.001, 0.005, "%.4f")) + { + dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("dziob d [m]", &Type_.blade.nose, 0.001, 0.005, "%.4f")) + { + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("zestrugana dlugosc od ostrza do punktu, w ktorym iglica ma grubosc u (Koc, tablica 5.1)"); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("glowka w [m]", &Type_.blade.railtop_width, 0.001, 0.005, "%.4f")) + { + dirty = true; + } + + auto const laid{maj0sted::domain::lay_turnout(maj0sted::domain::geometry::Pose{0.0, 0.0, 1.0, 0.0}, maj0sted::editor::to_domain(Type_, 1))}; + if (false == laid.valid) + { + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.55f, 1.0f), "lista nie da sie polozyc: dlugosci > 0, promienie >= 0"); + } + else + { + if (laid.has_blade) + { + ImGui::TextDisabled("iglica: ostrze a %.4f m, dziob do %.4f m (o %+.4f), struganie %.3f m, przyleganie %.4f st.", laid.blade_station, laid.nose_end, + laid.blade_residual, laid.planing_length, laid.blade_angle * 180.0 / std::numbers::pi); + } + auto const closed{std::abs(laid.angle_residual) <= 1e-6 && (Type_.length <= 0.0 || std::abs(laid.length_residual) <= 1e-3)}; + ImGui::TextColored(closed ? ImVec4(0.6f, 0.9f, 0.6f, 1.0f) : ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "domkniecie: kat %+.5f st., dlugosc %+.4f m", + laid.angle_residual * 180.0 / std::numbers::pi, laid.length_residual); + } + + if (dirty) + { + solve(); + } + ImGui::Separator(); +} + +// the catalogue drawing of the chosen type: the diverging path piece by piece in the colours of its +// roles, both tracks' rails, and the construction points - the same list the solver hands the map, +// so the two cannot drift apart. a rozjazd is thirty metres long and a metre and a half wide, so as +// on every real drawing the view across the track is stretched, and by how much is said on screen. +// the blade's own dimensions are millimetres; they come out under the wheel, which magnifies about +// the cursor +void plan_panel::render_template_window() +{ + if (false == m_show_template) + { + return; + } + ImGui::SetNextWindowSize(ImVec2(1020.0f, 580.0f), ImGuiCond_FirstUseEver); + if (false == ImGui::Begin("Rysunek szablonu rozjazdu", &m_show_template)) + { + ImGui::End(); + return; + } + if (m_document.turnout_types.empty()) + { + ImGui::TextDisabled("katalog jest pusty"); + ImGui::End(); + return; + } + + m_turnout_type = std::clamp(m_turnout_type, 0, static_cast(m_document.turnout_types.size()) - 1); + auto const &type{m_document.turnout_types[static_cast(m_turnout_type)]}; + auto const start{maj0sted::domain::geometry::Pose{0.0, 0.0, 1.0, 0.0}}; + auto const bend{m_tpl_bend_radius > 1.0 ? (m_tpl_bend_hand == 0 ? 1.0 : -1.0) / m_tpl_bend_radius : 0.0}; + auto const laid{maj0sted::domain::lay_turnout(start, maj0sted::editor::to_domain(type, 1), bend)}; + if (false == laid.valid) + { + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.55f, 1.0f), bend != 0.0 ? "typu \"%s\" nie da sie wygiac na taki luk" : "typ \"%s\" nie da sie polozyc", + type.name.c_str()); + ImGui::SetNextItemWidth(150.0f); + ImGui::InputDouble("R toru zasadniczego", &m_tpl_bend_radius, 10.0, 100.0, "%.3f"); + ImGui::End(); + return; + } + + // what the drawing says in words, above what it says in lines + ImGui::Text("%s skos 1:%.4g alfa %s dlugosc katalogowa %.3f m", type.name.c_str(), type.crossing_n, + angle_text(std::atan(1.0 / std::max(type.crossing_n, 1e-9))).c_str(), type.length); + ImGui::TextDisabled("styczne a %.3f + b %.3f m na torze zasadniczym %.3f m odgalezienie %.3f m", laid.tangent_front, laid.tangent_back, + laid.through_length, laid.diverging_length); + if (laid.has_blade) + { + ImGui::TextDisabled("iglica: u %.4f d %.4f w %.3f m ostrze a %.4f m dziob u %.4f m (o %+.4f) struganie %.3f m przyleganie %s", + type.blade.tip_thickness, type.blade.nose, type.blade.railtop_width, laid.blade_station, laid.nose_end, laid.blade_residual, + laid.planing_length, angle_text(laid.blade_angle).c_str()); + } + else + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "ten typ nie ma iglicy - zaden odcinek nie ma roli \"iglica\""); + } + auto const closed{std::abs(laid.angle_residual) <= 1e-6 && (type.length <= 0.0 || std::abs(laid.length_residual) <= 1e-3)}; + ImGui::TextColored(closed ? ImVec4(0.6f, 0.9f, 0.6f, 1.0f) : ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "domkniecie: kat %+.5f st., dlugosc %+.4f m", + laid.angle_residual * 180.0 / std::numbers::pi, laid.length_residual); + + // lukowanie: the drawing of a bent turnout is the drawing of the same turnout, rotated about its + // centre - the tangents and the crossing angle do not move, only the radii + ImGui::SetNextItemWidth(150.0f); + if (ImGui::InputDouble("R zasadniczego (0 = prosty)", &m_tpl_bend_radius, 10.0, 100.0, "%.3f")) + { + m_tpl_bend_radius = std::max(0.0, m_tpl_bend_radius); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + ImGui::Combo("wygiety", &m_tpl_bend_hand, "w lewo\0w prawo\0"); + ImGui::TextDisabled("%s", bend_text(laid.bend, laid.diverging_curvature, laid.bend_angle).c_str()); + if (auto const *limit{bend_limit(laid.diverging_curvature)}; limit != nullptr) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "%s", limit); + } + + ImGui::SetNextItemWidth(180.0f); + ImGui::SliderFloat("przewyzszenie", &m_tpl_exagg, 1.0f, 60.0f, "x%.0f"); + ImGui::SameLine(); + ImGui::Checkbox("szyny", &m_tpl_rails); + ImGui::SameLine(); + ImGui::Checkbox("punkty", &m_tpl_marks); + ImGui::SameLine(); + if (ImGui::SmallButton("dopasuj")) + { + m_tpl_zoom = 1.0f; + m_tpl_pan = 0.0f; + } + ImGui::SameLine(); + ImGui::TextDisabled("kolko powieksza, przeciaganie przesuwa"); + + auto const avail{ImGui::GetContentRegionAvail()}; + auto const w{std::max(avail.x, 120.0f)}; + auto const h{std::max(avail.y, 120.0f)}; + auto const origin{ImGui::GetCursorScreenPos()}; + ImGui::InvisibleButton("rysunek", ImVec2(w, h)); + + auto const span{std::max(laid.through_length, laid.tangent_front + laid.tangent_back) + 2.0}; + auto const scale_of{[&](float const Zoom) { return static_cast(w) * 0.94 / span * static_cast(Zoom); }}; + auto sx{scale_of(m_tpl_zoom)}; + auto centre{span * 0.5 + static_cast(m_tpl_pan)}; + + // the wheel magnifies about the cursor, so the blade can be run down to millimetres + if (ImGui::IsItemHovered() && ImGui::GetIO().MouseWheel != 0.0f) + { + auto const offset{static_cast(ImGui::GetIO().MousePos.x - origin.x - w * 0.5f)}; + auto const under{centre + offset / sx}; + m_tpl_zoom = std::clamp(m_tpl_zoom * std::pow(1.25f, ImGui::GetIO().MouseWheel), 1.0f, 5000.0f); + sx = scale_of(m_tpl_zoom); + centre = under - offset / sx; + m_tpl_pan = static_cast(centre - span * 0.5); + } + if (ImGui::IsItemActive() && ImGui::IsMouseDown(0)) + { + m_tpl_pan -= static_cast(static_cast(ImGui::GetIO().MouseDelta.x) / sx); + centre = span * 0.5 + static_cast(m_tpl_pan); + } + + auto const sy{sx * static_cast(m_tpl_exagg)}; + auto const to_screen{[&](double const X, double const Y) { + return ImVec2(origin.x + w * 0.5f + static_cast((X - centre) * sx), origin.y + h * 0.58f - static_cast(Y * sy)); + }}; + + auto *drawlist{ImGui::GetWindowDrawList()}; + drawlist->PushClipRect(origin, ImVec2(origin.x + w, origin.y + h), true); + drawlist->AddRectFilled(origin, ImVec2(origin.x + w, origin.y + h), IM_COL32(16, 20, 26, 255)); + + auto const half{maj0sted::domain::kHalfGauge}; + + // the tor zasadniczy, and the opornica the blade lies against. bent, it is an arc like any other, + // so it is walked rather than ruled + auto const through_at{[&](double const Station_) { return maj0sted::domain::geometry::layout_segment(bend, bend, Station_, start, nullptr); }}; + auto const through_line{[&](double const From_, double const To_, double const Offset_) { + auto const steps{bend == 0.0 ? 1 : 96}; + std::vector line; + line.reserve(static_cast(steps) + 1); + for (int i = 0; i <= steps; ++i) + { + auto const pose{through_at(From_ + (To_ - From_) * static_cast(i) / static_cast(steps))}; + line.push_back(to_screen(pose.x - pose.hy * Offset_, pose.y + pose.hx * Offset_)); + } + return line; + }}; + + { + auto const axis{through_line(-1.0, span, 0.0)}; + drawlist->AddPolyline(axis.data(), static_cast(axis.size()), IM_COL32(105, 115, 130, 220), false, 1.0f); + if (m_tpl_rails) + { + for (int side = -1; side <= 1; side += 2) + { + auto const rail{through_line(-1.0, span, half * side)}; + drawlist->AddPolyline(rail.data(), static_cast(rail.size()), IM_COL32(150, 160, 175, 210), false, 1.5f); + } + } + } + + // the diverging path, in the colours of the roles: przediglicowy, iglica, luk, krzyzownicowa. + // everything up to the punkt A is dashed, because none of it is rail the wheel can run on: before + // the ostrze there is no blade at all, and from the ostrze to A the blade is the straight planed + // dziob, not the arc. from A on the blade lies on the arc and the line goes solid + static ImU32 const part_colour[]{IM_COL32(150, 175, 210, 255), IM_COL32(120, 235, 255, 255), IM_COL32(240, 240, 240, 255), IM_COL32(255, 200, 110, 255)}; + auto const hand{laid.frog.y >= 0.0 ? 1 : -1}; + auto const rail_line{[&](std::vector const &Points_, int const Side_) { + std::vector rail; + rail.reserve(Points_.size()); + for (std::size_t i = 0; i < Points_.size(); ++i) + { + auto const &back{Points_[i == 0 ? 0 : i - 1]}; + auto const &ahead{Points_[i == 0 ? 1 : i]}; + auto const dx{ahead.x - back.x}; + auto const dy{ahead.y - back.y}; + auto const len{std::hypot(dx, dy)}; + if (len < 1e-12) + { + continue; + } + rail.push_back(to_screen(Points_[i].x - dy / len * half * Side_, Points_[i].y + dx / len * half * Side_)); + } + return rail; + }}; + + // the sampling step is metres and the dziob is centimetres, so the two stretches have to part on + // the exact distance rather than on whichever sample happens to fall nearest it + auto const cut_at{[](std::vector const &Points_, double const Distance_, + std::vector &Head_, std::vector &Tail_) { + auto travelled{0.0}; + for (std::size_t i = 0; i < Points_.size(); ++i) + { + auto const step{i == 0 ? 0.0 : std::hypot(Points_[i].x - Points_[i - 1].x, Points_[i].y - Points_[i - 1].y)}; + travelled += step; + if (travelled <= Distance_) + { + Head_.push_back(Points_[i]); + continue; + } + if (Tail_.empty() && false == Head_.empty() && step > 1e-12) + { + auto const &back{Points_[i - 1]}; + auto const share{(Distance_ - (travelled - step)) / step}; + maj0sted::domain::geometry::XY const meeting{back.x + (Points_[i].x - back.x) * share, back.y + (Points_[i].y - back.y) * share}; + Head_.push_back(meeting); + Tail_.push_back(meeting); + } + Tail_.push_back(Points_[i]); + } + }}; + + auto const draw_run{[&](std::vector const &Points_, ImU32 const Colour_, bool const Theoretical_) { + if (Points_.size() < 2) + { + return; + } + std::vector line; + line.reserve(Points_.size()); + for (auto const &point : Points_) + { + line.push_back(to_screen(point.x, point.y)); + } + if (Theoretical_) + { + dashed_polyline(drawlist, line, (Colour_ & 0x00ffffffu) | 0xb0000000u, 1.6f); + } + else + { + drawlist->AddPolyline(line.data(), static_cast(line.size()), Colour_, false, 2.5f); + } + if (false == m_tpl_rails) + { + return; + } + for (int side = -1; side <= 1; side += 2) + { + auto const rail{rail_line(Points_, side)}; + if (rail.size() < 2) + { + continue; + } + auto const shade{(Colour_ & 0x00ffffffu) | 0x88000000u}; + if (Theoretical_) + { + dashed_polyline(drawlist, rail, shade, 1.0f, 5.0f, 4.0f); + } + else + { + drawlist->AddPolyline(rail.data(), static_cast(rail.size()), shade, false, 1.2f); + } + } + }}; + + auto walker{start}; + auto station{0.0}; + auto heel{0.0}; + for (auto const &segment : laid.path) + { + if (segment.turn_in != 0.0) + { + auto const c{std::cos(segment.turn_in)}; + auto const s{std::sin(segment.turn_in)}; + walker = {walker.x, walker.y, walker.hx * c - walker.hy * s, walker.hx * s + walker.hy * c}; + } + std::vector points; + walker = maj0sted::domain::geometry::layout_segment(segment.k0, segment.k1, segment.length, walker, &points); + auto const from{station}; + station += segment.length; + if (segment.part == maj0sted::domain::TurnoutPart::Blade) + { + heel = station; + } + if (points.size() < 2) + { + continue; + } + auto const colour{part_colour[std::clamp(static_cast(segment.part), 0, 3)]}; + + // nothing is real before A; a straight blade is its own true shape, so it stays whole + auto const bent{segment.k0 != 0.0 || segment.k1 != 0.0}; + auto const upto{laid.has_blade && bent ? std::clamp(laid.nose_end - from, 0.0, segment.length) : 0.0}; + if (upto <= 0.0) + { + draw_run(points, colour, false); + continue; + } + std::vector ahead; + std::vector behind; + cut_at(points, upto, behind, ahead); + draw_run(behind, colour, true); + draw_run(ahead, colour, false); + } + + // the dziob as it is really cut: one straight planed rail lying on the opornica at the ostrze - the + // gap there is nothing at all - and opening to u at the punkt A, where it meets the arc. the blade + // is the rail on the far side from the way the turnout leads, so it peels off the stock rail the + // diverging track leaves behind + if (laid.has_blade && laid.nose_end > laid.blade_station) + { + auto const blade_side{-hand}; + auto const blade_colour{part_colour[1]}; + auto const foot_pose{through_at(laid.blade_station)}; + auto const tip{to_screen(foot_pose.x - foot_pose.hy * half * blade_side, foot_pose.y + foot_pose.hx * half * blade_side)}; + auto const nose{to_screen(laid.blade_nose_end.x - laid.blade_nose_end.hy * half * blade_side, laid.blade_nose_end.y + laid.blade_nose_end.hx * half * blade_side)}; + if (m_tpl_rails) + { + drawlist->AddLine(tip, nose, (blade_colour & 0x00ffffffu) | 0xee000000u, 2.4f); + } + + // the same straight said on the axis: half a gauge in from the face, so it leaves the through + // axis at nothing and meets the arc where the face meets the opornica at u + drawlist->AddLine(to_screen(foot_pose.x, foot_pose.y), to_screen(laid.blade_nose_end.x, laid.blade_nose_end.y), blade_colour, 2.5f); + if (std::hypot(nose.x - tip.x, nose.y - tip.y) >= 26.0f) + { + char label[64]; + std::snprintf(label, sizeof(label), "dziob prosty d %.4f m, od stycznosci do u", laid.nose_end - laid.blade_station); + drawlist->AddText(ImVec2((tip.x + nose.x) * 0.5f - ImGui::CalcTextSize(label).x * 0.5f, std::min(tip.y, nose.y) - 20.0f), blade_colour, label); + } + + // the other blade of the pair: the straight one, which carries the through route. its running + // edge is the tor zasadniczy's own rail, so it is drawn over it; what is planed is its back, + // from the ostrze lying on the opornica lukowa down to the rail at A + auto const straight{through_line(laid.blade_station, heel, half * hand)}; + drawlist->AddPolyline(straight.data(), static_cast(straight.size()), (blade_colour & 0x00ffffffu) | 0xdd000000u, false, 2.4f); + auto const foot{straight.front()}; + auto const shoulder{straight.back()}; + auto const nose_pose{through_at(laid.nose_end)}; + drawlist->AddLine(to_screen(laid.blade_tip.x - laid.blade_tip.hy * half * hand, laid.blade_tip.y + laid.blade_tip.hx * half * hand), + to_screen(nose_pose.x - nose_pose.hy * half * hand, nose_pose.y + nose_pose.hx * half * hand), + (blade_colour & 0x00ffffffu) | 0xdd000000u, 2.0f); + if (shoulder.x - foot.x >= 60.0f) + { + char label[64]; + std::snprintf(label, sizeof(label), "iglica prosta - jazda na wprost"); + drawlist->AddText(ImVec2((foot.x + shoulder.x) * 0.5f - ImGui::CalcTextSize(label).x * 0.5f, foot.y - (hand > 0 ? 20.0f : -8.0f)), blade_colour, label); + } + } + + // the construction points, from the very list the solver reports for a placement + if (m_tpl_marks) + { + auto marks{maj0sted::editor::turnout_marks(laid, start)}; + std::sort(marks.begin(), marks.end(), [](auto const &Left_, auto const &Right_) { return Left_.station < Right_.station; }); + + auto level{0}; + for (auto const &mark : marks) + { + if (mark.kind < 0 || mark.kind >= static_cast(sizeof(mark_names) / sizeof(mark_names[0]))) + { + continue; + } + auto const at{to_screen(mark.x, mark.y)}; + auto const blade{mark.kind == 1 || mark.kind == 7 || mark.kind == 8}; + auto const primary{mark.kind == 0 || mark.kind == 5 || mark.kind == 9}; + auto const colour{primary ? IM_COL32(255, 210, 90, 245) : (blade ? IM_COL32(120, 235, 255, 245) : IM_COL32(255, 150, 210, 220))}; + + // a tick across the track, then the name on a leader fanned out above it + drawlist->AddLine(ImVec2(at.x, at.y - 11.0f), ImVec2(at.x, at.y + 11.0f), colour, 1.5f); + + auto const reach{34.0f + 16.0f * static_cast(level % 5)}; + ImVec2 const anchor(at.x, origin.y + h * 0.58f - reach - 40.0f); + char label[96]; + if (mark.station > 0.0) + { + std::snprintf(label, sizeof(label), "%s %.4f", mark_names[mark.kind], mark.station); + } + else + { + std::snprintf(label, sizeof(label), "%s", mark_names[mark.kind]); + } + auto const size{ImGui::CalcTextSize(label)}; + ImVec2 const corner(anchor.x - size.x * 0.5f, anchor.y - size.y * 0.5f); + drawlist->AddLine(ImVec2(at.x, at.y - 11.0f), ImVec2(anchor.x, corner.y + size.y + 2.0f), IM_COL32(190, 190, 190, 110), 1.0f); + drawlist->AddRectFilled(ImVec2(corner.x - 3.0f, corner.y - 2.0f), ImVec2(corner.x + size.x + 3.0f, corner.y + size.y + 2.0f), + IM_COL32(12, 16, 24, 215), 3.0f); + drawlist->AddText(corner, colour, label); + ++level; + } + } + + // what the two line styles mean, since the difference between them is the whole point of the drawing + { + auto const left{origin.x + 14.0f}; + auto top{origin.y + 12.0f}; + auto const step{ImGui::GetTextLineHeight() + 3.0f}; + std::vector const sample{ImVec2(left, top + step * 0.5f), ImVec2(left + 34.0f, top + step * 0.5f)}; + dashed_polyline(drawlist, sample, IM_COL32(200, 210, 230, 200), 1.6f); + drawlist->AddText(ImVec2(left + 42.0f, top), IM_COL32(200, 210, 230, 220), "teoretyczny luk od PR do punktu A: nie ma tam szyny"); + top += step; + drawlist->AddLine(ImVec2(left, top + step * 0.5f), ImVec2(left + 34.0f, top + step * 0.5f), IM_COL32(120, 235, 255, 255), 2.5f); + drawlist->AddText(ImVec2(left + 42.0f, top), IM_COL32(120, 235, 255, 235), "dziob iglicy: prosty, od stycznosci z opornica do grubosci u"); + } + + // a metre bar, because with the view stretched across the track no shape can be trusted by eye + { + auto const decades{std::pow(10.0, std::floor(std::log10(std::max(span * 0.2, 1e-6))))}; + auto bar{decades}; + while (bar * sx > w * 0.3) + { + bar *= 0.5; + } + auto const left{origin.x + 14.0f}; + auto const base{origin.y + h - 16.0f}; + auto const right{left + static_cast(bar * sx)}; + drawlist->AddLine(ImVec2(left, base), ImVec2(right, base), IM_COL32(220, 220, 220, 220), 1.5f); + drawlist->AddLine(ImVec2(left, base - 5.0f), ImVec2(left, base + 5.0f), IM_COL32(220, 220, 220, 220), 1.5f); + drawlist->AddLine(ImVec2(right, base - 5.0f), ImVec2(right, base + 5.0f), IM_COL32(220, 220, 220, 220), 1.5f); + char scale_label[128]; + std::snprintf(scale_label, sizeof(scale_label), "%g m wzdluz poprzecznie x%.0f", bar, m_tpl_exagg); + drawlist->AddText(ImVec2(right + 8.0f, base - ImGui::GetTextLineHeight() * 0.5f), IM_COL32(220, 220, 220, 220), scale_label); + } + + drawlist->PopClipRect(); + ImGui::End(); +} + +void plan_panel::render_turnouts() +{ + if (false == ImGui::CollapsingHeader("Rozjazdy")) + { + return; + } + + if (m_document.turnout_types.empty()) + { + ImGui::TextDisabled("katalog jest pusty"); + return; + } + + m_turnout_type = std::clamp(m_turnout_type, 0, static_cast(m_document.turnout_types.size()) - 1); + auto &chosen{m_document.turnout_types[static_cast(m_turnout_type)]}; + ImGui::SetNextItemWidth(260.0f); + if (ImGui::BeginCombo("Typ", chosen.name.c_str())) + { + for (std::size_t i = 0; i < m_document.turnout_types.size(); ++i) + { + if (ImGui::Selectable(m_document.turnout_types[i].name.c_str(), static_cast(i) == m_turnout_type)) + { + m_turnout_type = static_cast(i); + } + } + ImGui::EndCombo(); + } + + // a document carries its own copy of the catalogue, so a plan saved before the figure + // changed keeps the numbers it was saved with - and a type laid before the iglica was a + // piece of its own has no blade in it at all. this puts the current figure back + if (auto const *preset{find_preset(chosen.name)}; preset != nullptr) + { + if (ImGui::SmallButton("przywroc z katalogu")) + { + adopt_preset(*preset, chosen); + m_sel_piece = -1; + m_status = "typ \"" + chosen.name + "\" wziety z katalogu na nowo"; + solve(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("wpisuje na nowo liczby z wbudowanego katalogu: odcinek przediglicowy, iglice, luk, prosta krzyzownicowa oraz u/d/w iglicy"); + } + ImGui::SameLine(); + } + if (ImGui::SmallButton(m_show_template ? "zamknij rysunek" : "rysunek szablonu")) + { + m_show_template = !m_show_template; + } + + render_turnout_type(chosen); + + if (ImGui::Button(m_pick_turnout == 1 ? "kliknij tor..." : "Postaw rozjazd")) + { + m_pick_turnout = m_pick_turnout == 1 ? 0 : 1; + m_status = m_pick_turnout == 1 ? "kliknij tor, na ktorym ma stanac rozjazd" : ""; + } + + ImGui::Separator(); + for (std::size_t i = 0; i < m_document.turnouts.size(); ++i) + { + auto &placement{m_document.turnouts[i]}; + ImGui::PushID(static_cast(1000 + i)); + + auto const *solved{maj0sted::editor::find_turnout(m_solution, placement.id)}; + auto const *on{maj0sted::editor::find_track(m_document, placement.on)}; + char label[200]; + std::snprintf(label, sizeof(label), "%s na %s, km %.1f%s", placement.type.c_str(), on != nullptr ? on->name.c_str() : "?", placement.station, + (solved != nullptr && solved->valid) ? "" : " (nie da sie zlozyc)"); + if (ImGui::Selectable(label, placement.id == m_sel_turnout)) + { + m_sel_turnout = placement.id; + } + + if (placement.id == m_sel_turnout) + { + ImGui::Indent(); + auto dirty{false}; + ImGui::SetNextItemWidth(130.0f); + if (ImGui::InputDouble("km", &placement.station, 1.0, 10.0, "%.2f")) + { + dirty = true; + } + int hand{placement.hand >= 0 ? 0 : 1}; + ImGui::SetNextItemWidth(130.0f); + if (ImGui::Combo("odgalezia", &hand, "w lewo\0w prawo\0")) + { + placement.hand = hand == 0 ? 1 : -1; + dirty = true; + } + if (ImGui::Checkbox("zwrotny w kierunku km", &placement.facing)) + { + dirty = true; + } + // lukowanie: standing on a curve is the ordinary way to get a rozjazd lukowy, and the + // solver takes the radius from under the PR. asking for one by hand is for a rozjazd + // bent on a track drawn straight + if (ImGui::Checkbox("wygiecie z toru pod rozjazdem", &placement.bend_from_track)) + { + dirty = true; + } + if (false == placement.bend_from_track) + { + auto radius{placement.bend != 0.0 ? std::abs(1.0 / placement.bend) : 0.0}; + int way{placement.bend >= 0.0 ? 0 : 1}; + ImGui::SetNextItemWidth(130.0f); + auto touched{ImGui::InputDouble("R zasadniczego", &radius, 10.0, 100.0, "%.3f")}; + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + touched = ImGui::Combo("wygiety", &way, "w lewo\0w prawo\0") || touched; + if (touched) + { + placement.bend = radius > 1.0 ? (way == 0 ? 1.0 : -1.0) / radius : 0.0; + dirty = true; + } + } + if (solved != nullptr && solved->valid) + { + ImGui::TextDisabled("styczne a %.3f / b %.3f m (razem %.3f), na torze zasadniczym %.3f m", solved->tangent_front, solved->tangent_back, + solved->tangent_front + solved->tangent_back, solved->through_length); + ImGui::TextDisabled("odgalezienie %.3f m", solved->diverging_length); + ImGui::TextDisabled("%s", bend_text(solved->bend, solved->diverging_curvature, solved->bend_angle).c_str()); + if (auto const *limit{bend_limit(solved->diverging_curvature)}; limit != nullptr) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "%s", limit); + } + if (solved->has_blade) + { + ImGui::TextDisabled("iglica: ostrze a %.4f m, dziob do %.4f m, struganie %.3f m", solved->blade_station, solved->nose_end, solved->planing_length); + } + } + // a rozjazd left standing across a joint, or off the end, needs a way back that does not + // mean guessing kilometres by hand: the nearest element long enough to hold it, whole + if (solved != nullptr && solved->valid) + { + auto const *through{maj0sted::editor::find_track(m_solution, placement.on)}; + double curvature{0.0}; + double behind{0.0}; + double ahead{0.0}; + auto const room{through != nullptr && maj0sted::editor::steady_room(*through, placement.station, curvature, behind, ahead) + ? (placement.facing ? ahead : behind) + : -1.0}; + if (through != nullptr && room < solved->through_length - 1e-6) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "rozjazd nie miesci sie na odcinku toru, na ktorym stoi"); + ImGui::SameLine(); + if (ImGui::SmallButton("wstaw na odcinek")) + { + double station{0.0}; + auto best{std::numeric_limits::max()}; + auto walked{0.0}; + for (auto const &element : through->elements) + { + if (std::abs(element.k1 - element.k0) < 1e-12 && element.length >= solved->through_length) + { + // anywhere on this straight with the whole rozjazd still on it + auto const low{placement.facing ? walked : walked + solved->through_length}; + auto const high{placement.facing ? walked + element.length - solved->through_length : walked + element.length}; + auto const put{std::clamp(placement.station, low, high)}; + if (std::abs(put - placement.station) < best) + { + best = std::abs(put - placement.station); + station = put; + } + } + walked += element.length; + } + if (best < std::numeric_limits::max()) + { + placement.station = station; + dirty = true; + } + else + { + m_status = "zaden odcinek tego toru nie jest dosc dlugi na ten rozjazd"; + } + } + } + } + if (ImGui::SmallButton("usun rozjazd")) + { + m_document.turnouts.erase(m_document.turnouts.begin() + static_cast(i)); + m_sel_turnout = TurnoutId::none; + ImGui::Unindent(); + ImGui::PopID(); + solve(); + return; + } + ImGui::Unindent(); + if (dirty) + { + solve(); + } + } + ImGui::PopID(); + } +} + +void plan_panel::render_diagnostics() +{ + if (m_solution.diagnostics.empty()) + { + return; + } + + // a fit that does not work says why, with the figure that matters in it. it used to come back as + // nullopt and simply disappear from the drawing + char header[64]; + std::snprintf(header, sizeof(header), "Nie wyszlo (%zu)###diag", m_solution.diagnostics.size()); + if (false == ImGui::CollapsingHeader(header, ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + for (std::size_t i = 0; i < m_solution.diagnostics.size(); ++i) + { + auto const &diagnostic{m_solution.diagnostics[i]}; + ImGui::PushID(static_cast(2000 + i)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.60f, 0.55f, 1.0f)); + auto const *track{maj0sted::editor::find_track(m_document, diagnostic.track)}; + if (ImGui::Selectable(diagnostic.text.c_str())) + { + if (diagnostic.track != TrackId::none) + { + m_track = diagnostic.track; + } + if (diagnostic.element != ElementId::none) + { + m_sel_element = diagnostic.element; + } + if (diagnostic.turnout != TurnoutId::none) + { + m_sel_turnout = diagnostic.turnout; + } + } + ImGui::PopStyleColor(); + if (track != nullptr) + { + ImGui::SameLine(); + ImGui::TextDisabled("(%s)", track->name.c_str()); + } + ImGui::PopID(); + } +} + +void plan_panel::render_storage() +{ + ImGui::Separator(); + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##path", m_path, sizeof(m_path)); + ImGui::SameLine(); + if (ImGui::Button("Save")) + { + double planx{0.0}; + double plany{0.0}; + world_to_plan(editor_mode::get_camera().Pos, planx, plany); + m_document.view_x = planx; + m_document.view_y = plany; + m_document.view_extent = static_cast(Global.editor_ortho_extent); + m_document.origin_set = true; + m_document.georeferenced = Global.scenery_georeferenced; + m_document.origin_x = Global.scenery_origin.x; + m_document.origin_y = Global.scenery_origin.y; + m_status = maj0sted::io::save(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path); + } + ImGui::SameLine(); + if (ImGui::Button("Load")) + { + auto const loaded{maj0sted::io::load(m_path)}; + if (loaded) + { + m_document = *loaded; + seed_catalogue(); + if (m_document.tracks.empty()) + { + new_track(); + } + else + { + m_track = m_document.tracks.front().id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.tracks.front().name.c_str()); + } + if (m_document.origin_set) + { + Global.scenery_georeferenced = m_document.georeferenced; + Global.scenery_origin = {m_document.origin_x, m_document.origin_y}; + } + m_document.origin_set = true; + m_document.georeferenced = Global.scenery_georeferenced; + m_document.origin_x = Global.scenery_origin.x; + m_document.origin_y = Global.scenery_origin.y; + m_pending = false; + clear_selection(); + solve(); + if (m_document.view_extent > 0.0) + { + auto &camera{editor_mode::get_camera()}; + auto const centre{plan_to_world(m_document.view_x, m_document.view_y)}; + camera.Pos.x = centre.x; + camera.Pos.z = centre.z; + Global.editor_ortho_extent = std::clamp(static_cast(m_document.view_extent), 5.0f, 20000.0f); + } + else + { + go_to_plan(); + } + m_status = "loaded " + std::string(m_path); + } + else + { + // version 1 files are not read: they described a different model + m_status = "could not read " + std::string(m_path) + " (potrzebny format m0s 2)"; + } + } + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##scnpath", m_scn_path, sizeof(m_scn_path)); + ImGui::SameLine(); + if (ImGui::Button("Export SCN")) + { + export_scn(); + } + if (false == m_status.empty()) + { + ImGui::TextDisabled("%s", m_status.c_str()); + } +} + +void plan_panel::export_scn() +{ + solve(); + maj0sted::io::ScnExportOptions opt; + opt.origin_east = Global.scenery_origin.x; + opt.origin_north = Global.scenery_origin.y; + std::ofstream out(m_scn_path, std::ios::binary | std::ios::trunc); + if (!out) + { + m_status = "could not write " + std::string(m_scn_path); + return; + } + auto const result{maj0sted::io::export_scn(m_solution, opt, out)}; + out.flush(); + m_status = out.good() ? ("exported " + std::to_string(result.tracks) + " tracks") : ("could not write " + std::string(m_scn_path)); +} + +// --------------------------------------------------------------------------- +// map dialogs +// --------------------------------------------------------------------------- + void plan_panel::render_newmap_dialog() { if (false == ImGui::BeginPopupModal("New map", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) @@ -629,12 +2830,9 @@ void plan_panel::render_location_dialog() { // the picture on screen is the one that was fetched, which may lag the current view by a // request; drawing it against its own box keeps it registered while the new one arrives - // the image arrives with its first row at the north edge; the vertical texture coordinates are - // swapped to put that row against the box's northern edge. the orthophoto is handed the same - // swap - both are placed north-west corner first, so they cannot want different conventions auto const &covered{m_topomap.covered()}; - drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y), ImVec2(0.0f, 1.0f), - ImVec2(1.0f, 0.0f)); + drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y), ImVec2(0.0f, 0.0f), + ImVec2(1.0f, 1.0f)); } auto const picked{to_screen(m_pickx, m_picky)}; @@ -671,21 +2869,18 @@ void plan_panel::start_map(bool const Georeferenced, double const Originx, doubl { Global.scenery_georeferenced = Georeferenced; Global.scenery_origin = {Originx, Originy}; - m_document.origin_set = true; - m_document.georeferenced = Georeferenced; - m_document.origin_x = Originx; - m_document.origin_y = Originy; - m_document.niwelety.clear(); - m_document.niwelety.push_back({"niweleta 1", {}, {}}); - m_niweleta = 0; - m_pending = false; - clear_selection(); - m_pick_rel = 0; - m_ask_rel_param = false; - m_sel_gap = -1; - m_pick_compound_point = false; - m_fit_guides.clear(); + Document fresh; + fresh.origin_set = true; + fresh.georeferenced = Georeferenced; + fresh.origin_x = Originx; + fresh.origin_y = Originy; + m_document = std::move(fresh); + seed_catalogue(); + new_track(); + + m_pick_turnout = 0; + m_pick_parallel = 0; solve(); // the scenery's zero is where the work starts, whichever frame it stands for @@ -698,2910 +2893,3 @@ void plan_panel::start_map(bool const Georeferenced, double const Originx, doubl m_status = Georeferenced ? "map pinned at " + to_string(Originx, 0) + ", " + to_string(Originy, 0) + " (EPSG:2180)" : "fictional map"; } - -void plan_panel::handle_scene() -{ - // clicks that land on a panel belong to the panel, not to the ground under it - if (ImGui::GetIO().WantCaptureMouse) - { - m_dragging = false; - m_dragging_cmp = false; - m_dragging_draft = false; - m_drag_junction = -1; - return; - } - - double cursorx{0.0}; - double cursory{0.0}; - // the plan view must not lean on the depth buffer: on empty ground it has nothing to read - world_to_plan(Global.editor_ortho ? ortho_cursor_world() : Global.pCamera.Pos + GfxRenderer->Mouse_Position(), cursorx, cursory); - - auto const mouse{ImGui::GetIO().MousePos}; - - if (ImGui::IsMouseDoubleClicked(0) && m_draft_place == 0 && m_anchor_pick == 0 && m_pick_rel == 0 && false == m_pick_compound_point) - { - int hit_niw{-1}; - int hit_str{-1}; - // straights catch only their trimmed remainders; fall back to the whole solved axis (curves - // included) so a double-click anywhere on the visible track still selects its niweleta - bool hit{hit_straight(mouse, hit_niw, hit_str, true)}; - if (false == hit) - { - hit_niw = nearest_niweleta_axis(mouse, 18.0f); - hit_str = -1; - hit = hit_niw >= 0; - } - if (hit && hit_niw >= 0 && static_cast(hit_niw) < m_document.niwelety.size()) - { - m_dragging = false; - m_dragging_cmp = false; - m_dragging_draft = false; - m_niweleta = static_cast(hit_niw); - m_sel_straight = hit_str; - m_sel_end = -1; - m_pending = false; - m_draft.active = false; - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - m_status = m_document.niwelety[m_niweleta].name; - return; - } - } - - if (ImGui::IsMouseClicked(0)) - { - m_dragging = false; - - if (m_draft_place == 1) - { - int hit_niw{-1}; - int hit_str{-1}; - int hit_end{-1}; - if (hit_endpoint(mouse, hit_niw, hit_str, hit_end)) - { - m_niweleta = static_cast(hit_niw); - start_draft_from_end(static_cast(hit_str), hit_end); - } - else - { - m_status = "kliknij koniec prostej"; - } - return; - } - - if (m_anchor_pick != 0) - { - int hit_niw{-1}; - int hit_str{-1}; - if (hit_straight(mouse, hit_niw, hit_str, false)) - { - auto *niweleta{current_niweleta()}; - if (niweleta != nullptr && niweleta->straights.size() >= 2) - { - auto gap{static_cast(hit_str)}; - if (gap + 1 >= niweleta->straights.size()) - { - gap = niweleta->straights.size() - 2; - } - m_anchor_pick = 0; - anchor_draft_to_gap(gap); - return; - } - } - m_status = "kliknij prostą przy luce"; - return; - } - - if (m_pick_compound_point) - { - m_fit_guides.push_back({cursorx, cursory}); - m_status = std::to_string(m_fit_guides.size()) + " pts"; - return; - } - - // dropping a switch on a through niweleta - if (m_pick_switch != 0) - { - int hit_niw{-1}; - int hit_str{-1}; - if (hit_straight(mouse, hit_niw, hit_str, true) && hit_niw >= 0 && static_cast(hit_niw) < m_solved.size()) - { - add_switch_on(static_cast(hit_niw), cursorx, cursory); - m_pick_switch = 0; - } - else - { - m_status = "kliknij niweletę na wprost"; - } - return; - } - - // picking a reference straight for a parallel/skew constraint - if (m_pick_rel != 0) - { - int hit_niw{-1}; - int hit_str{-1}; - if (hit_straight(mouse, hit_niw, hit_str, true) && m_sel_straight >= 0 && m_niweleta < m_document.niwelety.size() && - static_cast(m_sel_straight) < m_document.niwelety[m_niweleta].straights.size()) - { - auto &dep{m_document.niwelety[m_niweleta].straights[static_cast(m_sel_straight)]}; - if (!(hit_niw == static_cast(m_niweleta) && hit_str == m_sel_straight)) - { - dep.rel_kind = m_pick_rel; - dep.rel_niw = hit_niw; - dep.rel_str = hit_str; - if (m_pick_rel == 1) - { - dep.rel_offset = m_ask_rel_value; - m_status = "parallel → n" + std::to_string(hit_niw + 1) + " s" + std::to_string(hit_str); - } - else - { - auto const dx{dep.x2 - dep.x1}; - auto const dy{dep.y2 - dep.y1}; - dep.rel_length = std::max(1.0, std::hypot(dx, dy)); - dep.rel_cot = std::abs(m_ask_rel_value); - dep.rel_side = m_ask_rel_value < 0.0 ? -1 : 1; - m_status = "skew 1:" + to_string(dep.rel_cot, 0) + " → n" + std::to_string(hit_niw + 1) + " s" + std::to_string(hit_str); - } - solve(); - } - } - m_pick_rel = 0; - return; - } - - int draft_kind{-1}; - int draft_arc{-1}; - if (m_draft.active && hit_draft_station(mouse, draft_kind, draft_arc)) - { - clear_selection(); - m_dragging_draft = true; - m_draft_kind = draft_kind; - m_draft_arc = draft_arc; - m_drawing = false; - m_status = draft_kind == 0 ? "KP wejście" - : draft_kind == 1 ? "L łuku" - : draft_kind == 2 ? "KP między" - : "KP wyjście"; - return; - } - - int cmp_gap{-1}; - int cmp_kind{-1}; - int cmp_arc{-1}; - if (hit_compound_station(mouse, cmp_gap, cmp_kind, cmp_arc)) - { - clear_selection(); - m_dragging_cmp = true; - m_cmp_gap = cmp_gap; - m_cmp_kind = cmp_kind; - m_cmp_arc = cmp_arc; - m_sel_gap = cmp_gap; - m_drawing = false; - m_status = cmp_kind == 0 ? "KP wejście" - : cmp_kind == 1 ? "L łuku" - : cmp_kind == 2 ? "KP między" - : "KP wyjście"; - return; - } - - // grabbing a switch's start to slide it along the through track - for (std::size_t j = 0; j < m_junctions.size(); ++j) - { - if (false == m_junctions[j].valid) - { - continue; - } - ImVec2 sw; - if (world_to_screen(plan_to_world(m_junctions[j].px, m_junctions[j].py), sw) && std::hypot(sw.x - mouse.x, sw.y - mouse.y) <= 9.0f) - { - clear_selection(); - m_dragging_cmp = false; - m_dragging_draft = false; - m_drag_junction = static_cast(j); - m_drawing = false; - m_status = "przesuwanie rozjazdu"; - return; - } - } - - int hit_niw{-1}; - int hit_str{-1}; - int hit_end{-1}; - if (hit_endpoint(mouse, hit_niw, hit_str, hit_end)) - { - m_dragging_cmp = false; - m_dragging_draft = false; - m_sel_straight = hit_str; - m_sel_end = hit_end; - m_dragging = true; - auto *niweleta{current_niweleta()}; - if (niweleta != nullptr && static_cast(hit_str) < niweleta->straights.size()) - { - auto const &raw{niweleta->straights[static_cast(hit_str)]}; - m_drag_fixed_x = hit_end == 0 ? raw.x2 : raw.x1; - m_drag_fixed_y = hit_end == 0 ? raw.y2 : raw.y1; - auto const gx{hit_end == 0 ? raw.x1 : raw.x2}; - auto const gy{hit_end == 0 ? raw.y1 : raw.y2}; - auto const dl{std::max(1e-9, std::hypot(gx - m_drag_fixed_x, gy - m_drag_fixed_y))}; - m_drag_ux = (gx - m_drag_fixed_x) / dl; - m_drag_uy = (gy - m_drag_fixed_y) / dl; - } - } - else if (hit_straight(mouse, hit_niw, hit_str, false)) - { - m_dragging_cmp = false; - m_dragging_draft = false; - m_sel_straight = hit_str; - m_sel_end = -1; - } - else if (m_drawing && false == m_draft.active && m_draft_place == 0 && m_anchor_pick == 0) - { - clear_selection(); - append_vertex(cursorx, cursory); - } - else - { - clear_selection(); - } - } - - if (m_drag_junction >= 0) - { - if (ImGui::IsMouseDown(0) && static_cast(m_drag_junction) < m_document.junctions.size()) - { - auto &junction{m_document.junctions[static_cast(m_drag_junction)]}; - double station{junction.station}; - // slide along the through track: the cursor's nearest station on its centreline - if (junction.through >= 0 && station_on_through(static_cast(junction.through), cursorx, cursory, station)) - { - junction.station = station; - solve(); - } - } - else - { - m_drag_junction = -1; - } - } - else if (m_dragging_draft) - { - if (ImGui::IsMouseDown(0)) - { - drag_draft_station(cursorx, cursory); - } - else - { - m_dragging_draft = false; - } - } - else if (m_dragging_cmp) - { - if (ImGui::IsMouseDown(0)) - { - drag_compound_station(cursorx, cursory); - } - else - { - m_dragging_cmp = false; - } - } - else if (m_dragging && m_sel_straight >= 0 && m_sel_end >= 0) - { - if (ImGui::IsMouseDown(0)) - { - auto *niweleta{current_niweleta()}; - if (niweleta != nullptr && static_cast(m_sel_straight) < niweleta->straights.size()) - { - auto &st{niweleta->straights[static_cast(m_sel_straight)]}; - double x{cursorx}; - double y{cursory}; - // Shift: slide along the straight's axis — length changes, angle stays - if (ImGui::GetIO().KeyShift) - { - auto const t{std::max(1.0, (x - m_drag_fixed_x) * m_drag_ux + (y - m_drag_fixed_y) * m_drag_uy)}; - x = m_drag_fixed_x + m_drag_ux * t; - y = m_drag_fixed_y + m_drag_uy * t; - } - if (m_sel_end == 0) - { - st.x1 = x; - st.y1 = y; - } - else - { - st.x2 = x; - st.y2 = y; - } - if (st.rel_kind == 2) - { - st.rel_length = std::max(1.0, std::hypot(st.x2 - st.x1, st.y2 - st.y1)); - } - solve(); - } - } - else - { - m_dragging = false; - } - } -} - -void plan_panel::draw_on_scene() -{ - // the background list paints over the rendered scenery but under every window, so the controls - // stay readable on top of the drawing - auto *drawlist{ImGui::GetBackgroundDrawList()}; - - // orthophoto under everything else. only a georeferenced map knows where on the ground it sits, - // so a fictional one gets no imagery - if (m_showortho && Global.scenery_georeferenced) - { - double planx{0.0}; - double plany{0.0}; - world_to_plan(Global.pCamera.Pos, planx, plany); - - // yaw is pinned north-up, so the screen is an axis-aligned box in plan space - auto const display{ImGui::GetIO().DisplaySize}; - auto const halfheight{static_cast(Global.editor_ortho_extent)}; - auto const halfwidth{halfheight * std::max(1.0f, display.x) / std::max(1.0f, display.y)}; - maj0sted::editor::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; - - auto const draw_box = [&](maj0sted::editor::TileBBox const &Box, unsigned int const Texture) { - ImVec2 corners[4]; - auto const ok = world_to_screen(plan_to_world(Box.min_x, Box.max_y), corners[0]) && world_to_screen(plan_to_world(Box.max_x, Box.max_y), corners[1]) && - world_to_screen(plan_to_world(Box.max_x, Box.min_y), corners[2]) && world_to_screen(plan_to_world(Box.min_x, Box.min_y), corners[3]); - if (false == ok) - { - return; - } - // north-west corner first; the texture's first row is its northern edge - drawlist->AddImageQuad(reinterpret_cast(static_cast(Texture)), corners[0], corners[1], corners[2], corners[3], ImVec2(0.0f, 1.0f), ImVec2(1.0f, 1.0f), - ImVec2(1.0f, 0.0f), ImVec2(0.0f, 0.0f)); - }; - - // once the view is wider than a kilometre the 100 m orto cells are the wrong scale: - // switch to a 1 km topo grid that can cover the whole screen - constexpr double kTopoPreviewMetres = 1000.0; - auto const span{std::max(halfwidth, halfheight) * 2.0}; - if (span > kTopoPreviewMetres) - { - for (auto const &tile : m_topo.collect(view, 80)) - { - draw_box(tile.box, tile.texture); - } - } - else - { - for (auto const &tile : m_ortho.collect(view)) - { - draw_box(tile.box, tile.texture); - } - } - } - - std::vector points; - for (std::size_t n = 0; n < m_solved.size(); ++n) - { - auto const active{n == m_niweleta}; - for (auto const &polyline : m_solved[n].polylines) - { - if (polyline.points.size() < 2) - { - continue; - } - points.clear(); - points.reserve(polyline.points.size()); - for (auto const &point : polyline.points) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) - { - continue; - } - points.push_back(screen); - } - if (points.size() < 2) - { - continue; - } - drawlist->AddPolyline(points.data(), static_cast(points.size()), element_colour(polyline.kind, active), false, 2.5f); - } - } - - // switches: the diverging curve in its own colour, with a marker at the switch point and the frog - for (auto const &junction : m_junctions) - { - if (false == junction.valid) - { - continue; - } - for (auto const &polyline : junction.polylines) - { - if (polyline.points.size() < 2) - { - continue; - } - points.clear(); - for (auto const &point : polyline.points) - { - ImVec2 screen; - if (world_to_screen(plan_to_world(point.x, point.y), screen)) - { - points.push_back(screen); - } - } - if (points.size() >= 2) - { - drawlist->AddPolyline(points.data(), static_cast(points.size()), IM_COL32(255, 150, 40, 255), false, 2.5f); - } - } - ImVec2 marker; - if (world_to_screen(plan_to_world(junction.px, junction.py), marker)) - { - drawlist->AddCircleFilled(marker, 4.0f, IM_COL32(255, 210, 90, 255)); - } - if (world_to_screen(plan_to_world(junction.fx, junction.fy), marker)) - { - drawlist->AddCircleFilled(marker, 4.0f, IM_COL32(255, 150, 40, 255)); - } - } - - // arc / KP annotations: R= and KP labels, plus station ticks at each circular-arc end - // (and a stronger tick where consecutive arcs of a compound meet with different R) - if (m_niweleta < m_solved.size()) - { - auto const &solved{m_solved[m_niweleta]}; - std::set gaps; - for (auto const &poly : solved.polylines) - { - if (poly.gap >= 0) - { - gaps.insert(poly.gap); - } - } - for (auto const gap : gaps) - { - std::map by_el; - for (auto const &poly : solved.polylines) - { - if (poly.gap != gap || poly.points.size() < 2 || poly.element_index < 0) - { - continue; - } - if (by_el.find(poly.element_index) == by_el.end()) - { - by_el.emplace(poly.element_index, &poly); - } - } - - std::vector arcs; - for (auto const &[idx, poly] : by_el) - { - (void)idx; - std::vector screen; - screen.reserve(poly->points.size()); - for (auto const &point : poly->points) - { - ImVec2 s; - if (world_to_screen(plan_to_world(point.x, point.y), s)) - { - screen.push_back(s); - } - } - if (poly->kind == 1) - { - arcs.push_back(poly); - auto const r{poly->radius_start > 0.0 ? poly->radius_start : poly->radius_end}; - if (r > 0.0) - { - char buf[48]; - std::snprintf(buf, sizeof(buf), "R = %.1f m", r); - draw_curve_callout(drawlist, screen, buf, IM_COL32(255, 209, 102, 255)); - } - // ends of this circular arc - draw_arc_end_tick(drawlist, screen, true, IM_COL32(255, 100, 100, 230)); - draw_arc_end_tick(drawlist, screen, false, IM_COL32(255, 100, 100, 230)); - } - else if (poly->kind == 2 && poly->length > 0.0) - { - char buf[48]; - std::snprintf(buf, sizeof(buf), "KP L = %.1f m", poly->length); - draw_curve_callout(drawlist, screen, buf, IM_COL32(140, 245, 150, 255)); - } - } - - // compound: thicker tick where two consecutive arcs meet (radius break) - for (std::size_t i = 0; i + 1 < arcs.size(); ++i) - { - auto const r1{arcs[i]->radius_start > 0.0 ? arcs[i]->radius_start : arcs[i]->radius_end}; - auto const r2{arcs[i + 1]->radius_start > 0.0 ? arcs[i + 1]->radius_start : arcs[i + 1]->radius_end}; - if (std::abs(r1 - r2) < 1e-6) - { - continue; - } - auto const &a{arcs[i]->points.back()}; - auto const &b{arcs[i + 1]->points.front()}; - ImVec2 sa; - ImVec2 sb; - if (world_to_screen(plan_to_world(a.x, a.y), sa) && world_to_screen(plan_to_world(b.x, b.y), sb)) - { - draw_station_tick(drawlist, sa, sb, IM_COL32(255, 80, 80, 255), 10.0f); - } - } - } - } - - // independent endpoints of the active niweleta (prefer solved/trimmed positions) - if (auto const *niweleta{current_niweleta()}; niweleta != nullptr) - { - for (std::size_t i = 0; i < niweleta->straights.size(); ++i) - { - auto const *st{handle_straight(m_niweleta, i)}; - if (st == nullptr || st->hidden) - { - continue; - } - auto const selected{static_cast(i) == m_sel_straight}; - for (int end = 0; end < 2; ++end) - { - auto const x{end == 0 ? st->x1 : st->x2}; - auto const y{end == 0 ? st->y1 : st->y2}; - ImVec2 screen; - if (false == world_to_screen(plan_to_world(x, y), screen)) - { - continue; - } - auto const hot{selected && end == m_sel_end}; - drawlist->AddCircleFilled(screen, hot ? 6.0f : 5.0f, hot ? IM_COL32(255, 240, 120, 255) : selected ? IM_COL32(255, 220, 140, 220) : IM_COL32(240, 240, 240, 255)); - } - if (selected) - { - ImVec2 a; - ImVec2 b; - if (world_to_screen(plan_to_world(st->x1, st->y1), a) && world_to_screen(plan_to_world(st->x2, st->y2), b)) - { - drawlist->AddLine(a, b, IM_COL32(255, 240, 120, 200), 3.0f); - } - } - } - } - - // the corner still waiting for the one that closes its straight, and the rubber band to the cursor - auto const mouse{ImGui::GetIO().MousePos}; - auto const overscenery{false == ImGui::GetIO().WantCaptureMouse}; - if (m_draft_place == 1 && overscenery) - { - drawlist->AddCircleFilled(mouse, 5.0f, IM_COL32(255, 180, 90, 255)); - } - else if (m_pending) - { - ImVec2 screen; - if (world_to_screen(plan_to_world(m_pendingx, m_pendingy), screen)) - { - drawlist->AddCircleFilled(screen, 5.0f, IM_COL32(255, 240, 120, 255)); - if (overscenery) - { - drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); - } - } - } - else if (overscenery && m_drawing && false == m_dragging && m_draft_place == 0 && m_anchor_pick == 0) - { - if (auto const *niweleta{current_niweleta()}; niweleta != nullptr && false == niweleta->straights.empty()) - { - auto const &last{niweleta->straights.back()}; - ImVec2 screen; - if (world_to_screen(plan_to_world(last.x2, last.y2), screen)) - { - drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); - } - } - } - - // basket draft: dual rails + same station handles as compound - if (m_draft.active) - { - std::vector pts; - for (auto const &polyline : rails_from_centreline(m_draft_polys)) - { - if (polyline.points.size() < 2) - { - continue; - } - pts.clear(); - pts.reserve(polyline.points.size()); - for (auto const &point : polyline.points) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) - { - continue; - } - pts.push_back(screen); - } - if (pts.size() < 2) - { - continue; - } - drawlist->AddPolyline(pts.data(), static_cast(pts.size()), element_colour(polyline.kind, true), false, 2.5f); - } - for (auto const &h : draft_station_handles(m_draft, m_draft_polys)) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) - { - continue; - } - auto const hot{m_dragging_draft && h.kind == m_draft_kind && h.arc == m_draft_arc}; - auto const kp{h.kind == 0 || h.kind == 2 || h.kind == 3}; - auto const fill{kp ? (hot ? IM_COL32(120, 255, 160, 255) : IM_COL32(90, 230, 140, 230)) - : (hot ? IM_COL32(255, 200, 90, 255) : IM_COL32(255, 160, 70, 230))}; - drawlist->AddCircleFilled(screen, hot ? 6.5f : 5.0f, fill); - drawlist->AddCircle(screen, hot ? 6.5f : 5.0f, IM_COL32(245, 245, 245, 255)); - } - } - - // draggable compound stations (KP / arc ends) on the active niweleta - if (auto const *niweleta{current_niweleta()}; niweleta != nullptr && m_niweleta < m_solved.size()) - { - for (auto const &fit : niweleta->fits) - { - if (fit.mode != 3 || fit.arcs.empty()) - { - continue; - } - for (auto const &h : compound_station_handles(fit, m_solved[m_niweleta], fit.gap)) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) - { - continue; - } - auto const hot{m_dragging_cmp && h.gap == m_cmp_gap && h.kind == m_cmp_kind && h.arc == m_cmp_arc}; - auto const kp{h.kind == 0 || h.kind == 2 || h.kind == 3}; - auto const fill{kp ? (hot ? IM_COL32(120, 255, 160, 255) : IM_COL32(90, 230, 140, 230)) - : (hot ? IM_COL32(255, 200, 90, 255) : IM_COL32(255, 160, 70, 230))}; - drawlist->AddCircleFilled(screen, hot ? 6.5f : 5.0f, fill); - drawlist->AddCircle(screen, hot ? 6.5f : 5.0f, IM_COL32(245, 245, 245, 255)); - } - } - } - - // guide points for multi-point compound fit - for (std::size_t i = 0; i < m_fit_guides.size(); ++i) - { - auto const &g{m_fit_guides[i]}; - ImVec2 screen; - if (false == world_to_screen(plan_to_world(g.x, g.y), screen)) - { - continue; - } - drawlist->AddCircleFilled(screen, 4.5f, IM_COL32(80, 220, 255, 240)); - drawlist->AddCircle(screen, 4.5f, IM_COL32(230, 245, 255, 255)); - char buf[16]; - std::snprintf(buf, sizeof(buf), "%zu", i + 1); - drawlist->AddText({screen.x + 6.0f, screen.y - 8.0f}, IM_COL32(160, 230, 255, 255), buf); - } - if (m_pick_compound_point && overscenery && false == m_fit_guides.empty()) - { - ImVec2 prev; - if (world_to_screen(plan_to_world(m_fit_guides.back().x, m_fit_guides.back().y), prev)) - { - drawlist->AddLine(prev, mouse, IM_COL32(80, 220, 255, 140), 1.2f); - } - } -} - -void plan_panel::render_draft() -{ - ImGui::Separator(); - if (m_draft_place == 1) - { - ImGui::Text("Kosz: kliknij koniec prostej"); - ImGui::SameLine(); - if (ImGui::SmallButton("Anuluj##draftplace")) - { - m_draft_place = 0; - m_status.clear(); - } - return; - } - - if (false == m_draft.active) - { - if (ImGui::Button("Kosz")) - { - begin_draft(); - } - return; - } - - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Kosz"); - ImGui::SameLine(); - bool const left{m_draft.dir >= 0}; - if (ImGui::RadioButton("Left##ddir", left)) - { - m_draft.dir = 1; - rebuild_draft_preview(); - } - ImGui::SameLine(); - if (ImGui::RadioButton("Right##ddir", false == left)) - { - m_draft.dir = -1; - rebuild_draft_preview(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Anuluj##draft")) - { - discard_draft(); - m_status.clear(); - } - - ImGui::Indent(); - ImGui::SetNextItemWidth(100.0f); - if (ImGui::InputDouble("wejscie##dent", &m_draft.entry_t, 5.0, 20.0, "%.1f")) - { - rebuild_draft_preview(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(100.0f); - if (ImGui::InputDouble("wyjscie##dex", &m_draft.exit_t, 5.0, 20.0, "%.1f")) - { - rebuild_draft_preview(); - } - - ImGui::Spacing(); - double station{m_draft.entry_t}; - for (std::size_t i = 0; i < m_draft.arcs.size(); ++i) - { - auto &arc{m_draft.arcs[i]}; - ImGui::PushID(static_cast(i) + 9000); - ImGui::AlignTextToFramePadding(); - ImGui::Text("Luk %d", static_cast(i + 1)); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("R##dar", &arc.radius, 10.0, 100.0, "%.1f")) - { - rebuild_draft_preview(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("L##dal", &arc.length, 5.0, 20.0, "%.1f")) - { - rebuild_draft_preview(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("KP##dat", &arc.transition_to_next, 5.0, 20.0, "%.1f")) - { - rebuild_draft_preview(); - } - station += std::max(0.0, arc.length) + std::max(0.0, arc.transition_to_next); - ImGui::TextDisabled("~%.0f m", station); - ImGui::PopID(); - } - - if (ImGui::SmallButton("Add arc##draft")) - { - auto const radius{m_draft.arcs.empty() ? 300.0 : std::max(30.0, m_draft.arcs.back().radius * 0.75)}; - if (false == m_draft.arcs.empty() && m_draft.arcs.back().transition_to_next < 1e-6) - { - m_draft.arcs.back().transition_to_next = 40.0; - } - m_draft.arcs.push_back({radius, 60.0, 0.0}); - m_draft_r = radius; - m_draft.preview_next_r = radius; - rebuild_draft_preview(); - } - if (m_draft.arcs.size() > 1) - { - ImGui::SameLine(); - if (ImGui::SmallButton("Remove arc##draft")) - { - m_draft.arcs.pop_back(); - if (false == m_draft.arcs.empty()) - { - m_draft.arcs.back().transition_to_next = 0.0; - } - rebuild_draft_preview(); - } - } - - ImGui::Spacing(); - if (m_draft.reedit_gap >= 0) - { - if (ImGui::Button("Zatwierdź##dfin") && false == m_draft.arcs.empty()) - { - finish_draft(); - } - } - else - { - ImGui::SetNextItemWidth(80.0f); - ImGui::InputDouble("L prostej##dfin", &m_draft_finish_straight, 10.0, 50.0, "%.0f"); - if (m_draft_finish_straight < 20.0) - { - m_draft_finish_straight = 20.0; - } - ImGui::SameLine(); - if (ImGui::Button("Zatwierdź##dfin") && false == m_draft.arcs.empty()) - { - finish_draft(); - } - } - ImGui::Unindent(); -} - -void plan_panel::render_toolbar() -{ - auto *niweleta{current_niweleta()}; - - ImGui::Text("top-down view, %.0f m across", Global.editor_ortho_extent * 2.0f); - ImGui::TextDisabled("Shift = długość, double-click = inna niweleta"); - if (Global.scenery_georeferenced) - { - ImGui::Text("zero at %.0f, %.0f (EPSG:2180)", Global.scenery_origin.x, Global.scenery_origin.y); - } - else - { - ImGui::TextDisabled("fictional map, no georeference"); - } - ImGui::Separator(); - - if (ImGui::Button("New map")) - { - ImGui::OpenPopup("New map"); - } - ImGui::SameLine(); - if (ImGui::Button("New niweleta")) - { - m_document.niwelety.push_back({"niweleta " + std::to_string(m_document.niwelety.size() + 1), {}, {}}); - m_niweleta = m_document.niwelety.size() - 1; - m_pending = false; - clear_selection(); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - solve(); - } - ImGui::SameLine(); - if (ImGui::Button("Undo point")) - { - drop_last_vertex(); - } - ImGui::SameLine(); - if (ImGui::Button("Go to plan")) - { - go_to_plan(); - } - ImGui::SameLine(); - ImGui::Checkbox("Draw", &m_drawing); - if (Global.scenery_georeferenced) - { - ImGui::SameLine(); - ImGui::Checkbox("Orthophoto", &m_showortho); - if (m_showortho) - { - auto const pending{static_cast(m_ortho.pending() + m_topo.pending())}; - if (pending > 0) - { - ImGui::SameLine(); - ImGui::TextDisabled("(%d tiles on the way)", pending); - } - } - } - - if (m_document.niwelety.size() > 1) - { - ImGui::SetNextItemWidth(220.0f); - if (ImGui::BeginCombo("Edited", m_document.niwelety[m_niweleta].name.c_str())) - { - for (std::size_t i = 0; i < m_document.niwelety.size(); ++i) - { - if (ImGui::Selectable(m_document.niwelety[i].name.c_str(), i == m_niweleta)) - { - m_niweleta = i; - m_pending = false; - clear_selection(); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - } - } - ImGui::EndCombo(); - } - } - - if (niweleta != nullptr) - { - ImGui::SetNextItemWidth(220.0f); - if (ImGui::InputText("Name", m_namebuf, sizeof(m_namebuf))) - { - niweleta->name = m_namebuf; - } - else if (false == ImGui::IsItemActive() && niweleta->name != m_namebuf) - { - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", niweleta->name.c_str()); - } - ImGui::SameLine(); - if (m_document.niwelety.size() > 1 && ImGui::Button("Delete niweleta")) - { - delete_current_niweleta(); - niweleta = current_niweleta(); - } - } - - { - auto const sel_label{m_sel_straight >= 0 ? " | selected #" + std::to_string(m_sel_straight) : std::string{}}; - ImGui::Text("straights: %d%s", niweleta != nullptr ? static_cast(niweleta->straights.size()) : 0, sel_label.c_str()); - } - - auto const has_sel{niweleta != nullptr && m_sel_straight >= 0 && static_cast(m_sel_straight) < niweleta->straights.size()}; - if (has_sel && ImGui::Button("Delete straight")) - { - delete_selected_straight(); - niweleta = current_niweleta(); - } - if (has_sel) - { - auto &st{niweleta->straights[static_cast(m_sel_straight)]}; - ImGui::SameLine(); - if (ImGui::Button(st.rel_kind == 1 ? "Re-pick parallel..." : "Parallel...")) - { - m_ask_rel_value = st.rel_kind == 1 ? st.rel_offset : 4.5; - m_pick_rel = 1; - m_status = "referencja: parallel"; - } - ImGui::SameLine(); - if (ImGui::Button(st.rel_kind == 2 ? "Re-pick skew..." : "Skew 1:n...")) - { - m_ask_rel_value = st.rel_kind == 2 ? st.rel_cot * (st.rel_side < 0 ? -1.0 : 1.0) : 9.0; - m_pick_rel = 2; - m_status = "referencja: skew"; - } - if (st.rel_kind == 1) - { - ImGui::Text("parallel → n%d s%d", st.rel_niw + 1, st.rel_str); - ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("offset m##parlive", &st.rel_offset, 0.1, 1.0, "%.2f")) - { - solve(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Clear##parlive")) - { - st.rel_kind = 0; - m_status = "cleared parallel"; - solve(); - } - } - else if (st.rel_kind == 2) - { - auto skew_n{st.rel_cot * (st.rel_side < 0 ? -1.0 : 1.0)}; - ImGui::Text("skew → n%d s%d", st.rel_niw + 1, st.rel_str); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("1:n##skewlive", &skew_n, 1.0, 1.0, "%.2f")) - { - if (std::abs(skew_n) < 1e-9) - { - st.rel_kind = 0; - m_status = "cleared skew"; - } - else - { - st.rel_cot = std::abs(skew_n); - st.rel_side = skew_n < 0.0 ? -1 : 1; - } - solve(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("L##skewlive", &st.rel_length, 5.0, 20.0, "%.1f")) - { - if (st.rel_length < 1.0) - { - st.rel_length = 1.0; - } - solve(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Clear##skewlive")) - { - st.rel_kind = 0; - m_status = "cleared skew"; - solve(); - } - } - } - - if (m_pick_rel != 0) - { - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.4f, 1.0f), "%s", m_pick_rel == 1 ? "referencja: parallel" : "referencja: skew"); - ImGui::SameLine(); - if (ImGui::SmallButton("Cancel pick")) - { - m_pick_rel = 0; - } - } - if (m_pick_compound_point) - { - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.4f, 1.0f), "punkty: %zu", m_fit_guides.size()); - ImGui::SameLine(); - if (ImGui::SmallButton("Fit##cmpick") && false == m_fit_guides.empty()) - { - fit_compound_to_guides(); - m_pick_compound_point = false; - } - ImGui::SameLine(); - if (ImGui::SmallButton("Clear##cmpick")) - { - clear_fit_guides(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Cancel##cmpick")) - { - m_pick_compound_point = false; - clear_fit_guides(); - } - } -} - -void plan_panel::render_gaps() -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || niweleta->straights.size() < 2) - { - return; - } - - ImGui::Separator(); - if (false == ImGui::CollapsingHeader("Fits", ImGuiTreeNodeFlags_DefaultOpen)) - { - return; - } - - ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 280.0f), false); - bool dirty{false}; - for (std::size_t gap = 0; gap < niweleta->fits.size(); ++gap) - { - auto &fit{niweleta->fits[gap]}; - ImGui::PushID(static_cast(gap)); - - auto const selected_gap{m_sel_gap == static_cast(gap)}; - if (ImGui::Selectable(selected_gap ? "gap##sel" : "gap##unsel", selected_gap, ImGuiSelectableFlags_AllowItemOverlap, ImVec2(36.0f, 0.0f))) - { - m_sel_gap = static_cast(gap); - } - ImGui::SameLine(); - ImGui::Text("%d", static_cast(gap)); - ImGui::SameLine(70.0f); - ImGui::SetNextItemWidth(170.0f); - // the library numbers these; 3 is the unified compound, any number of arcs eased by clothoids - if (ImGui::Combo("##mode", &fit.mode, "none\0arc\0arc + clothoids\0compound\0")) - { - m_sel_gap = static_cast(gap); - if (fit.mode != 0 && fit.radius <= 0.0) - { - fit.radius = 300.0; // something drawable to start from, rather than a rejected fit - } - if (fit.mode == 3 && fit.arcs.empty()) - { - // a basket needs at least two arcs to be one; the last absorbs the leftover deflection - fit.arcs.push_back({600.0, 60.0, 0.0}); - fit.arcs.push_back({300.0, 0.0, 0.0}); - } - dirty = true; - } - if (fit.mode == 1 || fit.mode == 2) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(120.0f); - if (ImGui::InputDouble("R", &fit.radius, 10.0, 100.0, "%.1f")) - { - dirty = true; - } - } - if (fit.mode == 2) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(120.0f); - if (ImGui::InputDouble("L", &fit.transition, 5.0, 20.0, "%.1f")) - { - dirty = true; - } - } - if (fit.mode == 3) - { - render_compound(fit, gap, dirty); - } - // a fit the library could not produce is simply absent from its answer; say so rather than - // leaving the parameters looking as though they took effect - if (fit.mode != 0 && false == fit_applied(gap)) - { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "does not fit"); - } - else if (fit.mode != 0 && m_niweleta < m_solved.size()) - { - auto const &solved{m_solved[m_niweleta]}; - double total{0.0}; - double kp_len{0.0}; - int kp_count{0}; - std::vector radii; - for (auto const &poly : solved.polylines) - { - if (poly.gap != static_cast(gap) || poly.element_index < 0) - { - continue; - } - // count each element once (rails may duplicate polylines) - bool seen{false}; - for (auto const &other : solved.polylines) - { - if (&other == &poly) - { - break; - } - if (other.gap == poly.gap && other.element_index == poly.element_index) - { - seen = true; - break; - } - } - if (seen) - { - continue; - } - total += poly.length; - if (poly.kind == 2) - { - kp_len += poly.length; - ++kp_count; - } - if (poly.kind == 1) - { - auto const r{poly.radius_start > 0.0 ? poly.radius_start : poly.radius_end}; - if (r > 0.0) - { - radii.push_back(r); - } - } - } - if (false == radii.empty()) - { - std::string rtxt{"R lukow: "}; - for (std::size_t i = 0; i < radii.size(); ++i) - { - if (i > 0) - { - rtxt += " / "; - } - char piece[32]; - std::snprintf(piece, sizeof(piece), "%.1f", radii[i]); - rtxt += piece; - } - rtxt += " m"; - ImGui::TextDisabled("%s", rtxt.c_str()); - } - if (kp_count > 0) - { - ImGui::TextDisabled("KP: %d L = %.1f m (krzywa lacznie %.1f m)", kp_count, kp_len, total); - } - else if (total > 0.0) - { - ImGui::TextDisabled("krzywa L = %.1f m", total); - } - } - - ImGui::PopID(); - } - ImGui::EndChild(); - - if (dirty) - { - solve(); - } -} - -void plan_panel::render_compound(maj0sted::editor::GapFit &Fit, std::size_t const Gap, bool &Dirty) -{ - ImGui::Indent(); - - if (ImGui::SmallButton("Kosz##reopen") && false == m_draft.active) - { - if (reopen_draft_from_gap(Gap)) - { - Dirty = false; - ImGui::Unindent(); - return; - } - } - - ImGui::SetNextItemWidth(100.0f); - if (ImGui::InputDouble("wejscie##ent", &Fit.entry_t, 5.0, 20.0, "%.1f")) - { - Dirty = true; - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(100.0f); - if (ImGui::InputDouble("wyjscie##ex", &Fit.exit_t, 5.0, 20.0, "%.1f")) - { - Dirty = true; - } - - ImGui::Spacing(); - double station{Fit.entry_t}; - for (std::size_t i = 0; i < Fit.arcs.size(); ++i) - { - auto &arc{Fit.arcs[i]}; - auto const last{i + 1 == Fit.arcs.size()}; - ImGui::PushID(static_cast(i)); - - ImGui::AlignTextToFramePadding(); - ImGui::Text("Luk %d", static_cast(i + 1)); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("R##ar", &arc.radius, 10.0, 100.0, "%.1f")) - { - Dirty = true; - } - - if (false == last) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("L##al", &arc.length, 5.0, 20.0, "%.1f")) - { - Dirty = true; - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("KP##at", &arc.transition_to_next, 5.0, 20.0, "%.1f")) - { - Dirty = true; - } - station += std::max(0.0, arc.length) + std::max(0.0, arc.transition_to_next); - ImGui::TextDisabled("~%.0f m", station); - } - else - { - ImGui::SameLine(); - ImGui::TextDisabled("L auto"); - } - - ImGui::PopID(); - } - - if (ImGui::SmallButton("Add arc")) - { - auto const radius{Fit.arcs.empty() ? 300.0 : Fit.arcs.back().radius}; - if (false == Fit.arcs.empty() && !(Fit.arcs.back().length > 0.0)) - { - Fit.arcs.back().length = 60.0; - } - Fit.arcs.insert(Fit.arcs.end() - (Fit.arcs.empty() ? 0 : 1), {radius, 60.0, 0.0}); - Dirty = true; - } - if (Fit.arcs.size() > 2) - { - ImGui::SameLine(); - if (ImGui::SmallButton("Remove arc")) - { - Fit.arcs.erase(Fit.arcs.end() - 2); - Dirty = true; - } - } - - ImGui::Spacing(); - if (ImGui::Button("Fit through points")) - { - m_sel_gap = static_cast(Gap); - m_pick_compound_point = true; - m_pick_rel = 0; - m_drawing = false; - m_fit_guides.clear(); - m_status = "click points, then Fit"; - } - - ImGui::Unindent(); -} - -void plan_panel::clear_fit_guides() -{ - m_fit_guides.clear(); - m_status.clear(); -} - -void plan_panel::fit_compound_to_guides() -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || m_sel_gap < 0 || static_cast(m_sel_gap) >= niweleta->fits.size()) - { - return; - } - if (m_fit_guides.empty()) - { - return; - } - auto const gap{static_cast(m_sel_gap)}; - if (gap + 1 >= niweleta->straights.size()) - { - return; - } - auto &fit{niweleta->fits[gap]}; - if (fit.mode != 3) - { - return; - } - if (fit.arcs.empty()) - { - fit.arcs.push_back({fit.radius > 30.0 ? fit.radius : 300.0, 0.0, 0.0}); - } - for (auto &arc : fit.arcs) - { - if (!(arc.radius >= 30.0)) - { - arc.radius = 300.0; - } - } - for (std::size_t i = 1; i < fit.arcs.size(); ++i) - { - if (std::abs(fit.arcs[i].radius - fit.arcs[i - 1].radius) < 1.0) - { - fit.arcs[i].radius = std::max(30.0, fit.arcs[i - 1].radius * 0.75); - } - } - for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) - { - if (!(fit.arcs[i].length > 0.0)) - { - fit.arcs[i].length = 60.0; - } - } - - auto &entry{niweleta->straights[gap]}; - auto &exit{niweleta->straights[gap + 1]}; - - // search window for straight ends: big enough to reach the guide cloud - double gx{0.0}; - double gy{0.0}; - for (auto const &g : m_fit_guides) - { - gx += g.x; - gy += g.y; - } - gx /= static_cast(m_fit_guides.size()); - gy /= static_cast(m_fit_guides.size()); - auto guide_span{80.0}; - for (auto const &g : m_fit_guides) - { - guide_span = std::max(guide_span, std::hypot(g.x - gx, g.y - gy) * 1.6); - } - auto const near_span{std::max(guide_span, 120.0)}; - auto const far_span{std::max(guide_span * 0.5, 60.0)}; - - enum class KnobKind - { - EntryT, - ExitT, - Radius, - Length, - Between, - EntryNearX, - EntryNearY, - ExitNearX, - ExitNearY, - EntryFarX, - EntryFarY, - ExitFarX, - ExitFarY - }; - struct Knob - { - KnobKind kind{}; - std::size_t arc{0}; - double lo{0.0}; - double hi{0.0}; - int priority{0}; - }; - - std::vector knobs; - knobs.push_back({KnobKind::EntryT, 0, 0.0, std::max(fit.entry_t * 4.0, 180.0), 2}); - knobs.push_back({KnobKind::ExitT, 0, 0.0, std::max(fit.exit_t * 4.0, 180.0), 2}); - for (std::size_t i = 0; i < fit.arcs.size(); ++i) - { - auto const &arc{fit.arcs[i]}; - knobs.push_back({KnobKind::Radius, i, 30.0, std::max(arc.radius * 5.0, 1200.0), 3}); - if (i + 1 < fit.arcs.size()) - { - knobs.push_back({KnobKind::Length, i, 5.0, std::max(arc.length * 6.0, 400.0), 3}); - knobs.push_back({KnobKind::Between, i, 0.0, std::max(arc.transition_to_next * 4.0, 160.0), 2}); - } - } - knobs.push_back({KnobKind::EntryNearX, 0, entry.x2 - near_span, entry.x2 + near_span, 4}); - knobs.push_back({KnobKind::EntryNearY, 0, entry.y2 - near_span, entry.y2 + near_span, 4}); - knobs.push_back({KnobKind::ExitNearX, 0, exit.x1 - near_span, exit.x1 + near_span, 4}); - knobs.push_back({KnobKind::ExitNearY, 0, exit.y1 - near_span, exit.y1 + near_span, 4}); - knobs.push_back({KnobKind::EntryFarX, 0, entry.x1 - far_span, entry.x1 + far_span, 1}); - knobs.push_back({KnobKind::EntryFarY, 0, entry.y1 - far_span, entry.y1 + far_span, 1}); - knobs.push_back({KnobKind::ExitFarX, 0, exit.x2 - far_span, exit.x2 + far_span, 1}); - knobs.push_back({KnobKind::ExitFarY, 0, exit.y2 - far_span, exit.y2 + far_span, 1}); - std::stable_sort(knobs.begin(), knobs.end(), [](Knob const &A, Knob const &B) { return A.priority > B.priority; }); - - auto trial{m_document}; - auto &trial_niw{trial.niwelety[m_niweleta]}; - auto &trial_fit{trial_niw.fits[gap]}; - auto &trial_entry{trial_niw.straights[gap]}; - auto &trial_exit{trial_niw.straights[gap + 1]}; - - auto get_value = [&](Knob const &K) -> double { - switch (K.kind) - { - case KnobKind::EntryT: - return trial_fit.entry_t; - case KnobKind::ExitT: - return trial_fit.exit_t; - case KnobKind::Radius: - return trial_fit.arcs[K.arc].radius; - case KnobKind::Length: - return trial_fit.arcs[K.arc].length; - case KnobKind::Between: - return trial_fit.arcs[K.arc].transition_to_next; - case KnobKind::EntryNearX: - return trial_entry.x2; - case KnobKind::EntryNearY: - return trial_entry.y2; - case KnobKind::ExitNearX: - return trial_exit.x1; - case KnobKind::ExitNearY: - return trial_exit.y1; - case KnobKind::EntryFarX: - return trial_entry.x1; - case KnobKind::EntryFarY: - return trial_entry.y1; - case KnobKind::ExitFarX: - return trial_exit.x2; - case KnobKind::ExitFarY: - return trial_exit.y2; - } - return 0.0; - }; - auto set_value = [&](Knob const &K, double const V) { - switch (K.kind) - { - case KnobKind::EntryT: - trial_fit.entry_t = V; - break; - case KnobKind::ExitT: - trial_fit.exit_t = V; - break; - case KnobKind::Radius: - trial_fit.arcs[K.arc].radius = V; - break; - case KnobKind::Length: - trial_fit.arcs[K.arc].length = V; - break; - case KnobKind::Between: - trial_fit.arcs[K.arc].transition_to_next = V; - break; - case KnobKind::EntryNearX: - trial_entry.x2 = V; - break; - case KnobKind::EntryNearY: - trial_entry.y2 = V; - break; - case KnobKind::ExitNearX: - trial_exit.x1 = V; - break; - case KnobKind::ExitNearY: - trial_exit.y1 = V; - break; - case KnobKind::EntryFarX: - trial_entry.x1 = V; - break; - case KnobKind::EntryFarY: - trial_entry.y1 = V; - break; - case KnobKind::ExitFarX: - trial_exit.x2 = V; - break; - case KnobKind::ExitFarY: - trial_exit.y2 = V; - break; - } - }; - auto radii_legal = [&]() { - for (std::size_t i = 0; i < trial_fit.arcs.size(); ++i) - { - if (!(trial_fit.arcs[i].radius >= 30.0)) - { - return false; - } - if (i > 0 && std::abs(trial_fit.arcs[i].radius - trial_fit.arcs[i - 1].radius) < 1.0) - { - return false; - } - } - return true; - }; - - auto evaluate = [&](double &OutMaxMiss) { - OutMaxMiss = std::numeric_limits::infinity(); - if (false == radii_legal()) - { - return std::numeric_limits::infinity(); - } - // tiny straights break the fitter - if (std::hypot(trial_entry.x2 - trial_entry.x1, trial_entry.y2 - trial_entry.y1) < 1.0 || - std::hypot(trial_exit.x2 - trial_exit.x1, trial_exit.y2 - trial_exit.y1) < 1.0) - { - return std::numeric_limits::infinity(); - } - auto const solved{maj0sted::editor::solve_project(trial.niwelety)}; - if (m_niweleta >= solved.size()) - { - return std::numeric_limits::infinity(); - } - double sum2{0.0}; - double max_miss{0.0}; - for (auto const &g : m_fit_guides) - { - auto const d{distance_to_gap_track(solved[m_niweleta], m_sel_gap, g.x, g.y)}; - if (false == std::isfinite(d)) - { - return std::numeric_limits::infinity(); - } - sum2 += d * d; - max_miss = std::max(max_miss, d); - } - OutMaxMiss = max_miss; - return sum2; - }; - - double best_max{0.0}; - auto best_cost{evaluate(best_max)}; - auto best_niw{trial_niw}; - - auto scan_knob = [&](Knob const &K, int const Samples) { - for (int s = 0; s < Samples; ++s) - { - auto const v{K.lo + (K.hi - K.lo) * static_cast(s) / static_cast(std::max(1, Samples - 1))}; - set_value(K, v); - double max_miss{0.0}; - auto const cost{evaluate(max_miss)}; - if (cost < best_cost) - { - best_cost = cost; - best_max = max_miss; - best_niw = trial_niw; - } - } - trial_niw = best_niw; - }; - - for (int round = 0; round < 3; ++round) - { - for (auto const &knob : knobs) - { - auto const samples{knob.priority >= 4 ? 22 : (knob.priority >= 3 ? 20 : (knob.priority >= 2 ? 14 : 10))}; - scan_knob(knob, samples); - } - } - - for (auto const &knob : knobs) - { - trial_niw = best_niw; - auto const centre{get_value(knob)}; - auto const span{(knob.hi - knob.lo) / 16.0}; - auto const lo{std::max(knob.lo, centre - span)}; - auto const hi{std::min(knob.hi, centre + span)}; - for (int s = 0; s < 14; ++s) - { - set_value(knob, lo + (hi - lo) * static_cast(s) / 13.0); - double max_miss{0.0}; - auto const cost{evaluate(max_miss)}; - if (cost < best_cost) - { - best_cost = cost; - best_max = max_miss; - best_niw = trial_niw; - } - } - } - - *niweleta = best_niw; - // drop constraints that would yank the ends back after we moved them on purpose - niweleta->straights[gap].rel_kind = 0; - niweleta->straights[gap + 1].rel_kind = 0; - solve(); - auto const rms{std::sqrt(best_cost / static_cast(m_fit_guides.size()))}; - m_status = "fit " + std::to_string(m_fit_guides.size()) + " pts rms " + to_string(rms, 2) + " max " + to_string(best_max, 2) + " m"; -} - -void plan_panel::render_switches() -{ - if (false == ImGui::CollapsingHeader("Rozjazdy")) - { - return; - } - - // typical Polish turnout templates: pick one to set the crossing mark and radius at once - auto const &presets{editor::turnout_presets()}; - ImGui::SetNextItemWidth(160.0f); - if (ImGui::BeginCombo("typ", "wybierz szablon")) - { - for (auto const &preset : presets) - { - if (ImGui::Selectable(preset.name.c_str())) - { - m_sw_crossing = preset.crossing_n; - m_sw_radius = preset.radius; - m_sw_length = preset.length; - m_sw_pre_blade = preset.pre_blade; - m_sw_blade_angle = preset.blade_angle; - m_sw_blade_length = preset.blade_length; - } - } - ImGui::EndCombo(); - } - // the template's catalogue length (PR->KR); custom switches with no template just span their curve - ImGui::SameLine(); - if (m_sw_length > 0.0) - { - ImGui::TextDisabled("dl. %.3f m (Id-1)", m_sw_length); - } - else - { - ImGui::TextDisabled("dl. ~%.1f m (sam luk)", m_sw_radius * std::atan(1.0 / std::max(1.0, m_sw_crossing))); - } - - ImGui::SetNextItemWidth(90.0f); - ImGui::Combo("strona", &m_sw_side, "w lewo\0w prawo\0"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - ImGui::InputDouble("skos 1:", &m_sw_crossing, 1.0, 1.0, "%.1f"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - ImGui::InputDouble("R", &m_sw_radius, 10.0, 50.0, "%.0f"); - if (m_sw_crossing < 4.0) - { - m_sw_crossing = 4.0; - } - if (m_sw_radius < 50.0) - { - m_sw_radius = 50.0; - } - - if (m_pick_switch != 0) - { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "kliknij niweletę na wprost..."); - ImGui::SameLine(); - if (ImGui::Button("anuluj")) - { - m_pick_switch = 0; - } - } - else if (ImGui::Button("Dodaj rozjazd")) - { - m_pick_switch = 1; - m_status = "kliknij niweletę na wprost"; - } - - ImGui::TextDisabled("odnoga wychodzi wymuszenie stycznie; luk koszowy zaraz za rozjazdem = luka 0 odnogi"); - - // existing switches: the theoretical tangent lengths the solver worked out, plus the internal - // curve (a plain arc, or a łuk koszowy right inside the switch) - for (std::size_t i = 0; i < m_document.junctions.size(); ++i) - { - auto &junction{m_document.junctions[i]}; - ImGui::PushID(static_cast(i)); - char const *through_name{junction.through >= 0 && static_cast(junction.through) < m_document.niwelety.size() - ? m_document.niwelety[static_cast(junction.through)].name.c_str() - : "?"}; - bool const valid{i < m_junctions.size() && m_junctions[i].valid}; - - ImGui::AlignTextToFramePadding(); - ImGui::Text("%zu. na %s, 1:%.1f %s", i + 1, through_name, junction.crossing_n, junction.side == 0 ? "L" : "P"); - if (valid) - { - ImGui::SameLine(); - ImGui::TextDisabled("dl=%.1f m, styczne t=%.1f / %.1f m", m_junctions[i].length, m_junctions[i].tangent_front, m_junctions[i].tangent_back); - } - else - { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "nie wpasowany"); - } - ImGui::SameLine(); - if (ImGui::SmallButton(junction.side == 0 ? "L->P" : "P->L")) - { - junction.side = junction.side == 0 ? 1 : 0; - solve(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("usuń")) - { - m_document.junctions.erase(m_document.junctions.begin() + static_cast(i)); - solve(); - ImGui::PopID(); - break; - } - - // internal diverging curve of the switch: a single arc, or a basket of arcs - ImGui::Indent(); - int curve_mode{junction.curve.mode == 3 ? 1 : 0}; - ImGui::SetNextItemWidth(110.0f); - if (ImGui::Combo("krzywa", &curve_mode, "luk\0koszowy\0")) - { - if (curve_mode == 1) - { - junction.curve.mode = 3; - if (junction.curve.arcs.empty()) - { - // a basket needs at least two arcs; a gentler entry radius eases into the nominal - // one, whose closing arc absorbs the rest of the turn. a short fixed length keeps - // the whole curve inside the turnout rather than far past its end - auto const nominal{junction.curve.radius > 0.0 ? junction.curve.radius : 300.0}; - junction.curve.arcs.push_back({nominal * 2.0, 6.0, 0.0}); - junction.curve.arcs.push_back({nominal, 0.0, 0.0}); - } - } - else - { - junction.curve.mode = 1; - } - solve(); - } - - if (junction.curve.mode == 3) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("wej##je", &junction.curve.entry_t, 5.0, 20.0, "%.1f")) - { - solve(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("wyj##jx", &junction.curve.exit_t, 5.0, 20.0, "%.1f")) - { - solve(); - } - for (std::size_t a = 0; a < junction.curve.arcs.size(); ++a) - { - auto &arc{junction.curve.arcs[a]}; - bool const last{a + 1 == junction.curve.arcs.size()}; - ImGui::PushID(static_cast(a)); - ImGui::AlignTextToFramePadding(); - ImGui::Text("Luk %d", static_cast(a + 1)); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("R##jar", &arc.radius, 10.0, 100.0, "%.1f")) - { - solve(); - } - if (false == last) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("L##jal", &arc.length, 5.0, 20.0, "%.1f")) - { - solve(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("KP##jat", &arc.transition_to_next, 5.0, 20.0, "%.1f")) - { - solve(); - } - } - else - { - ImGui::SameLine(); - ImGui::TextDisabled("domyka"); - } - ImGui::PopID(); - } - if (ImGui::SmallButton("+ luk")) - { - junction.curve.arcs.insert(junction.curve.arcs.end() - 1, {junction.curve.arcs.back().radius, 40.0, 0.0}); - solve(); - } - if (junction.curve.arcs.size() > 2) - { - ImGui::SameLine(); - if (ImGui::SmallButton("- luk")) - { - junction.curve.arcs.erase(junction.curve.arcs.end() - 2); - solve(); - } - } - } - else - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("R##jr", &junction.curve.radius, 10.0, 50.0, "%.0f")) - { - solve(); - } - } - - if (junction.branch >= 0 && static_cast(junction.branch) < m_document.niwelety.size()) - { - ImGui::SameLine(); - if (ImGui::SmallButton("edytuj odnoge")) - { - m_niweleta = static_cast(junction.branch); - clear_selection(); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - } - } - ImGui::Unindent(); - ImGui::PopID(); - } -} - -void plan_panel::add_switch_on(std::size_t const Through, double const Wx, double const Wy) -{ - if (Through >= m_solved.size()) - { - return; - } - double best_station{0.0}; - if (false == station_on_through(Through, Wx, Wy, best_station)) - { - m_status = "niweleta bez osi"; - return; - } - - maj0sted::editor::Junction junction; - junction.through = static_cast(Through); - junction.station = best_station; - junction.side = m_sw_side; - junction.facing = true; - junction.crossing_n = m_sw_crossing; - junction.length = m_sw_length; // catalogue length from the template, 0 for a custom switch - // the blade figures come from the template too; all zero lays a tangent arc from PR - junction.pre_blade = m_sw_pre_blade; - junction.blade_angle = m_sw_blade_angle; - junction.blade_length = m_sw_blade_length; - junction.curve.mode = 1; // plain arc; the internal curve can be made compound later - junction.curve.radius = m_sw_radius; - junction.branch = static_cast(m_document.niwelety.size()); - - // a branch niweleta to draw the diverging track into, pinned to the frog by the solver - maj0sted::editor::NiweletaSpec branch; - branch.name = "odnoga " + std::to_string(m_document.niwelety.size() + 1); - branch.straights.push_back({Wx, Wy, Wx + 10.0, Wy, false}); // placeholder, repositioned below - m_document.niwelety.push_back(std::move(branch)); - m_document.junctions.push_back(junction); - - // solve once to learn where the frog lands, then seed the branch to leave it tangent - solve(); - if (false == m_junctions.empty() && m_junctions.back().valid) - { - auto const &geom{m_junctions.back()}; - auto &seed{m_document.niwelety[static_cast(junction.branch)].straights.front()}; - seed = {geom.fx, geom.fy, geom.fx + geom.fhx * 50.0, geom.fy + geom.fhy * 50.0, false}; - } - - m_niweleta = static_cast(junction.branch); - clear_selection(); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - solve(); - m_status = "rozjazd 1:" + to_string(m_sw_crossing, 1) + " dodany"; -} - -void plan_panel::render_storage() -{ - ImGui::Separator(); - ImGui::SetNextItemWidth(240.0f); - ImGui::InputText("##path", m_path, sizeof(m_path)); - ImGui::SameLine(); - if (ImGui::Button("Save")) - { - double planx{0.0}; - double plany{0.0}; - world_to_plan(editor_mode::get_camera().Pos, planx, plany); - m_document.view_x = planx; - m_document.view_y = plany; - m_document.view_extent = static_cast(Global.editor_ortho_extent); - m_document.origin_set = true; - m_document.georeferenced = Global.scenery_georeferenced; - m_document.origin_x = Global.scenery_origin.x; - m_document.origin_y = Global.scenery_origin.y; - m_status = maj0sted::app::save_project(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path); - } - ImGui::SameLine(); - if (ImGui::Button("Load")) - { - auto const loaded{maj0sted::app::load_project(m_path)}; - if (loaded) - { - m_document = *loaded; - if (m_document.niwelety.empty()) - { - m_document.niwelety.push_back({"niweleta 1", {}, {}}); - } - auto looks_puwg = [](maj0sted::app::EditorDocument const &Doc) { - if (std::abs(Doc.view_x) > 10000.0 || std::abs(Doc.view_y) > 10000.0) - { - return true; - } - for (auto const &n : Doc.niwelety) - { - for (auto const &s : n.straights) - { - if (std::abs(s.x1) > 10000.0 || std::abs(s.y1) > 10000.0 || std::abs(s.x2) > 10000.0 || std::abs(s.y2) > 10000.0) - { - return true; - } - } - } - return false; - }; - if (m_document.origin_set) - { - Global.scenery_georeferenced = m_document.georeferenced; - Global.scenery_origin = {m_document.origin_x, m_document.origin_y}; - } - if (false == Global.scenery_georeferenced && looks_puwg(m_document)) - { - Global.scenery_georeferenced = true; - } - m_document.origin_set = true; - m_document.georeferenced = Global.scenery_georeferenced; - m_document.origin_x = Global.scenery_origin.x; - m_document.origin_y = Global.scenery_origin.y; - m_niweleta = 0; - m_pending = false; - clear_selection(); - m_pick_rel = 0; - m_ask_rel_param = false; - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[0].name.c_str()); - solve(); - if (m_document.view_extent > 0.0) - { - auto &camera{editor_mode::get_camera()}; - auto const centre{plan_to_world(m_document.view_x, m_document.view_y)}; - camera.Pos.x = centre.x; - camera.Pos.z = centre.z; - Global.editor_ortho_extent = std::clamp(static_cast(m_document.view_extent), 5.0f, 20000.0f); - } - else - { - go_to_plan(); - } - m_status = "loaded " + std::string(m_path); - } - else - { - m_status = "could not read " + std::string(m_path); - } - } - ImGui::SetNextItemWidth(240.0f); - ImGui::InputText("##scnpath", m_scn_path, sizeof(m_scn_path)); - ImGui::SameLine(); - if (ImGui::Button("Export SCN")) - { - export_scn(); - } - if (false == m_status.empty()) - { - ImGui::TextDisabled("%s", m_status.c_str()); - } -} - -void plan_panel::export_scn() -{ - solve(); - maj0sted::io::ScnExportOptions opt; - opt.origin_east = Global.scenery_origin.x; - opt.origin_north = Global.scenery_origin.y; - std::ofstream out(m_scn_path, std::ios::binary | std::ios::trunc); - if (!out) - { - m_status = "could not write " + std::string(m_scn_path); - return; - } - auto const result{maj0sted::io::export_scn(m_solved, opt, out)}; - out.flush(); - m_status = out.good() ? ("exported " + std::to_string(result.tracks) + " tracks") - : ("could not write " + std::string(m_scn_path)); -} - - -void plan_panel::solve() -{ - for (auto &niweleta : m_document.niwelety) - { - maj0sted::app::align_gap_fits(niweleta); - } - maj0sted::app::apply_straight_constraints(m_document.niwelety); - // switches pin their branches, so the whole layout is solved together - auto layout{maj0sted::editor::solve_layout(m_document.niwelety, m_document.junctions)}; - m_solved = std::move(layout.niwelety); - m_junctions = std::move(layout.junctions); -} - - -void plan_panel::apply_constraints() -{ - maj0sted::app::apply_straight_constraints(m_document.niwelety); -} - - -maj0sted::editor::NiweletaSpec *plan_panel::current_niweleta() -{ - return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; -} - -maj0sted::editor::NiweletaSpec const *plan_panel::current_niweleta() const -{ - return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; -} - -void plan_panel::align_fits() -{ - for (auto &niweleta : m_document.niwelety) - { - maj0sted::app::align_gap_fits(niweleta); - } -} - - -bool plan_panel::fit_applied(std::size_t const Gap) const -{ - if (m_niweleta >= m_solved.size()) - { - return false; - } - for (auto const &fit : m_solved[m_niweleta].applied_fits) - { - if (fit.gap >= 0 && static_cast(fit.gap) == Gap) - { - return true; - } - } - return false; -} - -maj0sted::editor::StraightSpec const *plan_panel::handle_straight(std::size_t const Niw, std::size_t const Index) const -{ - if (Niw >= m_document.niwelety.size()) - { - return nullptr; - } - auto const &raw{m_document.niwelety[Niw].straights}; - if (Index >= raw.size()) - { - return nullptr; - } - if (Niw < m_solved.size() && Index < m_solved[Niw].rendered_straights.size()) - { - return &m_solved[Niw].rendered_straights[Index]; - } - return &raw[Index]; -} - -void plan_panel::append_vertex(double const X, double const Y) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - return; - } - - if (niweleta->straights.empty()) - { - if (false == m_pending) - { - // nothing to attach to yet, so the first corner waits for the one that closes the straight - m_pending = true; - m_pendingx = X; - m_pendingy = Y; - return; - } - m_pending = false; - niweleta->straights.push_back({m_pendingx, m_pendingy, X, Y, false}); - } - else - { - // seed from the previous end, but the joint is not locked afterwards - auto const &last{niweleta->straights.back()}; - niweleta->straights.push_back({last.x2, last.y2, X, Y, false}); - } - - solve(); -} - -void plan_panel::drop_last_vertex() -{ - if (m_pending) - { - m_pending = false; - return; - } - - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || niweleta->straights.empty()) - { - return; - } - - m_sel_straight = static_cast(niweleta->straights.size() - 1); - delete_selected_straight(); -} - -void plan_panel::clear_selection() -{ - m_sel_straight = -1; - m_sel_end = -1; - m_dragging = false; - m_dragging_cmp = false; - m_cmp_gap = -1; - m_cmp_kind = -1; - m_cmp_arc = -1; - m_dragging_draft = false; - m_draft_kind = -1; - m_draft_arc = -1; -} - -void plan_panel::delete_selected_straight() -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || m_sel_straight < 0 || static_cast(m_sel_straight) >= niweleta->straights.size()) - { - return; - } - - auto const sel{m_sel_straight}; - niweleta->straights.erase(niweleta->straights.begin() + sel); - - // gaps touching the removed straight disappear; later gaps shift down - std::vector kept; - kept.reserve(niweleta->fits.size()); - for (auto const &fit : niweleta->fits) - { - if (fit.gap < sel - 1) - { - kept.push_back(fit); - } - else if (fit.gap >= sel + 1) - { - auto copy{fit}; - copy.gap = fit.gap - 1; - kept.push_back(copy); - } - } - niweleta->fits = std::move(kept); - - // remap constraint references across every niweleta - for (std::size_t n = 0; n < m_document.niwelety.size(); ++n) - { - for (auto &st : m_document.niwelety[n].straights) - { - if (st.rel_kind == 0) - { - continue; - } - if (st.rel_niw == static_cast(m_niweleta)) - { - if (st.rel_str == sel) - { - st.rel_kind = 0; - } - else if (st.rel_str > sel) - { - --st.rel_str; - } - } - } - } - - clear_selection(); - solve(); -} - -void plan_panel::delete_current_niweleta() -{ - if (m_document.niwelety.size() <= 1 || m_niweleta >= m_document.niwelety.size()) - { - return; - } - - auto const removed{static_cast(m_niweleta)}; - m_document.niwelety.erase(m_document.niwelety.begin() + static_cast(m_niweleta)); - - for (auto &niweleta : m_document.niwelety) - { - for (auto &st : niweleta.straights) - { - if (st.rel_kind == 0) - { - continue; - } - if (st.rel_niw == removed) - { - st.rel_kind = 0; - } - else if (st.rel_niw > removed) - { - --st.rel_niw; - } - } - } - - if (m_niweleta >= m_document.niwelety.size()) - { - m_niweleta = m_document.niwelety.size() - 1; - } - m_pending = false; - clear_selection(); - m_pick_rel = 0; - m_ask_rel_param = false; - if (auto *niweleta{current_niweleta()}; niweleta != nullptr) - { - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", niweleta->name.c_str()); - } - solve(); -} - -bool plan_panel::hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const -{ - auto const *niweleta{current_niweleta()}; - if (niweleta == nullptr || m_niweleta >= m_solved.size()) - { - return false; - } - auto best{14.0f}; - bool found{false}; - for (auto const &fit : niweleta->fits) - { - if (fit.mode != 3 || fit.arcs.empty()) - { - continue; - } - for (auto const &h : compound_station_handles(fit, m_solved[m_niweleta], fit.gap)) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) - { - continue; - } - auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; - if (d <= best) - { - best = d; - OutGap = h.gap; - OutKind = h.kind; - OutArc = h.arc; - found = true; - } - } - } - return found; -} - -void plan_panel::begin_draft() -{ - discard_draft(); - m_draft.reedit_gap = -1; - m_draft.reedit_backup = {}; - m_draft_place = 1; - m_anchor_pick = 0; - m_drawing = false; - m_pick_compound_point = false; - m_pick_rel = 0; - m_dragging_draft = false; - m_draft_append_kind = 0; - m_fit_guides.clear(); - m_status = "kliknij koniec prostej"; -} - -void plan_panel::discard_draft() -{ - auto *niweleta{current_niweleta()}; - maj0sted::app::discard_basket_draft(m_draft, niweleta); - if (niweleta != nullptr) - { - solve(); - } - m_draft_polys.clear(); - m_draft_place = 0; - m_anchor_pick = 0; - m_dragging_draft = false; - m_draft_kind = -1; - m_draft_arc = -1; - m_draft_append_kind = 0; - m_status.clear(); -} - - -bool plan_panel::reopen_draft_from_gap(std::size_t const Gap) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - return false; - } - auto const *solved{m_niweleta < m_solved.size() ? &m_solved[m_niweleta] : nullptr}; - if (false == maj0sted::app::reopen_basket_draft(m_draft, *niweleta, Gap, solved)) - { - m_status = "nie compound"; - return false; - } - m_draft_place = 0; - m_anchor_pick = 0; - m_dragging_draft = false; - m_drawing = false; - m_sel_gap = static_cast(Gap); - m_sel_straight = static_cast(Gap); - m_sel_end = 1; - solve(); - rebuild_draft_preview(); - m_status = "luka " + std::to_string(Gap); - return true; -} - - -void plan_panel::start_draft_from_end(std::size_t const Str, int const End) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || Str >= niweleta->straights.size() || (End != 0 && End != 1)) - { - return; - } - maj0sted::app::start_basket_draft_from_end(m_draft, niweleta->straights[Str], static_cast(Str), End, m_draft_r); - m_draft_place = 0; - m_draft_append_kind = 0; - m_drawing = false; - m_sel_straight = static_cast(Str); - m_sel_end = End; - rebuild_draft_preview(); - m_status.clear(); -} - - -void plan_panel::append_draft_at(double const X, double const Y) -{ - if (false == m_draft.active) - { - return; - } - double tipx{0.0}; - double tipy{0.0}; - if (false == draft_tip(tipx, tipy)) - { - return; - } - auto const len{std::max(5.0, std::hypot(X - tipx, Y - tipy))}; - m_draft.preview_next_r = std::max(30.0, m_draft_r); - - if (m_draft_append_kind == 0) - { - m_draft.arcs.push_back({std::max(30.0, m_draft_r), len, 0.0}); - m_status = "luk L=" + to_string(len, 1) + " R=" + to_string(m_draft_r, 0); - } - else if (m_draft_append_kind == 1) - { - if (m_draft.arcs.empty()) - { - m_draft.entry_t = len; - m_status = "KP wejście " + to_string(len, 1) + " m"; - } - else - { - m_draft.arcs.back().transition_to_next = len; - m_draft.exit_t = 0.0; - m_status = "KP między " + to_string(len, 1) + " m"; - } - } - else - { - if (m_draft.arcs.empty()) - { - m_status = "najpierw łuk"; - return; - } - m_draft.exit_t = len; - rebuild_draft_preview(); - finish_draft(); - return; - } - rebuild_draft_preview(); -} - -void plan_panel::undo_draft_fragment() -{ - if (false == m_draft.active) - { - return; - } - if (m_draft.exit_t > 1e-6) - { - m_draft.exit_t = 0.0; - } - else if (false == m_draft.arcs.empty() && m_draft.arcs.back().transition_to_next > 1e-6) - { - m_draft.arcs.back().transition_to_next = 0.0; - } - else if (false == m_draft.arcs.empty()) - { - m_draft.arcs.pop_back(); - } - else if (m_draft.entry_t > 1e-6) - { - m_draft.entry_t = 0.0; - } - else - { - m_status = "nic do cofniecia"; - return; - } - rebuild_draft_preview(); - m_status = "cofnieto fragment"; -} - -bool plan_panel::draft_tip(double &OutX, double &OutY) const -{ - double az{0.0}; - return draft_end_pose(OutX, OutY, az); -} - -bool plan_panel::draft_end_pose(double &OutX, double &OutY, double &OutAz) const -{ - if (false == m_draft.active) - { - return false; - } - OutAz = m_draft.az0; - if (false == m_draft_polys.empty() && false == m_draft_polys.back().points.empty()) - { - auto const &pts{m_draft_polys.back().points}; - OutX = pts.back().x; - OutY = pts.back().y; - if (pts.size() >= 2) - { - OutAz = std::atan2(pts.back().x - pts[pts.size() - 2].x, pts.back().y - pts[pts.size() - 2].y); - } - return true; - } - OutX = m_draft.x0; - OutY = m_draft.y0; - return true; -} - -bool plan_panel::finish_draft() -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - m_status = "brak niwelety"; - return false; - } - auto const result{maj0sted::app::finish_basket_draft(m_draft, *niweleta, m_document.niwelety, static_cast(m_niweleta), m_draft_finish_straight)}; - m_status = result.status; - if (result.ok) - { - m_draft_polys.clear(); - m_draft_place = 0; - m_anchor_pick = 0; - m_dragging_draft = false; - solve(); - } - else - { - rebuild_draft_preview(); - solve(); - } - return result.ok; -} - - -void plan_panel::rebuild_draft_preview() -{ - m_draft_polys = maj0sted::app::preview_basket_draft(m_draft, m_draft_r); -} - - -bool plan_panel::hit_draft_station(ImVec2 const &Mouse, int &OutKind, int &OutArc) const -{ - if (false == m_draft.active) - { - return false; - } - auto best{14.0f}; - bool found{false}; - for (auto const &h : draft_station_handles(m_draft, m_draft_polys)) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) - { - continue; - } - auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; - if (d <= best) - { - best = d; - OutKind = h.kind; - OutArc = h.arc; - found = true; - } - } - return found; -} - -void plan_panel::drag_draft_station(double const X, double const Y) -{ - if (false == m_draft.active || m_draft.arcs.empty()) - { - return; - } - auto const samples{sample_draft_centreline(m_draft_polys)}; - double station{0.0}; - double px{0.0}; - double py{0.0}; - if (false == project_on_gap(samples, X, Y, station, px, py)) - { - return; - } - auto const total{samples.empty() ? 0.0 : samples.back().along}; - - auto station_before_arc = [&](int const Arc) { - auto s{std::max(0.0, m_draft.entry_t)}; - for (int i = 0; i < Arc && i < static_cast(m_draft.arcs.size()); ++i) - { - s += std::max(0.0, m_draft.arcs[static_cast(i)].length); - if (i + 1 < static_cast(m_draft.arcs.size())) - { - s += std::max(0.0, m_draft.arcs[static_cast(i)].transition_to_next); - } - } - return s; - }; - - constexpr double kMinL = 5.0; - constexpr double kMinKp = 0.0; - - if (m_draft_kind == 0) - { - m_draft.entry_t = std::clamp(station, 0.0, std::max(0.0, total - 20.0)); - } - else if (m_draft_kind == 1 && m_draft_arc >= 0 && m_draft_arc < static_cast(m_draft.arcs.size())) - { - auto const s0{station_before_arc(m_draft_arc)}; - auto &arc{m_draft.arcs[static_cast(m_draft_arc)]}; - if (ImGui::GetIO().KeyShift && m_draft_arc + 1 < static_cast(m_draft.arcs.size())) - { - auto const arc_end{s0 + std::max(kMinL, arc.length)}; - arc.transition_to_next = std::max(kMinKp, station - arc_end); - } - else - { - arc.length = std::max(kMinL, station - s0); - } - } - else if (m_draft_kind == 2 && m_draft_arc >= 0 && m_draft_arc + 1 < static_cast(m_draft.arcs.size())) - { - auto const s0{station_before_arc(m_draft_arc) + std::max(0.0, m_draft.arcs[static_cast(m_draft_arc)].length)}; - m_draft.arcs[static_cast(m_draft_arc)].transition_to_next = std::max(kMinKp, station - s0); - } - else if (m_draft_kind == 3) - { - m_draft.exit_t = std::clamp(total - station, 0.0, std::max(0.0, total - 20.0)); - } - - rebuild_draft_preview(); -} - -bool plan_panel::anchor_draft_to_gap(std::size_t const Gap) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - return false; - } - if (false == maj0sted::app::anchor_basket_draft(m_draft, *niweleta, Gap)) - { - m_status = "nie da się (kierunek / L+KP)"; - rebuild_draft_preview(); - solve(); - return false; - } - m_draft_polys.clear(); - m_draft_place = 0; - m_anchor_pick = 0; - m_dragging_draft = false; - m_sel_gap = static_cast(Gap); - solve(); - m_status = "luka " + std::to_string(Gap); - return true; -} - - -void plan_panel::drag_compound_station(double const X, double const Y) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || m_cmp_gap < 0 || static_cast(m_cmp_gap) >= niweleta->fits.size() || m_niweleta >= m_solved.size()) - { - return; - } - auto &fit{niweleta->fits[static_cast(m_cmp_gap)]}; - if (fit.mode != 3 || fit.arcs.empty()) - { - return; - } - - auto const samples{sample_gap_centreline(m_solved[m_niweleta], m_cmp_gap)}; - double station{0.0}; - double px{0.0}; - double py{0.0}; - if (false == project_on_gap(samples, X, Y, station, px, py)) - { - return; - } - auto const total{samples.empty() ? 0.0 : samples.back().along}; - - if (fit.arcs.empty()) - { - fit.arcs.push_back({300.0, 0.0, 0.0}); - } - for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) - { - if (!(fit.arcs[i].length > 0.0)) - { - fit.arcs[i].length = 40.0; - } - } - - auto station_before_arc = [&](int const Arc) { - auto s{std::max(0.0, fit.entry_t)}; - for (int i = 0; i < Arc && i + 1 < static_cast(fit.arcs.size()); ++i) - { - s += std::max(0.0, fit.arcs[static_cast(i)].length); - s += std::max(0.0, fit.arcs[static_cast(i)].transition_to_next); - } - return s; - }; - - constexpr double kMinL = 5.0; - constexpr double kMinKp = 0.0; - - if (m_cmp_kind == 0) - { - // entry KP end: station from the start of the curve - fit.entry_t = std::clamp(station, 0.0, std::max(0.0, total - 20.0)); - } - else if (m_cmp_kind == 1 && m_cmp_arc >= 0 && m_cmp_arc + 1 < static_cast(fit.arcs.size())) - { - auto const s0{station_before_arc(m_cmp_arc)}; - auto &arc{fit.arcs[static_cast(m_cmp_arc)]}; - if (ImGui::GetIO().KeyShift) - { - // Shift: pull a between-KP out past the arc end (L stays) - auto const arc_end{s0 + std::max(kMinL, arc.length)}; - arc.transition_to_next = std::max(kMinKp, station - arc_end); - } - else - { - arc.length = std::max(kMinL, station - s0); - } - } - else if (m_cmp_kind == 2 && m_cmp_arc >= 0 && m_cmp_arc + 1 < static_cast(fit.arcs.size())) - { - auto const s0{station_before_arc(m_cmp_arc) + std::max(0.0, fit.arcs[static_cast(m_cmp_arc)].length)}; - fit.arcs[static_cast(m_cmp_arc)].transition_to_next = std::max(kMinKp, station - s0); - } - else if (m_cmp_kind == 3) - { - fit.exit_t = std::clamp(total - station, 0.0, std::max(0.0, total - 20.0)); - } - - solve(); -} - -bool plan_panel::hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const -{ - auto best{12.0f}; - bool found{false}; - auto const niw{m_niweleta}; - auto const *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - return false; - } - for (std::size_t i = 0; i < niweleta->straights.size(); ++i) - { - auto const *st{handle_straight(niw, i)}; - if (st == nullptr || st->hidden) - { - continue; - } - for (int end = 0; end < 2; ++end) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(end == 0 ? st->x1 : st->x2, end == 0 ? st->y1 : st->y2), screen)) - { - continue; - } - auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; - if (d <= best) - { - best = d; - OutNiw = static_cast(niw); - OutStr = static_cast(i); - OutEnd = end; - found = true; - } - } - } - return found; -} - -bool plan_panel::station_on_through(std::size_t const Through, double const Wx, double const Wy, double &OutStation) const -{ - if (Through >= m_solved.size()) - { - return false; - } - auto const ¢re{m_solved[Through].centreline}; - if (centre.size() < 2) - { - return false; - } - // walk the axis, project the point onto each segment, keep the nearest station (arc length) - double best_station{0.0}; - double best_dist{std::numeric_limits::max()}; - double travelled{0.0}; - for (std::size_t i = 0; i + 1 < centre.size(); ++i) - { - double const ax{centre[i].x}; - double const ay{centre[i].y}; - double const dx{centre[i + 1].x - ax}; - double const dy{centre[i + 1].y - ay}; - double const seg{std::hypot(dx, dy)}; - if (seg < 1e-9) - { - continue; - } - double const t{std::clamp(((Wx - ax) * dx + (Wy - ay) * dy) / (seg * seg), 0.0, 1.0)}; - double const d{std::hypot(Wx - (ax + dx * t), Wy - (ay + dy * t))}; - if (d < best_dist) - { - best_dist = d; - best_station = travelled + seg * t; - } - travelled += seg; - } - OutStation = best_station; - return true; -} - -int plan_panel::nearest_niweleta_axis(ImVec2 const &Mouse, float const Tolerance) const -{ - auto best{Tolerance}; - int found{-1}; - for (std::size_t n = 0; n < m_solved.size(); ++n) - { - for (auto const &polyline : m_solved[n].polylines) - { - for (std::size_t i = 0; i + 1 < polyline.points.size(); ++i) - { - ImVec2 a; - ImVec2 b; - if (false == world_to_screen(plan_to_world(polyline.points[i].x, polyline.points[i].y), a) || - false == world_to_screen(plan_to_world(polyline.points[i + 1].x, polyline.points[i + 1].y), b)) - { - continue; - } - auto const dx{b.x - a.x}; - auto const dy{b.y - a.y}; - auto const len2{dx * dx + dy * dy}; - if (len2 <= 0.0f) - { - continue; - } - auto const t{std::clamp(((Mouse.x - a.x) * dx + (Mouse.y - a.y) * dy) / len2, 0.0f, 1.0f)}; - auto const d{std::hypot(Mouse.x - (a.x + t * dx), Mouse.y - (a.y + t * dy))}; - if (d <= best) - { - best = d; - found = static_cast(n); - } - } - } - } - return found; -} - -bool plan_panel::hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const -{ - auto best{10.0f}; - bool found{false}; - auto const consider = [&](std::size_t const Niw) { - if (Niw >= m_document.niwelety.size()) - { - return; - } - auto const &straights{m_document.niwelety[Niw].straights}; - for (std::size_t i = 0; i < straights.size(); ++i) - { - auto const *st{handle_straight(Niw, i)}; - if (st == nullptr || st->hidden) - { - continue; - } - ImVec2 a; - ImVec2 b; - if (false == world_to_screen(plan_to_world(st->x1, st->y1), a) || false == world_to_screen(plan_to_world(st->x2, st->y2), b)) - { - continue; - } - auto const dx{b.x - a.x}; - auto const dy{b.y - a.y}; - auto const len2{dx * dx + dy * dy}; - if (len2 <= 0.0f) - { - continue; - } - auto const t{std::clamp(((Mouse.x - a.x) * dx + (Mouse.y - a.y) * dy) / len2, 0.0f, 1.0f)}; - auto const px{a.x + t * dx}; - auto const py{a.y + t * dy}; - auto const d{std::hypot(Mouse.x - px, Mouse.y - py)}; - if (d <= best) - { - best = d; - OutNiw = static_cast(Niw); - OutStr = static_cast(i); - found = true; - } - } - }; - if (AllNiwelety) - { - for (std::size_t n = 0; n < m_document.niwelety.size(); ++n) - { - consider(n); - } - } - else - { - consider(m_niweleta); - } - return found; -} - -void plan_panel::go_to_plan() -{ - auto minx{std::numeric_limits::max()}; - auto miny{std::numeric_limits::max()}; - auto maxx{std::numeric_limits::lowest()}; - auto maxy{std::numeric_limits::lowest()}; - - for (auto const &niweleta : m_document.niwelety) - { - for (auto const &straight : niweleta.straights) - { - minx = std::min({minx, straight.x1, straight.x2}); - maxx = std::max({maxx, straight.x1, straight.x2}); - miny = std::min({miny, straight.y1, straight.y2}); - maxy = std::max({maxy, straight.y1, straight.y2}); - } - } - - if (minx > maxx) - { - return; // nothing drawn yet, so there is nowhere to go - } - - auto &camera{editor_mode::get_camera()}; - auto const centre{plan_to_world((minx + maxx) * 0.5, (miny + maxy) * 0.5)}; - camera.Pos.x = centre.x; - camera.Pos.z = centre.z; - - // show the whole drawing with a little room around it - auto const span{std::max({maxx - minx, maxy - miny, 50.0})}; - Global.editor_ortho_extent = std::clamp(static_cast(span * 0.6), 5.0f, 20000.0f); -} diff --git a/application/planpanel.h b/application/planpanel.h index 1ebbf6cb..786fbab5 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -12,19 +12,27 @@ http://mozilla.org/MPL/2.0/. #include "application/uilayer.h" #include "editor/orthophoto.h" -#include "maj0sted/app/basket_draft.hpp" -#include "maj0sted/app/editor_document.hpp" -#include "maj0sted/app/straight_constraints.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/render.hpp" +#include "maj0sted/editor/solution.hpp" #include "maj0sted/io/scn_export.hpp" -#include "maj0sted/editor/editor.hpp" -#include "maj0sted/editor/ribbon.hpp" +#include #include -// track layout tool: straights laid down by hand on the scenery itself and the curves fitted into -// the gaps between them. the panel holds only the controls - the drawing happens in the viewport, -// which the tool puts into a top-down orthographic plan view. none of the geometry is computed -// here: maj0sted solves the whole document and the panel paints what comes back. +// track layout tool. the drawing happens in the viewport, which the tool puts into a top-down +// orthographic plan view; the panel holds only the controls. none of the geometry is computed here: +// maj0sted solves the whole document and the panel paints what comes back. +// +// a track is a chain of elements, and every one of them says outright what it is: prosta of so many +// metres, luk of radius R turning so far, krzywa przejsciowa easing to R over so many metres. there +// is no second language of "run on until you reach that line" - laying by clicking along the ground +// works out the corner at the click and writes the numbers down. +// +// what one track keeps against another is said as a relation, not redrawn by hand: an element may be +// held parallel to another track's element at a given miedzytorze, and the solver moves the whole +// track until it really is. class plan_panel : public ui_panel { @@ -35,131 +43,133 @@ class plan_panel : public ui_panel void render_contents() override; 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_draft(); - void render_gaps(); - void render_compound(maj0sted::editor::GapFit &Fit, std::size_t const Gap, bool &Dirty); - void fit_compound_to_guides(); - void clear_fit_guides(); + void render_elements(); + void render_turnouts(); + void render_turnout_type(maj0sted::editor::TurnoutType &Type_); + // the catalogue drawing of the chosen type, in its own window: the diverging path piece by + // piece, both tracks' rails, and the points a drawing is dimensioned from + void render_template_window(); + void render_diagnostics(); void render_storage(); void export_scn(); + void seed_catalogue(); void render_newmap_dialog(); void render_location_dialog(); - void start_map(bool const Georeferenced, double const Originx, double const Originy); + + // scene void handle_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(); + // 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 - maj0sted::app::EditorDocument m_document; - std::vector m_solved; - std::vector m_junctions; - std::size_t m_niweleta{0}; + Document m_document; + Solution m_solution; + std::vector m_rails; - // switch placement: 0 idle, 1 waiting for a click on the through track - int m_pick_switch{0}; - int m_sw_side{0}; // 0 left, 1 right - double m_sw_crossing{9.0}; // skos 1:n - double m_sw_radius{190.0}; // internal arc radius (metres) - 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 + TrackId m_track{}; + ElementId m_sel_element{}; + TurnoutId m_sel_turnout{}; - int m_sel_straight{-1}; - int m_sel_end{-1}; - bool m_dragging{false}; - // captured at drag start so Shift keeps the angle while the length changes - double m_drag_fixed_x{0.0}; - double m_drag_fixed_y{0.0}; - double m_drag_ux{1.0}; - double m_drag_uy{0.0}; + // a press on the ground does whatever is under it: picks up what is drawn there, or carries the + // current track on to there. every transient errand (placing a turnout, picking the element to + // run parallel to) is started from its own button, and Esc drops back out of it + bool m_dragging_turnout{false}; - // compound station drag: kind 0=entry KP end, 1=free-arc end, 2=between-KP end, 3=exit KP start - bool m_dragging_cmp{false}; - int m_cmp_gap{-1}; - int m_cmp_kind{-1}; - int m_cmp_arc{-1}; + // grabbing a straight by one of its ends: which one, and the far end it swings about. the far + // end is captured once, at the grab, because re-fitting the arc behind it moves it + bool m_dragging_straight{false}; + std::size_t m_drag_index{0}; + int m_drag_end{0}; + double m_pivot_x{0.0}; + double m_pivot_y{0.0}; - 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}; - double m_pendingx{0.0}; - double m_pendingy{0.0}; + double m_startx{0.0}; + double m_starty{0.0}; - // 0 = idle, 1 = picking parallel reference, 2 = picking skew reference - int m_pick_rel{0}; - // after a reference is chosen, ask for the numeric parameter - bool m_ask_rel_param{false}; - double m_ask_rel_value{4.5}; - // compound: gap being edited; pick mode accumulates guide points, then Fit runs the solver - int m_sel_gap{-1}; - bool m_pick_compound_point{false}; - struct FitGuide - { - double x{0.0}; - double y{0.0}; - }; - std::vector m_fit_guides; + // 0 idle, 1 waiting for a click on the through track + int m_pick_turnout{0}; + // 0 idle, 1 waiting for the element the selected one is to be held parallel to + int m_pick_parallel{0}; - maj0sted::app::BasketDraft m_draft; - std::vector m_draft_polys; - // 0 idle, 1 pick straight end to continue from - int m_draft_place{0}; - // next click appends: 0 = luk, 1 = KP (wej/między), 2 = KP wyjście (kończy: prosta + kotwica) - int m_draft_append_kind{0}; - double m_draft_r{300.0}; - double m_draft_finish_straight{100.0}; - // 0 idle, 1 click a straight to choose the gap to anchor into - int m_anchor_pick{0}; - bool m_dragging_draft{false}; - int m_draft_kind{-1}; - int m_draft_arc{-1}; + // what the next appended element is made of + double m_new_radius{300.0}; + double m_new_length{100.0}; + int m_new_hand{1}; + // the radius a corner is rounded with when laying by clicking + double m_corner_radius{300.0}; + // miedzytorze a new hold is given, metres + double m_new_offset{4.75}; + + int m_turnout_type{0}; + // which piece of the chosen type's geometry list is open for editing; -1 = none + int m_sel_piece{-1}; + + // the template drawing. a switch is thirty metres long and a metre and a half wide, so like + // every catalogue drawing this one is stretched across the track - the ratio is said on screen + bool m_show_template{false}; + float m_tpl_zoom{1.0f}; + float m_tpl_pan{0.0f}; // metres off centre, along the through track + float m_tpl_exagg{6.0f}; // przewyzszenie poprzeczne + bool m_tpl_rails{true}; + bool m_tpl_marks{true}; + // lukowanie in the drawing: the radius the tor zasadniczy is bent to, and which way + double m_tpl_bend_radius{0.0}; + int m_tpl_bend_hand{0}; char m_namebuf[128]{}; - std::string m_status; char m_path[256]{"editor/plan.m0s"}; char m_scn_path[256]{"scenery/plan_export.scn"}; diff --git a/editor/orthophoto.cpp b/editor/orthophoto.cpp index 7fc09ef1..01a95d62 100644 --- a/editor/orthophoto.cpp +++ b/editor/orthophoto.cpp @@ -17,6 +17,8 @@ http://mozilla.org/MPL/2.0/. #include #include #pragma comment(lib, "winhttp.lib") +#else +#include #endif namespace editor @@ -127,9 +129,64 @@ bool http_get(std::string const &Url, std::vector &Bytes, std::str #else -bool http_get(std::string const &, std::vector &, 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 *>(Userdata); + auto const length = Size * Count; + bytes.insert(bytes.end(), Data, Data + length); + return length; +} + +// plain HTTPS GET, the same shape as the WinHttp one above: one request, one answer, nothing kept +// between calls. libcurl wants its global init done once before any handle exists, and this is the +// only place in the program that uses it, so a function-local static does the job thread-safely +bool http_get(std::string const &Url, std::vector &Bytes, std::string &Contenttype) +{ + static bool const initialised = (curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK); + if (false == initialised) + { + return false; + } + + CURL *handle = curl_easy_init(); + if (handle == nullptr) + { + return false; + } + + Bytes.clear(); + curl_easy_setopt(handle, CURLOPT_URL, Url.c_str()); + curl_easy_setopt(handle, CURLOPT_USERAGENT, kUserAgent); + curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, append_body); + curl_easy_setopt(handle, CURLOPT_WRITEDATA, &Bytes); + // a stuck transfer must not hold a worker forever; the caller retries + curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT, 10L); + curl_easy_setopt(handle, CURLOPT_TIMEOUT, 30L); + // workers run on their own threads: keep libcurl off signals, which are process-wide + curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L); + + bool ok = (curl_easy_perform(handle) == CURLE_OK); + if (true == ok) + { + long status = 0; + curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status); + char const *type = nullptr; + if ((curl_easy_getinfo(handle, CURLINFO_CONTENT_TYPE, &type) == CURLE_OK) && (type != nullptr)) + { + Contenttype.assign(type); + } + ok = (status == 200) && (false == Bytes.empty()); + } + if (false == ok) + { + Bytes.clear(); + } + + curl_easy_cleanup(handle); + + return ok; } #endif diff --git a/editor/turnouts.cpp b/editor/turnouts.cpp index 9cadad2b..eb442d8f 100644 --- a/editor/turnouts.cpp +++ b/editor/turnouts.cpp @@ -10,27 +10,74 @@ http://mozilla.org/MPL/2.0/. #include "stdafx.h" #include "editor/turnouts.h" +#include "maj0sted/domain/geometry/turnout.hpp" + +#include + namespace editor { +namespace +{ + +// the blade ends where the gap to the stock rail takes the przekladka in its heel. +// this is what fixes the length of a curved blade, so it is said once here. +// TODO: dlugosci iglic z tablic Id-1, gdy beda pod reka +double const heel_offset{0.136}; // metres +// 49E1 railhead. the blade is planed from its tip until it is this wide +double const railtop_width{0.070}; + +// a turnout with an iglica lukowa styczna do opornicy o scietym ostrzu (Koc, rys. 5.9). +// the curve is theoretically tangent to the opornica at PR, but the blade cannot begin +// there - it begins where there is metal enough, at the ostrze, and its planed nose (d) +// runs on to the point where the rail is u thick. what lies between PR and the ostrze is +// the odcinek przediglicowy: the curve is already there, the vehicle is still on the +// through track. Koc's tablica 5.1 gives a, d and u per type, and they obey +// a + d = the run to a gap of u, taken on the blade's curve R + s/2 +// so a is derived from u and d here rather than copied. The drawing dimensions it on the +// rail and a piece length is on the axis, so what goes in is the run scaled by R/(R+s/2); +// against the table itself that is a tenth of a millimetre at 1200-1:18,5, six at 190-1:9. +// +// b is the rear tangent from the drawing: a rozjazd podstawowy (krzyzownica lukowa) has +// b = R*tg(alfa/2) and no straight at all, while a straight crossing carries the arc out +// on a prosta krzyzownicowa of b - R*tg(alfa/2). Passing 0 asks for the podstawowy. +turnout_preset curved_tangent(std::string name, double crossing_n, double radius, double tip_thickness, double nose, double back_tangent = 0.0) +{ + auto const alfa{std::atan(1.0 / crossing_n)}; + auto const tangent{radius * std::tan(0.5 * alfa)}; + auto const b{back_tangent > 0.0 ? back_tangent : tangent}; + auto const lead{maj0sted::domain::blade_run_to_offset(radius, 0.0, tip_thickness) - nose}; + auto const blade{maj0sted::domain::blade_run_to_offset(radius, 0.0, heel_offset) - lead}; + + turnout_preset preset{std::move(name), crossing_n, tangent + b, tip_thickness, nose, railtop_width, {}}; + preset.pieces.push_back({0, lead, radius, radius, 0.0}); + preset.pieces.push_back({1, blade, radius, radius, 0.0}); + preset.pieces.push_back({2, radius * alfa - lead - blade, radius, radius, 0.0}); + if (b - tangent > 1e-6) + { + preset.pieces.push_back({3, b - tangent, 0.0, 0.0, 0.0}); + } + return preset; +} + +} // namespace + std::vector const &turnout_presets() { - // the standard PKP rozjazdy zwyczajne (49E1 / 60E1). crossing mark, radius and catalogue length - // (PR→KR along the through track). the crossing angles check out as atan(1/n); the lengths follow - // the type tables (metres). the diverging leaves not tangent but at the blade angle (beta) after a - // straight pre-blade lead, then arcs by (alfa - beta) to the frog. - // - // blade_angle, pre_blade and blade_length are NOT yet from Id-1 - left at 0, which lays a tangent - // 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. + // the standard PKP rozjazdy zwyczajne. u and d come from Koc, tablica 5.1 (iglice + // styczne do opornicy o scietym ostrzu); the rear tangents from the catalogue drawings: + // rys. 5.13 gives Rz 49E1-190-1:9 as 27138 = 10523 + 16615 (krzyzownica prosta) and + // rys. 5.15 gives Rz 49E1-300-1:9 as 33230 = 16615 + 16615 (lukowa, rozjazd podstawowy). + // krzyzownice proste sa w 49E1-190-1:9 i 49E1-500-1:14, lukowe w 300-1:9, 500-1:12 i + // 1200-1:18,5. TODO: 1:7,5 R190 i 1:14 R500 czekaja na wlasne u/d i styczne z Id-1 static std::vector const presets{ - {"Rz 1:7,5 R190", 7.5, 190.0, 25.222, 0.0, 0.0, 0.0}, - {"Rz 1:9 R190", 9.0, 190.0, 27.138, 0.0, 0.0, 0.0}, - {"Rz 1:9 R300", 9.0, 300.0, 33.230, 0.0, 0.0, 0.0}, - {"Rz 1:12 R500", 12.0, 500.0, 41.594, 0.0, 0.0, 0.0}, - {"Rz 1:14 R500", 14.0, 500.0, 42.371, 0.0, 0.0, 0.0}, - {"Rz 1:14 R760", 14.0, 760.0, 54.216, 0.0, 0.0, 0.0}, - {"Rz 1:18,5 R1200", 18.5, 1200.0, 64.818, 0.0, 0.0, 0.0}}; + curved_tangent("Rz 1:7,5 R190", 7.5, 190.0, 0.0049, 0.1250), + curved_tangent("Rz 1:9 R190", 9.0, 190.0, 0.0049, 0.1250, 16.615), + curved_tangent("Rz 1:9 R300", 9.0, 300.0, 0.0044, 0.1250), + curved_tangent("Rz 1:12 R500", 12.0, 500.0, 0.0053, 0.1250), + curved_tangent("Rz 1:14 R500", 14.0, 500.0, 0.0053, 0.1250, 24.544), + curved_tangent("Rz 1:14 R760", 14.0, 760.0, 0.0050, 0.1250), + curved_tangent("Rz 1:18,5 R1200", 18.5, 1200.0, 0.0050, 0.1304)}; return presets; } diff --git a/editor/turnouts.h b/editor/turnouts.h index 357430e3..91b042f8 100644 --- a/editor/turnouts.h +++ b/editor/turnouts.h @@ -15,23 +15,33 @@ http://mozilla.org/MPL/2.0/. namespace editor { -// a standard turnout template: its designation and the defining pair a catalogue entry is built on - -// the crossing mark 1:n (skos) and the radius of the diverging arc. the actual length the switch -// works out to is computed geometrically from these (arc = R * atan(1/n)), shown next to it; the -// exact catalogue length can be pinned later if the Id-1 tables call for a different figure +// one piece of a preset's diverging path, in catalogue order from PR. mirrors +// maj0sted::editor::TurnoutPieceSpec so this header stays free of the library +struct turnout_preset_piece +{ + int part; // 0 przediglicowy, 1 iglica, 2 luk, 3 krzyzownicowa + double length; // metres, always explicit - nothing here is fitted + double radius_start; // metres; 0 = straight + double radius_end; // metres; different from radius_start = clothoid + double turn_in; // kat nagiecia iglicy (beta), radians; 0 = tangent to the stock rail +}; + +// a standard turnout template: its designation, the catalogue numbers the laid path is +// checked against (skos 1:n and the length PR->KR along the through track), the pieces it +// is laid from, and the two blade dimensions that place its real, cut tip struct turnout_preset { std::string name; - double crossing_n; // skos 1:n - double radius; // metres - double length; // catalogue length PR->KR along the through track, metres - double blade_angle; // kąt nagięcia iglicy (beta), radians; the diverging leaves the straight here - double pre_blade; // odcinek przediglicowy: straight lead PR -> blade tip, metres - double blade_length; // the straight blade itself, tip -> heel where the arc starts, metres + double crossing_n; // skos 1:n + double length; // catalogue length: a + b, the two tangent legs of the drawing, metres + double tip_thickness; // grubosc iglicy na koncu zestruganego dzioba (u), metres + double nose; // dziob iglicy: zestrugana dlugosc od ostrza (d), metres + double railtop_width; // szerokosc glowki szyny (w), metres + std::vector pieces; }; -// typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard; -// confirm against Id-1 before treating the derived length as the catalogue length +// typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised +// standard; confirm the piece lengths and the blade against Id-1 std::vector const &turnout_presets(); } // namespace editor diff --git a/maj0sted/CMakeLists.txt b/maj0sted/CMakeLists.txt index 8feb4546..accc4a5b 100644 --- a/maj0sted/CMakeLists.txt +++ b/maj0sted/CMakeLists.txt @@ -2,14 +2,13 @@ cmake_minimum_required(VERSION 3.20) project(maj0sted 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) # ---------------------------------------------------------------------------- # Options # ---------------------------------------------------------------------------- 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) # Only drive tests/examples/install defaults when built on its own, not when @@ -24,26 +23,17 @@ endif() # The static library # ---------------------------------------------------------------------------- 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/io/project_serializer.cpp - src/io/svg_writer.cpp - src/io/scn_export.cpp - src/render/plan_sampler.cpp - src/render/rail_renderer.cpp - src/editor/solve.cpp - src/editor/editor.cpp - src/editor/ribbon.cpp + src/editor/model.cpp + src/editor/solution.cpp + src/editor/chain.cpp + src/editor/sketch.cpp + src/editor/layout.cpp + src/editor/render.cpp src/editor/tile_cache.cpp - src/app/editor_document.cpp - src/app/straight_constraints.cpp - src/app/basket_draft.cpp + src/render/rail_renderer.cpp + src/io/document_io.cpp + src/io/scn_export.cpp ) add_library(maj0sted::maj0sted ALIAS maj0sted) @@ -77,13 +67,6 @@ if(MAJ0STED_BUILD_TESTS AND MAJ0STED_STANDALONE) add_subdirectory(tests) endif() -# ---------------------------------------------------------------------------- -# Examples -# ---------------------------------------------------------------------------- -if(MAJ0STED_BUILD_EXAMPLES AND MAJ0STED_STANDALONE) - add_subdirectory(examples) -endif() - # ---------------------------------------------------------------------------- # Install / package # ---------------------------------------------------------------------------- diff --git a/maj0sted/examples/CMakeLists.txt b/maj0sted/examples/CMakeLists.txt deleted file mode 100644 index 227f9e31..00000000 --- a/maj0sted/examples/CMakeLists.txt +++ /dev/null @@ -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) diff --git a/maj0sted/examples/basic_editor.cpp b/maj0sted/examples/basic_editor.cpp deleted file mode 100644 index 00e4dd84..00000000 --- a/maj0sted/examples/basic_editor.cpp +++ /dev/null @@ -1,20 +0,0 @@ -// Minimal walkthrough: create a map project and inspect its CRS. - -#include - -#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; -} diff --git a/maj0sted/examples/plan_to_svg.cpp b/maj0sted/examples/plan_to_svg.cpp deleted file mode 100644 index 063ce687..00000000 --- a/maj0sted/examples/plan_to_svg.cpp +++ /dev/null @@ -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 -#include -#include - -#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; -} diff --git a/maj0sted/include/maj0sted/app/basket_draft.hpp b/maj0sted/include/maj0sted/app/basket_draft.hpp deleted file mode 100644 index 6214bfbc..00000000 --- a/maj0sted/include/maj0sted/app/basket_draft.hpp +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include - -#include "maj0sted/editor/editor.hpp" -#include "maj0sted/editor/ribbon.hpp" -#include "maj0sted/editor/solve.hpp" - -namespace maj0sted::app { - -struct BasketDraft { - bool active{false}; - double x0{0.0}; - double y0{0.0}; - double az0{0.0}; - int dir{1}; - double entry_t{0.0}; - double exit_t{0.0}; - std::vector arcs; - double preview_next_r{300.0}; - int attach_str{-1}; - int attach_end{-1}; - int reedit_gap{-1}; - maj0sted::editor::GapFit reedit_backup{}; -}; - -[[nodiscard]] maj0sted::editor::RibbonRequest draft_to_ribbon(const BasketDraft& draft); - -[[nodiscard]] std::vector preview_basket_draft( - BasketDraft& draft, double fallback_next_r = 300.0); - -void start_basket_draft_from_end(BasketDraft& draft, - const maj0sted::editor::StraightSpec& straight, - int straight_index, int end, double default_r = 300.0); - -[[nodiscard]] bool reopen_basket_draft(BasketDraft& draft, - maj0sted::editor::NiweletaSpec& niweleta, - std::size_t gap, - const maj0sted::editor::NiweletaPolys* solved); - -void discard_basket_draft(BasketDraft& draft, maj0sted::editor::NiweletaSpec* niweleta); - -[[nodiscard]] bool anchor_basket_draft(BasketDraft& draft, - maj0sted::editor::NiweletaSpec& niweleta, - std::size_t gap); - -struct FinishBasketDraftResult { - bool ok{false}; - std::string status; -}; - -[[nodiscard]] FinishBasketDraftResult finish_basket_draft( - BasketDraft& draft, maj0sted::editor::NiweletaSpec& niweleta, - std::vector& all_niwelety, int niweleta_index, - double finish_straight_m); - -} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/app/editor_document.hpp b/maj0sted/include/maj0sted/app/editor_document.hpp deleted file mode 100644 index d24aad3a..00000000 --- a/maj0sted/include/maj0sted/app/editor_document.hpp +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "maj0sted/domain/map_project.hpp" -#include "maj0sted/editor/editor.hpp" - -namespace maj0sted::app { - -struct EditorDocument { - std::vector niwelety; - std::vector junctions; - double view_x{0.0}; - double view_y{0.0}; - double view_extent{0.0}; - bool origin_set{false}; - bool georeferenced{false}; - double origin_x{0.0}; - double origin_y{0.0}; -}; - -[[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 load_project( - const std::string& path = default_project_path()); - -} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/app/straight_constraints.hpp b/maj0sted/include/maj0sted/app/straight_constraints.hpp deleted file mode 100644 index ddf2659f..00000000 --- a/maj0sted/include/maj0sted/app/straight_constraints.hpp +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/editor/editor.hpp" - -namespace maj0sted::app { - -void apply_straight_constraints(std::vector& niwelety); -void align_gap_fits(maj0sted::editor::NiweletaSpec& niweleta); - -} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp b/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp deleted file mode 100644 index 3d0e1b87..00000000 --- a/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include -#include - -#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 transition_length{}; ///< symetryczna klotoida; brak = sam łuk -}; - -/// One arc of a compound curve. -struct CompoundArc { - Radius radius; ///< promień łuku - std::optional length{}; ///< długość łuku; wymagana OPRÓCZ ostatniego - std::optional 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 entry_transition{}; ///< klotoida prosta -> pierwszy łuk - std::optional exit_transition{}; ///< klotoida ostatni łuk -> prosta - std::vector arcs{}; ///< >= 1 łuk; sąsiednie promienie różne -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp b/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp deleted file mode 100644 index a4d541a5..00000000 --- a/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include - -#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 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 diff --git a/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp b/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp deleted file mode 100644 index 8d1f97fe..00000000 --- a/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include - -#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 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 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 diff --git a/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp b/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp deleted file mode 100644 index eb76e5cc..00000000 --- a/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp +++ /dev/null @@ -1,74 +0,0 @@ -#pragma once - -#include -#include - -#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 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 connect( - const Straight& entry, const Straight& exit, - const GapConnectionRequest& request); -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp b/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp deleted file mode 100644 index fe1e08c8..00000000 --- a/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp +++ /dev/null @@ -1,60 +0,0 @@ -#pragma once - -#include -#include -#include - -#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& 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 elements_; - std::vector joints_; // size() == elements_.size() - 1 -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp b/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp deleted file mode 100644 index b4281287..00000000 --- a/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp +++ /dev/null @@ -1,127 +0,0 @@ -#pragma once - -#include -#include -#include - -#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 = 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() const noexcept { return length_; } - -private: - Radius radius_; - TurnDirection direction_; - std::optional 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 start_radius, - std::optional 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 start_radius() const noexcept { return start_radius_; } - [[nodiscard]] std::optional end_radius() const noexcept { return end_radius_; } - [[nodiscard]] TurnDirection direction() const noexcept { return direction_; } - -private: - Length length_; - std::optional start_radius_; - std::optional end_radius_; - TurnDirection direction_; -}; - -/// Any single element of the horizontal alignment. -using PlanElement = std::variant; - -/// Length of a plan element, if known. A circular arc may not carry one. -[[nodiscard]] inline std::optional length_of(const PlanElement& element) { - if (const auto* straight = std::get_if(&element)) { - return straight->length(); - } - if (const auto* transition = std::get_if(&element)) { - return transition->length(); - } - return std::get(element).length(); -} - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp b/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp deleted file mode 100644 index 9866cb1e..00000000 --- a/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include - -#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; - -/// 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 diff --git a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp index 104a6974..d356b232 100644 --- a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp +++ b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp @@ -9,6 +9,7 @@ namespace maj0sted::domain { /// The crossing mark of a turnout — the skos 1:n. It is the tangent of the angle /// between the through and diverging tracks, so it fixes the total angle the /// diverging curve must turn through: a turnout is 1:9, 1:12, 1:18,5 and so on. +/// Nothing is fitted to it any more: it is what the laid path is checked against. class CrossingMark { public: explicit CrossingMark(double denominator) noexcept : denominator_{denominator} {} @@ -24,75 +25,155 @@ private: /// Which way the branch leaves the through track. enum class DivergeSide { Left, Right }; -/// One arc of a turnout's diverging curve: its radius and — unless it is the -/// closing arc — its fixed length and the clothoid easing it into the next arc. -struct TurnoutArc { - double radius{0.0}; - double length{0.0}; ///< fixed length; ignored for the closing arc - double transition_to_next{0.0}; ///< clothoid to the next arc (0 = none) +/// Which part of the turnout a piece of the diverging path is. The role names the +/// piece in switch terms; it does not constrain its shape — a blade may be straight +/// (iglica prosta sieczna) or an arc (iglica łukowa styczna). +enum class TurnoutPart { + PreBlade, ///< odcinek przediglicowy: PR to the blade tip, along the through track + Blade, ///< iglica, from its (theoretical) tip to its heel + Curve, ///< the diverging curve between the blade heel and the frog rail + FrogRail, ///< the straight frog rail out to KR }; -/// The shape of a turnout's diverging curve. One arc is the everyday turnout; two -/// or more make a łuk koszowy. Whatever the shape, the curve turns through exactly -/// the crossing angle — the closing (last) arc spends whatever deflection the -/// earlier arcs and clothoids leave. -struct DivergingCurve { - std::vector arcs; ///< at least one; the last one closes the turn - double entry_transition{0.0}; ///< clothoid from the through track into the first arc - double exit_transition{0.0}; ///< clothoid from the last arc back toward tangent +/// One authored piece of the diverging path, in order from PR. Every length is +/// explicit — the catalogue says it, and nothing here is solved for. A piece is a +/// straight when both radii are zero, an arc when they are equal, a clothoid +/// otherwise; radii are unsigned, the hand comes from DivergeSide. +struct TurnoutPiece { + TurnoutPart part{TurnoutPart::Curve}; + double length{0.0}; + double radius_start{0.0}; ///< 0 = straight + double radius_end{0.0}; ///< != radius_start = clothoid + /// Kąt nagięcia iglicy (beta): the heading break applied before this piece, + /// radians, unsigned. Zero everywhere for a blade tangent to the stock rail. + double turn_in{0.0}; }; -/// A turnout (rozjazd), described the way a catalogue does: a crossing mark, the -/// side it opens to, the free diverging curve inside it, and the length it spans -/// from its start (początek rozjazdu) to its end (koniec rozjazdu) measured along -/// the through track. The curve turns to the crossing angle and a straight frog -/// rail carries the track on to that length. +/// Iglica — not the shape of the axis (that is a TurnoutPiece), but the two +/// dimensions of the blade rail itself that place its real tip. A blade cannot be +/// ground to nothing, so its tip is cut back to a thickness and the point where +/// the axis leaves the stock rail is only a theoretical one. +struct Blade { + double tip_thickness{0.0}; ///< u: the blade's width where its planed nose ends + double nose{0.0}; ///< d: the planed nose itself, from the tip to that point + double railtop_width{0.0}; ///< w: railhead width, metres (49E1 ≈ 0,070) +}; + +/// Half the standard gauge. The gap the blade opens is taken between two rails, +/// and the blade's own curve is `R + s/2` where R is the diverging track's radius +/// (Koc, 5.2), so this is what turns a gap into an angle. Stations stay on the +/// axis: the rail radius converts, it does not measure. +inline constexpr double kHalfGauge = 0.7175; + +/// A turnout (rozjazd), described the way a catalogue drawing is: the crossing +/// mark and length it is meant to work out to, and the list of pieces the +/// diverging path is actually laid from. struct Turnout { CrossingMark mark; DivergeSide side{DivergeSide::Left}; - DivergingCurve curve; - double length{0.0}; ///< catalogue length along the through track; 0 = a tangent-length rail - /// Odcinek przediglicowy: the straight lead from the switch start to the blade - /// tip, still along the through track. Zero for a curve tangent from the start. - double pre_blade{0.0}; - /// Kąt nagięcia iglicy (beta), radians: the heading break at the blade tip. The - /// diverging track leaves the through track here, not tangentially, and the - /// 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}; + std::vector path; ///< PR -> KR, in order; nothing implicit + /// Catalogue length: a + b, the two tangent legs of the drawing — PR to the + /// theoretical crossing point along the through track, and that point to KR + /// along the diverging one. This is the L the type tables give (Rz 190-1:9: + /// a 13,834 + b 13,304 = 27,138), not the projection on the through track. + double length{0.0}; + Blade blade{}; }; -/// One laid segment of the diverging path, as the constant/linear curvature the -/// renderer and the fitter already share (see segment_layout): a straight when -/// both curvatures are zero, an arc when they are equal, a clothoid otherwise. +/// One laid segment of the diverging path: a piece with its curvature signed for +/// the hand it diverges to, ready for layout_segment. struct TurnoutSegment { - double turn_in{0.0}; ///< signed heading break applied before laying this segment (the blade) + double turn_in{0.0}; ///< signed heading break applied before laying this segment double k0{0.0}; double k1{0.0}; double length{0.0}; + TurnoutPart part{TurnoutPart::Curve}; }; /// What a turnout works out to when laid from its start pose. struct TurnoutGeometry { bool valid{false}; std::vector path; ///< diverging segments from the switch start to the frog - geometry::Pose frog{}; ///< the end (koniec rozjazdu): position + heading 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_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), -/// tangent to the through track: the pre-blade straight, the blade breaking away -/// by `blade_angle`, the curve turning through what is left of the crossing angle, -/// then the frog rail out to the catalogue length. Never throws; returns -/// `valid == false` when the parameters admit no geometry: a non-positive radius, -/// 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. -[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, - const Turnout& turnout); +/// How far along an axis of radius @p radius, leaving the stock rail at angle +/// @p angle, the blade stands @p offset away from it: `R·(acos(cos β − offset/R1) − β)` +/// for a curved blade, `offset / sin β` for a straight one. The gap opens on the +/// blade's own curve `R1 = R + s/2`, but the answer is a station on the axis, which +/// is what every length here is measured in. +/// +/// This is the relation the catalogue's own numbers obey. A drawing dimensions the +/// blade on the rail rather than on the axis, so what Koc's Tablica 5.1 gives as +/// `a + d` is this run scaled by `R1/R` — at 1200-1:18,5 the two agree to a tenth +/// of a millimetre. Zero when the curve neither turns nor breaks away, or when the +/// offset is out of its reach. +[[nodiscard]] double blade_run_to_offset(double radius, double angle, double offset); + +/// Lays @p turnout from @p start. @p bend is the signed curvature (1/m, positive +/// turns left) of the through track under it: zero for a rozjazd zwyczajny, and +/// anything else for a rozjazd łukowy. +/// +/// Bending is not adding curvature. Koc, 5.3.6: it rotates the triangle OBC about +/// the centre of the turnout, so the crossing angle alfa and the tangent +/// `t = R·tg(alfa/2)` come through untouched and every radius follows from them — +/// the through track turns by `beta = 2·arctg(t·bend)`, the diverging path by +/// `beta + alfa`, and each gets the radius `t/tg(its own angle/2)`. That is where +/// Koc's (5.7) and (5.8) come from, and it is why a Rz 49E1-190-1:9 bent +/// symmetrically is the catalogue's Rłs 49E1-380,292-1:9. +/// +/// The switch and the frog are not re-cut, only bent: a straight krzyżownica stays +/// straight and the blade keeps its own dimensions, measured — as on the ground — +/// against the opornica that curves with it. +/// +/// Invalid when the path has no arc to bend or its arcs disagree on a radius: +/// there is then no single triangle to rotate, and guessing one would be a lie. +[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, const Turnout& turnout, + double bend = 0.0); } // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp b/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp deleted file mode 100644 index 41c23fb3..00000000 --- a/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include -#include -#include - -#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& 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 elements_; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/map_project.hpp b/maj0sted/include/maj0sted/domain/map_project.hpp deleted file mode 100644 index a8879605..00000000 --- a/maj0sted/include/maj0sted/domain/map_project.hpp +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include - -#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& niwelety() const noexcept { - return niwelety_; - } - -private: - Crs crs_; - std::vector niwelety_; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/niweleta.hpp b/maj0sted/include/maj0sted/domain/niweleta.hpp deleted file mode 100644 index c6cafae2..00000000 --- a/maj0sted/include/maj0sted/domain/niweleta.hpp +++ /dev/null @@ -1,70 +0,0 @@ -#pragma once - -#include -#include - -#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 diff --git a/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp b/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp deleted file mode 100644 index 77ee03b1..00000000 --- a/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp +++ /dev/null @@ -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 diff --git a/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp b/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp deleted file mode 100644 index 9a62c0e0..00000000 --- a/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include - -#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 diff --git a/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp b/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp deleted file mode 100644 index 8c7c73a8..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#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 diff --git a/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp b/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp deleted file mode 100644 index 60dbe82d..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include - -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 diff --git a/maj0sted/include/maj0sted/domain/value_objects/crs.hpp b/maj0sted/include/maj0sted/domain/value_objects/crs.hpp deleted file mode 100644 index 6ab6775a..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/crs.hpp +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -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 diff --git a/maj0sted/include/maj0sted/domain/value_objects/length.hpp b/maj0sted/include/maj0sted/domain/value_objects/length.hpp deleted file mode 100644 index a86c10b5..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/length.hpp +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include -#include - -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 diff --git a/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp b/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp deleted file mode 100644 index 067367df..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -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 { - std::size_t operator()(const maj0sted::domain::NiweletaId& id) const noexcept { - return std::hash{}(id.value()); - } -}; diff --git a/maj0sted/include/maj0sted/domain/value_objects/radius.hpp b/maj0sted/include/maj0sted/domain/value_objects/radius.hpp deleted file mode 100644 index e00119fe..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/radius.hpp +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include - -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 diff --git a/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp b/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp deleted file mode 100644 index d61a6724..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -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 { - std::size_t operator()(const maj0sted::domain::StraightId& id) const noexcept { - return std::hash{}(id.value()); - } -}; diff --git a/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp b/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp deleted file mode 100644 index c4e5d69c..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include - -#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 diff --git a/maj0sted/include/maj0sted/editor/chain.hpp b/maj0sted/include/maj0sted/editor/chain.hpp new file mode 100644 index 00000000..c75ee9fc --- /dev/null +++ b/maj0sted/include/maj0sted/editor/chain.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// What laying one track produced, and everything that went wrong doing it. +struct TrackOutcome { + SolvedTrack track; + std::vector diagnostics; +}; + +/// Lays @p track from @p start. +/// +/// Every element states its own length, so laying is a walk: each element starts +/// on the pose the one before it ended on, and nothing is solved for. A clothoid +/// takes its near-end curvature from where the chain has got to, which is the +/// only way one element depends on another. +/// +/// Never throws. An element that cannot be laid — a non-positive length, an arc +/// without a radius or a hand, a clothoid that changes nothing — is reported and +/// ends the chain there; everything before it is still returned and still +/// drawable. +[[nodiscard]] TrackOutcome lay_track(const Document& document, const Track& track, + const domain::geometry::Pose& start); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/editor.hpp b/maj0sted/include/maj0sted/editor/editor.hpp deleted file mode 100644 index c57cee45..00000000 --- a/maj0sted/include/maj0sted/editor/editor.hpp +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include -#include - -#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 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 straights; - std::vector fits; -}; - -struct NiweletaPolys { - std::vector polylines; - /// Centreline segments after adjacent fits trim the input straights. Kept - /// index-aligned with NiweletaSpec::straights for editor hit handles. - std::vector rendered_straights; - /// 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 applied_fits; - /// The solved axis as one polyline, in order, with no duplicate join points. - /// Lets a station along the niweleta be resolved to a pose (see pose_at). - std::vector centreline; -}; - -/// Builds each niweleta, fits every requested gap, and returns the sampled plan -/// 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 solve_project( - const std::vector& niwelety); - -/// A switch (rozjazd): the point where a branch leaves a through niweleta. It is -/// a relation, not a niweleta — the through track is not split. The switch is -/// described the surveyor's way: a crossing mark 1:n (skos) that fixes the angle -/// the branch leaves at, and an interior curve that is free to be anything — a -/// single arc, or a compound basket (łuk koszowy). @c station marks the switch's -/// theoretical point (środek rozjazdu, where the through and diverging tangent -/// lines cross); the curve is fitted around it and the branch niweleta is pinned -/// to the frog it ends on. The theoretical tangent lengths follow from the curve -/// (see JunctionGeom) — they are read off, not set. -struct Junction { - int through{-1}; ///< index of the through niweleta (not modified) - double station{0.0}; ///< theoretical point: arc length along the through centreline - int side{0}; ///< 0 = diverges left, 1 = diverges right - bool facing{true}; ///< true opens toward increasing station, false back - double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n) - 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 polylines; -}; - -/// A whole layout solved at once: the niwelety plus every switch. Returned by -/// solve_layout, which additionally pins each branch's start to its frog. -struct LayoutSolution { - std::vector niwelety; - std::vector junctions; ///< index-aligned with the input switches -}; - -/// Solves @p niwelety together with @p junctions: each branch niweleta's start is -/// pinned to its switch's frog before solving, and every switch's diverging arc -/// is computed from the through niweleta's centreline. Never throws; a switch -/// whose through/branch is out of range or whose geometry is degenerate comes -/// back with valid == false. Takes @p niwelety by value because it repins them. -[[nodiscard]] LayoutSolution solve_layout(std::vector niwelety, - const std::vector& junctions); - -} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/layout.hpp b/maj0sted/include/maj0sted/editor/layout.hpp new file mode 100644 index 00000000..945f3416 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/layout.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "maj0sted/domain/geometry/turnout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// Solves a whole document: lays every turnout on its through track, lays every +/// track from its anchor, and moves the tracks that are held parallel to another +/// track's element until they really lie at the offset they were given. +/// +/// The order is not fixed in advance — it is worked out from what refers to +/// what. A branch anchored to a turnout's frog is laid after that turnout, which +/// is laid after its through track, however deep that goes: a turnout on the +/// branch of a turnout is nothing special. A reference that runs in a circle is +/// reported and its members are left unlaid, rather than being fed through a +/// fixed number of relaxation passes and whatever came out. +/// +/// Nothing here writes to @p document. Never throws. +[[nodiscard]] Solution solve(const Document& document); + +/// The catalogue type as the geometry layer wants it: the piece list with its +/// curvatures signed for @p hand (+1 diverges left). Public because the editor +/// dimensions a *type* with it — laying one from an identity pose is how the +/// catalogue numbers are checked before any instance stands on a track. +[[nodiscard]] domain::Turnout to_domain(const TurnoutType& type, int hand); + +/// The construction points of a turnout laid from @p start: where its parts meet, +/// where its blade is dimensioned, and the points the drawing takes its lengths +/// between. Public for the same reason as to_domain — so a bare type is drawn from +/// the very same list the solver reports for a placement, rather than from a second +/// idea of what a construction point is. +[[nodiscard]] std::vector turnout_marks(const domain::TurnoutGeometry& geometry, + const domain::geometry::Pose& start); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/model.hpp b/maj0sted/include/maj0sted/editor/model.hpp new file mode 100644 index 00000000..9c60df1f --- /dev/null +++ b/maj0sted/include/maj0sted/editor/model.hpp @@ -0,0 +1,217 @@ +#pragma once + +#include +#include +#include +#include + +namespace maj0sted::editor { + +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +/// Stable handles. Every reference in a document points at one of these, never +/// at a position in a vector: inserting or deleting anything must not silently +/// re-target a reference somewhere else. Ids are minted from one counter per +/// document (Document::next_id), so they are unique across all four spaces — +/// which makes a stray reference a lookup miss rather than a wrong hit. +enum class ElementId : std::uint32_t { none = 0 }; +enum class TrackId : std::uint32_t { none = 0 }; +enum class TurnoutId : std::uint32_t { none = 0 }; + +// --------------------------------------------------------------------------- +// The element +// --------------------------------------------------------------------------- + +/// What an element is, geometrically. These are the three shapes +/// `geometry::layout_segment` lays: constant zero curvature, constant non-zero +/// curvature, linearly varying curvature. +enum class Kind { + Line, + Arc, + Clothoid, +}; + +// --------------------------------------------------------------------------- +// What holds an element +// --------------------------------------------------------------------------- + +/// Nothing holds it: its shape and its place are its own. +struct Free {}; + +/// Held parallel to another track's element at a signed offset (+ = left of +/// *that* element's travel, whichever way this one runs). +/// +/// This is międzytorze, said once: a straight beside a straight, an arc +/// concentric with an arc at R∓d. It fixes two things — the shape (kind, radius, +/// hand, taken from the reference) and where the track runs across the +/// reference. It fixes nothing along it, so the track stays where it was put and +/// slides nowhere when the reference is edited. +/// +/// A track that can move — one anchored at a pose of its own — is moved as one +/// rigid piece until the held element really lies at the offset. A track pinned +/// to a turnout's port cannot move: there the reference still fixes the shape, +/// and the offset that actually came out is reported rather than forced. +struct Parallel { + ElementId ref{ElementId::none}; + double offset{0.0}; +}; + +using Hold = std::variant; + +/// One element of a track. It starts where the previous element ends — by pose, +/// not by coordinates — so tangency is a property of the representation and not +/// a rule anyone has to check or restore. +/// +/// Everything about it is stated outright: what shape it is, how long it is, and +/// how tightly it turns. Nothing here is left for a solver to decide — there is +/// no "run on until you reach that line". Laying track by clicking along the +/// ground still works, but the corner is worked out at the click and written +/// down as numbers, so what the document says is what stands on the ground. +struct Element { + ElementId id{ElementId::none}; + Kind kind{Kind::Line}; + /// Arc: the radius, metres, > 0. Clothoid: the radius at its far end, with + /// 0 meaning infinite (a clothoid running out to a straight); its near-end + /// radius is whatever the previous element ends on. Line: ignored. + double radius{0.0}; + /// +1 turns left, -1 turns right, 0 for a straight. Signed curvature is + /// exactly `hand / radius`, and positive curvature turns left in + /// `geometry::layout_segment` — so no sign is flipped anywhere downstream. + int hand{0}; + /// Along the axis, metres, > 0. + double length{0.0}; + /// What holds it to something else. Never changes its length. + Hold hold{Free{}}; +}; + +// --------------------------------------------------------------------------- +// Anchors +// --------------------------------------------------------------------------- + +/// Start at an explicit pose. @c az is an azimuth: radians clockwise from north, +/// i.e. atan2(dx, dy), matching the rest of the editor. +struct AtPose { + double x{0.0}, y{0.0}, az{0.0}; +}; +/// Which end of a turnout a track leaves from. +enum class Port { + Frog, ///< KR — koniec rozjazdu, the diverging track's end + Start, ///< PR — początek rozjazdu +}; +/// Start at a turnout's port. This is what used to be `frog_of` plus the pinning +/// pass: the branch no longer gets moved back into place after the fact, it was +/// never anywhere else. +struct AtPort { + TurnoutId turnout{TurnoutId::none}; + Port port{Port::Frog}; +}; + +using Anchor = std::variant; + +// --------------------------------------------------------------------------- +// Track +// --------------------------------------------------------------------------- + +/// A niweleta: an anchor plus an ordered chain of elements. Straights, arcs and +/// clothoids are equal citizens here — a chain may start with an arc, put two +/// arcs back to back, or simply stop without closing onto anything. +struct Track { + TrackId id{TrackId::none}; + std::string name; + Anchor anchor{AtPose{}}; + std::vector elements; +}; + +// --------------------------------------------------------------------------- +// Turnouts: catalogue type + placement +// --------------------------------------------------------------------------- + +/// One piece of a catalogue turnout's diverging path. Mirrors +/// domain::TurnoutPiece; kept here so the document does not depend on the +/// geometry header. +struct TurnoutPieceSpec { + int part{2}; ///< 0 przediglicowy, 1 iglica, 2 łuk, 3 krzyżownicowa + double length{0.0}; ///< always explicit; nothing is fitted + double radius_start{0.0}; ///< 0 = straight + double radius_end{0.0}; ///< != radius_start = clothoid + double turn_in{0.0}; ///< kąt nagięcia iglicy, radians, unsigned +}; + +/// The blade's own dimensions, which place its cut tip. Mirrors domain::Blade. +struct BladeSpec { + double tip_thickness{0.0}; ///< u: width where the planed nose ends + double nose{0.0}; ///< d: the planed nose, from the tip to that point + double railtop_width{0.0}; ///< w: railhead width +}; + +/// A catalogue turnout: the list of pieces its diverging path is laid from, plus +/// the crossing mark and length the catalogue says it should work out to — which +/// the solver checks the list against and never fits it to. Every number that +/// belongs to the *type* lives here, so adding one is a catalogue change and +/// never touches the file format for placements. +struct TurnoutType { + std::string name; + double crossing_n{9.0}; ///< skos 1:n, checked against the laid deflection + double length{0.0}; ///< catalogue length PR->KR along the through track, checked + std::vector pieces; + BladeSpec blade{}; +}; + +/// An instance of a type, laid on a through track. There is nothing here to +/// edit but where it sits — the geometry belongs to the type. +struct TurnoutPlacement { + TurnoutId id{TurnoutId::none}; + std::string type; ///< catalogue name + TrackId on{TrackId::none}; ///< the through track, which is not split + double station{0.0}; ///< PR, as arc length along the through centreline + int hand{1}; ///< +1 diverges left, -1 diverges right + bool facing{true}; ///< opens toward increasing station + + /// Łukowanie. A turnout standing on a curve is bent onto it, and normally + /// that is all there is to say: the tor zasadniczy is whatever the track does + /// under the PR. Clearing this asks for a bend of its own instead — a rozjazd + /// łukowy on a track drawn straight — and then `bend` is the signed curvature + /// (1/m, positive turns left) the through track is taken to have. What the + /// bend works out to is the solver's business and is never written back here. + bool bend_from_track{true}; + double bend{0.0}; +}; + +// --------------------------------------------------------------------------- +// Document +// --------------------------------------------------------------------------- + +/// Everything that is authored. Nothing in here is ever written by the solver. +struct Document { + std::vector turnout_types; + std::vector turnouts; + std::vector tracks; + + double view_x{0.0}, view_y{0.0}, view_extent{0.0}; + bool origin_set{false}; + bool georeferenced{false}; + double origin_x{0.0}, origin_y{0.0}; + + /// Next handle to hand out. One counter for every id space. + std::uint32_t next_id{1}; +}; + +/// Mints the next handle. The four overloads exist so a caller states which kind +/// of thing it is naming. +[[nodiscard]] ElementId mint_element(Document& document); +[[nodiscard]] TrackId mint_track(Document& document); +[[nodiscard]] TurnoutId mint_turnout(Document& document); + +[[nodiscard]] const Track* find_track(const Document& document, TrackId id); +[[nodiscard]] Track* find_track(Document& document, TrackId id); +[[nodiscard]] const TurnoutPlacement* find_turnout(const Document& document, TurnoutId id); +[[nodiscard]] TurnoutPlacement* find_turnout(Document& document, TurnoutId id); +[[nodiscard]] const TurnoutType* find_type(const Document& document, const std::string& name); +/// The element itself, wherever it is; and the track it belongs to. Null when +/// unknown. +[[nodiscard]] const Element* find_element(const Document& document, ElementId id); +[[nodiscard]] const Track* find_element_track(const Document& document, ElementId id); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/render.hpp b/maj0sted/include/maj0sted/editor/render.hpp new file mode 100644 index 00000000..539ccb24 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/render.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// One drawable rail, tagged with what it came from so the host can colour it +/// and hit-test it. Exactly one of @c element / @c turnout is set. +struct PlanPolyline { + Kind kind{Kind::Line}; + ElementId element{ElementId::none}; + TrackId track{TrackId::none}; + TurnoutId turnout{TurnoutId::none}; + /// For a turnout rail: which piece of the diverging path it is, and what part + /// that piece plays (0 przediglicowy, 1 iglica, 2 łuk, 3 krzyżownicowa), so a + /// drawing can say every element apart. -1 on a plain track rail. + int part{-1}; + std::size_t piece{0}; + double length{0.0}; + double radius_start{0.0}; ///< 0 == infinite + double radius_end{0.0}; + std::vector points; +}; + +/// Draws a solved layout as rails: two per element, a gauge apart. Turnout +/// diverging paths are drawn too, tagged with their turnout. +/// +/// This is presentation only. It decides nothing about geometry, and the +/// exporter no longer reads it back — the axis it needs is in the Solution. +[[nodiscard]] std::vector render_rails(const Solution& solution, + double gauge_metres = 1.5); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/ribbon.hpp b/maj0sted/include/maj0sted/editor/ribbon.hpp deleted file mode 100644 index 2f39a0ad..00000000 --- a/maj0sted/include/maj0sted/editor/ribbon.hpp +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include - -#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 elements; -}; - -/// Lays the ribbon out and returns one polyline per element, ready to draw. -[[nodiscard]] std::vector solve_ribbon(const RibbonRequest& request); - -} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/sketch.hpp b/maj0sted/include/maj0sted/editor/sketch.hpp new file mode 100644 index 00000000..d7ce0294 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/sketch.hpp @@ -0,0 +1,118 @@ +#pragma once + +#include +#include + +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// Laying track by clicking along the ground. +/// +/// The corner is worked out here, at the click, and written into the document as +/// ordinary elements with ordinary lengths — a straight shortened by the +/// tangent, an arc of the radius asked for, a straight running out to the point +/// clicked. Nothing is left pointing at the drawing, so the numbers in the +/// document are the numbers on the ground. + +/// What rounding one corner costs. +struct Corner { + bool ok{false}; + /// How far back from the corner the arc starts, and how far past it the arc + /// ends: R·tan(|Δ|/2). Both straights lose this much. + double tangent{0.0}; + double arc_length{0.0}; ///< R·|Δ| + int hand{0}; ///< +1 left, -1 right + double deflection{0.0}; ///< Δ, radians, signed, + = left +}; + +/// Rounds the corner between a heading in and a heading out with an arc of +/// @p radius. Both headings are unit vectors in the plan's frame. +/// +/// Not ok for a corner that does not bend, one that doubles back on itself, or a +/// non-positive radius: none of those is an arc, and guessing one is how a fit +/// used to quietly lay geometry nobody asked for. +[[nodiscard]] Corner fit_corner(double in_hx, double in_hy, double out_hx, double out_hy, + double radius); + +/// The three elements a straight-arc-straight corner is made of, ready to append +/// after @p incoming has been shortened by `corner.tangent`. +[[nodiscard]] Element make_arc(Document& document, double radius, int hand, double length); +[[nodiscard]] Element make_line(Document& document, double length); +[[nodiscard]] Element make_clothoid(Document& document, double end_radius, int hand, + double length); + +/// Carries a change of radius or hand across the joint it belongs to. +/// +/// A transition curve's radius is the radius it ends on, so it belongs to +/// whatever comes after it: the arc it eases into, or nothing at all when a +/// straight follows and it runs out flat at R=0. That is one number, not two, so +/// editing either side of the joint carries the other with it — and the joint +/// only ever looks forward. The transition behind an arc is the one that eases +/// into it; the one in front of it eases into whatever is beyond, which is none +/// of that arc's business. +void sync_joint(Track& track, std::size_t at); + +// --------------------------------------------------------------------------- +// Straights as lines, and the arcs between them +// --------------------------------------------------------------------------- + +/// The line a straight lies on: a point on it and the direction of travel. +/// +/// This is what a straight really is once it is drawn — where it lies and which +/// way it points. Its length is not part of it: that falls out of where the arcs +/// at either end come off it. +struct Support { + double px{0.0}, py{0.0}; + double dx{1.0}, dy{0.0}; +}; + +/// What a track hangs on: the line under every one of its straights, and where +/// the first and last of them begin and end. +/// +/// The corners between two straights are not in here, because they do not have +/// to be: given the lines, each of them is worked out. A corner hanging off +/// either end of the chain — a track that starts or finishes mid-curve — is not +/// in here either, for the opposite reason: it has nothing on its far side to be +/// fitted against, so it is carried along exactly as it was authored. +struct Skeleton { + std::vector lines; + double start_x{0.0}, start_y{0.0}; ///< where the first straight begins + double end_x{0.0}, end_y{0.0}; ///< where the last straight ends +}; + +/// Pulls @p track's skeleton out of @p solved. +/// +/// A chain must read as straights with corners between them — a corner being any +/// run of arcs and transition curves that turns one way, one arc or a basket of +/// them. Two straights may never touch: two straights end to end are one +/// straight. A track with no straight at all has no skeleton, and says so. +[[nodiscard]] bool skeleton_of(const Track& track, const SolvedTrack& solved, + Skeleton& out, std::string& why); + +/// Rewrites @p track to run along @p skeleton, keeping every arc's radius and +/// hand, and every transition curve's length. +/// +/// Each corner with a straight on both sides is fitted: the transitions turn what +/// they turn, the arcs share out what is left of the corner in the proportions +/// they were drawn with — so a basket curve stays that basket, tighter or wider — +/// and the whole corner is then slid along the line it leaves until it lands on +/// the line it joins. The turn is taken the way the arcs go, so a corner may run +/// past a half circle, up to a full one, and nothing shortens it to the way round +/// that happens to be shorter. +/// +/// A corner hanging off either end of the chain keeps exactly what it was given: +/// there is no second line to fit it against, so a track ending mid-curve simply +/// ends where that curve takes it. +/// +/// The skeleton's ends stay put and the anchor follows them — unless the track is +/// pinned to a turnout's port, which is not an anchor anyone may move: a change +/// that would drag the branch off its frog is refused instead. +/// +/// False, with a reason, when the skeleton cannot carry the chain. Nothing is +/// written to @p track unless all of it works out. +[[nodiscard]] bool lay_along_skeleton(Track& track, const Skeleton& skeleton, + std::string& why); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/solution.hpp b/maj0sted/include/maj0sted/editor/solution.hpp new file mode 100644 index 00000000..1112baff --- /dev/null +++ b/maj0sted/include/maj0sted/editor/solution.hpp @@ -0,0 +1,169 @@ +#pragma once + +#include +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" // Pose +#include "maj0sted/domain/geometry/turnout.hpp" // TurnoutPart +#include "maj0sted/editor/model.hpp" + +namespace maj0sted::editor { + +/// A point in the project's CRS (metres for EPSG:2180). +struct PlanPoint { + double x{0.0}; + double y{0.0}; +}; + +// --------------------------------------------------------------------------- +// Solved geometry +// --------------------------------------------------------------------------- + +/// What one authored element works out to on the ground. Curvatures rather than +/// radii, because that is what `geometry::layout_segment` takes and what makes a +/// straight (0, 0), an arc (k, k) and a clothoid (k0, k1) one shape. +struct SolvedElement { + ElementId id{ElementId::none}; + Kind kind{Kind::Line}; + domain::geometry::Pose start{}; ///< pose the element is laid from + domain::geometry::Pose end{}; ///< pose it ends on = the next element's start + double k0{0.0}; ///< signed curvature at the start (1/m) + double k1{0.0}; ///< signed curvature at the end + double length{0.0}; + std::vector points; ///< sampled axis, first point == start +}; + +/// A solved track: its elements in order, plus the axis flattened into one +/// polyline with no duplicated join points, which is what a station along the +/// track is resolved against. +struct SolvedTrack { + TrackId id{TrackId::none}; + std::vector elements; + std::vector centreline; + domain::geometry::Pose start{}; + domain::geometry::Pose end{}; + double length{0.0}; + /// True when the chain was laid whole. False means it was cut short by a + /// diagnostic — the elements up to the cut are still here and still drawable. + bool complete{true}; +}; + +/// One laid segment of a turnout's diverging path. +struct SolvedTurnoutSegment { + domain::geometry::Pose start{}; + double k0{0.0}, k1{0.0}, length{0.0}; + domain::TurnoutPart part{domain::TurnoutPart::Curve}; + std::vector points; +}; + +/// A construction point of a turnout: where two of its parts meet, or a point +/// the catalogue drawing is dimensioned from. Reported rather than left to be +/// eyeballed off the drawn curve. +struct TurnoutMark { + double x{0.0}, y{0.0}; + /// 0 PR, 1 ostrze iglicy (where the blade begins, the odcinek przediglicowy + /// behind it), 2 pięta iglicy, 3 joint inside the curve, 4 end of curve / + /// start of the frog rail, 5 KR, 6 punkt teoretyczny, 7 koniec dzioba iglicy + /// (A, where the blade is u thick), 8 koniec strugania, 9 koniec rozjazdu on + /// the through track (the frog projected back onto the straight). + int kind{0}; + double station{0.0}; ///< along the diverging path from PR; 0 for the theoretical point +}; + +struct SolvedTurnout { + TurnoutId id{TurnoutId::none}; + bool valid{false}; + domain::geometry::Pose start{}; ///< PR, on the through track + domain::geometry::Pose frog{}; ///< KR, where a branch anchors + double tangent_front{0.0}, tangent_back{0.0}; + double diverging_length{0.0}; + double through_length{0.0}; ///< PR to the frog's projection, along the through track + std::vector path; + std::vector marks; + + /// The blade as dimensioned: where its tip stands (the odcinek przediglicowy + /// is everything before it), how long the planed nose and the planing run, + /// the kąt przylegania, and whether the type's own a/d/u agree with the curve. + /// Łukowanie, as laid: the signed curvature of the through track under it, the + /// one the branch came out at, and how far the through track turns over the + /// turnout. All zero for a rozjazd zwyczajny. + double bend{0.0}; + double diverging_curvature{0.0}; + double bend_angle{0.0}; + + bool has_blade{false}; + double blade_station{0.0}; + double nose_end{0.0}; + double planing_length{0.0}; + double blade_angle{0.0}; + double blade_residual{0.0}; + /// What the laid path works out to, against what the catalogue promised: + /// deflection minus the crossing angle, and through-length minus the + /// catalogue length. Reported, never fitted away. + double angle_residual{0.0}; + double length_residual{0.0}; +}; + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +/// Why something could not be laid. Reported, never silently dropped: the user +/// gets the reason along with whatever part of the drawing did stand up. +enum class Code { + UnknownReference, ///< an anchor points at an id that is not there + CyclicDependency, ///< tracks/turnouts depend on each other in a circle + DegenerateElement, ///< non-positive radius or length, a clothoid that eases nothing + TurnoutInvalid, ///< catalogue numbers admit no geometry + TurnoutNotClosed, ///< the laid path misses the crossing angle or the catalogue length + StationOffTrack, ///< a turnout sits past the end of its through track + TouchingStraights, ///< two straights end to end, which is one straight + HoldConflict, ///< two elements of one movable track both hold it parallel + OffsetNotHeld, ///< a pinned track cannot move, so its offset is what it is +}; + +[[nodiscard]] const char* code_name(Code code); + +struct Diagnostic { + Code code{Code::DegenerateElement}; + TrackId track{TrackId::none}; + ElementId element{ElementId::none}; + TurnoutId turnout{TurnoutId::none}; + /// Human-readable, with the number that matters in it ("R=300 m nie mieści + /// się, maksimum 214,4 m"). The host shows this; it does not compose it. + std::string text; +}; + +// --------------------------------------------------------------------------- +// The whole solution +// --------------------------------------------------------------------------- + +/// Everything derived. Rebuilt whole on each solve, never fed back into the +/// document. Vectors rather than maps so iteration order is the document's and +/// two solves of the same document compare equal. +struct Solution { + std::vector tracks; + std::vector turnouts; + std::vector diagnostics; +}; + +[[nodiscard]] const SolvedTrack* find_track(const Solution& solution, TrackId id); +[[nodiscard]] const SolvedTurnout* find_turnout(const Solution& solution, TurnoutId id); +[[nodiscard]] const SolvedElement* find_element(const Solution& solution, ElementId id); + +/// Pose at @p station metres along a solved track's centreline. Clamped to the +/// ends. Returns false for an empty track. +[[nodiscard]] bool pose_at(const SolvedTrack& track, double station, + domain::geometry::Pose& out); + +/// How much track of one steady curvature there is around @p station: @p behind +/// back to where the element begins, @p ahead on to where it ends, and +/// @p curvature what it runs at (signed, positive turns left; zero on a +/// straight). A turnout is laid as one piece of geometry, bent to the one radius +/// under it, so it belongs on a single element and has to fit on it whole. +/// False on a clothoid, where there is no one radius to bend to, and past the +/// end of the track. Nothing is written then. +[[nodiscard]] bool steady_room(const SolvedTrack& track, double station, double& curvature, + double& behind, double& ahead); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/solve.hpp b/maj0sted/include/maj0sted/editor/solve.hpp deleted file mode 100644 index 35a73647..00000000 --- a/maj0sted/include/maj0sted/editor/solve.hpp +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include - -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 points; -}; - -/// Builds the niweleta, runs the requested fit (falling back to the bare -/// straights if the parameters do not admit a fit) and returns the sampled plan -/// as polylines ready to draw. -[[nodiscard]] std::vector solve_scene(const SolveRequest& request); - -} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/io/document_io.hpp b/maj0sted/include/maj0sted/io/document_io.hpp new file mode 100644 index 00000000..c311cdae --- /dev/null +++ b/maj0sted/include/maj0sted/io/document_io.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "maj0sted/editor/model.hpp" + +namespace maj0sted::io { + +/// The `.m0s` format, version 3. +/// +/// Line-oriented text, one concept per token, every reference by id, and an +/// explicit version on the first line. Earlier versions are not read: they +/// described different models — anchored straights with mode-tagged fits between +/// them (1), then elements whose lengths were solved against construction lines +/// (2) — and translating either would have meant keeping those concepts alive in +/// the reader after they were removed from everything else. +/// +/// Doubles go through std::to_chars/from_chars, so a round trip is exact and +/// does not depend on the locale. +[[nodiscard]] std::string serialize(const editor::Document& document); + +/// Returns nothing when @p text is not a version 3 document or is malformed. +[[nodiscard]] std::optional deserialize(const std::string& text); + +[[nodiscard]] std::string default_project_path(); +bool save(const editor::Document& document, const std::string& path = default_project_path()); +[[nodiscard]] std::optional load( + const std::string& path = default_project_path()); + +} // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/io/project_serializer.hpp b/maj0sted/include/maj0sted/io/project_serializer.hpp deleted file mode 100644 index e43c8873..00000000 --- a/maj0sted/include/maj0sted/io/project_serializer.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include - -#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 diff --git a/maj0sted/include/maj0sted/io/scn_export.hpp b/maj0sted/include/maj0sted/io/scn_export.hpp index 42b4edd2..4f64fc92 100644 --- a/maj0sted/include/maj0sted/io/scn_export.hpp +++ b/maj0sted/include/maj0sted/io/scn_export.hpp @@ -2,9 +2,8 @@ #include #include -#include -#include "maj0sted/editor/editor.hpp" +#include "maj0sted/editor/solution.hpp" namespace maj0sted::io { @@ -22,12 +21,18 @@ struct ScnExportResult { double origin_north{0.0}; }; -[[nodiscard]] ScnExportResult export_scn( - const std::vector& solved, - const ScnExportOptions& options, std::ostream& out); +/// Writes the solved layout as MaSzyna scenery. The axis comes straight out of +/// the solution: the exporter used to average the two drawn rails back together +/// to recover it, which put the whole geometry through the renderer and back. +/// +/// 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( - const std::vector& solved, - ScnExportOptions options); +[[nodiscard]] ScnExportOptions resolve_scn_origin(const editor::Solution& solution, + ScnExportOptions options); } // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/io/svg_writer.hpp b/maj0sted/include/maj0sted/io/svg_writer.hpp deleted file mode 100644 index fd9e1168..00000000 --- a/maj0sted/include/maj0sted/io/svg_writer.hpp +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include - -#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 diff --git a/maj0sted/include/maj0sted/maj0sted.hpp b/maj0sted/include/maj0sted/maj0sted.hpp index aeaab42c..167c15a5 100644 --- a/maj0sted/include/maj0sted/maj0sted.hpp +++ b/maj0sted/include/maj0sted/maj0sted.hpp @@ -1,39 +1,27 @@ #pragma once -// maj0sted — a Domain-Driven Design library for a railway track-layout editor -// ("edytor torowiska"). Include this single header to pull in the whole public -// domain model. +// maj0sted — the model behind a railway track-layout editor ("edytor +// torowiska"). One header pulls in the whole public surface. +// +// The shape of it: a Document is authored (construction lines, catalogue +// turnout types and their placements, tracks made of elements), `solve` turns it +// into a Solution, and nothing ever travels back the other way. #include "maj0sted/version.hpp" -// Value Objects -#include "maj0sted/domain/value_objects/azimuth.hpp" -#include "maj0sted/domain/value_objects/cartesian_position.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" +// The authored model, and what solving it produces. +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" -// Geometry — krzywe w planie i w profilu -#include "maj0sted/domain/geometry/horizontal_alignment.hpp" -#include "maj0sted/domain/geometry/plan_element.hpp" -#include "maj0sted/domain/geometry/profile_element.hpp" -#include "maj0sted/domain/geometry/vertical_alignment.hpp" +// Solving: one track's chain, and a whole document in dependency order. +#include "maj0sted/editor/chain.hpp" +#include "maj0sted/editor/layout.hpp" -// Aggregates -#include "maj0sted/domain/map_project.hpp" -#include "maj0sted/domain/niweleta.hpp" +// Geometry primitives shared by the solver and the renderer. +#include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/domain/geometry/turnout.hpp" -// Parallelism — równoległość torów -#include "maj0sted/domain/parallelism/parallelism_service.hpp" -#include "maj0sted/domain/parallelism/track_parallelism.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" +// Presentation and IO. +#include "maj0sted/editor/render.hpp" +#include "maj0sted/io/document_io.hpp" +#include "maj0sted/io/scn_export.hpp" diff --git a/maj0sted/include/maj0sted/render/plan_sampler.hpp b/maj0sted/include/maj0sted/render/plan_sampler.hpp deleted file mode 100644 index 7899806c..00000000 --- a/maj0sted/include/maj0sted/render/plan_sampler.hpp +++ /dev/null @@ -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 diff --git a/maj0sted/include/maj0sted/render/rail_renderer.hpp b/maj0sted/include/maj0sted/render/rail_renderer.hpp index e43f6d30..93125718 100644 --- a/maj0sted/include/maj0sted/render/rail_renderer.hpp +++ b/maj0sted/include/maj0sted/render/rail_renderer.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include "maj0sted/render/track_renderer.hpp" @@ -12,9 +13,9 @@ namespace maj0sted::render { struct RailPolyline { ElementKind kind{ElementKind::Straight}; std::vector points; - int straight_index{-1}; - int gap{-1}; - int element_index{-1}; + std::uint32_t element_id{0}; + std::uint32_t owner_id{0}; + bool from_turnout{false}; double length{0.0}; double radius_start{0.0}; double radius_end{0.0}; diff --git a/maj0sted/include/maj0sted/render/scene.hpp b/maj0sted/include/maj0sted/render/scene.hpp index 843e07a8..41812153 100644 --- a/maj0sted/include/maj0sted/render/scene.hpp +++ b/maj0sted/include/maj0sted/render/scene.hpp @@ -1,7 +1,5 @@ #pragma once -#include - namespace maj0sted::render { /// 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). enum class ElementKind { Straight, Arc, Transition }; -/// A polyline approximating one plan element, in CRS coordinates. -struct Polyline { - ElementKind kind; - std::vector 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 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 diff --git a/maj0sted/include/maj0sted/render/track_renderer.hpp b/maj0sted/include/maj0sted/render/track_renderer.hpp index 14210973..bd5b9201 100644 --- a/maj0sted/include/maj0sted/render/track_renderer.hpp +++ b/maj0sted/include/maj0sted/render/track_renderer.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include "maj0sted/render/scene.hpp" // Point, ElementKind @@ -12,12 +13,12 @@ namespace maj0sted::render { /// hands to a renderer. struct CentrelineElement { ElementKind kind{ElementKind::Straight}; - std::vector points; ///< sampled axis in CRS metres - int straight_index{-1}; ///< owning straight, or -1 for a curve element - int gap{-1}; ///< owning gap, or -1 for a straight - int element_index{-1}; ///< position within a gap's curve chain - double length{0.0}; ///< element length (metres) - double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end) + std::vector points; ///< sampled axis in CRS metres + std::uint32_t element_id{0}; ///< the authored element this came from + std::uint32_t owner_id{0}; ///< the track or turnout it belongs to + bool from_turnout{false}; ///< true when owner_id names a turnout + double length{0.0}; ///< element length (metres) + double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end) double radius_end{0.0}; }; diff --git a/maj0sted/src/app/basket_draft.cpp b/maj0sted/src/app/basket_draft.cpp deleted file mode 100644 index 0bbef566..00000000 --- a/maj0sted/src/app/basket_draft.cpp +++ /dev/null @@ -1,389 +0,0 @@ -#include "maj0sted/app/basket_draft.hpp" - -#include -#include - -#include "maj0sted/app/straight_constraints.hpp" - -namespace maj0sted::app { -namespace { - -using maj0sted::editor::CompoundArcSpec; -using maj0sted::editor::GapFit; -using maj0sted::editor::NiweletaPolys; -using maj0sted::editor::NiweletaSpec; -using maj0sted::editor::RibbonElement; -using maj0sted::editor::RibbonRequest; -using maj0sted::editor::StraightSpec; -using maj0sted::editor::PlanPolyline; - -void sanitize_draft_arcs(BasketDraft& draft) { - for (auto& arc : draft.arcs) { - if (!(arc.radius >= 30.0)) { - arc.radius = 30.0; - } - if (!(arc.length > 0.0)) { - arc.length = 5.0; - } - if (arc.transition_to_next < 0.0) { - arc.transition_to_next = 0.0; - } - } - if (draft.entry_t < 0.0) { - draft.entry_t = 0.0; - } - if (draft.exit_t < 0.0) { - draft.exit_t = 0.0; - } -} - -bool draft_end_pose(const BasketDraft& draft, - const std::vector& polys, double& out_x, - double& out_y, double& out_az) { - if (!draft.active) { - return false; - } - out_az = draft.az0; - if (!polys.empty() && !polys.back().points.empty()) { - const auto& pts = polys.back().points; - out_x = pts.back().x; - out_y = pts.back().y; - if (pts.size() >= 2) { - out_az = std::atan2(pts.back().x - pts[pts.size() - 2].x, - pts.back().y - pts[pts.size() - 2].y); - } - return true; - } - out_x = draft.x0; - out_y = draft.y0; - return true; -} - -} // namespace - -RibbonRequest draft_to_ribbon(const BasketDraft& draft) { - RibbonRequest req; - req.x0 = draft.x0; - req.y0 = draft.y0; - req.az0 = draft.az0; - if (draft.arcs.empty()) { - return req; - } - const int dir = draft.dir >= 0 ? 1 : -1; - if (draft.entry_t > 1e-6) { - RibbonElement el; - el.kind = 2; - el.length = draft.entry_t; - el.radius_start = 0.0; - el.radius = draft.arcs.front().radius; - el.dir = dir; - req.elements.push_back(el); - } - for (std::size_t i = 0; i < draft.arcs.size(); ++i) { - const auto& arc = draft.arcs[i]; - const double r = std::max(30.0, arc.radius); - const double len = std::max(1e-3, arc.length); - RibbonElement a; - a.kind = 1; - a.radius = r; - a.angle = len / r; - a.dir = dir; - req.elements.push_back(a); - if (arc.transition_to_next > 1e-6) { - RibbonElement kp; - kp.kind = 2; - kp.length = arc.transition_to_next; - kp.radius_start = r; - if (i + 1 < draft.arcs.size()) { - kp.radius = std::max(30.0, draft.arcs[i + 1].radius); - } else { - kp.radius = std::max(30.0, draft.preview_next_r); - } - kp.dir = dir; - req.elements.push_back(kp); - } - } - if (draft.exit_t > 1e-6) { - RibbonElement el; - el.kind = 2; - el.length = draft.exit_t; - el.radius_start = draft.arcs.back().radius; - el.radius = 0.0; - el.dir = dir; - req.elements.push_back(el); - } - return req; -} - -std::vector preview_basket_draft(BasketDraft& draft, - double fallback_next_r) { - if (!draft.active || draft.arcs.empty()) { - return {}; - } - sanitize_draft_arcs(draft); - draft.preview_next_r = std::max(30.0, fallback_next_r); - return maj0sted::editor::solve_ribbon(draft_to_ribbon(draft)); -} - -void start_basket_draft_from_end(BasketDraft& draft, const StraightSpec& straight, - int straight_index, int end, double default_r) { - draft = {}; - draft.active = true; - draft.attach_str = straight_index; - draft.attach_end = end; - draft.dir = 1; - draft.preview_next_r = default_r; - draft.reedit_gap = -1; - draft.reedit_backup = {}; - if (end == 1) { - draft.x0 = straight.x2; - draft.y0 = straight.y2; - draft.az0 = std::atan2(straight.x2 - straight.x1, straight.y2 - straight.y1); - } else { - draft.x0 = straight.x1; - draft.y0 = straight.y1; - draft.az0 = std::atan2(straight.x1 - straight.x2, straight.y1 - straight.y2); - } - draft.arcs.push_back({600.0, 60.0, 40.0}); - draft.arcs.push_back({std::max(30.0, default_r), 80.0, 0.0}); -} - -bool reopen_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta, - std::size_t gap, const NiweletaPolys* solved) { - if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) { - return false; - } - const auto& fit = niweleta.fits[gap]; - if (fit.mode != 3 || fit.arcs.empty()) { - return false; - } - - std::vector solved_arc_lengths; - if (solved != nullptr) { - for (const auto& poly : solved->polylines) { - if (poly.gap != static_cast(gap) || poly.kind != 1 || - poly.element_index < 0) { - continue; - } - bool seen = false; - for (const auto& other : solved->polylines) { - if (&other == &poly) { - break; - } - if (other.gap == poly.gap && - other.element_index == poly.element_index) { - seen = true; - break; - } - } - if (!seen) { - solved_arc_lengths.push_back(std::max(5.0, poly.length)); - } - } - } - - const auto& entry = niweleta.straights[gap]; - const auto& exit = niweleta.straights[gap + 1]; - const double edx = entry.x2 - entry.x1; - const double edy = entry.y2 - entry.y1; - const double xdx = exit.x2 - exit.x1; - const double xdy = exit.y2 - exit.y1; - const double turn = edx * xdy - edy * xdx; - - draft = {}; - draft.active = true; - draft.reedit_backup = fit; - draft.reedit_gap = static_cast(gap); - draft.attach_str = static_cast(gap); - draft.attach_end = 1; - draft.x0 = entry.x2; - draft.y0 = entry.y2; - draft.az0 = std::atan2(edx, edy); - draft.dir = turn >= 0.0 ? 1 : -1; - draft.entry_t = std::max(0.0, fit.entry_t); - draft.exit_t = std::max(0.0, fit.exit_t); - draft.arcs = fit.arcs; - for (std::size_t i = 0; i < draft.arcs.size(); ++i) { - if (!(draft.arcs[i].radius >= 30.0)) { - draft.arcs[i].radius = 30.0; - } - if (draft.arcs[i].length <= 1e-6) { - draft.arcs[i].length = - i < solved_arc_lengths.size() ? solved_arc_lengths[i] : 60.0; - } - if (draft.arcs[i].transition_to_next < 0.0) { - draft.arcs[i].transition_to_next = 0.0; - } - } - if (!draft.arcs.empty()) { - draft.arcs.back().transition_to_next = 0.0; - draft.preview_next_r = draft.arcs.back().radius; - } - - niweleta.fits[gap] = {}; - niweleta.fits[gap].gap = static_cast(gap); - niweleta.fits[gap].mode = 0; - return true; -} - -void discard_basket_draft(BasketDraft& draft, NiweletaSpec* niweleta) { - if (draft.reedit_gap >= 0 && niweleta != nullptr && - static_cast(draft.reedit_gap) < niweleta->fits.size()) { - niweleta->fits[static_cast(draft.reedit_gap)] = - draft.reedit_backup; - } - draft = {}; -} - -bool anchor_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta, - std::size_t gap) { - if (!draft.active || draft.arcs.empty() || niweleta.straights.size() < 2) { - return false; - } - if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) { - return false; - } - - const GapFit previous = niweleta.fits[gap]; - const BasketDraft draft_backup = draft; - - GapFit fit{}; - fit.gap = static_cast(gap); - fit.mode = 3; - fit.entry_t = std::max(0.0, draft.entry_t); - fit.exit_t = std::max(0.0, draft.exit_t); - fit.arcs = draft.arcs; - for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) { - if (!(fit.arcs[i].radius >= 30.0)) { - fit.arcs[i].radius = 30.0; - } - if (!(fit.arcs[i].length > 0.0)) { - fit.arcs[i].length = 40.0; - } - } - fit.arcs.back().length = 0.0; - fit.arcs.back().transition_to_next = 0.0; - if (!(fit.arcs.back().radius >= 30.0)) { - fit.arcs.back().radius = 30.0; - } - for (std::size_t i = 1; i < fit.arcs.size(); ++i) { - if (std::abs(fit.arcs[i].radius - fit.arcs[i - 1].radius) < 1.0) { - fit.arcs[i].radius = std::max(30.0, fit.arcs[i - 1].radius * 0.75); - } - } - - niweleta.fits[gap] = fit; - niweleta.straights[gap].rel_kind = 0; - niweleta.straights[gap + 1].rel_kind = 0; - - const auto probe = maj0sted::editor::solve_project( - std::vector{niweleta}); - bool applied = false; - if (!probe.empty()) { - for (const auto& af : probe.front().applied_fits) { - if (af.gap == static_cast(gap) && af.mode == 3) { - applied = true; - break; - } - } - } - if (!applied) { - niweleta.fits[gap] = previous; - draft = draft_backup; - return false; - } - - draft = {}; - return true; -} - -FinishBasketDraftResult finish_basket_draft( - BasketDraft& draft, NiweletaSpec& niweleta, - std::vector& all_niwelety, int niweleta_index, - double finish_straight_m) { - FinishBasketDraftResult result; - if (!draft.active || draft.arcs.empty() || draft.attach_str < 0 || - static_cast(draft.attach_str) >= niweleta.straights.size()) { - result.status = "brak łuku"; - return result; - } - - if (draft.reedit_gap >= 0) { - const auto gap = static_cast(draft.reedit_gap); - const GapFit backup = draft.reedit_backup; - niweleta.fits[gap] = backup; - if (!anchor_basket_draft(draft, niweleta, gap)) { - draft.reedit_gap = static_cast(gap); - draft.reedit_backup = backup; - result.status = "nie da się (kierunek / L+KP)"; - return result; - } - result.ok = true; - result.status = "luka " + std::to_string(gap); - return result; - } - - auto polys = preview_basket_draft(draft); - double tipx = 0.0, tipy = 0.0, tipaz = 0.0; - if (!draft_end_pose(draft, polys, tipx, tipy, tipaz)) { - result.status = "brak końca kosza"; - return result; - } - - const NiweletaSpec backup = niweleta; - const BasketDraft draft_backup = draft; - const int attach = draft.attach_str; - - auto& entry = niweleta.straights[static_cast(attach)]; - if (draft.attach_end == 0) { - std::swap(entry.x1, entry.x2); - std::swap(entry.y1, entry.y2); - draft.attach_end = 1; - } - entry.x2 = draft.x0; - entry.y2 = draft.y0; - entry.rel_kind = 0; - - const double sl = std::max(20.0, finish_straight_m); - StraightSpec exit_st{}; - exit_st.x1 = tipx; - exit_st.y1 = tipy; - exit_st.x2 = tipx + std::sin(tipaz) * sl; - exit_st.y2 = tipy + std::cos(tipaz) * sl; - - const auto insert_at = static_cast(attach + 1); - for (auto& fit : niweleta.fits) { - if (fit.gap >= attach) { - ++fit.gap; - } - } - auto bump_rel = [&](int delta) { - for (std::size_t n = 0; n < all_niwelety.size(); ++n) { - for (auto& st : all_niwelety[n].straights) { - if (st.rel_kind != 0 && st.rel_niw == niweleta_index && - st.rel_str >= static_cast(insert_at)) { - st.rel_str += delta; - } - } - } - }; - bump_rel(+1); - niweleta.straights.insert( - niweleta.straights.begin() + static_cast(insert_at), - exit_st); - align_gap_fits(niweleta); - - if (!anchor_basket_draft(draft, niweleta, static_cast(attach))) { - bump_rel(-1); - niweleta = backup; - draft = draft_backup; - result.status = "nie da się (kierunek / L+KP)"; - return result; - } - - result.ok = true; - result.status = "kosz + " + std::to_string(static_cast(sl)) + " m"; - return result; -} - -} // namespace maj0sted::app diff --git a/maj0sted/src/app/editor_document.cpp b/maj0sted/src/app/editor_document.cpp deleted file mode 100644 index 759bcc01..00000000 --- a/maj0sted/src/app/editor_document.cpp +++ /dev/null @@ -1,520 +0,0 @@ -#include "maj0sted/app/editor_document.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#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 tokenize(const std::string& line) { - std::vector tokens; - std::size_t i = 0; - const std::size_t n = line.size(); - while (i < n) { - while (i < n && std::isspace(static_cast(line[i]))) ++i; - std::size_t j = i; - while (j < n && !std::isspace(static_cast(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(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(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(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(&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 [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 …) - 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 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(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 '"}; - } - const long niweleta_count = to_long(header[1]); - // Optional camera/zoom line (absent in older files). - { - std::size_t peek = cursor; - while (peek < lines.size()) { - bool only_ws = true; - for (const char c : lines[peek]) { - if (!std::isspace(static_cast(c))) { - only_ws = false; - break; - } - } - if (!only_ws) { - break; - } - ++peek; - } - if (peek < lines.size()) { - const auto view_tok = tokenize(lines[peek]); - if (view_tok.size() >= 4 && view_tok[0] == "eview") { - next(); - document.view_x = to_double(view_tok[1]); - document.view_y = to_double(view_tok[2]); - document.view_extent = to_double(view_tok[3]); - } - } - // Optional local-zero line (absent in older files). May follow eview. - peek = cursor; - while (peek < lines.size()) { - bool only_ws = true; - for (const char c : lines[peek]) { - if (!std::isspace(static_cast(c))) { - only_ws = false; - break; - } - } - if (!only_ws) { - break; - } - ++peek; - } - if (peek < lines.size()) { - const auto origin_tok = tokenize(lines[peek]); - if (origin_tok.size() >= 4 && origin_tok[0] == "eorigin") { - next(); - document.origin_set = true; - document.georeferenced = to_long(origin_tok[1]) != 0; - document.origin_x = to_double(origin_tok[2]); - document.origin_y = to_double(origin_tok[3]); - } - } - } - for (long n = 0; n < niweleta_count; ++n) { - const auto eniw = tokenize(next()); - if (eniw.size() < 3 || eniw[0] != "eniw") { - throw std::runtime_error{"maj0sted: expected 'eniw '"}; - } - 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& e) -> bool { - if (e.empty()) return false; - if (e[0] == "epar" && e.size() >= 5) { - const long idx = to_long(e[1]); - if (idx < 0 || static_cast(idx) >= spec.straights.size()) { - throw std::runtime_error{"maj0sted: bad 'epar' index"}; - } - auto& s = spec.straights[static_cast(idx)]; - s.rel_kind = 1; - s.rel_niw = static_cast(to_long(e[2])); - s.rel_str = static_cast(to_long(e[3])); - s.rel_offset = to_double(e[4]); - return true; - } - if (e[0] == "eskew" && e.size() >= 7) { - const long idx = to_long(e[1]); - if (idx < 0 || static_cast(idx) >= spec.straights.size()) { - throw std::runtime_error{"maj0sted: bad 'eskew' index"}; - } - auto& s = spec.straights[static_cast(idx)]; - s.rel_kind = 2; - s.rel_niw = static_cast(to_long(e[2])); - s.rel_str = static_cast(to_long(e[3])); - s.rel_cot = to_double(e[4]); - s.rel_side = static_cast(to_long(e[5])); - s.rel_length = to_double(e[6]); - return true; - } - return false; - }; - auto peek_tokens = [&]() -> std::vector { - std::size_t i = cursor; - while (i < lines.size()) { - bool only_ws = true; - for (const char c : lines[i]) { - if (!std::isspace(static_cast(c))) { - only_ws = false; - break; - } - } - if (!only_ws) { - return tokenize(lines[i]); - } - ++i; - } - return {}; - }; - while (apply_relation(peek_tokens())) { - next(); // consume the relation line - } - for (long i = 0; i < fit_count; ++i) { - const auto e = tokenize(next()); - GapFit f; - 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(to_long(e[1])); - f.mode = static_cast(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(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(to_long(e[1])); - f.mode = static_cast(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(c))) { - only_ws = false; - break; - } - } - if (!only_ws) break; - ++peek; - } - if (peek < lines.size()) { - const auto head = tokenize(lines[peek]); - if (head.size() >= 2 && head[0] == "ejuncs") { - next(); // consume the 'ejuncs ' header - const long junction_count = to_long(head[1]); - for (long j = 0; j < junction_count; ++j) { - const auto e = tokenize(next()); - if (e.size() < 12 || e[0] != "ejunc") { - throw std::runtime_error{"maj0sted: bad 'ejunc' line"}; - } - maj0sted::editor::Junction junction; - junction.through = static_cast(to_long(e[1])); - junction.station = to_double(e[2]); - junction.side = static_cast(to_long(e[3])); - junction.facing = to_long(e[4]) != 0; - junction.crossing_n = to_double(e[5]); - junction.branch = static_cast(to_long(e[6])); - junction.curve.mode = static_cast(to_long(e[7])); - junction.curve.radius = to_double(e[8]); - junction.curve.transition = to_double(e[9]); - junction.curve.entry_t = to_double(e[10]); - junction.curve.exit_t = to_double(e[11]); - const long arc_count = e.size() >= 13 ? to_long(e[12]) : 0; - for (long a = 0; a < arc_count; ++a) { - const std::size_t base = 13 + static_cast(a) * 3; - if (base + 2 >= e.size()) { - throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"}; - } - junction.curve.arcs.push_back(maj0sted::editor::CompoundArcSpec{ - to_double(e[base]), to_double(e[base + 1]), - to_double(e[base + 2])}); - } - // optional catalogue length after the arcs (absent in the earliest files) - const std::size_t after_arcs = 13 + static_cast(arc_count) * 3; - if (e.size() > after_arcs) { - junction.length = to_double(e[after_arcs]); - } - document.junctions.push_back(std::move(junction)); - } - } - } - } - 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(out); - } catch (...) { - return false; - } -} - -std::optional 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 diff --git a/maj0sted/src/app/straight_constraints.cpp b/maj0sted/src/app/straight_constraints.cpp deleted file mode 100644 index 65ce03c7..00000000 --- a/maj0sted/src/app/straight_constraints.cpp +++ /dev/null @@ -1,112 +0,0 @@ -#include "maj0sted/app/straight_constraints.hpp" - -#include -#include - -#include "maj0sted/domain/geometry/plan_element.hpp" -#include "maj0sted/domain/parallelism/parallelism_service.hpp" -#include "maj0sted/domain/value_objects/cartesian_position.hpp" - -namespace maj0sted::app { -namespace { - -using maj0sted::domain::CartesianPosition; -using maj0sted::domain::ParallelismService; -using maj0sted::domain::Straight; -using maj0sted::editor::NiweletaSpec; -using maj0sted::editor::StraightSpec; - -Straight to_straight(const StraightSpec& s) { - return Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}}; -} - -} // namespace - -void align_gap_fits(NiweletaSpec& niweleta) { - const auto gaps = niweleta.straights.empty() ? std::size_t{0} - : niweleta.straights.size() - 1; - std::vector slots(gaps); - for (std::size_t gap = 0; gap < gaps; ++gap) { - slots[gap].gap = static_cast(gap); - } - for (const auto& fit : niweleta.fits) { - if (fit.gap >= 0 && static_cast(fit.gap) < gaps) { - slots[static_cast(fit.gap)] = fit; - } - } - niweleta.fits = std::move(slots); -} - -void apply_straight_constraints(std::vector& niwelety) { - for (int pass = 0; pass < 3; ++pass) { - bool changed = false; - for (auto& niweleta : niwelety) { - for (auto& st : niweleta.straights) { - if (st.rel_kind == 0 || st.rel_niw < 0 || st.rel_str < 0) { - continue; - } - if (static_cast(st.rel_niw) >= niwelety.size()) { - continue; - } - const auto& ref_niw = niwelety[static_cast(st.rel_niw)]; - if (static_cast(st.rel_str) >= ref_niw.straights.size()) { - continue; - } - const auto& ref = ref_niw.straights[static_cast(st.rel_str)]; - if (&ref == &st) { - continue; - } - - const double dx = ref.x2 - ref.x1; - const double dy = ref.y2 - ref.y1; - const double len = std::hypot(dx, dy); - if (len < 1e-6) { - continue; - } - - double x1 = st.x1, y1 = st.y1, x2 = st.x2, y2 = st.y2; - if (st.rel_kind == 1) { - const Straight projected = ParallelismService::project_onto_offset_line( - to_straight(ref), st.rel_offset, to_straight(st)); - x1 = projected.start().x(); - y1 = projected.start().y(); - x2 = projected.end().x(); - y2 = projected.end().y(); - } else if (st.rel_kind == 2) { - const double a = - std::atan2(1.0, std::max(1e-9, st.rel_cot)) * - (st.rel_side < 0 ? -1.0 : 1.0); - const double ux = dx / len; - const double uy = dy / len; - const double ca = std::cos(a); - const double sa = std::sin(a); - const double rx = ux * ca - uy * sa; - const double ry = ux * sa + uy * ca; - double L = std::hypot(st.x2 - st.x1, st.y2 - st.y1); - if (L < 1.0) { - L = st.rel_length > 0.0 ? st.rel_length : len; - } - st.rel_length = L; - x1 = st.x1; - y1 = st.y1; - x2 = st.x1 + rx * L; - y2 = st.y1 + ry * L; - } - - if (std::abs(st.x1 - x1) > 1e-9 || std::abs(st.y1 - y1) > 1e-9 || - std::abs(st.x2 - x2) > 1e-9 || std::abs(st.y2 - y2) > 1e-9) { - st.x1 = x1; - st.y1 = y1; - st.x2 = x2; - st.y2 = y2; - changed = true; - } - } - } - if (!changed) { - break; - } - } -} - -} // namespace maj0sted::app diff --git a/maj0sted/src/domain/fitting_service.cpp b/maj0sted/src/domain/fitting_service.cpp deleted file mode 100644 index d7285bb5..00000000 --- a/maj0sted/src/domain/fitting_service.cpp +++ /dev/null @@ -1,582 +0,0 @@ -#include "maj0sted/domain/fitting/fitting_service.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -#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(&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& 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 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 chain; - std::vector 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 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 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 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 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 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 { - 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 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(sample) / - static_cast(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 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, - 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 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 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 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 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 diff --git a/maj0sted/src/domain/gap_fitter.cpp b/maj0sted/src/domain/gap_fitter.cpp deleted file mode 100644 index 8d4dd5d3..00000000 --- a/maj0sted/src/domain/gap_fitter.cpp +++ /dev/null @@ -1,215 +0,0 @@ -#include "maj0sted/domain/fitting/gap_fitter.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "maj0sted/domain/fitting/fit_parameters.hpp" -#include "maj0sted/domain/fitting/fitting_service.hpp" - -namespace maj0sted::domain { - -namespace { - -std::optional 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 attempt_arc(const Straight& entry, const Straight& exit, - double radius, - std::optional 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 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 refine(const Straight& entry, const Straight& exit, - std::optional 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 fit_nearest_arc(const Straight& entry, - const Straight& exit, - double requested_radius, - std::optional 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 connect_uturn(const Straight& entry, - const Straight& exit, - std::optional 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(&element)) { - applied = arc->radius().metres(); - break; - } - } - return GapConnection{std::move(fit), applied}; - } catch (...) { - return std::nullopt; - } -} - -std::optional 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 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 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 diff --git a/maj0sted/src/domain/horizontal_alignment.cpp b/maj0sted/src/domain/horizontal_alignment.cpp deleted file mode 100644 index a554a651..00000000 --- a/maj0sted/src/domain/horizontal_alignment.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#include "maj0sted/domain/geometry/horizontal_alignment.hpp" - -#include -#include -#include - -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(&previous)) { - if (const auto* b = std::get_if(¤t)) { - 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(&previous); - const auto* current_straight = std::get_if(¤t); - - 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 diff --git a/maj0sted/src/domain/map_project.cpp b/maj0sted/src/domain/map_project.cpp deleted file mode 100644 index aede9de8..00000000 --- a/maj0sted/src/domain/map_project.cpp +++ /dev/null @@ -1,13 +0,0 @@ -#include "maj0sted/domain/map_project.hpp" - -#include - -namespace maj0sted::domain { - -MapProject::MapProject(Crs crs) : crs_{crs} {} - -void MapProject::add_niweleta(Niweleta niweleta) { - niwelety_.push_back(std::move(niweleta)); -} - -} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/niweleta.cpp b/maj0sted/src/domain/niweleta.cpp deleted file mode 100644 index 3006c4f7..00000000 --- a/maj0sted/src/domain/niweleta.cpp +++ /dev/null @@ -1,90 +0,0 @@ -#include "maj0sted/domain/niweleta.hpp" - -#include -#include -#include -#include -#include - -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(&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(&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 diff --git a/maj0sted/src/domain/parallelism_service.cpp b/maj0sted/src/domain/parallelism_service.cpp deleted file mode 100644 index 34f93e2c..00000000 --- a/maj0sted/src/domain/parallelism_service.cpp +++ /dev/null @@ -1,83 +0,0 @@ -#include "maj0sted/domain/parallelism/parallelism_service.hpp" - -#include -#include - -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 diff --git a/maj0sted/src/domain/turnout.cpp b/maj0sted/src/domain/turnout.cpp index 4e9bb569..b88561b0 100644 --- a/maj0sted/src/domain/turnout.cpp +++ b/maj0sted/src/domain/turnout.cpp @@ -1,6 +1,9 @@ #include "maj0sted/domain/geometry/turnout.hpp" +#include #include +#include +#include namespace maj0sted::domain { @@ -19,50 +22,6 @@ double deflection(const TurnoutSegment& segment) { return 0.5 * (segment.k0 + segment.k1) * segment.length; } -// Builds the diverging curve as signed-curvature segments that together turn -// through exactly @p total_angle. Every part but the closing arc is fixed; the -// closing arc's length is chosen to spend whatever deflection is left. Returns -// false when a radius is non-positive or the fixed parts overshoot the angle. -bool build_curve(const DivergingCurve& curve, double total_angle, double sign, - std::vector& out) { - if (curve.arcs.empty()) return false; - const double first_radius = curve.arcs.front().radius; - const double last_radius = curve.arcs.back().radius; - if (first_radius <= 0.0 || last_radius <= 0.0) return false; - - double consumed = 0.0; // deflection of everything before the closing arc - - if (curve.entry_transition > 0.0) { - out.push_back({0.0, 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). Pose turn(const Pose& pose, double angle) { 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); } -} // 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) { - TurnoutGeometry geometry; - - const double angle = turnout.mark.angle(); - if (angle <= 0.0) return geometry; - const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0; - - const double blade = turnout.blade_angle; - if (blade < 0.0 || blade >= angle) return geometry; // the blade may not reach the frog on its own - - // odcinek przediglicowy: still along the through track, the blade tip sits at its end - if (turnout.pre_blade > 0.0) { - geometry.path.push_back(TurnoutSegment{0.0, 0.0, 0.0, turnout.pre_blade}); +// The pose @p station metres along @p path. Stations are the same whichever frame +// the path is measured in, because bending moves curvature and never length. +Pose pose_at_station(const std::vector& path, const Pose& start, + double station) { + Pose pose = start; + double walked = 0.0; + for (const auto& segment : path) { + const Pose from = turn(pose, segment.turn_in); + if (station <= walked + segment.length) { + return layout_segment(segment.k0, segment.k1, station - walked, from, nullptr); + } + walked += segment.length; + pose = layout_segment(segment.k0, segment.k1, segment.length, from, nullptr); } - // the blade breaks away by beta and runs straight to its heel; with no blade - // 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}); + return pose; +} + +// Bends the laid path onto a through track of curvature @p bend, the way a rozjazd +// łukowy is made: the triangle the turnout is dimensioned by is rotated about its +// centre, so `t` and the crossing angle stand and every arc takes the radius that +// follows from them. +// +// What is re-cut and what is not comes straight from Koc: the zwrotnica and the +// krzyżownica keep their dimensions and are only bent, so the blade, the odcinek +// przediglicowy and the frog rail keep their lengths to the millimetre. The szyny +// łączące — the plain curve between them — take up the whole difference, which is +// small: the arc between two tangent points barely changes length when it is bent. +// +// False when there is no single arc radius to rotate about, nothing flexible to +// take up the change, or the bend is sharp enough to eat the connecting rails whole. +bool bend_path(std::vector& path, double alpha, double sign, double bend, + double& tangent_out, double& curvature_out, double& beta_out) { + double radius = 0.0; + double cut = 0.0; // what bending does not re-cut: switch and frog + double flexible = 0.0; // the connecting rails, which it does + for (const auto& segment : path) { + if (segment.k0 == 0.0 && segment.k1 == 0.0) continue; + if (segment.k0 != segment.k1) return false; // a clothoid has no one radius + const double own = std::abs(1.0 / segment.k0); + if (radius != 0.0 && std::abs(own - radius) > 1e-6 * radius) return false; + radius = own; + (segment.part == TurnoutPart::Curve ? flexible : cut) += segment.length; + } + if (radius <= 0.0 || flexible <= 0.0 || alpha <= 0.0) return false; + + const double tangent = radius * std::tan(0.5 * alpha); + const double beta = 2.0 * std::atan(tangent * bend); + const double turn_to = beta + sign * alpha; + if (std::abs(turn_to) >= std::numbers::pi - 1e-9) return false; + + // t/tg(theta/2) is the radius of every leg of the triangle, the through track's + // as much as the branch's. At theta = 0 the branch comes out straight, and its + // two tangents are then all the length there is between PR and the crossing + const double curvature = + std::abs(turn_to) > 1e-12 ? std::tan(0.5 * turn_to) / tangent : 0.0; + const double run = curvature != 0.0 ? turn_to / curvature : 2.0 * tangent; + const double spare = run - cut; + if (spare <= 0.0) return false; + + for (auto& segment : path) { + if (segment.k0 == 0.0 && segment.k1 == 0.0) continue; + segment.k0 = curvature; + segment.k1 = curvature; + if (segment.part == TurnoutPart::Curve) { + segment.length *= spare / flexible; + } } - // the curve only has `alfa - beta` left to turn through - std::vector curve_path; - if (!build_curve(turnout.curve, angle - blade, sign, curve_path)) { - geometry.path.clear(); - 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()); + tangent_out = tangent; + curvature_out = curvature; + beta_out = beta; + return true; +} - // 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& path, const Pose& start, + double target, double sign, double& station_out, Pose& pose_out) { + if (target <= 0.0) return false; + + Pose pose = start; + double station = 0.0; + double gap = 0.0; + for (const auto& segment : path) { + const Pose from = turn(pose, segment.turn_in); + const Pose to = advance(segment, pose); + const double heading = heading_off(start, from, sign); + const double radius = segment.k0 != 0.0 ? std::abs(1.0 / segment.k0) : 0.0; + const double opened = + radius > 0.0 + ? (radius + kHalfGauge) * + (std::cos(heading) - std::cos(heading_off(start, to, sign))) + : segment.length * std::sin(heading); + + if (gap <= target && gap + opened >= target) { + const double run = std::clamp( + blade_run_to_offset(radius, heading, target - gap), 0.0, segment.length); + station_out = station + run; + pose_out = layout_segment(segment.k0, segment.k1, run, from, nullptr); + return true; + } + gap += opened; + station += segment.length; + pose = to; + } + return false; +} + +// Fills the blade dimensions: where its tip stands, how much of the curve runs ahead +// of it as the odcinek przediglicowy, where its planed nose ends and where it reaches +// full railhead width. +// @p relative is the path with the through track's own curvature taken out of it: +// the blade opens against the opornica, so what dimensions it is how the two part +// company, not how either of them runs across the ground. For a rozjazd zwyczajny +// the two paths are the same thing. +void dimension_blade(const Turnout& turnout, const Pose& start, + const std::vector& relative, TurnoutGeometry& geometry, + double sign) { + double station = 0.0; Pose pose = start; for (const auto& segment : geometry.path) { - 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 - // takes the switch to its catalogue length, measured along the through track; - // without a catalogue length a tangent-length rail keeps a frog rail present. - const double through_projection = - (pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy; - const double cos_angle = pose.hx * start.hx + pose.hy * start.hy; - double rail = turnout.length > 0.0 && cos_angle > 1e-6 - ? (turnout.length - through_projection) / cos_angle - : turnout.curve.arcs.back().radius * std::tan(angle * 0.5); - if (rail > 0.0) { - const TurnoutSegment frog_rail{0.0, 0.0, 0.0, rail}; - geometry.path.push_back(frog_rail); - pose = advance(frog_rail, pose); + double tangent = 0.0; + double beta = 0.0; + if (bend != 0.0) { + double curvature = 0.0; + if (!bend_path(geometry.path, turnout.mark.angle(), sign, bend, tangent, curvature, + beta)) { + return geometry; + } + geometry.bend = bend; + geometry.bend_angle = beta; + geometry.diverging_curvature = curvature; + } + + // the path is laid exactly as authored: what it works out to is then checked + // against the catalogue numbers rather than fitted to them + Pose pose = start; + double deflected = 0.0; + for (const auto& segment : geometry.path) { + deflected += segment.turn_in + deflection(segment); + pose = advance(segment, pose); + geometry.diverging_length += segment.length; } geometry.valid = true; geometry.frog = pose; - for (const auto& segment : geometry.path) geometry.diverging_length += segment.length; tangent_lengths(start, pose, geometry.tangent_front, geometry.tangent_back); + + // the blade is dimensioned against the opornica, which bends with the turnout + std::vector relative = geometry.path; + for (auto& segment : relative) { + segment.k0 -= bend; + segment.k1 -= bend; + } + dimension_blade(turnout, start, relative, geometry, sign); + + // deflected is signed by the hand, and it is the through track the crossing + // angle is measured from — which turns by beta when the turnout is bent + geometry.angle_residual = sign * (deflected - beta) - turnout.mark.angle(); + + // where the switch ends on the tor zasadniczy: the frog, projected back onto it. + // this is the second point a drawing marks on the through track, and on a bent + // turnout the projection runs along the radius rather than square to a straight + if (bend == 0.0) { + geometry.through_length = + (pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy; + geometry.through_end = Pose{start.x + start.hx * geometry.through_length, + start.y + start.hy * geometry.through_length, start.hx, + start.hy}; + } else { + const double centre_x = start.x - start.hy / bend; + const double centre_y = start.y + start.hx / bend; + const double from = std::atan2(start.y - centre_y, start.x - centre_x); + const double to = std::atan2(pose.y - centre_y, pose.x - centre_x); + double swept = to - from; + while (swept > std::numbers::pi) swept -= 2.0 * std::numbers::pi; + while (swept < -std::numbers::pi) swept += 2.0 * std::numbers::pi; + geometry.through_length = swept / bend; + geometry.through_end = + layout_segment(bend, bend, geometry.through_length, start, nullptr); + } + + // the catalogue length is a + b, the two tangent legs of the drawing + if (turnout.length > 0.0) { + geometry.length_residual = + geometry.tangent_front + geometry.tangent_back - turnout.length; + } return geometry; } diff --git a/maj0sted/src/domain/vertical_alignment.cpp b/maj0sted/src/domain/vertical_alignment.cpp deleted file mode 100644 index fc576e10..00000000 --- a/maj0sted/src/domain/vertical_alignment.cpp +++ /dev/null @@ -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 diff --git a/maj0sted/src/editor/chain.cpp b/maj0sted/src/editor/chain.cpp new file mode 100644 index 00000000..f6f1da36 --- /dev/null +++ b/maj0sted/src/editor/chain.cpp @@ -0,0 +1,141 @@ +#include "maj0sted/editor/chain.hpp" + +#include +#include +#include +#include + +namespace maj0sted::editor { +namespace { + +constexpr double kEps = 1e-9; + +using domain::geometry::Pose; + +Diagnostic make(Code code, TrackId track, ElementId element, std::string text) { + Diagnostic diagnostic; + diagnostic.code = code; + diagnostic.track = track; + diagnostic.element = element; + diagnostic.text = std::move(text); + return diagnostic; +} + +Diagnostic degenerate(TrackId track, ElementId element, std::string text) { + return make(Code::DegenerateElement, track, element, std::move(text)); +} + +/// Signed curvature an element ends on. A straight, and a clothoid running out +/// to an infinite radius, both end flat. +double end_curvature(const Element& element) noexcept { + if (element.kind == Kind::Line || element.radius <= kEps) { + return 0.0; + } + return static_cast(element.hand) / element.radius; +} + +} // namespace + +// --------------------------------------------------------------------------- + +TrackOutcome lay_track(const Document& /*document*/, const Track& track, const Pose& start) { + TrackOutcome out; + out.track.id = track.id; + out.track.start = start; + out.track.end = start; + + // Two straights end to end are one straight: there is no corner between them, + // so there is nothing to grab hold of and nothing an arc could re-fit against. + // The chain is still laid — this is a rule about how track is written down, + // not about whether the geometry closes. + for (std::size_t i = 1; i < track.elements.size(); ++i) { + if (track.elements[i].kind == Kind::Line && + track.elements[i - 1].kind == Kind::Line) { + out.diagnostics.push_back(make( + Code::TouchingStraights, track.id, track.elements[i].id, + "dwie proste stykają się ze sobą — to jedna prosta o długości " + + std::to_string(static_cast(track.elements[i - 1].length + + track.elements[i].length)) + + " m; złącz je albo wstaw między nie łuk")); + } + } + + Pose walker = start; + // curvature the chain is running at where the previous element ended, which + // is what a clothoid eases away from. It carries across the whole chain: two + // clothoids back to back are as ordinary as two arcs. + double k_prev = 0.0; + + out.track.elements.reserve(track.elements.size()); + for (const Element& element : track.elements) { + double k0 = 0.0; + double k1 = 0.0; + + if (element.kind == Kind::Arc) { + if (element.radius <= kEps) { + out.diagnostics.push_back( + degenerate(track.id, element.id, "łuk musi mieć dodatni promień")); + out.track.complete = false; + break; + } + if (element.hand == 0) { + out.diagnostics.push_back(degenerate( + track.id, element.id, "łuk musi skręcać w lewo albo w prawo")); + out.track.complete = false; + break; + } + k0 = k1 = static_cast(element.hand) / element.radius; + } else if (element.kind == Kind::Clothoid) { + k0 = k_prev; + k1 = end_curvature(element); + if (std::abs(k1 - k0) < kEps) { + out.diagnostics.push_back(degenerate( + track.id, element.id, + "krzywa przejściowa musi zmieniać krzywiznę — na jej końcu " + "jest ten sam promień, co na początku")); + out.track.complete = false; + break; + } + } + + if (element.length <= kEps) { + out.diagnostics.push_back( + degenerate(track.id, element.id, "długość musi być dodatnia")); + out.track.complete = false; + break; + } + + SolvedElement solved; + solved.id = element.id; + solved.kind = element.kind; + solved.start = walker; + solved.k0 = k0; + solved.k1 = k1; + solved.length = element.length; + + std::vector points; + walker = domain::geometry::layout_segment(k0, k1, element.length, walker, &points); + solved.end = walker; + solved.points.reserve(points.size()); + for (const auto& point : points) { + solved.points.push_back(PlanPoint{point.x, point.y}); + } + + out.track.length += element.length; + out.track.elements.push_back(std::move(solved)); + k_prev = k1; + } + + out.track.end = walker; + + for (const auto& element : out.track.elements) { + for (std::size_t i = out.track.centreline.empty() ? 0 : 1; i < element.points.size(); + ++i) { + out.track.centreline.push_back(element.points[i]); + } + } + + return out; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/editor.cpp b/maj0sted/src/editor/editor.cpp deleted file mode 100644 index 78ad0ba7..00000000 --- a/maj0sted/src/editor/editor.cpp +++ /dev/null @@ -1,434 +0,0 @@ -#include "maj0sted/editor/editor.hpp" - -#include -#include -#include -#include -#include -#include - -#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 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 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(&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(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 sample_centrelines( - const NiweletaSpec& spec, const std::vector>& straights, - const std::vector>& fits, - std::vector& rendered_straights) { - std::vector 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(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 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(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 render_rails( - const std::vector& axis) { - render::RailRenderer rail_renderer; - render::TrackRenderer& renderer = rail_renderer; - for (const auto& element : axis) renderer.add(element); - - std::vector polylines; - polylines.reserve(rail_renderer.rails().size()); - for (const auto& rail : rail_renderer.rails()) { - PlanPolyline poly; - poly.kind = static_cast(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> 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> fits(n == 0 ? 0 : n - 1); - for (const auto& gap : spec.fits) { - const int g = gap.gap; - if (g < 0 || static_cast(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& 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& solved) { - JunctionGeom geom; - if (junction.through < 0 || - static_cast(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 axis; - Pose pose = start; - for (const auto& segment : turnout.path) { - std::vector pts; - render::CentrelineElement element; - element.kind = - std::abs(segment.k1 - segment.k0) >= 1e-12 ? render::ElementKind::Transition - : std::abs(segment.k0) >= 1e-12 ? render::ElementKind::Arc - : render::ElementKind::Straight; - // 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 solve_project(const std::vector& niwelety) { - std::vector result; - result.reserve(niwelety.size()); - for (const auto& spec : niwelety) result.push_back(build(spec)); - return result; -} - -LayoutSolution solve_layout(std::vector niwelety, - const std::vector& junctions) { - // Pass 1: solve unpinned, to read the through centrelines the frogs sit on. - auto solved = solve_project(niwelety); - - // 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(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 diff --git a/maj0sted/src/editor/layout.cpp b/maj0sted/src/editor/layout.cpp new file mode 100644 index 00000000..34249647 --- /dev/null +++ b/maj0sted/src/editor/layout.cpp @@ -0,0 +1,706 @@ +#include "maj0sted/editor/layout.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "maj0sted/domain/geometry/turnout.hpp" +#include "maj0sted/editor/chain.hpp" +#include "maj0sted/editor/sketch.hpp" + +namespace maj0sted::editor { +namespace { + +constexpr double kEps = 1e-9; + +using domain::geometry::Pose; + +std::string num(double value, int decimals = 1) { + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%.*f", decimals, value); + return buffer; +} + +// --------------------------------------------------------------------------- +// The dependency graph +// --------------------------------------------------------------------------- + +enum class NodeKind { Turnout, Track }; + +struct Node { + NodeKind kind{NodeKind::Turnout}; + std::size_t index{0}; + std::vector depends_on; +}; + +/// Everything a track needs laid before it can be laid itself: the turnout a +/// branch hangs off, and every track it holds an element parallel to — however +/// deep either goes. A hold that runs in a circle is caught as a cycle, the same +/// as anything else. +void track_dependencies(const Document& document, const Track& track, + const std::vector& turnout_node, + const std::vector& track_node, Node& node) { + if (const auto* at_port = std::get_if(&track.anchor)) { + for (std::size_t i = 0; i < document.turnouts.size(); ++i) { + if (document.turnouts[i].id == at_port->turnout) { + node.depends_on.push_back(turnout_node[i]); + break; + } + } + } + for (const auto& element : track.elements) { + const auto* parallel = std::get_if(&element.hold); + if (parallel == nullptr) { + continue; + } + for (std::size_t i = 0; i < document.tracks.size(); ++i) { + if (document.tracks[i].id == track.id) { + continue; + } + for (const auto& candidate : document.tracks[i].elements) { + if (candidate.id == parallel->ref) { + node.depends_on.push_back(track_node[i]); + break; + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Holds: parallelism between elements +// --------------------------------------------------------------------------- + +/// Where a held element has to be, and what shape it has to be, to lie at the +/// offset asked for. Everything is worked out from the reference as solved, so +/// this is geometry and not a guess. +struct Held { + bool ok{false}; + Kind kind{Kind::Line}; + double radius{0.0}; + int hand{0}; + Pose target{}; ///< the pose the held element must start on + double actual{0.0}; ///< the offset that came out, for a track that cannot move + std::string why; ///< why not, when !ok +}; + +/// The offset curve of @p reference at @p offset, taken where @p current is now. +/// +/// Across the reference the answer is forced; along it, nothing is: the point +/// nearest to where the element already sits is the one taken, so holding a +/// track parallel never slides it sideways along the reference. +Held hold_against(const SolvedElement& reference, double offset, const Pose& current) { + Held held; + + if (std::abs(reference.k1 - reference.k0) > kEps) { + held.why = + "równoległości nie da się trzymać do krzywej przejściowej — odsunięcie " + "krzywej przejściowej nie jest krzywą przejściową"; + return held; + } + + const Pose& base = reference.start; + // Which way this element runs along the reference. The offset is always + // measured to the left of the *reference's* travel, whichever way this one + // goes, so a track laid against the run of another still sits where it looks. + const double sense = (current.hx * base.hx + current.hy * base.hy) >= 0.0 ? 1.0 : -1.0; + const double nx = -base.hy; + const double ny = base.hx; + const double k = reference.k0; + + if (std::abs(k) < kEps) { + const double px = base.x + nx * offset; + const double py = base.y + ny * offset; + const double hx = sense * base.hx; + const double hy = sense * base.hy; + const double along = (current.x - px) * hx + (current.y - py) * hy; + held.ok = true; + held.kind = Kind::Line; + held.target = Pose{px + hx * along, py + hy * along, hx, hy}; + held.actual = (current.x - base.x) * nx + (current.y - base.y) * ny; + return held; + } + + // Concentric with the reference: same centre, radius R∓d. + const double radius = 1.0 / std::abs(k); + const double cx = base.x + nx / k; + const double cy = base.y + ny / k; + const double curvature = k / (1.0 - offset * k); + if (!std::isfinite(curvature) || std::abs(1.0 - offset * k) < kEps) { + held.why = "odstęp " + num(offset, 2) + " m trafia w środek łuku R=" + + num(radius) + " m"; + return held; + } + + double ux = current.x - cx; + double uy = current.y - cy; + const double reach = std::hypot(ux, uy); + if (reach < kEps) { + held.why = "odcinek zaczyna się w środku łuku, do którego ma trzymać " + "równoległość"; + return held; + } + ux /= reach; + uy /= reach; + + const double held_radius = 1.0 / std::abs(curvature); + const double turn = curvature > 0.0 ? 1.0 : -1.0; + // The centre lies to the left of travel for a left-hand curve, so the + // heading follows from where the point sits on the circle. + const double hx = sense * turn * uy; + const double hy = sense * turn * -ux; + + held.ok = true; + held.kind = Kind::Arc; + held.radius = held_radius; + held.hand = (sense * turn) > 0.0 ? 1 : -1; + held.target = Pose{cx + ux * held_radius, cy + uy * held_radius, hx, hy}; + // What offset this element actually keeps, as the drawing stands. + held.actual = (k > 0.0 ? 1.0 : -1.0) * (radius - reach); + return held; +} + +/// Moves a whole chain, as one rigid piece, so that the pose @p from becomes the +/// pose @p to. Returns the anchor pose to lay it from again. +Pose rigid_move(const Pose& anchor, const Pose& from, const Pose& to) { + const double cos_t = from.hx * to.hx + from.hy * to.hy; + const double sin_t = from.hx * to.hy - from.hy * to.hx; + const double dx = anchor.x - from.x; + const double dy = anchor.y - from.y; + return Pose{to.x + dx * cos_t - dy * sin_t, to.y + dx * sin_t + dy * cos_t, + anchor.hx * cos_t - anchor.hy * sin_t, + anchor.hx * sin_t + anchor.hy * cos_t}; +} + +// --------------------------------------------------------------------------- +// Turnouts +// --------------------------------------------------------------------------- + +domain::TurnoutPart part_of(int part) { + switch (part) { + case 0: return domain::TurnoutPart::PreBlade; + case 1: return domain::TurnoutPart::Blade; + case 3: return domain::TurnoutPart::FrogRail; + default: return domain::TurnoutPart::Curve; + } +} + +int mark_kind_for(domain::TurnoutPart part) { + switch (part) { + case domain::TurnoutPart::PreBlade: return 0; // PR + case domain::TurnoutPart::Blade: return 1; // ostrze iglicy + case domain::TurnoutPart::Curve: return 2; // pięta iglicy + case domain::TurnoutPart::FrogRail: return 4; // koniec krzywej + } + return 3; +} + +} // namespace + +// --------------------------------------------------------------------------- + +domain::Turnout to_domain(const TurnoutType& type, int hand) { + domain::Turnout turnout{ + domain::CrossingMark{type.crossing_n}, + hand >= 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right, + {}, + type.length, + domain::Blade{type.blade.tip_thickness, type.blade.nose, type.blade.railtop_width}, + }; + turnout.path.reserve(type.pieces.size()); + for (const auto& piece : type.pieces) { + turnout.path.push_back(domain::TurnoutPiece{part_of(piece.part), piece.length, + piece.radius_start, piece.radius_end, + piece.turn_in}); + } + return turnout; +} + +std::vector turnout_marks(const domain::TurnoutGeometry& geometry, + const domain::geometry::Pose& start) { + std::vector marks; + if (!geometry.valid) { + return marks; + } + + // PR stands whatever the path starts with, so it is said once here + marks.push_back(TurnoutMark{start.x, start.y, 0, 0.0}); + + domain::geometry::Pose walker = start; + double station = 0.0; + domain::TurnoutPart previous = domain::TurnoutPart::PreBlade; + bool first = true; + for (const auto& segment : geometry.path) { + if (std::abs(segment.turn_in) > kEps) { + const double cos_t = std::cos(segment.turn_in); + const double sin_t = std::sin(segment.turn_in); + walker = domain::geometry::Pose{walker.x, walker.y, + walker.hx * cos_t - walker.hy * sin_t, + walker.hx * sin_t + walker.hy * cos_t}; + } + if ((first || segment.part != previous) && + !(first && segment.part == domain::TurnoutPart::PreBlade)) { + marks.push_back( + TurnoutMark{walker.x, walker.y, mark_kind_for(segment.part), station}); + } + previous = segment.part; + first = false; + + walker = domain::geometry::layout_segment(segment.k0, segment.k1, segment.length, + walker, nullptr); + station += segment.length; + } + + marks.push_back(TurnoutMark{geometry.frog.x, geometry.frog.y, 5, station}); + marks.push_back(TurnoutMark{start.x + start.hx * geometry.tangent_front, + start.y + start.hy * geometry.tangent_front, 6, 0.0}); + // PR is on the through track already; this is where the switch ends on it, so the + // straight carries both ends of the rozjazd + marks.push_back(TurnoutMark{geometry.through_end.x, geometry.through_end.y, 9, + geometry.through_length}); + // the ostrze itself is marked with the blade piece; what the drawing dimensions on + // top of it is where the planed nose ends (A, the rail u thick) and where the + // planing does + if (geometry.has_blade && geometry.nose_end > geometry.blade_station) { + marks.push_back(TurnoutMark{geometry.blade_nose_end.x, geometry.blade_nose_end.y, + 7, geometry.nose_end}); + } + if (geometry.has_blade && geometry.planing_length > 0.0) { + marks.push_back(TurnoutMark{geometry.blade_planed.x, geometry.blade_planed.y, 8, + geometry.planing_end}); + } + return marks; +} + +// --------------------------------------------------------------------------- + +Solution solve(const Document& document) { + Solution solution; + + const std::size_t turnout_count = document.turnouts.size(); + const std::size_t track_count = document.tracks.size(); + + std::vector nodes; + nodes.reserve(turnout_count + track_count); + std::vector turnout_node(turnout_count, 0); + std::vector track_node(track_count, 0); + + for (std::size_t i = 0; i < turnout_count; ++i) { + turnout_node[i] = nodes.size(); + nodes.push_back(Node{NodeKind::Turnout, i, {}}); + } + for (std::size_t i = 0; i < track_count; ++i) { + track_node[i] = nodes.size(); + nodes.push_back(Node{NodeKind::Track, i, {}}); + } + + // Edges. + for (std::size_t i = 0; i < turnout_count; ++i) { + for (std::size_t k = 0; k < track_count; ++k) { + if (document.tracks[k].id == document.turnouts[i].on) { + nodes[turnout_node[i]].depends_on.push_back(track_node[k]); + break; + } + } + } + for (std::size_t i = 0; i < track_count; ++i) { + track_dependencies(document, document.tracks[i], turnout_node, track_node, + nodes[track_node[i]]); + } + + // Topological order, Kahn's algorithm. + std::vector remaining(nodes.size(), 0); + std::vector> dependents(nodes.size()); + for (std::size_t i = 0; i < nodes.size(); ++i) { + remaining[i] = nodes[i].depends_on.size(); + for (const std::size_t dependency : nodes[i].depends_on) { + dependents[dependency].push_back(i); + } + } + std::vector order; + order.reserve(nodes.size()); + for (std::size_t i = 0; i < nodes.size(); ++i) { + if (remaining[i] == 0) { + order.push_back(i); + } + } + for (std::size_t head = 0; head < order.size(); ++head) { + for (const std::size_t next : dependents[order[head]]) { + if (--remaining[next] == 0) { + order.push_back(next); + } + } + } + + // Whatever never came free is in a cycle. + if (order.size() != nodes.size()) { + for (std::size_t i = 0; i < nodes.size(); ++i) { + if (remaining[i] == 0) { + continue; + } + Diagnostic diagnostic; + diagnostic.code = Code::CyclicDependency; + switch (nodes[i].kind) { + case NodeKind::Track: + diagnostic.track = document.tracks[nodes[i].index].id; + diagnostic.text = "tor „" + document.tracks[nodes[i].index].name + + "” zależy sam od siebie w kółko"; + break; + case NodeKind::Turnout: + diagnostic.turnout = document.turnouts[nodes[i].index].id; + diagnostic.text = "rozjazd zależy sam od siebie w kółko"; + break; + } + solution.diagnostics.push_back(std::move(diagnostic)); + } + } + + // ---- walk the order --------------------------------------------------- + for (const std::size_t index : order) { + const Node& node = nodes[index]; + switch (node.kind) { + case NodeKind::Turnout: { + const TurnoutPlacement& placement = document.turnouts[node.index]; + SolvedTurnout solved; + solved.id = placement.id; + + const TurnoutType* type = find_type(document, placement.type); + if (type == nullptr) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.turnout = placement.id; + diagnostic.text = "rozjazd odwołuje się do typu „" + placement.type + + "”, którego nie ma w katalogu"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + + const SolvedTrack* through = find_track(solution, placement.on); + Pose at{}; + if (through == nullptr || !pose_at(*through, placement.station, at)) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = "rozjazd stoi na torze, którego nie da się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + // łukowanie: the turnout is bent onto whatever the track does under + // it, unless it was asked to carry a bend of its own. Read the room + // here too — both answers come off the same element + double curvature = 0.0; + double behind = 0.0; + double ahead = 0.0; + const bool steady = steady_room(*through, placement.station, curvature, behind, + ahead); + double bend = placement.bend_from_track ? (steady ? curvature : 0.0) + : placement.bend; + + if (!placement.facing) { + at = Pose{at.x, at.y, -at.hx, -at.hy}; + // laid against the way the track runs, a left curve is a right one + bend = -bend; + } + + const domain::TurnoutGeometry geometry = + domain::lay_turnout(at, to_domain(*type, placement.hand), bend); + if (!geometry.valid) { + Diagnostic diagnostic; + diagnostic.code = Code::TurnoutInvalid; + diagnostic.turnout = placement.id; + diagnostic.text = + bend != 0.0 + ? "typu „" + type->name + "” nie da się wygiąć na łuk R " + + num(std::abs(1.0 / bend)) + + " m: łukowanie wymaga jednego promienia i szyn łączących, " + "które je przyjmą" + : "typ „" + type->name + + "” nie daje się złożyć: sprawdź skos, promienie i iglicę"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + + // a turnout is one piece of geometry bent to the one radius under it, so it has to + // stand on a single element and fit on it whole, or the drawing says something the + // ground would not. facing turnouts eat the room ahead, trailing ones behind + if (!steady) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = + through->length > 0.0 && placement.station > through->length + 1e-6 + ? "rozjazd stoi " + num(placement.station - through->length) + + " m za końcem toru zasadniczego" + : "rozjazd stoi na krzywej przejściowej: nie ma jednego promienia, " + "na który dałoby się go wygiąć"; + solution.diagnostics.push_back(std::move(diagnostic)); + } else if (const double room = placement.facing ? ahead : behind; + geometry.through_length > room + 1e-6) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = "rozjazd wychodzi " + num(geometry.through_length - room) + + " m poza odcinek toru, na którym stoi: potrzebuje " + + num(geometry.through_length) + " m, a zostało " + num(room); + solution.diagnostics.push_back(std::move(diagnostic)); + } + + solved.valid = true; + solved.bend = geometry.bend; + solved.diverging_curvature = geometry.diverging_curvature; + solved.bend_angle = geometry.bend_angle; + solved.start = at; + solved.frog = geometry.frog; + solved.tangent_front = geometry.tangent_front; + solved.tangent_back = geometry.tangent_back; + solved.diverging_length = geometry.diverging_length; + solved.through_length = geometry.through_length; + solved.has_blade = geometry.has_blade; + solved.blade_station = geometry.blade_station; + solved.nose_end = geometry.nose_end; + solved.planing_length = geometry.planing_length; + solved.blade_angle = geometry.blade_angle; + solved.blade_residual = geometry.blade_residual; + solved.angle_residual = geometry.angle_residual; + solved.length_residual = geometry.length_residual; + + solved.marks = turnout_marks(geometry, at); + + Pose walker = at; + for (const auto& segment : geometry.path) { + if (std::abs(segment.turn_in) > kEps) { + const double cos_t = std::cos(segment.turn_in); + const double sin_t = std::sin(segment.turn_in); + walker = Pose{walker.x, walker.y, + walker.hx * cos_t - walker.hy * sin_t, + walker.hx * sin_t + walker.hy * cos_t}; + } + + SolvedTurnoutSegment out_segment; + out_segment.start = walker; + out_segment.k0 = segment.k0; + out_segment.k1 = segment.k1; + out_segment.length = segment.length; + std::vector points; + walker = domain::geometry::layout_segment(segment.k0, segment.k1, + segment.length, walker, + &points); + out_segment.part = segment.part; + out_segment.points.reserve(points.size()); + for (const auto& point : points) { + out_segment.points.push_back(PlanPoint{point.x, point.y}); + } + solved.path.push_back(std::move(out_segment)); + } + + // the list is laid as authored; whether it keeps the catalogue's + // promises is said out loud rather than fitted away + if (std::abs(geometry.angle_residual) > 1e-6 || + (type->length > 0.0 && std::abs(geometry.length_residual) > 1e-3)) { + Diagnostic diagnostic; + diagnostic.code = Code::TurnoutNotClosed; + diagnostic.turnout = placement.id; + diagnostic.text = + "typ „" + type->name + "” nie domyka się: kąt o " + + num(geometry.angle_residual * 180.0 / std::numbers::pi) + + " st., długość o " + num(geometry.length_residual) + " m"; + solution.diagnostics.push_back(std::move(diagnostic)); + } + + solution.turnouts.push_back(std::move(solved)); + break; + } + + case NodeKind::Track: { + const Track& track = document.tracks[node.index]; + Pose start{0.0, 0.0, 0.0, 1.0}; + bool anchored = true; + + if (const auto* pose = std::get_if(&track.anchor)) { + start = Pose{pose->x, pose->y, std::sin(pose->az), std::cos(pose->az)}; + } else if (const auto* at_port = std::get_if(&track.anchor)) { + const SolvedTurnout* turnout = find_turnout(solution, at_port->turnout); + if (turnout == nullptr || !turnout->valid) { + anchored = false; + } else { + start = at_port->port == Port::Frog ? turnout->frog : turnout->start; + } + } + + if (!anchored) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.track = track.id; + diagnostic.text = "tor „" + track.name + + "” jest zakotwiczony w czymś, czego nie ma " + "albo czego nie dało się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + SolvedTrack empty; + empty.id = track.id; + empty.complete = false; + solution.tracks.push_back(std::move(empty)); + break; + } + + // Laying is a walk, so a hold is resolved around it rather than + // inside it: lay once to see which way the held element runs, + // take its shape from the reference, lay again, and finally move + // the whole chain onto the offset it is held at. + const bool movable = std::holds_alternative(track.anchor); + Track resolved = track; + TrackOutcome outcome = lay_track(document, resolved, start); + + std::size_t held_index = resolved.elements.size(); + for (std::size_t e = 0; e < resolved.elements.size(); ++e) { + const auto* parallel = std::get_if(&resolved.elements[e].hold); + if (parallel == nullptr) { + continue; + } + const SolvedElement* reference = + find_element(solution, parallel->ref); + if (reference == nullptr || e >= outcome.track.elements.size()) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[e].id; + diagnostic.text = + "odcinek ma trzymać równoległość do czegoś, czego nie ma " + "albo czego nie dało się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + continue; + } + const Held held = hold_against(*reference, parallel->offset, + outcome.track.elements[e].start); + if (!held.ok) { + Diagnostic diagnostic; + diagnostic.code = Code::DegenerateElement; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[e].id; + diagnostic.text = held.why; + solution.diagnostics.push_back(std::move(diagnostic)); + continue; + } + resolved.elements[e].kind = held.kind; + resolved.elements[e].radius = held.radius; + resolved.elements[e].hand = held.hand; + if (held_index == resolved.elements.size()) { + held_index = e; + } else if (movable) { + Diagnostic diagnostic; + diagnostic.code = Code::HoldConflict; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[e].id; + diagnostic.text = + "tor „" + track.name + + "” trzyma równoległość dwoma odcinkami naraz; przystawiony " + "jest do pierwszego z nich"; + solution.diagnostics.push_back(std::move(diagnostic)); + } + } + + if (held_index < resolved.elements.size()) { + outcome = lay_track(document, resolved, start); + if (held_index < outcome.track.elements.size()) { + const auto* parallel = + std::get_if(&resolved.elements[held_index].hold); + const SolvedElement* reference = + find_element(solution, parallel->ref); + const Held held = + hold_against(*reference, parallel->offset, + outcome.track.elements[held_index].start); + if (held.ok && movable) { + const Pose moved = + rigid_move(start, outcome.track.elements[held_index].start, + held.target); + outcome = lay_track(document, resolved, moved); + } else if (held.ok) { + // A track pinned to a turnout's port cannot be moved + // onto the offset, so the corner between the port and + // the held element has to take up the difference — + // the same fit as pulling a straight about by hand. + std::string why; + Skeleton skeleton; + Track fitted = resolved; + bool done = false; + if (resolved.elements[held_index].kind == Kind::Line && + skeleton_of(resolved, outcome.track, skeleton, why)) { + std::size_t line_index = 0; + for (std::size_t e = 0; e < held_index; ++e) { + if (resolved.elements[e].kind == Kind::Line) { + ++line_index; + } + } + if (line_index == 0) { + why = "przed odcinkiem trzymającym równoległość nie ma " + "łuku, który mógłby na nią wyprowadzić"; + } else { + skeleton.lines[line_index] = + Support{held.target.x, held.target.y, held.target.hx, + held.target.hy}; + if (line_index + 1 == skeleton.lines.size()) { + skeleton.end_x = held.target.x; + skeleton.end_y = held.target.y; + } + done = lay_along_skeleton(fitted, skeleton, why); + } + } + if (done) { + resolved = std::move(fitted); + outcome = lay_track(document, resolved, start); + } else if (std::abs(held.actual - parallel->offset) > 1e-3) { + Diagnostic diagnostic; + diagnostic.code = Code::OffsetNotHeld; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[held_index].id; + diagnostic.text = + "tor „" + track.name + + "” jest przypięty do rozjazdu i nie da się go dopasować (" + + why + "): odstęp wychodzi " + num(held.actual, 2) + + " m zamiast " + num(parallel->offset, 2) + " m"; + solution.diagnostics.push_back(std::move(diagnostic)); + } + } + } + } + + for (auto& diagnostic : outcome.diagnostics) { + solution.diagnostics.push_back(std::move(diagnostic)); + } + solution.tracks.push_back(std::move(outcome.track)); + break; + } + } + } + + // Anything left out by a cycle still gets an entry, so the host can index by + // id without checking for holes. + for (const auto& track : document.tracks) { + if (find_track(solution, track.id) == nullptr) { + SolvedTrack empty; + empty.id = track.id; + empty.complete = false; + solution.tracks.push_back(std::move(empty)); + } + } + for (const auto& turnout : document.turnouts) { + if (find_turnout(solution, turnout.id) == nullptr) { + SolvedTurnout empty; + empty.id = turnout.id; + solution.turnouts.push_back(std::move(empty)); + } + } + + return solution; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/model.cpp b/maj0sted/src/editor/model.cpp new file mode 100644 index 00000000..8550237e --- /dev/null +++ b/maj0sted/src/editor/model.cpp @@ -0,0 +1,74 @@ +#include "maj0sted/editor/model.hpp" + +namespace maj0sted::editor { +namespace { + +template +auto* lookup(Container& items, Id id) { + if (id == Id::none) { + return decltype(&items[0]){nullptr}; + } + for (auto& item : items) { + if (item.id == id) { + return &item; + } + } + return decltype(&items[0]){nullptr}; +} + +} // namespace + +ElementId mint_element(Document& document) { return static_cast(document.next_id++); } +TrackId mint_track(Document& document) { return static_cast(document.next_id++); } +TurnoutId mint_turnout(Document& document) { return static_cast(document.next_id++); } + +const Track* find_track(const Document& document, TrackId id) { + return lookup(document.tracks, id); +} +Track* find_track(Document& document, TrackId id) { return lookup(document.tracks, id); } + +const TurnoutPlacement* find_turnout(const Document& document, TurnoutId id) { + return lookup(document.turnouts, id); +} +TurnoutPlacement* find_turnout(Document& document, TurnoutId id) { + return lookup(document.turnouts, id); +} + +const TurnoutType* find_type(const Document& document, const std::string& name) { + for (const auto& type : document.turnout_types) { + if (type.name == name) { + return &type; + } + } + return nullptr; +} + +const Element* find_element(const Document& document, ElementId id) { + if (id == ElementId::none) { + return nullptr; + } + for (const auto& track : document.tracks) { + for (const auto& element : track.elements) { + if (element.id == id) { + return &element; + } + } + } + return nullptr; +} + +const Track* find_element_track(const Document& document, ElementId id) { + if (id == ElementId::none) { + return nullptr; + } + for (const auto& track : document.tracks) { + for (const auto& element : track.elements) { + if (element.id == id) { + return &track; + } + } + } + return nullptr; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/render.cpp b/maj0sted/src/editor/render.cpp new file mode 100644 index 00000000..58bf6c27 --- /dev/null +++ b/maj0sted/src/editor/render.cpp @@ -0,0 +1,122 @@ +#include "maj0sted/editor/render.hpp" + +#include +#include + +#include "maj0sted/render/rail_renderer.hpp" + +namespace maj0sted::editor { +namespace { + +render::ElementKind kind_of(Kind kind) { + switch (kind) { + case Kind::Line: return render::ElementKind::Straight; + case Kind::Arc: return render::ElementKind::Arc; + case Kind::Clothoid: return render::ElementKind::Transition; + } + return render::ElementKind::Straight; +} + +Kind kind_from(render::ElementKind kind) { + switch (kind) { + case render::ElementKind::Straight: return Kind::Line; + case render::ElementKind::Arc: return Kind::Arc; + case render::ElementKind::Transition: return Kind::Clothoid; + } + return Kind::Line; +} + +double radius_of(double curvature) { + return std::abs(curvature) > 1e-9 ? 1.0 / std::abs(curvature) : 0.0; +} + +render::CentrelineElement centreline_of(Kind kind, double k0, double k1, double length, + const std::vector& points, + std::uint32_t element_id, std::uint32_t owner_id, + bool from_turnout) { + render::CentrelineElement element; + element.kind = kind_of(kind); + element.element_id = element_id; + element.owner_id = owner_id; + element.from_turnout = from_turnout; + element.length = length; + element.radius_start = radius_of(k0); + element.radius_end = radius_of(k1); + element.points.reserve(points.size()); + for (const auto& point : points) { + element.points.push_back(render::Point{point.x, point.y}); + } + return element; +} + +Kind kind_of_segment(double k0, double k1) { + if (std::abs(k0) < 1e-12 && std::abs(k1) < 1e-12) { + return Kind::Line; + } + return std::abs(k1 - k0) < 1e-12 ? Kind::Arc : Kind::Clothoid; +} + +} // namespace + +std::vector render_rails(const Solution& solution, double gauge_metres) { + render::RailRenderer rail_renderer{gauge_metres}; + render::TrackRenderer& renderer = rail_renderer; + + // Owner ids are recorded alongside so a rail can be traced back without + // widening the render layer's vocabulary. + for (const auto& track : solution.tracks) { + for (const auto& element : track.elements) { + if (element.points.size() < 2) { + continue; + } + renderer.add(centreline_of(element.kind, element.k0, element.k1, element.length, + element.points, + static_cast(element.id), + static_cast(track.id), false)); + } + } + for (const auto& turnout : solution.turnouts) { + if (!turnout.valid) { + continue; + } + std::uint32_t index = 0; + for (const auto& segment : turnout.path) { + if (segment.points.size() < 2) { + ++index; + continue; + } + renderer.add(centreline_of(kind_of_segment(segment.k0, segment.k1), segment.k0, + segment.k1, segment.length, segment.points, index++, + static_cast(turnout.id), true)); + } + } + + std::vector out; + out.reserve(rail_renderer.rails().size()); + for (const auto& rail : rail_renderer.rails()) { + PlanPolyline polyline; + polyline.kind = kind_from(rail.kind); + polyline.length = rail.length; + polyline.radius_start = rail.radius_start; + polyline.radius_end = rail.radius_end; + if (rail.from_turnout) { + polyline.turnout = static_cast(rail.owner_id); + polyline.piece = rail.element_id; + const SolvedTurnout* owner = find_turnout(solution, polyline.turnout); + if (owner != nullptr && polyline.piece < owner->path.size()) { + polyline.part = static_cast(owner->path[polyline.piece].part); + } + } else { + polyline.element = static_cast(rail.element_id); + polyline.track = static_cast(rail.owner_id); + } + polyline.points.reserve(rail.points.size()); + for (const auto& point : rail.points) { + polyline.points.push_back(PlanPoint{point.x, point.y}); + } + out.push_back(std::move(polyline)); + } + return out; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/ribbon.cpp b/maj0sted/src/editor/ribbon.cpp deleted file mode 100644 index e6db4c1a..00000000 --- a/maj0sted/src/editor/ribbon.cpp +++ /dev/null @@ -1,55 +0,0 @@ -#include "maj0sted/editor/ribbon.hpp" - -#include - -#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 solve_ribbon(const RibbonRequest& request) { - std::vector result; - result.reserve(request.elements.size()); - - Pose pose{request.x0, request.y0, std::sin(request.az0), std::cos(request.az0)}; - - 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 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 diff --git a/maj0sted/src/editor/sketch.cpp b/maj0sted/src/editor/sketch.cpp new file mode 100644 index 00000000..9e567891 --- /dev/null +++ b/maj0sted/src/editor/sketch.cpp @@ -0,0 +1,481 @@ +#include "maj0sted/editor/sketch.hpp" + +#include +#include +#include +#include +#include + +namespace maj0sted::editor { +namespace { +constexpr double kEps = 1e-9; +} // namespace + +using domain::geometry::Pose; + + +Corner fit_corner(double in_hx, double in_hy, double out_hx, double out_hy, double radius) { + Corner corner; + if (radius <= kEps) { + return corner; + } + const double in_length = std::hypot(in_hx, in_hy); + const double out_length = std::hypot(out_hx, out_hy); + if (in_length < kEps || out_length < kEps) { + return corner; + } + in_hx /= in_length; + in_hy /= in_length; + out_hx /= out_length; + out_hy /= out_length; + + // On the short branch: a corner never turns further than it actually bends. + const double deflection = std::atan2(in_hx * out_hy - in_hy * out_hx, + in_hx * out_hx + in_hy * out_hy); + const double turn = std::abs(deflection); + if (turn < 1e-6 || turn > std::numbers::pi - 1e-6) { + return corner; + } + + corner.ok = true; + corner.deflection = deflection; + corner.hand = deflection > 0.0 ? 1 : -1; + corner.tangent = radius * std::tan(0.5 * turn); + corner.arc_length = radius * turn; + return corner; +} + +Element make_arc(Document& document, double radius, int hand, double length) { + Element element; + element.id = mint_element(document); + element.kind = Kind::Arc; + element.radius = radius; + element.hand = hand >= 0 ? 1 : -1; + element.length = length; + return element; +} + +Element make_line(Document& document, double length) { + Element element; + element.id = mint_element(document); + element.kind = Kind::Line; + element.length = length; + return element; +} + +Element make_clothoid(Document& document, double end_radius, int hand, double length) { + Element element; + element.id = mint_element(document); + element.kind = Kind::Clothoid; + element.radius = end_radius; + element.hand = hand >= 0 ? 1 : -1; + element.length = length; + return element; +} + + +namespace { + +std::string metres(double value) { + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%.1f", value); + return buffer; +} + +/// Signed distance from a point to a line, positive to the left of travel. +double left_of(const Support& line, double x, double y) noexcept { + return -(x - line.px) * line.dy + (y - line.py) * line.dx; +} + +/// How far along a line a point sits. +double along(const Support& line, double x, double y) noexcept { + return (x - line.px) * line.dx + (y - line.py) * line.dy; +} + +} // namespace + +namespace { + +/// What sits between two straights: one or more arcs turning the same way, with +/// transition curves easing into them, between them and out of them. One arc is +/// the everyday corner; several of them are a basket curve, and nothing here +/// treats that as a special case. +struct Group { + std::vector elements; ///< in order, between the two straights + std::vector arcs; ///< the arcs among them + int hand{0}; +}; + +/// Signed curvature an element ends on, the way the chain reads it. +double end_curvature(const Element& element) noexcept { + if (element.kind == Kind::Line || element.radius <= kEps) { + return 0.0; + } + return static_cast(element.hand) / element.radius; +} + +/// Cuts a chain into straights and the corners between them, or says why it is +/// not one. A corner may hang off either end, where there is no second straight +/// to fit it against: that is a track that starts or finishes mid-curve, which is +/// ordinary and is simply carried along as authored. +bool parse_chain(const Track& track, std::vector& straights, + std::vector& groups, bool& has_head, std::string& why) { + straights.clear(); + groups.clear(); + has_head = false; + const std::size_t count = track.elements.size(); + if (count == 0) { + why = "tor jest pusty"; + return false; + } + + std::size_t i = 0; + while (i < count) { + if (track.elements[i].kind == Kind::Line) { + if (!straights.empty() && straights.back() + 1 == i) { + why = "dwie proste stykają się ze sobą — to jedna prosta"; + return false; + } + straights.push_back(i++); + continue; + } + + // A corner: everything up to the next straight, turning one way. + Group group; + const std::size_t first = i; + while (i < count && track.elements[i].kind != Kind::Line) { + if (track.elements[i].kind == Kind::Arc) { + if (track.elements[i].hand == 0) { + why = "łuk nie mówi, w którą stronę skręca"; + return false; + } + if (group.hand != 0 && track.elements[i].hand != group.hand) { + why = "łuki w jednym narożniku skręcają w różne strony — to nie jest " + "łuk koszowy, tylko esica, i nie ma jej co dopasowywać do dwóch " + "prostych"; + return false; + } + group.hand = track.elements[i].hand; + group.arcs.push_back(i); + } + group.elements.push_back(i++); + } + if (group.arcs.empty()) { + why = "krzywa przejściowa nie wchodzi w żaden łuk — nie ma tu narożnika"; + return false; + } + + // Every transition must ease to whatever it runs into: to the radius of + // the arc after it, or out into the straight after it. Anything else is + // a kink dressed up as an easement. + for (const std::size_t at : group.elements) { + if (track.elements[at].kind != Kind::Clothoid || at + 1 >= count) { + continue; + } + const Element& next = track.elements[at + 1]; + if (next.kind == Kind::Line) { + if (track.elements[at].radius > kEps) { + why = "krzywa przejściowa przed prostą musi wybiegać w prostą (R=0)"; + return false; + } + } else if (std::abs(track.elements[at].radius - next.radius) > 1e-6 || + track.elements[at].hand != next.hand) { + why = "krzywa przejściowa nie schodzi się z łukiem: kończy się na R=" + + metres(track.elements[at].radius) + " m, a łuk ma R=" + + metres(next.radius) + " m"; + return false; + } + } + + if (first == 0) { + // A corner before the first straight: it hangs off the anchor and is + // carried along, not fitted. + has_head = true; + } else if (i >= count) { + // And one after the last straight, which simply runs out. + break; + } + groups.push_back(std::move(group)); + } + + if (straights.empty()) { + why = "tor nie ma ani jednej prostej — nie ma na czym oprzeć dopasowania"; + return false; + } + return true; +} + +/// Lays a corner on its own, from the origin heading along +x, and reports where +/// it ends up. The arc lengths are given; everything else is as authored. +Pose lay_group(const Track& track, const Group& group, const std::vector& arc_length) { + Pose pose{0.0, 0.0, 1.0, 0.0}; + double k_prev = 0.0; + std::size_t arc_at = 0; + for (const std::size_t at : group.elements) { + const Element& element = track.elements[at]; + const double k1 = end_curvature(element); + const double k0 = element.kind == Kind::Clothoid ? k_prev : k1; + const double length = + element.kind == Kind::Arc ? arc_length[arc_at++] : element.length; + pose = domain::geometry::layout_segment(k0, k1, length, pose, nullptr); + k_prev = k1; + } + return pose; +} + +/// The arc lengths a corner comes out with, and where it puts the chain down. +struct Fitted { + std::vector arc_length; + double leaves{0.0}; ///< along the line in, where the straight gives way + double arrives{0.0}; ///< along the line out, where the straight picks up +}; + +/// Fits one corner between two lines. @p keep_at is where it sits when the lines +/// are parallel and nothing else decides. +bool fit_group(const Track& track, const Group& group, const Support& in, + const Support& out_line, double keep_at, std::size_t ordinal, Fitted& fitted, + std::string& why) { + const double side = group.hand >= 0 ? 1.0 : -1.0; + + // What the corner was drawn as: the transitions turn what they turn, the arcs + // turn the rest. Both are read in the hand's convention, so both come out + // positive for a corner going the way its arcs go. + double spent = 0.0; + double authored = 0.0; + double k_prev = 0.0; + for (const std::size_t at : group.elements) { + const Element& element = track.elements[at]; + const double k1 = end_curvature(element); + if (element.kind == Kind::Clothoid) { + spent += side * 0.5 * (k_prev + k1) * element.length; + } else { + if (element.radius <= kEps) { + why = "łuk w narożniku " + std::to_string(ordinal) + " nie ma promienia"; + return false; + } + authored += element.length / element.radius; + } + k_prev = k1; + } + const double as_drawn = spent + authored; + + // How far it has to turn now. The angle between two lines says that only to + // within a full circle, so the turn taken is the one nearest to what was + // drawn: a corner drawn at 240° stays about 240°, and one drawn at 10° stays + // about 10° instead of looping the long way round to 350°. + const double raw = std::atan2(in.dx * out_line.dy - in.dy * out_line.dx, + in.dx * out_line.dx + in.dy * out_line.dy); + double turn = side * raw; + turn += 2.0 * std::numbers::pi * + std::round((as_drawn - turn) / (2.0 * std::numbers::pi)); + if (turn <= kEps) { + why = "narożnik " + std::to_string(ordinal) + + " musiałby skręcać w drugą stronę niż jego łuki"; + return false; + } + + // The transitions are authored, so what they turn is theirs; the arcs share + // out what is left, keeping the proportions they were drawn with. A basket + // curve therefore stays a basket curve — the same curve, tighter or wider, + // not one arc quietly swallowing the others. + const double left_for_arcs = turn - spent; + if (left_for_arcs <= kEps) { + why = "krzywe przejściowe w narożniku " + std::to_string(ordinal) + + " zużywają cały zwrot — na łuki nie zostaje nic"; + return false; + } + + fitted.arc_length.clear(); + fitted.arc_length.reserve(group.arcs.size()); + for (const std::size_t at : group.arcs) { + const Element& arc = track.elements[at]; + const double share = authored > kEps ? (arc.length / arc.radius) / authored + : 1.0 / static_cast(group.arcs.size()); + fitted.arc_length.push_back(arc.radius * left_for_arcs * share); + } + + // The corner is rigid now, so all that is left is sliding it along the line it + // leaves until it lands on the line it joins. + const Pose local = lay_group(track, group, fitted.arc_length); + const double reach_x = local.x * in.dx - local.y * in.dy; + const double reach_y = local.x * in.dy + local.y * in.dx; + const double determinant = in.dx * out_line.dy - in.dy * out_line.dx; + + double at_line = 0.0; + if (std::abs(determinant) < 1e-12) { + // Parallel lines: the half circle, and the full one. Their spacing fixes + // the geometry outright and says nothing about where along them the + // corner sits, so it stays where it is. + at_line = keep_at; + const double miss = left_of(out_line, in.px + in.dx * at_line + reach_x, + in.py + in.dy * at_line + reach_y); + if (std::abs(miss) > 1e-6) { + why = "proste przy narożniku " + std::to_string(ordinal) + + " są równoległe, więc ich rozstaw wyznacza geometrię: tak dopasowany " + "narożnik mija prostą o " + metres(std::abs(miss)) + " m"; + return false; + } + } else { + at_line = left_of(out_line, in.px + reach_x, in.py + reach_y) / determinant; + } + + fitted.leaves = at_line; + fitted.arrives = along(out_line, in.px + in.dx * at_line + reach_x, + in.py + in.dy * at_line + reach_y); + return true; +} + +} // namespace + +void sync_joint(Track& track, std::size_t at) { + if (at >= track.elements.size()) { + return; + } + const Element& edited = track.elements[at]; + + if (edited.kind == Kind::Arc) { + // The transition that eases into this arc is the one before it: its end + // radius is this arc's radius. What comes after the arc eases into + // something else, and is none of this arc's business. + if (at == 0 || track.elements[at - 1].kind != Kind::Clothoid) { + return; + } + track.elements[at - 1].radius = edited.radius; + track.elements[at - 1].hand = edited.hand; + return; + } + + if (edited.kind == Kind::Clothoid && edited.radius > kEps) { + // A transition that ends curved ends on an arc, and that arc is it. + if (at + 1 >= track.elements.size() || + track.elements[at + 1].kind != Kind::Arc) { + return; + } + track.elements[at + 1].radius = edited.radius; + track.elements[at + 1].hand = edited.hand; + } +} + +bool skeleton_of(const Track& track, const SolvedTrack& solved, Skeleton& out, + std::string& why) { + out.lines.clear(); + if (solved.elements.size() != track.elements.size()) { + why = "tor nie jest położony"; + return false; + } + std::vector straights; + std::vector groups; + bool has_head = false; + if (!parse_chain(track, straights, groups, has_head, why)) { + return false; + } + for (const std::size_t i : straights) { + const Pose& start = solved.elements[i].start; + out.lines.push_back(Support{start.x, start.y, start.hx, start.hy}); + } + out.start_x = solved.elements[straights.front()].start.x; + out.start_y = solved.elements[straights.front()].start.y; + out.end_x = solved.elements[straights.back()].end.x; + out.end_y = solved.elements[straights.back()].end.y; + return true; +} + +bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why) { + std::vector straights; + std::vector groups; + bool has_head = false; + if (!parse_chain(track, straights, groups, has_head, why)) { + return false; + } + if (skeleton.lines.size() != straights.size()) { + why = "proste nie pasują do łańcucha"; + return false; + } + + const std::vector& lines = skeleton.lines; + // Where each corner leaves the line before it and lands on the line after it, + // as distances along those lines. Nothing is written until every one of them + // works out. + std::vector leaves(straights.size(), 0.0); + std::vector arrives(straights.size(), 0.0); + std::vector fitted(straights.size() > 0 ? straights.size() - 1 : 0); + + arrives[0] = along(lines[0], skeleton.start_x, skeleton.start_y); + leaves[straights.size() - 1] = + along(lines[straights.size() - 1], skeleton.end_x, skeleton.end_y); + + // The corners with a straight on either side are the ones that get fitted. A + // head or a tail corner has nothing on its far side, so it keeps what it was + // given and the chain simply walks through it. + const std::size_t first_interior = has_head ? 1u : 0u; + for (std::size_t i = 0; i + 1 < straights.size(); ++i) { + const Group& group = groups[first_interior + i]; + const double keep_at = arrives[i] + track.elements[straights[i]].length; + if (!fit_group(track, group, lines[i], lines[i + 1], keep_at, i + 1, fitted[i], why)) { + return false; + } + leaves[i] = fitted[i].leaves; + arrives[i + 1] = fitted[i].arrives; + } + + std::vector straight_length(straights.size(), 0.0); + for (std::size_t i = 0; i < straights.size(); ++i) { + const double left = leaves[i] - arrives[i]; + if (left <= kEps) { + why = "narożniki zachodzą na siebie na prostej " + std::to_string(i + 1) + + ": brakuje " + metres(-left) + " m"; + return false; + } + straight_length[i] = left; + } + + // Where the chain has to start for all of that to stand where it stands. With + // a corner hanging off the front, that is worked out by laying it and putting + // its far end on the first straight. + const Pose head_end{lines[0].px + lines[0].dx * arrives[0], + lines[0].py + lines[0].dy * arrives[0], lines[0].dx, lines[0].dy}; + Pose begins = head_end; + if (has_head) { + std::vector as_authored; + for (const std::size_t at : groups.front().arcs) { + as_authored.push_back(track.elements[at].length); + } + const Pose local = lay_group(track, groups.front(), as_authored); + // Undo the corner: rotate its local end onto the pose it has to reach, + // and carry its local start along. + const double cos_t = local.hx * head_end.hx + local.hy * head_end.hy; + const double sin_t = local.hx * head_end.hy - local.hy * head_end.hx; + // the corner's local start heads along +x, so turning it by the same angle + // gives the heading the chain has to set off on + begins = Pose{head_end.x - (local.x * cos_t - local.y * sin_t), + head_end.y - (local.x * sin_t + local.y * cos_t), + cos_t, sin_t}; + } + + if (std::holds_alternative(track.anchor)) { + track.anchor = AtPose{begins.x, begins.y, std::atan2(begins.hx, begins.hy)}; + } else if (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 diff --git a/maj0sted/src/editor/solution.cpp b/maj0sted/src/editor/solution.cpp new file mode 100644 index 00000000..583b4185 --- /dev/null +++ b/maj0sted/src/editor/solution.cpp @@ -0,0 +1,118 @@ +#include "maj0sted/editor/solution.hpp" + +#include +#include + +namespace maj0sted::editor { + +const char* code_name(Code code) { + switch (code) { + case Code::UnknownReference: return "UnknownReference"; + case Code::CyclicDependency: return "CyclicDependency"; + case Code::DegenerateElement: return "DegenerateElement"; + case Code::TurnoutInvalid: return "TurnoutInvalid"; + case Code::TurnoutNotClosed: return "TurnoutNotClosed"; + case Code::StationOffTrack: return "StationOffTrack"; + case Code::TouchingStraights: return "TouchingStraights"; + case Code::HoldConflict: return "HoldConflict"; + case Code::OffsetNotHeld: return "OffsetNotHeld"; + } + return "Unknown"; +} + +const SolvedTrack* find_track(const Solution& solution, TrackId id) { + if (id == TrackId::none) { + return nullptr; + } + for (const auto& track : solution.tracks) { + if (track.id == id) { + return &track; + } + } + return nullptr; +} + +const SolvedTurnout* find_turnout(const Solution& solution, TurnoutId id) { + if (id == TurnoutId::none) { + return nullptr; + } + for (const auto& turnout : solution.turnouts) { + if (turnout.id == id) { + return &turnout; + } + } + return nullptr; +} + +const SolvedElement* find_element(const Solution& solution, ElementId id) { + if (id == ElementId::none) { + return nullptr; + } + for (const auto& track : solution.tracks) { + for (const auto& element : track.elements) { + if (element.id == id) { + return &element; + } + } + } + return nullptr; +} + +bool pose_at(const SolvedTrack& track, double station, domain::geometry::Pose& out) { + const auto& line = track.centreline; + if (line.empty()) { + return false; + } + if (line.size() == 1) { + out = domain::geometry::Pose{line[0].x, line[0].y, track.start.hx, track.start.hy}; + return true; + } + + double walked = 0.0; + for (std::size_t i = 1; i < line.size(); ++i) { + const double dx = line[i].x - line[i - 1].x; + const double dy = line[i].y - line[i - 1].y; + const double step = std::hypot(dx, dy); + if (step <= 0.0) { + continue; + } + if (station <= walked + step) { + const double t = std::clamp((station - walked) / step, 0.0, 1.0); + out = domain::geometry::Pose{line[i - 1].x + dx * t, line[i - 1].y + dy * t, + dx / step, dy / step}; + return true; + } + walked += step; + } + + out = track.end; + return true; +} + +bool steady_room(const SolvedTrack& track, double station, double& curvature, double& behind, + double& ahead) { + constexpr double kEps = 1e-6; + if (station < -kEps) { + return false; + } + double walked = 0.0; + for (std::size_t i = 0; i < track.elements.size(); ++i) { + const SolvedElement& element = track.elements[i]; + const double ends = walked + element.length; + // a station sitting exactly on a joint belongs to the element ahead of it + const bool last = i + 1 == track.elements.size(); + if (station < ends - kEps || (last && station <= ends + kEps)) { + if (std::abs(element.k1 - element.k0) > 1e-12) { + return false; // a clothoid: no one radius to bend a turnout to + } + curvature = element.k0; + behind = station - walked; + ahead = ends - station; + return true; + } + walked = ends; + } + return false; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/solve.cpp b/maj0sted/src/editor/solve.cpp deleted file mode 100644 index dfc05aeb..00000000 --- a/maj0sted/src/editor/solve.cpp +++ /dev/null @@ -1,98 +0,0 @@ -#include "maj0sted/editor/solve.hpp" - -#include - -#include "maj0sted/maj0sted.hpp" -#include "maj0sted/render/plan_sampler.hpp" - -namespace maj0sted::editor { - -using namespace maj0sted::domain; - -namespace { - -std::optional 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 to_polylines(const render::Scene& scene) { - std::vector result; - result.reserve(scene.polylines.size()); - for (const auto& polyline : scene.polylines) { - PlanPolyline out; - out.kind = static_cast(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 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 diff --git a/maj0sted/src/io/document_io.cpp b/maj0sted/src/io/document_io.cpp new file mode 100644 index 00000000..fa41c9aa --- /dev/null +++ b/maj0sted/src/io/document_io.cpp @@ -0,0 +1,544 @@ +#include "maj0sted/io/document_io.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace maj0sted::io { +namespace { + +using namespace maj0sted::editor; + +constexpr int kVersion = 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 tokens; + std::string_view tail; ///< everything after the first token, verbatim + + [[nodiscard]] std::string_view tag() const { + return tokens.empty() ? std::string_view{} : tokens.front(); + } + [[nodiscard]] std::size_t size() const { return tokens.size(); } +}; + +Line split(std::string_view text) { + Line line; + std::size_t i = 0; + bool first = true; + while (i < text.size()) { + while (i < text.size() && (text[i] == ' ' || text[i] == '\t')) { + ++i; + } + if (i >= text.size()) { + break; + } + if (!first && line.tail.empty()) { + line.tail = text.substr(i); + } + const std::size_t start = i; + while (i < text.size() && text[i] != ' ' && text[i] != '\t') { + ++i; + } + line.tokens.push_back(text.substr(start, i - start)); + first = false; + } + return line; +} + +bool to_double(std::string_view token, double& out) { + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out); + return error == std::errc{} && end == token.data() + token.size(); +} + +bool to_uint(std::string_view token, std::uint32_t& out) { + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out); + return error == std::errc{} && end == token.data() + token.size(); +} + +bool to_int(std::string_view token, int& out) { + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out); + return error == std::errc{} && end == token.data() + token.size(); +} + +/// A cursor over the file's lines, skipping blanks. +class Reader { +public: + explicit Reader(const std::string& text) { + std::istringstream stream{text}; + std::string raw; + while (std::getline(stream, raw)) { + if (!raw.empty() && raw.back() == '\r') { + raw.pop_back(); + } + storage_.push_back(std::move(raw)); + } + } + + /// The next non-blank line, or nothing at the end of the file. + std::optional next() { + while (at_ < storage_.size()) { + Line line = split(storage_[at_++]); + if (!line.tokens.empty()) { + return line; + } + } + return std::nullopt; + } + + /// The tag of the next non-blank line, without consuming it. Optional + /// records — an element's hold — are recognised this way. + std::string_view peek_tag() { + while (at_ < storage_.size()) { + const Line line = split(storage_[at_]); + if (!line.tokens.empty()) { + return line.tokens.front(); + } + ++at_; + } + return {}; + } + + /// The next line, required to carry @p tag and at least @p tokens tokens. + std::optional next(std::string_view tag, std::size_t tokens) { + const std::optional line = next(); + if (!line || line->tag() != tag || line->size() < tokens) { + return std::nullopt; + } + return line; + } + +private: + std::vector storage_; + std::size_t at_{0}; +}; + +/// Reads a version-3 turnout type — arcs plus the old fixed blade fields — and +/// converts it to a piece list. The old geometry closed itself: the last arc spent +/// whatever deflection was left to the crossing angle, and the frog rail ran on to +/// the catalogue length. Both are worked out here once, so an old plan opens as the +/// same shape and is saved in the new form. +bool read_legacy_type(Reader& reader, TurnoutType& type) { + const std::optional line = reader.next("type", 9); + double entry_transition = 0.0; + double exit_transition = 0.0; + double pre_blade = 0.0; + double blade_angle = 0.0; + double blade_length = 0.0; + std::uint32_t arc_count = 0; + if (!line || !to_double(line->tokens[1], type.crossing_n) || + !to_double(line->tokens[2], entry_transition) || + !to_double(line->tokens[3], exit_transition) || + !to_double(line->tokens[4], type.length) || + !to_double(line->tokens[5], pre_blade) || + !to_double(line->tokens[6], blade_angle) || + !to_double(line->tokens[7], blade_length) || !to_uint(line->tokens[8], arc_count)) { + return false; + } + + struct LegacyArc { + double radius{0.0}; + double length{0.0}; + double transition_to_next{0.0}; + }; + std::vector arcs; + for (std::uint32_t k = 0; k < arc_count; ++k) { + const std::optional arc_line = reader.next("tarc", 4); + LegacyArc arc; + if (!arc_line || !to_double(arc_line->tokens[1], arc.radius) || + !to_double(arc_line->tokens[2], arc.length) || + !to_double(arc_line->tokens[3], arc.transition_to_next)) { + return false; + } + arcs.push_back(arc); + } + if (arcs.empty() || arcs.front().radius <= 0.0 || arcs.back().radius <= 0.0) { + return false; + } + + const double alfa = type.crossing_n > 0.0 ? std::atan(1.0 / type.crossing_n) : 0.0; + // heading and through-projection are accumulated as the pieces are appended, so + // the closing arc and the frog rail can be given their old lengths + double heading = 0.0; + double projection = 0.0; // along the through track + double offset = 0.0; // away from it + const auto append = [&](int part, double length, double r0, double r1, double turn_in) { + if (length <= 0.0) { + return; + } + type.pieces.push_back(TurnoutPieceSpec{part, length, r0, r1, turn_in}); + heading += turn_in; + const double k0 = r0 > 0.0 ? 1.0 / r0 : 0.0; + const double k1 = r1 > 0.0 ? 1.0 / r1 : 0.0; + const double turned = 0.5 * (k0 + k1) * length; + // a straight projects whole; a turning piece projects its chord, which is + // shorter than its length by sin(theta/2)/(theta/2) and points at half the turn + const double half = 0.5 * turned; + const double chord = std::abs(half) < 1e-12 ? length : length * std::sin(half) / half; + projection += chord * std::cos(heading + half); + offset += chord * std::sin(heading + half); + heading += turned; + }; + + append(0, pre_blade, 0.0, 0.0, 0.0); + append(1, blade_length, 0.0, 0.0, blade_angle); + double pending_turn = blade_length > 0.0 ? 0.0 : blade_angle; + if (entry_transition > 0.0) { + append(2, entry_transition, 0.0, arcs.front().radius, pending_turn); + pending_turn = 0.0; + } + for (std::size_t k = 0; k + 1 < arcs.size(); ++k) { + append(2, arcs[k].length, arcs[k].radius, arcs[k].radius, pending_turn); + pending_turn = 0.0; + append(2, arcs[k].transition_to_next, arcs[k].radius, arcs[k + 1].radius, 0.0); + } + const double closing = alfa - heading - 0.5 * exit_transition / arcs.back().radius; + append(2, closing * arcs.back().radius, arcs.back().radius, arcs.back().radius, + pending_turn); + append(2, exit_transition, arcs.back().radius, 0.0, 0.0); + + const double rail = type.length > 0.0 && std::cos(alfa) > 1e-6 + ? (type.length - projection) / std::cos(alfa) + : arcs.back().radius * std::tan(alfa * 0.5); + append(3, rail, 0.0, 0.0, 0.0); + + // version 3 called the length the projection on the through track; version 4 uses + // the catalogue's own a + b, so it is restated here from where the frog ended up + if (std::abs(std::sin(alfa)) > 1e-9) { + const double back = offset / std::sin(alfa); + type.length = projection - back * std::cos(alfa) + back; + } + return !type.pieces.empty(); +} + +} // namespace + +// --------------------------------------------------------------------------- + +std::string serialize(const Document& document) { + std::string out; + out += "m0s " + std::to_string(kVersion) + "\n"; + out += "next " + num(document.next_id) + "\n"; + out += "view " + num(document.view_x) + " " + num(document.view_y) + " " + + num(document.view_extent) + "\n"; + out += "origin " + std::string(document.origin_set ? "1" : "0") + " " + + std::string(document.georeferenced ? "1" : "0") + " " + num(document.origin_x) + + " " + num(document.origin_y) + "\n"; + + out += "types " + std::to_string(document.turnout_types.size()) + "\n"; + for (const auto& type : document.turnout_types) { + out += "type " + num(type.crossing_n) + " " + num(type.length) + " " + + num(type.blade.tip_thickness) + " " + num(type.blade.nose) + " " + + num(type.blade.railtop_width) + " " + std::to_string(type.pieces.size()) + + "\n"; + for (const auto& piece : type.pieces) { + out += "tseg " + std::to_string(piece.part) + " " + num(piece.length) + " " + + num(piece.radius_start) + " " + num(piece.radius_end) + " " + + num(piece.turn_in) + "\n"; + } + out += "tname " + type.name + "\n"; + } + + out += "turnouts " + std::to_string(document.turnouts.size()) + "\n"; + for (const auto& turnout : document.turnouts) { + out += "turnout " + num(static_cast(turnout.id)) + " " + + num(static_cast(turnout.on)) + " " + num(turnout.station) + + " " + std::to_string(turnout.hand) + " " + + std::string(turnout.facing ? "1" : "0") + " " + + std::string(turnout.bend_from_track ? "1" : "0") + " " + num(turnout.bend) + + "\n"; + out += "tutype " + turnout.type + "\n"; + } + + out += "tracks " + std::to_string(document.tracks.size()) + "\n"; + for (const auto& track : document.tracks) { + out += "track " + num(static_cast(track.id)) + "\n"; + out += "tkname " + track.name + "\n"; + if (const auto* pose = std::get_if(&track.anchor)) { + out += "anchor pose " + num(pose->x) + " " + num(pose->y) + " " + num(pose->az) + + "\n"; + } else { + const auto& port = std::get(track.anchor); + out += "anchor port " + num(static_cast(port.turnout)) + " " + + std::string(port.port == Port::Frog ? "frog" : "start") + "\n"; + } + out += "elems " + std::to_string(track.elements.size()) + "\n"; + for (const auto& element : track.elements) { + const char* kind = element.kind == Kind::Line ? "line" + : element.kind == Kind::Arc ? "arc" + : "clot"; + out += "elem " + num(static_cast(element.id)) + " " + kind + " " + + num(element.radius) + " " + std::to_string(element.hand) + " " + + num(element.length) + "\n"; + if (const auto* parallel = std::get_if(&element.hold)) { + out += "epar " + num(static_cast(parallel->ref)) + " " + + num(parallel->offset) + "\n"; + } + } + } + return out; +} + +// --------------------------------------------------------------------------- + +std::optional deserialize(const std::string& text) { + Reader reader{text}; + Document document; + + int version = 0; + { + const std::optional header = reader.next("m0s", 2); + if (!header || !to_int(header->tokens[1], version) || + (version != kVersion && version != kStraightOnly && version != kNoseless && + version != kLegacyTurnoutVersion)) { + return std::nullopt; + } + } + { + const std::optional line = reader.next("next", 2); + if (!line || !to_uint(line->tokens[1], document.next_id)) { + return std::nullopt; + } + } + { + const std::optional line = reader.next("view", 4); + if (!line || !to_double(line->tokens[1], document.view_x) || + !to_double(line->tokens[2], document.view_y) || + !to_double(line->tokens[3], document.view_extent)) { + return std::nullopt; + } + } + { + const std::optional line = reader.next("origin", 5); + if (!line || !to_double(line->tokens[3], document.origin_x) || + !to_double(line->tokens[4], document.origin_y)) { + return std::nullopt; + } + document.origin_set = line->tokens[1] == "1"; + document.georeferenced = line->tokens[2] == "1"; + } + + const auto count_of = [&](std::string_view tag, std::size_t& out) { + const std::optional line = reader.next(tag, 2); + std::uint32_t value = 0; + if (!line || !to_uint(line->tokens[1], value)) { + return false; + } + out = value; + return true; + }; + + std::size_t count = 0; + + // ---- turnout types ---------------------------------------------------- + if (!count_of("types", count)) { + return std::nullopt; + } + for (std::size_t i = 0; i < count; ++i) { + TurnoutType type; + if (version == kLegacyTurnoutVersion) { + if (!read_legacy_type(reader, type)) { + return std::nullopt; + } + } else { + // the nose (d) came late: before it, a type line went straight from the tip + // thickness to the railhead width + const bool has_nose = version > kNoseless; + const std::optional line = reader.next("type", has_nose ? 7 : 6); + std::uint32_t piece_count = 0; + if (!line || !to_double(line->tokens[1], type.crossing_n) || + !to_double(line->tokens[2], type.length) || + !to_double(line->tokens[3], type.blade.tip_thickness) || + (has_nose && !to_double(line->tokens[4], type.blade.nose)) || + !to_double(line->tokens[has_nose ? 5 : 4], type.blade.railtop_width) || + !to_uint(line->tokens[has_nose ? 6 : 5], piece_count)) { + return std::nullopt; + } + for (std::uint32_t k = 0; k < piece_count; ++k) { + const std::optional piece_line = reader.next("tseg", 6); + TurnoutPieceSpec piece; + if (!piece_line || !to_int(piece_line->tokens[1], piece.part) || + !to_double(piece_line->tokens[2], piece.length) || + !to_double(piece_line->tokens[3], piece.radius_start) || + !to_double(piece_line->tokens[4], piece.radius_end) || + !to_double(piece_line->tokens[5], piece.turn_in)) { + return std::nullopt; + } + type.pieces.push_back(piece); + } + } + const std::optional name = reader.next("tname", 1); + if (!name) { + return std::nullopt; + } + type.name = std::string{name->tail}; + document.turnout_types.push_back(std::move(type)); + } + + // ---- turnouts --------------------------------------------------------- + if (!count_of("turnouts", count)) { + return std::nullopt; + } + for (std::size_t i = 0; i < count; ++i) { + // łukowanie came late: before it every placement stood on the track as it + // found it, which is what taking the bend from the track means anyway + const bool has_bend = version > kStraightOnly; + const std::optional line = reader.next("turnout", has_bend ? 8 : 6); + TurnoutPlacement placement; + std::uint32_t id = 0; + std::uint32_t on = 0; + if (!line || !to_uint(line->tokens[1], id) || !to_uint(line->tokens[2], on) || + !to_double(line->tokens[3], placement.station) || + !to_int(line->tokens[4], placement.hand)) { + return std::nullopt; + } + placement.id = static_cast(id); + placement.on = static_cast(on); + placement.facing = line->tokens[5] == "1"; + if (has_bend) { + placement.bend_from_track = line->tokens[6] == "1"; + if (!to_double(line->tokens[7], placement.bend)) { + return std::nullopt; + } + } + const std::optional type = reader.next("tutype", 1); + if (!type) { + return std::nullopt; + } + placement.type = std::string{type->tail}; + document.turnouts.push_back(std::move(placement)); + } + + // ---- tracks ----------------------------------------------------------- + if (!count_of("tracks", count)) { + return std::nullopt; + } + for (std::size_t i = 0; i < count; ++i) { + const std::optional head = reader.next("track", 2); + Track track; + std::uint32_t id = 0; + if (!head || !to_uint(head->tokens[1], id)) { + return std::nullopt; + } + track.id = static_cast(id); + + const std::optional name = reader.next("tkname", 1); + if (!name) { + return std::nullopt; + } + track.name = std::string{name->tail}; + + const std::optional anchor = reader.next("anchor", 2); + if (!anchor) { + return std::nullopt; + } + if (anchor->tokens[1] == "pose" && anchor->size() >= 5) { + AtPose pose; + if (!to_double(anchor->tokens[2], pose.x) || + !to_double(anchor->tokens[3], pose.y) || + !to_double(anchor->tokens[4], pose.az)) { + return std::nullopt; + } + track.anchor = pose; + } else if (anchor->tokens[1] == "port" && anchor->size() >= 4) { + std::uint32_t turnout_id = 0; + if (!to_uint(anchor->tokens[2], turnout_id)) { + return std::nullopt; + } + track.anchor = AtPort{static_cast(turnout_id), + anchor->tokens[3] == "start" ? Port::Start : Port::Frog}; + } else { + return std::nullopt; + } + + std::size_t element_count = 0; + if (!count_of("elems", element_count)) { + return std::nullopt; + } + for (std::size_t k = 0; k < element_count; ++k) { + const std::optional line = reader.next("elem", 6); + Element element; + std::uint32_t element_id = 0; + if (!line || !to_uint(line->tokens[1], element_id) || + !to_double(line->tokens[3], element.radius) || + !to_int(line->tokens[4], element.hand) || + !to_double(line->tokens[5], element.length)) { + return std::nullopt; + } + element.id = static_cast(element_id); + if (line->tokens[2] == "line") { + element.kind = Kind::Line; + } else if (line->tokens[2] == "arc") { + element.kind = Kind::Arc; + } else if (line->tokens[2] == "clot") { + element.kind = Kind::Clothoid; + } else { + return std::nullopt; + } + + // A hold, when there is one, follows its element immediately. + if (reader.peek_tag() == "epar") { + const std::optional hold = reader.next("epar", 3); + std::uint32_t ref = 0; + Parallel parallel; + if (!hold || !to_uint(hold->tokens[1], ref) || + !to_double(hold->tokens[2], parallel.offset)) { + return std::nullopt; + } + parallel.ref = static_cast(ref); + element.hold = parallel; + } + + track.elements.push_back(element); + } + document.tracks.push_back(std::move(track)); + } + + return document; +} + +// --------------------------------------------------------------------------- + +std::string default_project_path() { return "editor/plan.m0s"; } + +bool save(const Document& document, const std::string& path) { + std::ofstream file{path, std::ios::binary | std::ios::trunc}; + if (!file) { + return false; + } + const std::string text = serialize(document); + file.write(text.data(), static_cast(text.size())); + return static_cast(file); +} + +std::optional load(const std::string& path) { + std::ifstream file{path, std::ios::binary}; + if (!file) { + return std::nullopt; + } + std::ostringstream buffer; + buffer << file.rdbuf(); + return deserialize(buffer.str()); +} + +} // namespace maj0sted::io diff --git a/maj0sted/src/io/project_serializer.cpp b/maj0sted/src/io/project_serializer.cpp deleted file mode 100644 index f52ab1fe..00000000 --- a/maj0sted/src/io/project_serializer.cpp +++ /dev/null @@ -1,291 +0,0 @@ -#include "maj0sted/io/project_serializer.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "maj0sted/maj0sted.hpp" - -namespace maj0sted::io { - -using namespace maj0sted::domain; - -namespace { - -template -struct overloaded : Ts... { - using Ts::operator()...; -}; -template -overloaded(Ts...) -> overloaded; - -// --- 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) { - return length ? num(length->metres()) : std::string{"-"}; -} -std::string optional_metres(const std::optional& radius) { - return radius ? num(radius->metres()) : std::string{"-"}; -} - -// --- reading ------------------------------------------------------------- - -std::vector tokenize(const std::string& line) { - std::vector tokens; - std::size_t i = 0; - const std::size_t n = line.size(); - while (i < n) { - while (i < n && std::isspace(static_cast(line[i]))) ++i; - std::size_t j = i; - while (j < n && !std::isspace(static_cast(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 to_optional_length(const std::string& text) { - if (text == "-") return std::nullopt; - return Length::from_metres(to_double(text)); -} -std::optional 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 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(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& 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(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(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(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(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 diff --git a/maj0sted/src/io/scn_export.cpp b/maj0sted/src/io/scn_export.cpp index bd51bfb5..554b6e06 100644 --- a/maj0sted/src/io/scn_export.cpp +++ b/maj0sted/src/io/scn_export.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -9,15 +10,13 @@ namespace maj0sted::io { namespace { -using maj0sted::editor::NiweletaPolys; using maj0sted::editor::PlanPoint; -using maj0sted::editor::PlanPolyline; +using maj0sted::editor::SolvedElement; +using maj0sted::editor::SolvedTrack; struct AxisElement { int kind{0}; - int straight_index{-1}; - int gap{-1}; - int element_index{-1}; + std::uint32_t element_id{0}; double length{0.0}; double radius_start{0.0}; double radius_end{0.0}; @@ -30,49 +29,25 @@ struct WorldXYZ { double z{0.0}; }; -std::vector 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 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 out; - const auto& polys = solved.polylines; - for (std::size_t i = 0; i < polys.size();) { - const auto& a = polys[i]; - if (a.points.size() < 2) { - ++i; - continue; - } - if (i + 1 < polys.size()) { - const auto& b = polys[i + 1]; - if (b.straight_index == a.straight_index && b.gap == a.gap && - b.element_index == a.element_index && - b.points.size() == a.points.size()) { - AxisElement el; - el.kind = a.kind; - el.straight_index = a.straight_index; - el.gap = a.gap; - el.element_index = a.element_index; - el.length = a.length; - el.radius_start = a.radius_start; - el.radius_end = a.radius_end; - el.points.resize(a.points.size()); - for (std::size_t k = 0; k < a.points.size(); ++k) { - el.points[k] = {(a.points[k].x + b.points[k].x) * 0.5, - (a.points[k].y + b.points[k].y) * 0.5}; - } - out.push_back(std::move(el)); - i += 2; - continue; - } - } - AxisElement el; - el.kind = a.kind; - el.straight_index = a.straight_index; - el.gap = a.gap; - el.element_index = a.element_index; - el.length = a.length; - el.radius_start = a.radius_start; - el.radius_end = a.radius_end; - el.points = a.points; - out.push_back(std::move(el)); - ++i; + out.reserve(track.elements.size()); + for (const SolvedElement& element : track.elements) { + AxisElement axis; + axis.kind = element.kind == maj0sted::editor::Kind::Line ? 0 + : element.kind == maj0sted::editor::Kind::Arc ? 1 + : 2; + axis.element_id = static_cast(element.id); + axis.length = element.length; + axis.radius_start = radius_of(element.k0); + axis.radius_end = radius_of(element.k1); + axis.points = element.points; + out.push_back(std::move(axis)); } return out; } @@ -215,29 +190,29 @@ void write_scn_track(std::ostream& out, const std::string& name, const WorldXYZ& } // namespace -ScnExportOptions resolve_scn_origin(const std::vector& solved, +ScnExportOptions resolve_scn_origin(const maj0sted::editor::Solution& solution, ScnExportOptions options) { if (std::abs(options.origin_east) >= 1.0 || std::abs(options.origin_north) >= 1.0) { return options; } - for (const auto& s : solved) { - const auto axis = recover_axis(s); - if (!axis.empty() && !axis.front().points.empty()) { - const auto& p = axis.front().points.front(); - if (std::abs(p.x) > 10000.0 || std::abs(p.y) > 10000.0) { - options.origin_east = p.x; - options.origin_north = p.y; - } - break; + for (const auto& track : solution.tracks) { + if (track.elements.empty() || track.elements.front().points.empty()) { + continue; } + const auto& point = track.elements.front().points.front(); + if (std::abs(point.x) > 10000.0 || std::abs(point.y) > 10000.0) { + options.origin_east = point.x; + options.origin_north = point.y; + } + break; } return options; } -ScnExportResult export_scn(const std::vector& solved, +ScnExportResult export_scn(const maj0sted::editor::Solution& solution, const ScnExportOptions& options, std::ostream& out) { - const auto opt = resolve_scn_origin(solved, options); + const auto opt = resolve_scn_origin(solution, options); ScnExportResult result; result.origin_east = opt.origin_east; result.origin_north = opt.origin_north; @@ -248,8 +223,9 @@ ScnExportResult export_scn(const std::vector& solved, const double kRailY = opt.rail_y; const double kMaxArcAngle = opt.max_arc_angle; - for (std::size_t n = 0; n < solved.size(); ++n) { - const auto axis = recover_axis(solved[n]); + for (const auto& track : solution.tracks) { + const auto track_id = static_cast(track.id); + const auto axis = axis_of(track); for (std::size_t ei = 0; ei < axis.size(); ++ei) { const auto& el = axis[ei]; if (el.points.size() < 2) { @@ -315,13 +291,8 @@ ScnExportResult export_scn(const std::vector& solved, } std::ostringstream name; - name << "plan_n" << n; - if (el.straight_index >= 0) { - name << "_s" << el.straight_index; - } else { - name << "_g" << el.gap << "_e" << el.element_index; - } - name << '_' << tag << '_' << result.tracks; + name << "plan_t" << track_id << "_e" << el.element_id << '_' << tag + << '_' << result.tracks; const auto track_name = name.str(); if (result.first_track_name.empty()) { result.first_track_name = track_name; diff --git a/maj0sted/src/io/svg_writer.cpp b/maj0sted/src/io/svg_writer.cpp deleted file mode 100644 index 5a32b9e4..00000000 --- a/maj0sted/src/io/svg_writer.cpp +++ /dev/null @@ -1,63 +0,0 @@ -#include "maj0sted/io/svg_writer.hpp" - -#include -#include - -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 += "\n"; - out += "\n"; - - for (const auto& polyline : scene.polylines) { - out += std::string("\n"; - } - - out += "\n"; - return out; -} - -} // namespace maj0sted::io diff --git a/maj0sted/src/render/plan_sampler.cpp b/maj0sted/src/render/plan_sampler.cpp deleted file mode 100644 index f9687909..00000000 --- a/maj0sted/src/render/plan_sampler.cpp +++ /dev/null @@ -1,116 +0,0 @@ -#include "maj0sted/render/plan_sampler.hpp" - -#include -#include -#include - -#include "maj0sted/domain/geometry/segment_layout.hpp" -#include "maj0sted/maj0sted.hpp" - -namespace maj0sted::render { - -using namespace maj0sted::domain; - -namespace { - -template -struct overloaded : Ts... { - using Ts::operator()...; -}; -template -overloaded(Ts...) -> overloaded; - -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& out) { - geometry::Pose start{pos.east, pos.north, heading.east, heading.north}; - std::vector 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 diff --git a/maj0sted/src/render/rail_renderer.cpp b/maj0sted/src/render/rail_renderer.cpp index f1ece92b..7cf5e619 100644 --- a/maj0sted/src/render/rail_renderer.cpp +++ b/maj0sted/src/render/rail_renderer.cpp @@ -46,9 +46,9 @@ void RailRenderer::add(const CentrelineElement& element) { for (const double side : {half_gauge_, -half_gauge_}) { RailPolyline rail; rail.kind = element.kind; - rail.straight_index = element.straight_index; - rail.gap = element.gap; - rail.element_index = element.element_index; + rail.element_id = element.element_id; + rail.owner_id = element.owner_id; + rail.from_turnout = element.from_turnout; rail.length = element.length; rail.radius_start = element.radius_start; rail.radius_end = element.radius_end; diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt index 94e2b978..dabea4e8 100644 --- a/maj0sted/tests/CMakeLists.txt +++ b/maj0sted/tests/CMakeLists.txt @@ -1,54 +1,35 @@ -add_executable(maj0sted_test_map_project test_map_project.cpp) -target_link_libraries(maj0sted_test_map_project PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_map_project PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME map_project COMMAND maj0sted_test_map_project) +add_executable(maj0sted_test_chain test_chain.cpp) +target_link_libraries(maj0sted_test_chain PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_chain PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME chain COMMAND maj0sted_test_chain) -add_executable(maj0sted_test_niweleta test_niweleta.cpp) -target_link_libraries(maj0sted_test_niweleta PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_niweleta PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME niweleta COMMAND maj0sted_test_niweleta) +add_executable(maj0sted_test_hold test_hold.cpp) +target_link_libraries(maj0sted_test_hold PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_hold PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME hold COMMAND maj0sted_test_hold) -add_executable(maj0sted_test_plan_alignment test_plan_alignment.cpp) -target_link_libraries(maj0sted_test_plan_alignment PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_plan_alignment PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME plan_alignment COMMAND maj0sted_test_plan_alignment) +add_executable(maj0sted_test_lines test_lines.cpp) +target_link_libraries(maj0sted_test_lines PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_lines PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME lines COMMAND maj0sted_test_lines) -add_executable(maj0sted_test_parallelism test_parallelism.cpp) -target_link_libraries(maj0sted_test_parallelism PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_parallelism PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME parallelism COMMAND maj0sted_test_parallelism) +add_executable(maj0sted_test_graph test_graph.cpp) +target_link_libraries(maj0sted_test_graph PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_graph PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME graph COMMAND maj0sted_test_graph) -add_executable(maj0sted_test_fitting test_fitting.cpp) -target_link_libraries(maj0sted_test_fitting PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_fitting PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME fitting COMMAND maj0sted_test_fitting) +add_executable(maj0sted_test_format test_format.cpp) +target_link_libraries(maj0sted_test_format PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_format PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME format COMMAND maj0sted_test_format) -add_executable(maj0sted_test_serialization test_serialization.cpp) -target_link_libraries(maj0sted_test_serialization PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_serialization PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME serialization COMMAND maj0sted_test_serialization) - -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_turnout test_turnout.cpp) +target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME turnout COMMAND maj0sted_test_turnout) add_executable(maj0sted_test_tile_cache test_tile_cache.cpp) target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted) target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME tile_cache COMMAND maj0sted_test_tile_cache) -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) diff --git a/maj0sted/tests/test_chain.cpp b/maj0sted/tests/test_chain.cpp new file mode 100644 index 00000000..80e3bcae --- /dev/null +++ b/maj0sted/tests/test_chain.cpp @@ -0,0 +1,230 @@ +// The element chain: a walk, one element at a time, with every length said +// outright. Nothing here is solved for — what the document says is what is laid. + +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/chain.hpp" + +using namespace maj0sted::editor; +using maj0sted::domain::geometry::Pose; + +namespace { + +constexpr double kTol = 1e-6; + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Pose pose_at(double x, double y, double azimuth) { + return Pose{x, y, std::sin(azimuth), std::cos(azimuth)}; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} +Element clothoid(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Clothoid, radius, hand, metres, Free{}}; +} + +bool has(const TrackOutcome& outcome, Code code) { + for (const auto& diagnostic : outcome.diagnostics) { + if (diagnostic.code == code) { + return true; + } + } + return false; +} + +// ------------------------------------------------------------------------- + +/// A straight, an arc and a straight laid head to tail: no construction lines, +/// no fitting, nothing to close onto. +void open_chain_lays_head_to_tail() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 100.0), arc(document, 300.0, 1, 150.0), + line(document, 50.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.complete); + CHECK(out.track.elements.size() == 3); + CHECK(near(out.track.length, 300.0)); + + // Heading turned by L/R = 150/300 = 0.5 rad, to the left (hand = +1). + const double turned = 0.5; + CHECK(near(out.track.end.hx, std::sin(-turned) * -1.0 + 0.0, 1e-9) || + near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9)); + // Start pose points north; after a left turn the azimuth decreases. + CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9)); + + // The first element starts exactly at the anchor. + CHECK(near(out.track.elements[0].start.x, 0.0)); + CHECK(near(out.track.elements[0].start.y, 0.0)); + // The straight really is 100 m due north. + CHECK(near(out.track.elements[0].end.x, 0.0, 1e-9)); + CHECK(near(out.track.elements[0].end.y, 100.0, 1e-9)); +} + +/// An arc as the very first element — the "łuk wprost z krzyżownicy" case. The +/// old model could not say this: a curve only existed between two straights. +void a_chain_may_start_with_an_arc() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 190.0, -1, 60.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(10.0, 20.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.elements.size() == 1); + CHECK(near(out.track.elements[0].start.x, 10.0)); + CHECK(near(out.track.elements[0].start.y, 20.0)); + CHECK(near(out.track.elements[0].k0, -1.0 / 190.0)); + CHECK(near(out.track.length, 60.0)); +} + +/// Two arcs back to back with no straight between them — also unsayable before. +void arcs_may_follow_each_other() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 400.0, 1, 100.0), arc(document, 250.0, -1, 80.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.elements.size() == 2); + // The second starts exactly where the first ends, tangentially. + CHECK(near(out.track.elements[1].start.x, out.track.elements[0].end.x, 1e-12)); + CHECK(near(out.track.elements[1].start.y, out.track.elements[0].end.y, 1e-12)); + CHECK(near(out.track.elements[1].start.hx, out.track.elements[0].end.hx, 1e-12)); + // Net turn: +100/400 - 80/250 = 0.25 - 0.32 = -0.07 rad. + CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), 0.07, 1e-9)); +} + +/// A chain that simply stops. Nothing is synthesised to close it onto. +void an_open_chain_need_not_close() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 300.0, 1, 500.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(out.diagnostics.empty()); + CHECK(out.track.complete); + CHECK(out.track.elements.size() == 1); +} + +/// What cannot be laid is said out loud, and the chain still gives up whatever +/// stood before it. +void a_bad_element_is_reported_and_stops_the_chain() { + { // A length of nothing. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {line(document, 100.0), arc(document, 300.0, 1, 0.0), + line(document, 50.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + CHECK(!out.track.complete); + CHECK(out.track.elements.size() == 1); // the straight before it still stands + CHECK(near(out.track.length, 100.0)); + } + { // An arc without a radius. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 0.0, 1, 50.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + CHECK(out.track.elements.empty()); + } + { // An arc that does not say which way it turns. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 300.0, 0, 50.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + } + { // A transition curve that eases nothing: flat to flat. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {line(document, 20.0), clothoid(document, 0.0, 1, 40.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + CHECK(out.track.elements.size() == 1); + } +} + +/// A transition curve takes its near-end curvature from wherever the chain has +/// got to, and hands its far-end curvature on. That is the only way one element +/// depends on another. +void a_transition_eases_between_what_it_finds() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 300.0, 1, 100.0), clothoid(document, 600.0, 1, 50.0), + arc(document, 600.0, 1, 80.0), clothoid(document, 0.0, 1, 40.0), + line(document, 30.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.elements.size() == 5); + CHECK(near(out.track.elements[1].k0, 1.0 / 300.0)); + CHECK(near(out.track.elements[1].k1, 1.0 / 600.0)); + CHECK(near(out.track.elements[3].k0, 1.0 / 600.0)); + CHECK(near(out.track.elements[3].k1, 0.0)); + // Every element kept the length it was given. + CHECK(near(out.track.length, 100.0 + 50.0 + 80.0 + 40.0 + 30.0)); +} + +/// Solving twice gives the same answer: nothing is written back anywhere. +void solving_is_idempotent() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {line(document, 120.0), clothoid(document, 500.0, -1, 40.0), + arc(document, 500.0, -1, 180.0), clothoid(document, 0.0, -1, 40.0), + line(document, 60.0)}; + + const TrackOutcome first = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + const TrackOutcome second = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(first.diagnostics.empty()); + CHECK(first.track.elements.size() == second.track.elements.size()); + for (std::size_t i = 0; i < first.track.elements.size(); ++i) { + CHECK(near(first.track.elements[i].length, second.track.elements[i].length, kTol)); + CHECK(near(first.track.elements[i].start.x, second.track.elements[i].start.x, kTol)); + } +} + +} // namespace + +int main() { + RUN(open_chain_lays_head_to_tail); + RUN(a_chain_may_start_with_an_arc); + RUN(arcs_may_follow_each_other); + RUN(an_open_chain_need_not_close); + RUN(a_bad_element_is_reported_and_stops_the_chain); + RUN(a_transition_eases_between_what_it_finds); + RUN(solving_is_idempotent); + return REPORT(); +} diff --git a/maj0sted/tests/test_document.cpp b/maj0sted/tests/test_document.cpp deleted file mode 100644 index cb59e5d2..00000000 --- a/maj0sted/tests/test_document.cpp +++ /dev/null @@ -1,144 +0,0 @@ -#include -#include - -#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(); -} diff --git a/maj0sted/tests/test_editor.cpp b/maj0sted/tests/test_editor.cpp deleted file mode 100644 index afc3eb72..00000000 --- a/maj0sted/tests/test_editor.cpp +++ /dev/null @@ -1,271 +0,0 @@ -#include -#include - -#include "check.hpp" -#include "maj0sted/editor/editor.hpp" -#include "maj0sted/editor/solve.hpp" - -using namespace maj0sted::editor; - -namespace { - -bool has_kind(const std::vector& 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(); -} diff --git a/maj0sted/tests/test_fitting.cpp b/maj0sted/tests/test_fitting.cpp deleted file mode 100644 index 27b75048..00000000 --- a/maj0sted/tests/test_fitting.cpp +++ /dev/null @@ -1,346 +0,0 @@ -#include -#include -#include - -#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(&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(fit.curve[0])); - CHECK(std::holds_alternative(fit.curve[1])); - CHECK(std::holds_alternative(fit.curve[2])); - - // Entry transition runs from an infinite radius (straight) into R. - const auto& entry_kp = std::get(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(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(fit.curve[0]); - const auto& arc2 = std::get(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(fit.curve[0]).radius().metres() == 80.0); - CHECK(std::get(fit.curve[1]).radius().metres() == 50.0); - CHECK(std::get(fit.curve[2]).radius().metres() == 30.0); - - // Last arc takes the remaining 30 deg -> length = 30 * pi/6. - CHECK(std::abs(std::get(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(fit.curve[0])); - CHECK(std::holds_alternative(fit.curve[1])); - CHECK(std::holds_alternative(fit.curve[2])); - CHECK(std::holds_alternative(fit.curve[3])); - CHECK(std::holds_alternative(fit.curve[4])); - - // Entry transition: straight (inf) -> R60. - const auto& kp_in = std::get(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(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(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(fit.curve[0]); - const auto& second = std::get(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(fit.curve[0]); - const auto& second = std::get(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(corner.plan().elements()[0])); - CHECK(std::holds_alternative(corner.plan().elements()[1])); - CHECK(std::holds_alternative(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(corner.plan().elements()[0]); - const auto& last = std::get(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(); -} diff --git a/maj0sted/tests/test_format.cpp b/maj0sted/tests/test_format.cpp new file mode 100644 index 00000000..6696fed7 --- /dev/null +++ b/maj0sted/tests/test_format.cpp @@ -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 +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/io/document_io.hpp" + +using namespace maj0sted::editor; + +namespace { + +Document sample() { + Document document; + + TurnoutType type; + type.name = "Rz 1:9 R190 z iglicą nagiętą"; // spaces and non-ASCII on purpose + type.crossing_n = 9.0; + type.length = 27.1; + type.pieces = { + TurnoutPieceSpec{0, 2.42, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 8.3, 190.0, 190.0, 0.0058}, + TurnoutPieceSpec{2, 12.75, 190.0, 250.0, 0.0}, + TurnoutPieceSpec{3, 4.125, 0.0, 0.0, 0.0}, + }; + type.blade.tip_thickness = 0.005; + type.blade.nose = 0.125; + type.blade.railtop_width = 0.07; + document.turnout_types.push_back(type); + + Track through; + through.id = mint_track(document); + through.name = "tor zasadniczy 1"; + through.anchor = AtPose{100.5, -2.25, 0.7853981633974483}; + through.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 412.5, Free{}}, + Element{mint_element(document), Kind::Clothoid, 600.0, -1, 40.0, Free{}}, + Element{mint_element(document), Kind::Arc, 600.0, -1, 235.125, Free{}}, + Element{mint_element(document), Kind::Line, 0.0, 0, 180.0, Free{}}, + }; + document.tracks.push_back(through); + + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = type.name; + placement.on = through.id; + placement.station = 312.75; + placement.hand = -1; + placement.facing = false; + placement.bend_from_track = false; + placement.bend = -1.0 / 380.292; // wygięty dwustronnie, na własny łuk + document.turnouts.push_back(placement); + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.anchor = AtPort{placement.id, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Arc, 190.0, -1, 45.5, Free{}}, + Element{mint_element(document), Kind::Line, 0.0, 0, 123.5, Free{}}, + }; + // Held parallel to the through track's last straight, at międzytorze. + branch.elements[1].hold = Parallel{through.elements[3].id, 4.75}; + document.tracks.push_back(branch); + + document.view_x = 500.0; + document.view_y = 250.0; + document.view_extent = 2000.0; + document.origin_set = true; + document.georeferenced = true; + document.origin_x = 6540000.25; + document.origin_y = 5760000.5; + return document; +} + +bool same(const Document& a, const Document& b) { + if (a.tracks.size() != b.tracks.size() || + a.turnouts.size() != b.turnouts.size() || + a.turnout_types.size() != b.turnout_types.size() || a.next_id != b.next_id) { + return false; + } + for (std::size_t i = 0; i < a.tracks.size(); ++i) { + if (a.tracks[i].id != b.tracks[i].id || a.tracks[i].name != b.tracks[i].name || + a.tracks[i].elements.size() != b.tracks[i].elements.size()) { + return false; + } + if (a.tracks[i].anchor.index() != b.tracks[i].anchor.index()) { + return false; + } + for (std::size_t k = 0; k < a.tracks[i].elements.size(); ++k) { + const Element& x = a.tracks[i].elements[k]; + const Element& y = b.tracks[i].elements[k]; + if (x.id != y.id || x.kind != y.kind || x.radius != y.radius || + x.hand != y.hand || x.length != y.length || + x.hold.index() != y.hold.index()) { + return false; + } + } + } + return true; +} + +void round_trip_is_byte_identical() { + const Document document = sample(); + const std::string text = maj0sted::io::serialize(document); + + const auto reloaded = maj0sted::io::deserialize(text); + CHECK(reloaded.has_value()); + if (!reloaded) { + return; + } + CHECK(same(document, *reloaded)); + CHECK(maj0sted::io::serialize(*reloaded) == text); +} + +void every_field_survives() { + const Document document = sample(); + const auto reloaded = maj0sted::io::deserialize(maj0sted::io::serialize(document)); + CHECK(reloaded.has_value()); + if (!reloaded) { + return; + } + + // A name with spaces and non-ASCII. + CHECK(reloaded->turnout_types[0].name == "Rz 1:9 R190 z iglicą nagiętą"); + // The geometry list, every piece of it, and the blade's own dimensions. + CHECK(reloaded->turnout_types[0].pieces.size() == 4); + CHECK(reloaded->turnout_types[0].pieces[0].part == 0); + CHECK(reloaded->turnout_types[0].pieces[0].length == 2.42); + CHECK(reloaded->turnout_types[0].pieces[1].part == 1); + CHECK(reloaded->turnout_types[0].pieces[1].radius_start == 190.0); + CHECK(reloaded->turnout_types[0].pieces[1].turn_in == 0.0058); + CHECK(reloaded->turnout_types[0].pieces[2].radius_end == 250.0); + CHECK(reloaded->turnout_types[0].pieces[3].part == 3); + CHECK(reloaded->turnout_types[0].blade.tip_thickness == 0.005); + CHECK(reloaded->turnout_types[0].blade.nose == 0.125); + CHECK(reloaded->turnout_types[0].blade.railtop_width == 0.07); + // Placement, including a backward-facing turnout. + CHECK(reloaded->turnouts[0].station == 312.75); + CHECK(reloaded->turnouts[0].hand == -1); + CHECK(reloaded->turnouts[0].facing == false); + CHECK(reloaded->turnouts[0].bend_from_track == false); + CHECK(std::abs(reloaded->turnouts[0].bend + 1.0 / 380.292) < 1e-12); + CHECK(reloaded->turnouts[0].type == "Rz 1:9 R190 z iglicą nagiętą"); + // Every element says its own shape and its own length. + CHECK(reloaded->tracks[0].elements[0].kind == Kind::Line); + CHECK(reloaded->tracks[0].elements[0].length == 412.5); + CHECK(reloaded->tracks[0].elements[1].kind == Kind::Clothoid); + CHECK(reloaded->tracks[0].elements[1].radius == 600.0); + CHECK(reloaded->tracks[0].elements[2].length == 235.125); + CHECK(reloaded->tracks[0].elements[2].hand == -1); + // A hold, and the one element that carries it. + CHECK(std::holds_alternative(reloaded->tracks[1].elements[0].hold)); + CHECK(std::holds_alternative(reloaded->tracks[1].elements[1].hold)); + CHECK(std::get(reloaded->tracks[1].elements[1].hold).offset == 4.75); + CHECK(std::get(reloaded->tracks[1].elements[1].hold).ref == + reloaded->tracks[0].elements[3].id); + // Anchors. + CHECK(std::holds_alternative(reloaded->tracks[0].anchor)); + CHECK(std::holds_alternative(reloaded->tracks[1].anchor)); + CHECK(std::get(reloaded->tracks[1].anchor).port == Port::Frog); + // Georeference and view. + CHECK(reloaded->origin_x == 6540000.25); + CHECK(reloaded->view_extent == 2000.0); + CHECK(reloaded->georeferenced); + // 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(); +} diff --git a/maj0sted/tests/test_graph.cpp b/maj0sted/tests/test_graph.cpp new file mode 100644 index 00000000..06791d81 --- /dev/null +++ b/maj0sted/tests/test_graph.cpp @@ -0,0 +1,225 @@ +// What refers to what decides the order things are laid in. A turnout on the +// branch of a turnout is not a special case, and a reference that runs in a +// circle is reported rather than relaxed at for a fixed number of passes. + +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" + +using namespace maj0sted::editor; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +bool has(const Solution& solution, Code code) { + for (const auto& diagnostic : solution.diagnostics) { + if (diagnostic.code == code) { + return true; + } + } + return false; +} + +/// A plain Rz 1:9 R190, the everyday Polish turnout. +void add_catalogue(Document& document) { + TurnoutType type; + type.name = "Rz 1:9 R190"; + type.crossing_n = 9.0; + type.length = 27.138; // the catalogue's a + b + type.blade = BladeSpec{0.005, 0.070}; + type.pieces = { + // przediglicowy, then blade and curve as one arc of the crossing angle, then + // the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232 + TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0}, + }; + document.turnout_types.push_back(type); +} + +TurnoutId add_turnout(Document& document, TrackId on, double station, int hand) { + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = "Rz 1:9 R190"; + placement.on = on; + placement.station = station; + placement.hand = hand; + document.turnouts.push_back(placement); + return placement.id; +} + +TrackId add_straight_track(Document& document, const char* name, double x, double y, + double azimuth, double length) { + Track track; + track.id = mint_track(document); + track.name = name; + track.anchor = AtPose{x, y, azimuth}; + track.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, length, Free{}}}; + document.tracks.push_back(track); + return track.id; +} + +// ------------------------------------------------------------------------- + +/// A branch anchored to a frog is laid after the turnout, which is laid after +/// its through track — whatever order they appear in the document. +void a_branch_follows_its_frog() { + Document document; + add_catalogue(document); + + // The branch is declared BEFORE the turnout and the through track it needs. + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 100.0, Free{}}}; + document.tracks.push_back(branch); + + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0); + const TurnoutId turnout = add_turnout(document, through, 300.0, 1); + document.tracks[0].anchor = AtPort{turnout, Port::Frog}; + + const Solution solution = solve(document); + + CHECK(solution.diagnostics.empty()); + const SolvedTurnout* solved_turnout = find_turnout(solution, turnout); + CHECK(solved_turnout != nullptr && solved_turnout->valid); + + const SolvedTrack* solved_branch = find_track(solution, branch.id); + CHECK(solved_branch != nullptr); + CHECK(solved_branch->elements.size() == 1); + // The branch starts exactly at the frog, on the frog's heading. It was never + // anywhere else, so there is nothing to pin back. + CHECK(near(solved_branch->start.x, solved_turnout->frog.x)); + CHECK(near(solved_branch->start.y, solved_turnout->frog.y)); + CHECK(near(solved_branch->start.hx, solved_turnout->frog.hx)); + CHECK(near(solved_branch->start.hy, solved_turnout->frog.hy)); + // It diverges: the frog is off the through track's straight line. + CHECK(std::abs(solved_turnout->frog.x) > 0.5); +} + +/// A turnout on the branch of a turnout. The old solver ran a fixed two passes, +/// so this depth had no way to settle. +void a_turnout_on_a_branch_of_a_turnout() { + Document document; + add_catalogue(document); + + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 2000.0); + const TurnoutId first = add_turnout(document, through, 300.0, 1); + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga 1"; + branch.anchor = AtPort{first, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 400.0, Free{}}}; + document.tracks.push_back(branch); + + const TurnoutId second = add_turnout(document, branch.id, 150.0, -1); + + Track branch2; + branch2.id = mint_track(document); + branch2.name = "odnoga 2"; + branch2.anchor = AtPort{second, Port::Frog}; + branch2.elements = { + Element{mint_element(document), Kind::Arc, 300.0, -1, 120.0, Free{}}}; + document.tracks.push_back(branch2); + + const Solution solution = solve(document); + + CHECK(solution.diagnostics.empty()); + CHECK(find_turnout(solution, second) != nullptr); + CHECK(find_turnout(solution, second)->valid); + + const SolvedTrack* deep = find_track(solution, branch2.id); + CHECK(deep != nullptr); + CHECK(deep->elements.size() == 1); + CHECK(near(deep->start.x, find_turnout(solution, second)->frog.x)); + CHECK(near(deep->start.y, find_turnout(solution, second)->frog.y)); + // Three levels deep, well away from the through track. + CHECK(std::abs(deep->start.x) > 1.0); +} + +/// References that run in a circle are named, not relaxed at. +void a_cycle_is_reported() { + // Two tracks, each held parallel to the other's element. + Document document; + const TrackId first = add_straight_track(document, "tor 1", 0.0, 0.0, 0.0, 500.0); + const TrackId second = add_straight_track(document, "tor 2", 20.0, 0.0, 0.0, 500.0); + document.tracks[0].elements[0].hold = + Parallel{document.tracks[1].elements[0].id, 4.75}; + document.tracks[1].elements[0].hold = + Parallel{document.tracks[0].elements[0].id, 4.75}; + (void)first; + (void)second; + + const Solution solution = solve(document); + CHECK(has(solution, Code::CyclicDependency)); +} + +/// A turnout whose catalogue type is missing says so, and the branch that hangs +/// off it says why it could not be laid — neither is silently dropped. +void a_missing_type_is_reported() { + Document document; + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 500.0); + const TurnoutId turnout = add_turnout(document, through, 100.0, 1); // no catalogue + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.anchor = AtPort{turnout, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 50.0, Free{}}}; + document.tracks.push_back(branch); + + const Solution solution = solve(document); + CHECK(has(solution, Code::UnknownReference)); + CHECK(solution.diagnostics.size() >= 2); + // Every track still has an entry, so a host can index by id without holes. + CHECK(find_track(solution, branch.id) != nullptr); + CHECK(find_track(solution, through) != nullptr); +} + +/// Solving twice gives the same answer, and solving does not touch the document. +void solving_leaves_the_document_alone() { + Document document; + add_catalogue(document); + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0); + const TurnoutId turnout = add_turnout(document, through, 300.0, 1); + Track branch; + branch.id = mint_track(document); + branch.anchor = AtPort{turnout, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Arc, 300.0, 1, 200.0, Free{}}}; + document.tracks.push_back(branch); + + const Document before = document; + const Solution first = solve(document); + const Solution second = solve(document); + + CHECK(document.tracks.size() == before.tracks.size()); + CHECK(document.next_id == before.next_id); + CHECK(first.tracks.size() == second.tracks.size()); + for (std::size_t i = 0; i < first.tracks.size(); ++i) { + CHECK(near(first.tracks[i].length, second.tracks[i].length)); + CHECK(near(first.tracks[i].end.x, second.tracks[i].end.x)); + CHECK(near(first.tracks[i].end.y, second.tracks[i].end.y)); + } +} + +} // namespace + +int main() { + RUN(a_branch_follows_its_frog); + RUN(a_turnout_on_a_branch_of_a_turnout); + RUN(a_cycle_is_reported); + RUN(a_missing_type_is_reported); + RUN(solving_leaves_the_document_alone); + return REPORT(); +} diff --git a/maj0sted/tests/test_hold.cpp b/maj0sted/tests/test_hold.cpp new file mode 100644 index 00000000..67875a35 --- /dev/null +++ b/maj0sted/tests/test_hold.cpp @@ -0,0 +1,310 @@ +// What one track keeps against another, and what clicking along the ground +// works out at the click. Both are the same idea from two ends: geometry is +// decided once, in numbers, and nothing is left pointing at a drawing. + +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/sketch.hpp" + +using namespace maj0sted::editor; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} + +/// A straight due north from the origin, 1000 m of it. +TrackId add_main(Document& document, ElementId& out_element) { + Track track; + track.id = mint_track(document); + track.name = "tor 1"; + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 1000.0)}; + out_element = track.elements.front().id; + document.tracks.push_back(track); + return track.id; +} + +bool has(const Solution& solution, Code code) { + for (const auto& diagnostic : solution.diagnostics) { + if (diagnostic.code == code) { + return true; + } + } + return false; +} + +// ------------------------------------------------------------------------- + +/// A corner rounded at the click: the tangent comes off both straights, and the +/// arc turns exactly as far as the corner bends. +void a_corner_is_rounded_with_a_tangent_arc() { + // Due north, then due east: a 90° corner to the right. + const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0); + CHECK(corner.ok); + CHECK(corner.hand == -1); + CHECK(near(corner.deflection, -std::numbers::pi / 2.0)); + CHECK(near(corner.tangent, 300.0)); // R·tan(45°) + CHECK(near(corner.arc_length, 300.0 * std::numbers::pi / 2.0)); + + // Straight on is not a corner, and neither is doubling back. + CHECK(!fit_corner(0.0, 1.0, 0.0, 1.0, 300.0).ok); + CHECK(!fit_corner(0.0, 1.0, 0.0, -1.0, 300.0).ok); + CHECK(!fit_corner(0.0, 1.0, 1.0, 0.0, 0.0).ok); +} + +/// The corner really does come out tangent: laying the three elements it +/// produces lands the track on the second leg, heading along it. +void a_rounded_corner_lands_on_the_next_leg() { + Document document; + const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0); + + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 1000.0 - corner.tangent), + arc(document, 300.0, corner.hand, corner.arc_length), + line(document, 800.0 - corner.tangent)}; + document.tracks.push_back(track); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + const SolvedTrack* laid = find_track(solution, track.id); + CHECK(laid != nullptr); + // The corner was at (0, 1000); the second leg runs east from there to (800, 1000). + CHECK(near(laid->end.x, 800.0, 1e-6)); + CHECK(near(laid->end.y, 1000.0, 1e-6)); + CHECK(near(laid->end.hx, 1.0, 1e-9)); + CHECK(near(laid->end.hy, 0.0, 1e-9)); +} + +/// Międzytorze, said once: the held track is moved bodily onto the offset, and +/// stays where it was put along it. +void a_track_held_parallel_is_moved_onto_the_offset() { + Document document; + ElementId reference{ElementId::none}; + add_main(document, reference); + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + // Put it well off the offset, and part way up the main track. + siding.anchor = AtPose{40.0, 300.0, 0.0}; + siding.elements = {line(document, 500.0)}; + siding.elements.front().hold = Parallel{reference, 4.75}; + document.tracks.push_back(siding); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + + const SolvedTrack* laid = find_track(solution, siding.id); + CHECK(laid != nullptr && laid->elements.size() == 1); + // The main track heads north, so left of its travel is -x. + CHECK(near(laid->elements[0].start.x, -4.75, 1e-9)); + // Nothing slid it along: it kept the y it was anchored at. + CHECK(near(laid->elements[0].start.y, 300.0, 1e-9)); + CHECK(near(laid->elements[0].start.hy, 1.0, 1e-9)); + CHECK(near(laid->length, 500.0)); + + // The offset is signed: the other side is the other side. + document.tracks.back().elements.front().hold = Parallel{reference, -4.75}; + const Solution mirrored = solve(document); + const SolvedTrack* other = find_track(mirrored, document.tracks.back().id); + CHECK(other != nullptr && near(other->elements[0].start.x, 4.75, 1e-9)); +} + +/// Parallel to an arc is concentric with it: same centre, R∓d. +void a_track_held_parallel_to_an_arc_is_concentric() { + Document document; + Track main; + main.id = mint_track(document); + main.name = "tor 1"; + main.anchor = AtPose{0.0, 0.0, 0.0}; + main.elements = {arc(document, 500.0, 1, 400.0)}; + const ElementId reference = main.elements.front().id; + document.tracks.push_back(main); + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{-60.0, 10.0, 0.0}; + // The radius and hand asked for here are beside the point: the reference + // fixes the shape. + siding.elements = {arc(document, 999.0, -1, 200.0)}; + siding.elements.front().hold = Parallel{reference, 50.0}; + document.tracks.push_back(siding); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + + const SolvedTrack* laid = find_track(solution, siding.id); + CHECK(laid != nullptr && laid->elements.size() == 1); + // Left of a left-hand curve is the inside: R - d. + CHECK(near(laid->elements[0].k0, 1.0 / 450.0, 1e-12)); + // The main track's centre of curvature is 500 m to the left of its start. + CHECK(near(std::hypot(laid->elements[0].start.x + 500.0, laid->elements[0].start.y), + 450.0, 1e-6)); + CHECK(near(laid->length, 200.0)); +} + +/// A hold is a reference like any other: it orders the solve, and a circle of +/// them is reported rather than relaxed at. +void holds_are_ordered_and_cycles_are_caught() { + Document document; + ElementId reference{ElementId::none}; + add_main(document, reference); + + // Held to a track that is itself held: laid third, and still exact. + Track second; + second.id = mint_track(document); + second.name = "tor 2"; + second.anchor = AtPose{40.0, 0.0, 0.0}; + second.elements = {line(document, 400.0)}; + second.elements.front().hold = Parallel{reference, 4.75}; + const ElementId middle = second.elements.front().id; + document.tracks.push_back(second); + + Track third; + third.id = mint_track(document); + third.name = "tor 3"; + third.anchor = AtPose{80.0, 0.0, 0.0}; + third.elements = {line(document, 400.0)}; + third.elements.front().hold = Parallel{middle, 4.75}; + document.tracks.push_back(third); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + const SolvedTrack* laid = find_track(solution, third.id); + CHECK(laid != nullptr && near(laid->elements[0].start.x, -9.5, 1e-9)); + + // Now point the first one back at the last: a circle. + document.tracks[0].elements[0].hold = + Parallel{document.tracks[2].elements[0].id, 4.75}; + CHECK(has(solve(document), Code::CyclicDependency)); +} + +/// Two elements of one movable track cannot both decide where it goes. +void two_holds_on_one_movable_track_conflict() { + Document document; + ElementId reference{ElementId::none}; + add_main(document, reference); + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{40.0, 0.0, 0.0}; + siding.elements = {line(document, 200.0), line(document, 200.0)}; + siding.elements[0].hold = Parallel{reference, 4.75}; + siding.elements[1].hold = Parallel{reference, 9.50}; + document.tracks.push_back(siding); + + CHECK(has(solve(document), Code::HoldConflict)); +} + +/// A branch pinned to a turnout's frog cannot be moved onto the offset, so the +/// corner between the frog and the held straight takes up the difference — the +/// same fit as pulling a straight about by hand, and no full turn anywhere. +void a_pinned_branch_fits_its_way_onto_the_offset() { + Document document; + + TurnoutType type; + type.name = "Rz 1:9 R190"; + type.crossing_n = 9.0; + type.length = 27.138; // the catalogue's a + b + type.blade = BladeSpec{0.005, 0.070}; + type.pieces = { + // przediglicowy, then blade and curve as one arc of the crossing angle, then + // the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232 + TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0}, + }; + document.turnout_types.push_back(type); + + ElementId reference{ElementId::none}; + const TrackId main = add_main(document, reference); + + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = type.name; + placement.on = main; + placement.station = 300.0; + placement.hand = -1; // diverging to the right of a northbound track + document.turnouts.push_back(placement); + + // Off the frog: a straight, a corner, and the straight that is to run beside + // the main track. Nothing about it is right to begin with. + Track branch; + branch.id = mint_track(document); + branch.name = "bocznica"; + branch.anchor = AtPort{placement.id, Port::Frog}; + branch.elements = {line(document, 40.0), arc(document, 300.0, 1, 30.0), + line(document, 400.0)}; + branch.elements[2].hold = Parallel{reference, -4.75}; + document.tracks.push_back(branch); + + const Solution solution = solve(document); + for (const auto& diagnostic : solution.diagnostics) { + std::printf(" diag: %s\n", diagnostic.text.c_str()); + } + CHECK(!has(solution, Code::OffsetNotHeld)); + + const SolvedTrack* laid = find_track(solution, branch.id); + CHECK(laid != nullptr && laid->elements.size() == 3); + const SolvedTurnout* turnout = find_turnout(solution, placement.id); + CHECK(turnout != nullptr && turnout->valid); + + // It still starts exactly on the frog: the fit moved lengths, not the branch. + CHECK(near(laid->start.x, turnout->frog.x, 1e-6)); + CHECK(near(laid->start.y, turnout->frog.y, 1e-6)); + // And the held straight really lies 4.75 m to the right of the main track, + // pointing the same way. + CHECK(near(laid->elements[2].start.x, 4.75, 1e-6)); + CHECK(near(laid->elements[2].start.hy, 1.0, 1e-9)); + // The corner took up the difference without looping the long way round. + CHECK(laid->elements[1].length < 300.0 * std::numbers::pi); +} + +/// A reference that is not there is said so, and the track is still laid. +void a_missing_reference_is_reported() { + Document document; + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{0.0, 0.0, 0.0}; + siding.elements = {line(document, 100.0)}; + siding.elements.front().hold = Parallel{static_cast(9999), 4.75}; + document.tracks.push_back(siding); + + const Solution solution = solve(document); + CHECK(has(solution, Code::UnknownReference)); + const SolvedTrack* laid = find_track(solution, siding.id); + CHECK(laid != nullptr && laid->elements.size() == 1); +} + +} // namespace + +int main() { + RUN(a_corner_is_rounded_with_a_tangent_arc); + RUN(a_rounded_corner_lands_on_the_next_leg); + RUN(a_track_held_parallel_is_moved_onto_the_offset); + RUN(a_track_held_parallel_to_an_arc_is_concentric); + RUN(holds_are_ordered_and_cycles_are_caught); + RUN(two_holds_on_one_movable_track_conflict); + RUN(a_pinned_branch_fits_its_way_onto_the_offset); + RUN(a_missing_reference_is_reported); + return REPORT(); +} diff --git a/maj0sted/tests/test_lines.cpp b/maj0sted/tests/test_lines.cpp new file mode 100644 index 00000000..27ca9066 --- /dev/null +++ b/maj0sted/tests/test_lines.cpp @@ -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 +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/sketch.hpp" + +using namespace maj0sted::editor; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} + +double azimuth(const SolvedElement& element) { + return std::atan2(element.start.hx, element.start.hy); +} + +/// The shape most of this is about: straight, corner, straight, corner, straight. +TrackId add_track(Document& document, std::vector elements) { + Track track; + track.id = mint_track(document); + track.name = "tor 1"; + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = std::move(elements); + document.tracks.push_back(track); + return track.id; +} + +// ------------------------------------------------------------------------- + +/// Taking a track apart into lines and putting it back together, without moving +/// anything, gives back exactly the track that went in. +void a_track_survives_being_taken_apart_and_put_back() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + CHECK(skeleton.lines.size() == 3); + + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) { + CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-9)); + CHECK(rebuilt.elements[i].radius == document.tracks[0].elements[i].radius); + CHECK(rebuilt.elements[i].hand == document.tracks[0].elements[i].hand); + } +} + +/// An arc of 240°, which has no tangent crossing to be found: the two straights +/// it joins meet at 120° the other way round. Nothing may quietly turn it into +/// the 120° arc that would fit between the same two lines. +void an_arc_past_a_half_circle_stays_where_it_was_drawn() { + Document document; + const double turn = 240.0 * std::numbers::pi / 180.0; + const TrackId id = add_track(document, {line(document, 200.0), + arc(document, 200.0, 1, 200.0 * turn), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + CHECK(near(rebuilt.elements[1].length, 200.0 * turn, 1e-9)); + CHECK(near(rebuilt.elements[0].length, 200.0, 1e-9)); + CHECK(near(rebuilt.elements[2].length, 200.0, 1e-9)); +} + +/// Exactly a half circle: the two straights are antiparallel and never cross at +/// all. Tangency fixes their spacing and nothing else, so the arc stays put. +void a_half_circle_is_laid_and_not_refused() { + Document document; + const double turn = std::numbers::pi; + const TrackId id = add_track(document, {line(document, 150.0), + arc(document, 250.0, -1, 250.0 * turn), + line(document, 400.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + Track rebuilt = document.tracks[0]; + const bool ok = lay_along_skeleton(rebuilt, skeleton, why); + CHECK(ok); + if (!ok) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(near(rebuilt.elements[1].length, 250.0 * turn, 1e-6)); + CHECK(near(rebuilt.elements[0].length, 150.0, 1e-6)); + CHECK(near(rebuilt.elements[2].length, 400.0, 1e-6)); +} + +/// Swinging the middle straight: the arcs beside it re-fit, keeping their radii, +/// and the straights that do not touch it keep their bearing to the last bit. +void swinging_one_straight_leaves_the_others_pointing_where_they_did() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr); + const double first_azimuth = azimuth(laid->elements[0]); + const double last_azimuth = azimuth(laid->elements[4]); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + // Swing the middle line about the point the straight starts on, by 4°. + const double angle = 4.0 * std::numbers::pi / 180.0; + Support& middle = skeleton.lines[1]; + middle.px = laid->elements[2].start.x; + middle.py = laid->elements[2].start.y; + const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle); + const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle); + middle.dx = dx; + middle.dy = dy; + + Track moved = document.tracks[0]; + CHECK(lay_along_skeleton(moved, skeleton, why)); + document.tracks[0] = moved; + + const Solution after = solve(document); + const SolvedTrack* again = find_track(after, id); + CHECK(again != nullptr && after.diagnostics.empty()); + + // The straights at either end never moved: same bearing, to the micro-radian. + CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12)); + CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12)); + // The arcs kept their radii and changed only how far they run. + CHECK(near(again->elements[1].k0, 1.0 / 400.0, 1e-12)); + CHECK(near(again->elements[3].k0, -1.0 / 300.0, 1e-12)); + CHECK(!near(again->elements[1].length, 210.0, 1e-6)); + // And the free ends are still exactly where they were. + CHECK(near(again->start.x, laid->start.x, 1e-9)); + CHECK(near(again->end.y, laid->end.y, 1e-9)); +} + +/// What cannot be laid says why, and leaves the caller's track alone. +void a_move_that_cannot_be_laid_is_refused() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + { // Swing the middle straight far enough that the two arcs overlap on it. + Skeleton bent = skeleton; + const double angle = 60.0 * std::numbers::pi / 180.0; + Support& middle = bent.lines[1]; + const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle); + const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle); + middle.dx = dx; + middle.dy = dy; + Track candidate = document.tracks[0]; + CHECK(!lay_along_skeleton(candidate, bent, why)); + CHECK(!why.empty()); + // Nothing was written: the caller's copy still reads as it did. + CHECK(candidate.elements[0].length == document.tracks[0].elements[0].length); + } + { // A transition that does not run out into the straight after it is a kink. + Document other; + Track track; + track.id = mint_track(other); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0), + Element{mint_element(other), Kind::Clothoid, 400.0, 1, 40.0, Free{}}, + line(other, 100.0)}; + other.tracks.push_back(track); + const Solution solution = solve(other); + Skeleton none; + CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why)); + } + { // Two arcs turning opposite ways inside one corner is an S, not a basket. + Document other; + Track track; + track.id = mint_track(other); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0), + arc(other, 400.0, -1, 100.0), line(other, 100.0)}; + other.tracks.push_back(track); + const Solution solution = solve(other); + Skeleton none; + CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why)); + } +} + +/// The everyday main-line corner: straight, transition, arc, transition, straight. +/// The transitions are authored, so they keep their lengths; the arc takes what +/// is left of the turn. +void a_corner_with_transitions_re_fits() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = { + line(document, 400.0), + Element{mint_element(document), Kind::Clothoid, 600.0, 1, 80.0, Free{}}, + arc(document, 600.0, 1, 300.0), + Element{mint_element(document), Kind::Clothoid, 0.0, 1, 80.0, Free{}}, + line(document, 400.0)}; + document.tracks.push_back(track); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, track.id); + CHECK(laid != nullptr && before.diagnostics.empty()); + const double first_azimuth = azimuth(laid->elements[0]); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.lines.size() == 2); + + // Put it back untouched: everything comes out as it went in. + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) { + CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-6)); + } + + // Now swing the second straight by 3°: the transitions keep their lengths, + // the arc takes up the difference, and the first straight does not budge. + const double angle = 3.0 * std::numbers::pi / 180.0; + Support& second = skeleton.lines[1]; + const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle); + const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle); + second.dx = dx; + second.dy = dy; + + Track moved = document.tracks[0]; + CHECK(lay_along_skeleton(moved, skeleton, why)); + CHECK(near(moved.elements[1].length, 80.0, 1e-12)); + CHECK(near(moved.elements[3].length, 80.0, 1e-12)); + CHECK(!near(moved.elements[2].length, 300.0, 1e-6)); + document.tracks[0] = moved; + + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + const SolvedTrack* again = find_track(after, track.id); + CHECK(again != nullptr); + CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12)); + // The transition still hands the arc exactly the curvature it eased to. + CHECK(near(again->elements[1].k1, 1.0 / 600.0, 1e-12)); + CHECK(near(again->elements[2].k0, 1.0 / 600.0, 1e-12)); +} + +/// A basket curve — two arcs of different radii turning the same way. Re-fitting +/// shares the corner's turn out between them in the proportions they were drawn +/// with, so it stays the same basket, tighter or wider. +void a_basket_curve_keeps_its_proportions() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 400.0), arc(document, 800.0, 1, 160.0), + arc(document, 400.0, 1, 120.0), line(document, 400.0)}; + document.tracks.push_back(track); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, track.id); + CHECK(laid != nullptr && before.diagnostics.empty()); + const double wide = 160.0 / 800.0; // what each arc turns, as drawn + const double tight = 120.0 / 400.0; + const double first_azimuth = azimuth(laid->elements[0]); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + CHECK(near(rebuilt.elements[1].length, 160.0, 1e-9)); + CHECK(near(rebuilt.elements[2].length, 120.0, 1e-9)); + + // Swing the far straight: both arcs give, in proportion, and neither radius + // changes. + const double angle = 5.0 * std::numbers::pi / 180.0; + Support& second = skeleton.lines[1]; + const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle); + const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle); + second.dx = dx; + second.dy = dy; + + Track moved = document.tracks[0]; + CHECK(lay_along_skeleton(moved, skeleton, why)); + CHECK(moved.elements[1].radius == 800.0); + CHECK(moved.elements[2].radius == 400.0); + const double now_wide = moved.elements[1].length / 800.0; + const double now_tight = moved.elements[2].length / 400.0; + CHECK(!near(now_wide, wide, 1e-9)); + CHECK(near(now_wide / now_tight, wide / tight, 1e-9)); + + document.tracks[0] = moved; + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + CHECK(near(azimuth(find_track(after, track.id)->elements[0]), first_azimuth, 1e-12)); +} + +/// Changing a radius is the corner's business and nobody else's: the straights +/// on either side give, and nothing past them moves a millimetre. +void a_new_radius_is_absorbed_by_its_own_corner() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr); + const double last_azimuth = azimuth(laid->elements[4]); + const double end_x = laid->end.x; + const double end_y = laid->end.y; + const double second_arc_length = document.tracks[0].elements[3].length; + + // The lines are taken off the track as it stands, then the radius changes. + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + document.tracks[0].elements[1].radius = 250.0; + + CHECK(lay_along_skeleton(document.tracks[0], skeleton, why)); + + const Solution after = solve(document); + const SolvedTrack* again = find_track(after, id); + CHECK(again != nullptr && after.diagnostics.empty()); + + // A tighter arc turns the same corner, so it is shorter and the straights on + // either side of it grow. The corner beyond is untouched, and so is the end. + CHECK(near(again->elements[1].k0, 1.0 / 250.0, 1e-12)); + CHECK(near(document.tracks[0].elements[3].length, second_arc_length, 1e-9)); + CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12)); + CHECK(near(again->end.x, end_x, 1e-9)); + CHECK(near(again->end.y, end_y, 1e-9)); + CHECK(near(again->start.x, laid->start.x, 1e-9)); +} + +/// A track that finishes mid-curve — laid by clicking and then given an arc, so +/// there is no straight after it. The corner at the end has nothing on its far +/// side to be fitted against, so it keeps what it was given and the track simply +/// ends where it takes it. Turning it the other way is an ordinary edit, not a +/// reason to refuse the whole chain. +void a_chain_ending_on_a_curve_still_re_fits() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + const double first_azimuth = azimuth(laid->elements[0]); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.lines.size() == 2); + + // Turn the trailing arc the other way. Everything before it stays put; the + // arc keeps the length it was given and takes the track off elsewhere. + document.tracks[0].elements[3].hand = 1; + CHECK(lay_along_skeleton(document.tracks[0], skeleton, why)); + CHECK(near(document.tracks[0].elements[3].length, 180.0, 1e-12)); + + const Solution after = solve(document); + const SolvedTrack* again = find_track(after, id); + CHECK(again != nullptr && after.diagnostics.empty()); + CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12)); + CHECK(near(again->elements[2].length, laid->elements[2].length, 1e-9)); + CHECK(near(again->elements[3].start.x, laid->elements[3].start.x, 1e-9)); + CHECK(near(again->elements[3].k0, 1.0 / 300.0, 1e-12)); +} + +/// And one that starts mid-curve: a branch leaving a frog on a curve. The corner +/// at the front rides along, and the anchor is worked back through it. +void a_chain_starting_on_a_curve_carries_its_head_along() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{10.0, 20.0, 0.3}; + track.elements = {arc(document, 250.0, -1, 90.0), line(document, 300.0), + arc(document, 400.0, 1, 210.0), line(document, 250.0)}; + document.tracks.push_back(track); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, track.id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.lines.size() == 2); + + // Untouched, it comes back exactly as it was — anchor included. + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + CHECK(near(rebuilt.elements[0].length, 90.0, 1e-12)); + CHECK(near(std::get(rebuilt.anchor).x, 10.0, 1e-9)); + CHECK(near(std::get(rebuilt.anchor).y, 20.0, 1e-9)); + CHECK(near(std::get(rebuilt.anchor).az, 0.3, 1e-9)); + + // Tighten the interior corner: the head keeps its length, and the straights + // beside the corner give. + document.tracks[0].elements[2].radius = 250.0; + CHECK(lay_along_skeleton(document.tracks[0], skeleton, why)); + CHECK(near(document.tracks[0].elements[0].length, 90.0, 1e-12)); + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + const SolvedTrack* again = find_track(after, track.id); + CHECK(near(again->elements[1].start.x, laid->elements[1].start.x, 1e-9)); + CHECK(near(again->elements[2].k0, 1.0 / 250.0, 1e-12)); +} + +/// 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(); +} diff --git a/maj0sted/tests/test_map_project.cpp b/maj0sted/tests/test_map_project.cpp deleted file mode 100644 index 5cd09d1b..00000000 --- a/maj0sted/tests/test_map_project.cpp +++ /dev/null @@ -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(); -} diff --git a/maj0sted/tests/test_niweleta.cpp b/maj0sted/tests/test_niweleta.cpp deleted file mode 100644 index 7c99a978..00000000 --- a/maj0sted/tests/test_niweleta.cpp +++ /dev/null @@ -1,110 +0,0 @@ -#include - -#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(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(); -} diff --git a/maj0sted/tests/test_parallelism.cpp b/maj0sted/tests/test_parallelism.cpp deleted file mode 100644 index a603f88c..00000000 --- a/maj0sted/tests/test_parallelism.cpp +++ /dev/null @@ -1,85 +0,0 @@ -#include - -#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(); -} diff --git a/maj0sted/tests/test_plan_alignment.cpp b/maj0sted/tests/test_plan_alignment.cpp deleted file mode 100644 index 353a6bd0..00000000 --- a/maj0sted/tests/test_plan_alignment.cpp +++ /dev/null @@ -1,132 +0,0 @@ -#include - -#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(); -} diff --git a/maj0sted/tests/test_render.cpp b/maj0sted/tests/test_render.cpp deleted file mode 100644 index 58261a2a..00000000 --- a/maj0sted/tests/test_render.cpp +++ /dev/null @@ -1,78 +0,0 @@ -#include - -#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(" - -#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(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(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(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(); -} diff --git a/maj0sted/tests/test_turnout.cpp b/maj0sted/tests/test_turnout.cpp index d1ae8ae0..0c0b49d3 100644 --- a/maj0sted/tests/test_turnout.cpp +++ b/maj0sted/tests/test_turnout.cpp @@ -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 #include @@ -9,12 +17,54 @@ using maj0sted::domain::geometry::Pose; namespace { +constexpr double kAlfa19 = 0.11065722117389563; // atan(1/9) +constexpr double kStart[]{0.0, 0.0, 1.0, 0.0}; + +// Rz 49E1-190-1:9 as the drawings have it: u and d from Koc's tablica 5.1, the +// rear tangent from rys. 5.13 (27138 = 10523 + 16615, krzyżownica prosta). The +// blade ends where the gap to the stock rail takes the przekładka in its heel. +constexpr double kRadius = 190.0; +constexpr double kTipThickness = 0.0049; // u +constexpr double kNose = 0.1250; // d +constexpr double kRailtop = 0.070; // w +constexpr double kHeelOffset = 0.136; +constexpr double kBackTangent = 16.615; // b + +// a: the odcinek przediglicowy, derived from u and d rather than copied — the gap +// opens on the blade's own curve, and the station it opens at is on the axis +const double kLead = blade_run_to_offset(kRadius, 0.0, kTipThickness) - kNose; +const double kBlade = blade_run_to_offset(kRadius, 0.0, kHeelOffset) - kLead; + +// The gap @p pose has opened between the blade and the stock rail: the axis offset +// plus what half a gauge of rail swings out of the way as the path turns off. +double gap_at(const Pose& start, const Pose& pose) { + const double offset = (pose.x - start.x) * -start.hy + (pose.y - start.y) * start.hx; + const double heading = std::atan2(pose.hy * start.hx - pose.hx * start.hy, + pose.hx * start.hx + pose.hy * start.hy); + return offset + kHalfGauge * (1.0 - std::cos(heading)); +} +const double kFrontTangent = kRadius * std::tan(0.5 * kAlfa19); +const double kFrogRail = kBackTangent - kFrontTangent; + Turnout rz_1_9_r190() { - Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, DivergingCurve{}, 27.138}; - turnout.curve.arcs.push_back(TurnoutArc{190.0, 0.0, 0.0}); + Turnout turnout{CrossingMark{9.0}, + DivergeSide::Left, + {}, + kFrontTangent + kBackTangent, + Blade{kTipThickness, kNose, kRailtop}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, kLead, kRadius, kRadius, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, kBlade, kRadius, kRadius, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, + kRadius * kAlfa19 - kLead - kBlade, kRadius, + kRadius, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::FrogRail, kFrogRail, 0.0, 0.0, 0.0}); return turnout; } +TurnoutGeometry laid(const Turnout& turnout) { + return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout); +} + // Walks the laid path and returns the pose at the end of segment @p index. Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) { Pose pose{0.0, 0.0, 1.0, 0.0}; @@ -32,99 +82,365 @@ Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) { return pose; } -void pre_blade_runs_along_the_through_track() { - auto turnout = 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); +void a_tangential_blade_never_breaks_away() { + const auto geometry = laid(rz_1_9_r190()); CHECK(geometry.valid); - - // the lead is straight, on the through track, and the blade tip sits at its end - const Pose tip = pose_after(geometry, 0); - CHECK(std::abs(tip.x - 3.5) < 1e-9); - CHECK(std::abs(tip.y) < 1e-12); - CHECK(std::abs(tip.hy) < 1e-12); - - // the blade breaks away by beta exactly, and stays straight over its length - const Pose heel = pose_after(geometry, 1); - CHECK(std::abs(std::atan2(heel.hy, heel.hx) - 0.02) < 1e-9); - CHECK(std::abs(std::hypot(heel.x - tip.x, heel.y - tip.y) - 6.0) < 1e-9); + for (const auto& segment : geometry.path) { + CHECK(segment.turn_in == 0.0); + } + // the curve is there from PR: the odcinek przediglicowy is already on it, the + // vehicle is simply still on the through track + CHECK(std::abs(geometry.path[0].k0 - 1.0 / kRadius) < 1e-12); + CHECK(std::abs(geometry.path[1].k0 - 1.0 / kRadius) < 1e-12); + // the blade begins where its piece does, a from PR + CHECK(geometry.has_blade); + CHECK(std::abs(geometry.blade_station - kLead) < 1e-12); + CHECK(std::abs(geometry.blade_tip.x - kRadius * std::sin(kLead / kRadius)) < 1e-9); + CHECK(std::abs(geometry.blade_tip.y - + kRadius * (1.0 - std::cos(kLead / kRadius))) < 1e-9); } -void the_frog_still_lands_on_the_crossing_angle() { - const double alfa = std::atan(1.0 / 9.0); +void a_planed_nose_reaches_its_thickness_where_the_table_says() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); - auto tangential = rz_1_9_r190(); - auto bladed = rz_1_9_r190(); - bladed.pre_blade = 3.5; - bladed.blade_angle = 0.02; - bladed.blade_length = 6.0; + const Pose start{kStart[0], kStart[1], kStart[2], kStart[3]}; + // A: where the blade stands u away from the stock rail, and where the tangent + // there is the kąt przylegania — a couple of dozen minutes + CHECK(std::abs(gap_at(start, geometry.blade_nose_end) - kTipThickness) < 1e-9); + CHECK(std::abs(geometry.blade_angle - geometry.nose_end / kRadius) < 1e-9); + CHECK(geometry.blade_angle * 180.0 / std::numbers::pi > 0.40); + CHECK(geometry.blade_angle * 180.0 / std::numbers::pi < 0.42); + // a was cut so that A lands on a + d, so a type carrying all three agrees with + // the curve it is laid on and the residual is nothing + CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-12); + CHECK(std::abs(geometry.blade_residual) < 1e-12); - for (const auto& turnout : {tangential, bladed}) { - const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); - CHECK(geometry.valid); - // the curve spends only alfa - beta, so the frog heading is alfa either way - CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - alfa) < 1e-9); - // and the catalogue length is still measured along the through track - CHECK(std::abs(geometry.frog.x - 27.138) < 1e-6); + // the planing runs from the ostrze to full railhead width + CHECK(std::abs(geometry.planing_end - blade_run_to_offset(kRadius, 0.0, kRailtop)) < + 1e-12); + CHECK(std::abs(gap_at(start, geometry.blade_planed) - kRailtop) < 1e-9); + CHECK(std::abs(geometry.planing_length - (geometry.planing_end - kLead)) < 1e-12); + CHECK(geometry.planing_end > geometry.nose_end); +} + +void a_lead_derived_from_u_and_d_lands_on_the_table() { + // Koc, tablica 5.1: a, d and u for the iglice styczne do opornicy o ściętym + // ostrzu. They obey a + d = the run to a gap of u, which is what lets a preset + // carry only u and d. The table dimensions it on the rail, as a drawing does, so + // the axis station is scaled back up by R1/R; the numbers then agree to a hair at + // 1200 m and to millimetres at 190 + struct Row { + double radius, a, d, u, tolerance; + }; + constexpr Row rows[]{ + {190.0, 1.2483, 0.1250, 0.0049, 7e-3}, {300.0, 1.5000, 0.1250, 0.0044, 2e-3}, + {500.0, 2.1650, 0.1250, 0.0053, 1.4e-2}, {760.0, 2.6330, 0.1250, 0.0050, 2e-4}, + {1200.0, 3.3347, 0.1304, 0.0050, 1e-4}, + }; + for (const auto& row : rows) { + const double rail = row.radius + kHalfGauge; + const double drawn = + blade_run_to_offset(row.radius, 0.0, row.u) * rail / row.radius; + CHECK(std::abs(drawn - (row.a + row.d)) < row.tolerance); } } -void a_blade_reaching_the_crossing_angle_is_rejected() { - auto turnout = rz_1_9_r190(); - turnout.blade_angle = std::atan(1.0 / 9.0); // nothing left for the curve to turn - CHECK(!lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout).valid); +void a_straight_blade_is_planed_by_its_break() { + // iglica prosta sieczna: the same two dimensions, taken off the break angle + const double beta = 0.02; + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, + Blade{0.005, 0.0, 0.070}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, 3.5, 0.0, 0.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, 6.0, 0.0, 0.0, beta}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, 190.0 * (kAlfa19 - beta), + 190.0, 190.0, 0.0}); - turnout.blade_angle = -0.01; - 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()); + const auto geometry = laid(turnout); 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_zero_length_blade_keeps_its_break() { +void a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start() { auto turnout = rz_1_9_r190(); - turnout.pre_blade = 3.5; - turnout.blade_angle = 0.02; - - const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + turnout.blade = Blade{0.0, 0.0, 0.0}; + const auto geometry = laid(turnout); CHECK(geometry.valid); - CHECK(geometry.path.size() >= 2); - CHECK(std::abs(geometry.path[1].turn_in - 0.02) < 1e-12); - CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - - std::atan(1.0 / 9.0)) < 1e-9); + CHECK(geometry.has_blade); + CHECK(std::abs(geometry.blade_station - kLead) < 1e-12); + CHECK(geometry.nose_end == 0.0); + CHECK(geometry.planing_length == 0.0); + CHECK(geometry.blade_residual == 0.0); } -// A right-hand turnout is the mirror image: the blade breaks the other way. -void a_right_hand_blade_breaks_the_other_way() { +void a_catalogue_list_closes_on_its_own_numbers() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + CHECK(std::abs(geometry.angle_residual) < 1e-9); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9); + // the two tangent legs are the drawing's a and b, and L is their sum + CHECK(std::abs(geometry.tangent_front - 10.523) < 1e-3); + CHECK(std::abs(geometry.tangent_back - 16.615) < 1e-3); + CHECK(std::abs(geometry.tangent_front + geometry.tangent_back - 27.138) < 1e-3); + CHECK(std::abs(geometry.length_residual) < 1e-9); +} + +void the_switch_ends_on_the_straight_too() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + // koniec rozjazdu as the straight sees it: the frog projected back onto it, which + // is shorter than a + b and is the second point marked on the tor zasadniczy + CHECK(std::abs(geometry.through_length - + (kRadius * std::sin(kAlfa19) + kFrogRail * std::cos(kAlfa19))) < 1e-9); + CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back); + CHECK(std::abs(geometry.through_end.x - geometry.through_length) < 1e-12); + CHECK(geometry.through_end.y == 0.0); + CHECK(geometry.through_end.hx == 1.0); +} + +void a_list_that_misses_the_mark_is_still_laid_and_said_out_loud() { + auto turnout = rz_1_9_r190(); + turnout.path[2].length -= 2.0; // two metres of curve short of the crossing angle + const auto geometry = laid(turnout); + CHECK(geometry.valid); // it lays: the numbers are the author's business + CHECK(std::abs(geometry.angle_residual + 2.0 / kRadius) < 1e-9); + CHECK(geometry.length_residual != 0.0); +} + +void a_degenerate_piece_is_refused() { + auto turnout = rz_1_9_r190(); + turnout.path[2].length = 0.0; + CHECK(!laid(turnout).valid); + + turnout = rz_1_9_r190(); + turnout.path[1].radius_start = -190.0; + CHECK(!laid(turnout).valid); + + turnout = rz_1_9_r190(); + turnout.path.clear(); + CHECK(!laid(turnout).valid); +} + +void a_right_hand_turnout_is_the_mirror_of_a_left_one() { + const auto left = laid(rz_1_9_r190()); auto turnout = rz_1_9_r190(); turnout.side = DivergeSide::Right; - 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); + const auto geometry = laid(turnout); CHECK(geometry.valid); - CHECK(std::abs(std::atan2(pose_after(geometry, 1).hy, pose_after(geometry, 1).hx) + - 0.02) < 1e-9); - CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + - std::atan(1.0 / 9.0)) < 1e-9); + CHECK(std::abs(geometry.path[1].k0 + 1.0 / kRadius) < 1e-12); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + kAlfa19) < 1e-9); + CHECK(std::abs(geometry.through_length - left.through_length) < 1e-9); + // the residual is not signed by the hand: a right turnout closes just as well + CHECK(std::abs(geometry.angle_residual) < 1e-9); + // and the blade is dimensioned the same, on the other side + CHECK(std::abs(geometry.nose_end - left.nose_end) < 1e-12); + CHECK(std::abs(geometry.blade_angle - left.blade_angle) < 1e-12); + CHECK(std::abs(geometry.blade_nose_end.y + left.blade_nose_end.y) < 1e-12); +} + +void a_basket_is_three_pieces_and_nothing_special() { + // łuk koszowy: 190 m eased to 300 m by a clothoid, ending on the same angle + const double first = 8.0; + const double transition = 6.0; + // deflection: first/190 + transition*(1/190 + 1/300)/2 + rest/300 == alfa + const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0); + const double rest = (kAlfa19 - turned) * 300.0; + + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, + Blade{0.0049, 0.125, 0.070}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, rest, 300.0, 300.0, 0.0}); + + const auto geometry = laid(turnout); + CHECK(geometry.valid); + CHECK(std::abs(geometry.angle_residual) < 1e-9); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9); + const Pose end = pose_after(geometry, geometry.path.size() - 1); + CHECK(std::abs(end.x - geometry.frog.x) < 1e-9); + CHECK(std::abs(end.y - geometry.frog.y) < 1e-9); + // a blade starting at PR has no lead ahead of it, so a is nothing and the + // residual is what the type's own d asks for against the curve + CHECK(geometry.blade_station == 0.0); + CHECK(std::abs(geometry.blade_residual - (0.125 - geometry.nose_end)) < 1e-12); +} + +TurnoutGeometry laid_bent(const Turnout& turnout, double bend) { + return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout, bend); +} + +// The bend that turns the tor zasadniczy by @p beta over the turnout. Every leg of +// the triangle has the same tangent t, so a radius is t/tg(half its own angle). +double bend_for(double beta) { return std::tan(0.5 * beta) / kFrontTangent; } + +void a_bend_of_nothing_is_the_turnout_it_was() { + const auto plain = laid(rz_1_9_r190()); + const auto bent = laid_bent(rz_1_9_r190(), 0.0); + CHECK(bent.valid); + CHECK(bent.bend == 0.0); + CHECK(bent.diverging_curvature == 0.0); + CHECK(bent.path.size() == plain.path.size()); + for (std::size_t i = 0; i < plain.path.size(); ++i) { + CHECK(bent.path[i].k0 == plain.path[i].k0); + CHECK(bent.path[i].length == plain.path[i].length); + } + CHECK(bent.through_length == plain.through_length); + CHECK(bent.nose_end == plain.nose_end); +} + +void a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls() { + // Koc, 5.3.6: Rłs 49E1-380,292-1:9 z krzyżownicą prostą powstaje przez wygięcie + // Rz 49E1-190-1:9. Symmetric means the two tracks split the crossing angle, so + // the tor zasadniczy turns half of it the other way and both radii come out the + // same — and the catalogue's own 380,292 is what they come out at + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19)); + CHECK(geometry.valid); + CHECK(geometry.bend < 0.0); + CHECK(geometry.diverging_curvature > 0.0); + CHECK(std::abs(std::abs(1.0 / geometry.bend) - 380.292) < 0.02); + CHECK(std::abs(std::abs(1.0 / geometry.diverging_curvature) - 380.292) < 0.02); + CHECK(std::abs(geometry.bend_angle + 0.5 * kAlfa19) < 1e-12); +} + +void a_bend_lands_on_kocs_own_formulas() { + // (5.7) jednostronny and (5.8) dwustronny. The second is written here with the + // sign our curvature carries openly: Koc's R - R1 denominator is the same figure + // read off a drawing that has already decided which way the branch goes + const double t2 = kFrontTangent * kFrontTangent; + for (const double r1 : {320.0, 600.0, 2000.0}) { + const auto same = laid_bent(rz_1_9_r190(), 1.0 / r1); + CHECK(same.valid); + CHECK(std::abs(1.0 / same.diverging_curvature - + (kRadius * r1 - t2) / (kRadius + r1)) < 1e-9); + // bending with the branch makes it sharper, which is why R190 may not be + CHECK(1.0 / same.diverging_curvature < kRadius); + + const auto against = laid_bent(rz_1_9_r190(), -1.0 / r1); + CHECK(against.valid); + CHECK(std::abs(1.0 / against.diverging_curvature - + (kRadius * r1 + t2) / (r1 - kRadius)) < 1e-9); + CHECK(1.0 / against.diverging_curvature > kRadius); + } +} + +void a_bend_that_straightens_the_branch_still_lays() { + // wygięcie dwustronne aż tor zwrotny wychodzi prosty: the triangle is all + // tangent and no arc, so the path from PR is 2t of straight and the frog rail + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-kAlfa19)); + CHECK(geometry.valid); + CHECK(geometry.diverging_curvature == 0.0); + CHECK(std::abs(geometry.diverging_length - + (2.0 * kFrontTangent + kFrogRail)) < 1e-9); + CHECK(std::abs(geometry.frog.hy) < 1e-12); // it runs dead straight +} + +void bending_leaves_the_triangle_alone() { + // what łukowanie does not touch: the crossing angle, the tangent legs, and so + // the catalogue length. that is the whole point of rotating the triangle + for (const double beta : {-0.5 * kAlfa19, -0.02, 0.01, 0.03}) { + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(beta)); + CHECK(geometry.valid); + CHECK(std::abs(geometry.angle_residual) < 1e-9); + CHECK(std::abs(geometry.tangent_front - kFrontTangent) < 1e-9); + CHECK(std::abs(geometry.tangent_back - kBackTangent) < 1e-9); + CHECK(std::abs(geometry.length_residual) < 1e-9); + CHECK(std::abs(geometry.bend_angle - beta) < 1e-12); + } +} + +void a_bent_switch_keeps_its_blade() { + // Koc: zwrotnica i krzyżownica pozostają bez zmian co do wymiarów, należy je + // tylko wygiąć. The blade opens against an opornica that curves with it, so what + // dimensions it is the difference of the two — and a + d still lands on A, to + // well under the millimetre the rail is cut to + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19)); + CHECK(geometry.valid); + CHECK(geometry.has_blade); + // the rails are the same rails: a and the blade keep their lengths exactly + CHECK(geometry.blade_station == kLead); + CHECK(std::abs(geometry.path[1].length - kBlade) < 1e-12); + CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-3); + CHECK(std::abs(geometry.blade_residual) < 1e-3); + CHECK(geometry.blade_angle > 0.0); + // and the tip sits on the bent path: still off to the left, but half as far as + // it was, because the tor zasadniczy now runs away under it + const auto plain = laid(rz_1_9_r190()); + CHECK(geometry.blade_tip.y > 0.0); + CHECK(geometry.blade_tip.y < plain.blade_tip.y); +} + +void the_bent_switch_ends_on_its_own_arc() { + const double bend = bend_for(-0.5 * kAlfa19); + const auto geometry = laid_bent(rz_1_9_r190(), bend); + CHECK(geometry.valid); + // koniec rozjazdu is the frog brought back onto the tor zasadniczy along the + // radius, so it lands on the arc rather than on the straight PR set out along + const double radius = 1.0 / bend; + const double centre_y = radius; + CHECK(std::abs(std::hypot(geometry.through_end.x, + geometry.through_end.y - centre_y) - std::abs(radius)) < 1e-9); + CHECK(geometry.through_length > 0.0); + CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back); + CHECK(std::abs(geometry.through_end.y) > 1e-6); // the through track has left y=0 +} + +void a_path_with_no_one_radius_will_not_bend() { + // a łuk koszowy has no single triangle to rotate about, and a bend guessed at + // one of its radii would be a drawing of nothing + const double first = 8.0; + const double transition = 6.0; + const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0); + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, Blade{0.0049, 0.125, 0.070}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0}); + turnout.path.push_back( + TurnoutPiece{TurnoutPart::Curve, (kAlfa19 - turned) * 300.0, 300.0, 300.0, 0.0}); + CHECK(laid(turnout).valid); + CHECK(!laid_bent(turnout, 1.0 / 600.0).valid); +} + +void the_tangent_lengths_meet_where_the_tracks_would() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + // the through tangent and the frog tangent meet at one point: walking either + // way from the two ends has to land on it + const double cx = geometry.tangent_front; + CHECK(std::abs(geometry.frog.x - geometry.frog.hx * geometry.tangent_back - cx) < 1e-9); + CHECK(std::abs(geometry.frog.y - geometry.frog.hy * geometry.tangent_back) < 1e-9); } } // namespace int main() { - RUN(pre_blade_runs_along_the_through_track); - RUN(the_frog_still_lands_on_the_crossing_angle); - RUN(a_blade_reaching_the_crossing_angle_is_rejected); - RUN(a_bladeless_curve_still_starts_tangent); - RUN(a_zero_length_blade_keeps_its_break); - RUN(a_right_hand_blade_breaks_the_other_way); + RUN(a_tangential_blade_never_breaks_away); + RUN(a_planed_nose_reaches_its_thickness_where_the_table_says); + RUN(a_lead_derived_from_u_and_d_lands_on_the_table); + RUN(a_straight_blade_is_planed_by_its_break); + RUN(a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start); + RUN(a_catalogue_list_closes_on_its_own_numbers); + RUN(the_switch_ends_on_the_straight_too); + RUN(a_list_that_misses_the_mark_is_still_laid_and_said_out_loud); + RUN(a_degenerate_piece_is_refused); + RUN(a_right_hand_turnout_is_the_mirror_of_a_left_one); + RUN(a_basket_is_three_pieces_and_nothing_special); + RUN(the_tangent_lengths_meet_where_the_tracks_would); + RUN(a_bend_of_nothing_is_the_turnout_it_was); + RUN(a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls); + RUN(a_bend_lands_on_kocs_own_formulas); + RUN(a_bend_that_straightens_the_branch_still_lays); + RUN(bending_leaves_the_triangle_alone); + RUN(a_bent_switch_keeps_its_blade); + RUN(the_bent_switch_ends_on_its_own_arc); + RUN(a_path_with_no_one_radius_will_not_bend); return REPORT(); }