diff --git a/CMakeLists.txt b/CMakeLists.txt index 56cf1bce..06f5f6e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -516,6 +516,11 @@ add_subdirectory(ref/json) target_link_libraries(${PROJECT_NAME} nlohmann_json::nlohmann_json) target_include_directories(${PROJECT_NAME} PRIVATE ${NLOHMANN_JSON_INCLUDE_INSTALL_DIR}) +# track layout domain model used by the editor; plain C++20, no dependencies of its own +add_subdirectory(maj0sted) +set_target_properties(maj0sted PROPERTIES FOLDER "libraries") +target_link_libraries(${PROJECT_NAME} maj0sted::maj0sted) + if (WITH_DISCORD_RPC) diff --git a/maj0sted/.clang-format b/maj0sted/.clang-format new file mode 100644 index 00000000..e58a6fea --- /dev/null +++ b/maj0sted/.clang-format @@ -0,0 +1,12 @@ +--- +Language: Cpp +BasedOnStyle: Google +Standard: c++20 +ColumnLimit: 90 +IndentWidth: 4 +AccessModifierOffset: -4 +PointerAlignment: Left +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: false +SortIncludes: true +IncludeBlocks: Preserve diff --git a/maj0sted/.gitignore b/maj0sted/.gitignore new file mode 100644 index 00000000..8e43dbdd --- /dev/null +++ b/maj0sted/.gitignore @@ -0,0 +1,24 @@ +# Build output +/build/ +/out/ +/cmake-build-*/ + +# Compiled objects / archives +*.o +*.obj +*.a +*.lib +*.so +*.dylib + +# Editors / IDEs +.vscode/ +.idea/ +*.swp +.cache/ +compile_commands.json + +# WASM / web build artifacts (produced by web/build.sh) +web/maj0sted.js +web/maj0sted.wasm +web/serve diff --git a/maj0sted/CMakeLists.txt b/maj0sted/CMakeLists.txt new file mode 100644 index 00000000..e91c5677 --- /dev/null +++ b/maj0sted/CMakeLists.txt @@ -0,0 +1,113 @@ +cmake_minimum_required(VERSION 3.20) + +project(maj0sted + VERSION 0.1.0 + DESCRIPTION "Domain-Driven Design library 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 +# pulled in via add_subdirectory() from a larger project. +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(MAJ0STED_STANDALONE ON) +else() + set(MAJ0STED_STANDALONE OFF) +endif() + +# ---------------------------------------------------------------------------- +# The static library +# ---------------------------------------------------------------------------- +add_library(maj0sted STATIC + src/domain/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/io/project_serializer.cpp + src/io/svg_writer.cpp + src/render/plan_sampler.cpp + src/render/rail_renderer.cpp + src/web/solve.cpp + src/web/editor.cpp + src/web/ribbon.cpp + src/web/tile_cache.cpp + src/app/editor_document.cpp +) +add_library(maj0sted::maj0sted ALIAS maj0sted) + +target_compile_features(maj0sted PUBLIC cxx_std_20) + +target_include_directories(maj0sted PUBLIC + $ + $ +) + +if(MSVC) + target_compile_options(maj0sted PRIVATE /W4) +else() + target_compile_options(maj0sted PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wshadow) +endif() + +if(MAJ0STED_WARNINGS_AS_ERRORS) + if(MSVC) + target_compile_options(maj0sted PRIVATE /WX) + else() + target_compile_options(maj0sted PRIVATE -Werror) + endif() +endif() + +# ---------------------------------------------------------------------------- +# Tests +# ---------------------------------------------------------------------------- +if(MAJ0STED_BUILD_TESTS AND MAJ0STED_STANDALONE) + enable_testing() + add_subdirectory(tests) +endif() + +# ---------------------------------------------------------------------------- +# Examples +# ---------------------------------------------------------------------------- +if(MAJ0STED_BUILD_EXAMPLES AND MAJ0STED_STANDALONE) + add_subdirectory(examples) +endif() + +# ---------------------------------------------------------------------------- +# Install / package +# ---------------------------------------------------------------------------- +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +install(TARGETS maj0sted + EXPORT maj0stedTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(EXPORT maj0stedTargets + FILE maj0stedTargets.cmake + NAMESPACE maj0sted:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/maj0sted) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/maj0stedConfig.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfig.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/maj0sted) + +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfigVersion.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfigVersion.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/maj0sted) diff --git a/maj0sted/HANDOFF.md b/maj0sted/HANDOFF.md new file mode 100644 index 00000000..f1f59993 --- /dev/null +++ b/maj0sted/HANDOFF.md @@ -0,0 +1,184 @@ +# HANDOFF — maj0sted + +Stan prac i punkt zaczepienia dla kolejnej sesji (np. po przejściu na `claude` CLI). +Kod jest samoopisowy; ten plik to skrót "gdzie jesteśmy i co dalej". + +## Czym jest projekt +Statyczna biblioteka **C++20** (DDD) do edytora torowiska. Czysty model domenowy, +bez zależności zewnętrznych. Namespace: `maj0sted::domain`. + +**Uwaga (sandbox):** na tej maszynie wyłączone są nieuprzywilejowane user +namespaces, więc domyślny sandbox komend pada w preflight (komenda „nie zwraca +statusu”). Uruchamiaj polecenia powłoki **z wyłączonym sandboxem** (w Cursorze: +`required_permissions: ["all"]`). Wtedy `git`, `g++`, `node` działają normalnie. + +**Uwaga:** `cmake` NIE jest zainstalowany. Weryfikacja odbywa się przez `g++ 14.2`. +Toolchain WASM: `emsdk` jest w `~/emsdk` (`source ~/emsdk/emsdk_env.sh` dodaje `em++`). + +## Jak weryfikować (bez cmake) +```bash +cd /home/maj00r/Projects/maj0sted +FLAGS="-std=c++20 -Iinclude -Wall -Wextra -Wpedantic -Wconversion -Wshadow" +SRC="src/domain/*.cpp src/io/*.cpp src/render/*.cpp src/web/solve.cpp src/web/tile_cache.cpp src/app/editor_document.cpp" +for t in map_project niweleta plan_alignment parallelism fitting serialization render web tile_cache document; do + g++ $FLAGS -Itests $SRC tests/test_$t.cpp -o /tmp/t_$t && /tmp/t_$t | tail -1 +done +``` +Wszystkie testy przechodzą, kompilacja bez ostrzeżeń. Oszczędnie: kompiluj tylko +zmieniony test z minimalnym zestawem źródeł (np. `tile_cache` wymaga tylko +`src/web/tile_cache.cpp`; `document` — `src/domain/*.cpp src/io/project_serializer.cpp +src/app/editor_document.cpp`). Szybki syntax-check: `g++ $FLAGS -fsyntax-only ...`. + +Budowa WASM: `source ~/emsdk/emsdk_env.sh && cd web && ./build.sh` +(produkuje `web/maj0sted.js` + `.wasm`, oba w `.gitignore`). + +## Model — co już jest (✓ = zaimplementowane + testy) + +### Value Objects (`include/maj0sted/domain/value_objects/`) +- `Crs` — układ EPSG, domyślnie **EPSG:2180** (metry) ✓ +- `CartesianPosition` — (x=easting, y=northing), metry, double ✓ +- `Length` (≥0), `Radius` (>0) ✓ +- `Azimuth` — radiany, **zgodnie z ruchem wskazówek zegara od północy (+y)**, znormalizowany [0,2π) ✓ +- `NiweletaId`, `StraightId` — silne tożsamości ✓ +- `TrackOffset` — dystans (>0) + `Side::Left/Right` ✓ + +### Agregaty +- `MapProject` — **projekt**: trzyma `Crs` (domyślnie EPSG:2180) + kolekcję niwelet + (`add_niweleta`, `niwelety()`) ✓ +- `Niweleta` (`domain/niweleta.hpp`) — id, nazwa, plan + profil. **Sama nadaje `StraightId`**: + `add_straight(start,end,joint) -> StraightId`; `add_plan_element` mintuje id anonimowej prostej + (i podbija licznik przy id wczytanym z pliku). `lengths_consistent()`. + **`apply_fit(FitResult)`** — wpina wynik fitowania w plan: podmienia sąsiednie proste + (entry, exit) na `entry → curve... → exit` stykami Tangent; proste zachowują id ✓ + +### Geometria planu (`domain/geometry/`) +- `PlanElement = variant` + - `Straight` — **zakotwiczony w XY (start/end)**, azymut i długość liczone; opcjonalny `StraightId`. + Zasada: prosta trzyma linię nośną (pozycja+azymut), zmienia się tylko długość; krzywe to wypadkowa. + - `CircularArc` — promień + kierunek + **opcjonalna** długość + - `TransitionCurve` (klotoida) — długość + promienie końcowe (`optional`=∞) + kierunek +- `HorizontalAlignment` — lista kierunkowa + `JointContinuity{Tangent, AzimuthBreak}`. Reguły styku ✓: + - sąsiednie proste Tangent: muszą się stykać i mieć równy azymut + - różny azymut prostych → wymaga `AzimuthBreak` (tylko prosta-prosta, muszą się stykać, różne azymuty) + - sąsiednie łuki o tym samym promieniu i kierunku → zabronione + - styki z krzywą akceptowane "pending fit" + +### Geometria profilu +- `ProfileElement = variant`; `VerticalAlignment`. (Profil na razie nietknięty.) + +### Równoległość (`domain/parallelism/`) +- `TrackParallelism { StraightId origin, StraightId parallel, TrackOffset }` — **referencje wprost po StraightId**; + niezmienniki: oba id niepuste, różne ✓ +- `ParallelismService`: `mark_parallel(originStraight, parallelStraight, offset)`, `are_parallel`, + `derive_parallel_line`, `perpendicular_distance` ✓ + +### Fitowanie (`domain/fitting/`) +- `FitParameters { entry, exit, radius, optional transition_length }` — klotoida symetryczna lub sam łuk +- `CompoundFitParameters { entry, exit, radius1, radius2, split_station }` — **łuk koszowy** + (miejsce podziału = kilometraż pierwszego łuku; `δ1 = split_station/R1`) +- `FitResult { entry(przycięta), curve[], exit(przycięta), tangent_in, tangent_out, direction }` +- `FittingService`: + - `fit_between_straights` — klotoida–łuk–klotoida (lub sam łuk), przycina obie proste ✓ + - `fit_compound` — dwa łuki różnych promieni; **obie linie prostych bez zmian**, obie proste + doprzycięte na długości; podział ustala `δ1`. Metoda stycznej pośredniej ✓ + +### Serializacja (`maj0sted::io`, `include/maj0sted/io/project_serializer.hpp`) +- `serialize(MapProject) -> std::string` / `deserialize(std::string) -> MapProject` ✓ +- Format tekstowy, liniowy, bez zależności; liczby przez `to_chars`/`from_chars` (dokładny round-trip, + niezależny od locale). Round-trip jest bajtowo stabilny (test to sprawdza). +- Zapisuje: CRS + niwelety (id, nazwa, plan z elementami i stykami, profil). **NIE** zapisuje relacji + równoległości (patrz niżej — StraightId nie są globalnie unikalne). + +### Rendering: dwie szyny +`src/web/editor.cpp` rysuje **dwie szyny w rozstawie 1500 mm** (`kHalfGauge=0.75`, +±750 mm od osi) zamiast samej osi. `offset_points` przesuwa linię osi wzdłuż +normalnej per-wierzchołek (różnica centralna, normalna lewa `(-dy,dx)`), +`push_rails` emituje lewą i prawą szynę zachowując `kind` (kolor). Uchwyty edycji +(węzły/końce prostych) w JS dalej rysują się z osi (model). + +### Warstwa web (WASM) — biblioteka robi wszystko, JS tylko rysuje +Zasada: **cała logika jest w libce**; `web/` to cienki renderer + przekazywanie +wejścia. `web/maj0sted_bindings.cpp` (embind) to jedyny plik zależny od emscripten. + +- **Kafle podkładu WMS** (`maj0sted::web`, `web/tile_cache.hpp/.cpp`) ✓ + - `TileGrid` — **jeden stały grid kafli 100 m × 100 m** w **EPSG:2180** + (`kCellMetres=100`, **1024 px/kafel** → 100/1024 m/px, origin (0,0), jeden + poziom `kLevel=0`, **bez piramidy per-zoom**); `tile_at`, `bbox`, + `tiles_for_view`, `zoom_for_resolution` (argument zoom ignorowany, sygnatury + zachowane dla boundary). Czysta, testowana natywnie. + - **Retry:** nieudany fetch jest ponawiany **maks. `kMaxRetries=10` razy na + kafel** (licznik `TileImage.attempts`); po przekroczeniu limitu kafel dostaje + status `Failed`. Pod Emscripten retry jest odroczony async + (`emscripten_async_call`, `kRetryDelayMs=1500`), bez blokowania/rekurencji; + z wstrzykniętym fetcherem (testy) powtarza od razu. + - `WmsConfig` + `wms_getmap_url` — WMS **1.1.1** GetMap (BBOX = minx,miny,maxx,maxy), + presety Geoportalu (ortofoto/topo). Liczby przez `to_chars`. + - `TileService` — cache + **fetch**: na trafienie zwraca z cache, na pudło sam + wykonuje GetMap. Pod `__EMSCRIPTEN__` używa **Emscripten Fetch** (`-sFETCH`); + natywnie/w testach przez wstrzykiwany `Fetcher` (bez sieci). Callback + `on_ready` budzi przerysowanie w JS. + - **Proxy WMS (`web/serve.py`):** przeglądarka pobiera kafle **same-origin** + (`/wms?...`), a lokalny serwer pobiera je serwer-serwer z Geoportalu. Powód: + WAF Geoportalu **zwraca 404 dla User-Agentów zawierających „Mozilla"** (czyli + dla prawdziwych przeglądarek), a przepuszcza proste UA (`curl`), więc bezpośredni + `fetch`/`emscripten_fetch` z przeglądarki dostaje 404. Proxy używa UA `curl/8.5.0`. + Frontend ustawia `Module.wmsSetConfig({base_url: origin+'/wms'})` w `finishBoot`. + Libka nadal robi fetch (`emscripten_fetch`) — tylko na `localhost`. + Uruchamianie: `python3 web/serve.py 8080` (zamiast `http.server`). + - **Cache na dysku:** proxy odsyła udane kafle z `Cache-Control: public, + max-age=31536000, immutable`, więc przeglądarka trzyma je w HTTP cache na dysku — + po odświeżeniu strony kafle są brane z cache, bez ponownego pobierania. Błędy + (`502`) idą z `no-store` (nie cache'ują się). Uwaga: cache `TileService` w C++ jest + tylko w pamięci WASM (ginie przy refreshu) — trwałość zapewnia właśnie HTTP cache. + - **Retry w proxy:** WAF Geoportalu bywa niestabilny (sporadyczne 404/reset nawet dla + UA `curl`), więc proxy ponawia pobranie `WMS_PROXY_RETRIES=6` razy zanim zwróci 502; + do tego dochodzi client-side retry libki (10×). +- **Trwałość projektu** (`maj0sted::app`, `app/editor_document.hpp/.cpp`) ✓ + - `EditorDocument` = model GUI (niezależne proste + parametry fitów) — bezstratny. + - `serialize_document`/`deserialize_document` — **reużywa `io::serialize`** (osadza + prawdziwy projekt maj0sted) + sekcja `editor` z prostymi i parametrami fitów. + - `to_map_project`/`from_map_project` — konwersja do/z `MapProject` (best-effort, + nie rzuca). + - `save_project`/`load_project` — **I/O plikowe w libce** (`std::fstream` + + `std::filesystem`), domyślna ścieżka `/data/maj0sted/project.m0s`. +- **Fit nieudany → „brak" (decyzja w libce):** `solve_project` (editor) zwraca per + niweleta `polylines` **oraz `applied_fits`** — autorytatywną listę fitów, które + faktycznie zaszły. Fit, którego nie da się policzyć (zły parametr/geometria, punkty + styczne poza odcinkami prostych), jest **odrzucany w libce** i nie ma go w `applied_fits`. + Frontend tylko lustrzanie przyjmuje tę listę (bez własnej reguły o porażce). +- **Boundary (embind, cienki):** `solveProject`, `wmsTilesForView`, + `wmsZoomForResolution`, `wmsTileBytes`, `wmsSetOnReady`, `wmsSetConfig`, + `saveProject`, `loadDefaultProject`. +- **Frontend (`web/index.html`):** rysuje podkład WMS pod geometrią (georeferencja + EPSG:2180, trzyma się przy pan/zoom); `Ctrl+S` → `saveProject` + `FS.syncfs`; + na starcie montuje **IDBFS** w `/data`, `syncfs(true)` i `loadDefaultProject` + (fallback: demo). Flow budowy: prosta → krzywa (czeka jako „pending”, rysowana + przerywaną) → kolejna prosta kotwiczy ją z obu stron i **dopiero wtedy** liczony + jest fit. + +## Konwencje geometryczne +- EPSG:2180, jednostka = metr; kierunek jazdy `d = (sin az, cos az)`; normalna lewa `(-cos az, sin az)`. +- Tolerancje: pozycja `1e-6 m`, azymut `1e-9 rad`. + +## Znane ograniczenia +- **`StraightId` są lokalne per-niweleta** (licznik startuje od 1 w każdej niwelecie), więc NIE są + globalnie unikalne. `TrackParallelism` referuje proste po samym `StraightId` — działa dla luźnych + obiektów, ale w projekcie z wieloma niweletami trzeba by referować `(NiweletaId, StraightId)`. + Do przemyślenia, jeśli równoległość ma być trwała/serializowana. + +## Następne kroki (do decyzji / do zrobienia) +1. **Klotoidy w łuku koszowym** (między łukami i/lub na końcach). (NIE zrobione.) +2. Asymetryczne klotoidy w `fit_between_straights` (teraz tylko symetryczne). (NIE zrobione.) +3. **Rejestr relacji równoległości** + poprawka referencji na `(NiweletaId, StraightId)` i ich serializacja. (NIE.) +4. **Solver** ustawiający prostą równoległą na `derive_parallel_line` i przeliczający resztę. (NIE.) +5. Instalacja `cmake`/`ninja` i budowa przez CMake (na razie tylko `g++`). + +## Preferencje pracy (ustalone z użytkownikiem) +- **Nie edytować `README.md`** (utrzymuje go użytkownik). +- Robić **tylko to, o co proszono** — bez dokładania funkcji z własnej inicjatywy. +- **Oszczędzać CPU**: kompilować tylko zmieniony test, `-fsyntax-only` do szybkiej walidacji, + pełne testy na koniec/na żądanie; bez procesów w tle, przeglądarki i podagentów. +- Język domeny: **niweleta** = jednolity odcinek toru złożony z wielu krzywych w planie i profilu. + +## Git +Repozytorium zainicjalizowane; pliki dodane do stage'a, **brak commitów** (commit dopiero na wyraźną prośbę). diff --git a/maj0sted/README.md b/maj0sted/README.md new file mode 100644 index 00000000..5f3a916b --- /dev/null +++ b/maj0sted/README.md @@ -0,0 +1,86 @@ +# maj0sted + +Statyczna biblioteka **C++20** do obsługi **edytora torowiska**, zbudowana +zgodnie z **Domain-Driven Design (DDD)**. + +Biblioteka dostarcza czysty model domenowy — bez zależności od UI, bazy danych +czy frameworka. Warstwy aplikacji i infrastruktury podpina się do zdefiniowanych +tu typów. + +## Model domenowy + +``` +maj0sted::domain +├── value_objects/ Value Objects (niezmienne, porównywane przez wartość) +│ ├── Crs układ odniesienia (kod EPSG), domyślnie EPSG:2180 +│ └── CartesianPosition pozycja kartezjańska (x, y w jednostkach CRS) +└── MapProject projekt mapy — definiuje CRS dla wszystkich pozycji +``` + +Projekt mapy (`MapProject`) definiuje kartezjański układ odniesienia, w którym +wyrażane są wszystkie pozycje. Domyślnie jest to **EPSG:2180** (PUWG 1992 / +Poland CS92), którego jednostką jest metr. + +## Szybki start + +```cpp +#include "maj0sted/maj0sted.hpp" +using namespace maj0sted::domain; + +MapProject project; // domyślnie EPSG:2180 +int epsg = project.crs().epsg(); // 2180 + +CartesianPosition p{500000.0, 300000.0}; // x, y w metrach +``` + +Pełniejszy przykład: [`examples/basic_editor.cpp`](examples/basic_editor.cpp). + +## Budowanie + +Wymagania: kompilator C++20 (GCC 11+/Clang 14+/MSVC 19.3+) oraz CMake ≥ 3.20. + +```bash +cmake -S . -B build # dodaj -G Ninja, jeśli masz ninja +cmake --build build +ctest --test-dir build --output-on-failure +./build/examples/maj0sted_example +``` + +### Opcje CMake + +| Opcja | Domyślnie | Znaczenie | +|------------------------------|-----------|-------------------------------| +| `MAJ0STED_BUILD_TESTS` | `ON` | buduj testy jednostkowe | +| `MAJ0STED_BUILD_EXAMPLES` | `ON` | buduj przykłady | +| `MAJ0STED_WARNINGS_AS_ERRORS`| `OFF` | traktuj ostrzeżenia jak błędy | + +### Użycie w innym projekcie + +Po `cmake --install build` (albo przez `add_subdirectory`): + +```cmake +find_package(maj0sted REQUIRED) +target_link_libraries(twoj_target PRIVATE maj0sted::maj0sted) +``` + +## Struktura projektu + +``` +maj0sted/ +├── CMakeLists.txt +├── include/maj0sted/ nagłówki publiczne (API) +├── src/domain/ implementacje (.cpp) +├── tests/ testy jednostkowe (CTest, bez zależności) +├── examples/ przykłady użycia +└── cmake/ pliki pakietu find_package +``` + +## Testy + +Testy używają własnego, malutkiego frameworka (`tests/check.hpp`) — nie wymagają +żadnych zewnętrznych bibliotek. Każdy plik testowy to osobny plik wykonywalny +zwracający kod wyjścia dla CTest. + +## Licencja + +Do uzupełnienia (np. MIT) — dodaj plik `LICENSE`. diff --git a/maj0sted/cmake/maj0stedConfig.cmake.in b/maj0sted/cmake/maj0stedConfig.cmake.in new file mode 100644 index 00000000..33cbabc9 --- /dev/null +++ b/maj0sted/cmake/maj0stedConfig.cmake.in @@ -0,0 +1,5 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/maj0stedTargets.cmake") + +check_required_components(maj0sted) diff --git a/maj0sted/examples/CMakeLists.txt b/maj0sted/examples/CMakeLists.txt new file mode 100644 index 00000000..227f9e31 --- /dev/null +++ b/maj0sted/examples/CMakeLists.txt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..00e4dd84 --- /dev/null +++ b/maj0sted/examples/basic_editor.cpp @@ -0,0 +1,20 @@ +// 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 new file mode 100644 index 00000000..063ce687 --- /dev/null +++ b/maj0sted/examples/plan_to_svg.cpp @@ -0,0 +1,58 @@ +// 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/editor_document.hpp b/maj0sted/include/maj0sted/app/editor_document.hpp new file mode 100644 index 00000000..2a26147c --- /dev/null +++ b/maj0sted/include/maj0sted/app/editor_document.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +#include "maj0sted/domain/map_project.hpp" +#include "maj0sted/web/editor.hpp" // NiweletaSpec (the editor's GUI model) + +namespace maj0sted::app { + +/// The editor's persistable document: the GUI model exactly as the user built +/// it — independent anchored straights plus the parametric fits between them. +/// +/// This is the source of truth for save/restore because it is lossless: it keeps +/// the untrimmed straights and the fit *parameters* (radius, transition lengths, +/// compound arcs, ...), which the resolved domain geometry alone cannot express. +struct EditorDocument { + std::vector niwelety; +}; + +/// Best-effort resolved-domain view of @p document: one Niweleta per editor +/// niweleta, keeping the anchored straights (a floating placeholder arc sits in +/// each gap so the alignment stays valid). Used for interop / inspection and so +/// the on-disk file embeds a real maj0sted project. Never throws. +[[nodiscard]] maj0sted::domain::MapProject to_map_project(const EditorDocument& document); + +/// Imports a plain domain project into the editor model: every Straight in each +/// niweleta's plan becomes an editor straight (fits are left empty — resolved +/// geometry carries no fit parameters to recover). +[[nodiscard]] EditorDocument from_map_project(const maj0sted::domain::MapProject& project); + +/// Serializes @p document to the library's textual format. The document embeds a +/// real maj0sted project (via maj0sted::io::serialize of to_map_project) followed +/// by an `editor` section that captures the straights and fit parameters +/// losslessly. @c deserialize_document restores it exactly. +[[nodiscard]] std::string serialize_document(const EditorDocument& document); + +/// Rebuilds an EditorDocument from text produced by @c serialize_document. +/// @throws std::runtime_error on malformed input. +[[nodiscard]] EditorDocument deserialize_document(const std::string& text); + +/// The default on-disk project path. Under Emscripten this lives beneath the +/// IDBFS mount point (see web/index.html), so it survives reloads once flushed. +[[nodiscard]] std::string default_project_path(); + +/// Serializes @p document and writes it to @p path (creating parent directories). +/// @returns true on success; never throws. +bool save_project(const EditorDocument& document, + const std::string& path = default_project_path()); + +/// Reads and deserializes the project at @p path, or std::nullopt if the file is +/// absent or unreadable/malformed. Never throws. +[[nodiscard]] std::optional load_project( + const std::string& path = default_project_path()); + +} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp b/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp new file mode 100644 index 00000000..3d0e1b87 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp @@ -0,0 +1,55 @@ +#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 new file mode 100644 index 00000000..a4d541a5 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp @@ -0,0 +1,25 @@ +#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 new file mode 100644 index 00000000..8d1f97fe --- /dev/null +++ b/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp @@ -0,0 +1,69 @@ +#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 new file mode 100644 index 00000000..eb76e5cc --- /dev/null +++ b/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp @@ -0,0 +1,74 @@ +#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 new file mode 100644 index 00000000..fe1e08c8 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp @@ -0,0 +1,60 @@ +#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 new file mode 100644 index 00000000..b4281287 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp @@ -0,0 +1,127 @@ +#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 new file mode 100644 index 00000000..9866cb1e --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp @@ -0,0 +1,53 @@ +#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/segment_layout.hpp b/maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp new file mode 100644 index 00000000..4716824f --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace maj0sted::domain::geometry { + +struct XY { + double x; + double y; +}; + +/// Position plus unit heading, in (east=x, north=y). +struct Pose { + double x; + double y; + double hx; + double hy; +}; + +/// Lays out one segment of linearly varying signed curvature, starting at +/// @p start: a circular arc when @p k0 == @p k1, a clothoid otherwise, a +/// straight when both are zero. When @p out is non-null the sampled points +/// (including the start point) are appended to it. Returns the segment's end +/// pose. +/// +/// Both the fitting solver and the renderer call this so their geometry is +/// bit-for-bit identical — the fitted tangent points then coincide exactly with +/// the drawn curve, with no lateral gap even for tight arcs. +inline Pose layout_segment(double k0, double k1, double length, Pose start, + std::vector* out) { + const double left_x = -start.hy; + const double left_y = start.hx; + const auto emit = [&](double local_x, double local_y) { + if (out != nullptr) { + out->push_back({start.x + local_x * start.hx + local_y * left_x, + start.y + local_x * start.hy + local_y * left_y}); + } + }; + + emit(0.0, 0.0); + + double end_x = 0.0; + double end_y = 0.0; + if (length > 0.0) { + const double turned = std::abs(0.5 * (k0 + k1) * length); + const int steps = std::clamp( + static_cast(std::ceil(std::max( + length / 0.5, turned * 180.0 / std::numbers::pi / 2.0))), + 8, 512); + const double ds = length / steps; + + if (std::abs(k1 - k0) < 1e-12) { + const double k = k0; + if (std::abs(k) < 1e-12) { + end_x = length; // straight + emit(end_x, 0.0); + } else { + for (int i = 1; i <= steps; ++i) { // circular arc, exact + const double s = ds * i; + end_x = std::sin(k * s) / k; + end_y = (1.0 - std::cos(k * s)) / k; + emit(end_x, end_y); + } + } + } else { + const double slope = (k1 - k0) / (2.0 * length); // clothoid + for (int i = 1; i <= steps; ++i) { + const double s_mid = ds * (i - 0.5); + const double theta = k0 * s_mid + slope * s_mid * s_mid; + end_x += std::cos(theta) * ds; + end_y += std::sin(theta) * ds; + emit(end_x, end_y); + } + } + } + + const double dtheta = 0.5 * (k0 + k1) * length; + const double cos_t = std::cos(dtheta); + const double sin_t = std::sin(dtheta); + return Pose{start.x + end_x * start.hx + end_y * left_x, + start.y + end_x * start.hy + end_y * left_y, + start.hx * cos_t - start.hy * sin_t, + start.hx * sin_t + start.hy * cos_t}; +} + +} // namespace maj0sted::domain::geometry diff --git a/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp b/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp new file mode 100644 index 00000000..41c23fb3 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp @@ -0,0 +1,31 @@ +#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 new file mode 100644 index 00000000..a8879605 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/map_project.hpp @@ -0,0 +1,32 @@ +#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 new file mode 100644 index 00000000..c6cafae2 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/niweleta.hpp @@ -0,0 +1,70 @@ +#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 new file mode 100644 index 00000000..4a308e09 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp @@ -0,0 +1,35 @@ +#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); +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp b/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp new file mode 100644 index 00000000..9a62c0e0 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp @@ -0,0 +1,38 @@ +#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 new file mode 100644 index 00000000..8c7c73a8 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp @@ -0,0 +1,67 @@ +#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 new file mode 100644 index 00000000..60dbe82d --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp @@ -0,0 +1,27 @@ +#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 new file mode 100644 index 00000000..6ab6775a --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/crs.hpp @@ -0,0 +1,29 @@ +#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 new file mode 100644 index 00000000..a86c10b5 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/length.hpp @@ -0,0 +1,37 @@ +#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 new file mode 100644 index 00000000..067367df --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp @@ -0,0 +1,36 @@ +#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 new file mode 100644 index 00000000..e00119fe --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/radius.hpp @@ -0,0 +1,30 @@ +#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 new file mode 100644 index 00000000..d61a6724 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp @@ -0,0 +1,36 @@ +#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 new file mode 100644 index 00000000..c4e5d69c --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp @@ -0,0 +1,31 @@ +#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/io/project_serializer.hpp b/maj0sted/include/maj0sted/io/project_serializer.hpp new file mode 100644 index 00000000..e43c8873 --- /dev/null +++ b/maj0sted/include/maj0sted/io/project_serializer.hpp @@ -0,0 +1,19 @@ +#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/svg_writer.hpp b/maj0sted/include/maj0sted/io/svg_writer.hpp new file mode 100644 index 00000000..fd9e1168 --- /dev/null +++ b/maj0sted/include/maj0sted/io/svg_writer.hpp @@ -0,0 +1,20 @@ +#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 new file mode 100644 index 00000000..aeaab42c --- /dev/null +++ b/maj0sted/include/maj0sted/maj0sted.hpp @@ -0,0 +1,39 @@ +#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. + +#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" + +// 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" + +// Aggregates +#include "maj0sted/domain/map_project.hpp" +#include "maj0sted/domain/niweleta.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" diff --git a/maj0sted/include/maj0sted/render/plan_sampler.hpp b/maj0sted/include/maj0sted/render/plan_sampler.hpp new file mode 100644 index 00000000..7899806c --- /dev/null +++ b/maj0sted/include/maj0sted/render/plan_sampler.hpp @@ -0,0 +1,18 @@ +#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 new file mode 100644 index 00000000..e43f6d30 --- /dev/null +++ b/maj0sted/include/maj0sted/render/rail_renderer.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include + +#include "maj0sted/render/track_renderer.hpp" + +namespace maj0sted::render { + +/// A drawable polyline produced by RailRenderer: one rail, carrying the logical +/// metadata of the centreline element it came from so the host can colour and +/// hit-test it. +struct RailPolyline { + ElementKind kind{ElementKind::Straight}; + std::vector points; + int straight_index{-1}; + int gap{-1}; + int element_index{-1}; + double length{0.0}; + double radius_start{0.0}; + double radius_end{0.0}; +}; + +/// Renders each centreline as two parallel rails a fixed gauge apart — the track +/// is drawn as its rails, not its axis. A concrete TrackRenderer; substituting a +/// different one (single-axis, SVG, ...) needs no change to the solver. +class RailRenderer : public TrackRenderer { +public: + /// @param gauge_metres distance between the two rails (default 1.5 m). + explicit RailRenderer(double gauge_metres = 1.5) noexcept; + + void add(const CentrelineElement& element) override; + + [[nodiscard]] const std::vector& rails() const noexcept { + return rails_; + } + +private: + double half_gauge_; + std::vector rails_; +}; + +} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/render/scene.hpp b/maj0sted/include/maj0sted/render/scene.hpp new file mode 100644 index 00000000..843e07a8 --- /dev/null +++ b/maj0sted/include/maj0sted/render/scene.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace maj0sted::render { + +/// A point in the project's CRS (metres for EPSG:2180). +struct Point { + double x; + double y; +}; + +/// Which kind of plan element a polyline came from (lets a GUI colour it). +enum class ElementKind { Straight, Arc, Transition }; + +/// 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 new file mode 100644 index 00000000..14210973 --- /dev/null +++ b/maj0sted/include/maj0sted/render/track_renderer.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include "maj0sted/render/scene.hpp" // Point, ElementKind + +namespace maj0sted::render { + +/// One sampled centreline element of a solved niweleta, tagged with its logical +/// origin so a UI can colour, label and hit-test it. Rendering-neutral: it holds +/// the track axis only — no rails, no styling. This is the contract the solver +/// hands to a renderer. +struct CentrelineElement { + ElementKind kind{ElementKind::Straight}; + std::vector points; ///< sampled axis in CRS metres + 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) + double radius_end{0.0}; +}; + +/// Interface every renderer must satisfy. +/// +/// The solver emits rendering-neutral CentrelineElements; a renderer turns each +/// into concrete drawable geometry (a single axis line, two rails, an SVG path, +/// ...). A renderer holds NO business logic — it only shapes presentation, so +/// swapping renderers never changes what geometry the solver computed. +class TrackRenderer { +public: + virtual ~TrackRenderer() = default; + + /// Called once per centreline element, in plan order. + virtual void add(const CentrelineElement& element) = 0; +}; + +} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/version.hpp b/maj0sted/include/maj0sted/version.hpp new file mode 100644 index 00000000..4f280447 --- /dev/null +++ b/maj0sted/include/maj0sted/version.hpp @@ -0,0 +1,7 @@ +#pragma once + +// Version of the maj0sted library. Keep in sync with project() in CMakeLists.txt. +#define MAJ0STED_VERSION_MAJOR 0 +#define MAJ0STED_VERSION_MINOR 1 +#define MAJ0STED_VERSION_PATCH 0 +#define MAJ0STED_VERSION_STRING "0.1.0" diff --git a/maj0sted/include/maj0sted/web/editor.hpp b/maj0sted/include/maj0sted/web/editor.hpp new file mode 100644 index 00000000..84b092d5 --- /dev/null +++ b/maj0sted/include/maj0sted/web/editor.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline + +namespace maj0sted::web { + +/// 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}; +}; + +/// 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; +}; + +/// 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); + +} // namespace maj0sted::web diff --git a/maj0sted/include/maj0sted/web/ribbon.hpp b/maj0sted/include/maj0sted/web/ribbon.hpp new file mode 100644 index 00000000..85aa793e --- /dev/null +++ b/maj0sted/include/maj0sted/web/ribbon.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline + +namespace maj0sted::web { + +/// 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::web diff --git a/maj0sted/include/maj0sted/web/solve.hpp b/maj0sted/include/maj0sted/web/solve.hpp new file mode 100644 index 00000000..cd4dd8b1 --- /dev/null +++ b/maj0sted/include/maj0sted/web/solve.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include + +namespace maj0sted::web { + +/// A request from the GUI: three draggable vertices (entry straight A->B, exit +/// straight B->C) plus the fit parameters. Plain doubles so it maps trivially +/// onto a JS object / WASM boundary. This layer is pure C++ — the emscripten +/// bindings are a thin wrapper around it, and it is unit-tested natively. +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 WebPoint { + double x{0.0}; + double y{0.0}; +}; + +/// One polyline; @c kind is render::ElementKind (0 straight, 1 arc, 2 transition). +struct WebPolyline { + 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::web diff --git a/maj0sted/include/maj0sted/web/tile_cache.hpp b/maj0sted/include/maj0sted/web/tile_cache.hpp new file mode 100644 index 00000000..dfc4d163 --- /dev/null +++ b/maj0sted/include/maj0sted/web/tile_cache.hpp @@ -0,0 +1,186 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace maj0sted::web { + +/// A stable tile in the EPSG:2180 tiling pyramid. Tiles are addressed by their +/// zoom level and integer column/row, so the same ground square always maps to +/// the same key regardless of the current viewport — that is what makes the +/// cache reusable across pans and zooms. +struct TileKey { + int zoom{0}; + int x{0}; ///< column (increases with easting) + int y{0}; ///< row (increases with northing) + + friend bool operator==(const TileKey&, const TileKey&) = default; +}; + +struct TileKeyHash { + std::size_t operator()(const TileKey& k) const noexcept { + // Mix the three small integers into one hash. + std::uint64_t h = static_cast(static_cast(k.x)); + h = h * 1099511628211ULL ^ + static_cast(static_cast(k.y)); + h = h * 1099511628211ULL ^ static_cast(k.zoom); + return static_cast(h); + } +}; + +/// A bounding box in EPSG:2180 metres (easting = x, northing = y). +struct TileBBox { + double min_x{0.0}; + double min_y{0.0}; + double max_x{0.0}; + double max_y{0.0}; +}; + +/// The tiling scheme. A pure, static value type defining a single fixed square +/// grid of 100 m × 100 m ground cells over the EPSG:2180 plane — the same grid +/// at every zoom, *not* a per-zoom pyramid. Each cell has a deterministic ground +/// bounding box, so it is cached once and reused across all pans and zooms. +/// Nothing here touches the network — it is trivially unit testable natively. +/// +/// The zoom parameters on the methods below are ignored; they are kept only so +/// the host boundary (bindings/JS) stays unchanged. +class TileGrid { +public: + static constexpr int kTilePixels = 1024; ///< pixels per cell edge (GetMap WIDTH/HEIGHT) + static constexpr double kOriginX = 0.0; ///< easting of cell (0,0)'s left edge + static constexpr double kOriginY = 0.0; ///< northing of cell (0,0)'s bottom edge + /// Fixed cache cell size: every tile is a 100 m × 100 m ground square. + static constexpr double kCellMetres = 100.0; + /// The single grid level stored in every TileKey (there is only one grid). + static constexpr int kLevel = 0; + + /// Ground resolution (metres per pixel) of a cell. Constant (zoom ignored). + [[nodiscard]] static double resolution(int zoom = kLevel) noexcept; + + /// Edge length of a cell in metres — always kCellMetres (zoom ignored). + [[nodiscard]] static double tile_span(int zoom = kLevel) noexcept; + + /// The 100 m cell whose ground square contains (@p x, @p y). Zoom ignored. + [[nodiscard]] static TileKey tile_at(double x, double y, int zoom = kLevel) noexcept; + + /// The ground bounding box (EPSG:2180) of @p key. + [[nodiscard]] static TileBBox bbox(const TileKey& key) noexcept; + + /// The grid level for a target resolution. Always kLevel — the grid has a + /// single fixed 100 m level. Kept for boundary compatibility. + [[nodiscard]] static int zoom_for_resolution(double metres_per_pixel) noexcept; + + /// Every 100 m cell whose ground square intersects @p view, capped at + /// @p max_tiles to guard against absurd requests. Zoom ignored. + [[nodiscard]] static std::vector tiles_for_view(const TileBBox& view, + int zoom = kLevel, + int max_tiles = 64); +}; + +/// Configuration for a WMS GetMap source. Defaults target a public EPSG:2180 +/// endpoint (Polish Geoportal). Every field is data, so a different backend is a +/// pure configuration change with no code edits. +struct WmsConfig { + std::string base_url; + std::string layers; + std::string format{"image/jpeg"}; + std::string version{"1.1.1"}; ///< 1.1.1 keeps BBOX as minx,miny,maxx,maxy + std::string srs{"EPSG:2180"}; + int tile_pixels{TileGrid::kTilePixels}; + + /// Geoportal ortophoto (ortofotomapa), EPSG:2180, JPEG. + [[nodiscard]] static WmsConfig geoportal_ortho(); + + /// Geoportal topographic base map (BDOT/raster), EPSG:2180, PNG. + [[nodiscard]] static WmsConfig geoportal_topo(); +}; + +/// Builds the full WMS 1.1.1 GetMap URL for @p box using @p cfg. Numbers are +/// formatted locale-independently (std::to_chars), matching the rest of the +/// library's serialization. +[[nodiscard]] std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box); + +enum class TileStatus { Missing, Loading, Ready, Failed }; + +/// A cached tile image: raw encoded bytes (JPEG/PNG) plus their MIME type. +struct TileImage { + TileStatus status{TileStatus::Missing}; + std::vector bytes; + std::string content_type; + int attempts{0}; ///< failed fetch attempts so far (see TileService retry cap) +}; + +/// Owns the tile grid, the tile cache and the WMS fetch. This is the single +/// place that decides which tile is needed, whether it is already cached, and — +/// on a miss — issues the network GetMap request itself. Under Emscripten the +/// fetch uses the Emscripten Fetch API; natively (and in tests) a fetcher can be +/// injected, so the tiling/caching logic is exercised without a real network. +class TileService { +public: + /// Called when a fetch finishes: (success, bytes, content_type). + using DoneFn = + std::function, std::string)>; + /// Injectable network backend: given a URL, eventually invoke DoneFn. + using Fetcher = std::function; + + explicit TileService(WmsConfig cfg = WmsConfig::geoportal_ortho()); + + [[nodiscard]] const WmsConfig& config() const noexcept { return cfg_; } + void set_config(WmsConfig cfg) { cfg_ = std::move(cfg); } + + /// Overrides the network backend. When set it is used on both native and + /// Emscripten builds; when unset, Emscripten builds fall back to + /// emscripten_fetch and native builds mark the tile Failed. + void set_fetcher(Fetcher fetcher) { fetcher_ = std::move(fetcher); } + + /// Notified (with the tile's key) whenever a tile transitions to Ready or + /// Failed. Lets the renderer schedule a redraw when an async tile arrives. + void set_on_ready(std::function cb) { on_ready_ = std::move(cb); } + + /// Ensures @p key is cached or being fetched, and returns its current status. + /// A cache hit returns Ready immediately and issues no network request. + TileStatus request(const TileKey& key); + + /// The cached image for @p key, or nullptr if it is not Ready. + [[nodiscard]] const TileImage* peek(const TileKey& key) const; + + /// Stores bytes for @p key directly, marking it Ready. Exposed so a caller + /// (or a test) can seed the cache without going through the fetcher. + void store(const TileKey& key, std::vector bytes, + std::string content_type = {}); + + [[nodiscard]] bool has(const TileKey& key) const; + [[nodiscard]] std::size_t size() const noexcept { return cache_.size(); } + void clear() { cache_.clear(); } + + /// The GetMap URL this service would use for @p key. + [[nodiscard]] std::string url_for(const TileKey& key) const; + + /// Finalises an in-flight fetch: on success stores the bytes (Ready), on + /// failure marks the tile Failed, then fires the on_ready callback. Public so + /// the Emscripten fetch glue (which needs free C callbacks) can delegate here. + void complete_fetch(const TileKey& key, bool ok, std::vector bytes, + std::string content_type); + + /// Re-issues the fetch for @p key. Public so the Emscripten async-retry glue + /// (a free C callback) can delegate here. + void retry_now(const TileKey& key); + +private: + void begin_fetch(const TileKey& key); + /// Re-issues a fetch for @p key after a failure — the service retries up to + /// kMaxRetries times, then marks the tile Failed. Under Emscripten the retry + /// is deferred asynchronously (after a short delay) so it never blocks or + /// recurses; with an injected fetcher it re-issues immediately. + void schedule_retry(const TileKey& key); + + WmsConfig cfg_; + std::unordered_map cache_; + Fetcher fetcher_; + std::function on_ready_; +}; + +} // namespace maj0sted::web diff --git a/maj0sted/src/app/editor_document.cpp b/maj0sted/src/app/editor_document.cpp new file mode 100644 index 00000000..9a500855 --- /dev/null +++ b/maj0sted/src/app/editor_document.cpp @@ -0,0 +1,323 @@ +#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::web::GapFit; +using maj0sted::web::NiweletaSpec; +using maj0sted::web::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"; + 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 (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"; + } + } + 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]); + 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}); + } + 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::web::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)); + } + return document; +} + +std::string default_project_path() { + // Under Emscripten the host mounts IDBFS at /data (see web/index.html), so + // this path is persistent across reloads once FS.syncfs has flushed. + return "/data/maj0sted/project.m0s"; +} + +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/domain/fitting_service.cpp b/maj0sted/src/domain/fitting_service.cpp new file mode 100644 index 00000000..d7285bb5 --- /dev/null +++ b/maj0sted/src/domain/fitting_service.cpp @@ -0,0 +1,582 @@ +#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 new file mode 100644 index 00000000..8d4dd5d3 --- /dev/null +++ b/maj0sted/src/domain/gap_fitter.cpp @@ -0,0 +1,215 @@ +#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 new file mode 100644 index 00000000..a554a651 --- /dev/null +++ b/maj0sted/src/domain/horizontal_alignment.cpp @@ -0,0 +1,95 @@ +#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 new file mode 100644 index 00000000..aede9de8 --- /dev/null +++ b/maj0sted/src/domain/map_project.cpp @@ -0,0 +1,13 @@ +#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 new file mode 100644 index 00000000..3006c4f7 --- /dev/null +++ b/maj0sted/src/domain/niweleta.cpp @@ -0,0 +1,90 @@ +#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 new file mode 100644 index 00000000..874326a8 --- /dev/null +++ b/maj0sted/src/domain/parallelism_service.cpp @@ -0,0 +1,58 @@ +#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); +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/vertical_alignment.cpp b/maj0sted/src/domain/vertical_alignment.cpp new file mode 100644 index 00000000..fc576e10 --- /dev/null +++ b/maj0sted/src/domain/vertical_alignment.cpp @@ -0,0 +1,13 @@ +#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/io/project_serializer.cpp b/maj0sted/src/io/project_serializer.cpp new file mode 100644 index 00000000..f52ab1fe --- /dev/null +++ b/maj0sted/src/io/project_serializer.cpp @@ -0,0 +1,291 @@ +#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/svg_writer.cpp b/maj0sted/src/io/svg_writer.cpp new file mode 100644 index 00000000..5a32b9e4 --- /dev/null +++ b/maj0sted/src/io/svg_writer.cpp @@ -0,0 +1,63 @@ +#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 new file mode 100644 index 00000000..f9687909 --- /dev/null +++ b/maj0sted/src/render/plan_sampler.cpp @@ -0,0 +1,116 @@ +#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 new file mode 100644 index 00000000..f1ece92b --- /dev/null +++ b/maj0sted/src/render/rail_renderer.cpp @@ -0,0 +1,60 @@ +#include "maj0sted/render/rail_renderer.hpp" + +#include +#include +#include + +namespace maj0sted::render { + +namespace { + +// Offsets a centreline polyline sideways by @p dist metres (signed) along the +// per-vertex normal. The vertex tangent uses a central difference of the +// neighbours (forward/backward at the ends); the left normal of (dx, dy) is +// (-dy, dx), matching the project's (east=x, north=y) convention. +std::vector offset(const std::vector& pts, double dist) { + const std::size_t m = pts.size(); + std::vector out(m); + for (std::size_t i = 0; i < m; ++i) { + double dx = 1.0, dy = 0.0; + if (m >= 2) { + if (i == 0) { + dx = pts[1].x - pts[0].x; + dy = pts[1].y - pts[0].y; + } else if (i + 1 == m) { + dx = pts[i].x - pts[i - 1].x; + dy = pts[i].y - pts[i - 1].y; + } else { + dx = pts[i + 1].x - pts[i - 1].x; + dy = pts[i + 1].y - pts[i - 1].y; + } + } + const double len = std::hypot(dx, dy); + const double nx = len > 0.0 ? -dy / len : 0.0; + const double ny = len > 0.0 ? dx / len : 0.0; + out[i] = Point{pts[i].x + nx * dist, pts[i].y + ny * dist}; + } + return out; +} + +} // namespace + +RailRenderer::RailRenderer(double gauge_metres) noexcept + : half_gauge_{gauge_metres * 0.5} {} + +void RailRenderer::add(const CentrelineElement& element) { + for (const double side : {half_gauge_, -half_gauge_}) { + RailPolyline rail; + rail.kind = element.kind; + rail.straight_index = element.straight_index; + rail.gap = element.gap; + rail.element_index = element.element_index; + rail.length = element.length; + rail.radius_start = element.radius_start; + rail.radius_end = element.radius_end; + rail.points = offset(element.points, side); + rails_.push_back(std::move(rail)); + } +} + +} // namespace maj0sted::render diff --git a/maj0sted/src/web/editor.cpp b/maj0sted/src/web/editor.cpp new file mode 100644 index 00000000..953832ff --- /dev/null +++ b/maj0sted/src/web/editor.cpp @@ -0,0 +1,235 @@ +#include "maj0sted/web/editor.hpp" + +#include +#include +#include +#include +#include +#include + +#include "maj0sted/domain/fitting/gap_fitter.hpp" +#include "maj0sted/domain/geometry/segment_layout.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::web { + +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 WebPolylines 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()) { + WebPolyline 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(WebPoint{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); + + // 3. Presentation: hand the centreline to a renderer. + result.polylines = render_rails(axis); + return result; +} + +} // 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; +} + +} // namespace maj0sted::web diff --git a/maj0sted/src/web/ribbon.cpp b/maj0sted/src/web/ribbon.cpp new file mode 100644 index 00000000..21d82334 --- /dev/null +++ b/maj0sted/src/web/ribbon.cpp @@ -0,0 +1,55 @@ +#include "maj0sted/web/ribbon.hpp" + +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" + +namespace maj0sted::web { + +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); + + WebPolyline polyline; + polyline.kind = kind; + polyline.points.reserve(points.size()); + for (const auto& p : points) { + polyline.points.push_back(WebPoint{p.x, p.y}); + } + result.push_back(std::move(polyline)); + } + return result; +} + +} // namespace maj0sted::web diff --git a/maj0sted/src/web/solve.cpp b/maj0sted/src/web/solve.cpp new file mode 100644 index 00000000..19caa322 --- /dev/null +++ b/maj0sted/src/web/solve.cpp @@ -0,0 +1,98 @@ +#include "maj0sted/web/solve.hpp" + +#include + +#include "maj0sted/maj0sted.hpp" +#include "maj0sted/render/plan_sampler.hpp" + +namespace maj0sted::web { + +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) { + WebPolyline out; + out.kind = static_cast(polyline.kind); + out.points.reserve(polyline.points.size()); + for (const auto& p : polyline.points) { + out.points.push_back(WebPoint{p.x, p.y}); + } + 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::web diff --git a/maj0sted/src/web/tile_cache.cpp b/maj0sted/src/web/tile_cache.cpp new file mode 100644 index 00000000..4022984a --- /dev/null +++ b/maj0sted/src/web/tile_cache.cpp @@ -0,0 +1,326 @@ +#include "maj0sted/web/tile_cache.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include + +#include +#include +#endif + +namespace maj0sted::web { + +namespace { + +// Shortest round-trippable, locale-independent representation of a double — +// same convention as maj0sted::io so URLs are stable and precise. +std::string num(double value) { + char buffer[64]; + const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value); + return std::string(buffer, result.ptr); +} + +// Maximum number of retry attempts per tile after the initial fetch fails. +constexpr int kMaxRetries = 10; + +} // namespace + +// --- TileGrid ------------------------------------------------------------ + +double TileGrid::resolution(int /*zoom*/) noexcept { + return kCellMetres / static_cast(kTilePixels); // constant, single grid +} + +double TileGrid::tile_span(int /*zoom*/) noexcept { + return kCellMetres; // every cell is 100 m × 100 m +} + +TileKey TileGrid::tile_at(double x, double y, int /*zoom*/) noexcept { + const auto safe_index = [](double coordinate, double origin) { + const double value = std::floor((coordinate - origin) / kCellMetres); + if (!std::isfinite(value)) return 0; + if (value <= static_cast(std::numeric_limits::min())) { + return std::numeric_limits::min(); + } + if (value >= static_cast(std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + return static_cast(value); + }; + return TileKey{kLevel, safe_index(x, kOriginX), safe_index(y, kOriginY)}; +} + +TileBBox TileGrid::bbox(const TileKey& key) noexcept { + const double min_x = kOriginX + static_cast(key.x) * kCellMetres; + const double min_y = kOriginY + static_cast(key.y) * kCellMetres; + return TileBBox{min_x, min_y, min_x + kCellMetres, min_y + kCellMetres}; +} + +int TileGrid::zoom_for_resolution(double /*metres_per_pixel*/) noexcept { + return kLevel; // one fixed 100 m grid, no pyramid +} + +std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/, + int max_tiles) { + std::vector tiles; + if (max_tiles <= 0 || !std::isfinite(view.min_x) || + !std::isfinite(view.min_y) || !std::isfinite(view.max_x) || + !std::isfinite(view.max_y)) { + return tiles; + } + const double min_x = std::min(view.min_x, view.max_x); + const double max_x = std::max(view.min_x, view.max_x); + const double min_y = std::min(view.min_y, view.max_y); + const double max_y = std::max(view.min_y, view.max_y); + + const TileKey lo = tile_at(min_x, min_y); + const TileKey hi = tile_at(max_x, max_y); + + using Wide = std::int64_t; + const Wide width = static_cast(hi.x) - lo.x + 1; + const Wide height = static_cast(hi.y) - lo.y + 1; + if (width <= 0 || height <= 0) return tiles; + + Wide columns = width; + Wide rows = height; + const Wide cap = max_tiles; + if (height > cap || width > cap / height) { + // A fixed 100 m grid can contain millions of cells when zoomed far out. + // Return a small, viewport-centred window instead of walking from a + // remote corner or allocating/requesting the whole range. + const long double aspect = + static_cast(width) / static_cast(height); + columns = std::clamp( + static_cast(std::sqrt(static_cast(cap) * aspect)), + 1, std::min(width, cap)); + rows = std::clamp(cap / columns, 1, height); + } + + const Wide centre_x = + std::midpoint(static_cast(lo.x), static_cast(hi.x)); + const Wide centre_y = + std::midpoint(static_cast(lo.y), static_cast(hi.y)); + const Wide first_col = std::clamp( + centre_x - columns / 2, static_cast(lo.x), + static_cast(hi.x) - columns + 1); + const Wide first_row = std::clamp( + centre_y - rows / 2, static_cast(lo.y), + static_cast(hi.y) - rows + 1); + + tiles.reserve(static_cast(columns * rows)); + for (Wide row = first_row; row < first_row + rows; ++row) { + for (Wide col = first_col; col < first_col + columns; ++col) { + tiles.push_back( + TileKey{kLevel, static_cast(col), static_cast(row)}); + } + } + return tiles; +} + +// --- WmsConfig ----------------------------------------------------------- + +WmsConfig WmsConfig::geoportal_ortho() { + WmsConfig cfg; + // Public Geoportal ortophoto WMS (EPSG:2180). If this endpoint blocks CORS + // for emscripten_fetch, swap base_url/layers via set_config() from the host. + cfg.base_url = + "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution"; + cfg.layers = "Raster"; + cfg.format = "image/jpeg"; + return cfg; +} + +WmsConfig WmsConfig::geoportal_topo() { + WmsConfig cfg; + cfg.base_url = + "https://mapy.geoportal.gov.pl/wss/service/img/guest/TOPO/MapServer/WMSServer"; + cfg.layers = "Raster"; + cfg.format = "image/png"; + return cfg; +} + +std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box) { + std::string url = cfg.base_url; + url += (url.find('?') == std::string::npos) ? '?' : '&'; + url += "SERVICE=WMS"; + url += "&VERSION=" + cfg.version; + url += "&REQUEST=GetMap"; + url += "&LAYERS=" + cfg.layers; + url += "&STYLES="; + url += "&SRS=" + cfg.srs; + url += "&FORMAT=" + cfg.format; + url += "&WIDTH=" + std::to_string(cfg.tile_pixels); + url += "&HEIGHT=" + std::to_string(cfg.tile_pixels); + // WMS 1.1.1: BBOX = minx,miny,maxx,maxy in the SRS axis order (easting, + // northing for EPSG:2180). + url += "&BBOX=" + num(box.min_x) + "," + num(box.min_y) + "," + num(box.max_x) + + "," + num(box.max_y); + return url; +} + +// --- TileService --------------------------------------------------------- + +TileService::TileService(WmsConfig cfg) : cfg_{std::move(cfg)} {} + +std::string TileService::url_for(const TileKey& key) const { + return wms_getmap_url(cfg_, TileGrid::bbox(key)); +} + +bool TileService::has(const TileKey& key) const { + const auto it = cache_.find(key); + return it != cache_.end() && it->second.status == TileStatus::Ready; +} + +const TileImage* TileService::peek(const TileKey& key) const { + const auto it = cache_.find(key); + if (it != cache_.end() && it->second.status == TileStatus::Ready) { + return &it->second; + } + return nullptr; +} + +void TileService::store(const TileKey& key, std::vector bytes, + std::string content_type) { + TileImage& image = cache_[key]; + image.status = TileStatus::Ready; + image.bytes = std::move(bytes); + image.content_type = std::move(content_type); +} + +TileStatus TileService::request(const TileKey& key) { + const auto it = cache_.find(key); + if (it != cache_.end()) { + // A tile that gave up (10 failed attempts) is retried the next time it is + // asked for (a pan/zoom/toggle), so the underlay self-heals once the + // endpoint is reachable again — no page reload needed. + if (it->second.status == TileStatus::Failed) { + it->second.attempts = 0; + it->second.status = TileStatus::Loading; + begin_fetch(key); + } + return it->second.status; // otherwise: hit, no network + } + + cache_[key].status = TileStatus::Loading; + begin_fetch(key); + return cache_[key].status; +} + +void TileService::complete_fetch(const TileKey& key, bool ok, + std::vector bytes, + std::string content_type) { + if (ok) { + store(key, std::move(bytes), std::move(content_type)); + if (on_ready_) on_ready_(key); + return; + } + // Retry up to kMaxRetries times per tile; after that give up (Failed). + TileImage& image = cache_[key]; + if (++image.attempts <= kMaxRetries) { + image.status = TileStatus::Loading; + schedule_retry(key); + } else { + // Give up quietly: do NOT notify the host, so a Failed tile does not by + // itself trigger a redraw → re-request → refetch loop in the background. + // It is retried only on the next user-driven request (see request()). + image.status = TileStatus::Failed; + } +} + +#ifdef __EMSCRIPTEN__ +namespace { +// Delay between retry attempts, so a persistently failing endpoint is retried +// steadily rather than in a tight loop. +constexpr int kRetryDelayMs = 1500; + +struct FetchCtx { + TileService* self; + TileKey key; +}; + +struct RetryCtx { + TileService* self; + TileKey key; +}; + +void on_retry(void* user_data) { + auto* ctx = static_cast(user_data); + TileService* self = ctx->self; + const TileKey key = ctx->key; + delete ctx; + self->retry_now(key); +} + +void on_fetch_success(emscripten_fetch_t* fetch) { + auto* ctx = static_cast(fetch->userData); + std::vector bytes( + reinterpret_cast(fetch->data), + reinterpret_cast(fetch->data) + + static_cast(fetch->numBytes)); + TileService* self = ctx->self; + const TileKey key = ctx->key; + delete ctx; + emscripten_fetch_close(fetch); + self->complete_fetch(key, true, std::move(bytes), {}); +} + +void on_fetch_error(emscripten_fetch_t* fetch) { + auto* ctx = static_cast(fetch->userData); + TileService* self = ctx->self; + const TileKey key = ctx->key; + delete ctx; + emscripten_fetch_close(fetch); + self->complete_fetch(key, false, {}, {}); +} +} // namespace +#endif + +void TileService::begin_fetch(const TileKey& key) { + const std::string url = url_for(key); + + // An injected fetcher wins on every platform (used by native tests and any + // host that wants to route the request itself). + if (fetcher_) { + const TileKey captured = key; + fetcher_(url, [this, captured](bool ok, std::vector bytes, + std::string content_type) { + complete_fetch(captured, ok, std::move(bytes), std::move(content_type)); + }); + return; + } + +#ifdef __EMSCRIPTEN__ + emscripten_fetch_attr_t attr; + emscripten_fetch_attr_init(&attr); + std::strcpy(attr.requestMethod, "GET"); + attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY; + attr.userData = new FetchCtx{this, key}; + attr.onsuccess = on_fetch_success; + attr.onerror = on_fetch_error; + emscripten_fetch(&attr, url.c_str()); +#else + // No network backend available (native, no injected fetcher): mark failed. + complete_fetch(key, false, {}, {}); +#endif +} + +void TileService::retry_now(const TileKey& key) { begin_fetch(key); } + +void TileService::schedule_retry(const TileKey& key) { +#ifdef __EMSCRIPTEN__ + // Defer asynchronously: infinite retry without blocking or recursion. + emscripten_async_call(&on_retry, new RetryCtx{this, key}, kRetryDelayMs); +#else + // Injected-fetcher path (native/tests): re-issue immediately. The fetcher + // controls timing, so a fetcher that eventually succeeds ends the loop. + begin_fetch(key); +#endif +} + +} // namespace maj0sted::web diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt new file mode 100644 index 00000000..3b908224 --- /dev/null +++ b/maj0sted/tests/CMakeLists.txt @@ -0,0 +1,49 @@ +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_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_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_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_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_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_web test_web.cpp) +target_link_libraries(maj0sted_test_web PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_web PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME web COMMAND maj0sted_test_web) + +add_executable(maj0sted_test_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) diff --git a/maj0sted/tests/check.hpp b/maj0sted/tests/check.hpp new file mode 100644 index 00000000..3f500df1 --- /dev/null +++ b/maj0sted/tests/check.hpp @@ -0,0 +1,51 @@ +#pragma once + +// A tiny zero-dependency check framework so the test suite needs nothing but a +// C++20 compiler. Each test executable returns 0 on success, 1 on any failure, +// which is exactly what CTest interprets as pass/fail. + +#include + +namespace maj0test { +inline int checks = 0; +inline int failures = 0; +} // namespace maj0test + +// Variadic so that brace-init lists with commas, e.g. CHECK(Point{1, 2} == ...), +// are passed through as a single condition instead of several macro arguments. +#define CHECK(...) \ + do { \ + ++::maj0test::checks; \ + if (!(__VA_ARGS__)) { \ + ++::maj0test::failures; \ + std::printf(" [FAIL] %s:%d CHECK(%s)\n", __FILE__, __LINE__, \ + #__VA_ARGS__); \ + } \ + } while (0) + +#define CHECK_THROWS(...) \ + do { \ + ++::maj0test::checks; \ + bool threw = false; \ + try { \ + (void)(__VA_ARGS__); \ + } catch (...) { \ + threw = true; \ + } \ + if (!threw) { \ + ++::maj0test::failures; \ + std::printf(" [FAIL] %s:%d CHECK_THROWS(%s)\n", __FILE__, \ + __LINE__, #__VA_ARGS__); \ + } \ + } while (0) + +#define RUN(fn) \ + do { \ + std::printf("[RUN ] %s\n", #fn); \ + fn(); \ + } while (0) + +#define REPORT() \ + (std::printf("\n%d checks, %d failures\n", ::maj0test::checks, \ + ::maj0test::failures), \ + ::maj0test::failures == 0 ? 0 : 1) diff --git a/maj0sted/tests/test_document.cpp b/maj0sted/tests/test_document.cpp new file mode 100644 index 00000000..074347ba --- /dev/null +++ b/maj0sted/tests/test_document.cpp @@ -0,0 +1,123 @@ +#include +#include + +#include "check.hpp" +#include "maj0sted/app/editor_document.hpp" + +using namespace maj0sted::app; +using maj0sted::web::GapFit; +using maj0sted::web::NiweletaSpec; +using maj0sted::web::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}}; + + doc.niwelety = {a, b}; + 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; +} + +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.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_fitting.cpp b/maj0sted/tests/test_fitting.cpp new file mode 100644 index 00000000..27b75048 --- /dev/null +++ b/maj0sted/tests/test_fitting.cpp @@ -0,0 +1,346 @@ +#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_map_project.cpp b/maj0sted/tests/test_map_project.cpp new file mode 100644 index 00000000..5cd09d1b --- /dev/null +++ b/maj0sted/tests/test_map_project.cpp @@ -0,0 +1,25 @@ +#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 new file mode 100644 index 00000000..7c99a978 --- /dev/null +++ b/maj0sted/tests/test_niweleta.cpp @@ -0,0 +1,110 @@ +#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 new file mode 100644 index 00000000..a603f88c --- /dev/null +++ b/maj0sted/tests/test_parallelism.cpp @@ -0,0 +1,85 @@ +#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 new file mode 100644 index 00000000..353a6bd0 --- /dev/null +++ b/maj0sted/tests/test_plan_alignment.cpp @@ -0,0 +1,132 @@ +#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 new file mode 100644 index 00000000..58261a2a --- /dev/null +++ b/maj0sted/tests/test_render.cpp @@ -0,0 +1,78 @@ +#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_tile_cache.cpp b/maj0sted/tests/test_tile_cache.cpp new file mode 100644 index 00000000..0ac3da77 --- /dev/null +++ b/maj0sted/tests/test_tile_cache.cpp @@ -0,0 +1,208 @@ +#include +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/web/tile_cache.hpp" + +using namespace maj0sted::web; + +namespace { + +// --- tile grid geometry -------------------------------------------------- + +void grid_is_fixed_100m_cells() { + // One fixed grid: every cell is 100 m × 100 m regardless of the zoom arg. + CHECK(std::abs(TileGrid::kCellMetres - 100.0) < 1e-9); + CHECK(std::abs(TileGrid::tile_span() - 100.0) < 1e-6); + CHECK(std::abs(TileGrid::tile_span(14) - 100.0) < 1e-6); // zoom ignored + // Resolution is constant: 100 m over 1024 px. + CHECK(std::abs(TileGrid::resolution() - 100.0 / 1024.0) < 1e-9); + CHECK(std::abs(TileGrid::resolution(7) - 100.0 / 1024.0) < 1e-9); +} + +void tile_at_and_bbox_are_consistent() { + const double x = 500123.0; // somewhere in EPSG:2180 (Poland) + const double y = 300456.0; + const TileKey key = TileGrid::tile_at(x, y); + const TileBBox box = TileGrid::bbox(key); + + // Every tile key sits on the single fixed level. + CHECK(key.zoom == TileGrid::kLevel); + + // The point must fall inside its own cell's ground box. + CHECK(box.min_x <= x && x < box.max_x); + CHECK(box.min_y <= y && y < box.max_y); + + // The box edge length must equal the fixed 100 m cell span. + CHECK(std::abs((box.max_x - box.min_x) - 100.0) < 1e-6); + CHECK(std::abs((box.max_y - box.min_y) - 100.0) < 1e-6); + + // Neighbouring cells are exactly one span (100 m) apart. + const TileBBox right = TileGrid::bbox(TileKey{TileGrid::kLevel, key.x + 1, key.y}); + CHECK(std::abs(right.min_x - box.max_x) < 1e-6); +} + +void zoom_for_resolution_is_single_level() { + // There is no pyramid: any target resolution maps to the one fixed level. + for (double mpp : {50.0, 8.0, 1.0, 0.25}) { + CHECK(TileGrid::zoom_for_resolution(mpp) == TileGrid::kLevel); + } +} + +void tiles_for_view_covers_and_caps() { + const double span = 100.0; // fixed cell + // A view roughly 3x2 cells. + TileBBox view{500000.0, 300000.0, 500000.0 + 2.5 * span, 300000.0 + 1.5 * span}; + const auto tiles = TileGrid::tiles_for_view(view); + CHECK(!tiles.empty()); + // Every corner cell of the view must be present. + const TileKey lo = TileGrid::tile_at(view.min_x, view.min_y); + const TileKey hi = TileGrid::tile_at(view.max_x, view.max_y); + bool has_lo = false, has_hi = false; + for (const auto& t : tiles) { + if (t == lo) has_lo = true; + if (t == hi) has_hi = true; + } + CHECK(has_lo && has_hi); + + // The cap is honoured. + TileBBox huge{0.0, 0.0, 1e9, 1e9}; + const auto capped = TileGrid::tiles_for_view(huge, TileGrid::kLevel, 32); + CHECK(static_cast(capped.size()) <= 32); + const TileKey centre = TileGrid::tile_at(5e8, 5e8); + bool has_centre = false; + for (const auto& tile : capped) { + if (tile == centre) has_centre = true; + } + CHECK(has_centre); // cap is centred, not taken from a remote corner + + const double infinity = std::numeric_limits::infinity(); + CHECK(TileGrid::tiles_for_view({0.0, 0.0, infinity, 1.0}).empty()); +} + +// --- WMS URL ------------------------------------------------------------- + +void getmap_url_is_wms_1_1_1_epsg2180() { + WmsConfig cfg = WmsConfig::geoportal_ortho(); + const TileBBox box{100.0, 200.0, 300.0, 400.0}; + const std::string url = wms_getmap_url(cfg, box); + CHECK(url.find("SERVICE=WMS") != std::string::npos); + CHECK(url.find("VERSION=1.1.1") != std::string::npos); + CHECK(url.find("REQUEST=GetMap") != std::string::npos); + CHECK(url.find("SRS=EPSG:2180") != std::string::npos); + // BBOX in minx,miny,maxx,maxy order. + CHECK(url.find("BBOX=100,200,300,400") != std::string::npos); + CHECK(url.find("WIDTH=1024") != std::string::npos); +} + +// --- cache + injected fetch --------------------------------------------- + +void miss_fetches_then_hit_is_served_from_cache() { + TileService service; + int fetch_calls = 0; + // Synchronous injected fetcher that returns three canned bytes. + service.set_fetcher([&](const std::string& url, + TileService::DoneFn done) { + ++fetch_calls; + CHECK(url.find("BBOX=") != std::string::npos); + done(true, std::vector{1, 2, 3}, "image/jpeg"); + }); + + int ready_events = 0; + TileKey last_ready{}; + service.set_on_ready([&](TileKey k) { + ++ready_events; + last_ready = k; + }); + + const TileKey key{12, 3, 4}; + CHECK(!service.has(key)); + + // First request: a miss, so exactly one fetch and the tile becomes Ready. + const TileStatus first = service.request(key); + CHECK(first == TileStatus::Ready); // fetcher was synchronous + CHECK(fetch_calls == 1); + CHECK(ready_events == 1); + CHECK(last_ready == key); + CHECK(service.has(key)); + + const TileImage* image = service.peek(key); + CHECK(image != nullptr); + CHECK(image->bytes.size() == 3); + CHECK(image->content_type == "image/jpeg"); + + // Second request for the same tile: a hit, no extra fetch. + const TileStatus second = service.request(key); + CHECK(second == TileStatus::Ready); + CHECK(fetch_calls == 1); // unchanged: served from cache + CHECK(service.size() == 1); +} + +void failed_fetch_is_retried_until_success() { + // A failure is not terminal: the service retries until a tile arrives. With + // a synchronous injected fetcher the retries unwind inline, so a fetcher that + // fails twice then succeeds must end Ready after exactly three attempts. + TileService service; + int calls = 0; + service.set_fetcher([&](const std::string&, TileService::DoneFn done) { + ++calls; + if (calls < 3) { + done(false, {}, {}); // fail + } else { + done(true, std::vector{7}, "image/jpeg"); // then succeed + } + }); + + const TileKey key{10, 1, 1}; + const TileStatus status = service.request(key); + CHECK(status == TileStatus::Ready); + CHECK(calls == 3); + CHECK(service.has(key)); + + const TileImage* image = service.peek(key); + CHECK(image != nullptr); + CHECK(image->bytes.size() == 1); +} + +void retry_gives_up_after_ten_attempts() { + // A tile that never loads is retried at most 10 times, then marked Failed. + // With a synchronous always-failing fetcher that is 1 initial + 10 retries. + TileService service; + int calls = 0; + service.set_fetcher([&](const std::string&, TileService::DoneFn done) { + ++calls; + done(false, {}, {}); + }); + + const TileKey key{2, 2, 2}; + CHECK(service.request(key) == TileStatus::Failed); + CHECK(calls == 11); // initial + kMaxRetries (10) + CHECK(!service.has(key)); + CHECK(service.peek(key) == nullptr); +} + +void store_seeds_cache_directly() { + TileService service; + const TileKey key{9, 2, 2}; + service.store(key, std::vector{9, 9}, "image/png"); + CHECK(service.has(key)); + // A request now is a pure hit even with no fetcher installed. + CHECK(service.request(key) == TileStatus::Ready); +} + +} // namespace + +int main() { + RUN(grid_is_fixed_100m_cells); + RUN(tile_at_and_bbox_are_consistent); + RUN(zoom_for_resolution_is_single_level); + RUN(tiles_for_view_covers_and_caps); + RUN(getmap_url_is_wms_1_1_1_epsg2180); + RUN(miss_fetches_then_hit_is_served_from_cache); + RUN(failed_fetch_is_retried_until_success); + RUN(retry_gives_up_after_ten_attempts); + RUN(store_seeds_cache_directly); + return REPORT(); +} diff --git a/maj0sted/tests/test_web.cpp b/maj0sted/tests/test_web.cpp new file mode 100644 index 00000000..b8a848cc --- /dev/null +++ b/maj0sted/tests/test_web.cpp @@ -0,0 +1,229 @@ +#include +#include + +#include "check.hpp" +#include "maj0sted/web/editor.hpp" +#include "maj0sted/web/solve.hpp" + +using namespace maj0sted::web; + +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 WebPolyline* 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 WebPolyline* 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); +} + +} // 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); + return REPORT(); +}