mirror of
https://github.com/MaSzyna-EU07/maszyna.git
synced 2026-10-17 05:09:20 +02:00
Vendor the maj0sted track layout library and link it into the editor
Drop-in copy of C:\Users\maj00r\Projects\maj0sted-master minus its web/ directory - the WASM frontend is not being ported, the editor drives the same C++ API natively. What is left is plain C++20 with no dependencies: domain model (niweleta, plan and profile geometry, fitting, parallelism), project serialization, rail sampling and the editor document. src/web/ribbon.cpp was missing from the library source list; added, since without it the ribbon symbols never make it into the archive.
This commit is contained in:
@@ -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)
|
||||
|
||||
12
maj0sted/.clang-format
Normal file
12
maj0sted/.clang-format
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
Language: Cpp
|
||||
BasedOnStyle: Google
|
||||
Standard: c++20
|
||||
ColumnLimit: 90
|
||||
IndentWidth: 4
|
||||
AccessModifierOffset: -4
|
||||
PointerAlignment: Left
|
||||
AllowShortFunctionsOnASingleLine: Inline
|
||||
AllowShortIfStatementsOnASingleLine: false
|
||||
SortIncludes: true
|
||||
IncludeBlocks: Preserve
|
||||
24
maj0sted/.gitignore
vendored
Normal file
24
maj0sted/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Build output
|
||||
/build/
|
||||
/out/
|
||||
/cmake-build-*/
|
||||
|
||||
# Compiled objects / archives
|
||||
*.o
|
||||
*.obj
|
||||
*.a
|
||||
*.lib
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Editors / IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
.cache/
|
||||
compile_commands.json
|
||||
|
||||
# WASM / web build artifacts (produced by web/build.sh)
|
||||
web/maj0sted.js
|
||||
web/maj0sted.wasm
|
||||
web/serve
|
||||
113
maj0sted/CMakeLists.txt
Normal file
113
maj0sted/CMakeLists.txt
Normal file
@@ -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
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:include>
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(maj0sted PRIVATE /W4)
|
||||
else()
|
||||
target_compile_options(maj0sted PRIVATE
|
||||
-Wall -Wextra -Wpedantic -Wconversion -Wshadow)
|
||||
endif()
|
||||
|
||||
if(MAJ0STED_WARNINGS_AS_ERRORS)
|
||||
if(MSVC)
|
||||
target_compile_options(maj0sted PRIVATE /WX)
|
||||
else()
|
||||
target_compile_options(maj0sted PRIVATE -Werror)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ----------------------------------------------------------------------------
|
||||
if(MAJ0STED_BUILD_TESTS AND MAJ0STED_STANDALONE)
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 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)
|
||||
184
maj0sted/HANDOFF.md
Normal file
184
maj0sted/HANDOFF.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# HANDOFF — maj0sted
|
||||
|
||||
Stan prac i punkt zaczepienia dla kolejnej sesji (np. po przejściu na `claude` CLI).
|
||||
Kod jest samoopisowy; ten plik to skrót "gdzie jesteśmy i co dalej".
|
||||
|
||||
## Czym jest projekt
|
||||
Statyczna biblioteka **C++20** (DDD) do edytora torowiska. Czysty model domenowy,
|
||||
bez zależności zewnętrznych. Namespace: `maj0sted::domain`.
|
||||
|
||||
**Uwaga (sandbox):** na tej maszynie wyłączone są nieuprzywilejowane user
|
||||
namespaces, więc domyślny sandbox komend pada w preflight (komenda „nie zwraca
|
||||
statusu”). Uruchamiaj polecenia powłoki **z wyłączonym sandboxem** (w Cursorze:
|
||||
`required_permissions: ["all"]`). Wtedy `git`, `g++`, `node` działają normalnie.
|
||||
|
||||
**Uwaga:** `cmake` NIE jest zainstalowany. Weryfikacja odbywa się przez `g++ 14.2`.
|
||||
Toolchain WASM: `emsdk` jest w `~/emsdk` (`source ~/emsdk/emsdk_env.sh` dodaje `em++`).
|
||||
|
||||
## Jak weryfikować (bez cmake)
|
||||
```bash
|
||||
cd /home/maj00r/Projects/maj0sted
|
||||
FLAGS="-std=c++20 -Iinclude -Wall -Wextra -Wpedantic -Wconversion -Wshadow"
|
||||
SRC="src/domain/*.cpp src/io/*.cpp src/render/*.cpp src/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, CircularArc, TransitionCurve>`
|
||||
- `Straight` — **zakotwiczony w XY (start/end)**, azymut i długość liczone; opcjonalny `StraightId`.
|
||||
Zasada: prosta trzyma linię nośną (pozycja+azymut), zmienia się tylko długość; krzywe to wypadkowa.
|
||||
- `CircularArc` — promień + kierunek + **opcjonalna** długość
|
||||
- `TransitionCurve` (klotoida) — długość + promienie końcowe (`optional`=∞) + kierunek
|
||||
- `HorizontalAlignment` — lista kierunkowa + `JointContinuity{Tangent, AzimuthBreak}`. Reguły styku ✓:
|
||||
- sąsiednie proste Tangent: muszą się stykać i mieć równy azymut
|
||||
- różny azymut prostych → wymaga `AzimuthBreak` (tylko prosta-prosta, muszą się stykać, różne azymuty)
|
||||
- sąsiednie łuki o tym samym promieniu i kierunku → zabronione
|
||||
- styki z krzywą akceptowane "pending fit"
|
||||
|
||||
### Geometria profilu
|
||||
- `ProfileElement = variant<Grade, VerticalCurve>`; `VerticalAlignment`. (Profil na razie nietknięty.)
|
||||
|
||||
### Równoległość (`domain/parallelism/`)
|
||||
- `TrackParallelism { StraightId origin, StraightId parallel, TrackOffset }` — **referencje wprost po StraightId**;
|
||||
niezmienniki: oba id niepuste, różne ✓
|
||||
- `ParallelismService`: `mark_parallel(originStraight, parallelStraight, offset)`, `are_parallel`,
|
||||
`derive_parallel_line`, `perpendicular_distance` ✓
|
||||
|
||||
### Fitowanie (`domain/fitting/`)
|
||||
- `FitParameters { entry, exit, radius, optional transition_length }` — klotoida symetryczna lub sam łuk
|
||||
- `CompoundFitParameters { entry, exit, radius1, radius2, split_station }` — **łuk koszowy**
|
||||
(miejsce podziału = kilometraż pierwszego łuku; `δ1 = split_station/R1`)
|
||||
- `FitResult { entry(przycięta), curve[], exit(przycięta), tangent_in, tangent_out, direction }`
|
||||
- `FittingService`:
|
||||
- `fit_between_straights` — klotoida–łuk–klotoida (lub sam łuk), przycina obie proste ✓
|
||||
- `fit_compound` — dwa łuki różnych promieni; **obie linie prostych bez zmian**, obie proste
|
||||
doprzycięte na długości; podział ustala `δ1`. Metoda stycznej pośredniej ✓
|
||||
|
||||
### Serializacja (`maj0sted::io`, `include/maj0sted/io/project_serializer.hpp`)
|
||||
- `serialize(MapProject) -> std::string` / `deserialize(std::string) -> MapProject` ✓
|
||||
- Format tekstowy, liniowy, bez zależności; liczby przez `to_chars`/`from_chars` (dokładny round-trip,
|
||||
niezależny od locale). Round-trip jest bajtowo stabilny (test to sprawdza).
|
||||
- Zapisuje: CRS + niwelety (id, nazwa, plan z elementami i stykami, profil). **NIE** zapisuje relacji
|
||||
równoległości (patrz niżej — StraightId nie są globalnie unikalne).
|
||||
|
||||
### Rendering: dwie szyny
|
||||
`src/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ę).
|
||||
86
maj0sted/README.md
Normal file
86
maj0sted/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# maj0sted
|
||||
|
||||
Statyczna biblioteka **C++20** do obsługi **edytora torowiska**, zbudowana
|
||||
zgodnie z **Domain-Driven Design (DDD)**.
|
||||
|
||||
Biblioteka dostarcza czysty model domenowy — bez zależności od UI, bazy danych
|
||||
czy frameworka. Warstwy aplikacji i infrastruktury podpina się do zdefiniowanych
|
||||
tu typów.
|
||||
|
||||
## Model domenowy
|
||||
|
||||
```
|
||||
maj0sted::domain
|
||||
├── value_objects/ Value Objects (niezmienne, porównywane przez wartość)
|
||||
│ ├── Crs układ odniesienia (kod EPSG), domyślnie EPSG:2180
|
||||
│ └── CartesianPosition pozycja kartezjańska (x, y w jednostkach CRS)
|
||||
└── MapProject projekt mapy — definiuje CRS dla wszystkich pozycji
|
||||
```
|
||||
|
||||
Projekt mapy (`MapProject`) definiuje kartezjański układ odniesienia, w którym
|
||||
wyrażane są wszystkie pozycje. Domyślnie jest to **EPSG:2180** (PUWG 1992 /
|
||||
Poland CS92), którego jednostką jest metr.
|
||||
|
||||
## Szybki start
|
||||
|
||||
```cpp
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
MapProject project; // domyślnie EPSG:2180
|
||||
int epsg = project.crs().epsg(); // 2180
|
||||
|
||||
CartesianPosition p{500000.0, 300000.0}; // x, y w metrach
|
||||
```
|
||||
|
||||
Pełniejszy przykład: [`examples/basic_editor.cpp`](examples/basic_editor.cpp).
|
||||
|
||||
## Budowanie
|
||||
|
||||
Wymagania: kompilator C++20 (GCC 11+/Clang 14+/MSVC 19.3+) oraz CMake ≥ 3.20.
|
||||
|
||||
```bash
|
||||
cmake -S . -B build # dodaj -G Ninja, jeśli masz ninja
|
||||
cmake --build build
|
||||
ctest --test-dir build --output-on-failure
|
||||
./build/examples/maj0sted_example
|
||||
```
|
||||
|
||||
### Opcje CMake
|
||||
|
||||
| Opcja | Domyślnie | Znaczenie |
|
||||
|------------------------------|-----------|-------------------------------|
|
||||
| `MAJ0STED_BUILD_TESTS` | `ON` | buduj testy jednostkowe |
|
||||
| `MAJ0STED_BUILD_EXAMPLES` | `ON` | buduj przykłady |
|
||||
| `MAJ0STED_WARNINGS_AS_ERRORS`| `OFF` | traktuj ostrzeżenia jak błędy |
|
||||
|
||||
### Użycie w innym projekcie
|
||||
|
||||
Po `cmake --install build` (albo przez `add_subdirectory`):
|
||||
|
||||
```cmake
|
||||
find_package(maj0sted REQUIRED)
|
||||
target_link_libraries(twoj_target PRIVATE maj0sted::maj0sted)
|
||||
```
|
||||
|
||||
## Struktura projektu
|
||||
|
||||
```
|
||||
maj0sted/
|
||||
├── CMakeLists.txt
|
||||
├── include/maj0sted/ nagłówki publiczne (API)
|
||||
├── src/domain/ implementacje (.cpp)
|
||||
├── tests/ testy jednostkowe (CTest, bez zależności)
|
||||
├── examples/ przykłady użycia
|
||||
└── cmake/ pliki pakietu find_package
|
||||
```
|
||||
|
||||
## Testy
|
||||
|
||||
Testy używają własnego, malutkiego frameworka (`tests/check.hpp`) — nie wymagają
|
||||
żadnych zewnętrznych bibliotek. Każdy plik testowy to osobny plik wykonywalny
|
||||
zwracający kod wyjścia dla CTest.
|
||||
|
||||
## Licencja
|
||||
|
||||
Do uzupełnienia (np. MIT) — dodaj plik `LICENSE`.
|
||||
5
maj0sted/cmake/maj0stedConfig.cmake.in
Normal file
5
maj0sted/cmake/maj0stedConfig.cmake.in
Normal file
@@ -0,0 +1,5 @@
|
||||
@PACKAGE_INIT@
|
||||
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/maj0stedTargets.cmake")
|
||||
|
||||
check_required_components(maj0sted)
|
||||
5
maj0sted/examples/CMakeLists.txt
Normal file
5
maj0sted/examples/CMakeLists.txt
Normal file
@@ -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)
|
||||
20
maj0sted/examples/basic_editor.cpp
Normal file
20
maj0sted/examples/basic_editor.cpp
Normal file
@@ -0,0 +1,20 @@
|
||||
// Minimal walkthrough: create a map project and inspect its CRS.
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
int main() {
|
||||
std::printf("maj0sted %s\n\n", MAJ0STED_VERSION_STRING);
|
||||
|
||||
const MapProject project; // defaults to EPSG:2180
|
||||
std::printf("Map project CRS: EPSG:%d\n", project.crs().epsg());
|
||||
|
||||
const CartesianPosition position{500000.0, 300000.0};
|
||||
std::printf("Sample position: x=%.1f m, y=%.1f m\n", position.x(),
|
||||
position.y());
|
||||
|
||||
return 0;
|
||||
}
|
||||
58
maj0sted/examples/plan_to_svg.cpp
Normal file
58
maj0sted/examples/plan_to_svg.cpp
Normal file
@@ -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 <cstdio>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include "maj0sted/io/svg_writer.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
#include "maj0sted/render/plan_sampler.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
MapProject project;
|
||||
|
||||
// Two straights forming a 90-degree corner.
|
||||
Niweleta niweleta{NiweletaId{1}, "Demo"};
|
||||
const StraightId entry =
|
||||
niweleta.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{300.0, 0.0});
|
||||
const StraightId exit = niweleta.add_straight(
|
||||
CartesianPosition{300.0, 0.0}, CartesianPosition{300.0, 300.0},
|
||||
JointContinuity::AzimuthBreak);
|
||||
|
||||
// A compound curve R120 -> R80 with clothoids at the ends and between arcs.
|
||||
const CompoundFitParameters params{
|
||||
.entry = entry,
|
||||
.exit = exit,
|
||||
.entry_transition = Length::from_metres(30.0),
|
||||
.exit_transition = Length::from_metres(30.0),
|
||||
.arcs = {CompoundArc{Radius::from_metres(120.0), Length::from_metres(60.0),
|
||||
Length::from_metres(25.0)},
|
||||
CompoundArc{Radius::from_metres(80.0)}}};
|
||||
|
||||
const FitResult fit = FittingService::fit_compound(niweleta, params);
|
||||
niweleta.apply_fit(fit);
|
||||
project.add_niweleta(std::move(niweleta));
|
||||
|
||||
const maj0sted::render::Scene scene = maj0sted::render::sample(project);
|
||||
const std::string svg = maj0sted::io::to_svg(scene);
|
||||
|
||||
const std::string path = (argc > 1) ? argv[1] : "plan.svg";
|
||||
std::ofstream out(path);
|
||||
if (!out) {
|
||||
std::fprintf(stderr, "cannot write %s\n", path.c_str());
|
||||
return 1;
|
||||
}
|
||||
out << svg;
|
||||
std::printf("wrote %s (%zu polylines) — open it in a browser\n", path.c_str(),
|
||||
scene.polylines.size());
|
||||
return 0;
|
||||
}
|
||||
57
maj0sted/include/maj0sted/app/editor_document.hpp
Normal file
57
maj0sted/include/maj0sted/app/editor_document.hpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<maj0sted::web::NiweletaSpec> 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<EditorDocument> load_project(
|
||||
const std::string& path = default_project_path());
|
||||
|
||||
} // namespace maj0sted::app
|
||||
55
maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp
Normal file
55
maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/value_objects/length.hpp"
|
||||
#include "maj0sted/domain/value_objects/radius.hpp"
|
||||
#include "maj0sted/domain/value_objects/straight_id.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Input parameters for fitting a curve between two straights of one niweleta.
|
||||
///
|
||||
/// These map directly onto a GUI form: the user picks the entry and exit
|
||||
/// straights, types a radius, and optionally a (symmetric) transition-curve
|
||||
/// length. The turn direction is NOT an input — it follows from the geometry of
|
||||
/// the two straights (their deflection angle).
|
||||
///
|
||||
/// * transition_length absent -> a plain circular arc is fitted;
|
||||
/// * transition_length present -> a symmetric klotoida-arc-klotoida is fitted,
|
||||
/// with that length on both ends.
|
||||
struct FitParameters {
|
||||
StraightId entry{}; ///< pierwsza (wejściowa) prosta
|
||||
StraightId exit{}; ///< druga (wyjściowa) prosta
|
||||
Radius radius; ///< promień łuku kołowego
|
||||
std::optional<Length> transition_length{}; ///< symetryczna klotoida; brak = sam łuk
|
||||
};
|
||||
|
||||
/// One arc of a compound curve.
|
||||
struct CompoundArc {
|
||||
Radius radius; ///< promień łuku
|
||||
std::optional<Length> length{}; ///< długość łuku; wymagana OPRÓCZ ostatniego
|
||||
std::optional<Length> transition_to_next{}; ///< klotoida do następnego łuku (ignorowana dla ostatniego)
|
||||
};
|
||||
|
||||
/// Input parameters for fitting a compound curve — a sequence of one or more
|
||||
/// circular arcs (of different neighbouring radii, same direction) with optional
|
||||
/// transition curves (klotoidy) between the arcs and at the ends — between two
|
||||
/// straights. This single shape covers both a "łuk + klotoidy" (one arc eased by
|
||||
/// symmetric clothoids) and a "łuk koszowy" (two or more arcs).
|
||||
///
|
||||
/// Both straight lines stay put (position and azimuth). Each arc except the last
|
||||
/// carries its length, which fixes its central angle (`δ = L / R`); the last (or
|
||||
/// only) arc takes whatever deflection remains after the other arcs and every
|
||||
/// transition curve. The two straights are re-trimmed in length so the whole
|
||||
/// chain closes.
|
||||
struct CompoundFitParameters {
|
||||
StraightId entry{}; ///< pierwsza (wejściowa) prosta
|
||||
StraightId exit{}; ///< druga (wyjściowa) prosta
|
||||
std::optional<Length> entry_transition{}; ///< klotoida prosta -> pierwszy łuk
|
||||
std::optional<Length> exit_transition{}; ///< klotoida ostatni łuk -> prosta
|
||||
std::vector<CompoundArc> arcs{}; ///< >= 1 łuk; sąsiednie promienie różne
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
25
maj0sted/include/maj0sted/domain/fitting/fit_result.hpp
Normal file
25
maj0sted/include/maj0sted/domain/fitting/fit_result.hpp
Normal file
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/geometry/plan_element.hpp"
|
||||
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Result of fitting a curve between two straights.
|
||||
///
|
||||
/// The two straights keep their identity but are trimmed back to the tangent
|
||||
/// points; @c curve is the klotoida?/łuk/klotoida? to splice between them. The
|
||||
/// caller (or an aggregate command) replaces the original entry/exit straights
|
||||
/// with @c entry, then @c curve, then @c exit.
|
||||
struct FitResult {
|
||||
Straight entry; ///< trimmed entry straight (ends at tangent_in)
|
||||
std::vector<PlanElement> curve; ///< klotoida? / łuk / klotoida?
|
||||
Straight exit; ///< trimmed exit straight (starts at tangent_out)
|
||||
CartesianPosition tangent_in; ///< entry -> curve tangent point
|
||||
CartesianPosition tangent_out; ///< curve -> exit tangent point
|
||||
TurnDirection direction; ///< derived turn direction
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
69
maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp
Normal file
69
maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp
Normal file
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "maj0sted/domain/fitting/fit_parameters.hpp"
|
||||
#include "maj0sted/domain/fitting/fit_result.hpp"
|
||||
#include "maj0sted/domain/niweleta.hpp"
|
||||
#include "maj0sted/domain/value_objects/length.hpp"
|
||||
#include "maj0sted/domain/value_objects/radius.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Domain Service: fits a curve between two straights of a single niweleta.
|
||||
class FittingService {
|
||||
public:
|
||||
// --- Overloads that fit two standalone straights (no niweleta) ---
|
||||
|
||||
/// Rounds the corner between two straight lines with an arc (and optional
|
||||
/// symmetric transition curves). Works for any deflection strictly between
|
||||
/// 0° and 180°. For a 180° reversal use fit_uturn instead.
|
||||
static FitResult fit_between_straights(
|
||||
const Straight& entry, const Straight& exit, Radius radius,
|
||||
std::optional<Length> transition = std::nullopt);
|
||||
|
||||
/// Fits a compound curve between two standalone straight lines.
|
||||
static FitResult fit_compound(const Straight& entry, const Straight& exit,
|
||||
const CompoundFitParameters& params);
|
||||
|
||||
/// Connects two (anti-)parallel straights with a 180° reversal ("nawrót").
|
||||
/// The radius is derived from the perpendicular offset between the two lines
|
||||
/// (a plain arc uses R = offset / 2). Optional symmetric transition curves
|
||||
/// are solved for numerically.
|
||||
/// @throws std::invalid_argument if the straights are not anti-parallel or
|
||||
/// their offset is zero.
|
||||
static FitResult fit_uturn(const Straight& entry, const Straight& exit,
|
||||
std::optional<Length> transition = std::nullopt);
|
||||
|
||||
/// Fits an S-curve made of two tangent circular arcs with the same radius
|
||||
/// and opposite turn directions. Supports non-parallel straights and
|
||||
/// same-direction parallel straights with a non-zero lateral offset.
|
||||
/// Tangent points are required to lie on both finite straight segments.
|
||||
/// @throws std::invalid_argument if no equal-radius reverse curve fits.
|
||||
static FitResult fit_reverse(const Straight& entry, const Straight& exit,
|
||||
Radius radius);
|
||||
|
||||
/// Computes the curve (and the trimmed straights) that connects the two
|
||||
/// straights named in @p params, both belonging to @p niweleta.
|
||||
///
|
||||
/// @throws std::invalid_argument if a straight is missing, the two are the
|
||||
/// same, the straights are parallel/collinear, or the transition
|
||||
/// curves are too long for the chosen radius and deflection.
|
||||
static FitResult fit_between_straights(const Niweleta& niweleta,
|
||||
const FitParameters& params);
|
||||
|
||||
/// Computes a compound curve ("łuk koszowy") — a chain of N arcs of
|
||||
/// different neighbouring radii, same direction — between the two straights
|
||||
/// named in @p params. Both straight lines stay fixed and are re-trimmed in
|
||||
/// length; each arc's length sets its central angle, and the last arc takes
|
||||
/// the remaining deflection.
|
||||
///
|
||||
/// @throws std::invalid_argument if a straight is missing, the two are the
|
||||
/// same, there are no arcs, neighbouring radii are equal, a non-last
|
||||
/// arc has no length, the straights are parallel/collinear, or the
|
||||
/// split lengths exceed the total deflection.
|
||||
static FitResult fit_compound(const Niweleta& niweleta,
|
||||
const CompoundFitParameters& params);
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
74
maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp
Normal file
74
maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/fitting/fit_result.hpp"
|
||||
#include "maj0sted/domain/geometry/plan_element.hpp" // Straight
|
||||
#include "maj0sted/domain/value_objects/length.hpp"
|
||||
#include "maj0sted/domain/value_objects/radius.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// How the gap between two independent, finite straights should be connected.
|
||||
/// A pure domain concept: the editor's numeric fit modes map onto it, but this
|
||||
/// enum carries no UI knowledge. A plain circular arc (Arc) and a compound curve
|
||||
/// (Compound) are the only kinds — a "łuk + klotoidy" is just a Compound with a
|
||||
/// single arc eased by symmetric clothoids.
|
||||
enum class GapConnectionMode { Arc, Compound };
|
||||
|
||||
/// One arc of a compound connection.
|
||||
struct CompoundGapArc {
|
||||
double radius{0.0}; ///< arc radius (metres)
|
||||
double length{0.0}; ///< fixed arc length (metres); ignored for the last arc
|
||||
double transition_to_next{0.0}; ///< clothoid to the next arc (metres, 0 = none)
|
||||
};
|
||||
|
||||
/// Extra parameters for a compound connection: entry/exit easing clothoids plus a
|
||||
/// sequence of one or more arcs. A single arc yields "łuk + klotoidy"; two or
|
||||
/// more yield a "łuk koszowy". The last arc absorbs the remaining deflection, so
|
||||
/// its @c length / @c transition_to_next are ignored.
|
||||
struct CompoundGapParameters {
|
||||
double entry_transition{0.0};
|
||||
double exit_transition{0.0};
|
||||
std::vector<CompoundGapArc> arcs{};
|
||||
};
|
||||
|
||||
/// A request to connect two finite straights with a curve.
|
||||
struct GapConnectionRequest {
|
||||
GapConnectionMode mode{GapConnectionMode::Arc};
|
||||
double radius{0.0}; ///< plain-arc radius (metres)
|
||||
CompoundGapParameters compound{}; ///< compound only
|
||||
};
|
||||
|
||||
/// The connection actually produced.
|
||||
struct GapConnection {
|
||||
FitResult fit; ///< trimmed straights + spliced curve
|
||||
double applied_radius{0.0}; ///< radius truly used (may be reduced to fit)
|
||||
};
|
||||
|
||||
/// Domain policy connecting two independent, finite straights for the editor.
|
||||
///
|
||||
/// This is the single source of truth for the fitting business rules:
|
||||
/// * a 180° reversal is detected from the geometry and handled as a nawrót,
|
||||
/// never surfaced as a user-chosen mode;
|
||||
/// * a plain arc uses the requested radius verbatim whenever it fits; the
|
||||
/// radius is NEVER enlarged. A tighter-than-needed arc fits by extending the
|
||||
/// straights toward the corner; only a radius too large to land on the
|
||||
/// straights is reduced, to the largest one that fits. No other curve type is
|
||||
/// ever substituted;
|
||||
/// * a compound curve (one arc eased by clothoids, or a multi-arc basket) is
|
||||
/// built as specified;
|
||||
/// * a tangent point may extend a straight toward the corner, but must not cross
|
||||
/// behind its outer, anchored end.
|
||||
///
|
||||
/// Returns nullopt when nothing admissible fits (the caller then leaves the gap
|
||||
/// unconnected). Never throws.
|
||||
class GapFitter {
|
||||
public:
|
||||
[[nodiscard]] static std::optional<GapConnection> connect(
|
||||
const Straight& entry, const Straight& exit,
|
||||
const GapConnectionRequest& request);
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/geometry/plan_element.hpp"
|
||||
#include "maj0sted/domain/value_objects/length.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// How two consecutive plan elements meet.
|
||||
enum class JointContinuity {
|
||||
/// Uniform azimuth ("styczność") — the rule for every joint.
|
||||
Tangent,
|
||||
/// An explicitly marked azimuth discontinuity ("załamanie"). Allowed only
|
||||
/// between two straights that touch at different azimuths.
|
||||
AzimuthBreak,
|
||||
};
|
||||
|
||||
/// "Niweleta w planie": a directional (ordered) list of plan elements.
|
||||
///
|
||||
/// Straights are anchored in XY; the curves float and are positioned by later
|
||||
/// fitting. The list enforces the connection rules between consecutive elements:
|
||||
///
|
||||
/// * as a rule consecutive elements meet under a uniform azimuth (Tangent);
|
||||
/// * two straights may meet at different azimuths only if the joint is
|
||||
/// explicitly marked as an AzimuthBreak;
|
||||
/// * two circular arcs with the same radius and the same direction may not
|
||||
/// be adjacent (it would be a single arc).
|
||||
///
|
||||
/// Joints that involve a floating curve cannot be fully checked yet and are
|
||||
/// accepted pending the fitting step.
|
||||
class HorizontalAlignment {
|
||||
public:
|
||||
/// Appends an element. The first element ignores @p joint; every later
|
||||
/// element is validated against the current last element.
|
||||
/// @throws std::logic_error if the joint violates a connection rule.
|
||||
void append(PlanElement element, JointContinuity joint = JointContinuity::Tangent);
|
||||
|
||||
[[nodiscard]] const std::vector<PlanElement>& elements() const noexcept {
|
||||
return elements_;
|
||||
}
|
||||
/// Joint between element @p index and @p index + 1 (index < size() - 1).
|
||||
[[nodiscard]] JointContinuity joint(std::size_t index) const {
|
||||
return joints_.at(index);
|
||||
}
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return elements_.empty(); }
|
||||
[[nodiscard]] std::size_t size() const noexcept { return elements_.size(); }
|
||||
|
||||
/// Sum of the known element lengths; arcs without a length are skipped.
|
||||
[[nodiscard]] Length total_length() const;
|
||||
|
||||
private:
|
||||
std::vector<PlanElement> elements_;
|
||||
std::vector<JointContinuity> joints_; // size() == elements_.size() - 1
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
127
maj0sted/include/maj0sted/domain/geometry/plan_element.hpp
Normal file
127
maj0sted/include/maj0sted/domain/geometry/plan_element.hpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
#include <variant>
|
||||
|
||||
#include "maj0sted/domain/value_objects/azimuth.hpp"
|
||||
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
|
||||
#include "maj0sted/domain/value_objects/length.hpp"
|
||||
#include "maj0sted/domain/value_objects/radius.hpp"
|
||||
#include "maj0sted/domain/value_objects/straight_id.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Direction of a horizontal turn: left ("w lewo") or right ("w prawo").
|
||||
enum class TurnDirection { Left, Right };
|
||||
|
||||
/// Krzywa w planie: odcinek prosty.
|
||||
///
|
||||
/// A straight is *anchored* by both of its XY endpoints; its azimuth and length
|
||||
/// are computed from them. During fitting a straight keeps its supporting line
|
||||
/// (position + azimuth) fixed — only its length may change as an endpoint slides
|
||||
/// along that line. The curves between straights are positioned as a result of
|
||||
/// that fitting, not the other way round.
|
||||
///
|
||||
/// A straight also carries an optional identity (StraightId). References between
|
||||
/// straight segments — e.g. parallelism — point at that id directly. Straights
|
||||
/// used purely as geometry may stay anonymous (null id).
|
||||
class Straight {
|
||||
public:
|
||||
/// Anonymous straight (null id).
|
||||
/// @throws std::invalid_argument if @p start and @p end coincide (a
|
||||
/// zero-length straight has no defined azimuth).
|
||||
Straight(CartesianPosition start, CartesianPosition end)
|
||||
: Straight{StraightId{}, start, end} {}
|
||||
|
||||
/// Identified straight.
|
||||
/// @throws std::invalid_argument if @p start and @p end coincide.
|
||||
Straight(StraightId id, CartesianPosition start, CartesianPosition end)
|
||||
: id_{id},
|
||||
start_{start},
|
||||
end_{end},
|
||||
azimuth_{Azimuth::of_direction(start, end)},
|
||||
length_{Length::from_metres(distance(start, end))} {}
|
||||
|
||||
[[nodiscard]] StraightId id() const noexcept { return id_; }
|
||||
[[nodiscard]] CartesianPosition start() const noexcept { return start_; }
|
||||
[[nodiscard]] CartesianPosition end() const noexcept { return end_; }
|
||||
[[nodiscard]] Azimuth azimuth() const noexcept { return azimuth_; }
|
||||
[[nodiscard]] Length length() const noexcept { return length_; }
|
||||
|
||||
private:
|
||||
static double distance(CartesianPosition a, CartesianPosition b) noexcept {
|
||||
const double dx = b.x() - a.x();
|
||||
const double dy = b.y() - a.y();
|
||||
return std::sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
StraightId id_;
|
||||
CartesianPosition start_;
|
||||
CartesianPosition end_;
|
||||
Azimuth azimuth_;
|
||||
Length length_;
|
||||
};
|
||||
|
||||
/// Krzywa w planie: łuk kołowy o stałym promieniu.
|
||||
///
|
||||
/// A floating element: it is defined by its radius and turn direction. Its
|
||||
/// length is not essential and is therefore optional — typically it becomes
|
||||
/// known only as a resultant of fitting between the anchored straights.
|
||||
class CircularArc {
|
||||
public:
|
||||
CircularArc(Radius radius, TurnDirection direction,
|
||||
std::optional<Length> length = std::nullopt) noexcept
|
||||
: radius_{radius}, direction_{direction}, length_{length} {}
|
||||
|
||||
[[nodiscard]] Radius radius() const noexcept { return radius_; }
|
||||
[[nodiscard]] TurnDirection direction() const noexcept { return direction_; }
|
||||
[[nodiscard]] std::optional<Length> length() const noexcept { return length_; }
|
||||
|
||||
private:
|
||||
Radius radius_;
|
||||
TurnDirection direction_;
|
||||
std::optional<Length> length_;
|
||||
};
|
||||
|
||||
/// Krzywa w planie: krzywa przejściowa (klotoida).
|
||||
///
|
||||
/// A floating element defined by its length, the radii at each end and its turn
|
||||
/// direction. A missing radius (nullopt) denotes an infinite radius — a tangent
|
||||
/// to a straight — so one type covers straight->arc, arc->arc and arc->straight.
|
||||
class TransitionCurve {
|
||||
public:
|
||||
TransitionCurve(Length length, std::optional<Radius> start_radius,
|
||||
std::optional<Radius> end_radius, TurnDirection direction) noexcept
|
||||
: length_{length},
|
||||
start_radius_{start_radius},
|
||||
end_radius_{end_radius},
|
||||
direction_{direction} {}
|
||||
|
||||
[[nodiscard]] Length length() const noexcept { return length_; }
|
||||
[[nodiscard]] std::optional<Radius> start_radius() const noexcept { return start_radius_; }
|
||||
[[nodiscard]] std::optional<Radius> end_radius() const noexcept { return end_radius_; }
|
||||
[[nodiscard]] TurnDirection direction() const noexcept { return direction_; }
|
||||
|
||||
private:
|
||||
Length length_;
|
||||
std::optional<Radius> start_radius_;
|
||||
std::optional<Radius> end_radius_;
|
||||
TurnDirection direction_;
|
||||
};
|
||||
|
||||
/// Any single element of the horizontal alignment.
|
||||
using PlanElement = std::variant<Straight, CircularArc, TransitionCurve>;
|
||||
|
||||
/// Length of a plan element, if known. A circular arc may not carry one.
|
||||
[[nodiscard]] inline std::optional<Length> length_of(const PlanElement& element) {
|
||||
if (const auto* straight = std::get_if<Straight>(&element)) {
|
||||
return straight->length();
|
||||
}
|
||||
if (const auto* transition = std::get_if<TransitionCurve>(&element)) {
|
||||
return transition->length();
|
||||
}
|
||||
return std::get<CircularArc>(element).length();
|
||||
}
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <variant>
|
||||
|
||||
#include "maj0sted/domain/value_objects/length.hpp"
|
||||
#include "maj0sted/domain/value_objects/radius.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Sense of a vertical curve: a crest ("wypukły" — hilltop) or a sag
|
||||
/// ("wklęsły" — valley).
|
||||
enum class VerticalCurvature { Crest, Sag };
|
||||
|
||||
/// Krzywa w profilu: pochylenie o stałym nachyleniu, wyrażonym w promilach (‰).
|
||||
/// Wartość dodatnia oznacza wznoszenie, ujemna — opadanie.
|
||||
class Grade {
|
||||
public:
|
||||
Grade(Length length, double slope_permille) noexcept
|
||||
: length_{length}, slope_permille_{slope_permille} {}
|
||||
|
||||
[[nodiscard]] Length length() const noexcept { return length_; }
|
||||
[[nodiscard]] double slope_permille() const noexcept { return slope_permille_; }
|
||||
|
||||
private:
|
||||
Length length_;
|
||||
double slope_permille_;
|
||||
};
|
||||
|
||||
/// Krzywa w profilu: łuk pionowy o stałym promieniu, łączący dwa pochylenia.
|
||||
class VerticalCurve {
|
||||
public:
|
||||
VerticalCurve(Length length, Radius radius, VerticalCurvature curvature) noexcept
|
||||
: length_{length}, radius_{radius}, curvature_{curvature} {}
|
||||
|
||||
[[nodiscard]] Length length() const noexcept { return length_; }
|
||||
[[nodiscard]] Radius radius() const noexcept { return radius_; }
|
||||
[[nodiscard]] VerticalCurvature curvature() const noexcept { return curvature_; }
|
||||
|
||||
private:
|
||||
Length length_;
|
||||
Radius radius_;
|
||||
VerticalCurvature curvature_;
|
||||
};
|
||||
|
||||
/// Any single element of the vertical alignment.
|
||||
using ProfileElement = std::variant<Grade, VerticalCurve>;
|
||||
|
||||
/// Length of a profile element, whatever its concrete kind.
|
||||
[[nodiscard]] inline Length length_of(const ProfileElement& element) {
|
||||
return std::visit([](const auto& e) { return e.length(); }, element);
|
||||
}
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
90
maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp
Normal file
90
maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp
Normal file
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
namespace maj0sted::domain::geometry {
|
||||
|
||||
struct XY {
|
||||
double x;
|
||||
double y;
|
||||
};
|
||||
|
||||
/// Position plus unit heading, in (east=x, north=y).
|
||||
struct Pose {
|
||||
double x;
|
||||
double y;
|
||||
double hx;
|
||||
double hy;
|
||||
};
|
||||
|
||||
/// Lays out one segment of linearly varying signed curvature, starting at
|
||||
/// @p start: a circular arc when @p k0 == @p k1, a clothoid otherwise, a
|
||||
/// straight when both are zero. When @p out is non-null the sampled points
|
||||
/// (including the start point) are appended to it. Returns the segment's end
|
||||
/// pose.
|
||||
///
|
||||
/// Both the fitting solver and the renderer call this so their geometry is
|
||||
/// bit-for-bit identical — the fitted tangent points then coincide exactly with
|
||||
/// the drawn curve, with no lateral gap even for tight arcs.
|
||||
inline Pose layout_segment(double k0, double k1, double length, Pose start,
|
||||
std::vector<XY>* out) {
|
||||
const double left_x = -start.hy;
|
||||
const double left_y = start.hx;
|
||||
const auto emit = [&](double local_x, double local_y) {
|
||||
if (out != nullptr) {
|
||||
out->push_back({start.x + local_x * start.hx + local_y * left_x,
|
||||
start.y + local_x * start.hy + local_y * left_y});
|
||||
}
|
||||
};
|
||||
|
||||
emit(0.0, 0.0);
|
||||
|
||||
double end_x = 0.0;
|
||||
double end_y = 0.0;
|
||||
if (length > 0.0) {
|
||||
const double turned = std::abs(0.5 * (k0 + k1) * length);
|
||||
const int steps = std::clamp(
|
||||
static_cast<int>(std::ceil(std::max(
|
||||
length / 0.5, turned * 180.0 / std::numbers::pi / 2.0))),
|
||||
8, 512);
|
||||
const double ds = length / steps;
|
||||
|
||||
if (std::abs(k1 - k0) < 1e-12) {
|
||||
const double k = k0;
|
||||
if (std::abs(k) < 1e-12) {
|
||||
end_x = length; // straight
|
||||
emit(end_x, 0.0);
|
||||
} else {
|
||||
for (int i = 1; i <= steps; ++i) { // circular arc, exact
|
||||
const double s = ds * i;
|
||||
end_x = std::sin(k * s) / k;
|
||||
end_y = (1.0 - std::cos(k * s)) / k;
|
||||
emit(end_x, end_y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const double slope = (k1 - k0) / (2.0 * length); // clothoid
|
||||
for (int i = 1; i <= steps; ++i) {
|
||||
const double s_mid = ds * (i - 0.5);
|
||||
const double theta = k0 * s_mid + slope * s_mid * s_mid;
|
||||
end_x += std::cos(theta) * ds;
|
||||
end_y += std::sin(theta) * ds;
|
||||
emit(end_x, end_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const double dtheta = 0.5 * (k0 + k1) * length;
|
||||
const double cos_t = std::cos(dtheta);
|
||||
const double sin_t = std::sin(dtheta);
|
||||
return Pose{start.x + end_x * start.hx + end_y * left_x,
|
||||
start.y + end_x * start.hy + end_y * left_y,
|
||||
start.hx * cos_t - start.hy * sin_t,
|
||||
start.hx * sin_t + start.hy * cos_t};
|
||||
}
|
||||
|
||||
} // namespace maj0sted::domain::geometry
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/geometry/profile_element.hpp"
|
||||
#include "maj0sted/domain/value_objects/length.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Geometria w profilu (rzut pionowy toru): uporządkowany ciąg pochyleń i łuków
|
||||
/// pionowych, gdzie każdy element zaczyna się tam, gdzie kończy się poprzedni.
|
||||
class VerticalAlignment {
|
||||
public:
|
||||
void append(ProfileElement element) { elements_.push_back(std::move(element)); }
|
||||
|
||||
[[nodiscard]] const std::vector<ProfileElement>& elements() const noexcept {
|
||||
return elements_;
|
||||
}
|
||||
[[nodiscard]] bool empty() const noexcept { return elements_.empty(); }
|
||||
[[nodiscard]] std::size_t size() const noexcept { return elements_.size(); }
|
||||
|
||||
/// Sumaryczna długość toru w profilu.
|
||||
[[nodiscard]] Length total_length() const;
|
||||
|
||||
private:
|
||||
std::vector<ProfileElement> elements_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
32
maj0sted/include/maj0sted/domain/map_project.hpp
Normal file
32
maj0sted/include/maj0sted/domain/map_project.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/niweleta.hpp"
|
||||
#include "maj0sted/domain/value_objects/crs.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// The map project ("projekt mapy").
|
||||
///
|
||||
/// It defines the Cartesian coordinate reference system in which every position
|
||||
/// in the project is expressed (by default EPSG:2180, metres) and owns the
|
||||
/// niwelety that make up the layout.
|
||||
class MapProject {
|
||||
public:
|
||||
/// Creates a project using @p crs, or EPSG:2180 by default.
|
||||
explicit MapProject(Crs crs = Crs::default_crs());
|
||||
|
||||
[[nodiscard]] Crs crs() const noexcept { return crs_; }
|
||||
|
||||
void add_niweleta(Niweleta niweleta);
|
||||
[[nodiscard]] const std::vector<Niweleta>& niwelety() const noexcept {
|
||||
return niwelety_;
|
||||
}
|
||||
|
||||
private:
|
||||
Crs crs_;
|
||||
std::vector<Niweleta> niwelety_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
70
maj0sted/include/maj0sted/domain/niweleta.hpp
Normal file
70
maj0sted/include/maj0sted/domain/niweleta.hpp
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "maj0sted/domain/fitting/fit_result.hpp"
|
||||
#include "maj0sted/domain/geometry/horizontal_alignment.hpp"
|
||||
#include "maj0sted/domain/geometry/vertical_alignment.hpp"
|
||||
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
|
||||
#include "maj0sted/domain/value_objects/length.hpp"
|
||||
#include "maj0sted/domain/value_objects/niweleta_id.hpp"
|
||||
#include "maj0sted/domain/value_objects/straight_id.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Niweleta — jednolity odcinek toru złożony z wielu krzywych w planie i w
|
||||
/// profilu.
|
||||
///
|
||||
/// Aggregate Root: to granica spójności całego odcinka. Geometria w planie i w
|
||||
/// profilu opisują ten sam fizyczny tor, więc ich sumaryczne długości muszą być
|
||||
/// zgodne — pilnuje tego lengths_consistent(). Krzywe dokłada się wyłącznie
|
||||
/// przez metody agregatu, który utrzymuje ich kolejność.
|
||||
class Niweleta {
|
||||
public:
|
||||
explicit Niweleta(NiweletaId id, std::string name = {});
|
||||
|
||||
[[nodiscard]] NiweletaId id() const noexcept { return id_; }
|
||||
[[nodiscard]] const std::string& name() const noexcept { return name_; }
|
||||
|
||||
/// Dodaje prostą na końcu planu, nadając jej automatycznie StraightId,
|
||||
/// który zwraca (do późniejszych referencji: równoległość, fitowanie).
|
||||
StraightId add_straight(CartesianPosition start, CartesianPosition end,
|
||||
JointContinuity joint = JointContinuity::Tangent);
|
||||
|
||||
/// Dokłada krzywą na końcu geometrii w planie, łącząc ją z poprzednim
|
||||
/// elementem zadanym stykiem (domyślnie stycznym). Jeśli elementem jest
|
||||
/// prosta bez tożsamości, niweleta nadaje jej StraightId.
|
||||
void add_plan_element(PlanElement element,
|
||||
JointContinuity joint = JointContinuity::Tangent);
|
||||
|
||||
/// Wpina wynik fitowania w plan: podmienia dwie sąsiednie proste (entry,
|
||||
/// exit z @p result) na entry(przyciętą) → curve... → exit(przyciętą),
|
||||
/// stykami stycznymi (Tangent). Proste zachowują swoje StraightId.
|
||||
/// @throws std::invalid_argument jeśli proste nie zostaną znalezione lub nie
|
||||
/// są sąsiednie (entry bezpośrednio przed exit).
|
||||
void apply_fit(const FitResult& result);
|
||||
|
||||
/// Dokłada krzywą na końcu geometrii w profilu.
|
||||
void add_profile_element(ProfileElement element);
|
||||
|
||||
[[nodiscard]] const HorizontalAlignment& plan() const noexcept { return plan_; }
|
||||
[[nodiscard]] const VerticalAlignment& profile() const noexcept { return profile_; }
|
||||
|
||||
[[nodiscard]] Length plan_length() const { return plan_.total_length(); }
|
||||
[[nodiscard]] Length profile_length() const { return profile_.total_length(); }
|
||||
|
||||
/// Czy plan i profil opisują tor tej samej długości (z zadaną tolerancją)?
|
||||
[[nodiscard]] bool lengths_consistent(double tolerance_m = 1e-6) const;
|
||||
|
||||
private:
|
||||
StraightId next_straight_id() noexcept;
|
||||
|
||||
NiweletaId id_;
|
||||
std::string name_;
|
||||
HorizontalAlignment plan_;
|
||||
VerticalAlignment profile_;
|
||||
std::uint64_t straight_seq_{0};
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
@@ -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
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#include "maj0sted/domain/value_objects/straight_id.hpp"
|
||||
#include "maj0sted/domain/value_objects/track_offset.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// A reference-based relation between two straight segments, identified by their
|
||||
/// StraightId. @c origin is the authoritative reference track; @c parallel runs
|
||||
/// alongside it at @c offset (a perpendicular distance on a given side).
|
||||
///
|
||||
/// Invariants: both straights must be identified (non-null id), and a straight
|
||||
/// cannot be parallel to itself.
|
||||
class TrackParallelism {
|
||||
public:
|
||||
TrackParallelism(StraightId origin, StraightId parallel, TrackOffset offset)
|
||||
: origin_{origin}, parallel_{parallel}, offset_{offset} {
|
||||
if (origin.is_null() || parallel.is_null()) {
|
||||
throw std::invalid_argument{"Parallelism requires identified straights"};
|
||||
}
|
||||
if (origin == parallel) {
|
||||
throw std::invalid_argument{"A straight cannot be parallel to itself"};
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] StraightId origin() const noexcept { return origin_; }
|
||||
[[nodiscard]] StraightId parallel() const noexcept { return parallel_; }
|
||||
[[nodiscard]] TrackOffset offset() const noexcept { return offset_; }
|
||||
|
||||
private:
|
||||
StraightId origin_;
|
||||
StraightId parallel_;
|
||||
TrackOffset offset_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
67
maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp
Normal file
67
maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp
Normal file
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <compare>
|
||||
#include <numbers>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Value Object: a horizontal azimuth — the direction of travel in the plan,
|
||||
/// measured clockwise from north (the +y / northing axis) and normalised to
|
||||
/// [0, 2*pi) radians.
|
||||
class Azimuth {
|
||||
public:
|
||||
static Azimuth from_radians(double radians) noexcept {
|
||||
return Azimuth{normalise(radians)};
|
||||
}
|
||||
|
||||
static Azimuth from_degrees(double degrees) noexcept {
|
||||
return from_radians(degrees * std::numbers::pi / 180.0);
|
||||
}
|
||||
|
||||
/// Azimuth of the direction from @p from to @p to.
|
||||
/// @throws std::invalid_argument if the two points coincide.
|
||||
static Azimuth of_direction(CartesianPosition from, CartesianPosition to) {
|
||||
const double d_east = to.x() - from.x();
|
||||
const double d_north = to.y() - from.y();
|
||||
if (d_east == 0.0 && d_north == 0.0) {
|
||||
throw std::invalid_argument{"Azimuth is undefined for coincident points"};
|
||||
}
|
||||
return from_radians(std::atan2(d_east, d_north));
|
||||
}
|
||||
|
||||
[[nodiscard]] double radians() const noexcept { return radians_; }
|
||||
[[nodiscard]] double degrees() const noexcept {
|
||||
return radians_ * 180.0 / std::numbers::pi;
|
||||
}
|
||||
|
||||
/// Smallest absolute angular difference to @p other, in [0, pi].
|
||||
[[nodiscard]] double angular_distance(Azimuth other) const noexcept {
|
||||
double difference = std::fabs(radians_ - other.radians_);
|
||||
if (difference > std::numbers::pi) {
|
||||
difference = 2.0 * std::numbers::pi - difference;
|
||||
}
|
||||
return difference;
|
||||
}
|
||||
|
||||
friend auto operator<=>(const Azimuth&, const Azimuth&) noexcept = default;
|
||||
|
||||
private:
|
||||
explicit Azimuth(double radians) noexcept : radians_{radians} {}
|
||||
|
||||
static double normalise(double radians) noexcept {
|
||||
const double two_pi = 2.0 * std::numbers::pi;
|
||||
double result = std::fmod(radians, two_pi);
|
||||
if (result < 0.0) {
|
||||
result += two_pi;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
double radians_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <compare>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Value Object: a position in a Cartesian coordinate reference system.
|
||||
///
|
||||
/// Coordinates are expressed in the units of the project's CRS. For the default
|
||||
/// EPSG:2180 these are metres, with @c x as easting and @c y as northing.
|
||||
class CartesianPosition {
|
||||
public:
|
||||
constexpr CartesianPosition() noexcept = default;
|
||||
constexpr CartesianPosition(double x, double y) noexcept : x_{x}, y_{y} {}
|
||||
|
||||
[[nodiscard]] constexpr double x() const noexcept { return x_; }
|
||||
[[nodiscard]] constexpr double y() const noexcept { return y_; }
|
||||
|
||||
friend constexpr bool operator==(const CartesianPosition&,
|
||||
const CartesianPosition&) noexcept = default;
|
||||
|
||||
private:
|
||||
double x_{0.0};
|
||||
double y_{0.0};
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
29
maj0sted/include/maj0sted/domain/value_objects/crs.hpp
Normal file
29
maj0sted/include/maj0sted/domain/value_objects/crs.hpp
Normal file
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <compare>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Value Object: a Coordinate Reference System, identified by its EPSG code.
|
||||
///
|
||||
/// The default for a map project is EPSG:2180 (PUWG 1992 / Poland CS92), a
|
||||
/// Cartesian, metre-based projected system.
|
||||
class Crs {
|
||||
public:
|
||||
// EPSG code of the default map-project CRS.
|
||||
static constexpr int kDefaultEpsg = 2180;
|
||||
|
||||
explicit constexpr Crs(int epsg) noexcept : epsg_{epsg} {}
|
||||
|
||||
/// The default CRS: EPSG:2180.
|
||||
static constexpr Crs default_crs() noexcept { return Crs{kDefaultEpsg}; }
|
||||
|
||||
[[nodiscard]] constexpr int epsg() const noexcept { return epsg_; }
|
||||
|
||||
friend constexpr auto operator<=>(const Crs&, const Crs&) noexcept = default;
|
||||
|
||||
private:
|
||||
int epsg_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
37
maj0sted/include/maj0sted/domain/value_objects/length.hpp
Normal file
37
maj0sted/include/maj0sted/domain/value_objects/length.hpp
Normal file
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <compare>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Value Object: a non-negative length, in metres (the unit of the default
|
||||
/// EPSG:2180 CRS). Lengths add and compare by value.
|
||||
class Length {
|
||||
public:
|
||||
constexpr Length() noexcept = default;
|
||||
|
||||
static constexpr Length from_metres(double metres) {
|
||||
if (metres < 0.0) {
|
||||
throw std::invalid_argument{"Length cannot be negative"};
|
||||
}
|
||||
return Length{metres};
|
||||
}
|
||||
|
||||
static constexpr Length zero() noexcept { return Length{}; }
|
||||
|
||||
[[nodiscard]] constexpr double metres() const noexcept { return metres_; }
|
||||
|
||||
[[nodiscard]] constexpr Length operator+(Length other) const noexcept {
|
||||
return Length{metres_ + other.metres_};
|
||||
}
|
||||
|
||||
friend constexpr auto operator<=>(const Length&, const Length&) noexcept = default;
|
||||
|
||||
private:
|
||||
explicit constexpr Length(double metres) noexcept : metres_{metres} {}
|
||||
|
||||
double metres_{0.0};
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <compare>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Value Object: identity of a Niweleta aggregate. Cross-aggregate references
|
||||
/// (e.g. parallelism) point at niwelety by this id, never by pointer. Value 0
|
||||
/// denotes "no identity".
|
||||
class NiweletaId {
|
||||
public:
|
||||
using value_type = std::uint64_t;
|
||||
|
||||
constexpr NiweletaId() noexcept = default;
|
||||
explicit constexpr NiweletaId(value_type value) noexcept : value_{value} {}
|
||||
|
||||
[[nodiscard]] constexpr value_type value() const noexcept { return value_; }
|
||||
[[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; }
|
||||
|
||||
friend constexpr auto operator<=>(const NiweletaId&, const NiweletaId&) noexcept = default;
|
||||
|
||||
private:
|
||||
value_type value_{0};
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
|
||||
template <>
|
||||
struct std::hash<maj0sted::domain::NiweletaId> {
|
||||
std::size_t operator()(const maj0sted::domain::NiweletaId& id) const noexcept {
|
||||
return std::hash<std::uint64_t>{}(id.value());
|
||||
}
|
||||
};
|
||||
30
maj0sted/include/maj0sted/domain/value_objects/radius.hpp
Normal file
30
maj0sted/include/maj0sted/domain/value_objects/radius.hpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <compare>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Value Object: a radius of curvature, in metres. Always strictly positive —
|
||||
/// the turn direction (plan) or curve sense (profile) is carried separately by
|
||||
/// the geometric element, so the radius itself is just a magnitude.
|
||||
class Radius {
|
||||
public:
|
||||
static constexpr Radius from_metres(double metres) {
|
||||
if (!(metres > 0.0)) {
|
||||
throw std::invalid_argument{"Radius must be positive"};
|
||||
}
|
||||
return Radius{metres};
|
||||
}
|
||||
|
||||
[[nodiscard]] constexpr double metres() const noexcept { return metres_; }
|
||||
|
||||
friend constexpr auto operator<=>(const Radius&, const Radius&) noexcept = default;
|
||||
|
||||
private:
|
||||
explicit constexpr Radius(double metres) noexcept : metres_{metres} {}
|
||||
|
||||
double metres_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <compare>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Value Object: identity of a straight segment. A straight that takes part in
|
||||
/// references (e.g. parallelism) carries a non-null id; anonymous straights used
|
||||
/// purely as geometry may leave it null. Value 0 denotes "no identity".
|
||||
class StraightId {
|
||||
public:
|
||||
using value_type = std::uint64_t;
|
||||
|
||||
constexpr StraightId() noexcept = default;
|
||||
explicit constexpr StraightId(value_type value) noexcept : value_{value} {}
|
||||
|
||||
[[nodiscard]] constexpr value_type value() const noexcept { return value_; }
|
||||
[[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; }
|
||||
|
||||
friend constexpr auto operator<=>(const StraightId&, const StraightId&) noexcept = default;
|
||||
|
||||
private:
|
||||
value_type value_{0};
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
|
||||
template <>
|
||||
struct std::hash<maj0sted::domain::StraightId> {
|
||||
std::size_t operator()(const maj0sted::domain::StraightId& id) const noexcept {
|
||||
return std::hash<std::uint64_t>{}(id.value());
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#include "maj0sted/domain/value_objects/length.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
/// Which side a parallel track runs on, relative to the origin straight's
|
||||
/// direction of travel: left ("po lewej") or right ("po prawej").
|
||||
enum class Side { Left, Right };
|
||||
|
||||
/// Value Object: the sideways offset of a parallel track from its origin — a
|
||||
/// strictly positive perpendicular distance plus the side it lies on.
|
||||
class TrackOffset {
|
||||
public:
|
||||
TrackOffset(Length distance, Side side) : distance_{distance}, side_{side} {
|
||||
if (!(distance.metres() > 0.0)) {
|
||||
throw std::invalid_argument{"Track offset distance must be positive"};
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] Length distance() const noexcept { return distance_; }
|
||||
[[nodiscard]] Side side() const noexcept { return side_; }
|
||||
|
||||
private:
|
||||
Length distance_;
|
||||
Side side_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
19
maj0sted/include/maj0sted/io/project_serializer.hpp
Normal file
19
maj0sted/include/maj0sted/io/project_serializer.hpp
Normal file
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "maj0sted/domain/map_project.hpp"
|
||||
|
||||
namespace maj0sted::io {
|
||||
|
||||
/// Serializes a whole map project (CRS + niwelety, each with its plan and
|
||||
/// profile) to a self-contained textual format. The format is line-oriented and
|
||||
/// dependency-free; @c deserialize round-trips it exactly.
|
||||
[[nodiscard]] std::string serialize(const maj0sted::domain::MapProject& project);
|
||||
|
||||
/// Rebuilds a map project from text produced by @c serialize.
|
||||
/// @throws std::runtime_error on malformed input; std::invalid_argument if the
|
||||
/// reconstructed geometry violates a domain invariant.
|
||||
[[nodiscard]] maj0sted::domain::MapProject deserialize(const std::string& text);
|
||||
|
||||
} // namespace maj0sted::io
|
||||
20
maj0sted/include/maj0sted/io/svg_writer.hpp
Normal file
20
maj0sted/include/maj0sted/io/svg_writer.hpp
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "maj0sted/render/scene.hpp"
|
||||
|
||||
namespace maj0sted::io {
|
||||
|
||||
struct SvgOptions {
|
||||
double width_px = 900.0; ///< target drawing width
|
||||
double margin_px = 24.0; ///< padding around the drawing
|
||||
double stroke_px = 2.0; ///< line width
|
||||
};
|
||||
|
||||
/// Renders a scene to a standalone SVG document (north up). Straights, arcs and
|
||||
/// transition curves are drawn in distinct colours. Dependency-free — just a
|
||||
/// simple viewer; a real GUI would consume render::Scene directly.
|
||||
[[nodiscard]] std::string to_svg(const render::Scene& scene, const SvgOptions& options = {});
|
||||
|
||||
} // namespace maj0sted::io
|
||||
39
maj0sted/include/maj0sted/maj0sted.hpp
Normal file
39
maj0sted/include/maj0sted/maj0sted.hpp
Normal file
@@ -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"
|
||||
18
maj0sted/include/maj0sted/render/plan_sampler.hpp
Normal file
18
maj0sted/include/maj0sted/render/plan_sampler.hpp
Normal file
@@ -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
|
||||
42
maj0sted/include/maj0sted/render/rail_renderer.hpp
Normal file
42
maj0sted/include/maj0sted/render/rail_renderer.hpp
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/render/track_renderer.hpp"
|
||||
|
||||
namespace maj0sted::render {
|
||||
|
||||
/// A drawable polyline produced by RailRenderer: one rail, carrying the logical
|
||||
/// metadata of the centreline element it came from so the host can colour and
|
||||
/// hit-test it.
|
||||
struct RailPolyline {
|
||||
ElementKind kind{ElementKind::Straight};
|
||||
std::vector<Point> points;
|
||||
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<RailPolyline>& rails() const noexcept {
|
||||
return rails_;
|
||||
}
|
||||
|
||||
private:
|
||||
double half_gauge_;
|
||||
std::vector<RailPolyline> rails_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::render
|
||||
33
maj0sted/include/maj0sted/render/scene.hpp
Normal file
33
maj0sted/include/maj0sted/render/scene.hpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
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<Point> points;
|
||||
};
|
||||
|
||||
/// A rendering-agnostic view of one or more plans: polylines plus a bounding box.
|
||||
/// Any GUI can draw this — the library computes the geometry, the GUI only paints.
|
||||
struct Scene {
|
||||
std::vector<Polyline> polylines;
|
||||
double min_x{0.0};
|
||||
double min_y{0.0};
|
||||
double max_x{0.0};
|
||||
double max_y{0.0};
|
||||
bool empty{true};
|
||||
};
|
||||
|
||||
} // namespace maj0sted::render
|
||||
38
maj0sted/include/maj0sted/render/track_renderer.hpp
Normal file
38
maj0sted/include/maj0sted/render/track_renderer.hpp
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/render/scene.hpp" // Point, ElementKind
|
||||
|
||||
namespace maj0sted::render {
|
||||
|
||||
/// One sampled centreline element of a solved niweleta, tagged with its logical
|
||||
/// origin so a UI can colour, label and hit-test it. Rendering-neutral: it holds
|
||||
/// the track axis only — no rails, no styling. This is the contract the solver
|
||||
/// hands to a renderer.
|
||||
struct CentrelineElement {
|
||||
ElementKind kind{ElementKind::Straight};
|
||||
std::vector<Point> points; ///< sampled axis in CRS metres
|
||||
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
|
||||
7
maj0sted/include/maj0sted/version.hpp
Normal file
7
maj0sted/include/maj0sted/version.hpp
Normal file
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// Version of the maj0sted library. Keep in sync with project() in CMakeLists.txt.
|
||||
#define MAJ0STED_VERSION_MAJOR 0
|
||||
#define MAJ0STED_VERSION_MINOR 1
|
||||
#define MAJ0STED_VERSION_PATCH 0
|
||||
#define MAJ0STED_VERSION_STRING "0.1.0"
|
||||
72
maj0sted/include/maj0sted/web/editor.hpp
Normal file
72
maj0sted/include/maj0sted/web/editor.hpp
Normal file
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<CompoundArcSpec> arcs{};
|
||||
|
||||
// Legacy fixed two-arc fields, kept so old saved projects still deserialize;
|
||||
// superseded by @c arcs. Used only when @c arcs is empty.
|
||||
double r1{0.0}, arc1_len{0.0}, between{0.0}, r2{0.0};
|
||||
};
|
||||
|
||||
/// A niweleta as edited in the GUI: independent anchored straights plus fits
|
||||
/// connecting consecutive straights. Because the straights are independent, a
|
||||
/// gap can be a normal corner (straights meet at an intersection) OR a 180°
|
||||
/// reversal (parallel, offset straights) — no 180° limitation.
|
||||
struct NiweletaSpec {
|
||||
std::string name;
|
||||
std::vector<StraightSpec> straights;
|
||||
std::vector<GapFit> fits;
|
||||
};
|
||||
|
||||
struct NiweletaPolys {
|
||||
std::vector<WebPolyline> polylines;
|
||||
/// Centreline segments after adjacent fits trim the input straights. Kept
|
||||
/// index-aligned with NiweletaSpec::straights for editor hit handles.
|
||||
std::vector<StraightSpec> rendered_straights;
|
||||
/// The authoritative fit set after solving: exactly the requested fits that
|
||||
/// actually succeeded. A requested fit that could not be produced (bad
|
||||
/// parameters or geometry) is simply absent here — the library, not the host,
|
||||
/// decides that a failed fit becomes "no fit". The host mirrors this list.
|
||||
std::vector<GapFit> applied_fits;
|
||||
};
|
||||
|
||||
/// Builds each niweleta, fits every requested gap, and returns the sampled plan
|
||||
/// per niweleta. Never throws: gaps whose parameters don't admit a fit are left
|
||||
/// unconnected (the two straights simply stay as drawn).
|
||||
[[nodiscard]] std::vector<NiweletaPolys> solve_project(
|
||||
const std::vector<NiweletaSpec>& niwelety);
|
||||
|
||||
} // namespace maj0sted::web
|
||||
32
maj0sted/include/maj0sted/web/ribbon.hpp
Normal file
32
maj0sted/include/maj0sted/web/ribbon.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#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<RibbonElement> elements;
|
||||
};
|
||||
|
||||
/// Lays the ribbon out and returns one polyline per element, ready to draw.
|
||||
[[nodiscard]] std::vector<WebPolyline> solve_ribbon(const RibbonRequest& request);
|
||||
|
||||
} // namespace maj0sted::web
|
||||
56
maj0sted/include/maj0sted/web/solve.hpp
Normal file
56
maj0sted/include/maj0sted/web/solve.hpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
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<WebPoint> 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<WebPolyline> solve_scene(const SolveRequest& request);
|
||||
|
||||
} // namespace maj0sted::web
|
||||
186
maj0sted/include/maj0sted/web/tile_cache.hpp
Normal file
186
maj0sted/include/maj0sted/web/tile_cache.hpp
Normal file
@@ -0,0 +1,186 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
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<std::uint64_t>(static_cast<std::uint32_t>(k.x));
|
||||
h = h * 1099511628211ULL ^
|
||||
static_cast<std::uint64_t>(static_cast<std::uint32_t>(k.y));
|
||||
h = h * 1099511628211ULL ^ static_cast<std::uint64_t>(k.zoom);
|
||||
return static_cast<std::size_t>(h);
|
||||
}
|
||||
};
|
||||
|
||||
/// A bounding box in EPSG:2180 metres (easting = x, northing = y).
|
||||
struct TileBBox {
|
||||
double min_x{0.0};
|
||||
double min_y{0.0};
|
||||
double max_x{0.0};
|
||||
double max_y{0.0};
|
||||
};
|
||||
|
||||
/// The tiling scheme. A pure, static value type defining a 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<TileKey> tiles_for_view(const TileBBox& view,
|
||||
int zoom = kLevel,
|
||||
int max_tiles = 64);
|
||||
};
|
||||
|
||||
/// Configuration for a WMS GetMap source. Defaults target a public EPSG:2180
|
||||
/// endpoint (Polish Geoportal). Every field is data, so a different backend is a
|
||||
/// pure configuration change with no code edits.
|
||||
struct WmsConfig {
|
||||
std::string base_url;
|
||||
std::string layers;
|
||||
std::string format{"image/jpeg"};
|
||||
std::string version{"1.1.1"}; ///< 1.1.1 keeps BBOX as minx,miny,maxx,maxy
|
||||
std::string srs{"EPSG:2180"};
|
||||
int tile_pixels{TileGrid::kTilePixels};
|
||||
|
||||
/// Geoportal ortophoto (ortofotomapa), EPSG:2180, JPEG.
|
||||
[[nodiscard]] static WmsConfig geoportal_ortho();
|
||||
|
||||
/// Geoportal topographic base map (BDOT/raster), EPSG:2180, PNG.
|
||||
[[nodiscard]] static WmsConfig geoportal_topo();
|
||||
};
|
||||
|
||||
/// Builds the full WMS 1.1.1 GetMap URL for @p box using @p cfg. Numbers are
|
||||
/// formatted locale-independently (std::to_chars), matching the rest of the
|
||||
/// library's serialization.
|
||||
[[nodiscard]] std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box);
|
||||
|
||||
enum class TileStatus { Missing, Loading, Ready, Failed };
|
||||
|
||||
/// A cached tile image: raw encoded bytes (JPEG/PNG) plus their MIME type.
|
||||
struct TileImage {
|
||||
TileStatus status{TileStatus::Missing};
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::string content_type;
|
||||
int attempts{0}; ///< failed fetch attempts so far (see TileService retry cap)
|
||||
};
|
||||
|
||||
/// Owns the tile grid, the tile cache and the WMS fetch. This is the single
|
||||
/// place that decides which tile is needed, 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<void(bool, std::vector<std::uint8_t>, std::string)>;
|
||||
/// Injectable network backend: given a URL, eventually invoke DoneFn.
|
||||
using Fetcher = std::function<void(const std::string& url, DoneFn)>;
|
||||
|
||||
explicit TileService(WmsConfig cfg = WmsConfig::geoportal_ortho());
|
||||
|
||||
[[nodiscard]] const WmsConfig& config() const noexcept { return cfg_; }
|
||||
void set_config(WmsConfig cfg) { cfg_ = std::move(cfg); }
|
||||
|
||||
/// 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<void(TileKey)> cb) { on_ready_ = std::move(cb); }
|
||||
|
||||
/// Ensures @p key is cached or being fetched, and returns its current status.
|
||||
/// A cache hit returns Ready immediately and issues no network request.
|
||||
TileStatus request(const TileKey& key);
|
||||
|
||||
/// The cached image for @p key, or nullptr if it is not Ready.
|
||||
[[nodiscard]] const TileImage* peek(const TileKey& key) const;
|
||||
|
||||
/// Stores bytes for @p key directly, marking it Ready. Exposed so a caller
|
||||
/// (or a test) can seed the cache without going through the fetcher.
|
||||
void store(const TileKey& key, std::vector<std::uint8_t> bytes,
|
||||
std::string content_type = {});
|
||||
|
||||
[[nodiscard]] bool has(const TileKey& key) const;
|
||||
[[nodiscard]] std::size_t size() const noexcept { return cache_.size(); }
|
||||
void clear() { cache_.clear(); }
|
||||
|
||||
/// The GetMap URL this service would use for @p key.
|
||||
[[nodiscard]] std::string url_for(const TileKey& key) const;
|
||||
|
||||
/// Finalises an in-flight fetch: on success stores the bytes (Ready), on
|
||||
/// failure marks the tile Failed, then fires the on_ready callback. Public so
|
||||
/// the Emscripten fetch glue (which needs free C callbacks) can delegate here.
|
||||
void complete_fetch(const TileKey& key, bool ok, std::vector<std::uint8_t> bytes,
|
||||
std::string content_type);
|
||||
|
||||
/// Re-issues the fetch for @p key. 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<TileKey, TileImage, TileKeyHash> cache_;
|
||||
Fetcher fetcher_;
|
||||
std::function<void(TileKey)> on_ready_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::web
|
||||
323
maj0sted/src/app/editor_document.cpp
Normal file
323
maj0sted/src/app/editor_document.cpp
Normal file
@@ -0,0 +1,323 @@
|
||||
#include "maj0sted/app/editor_document.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/io/project_serializer.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
namespace maj0sted::app {
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
using maj0sted::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<std::string> tokenize(const std::string& line) {
|
||||
std::vector<std::string> tokens;
|
||||
std::size_t i = 0;
|
||||
const std::size_t n = line.size();
|
||||
while (i < n) {
|
||||
while (i < n && std::isspace(static_cast<unsigned char>(line[i]))) ++i;
|
||||
std::size_t j = i;
|
||||
while (j < n && !std::isspace(static_cast<unsigned char>(line[j]))) ++j;
|
||||
if (j > i) tokens.push_back(line.substr(i, j - i));
|
||||
i = j;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
double to_double(const std::string& text) {
|
||||
double value{};
|
||||
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
if (result.ec != std::errc{}) {
|
||||
throw std::runtime_error{"maj0sted: bad number '" + text + "'"};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
long to_long(const std::string& text) {
|
||||
long value{};
|
||||
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
if (result.ec != std::errc{}) {
|
||||
throw std::runtime_error{"maj0sted: bad integer '" + text + "'"};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Placeholder turn direction for the resolved-domain view: derived from the two
|
||||
// straights' headings (sign of the cross product).
|
||||
TurnDirection gap_direction(const StraightSpec& a, const StraightSpec& b) noexcept {
|
||||
const double ax = a.x2 - a.x1, ay = a.y2 - a.y1;
|
||||
const double bx = b.x2 - b.x1, by = b.y2 - b.y1;
|
||||
return (ax * by - ay * bx) >= 0.0 ? TurnDirection::Left : TurnDirection::Right;
|
||||
}
|
||||
|
||||
double gap_radius(const GapFit* fit) noexcept {
|
||||
if (fit != nullptr) {
|
||||
if (fit->radius > 0.0) return fit->radius;
|
||||
if (!fit->arcs.empty() && fit->arcs.front().radius > 0.0) {
|
||||
return fit->arcs.front().radius;
|
||||
}
|
||||
if (fit->r1 > 0.0) return fit->r1;
|
||||
}
|
||||
return 300.0; // benign placeholder; arcs carry no length in this view
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MapProject to_map_project(const EditorDocument& document) {
|
||||
MapProject project{Crs::default_crs()};
|
||||
|
||||
for (std::size_t index = 0; index < document.niwelety.size(); ++index) {
|
||||
const NiweletaSpec& spec = document.niwelety[index];
|
||||
try {
|
||||
Niweleta niweleta{NiweletaId{static_cast<std::uint64_t>(index + 1)},
|
||||
spec.name};
|
||||
for (std::size_t i = 0; i < spec.straights.size(); ++i) {
|
||||
const StraightSpec& s = spec.straights[i];
|
||||
if (i > 0) {
|
||||
// Keep two anchored straights from being adjacent (which the
|
||||
// alignment forbids) by threading a floating placeholder arc.
|
||||
const GapFit* fit = nullptr;
|
||||
for (const auto& f : spec.fits) {
|
||||
if (static_cast<std::size_t>(f.gap) == i - 1 && f.mode > 0) {
|
||||
fit = &f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
niweleta.add_plan_element(
|
||||
CircularArc{Radius::from_metres(gap_radius(fit)),
|
||||
gap_direction(spec.straights[i - 1], s)});
|
||||
}
|
||||
niweleta.add_plan_element(
|
||||
Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}});
|
||||
}
|
||||
project.add_niweleta(std::move(niweleta));
|
||||
} catch (...) {
|
||||
// Degenerate/inconsistent niweleta: keep the (named) shell so the
|
||||
// project still lists it; the editor section restores the details.
|
||||
project.add_niweleta(Niweleta{
|
||||
NiweletaId{static_cast<std::uint64_t>(index + 1)}, spec.name});
|
||||
}
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
EditorDocument from_map_project(const MapProject& project) {
|
||||
EditorDocument document;
|
||||
for (const auto& niweleta : project.niwelety()) {
|
||||
NiweletaSpec spec;
|
||||
spec.name = niweleta.name();
|
||||
for (const auto& element : niweleta.plan().elements()) {
|
||||
if (const auto* straight = std::get_if<Straight>(&element)) {
|
||||
spec.straights.push_back(StraightSpec{
|
||||
straight->start().x(), straight->start().y(),
|
||||
straight->end().x(), straight->end().y()});
|
||||
}
|
||||
}
|
||||
document.niwelety.push_back(std::move(spec));
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
std::string serialize_document(const EditorDocument& document) {
|
||||
// Reuse the domain serializer for a real, inspectable maj0sted project...
|
||||
std::string out = maj0sted::io::serialize(to_map_project(document));
|
||||
|
||||
// ...then the lossless editor section (straights + fit parameters).
|
||||
out += "editor " + std::to_string(document.niwelety.size()) + "\n";
|
||||
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 <n> [r len trans]*
|
||||
// The variable arc list supersedes the legacy fixed two-arc 'efit'.
|
||||
out += "efit2 " + std::to_string(f.gap) + " " +
|
||||
std::to_string(f.mode) + " " + num(f.radius) + " " +
|
||||
num(f.transition) + " " + num(f.entry_t) + " " + num(f.exit_t) +
|
||||
" " + std::to_string(f.arcs.size());
|
||||
for (const auto& a : f.arcs) {
|
||||
out += " " + num(a.radius) + " " + num(a.length) + " " +
|
||||
num(a.transition_to_next);
|
||||
}
|
||||
out += "\n";
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
EditorDocument deserialize_document(const std::string& text) {
|
||||
std::vector<std::string> lines;
|
||||
{
|
||||
std::istringstream in(text);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
lines.push_back(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Locate the editor section (the domain block before it is interop-only).
|
||||
std::size_t cursor = lines.size();
|
||||
for (std::size_t i = 0; i < lines.size(); ++i) {
|
||||
const auto tokens = tokenize(lines[i]);
|
||||
if (!tokens.empty() && tokens[0] == "editor") {
|
||||
cursor = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cursor >= lines.size()) {
|
||||
throw std::runtime_error{"maj0sted: missing 'editor' section"};
|
||||
}
|
||||
|
||||
const auto next = [&]() -> const std::string& {
|
||||
while (cursor < lines.size()) {
|
||||
const std::string& l = lines[cursor++];
|
||||
bool only_ws = true;
|
||||
for (const char c : l) {
|
||||
if (!std::isspace(static_cast<unsigned char>(c))) {
|
||||
only_ws = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!only_ws) return l;
|
||||
}
|
||||
throw std::runtime_error{"maj0sted: unexpected end of editor section"};
|
||||
};
|
||||
|
||||
EditorDocument document;
|
||||
const auto header = tokenize(next());
|
||||
if (header.size() < 2 || header[0] != "editor") {
|
||||
throw std::runtime_error{"maj0sted: expected 'editor <count>'"};
|
||||
}
|
||||
const long niweleta_count = to_long(header[1]);
|
||||
for (long n = 0; n < niweleta_count; ++n) {
|
||||
const auto eniw = tokenize(next());
|
||||
if (eniw.size() < 3 || eniw[0] != "eniw") {
|
||||
throw std::runtime_error{"maj0sted: expected 'eniw <straights> <fits>'"};
|
||||
}
|
||||
const long straight_count = to_long(eniw[1]);
|
||||
const long fit_count = to_long(eniw[2]);
|
||||
|
||||
NiweletaSpec spec;
|
||||
const std::string& name_line = next();
|
||||
if (name_line.rfind("ename", 0) != 0) {
|
||||
throw std::runtime_error{"maj0sted: expected 'ename'"};
|
||||
}
|
||||
spec.name = name_line.size() > 6 ? name_line.substr(6) : std::string{};
|
||||
|
||||
for (long i = 0; i < straight_count; ++i) {
|
||||
const auto e = tokenize(next());
|
||||
if (e.size() < 5 || e[0] != "estr") {
|
||||
throw std::runtime_error{"maj0sted: bad 'estr' line"};
|
||||
}
|
||||
spec.straights.push_back(
|
||||
StraightSpec{to_double(e[1]), to_double(e[2]), to_double(e[3]),
|
||||
to_double(e[4]), e.size() >= 6 && to_long(e[5]) != 0});
|
||||
}
|
||||
for (long i = 0; i < fit_count; ++i) {
|
||||
const auto e = tokenize(next());
|
||||
GapFit f;
|
||||
if (!e.empty() && e[0] == "efit2") {
|
||||
// New variable-arc format.
|
||||
if (e.size() < 8) {
|
||||
throw std::runtime_error{"maj0sted: bad 'efit2' line"};
|
||||
}
|
||||
f.gap = static_cast<int>(to_long(e[1]));
|
||||
f.mode = static_cast<int>(to_long(e[2]));
|
||||
f.radius = to_double(e[3]);
|
||||
f.transition = to_double(e[4]);
|
||||
f.entry_t = to_double(e[5]);
|
||||
f.exit_t = to_double(e[6]);
|
||||
const long arc_count = to_long(e[7]);
|
||||
for (long a = 0; a < arc_count; ++a) {
|
||||
const std::size_t base = 8 + static_cast<std::size_t>(a) * 3;
|
||||
if (base + 2 >= e.size()) {
|
||||
throw std::runtime_error{"maj0sted: truncated 'efit2' arcs"};
|
||||
}
|
||||
f.arcs.push_back(maj0sted::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<int>(to_long(e[1]));
|
||||
f.mode = static_cast<int>(to_long(e[2]));
|
||||
f.radius = to_double(e[3]);
|
||||
f.transition = to_double(e[4]);
|
||||
f.r1 = to_double(e[5]);
|
||||
f.arc1_len = to_double(e[6]);
|
||||
f.between = to_double(e[7]);
|
||||
f.r2 = to_double(e[8]);
|
||||
f.entry_t = to_double(e[9]);
|
||||
f.exit_t = to_double(e[10]);
|
||||
} else {
|
||||
throw std::runtime_error{"maj0sted: bad 'efit'/'efit2' line"};
|
||||
}
|
||||
spec.fits.push_back(f);
|
||||
}
|
||||
document.niwelety.push_back(std::move(spec));
|
||||
}
|
||||
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<bool>(out);
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<EditorDocument> load_project(const std::string& path) {
|
||||
try {
|
||||
const std::filesystem::path file{path};
|
||||
if (!std::filesystem::exists(file)) return std::nullopt;
|
||||
std::ifstream in(file, std::ios::binary);
|
||||
if (!in) return std::nullopt;
|
||||
std::ostringstream buffer;
|
||||
buffer << in.rdbuf();
|
||||
return deserialize_document(buffer.str());
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace maj0sted::app
|
||||
582
maj0sted/src/domain/fitting_service.cpp
Normal file
582
maj0sted/src/domain/fitting_service.cpp
Normal file
@@ -0,0 +1,582 @@
|
||||
#include "maj0sted/domain/fitting/fitting_service.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <numeric>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/geometry/segment_layout.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
namespace {
|
||||
|
||||
constexpr double kEpsilon = 1e-9;
|
||||
|
||||
struct Vec {
|
||||
double east;
|
||||
double north;
|
||||
};
|
||||
|
||||
// Unit direction of travel for an azimuth (east=x, north=y).
|
||||
Vec direction_of(Azimuth azimuth) noexcept {
|
||||
return Vec{std::sin(azimuth.radians()), std::cos(azimuth.radians())};
|
||||
}
|
||||
|
||||
const Straight& find_straight(const Niweleta& niweleta, StraightId id) {
|
||||
for (const auto& element : niweleta.plan().elements()) {
|
||||
if (const auto* straight = std::get_if<Straight>(&element)) {
|
||||
if (!straight->id().is_null() && straight->id() == id) {
|
||||
return *straight;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw std::invalid_argument{"Straight with the given id not found in the niweleta"};
|
||||
}
|
||||
|
||||
// A segment of the chain: signed curvature k0 -> k1 over `length`.
|
||||
struct Seg {
|
||||
double k0;
|
||||
double k1;
|
||||
double length;
|
||||
};
|
||||
|
||||
// Result of closing a chain onto the exit straight: the two tangent points that
|
||||
// the entry/exit straights are trimmed to.
|
||||
struct ChainClose {
|
||||
CartesianPosition tangent_in;
|
||||
CartesianPosition tangent_out;
|
||||
};
|
||||
|
||||
double straight_length(const Straight& straight) noexcept {
|
||||
return straight.length().metres();
|
||||
}
|
||||
|
||||
double parameter_on(const Straight& straight, CartesianPosition point) noexcept {
|
||||
const Vec direction = direction_of(straight.azimuth());
|
||||
return (point.x() - straight.start().x()) * direction.east +
|
||||
(point.y() - straight.start().y()) * direction.north;
|
||||
}
|
||||
|
||||
bool valid_tangent_points(const Straight& entry, const Straight& exit,
|
||||
const ChainClose& close) noexcept {
|
||||
constexpr double tolerance = 1e-6;
|
||||
const double entry_parameter = parameter_on(entry, close.tangent_in);
|
||||
const double exit_parameter = parameter_on(exit, close.tangent_out);
|
||||
// A zero-length trimmed straight cannot be represented by Straight.
|
||||
return entry_parameter > tolerance &&
|
||||
entry_parameter <= straight_length(entry) + tolerance &&
|
||||
exit_parameter >= -tolerance &&
|
||||
exit_parameter < straight_length(exit) - tolerance;
|
||||
}
|
||||
|
||||
// Lays out `segments` head-to-tail from entry.start() heading along the entry
|
||||
// azimuth, then slides the whole rigid chain along the entry line until its end
|
||||
// lands on the exit line. Uses the SAME layout as the renderer, so the fitted
|
||||
// tangent points coincide exactly with the drawn curve.
|
||||
ChainClose solve_chain(const Straight& entry, const Straight& exit,
|
||||
const std::vector<Seg>& segments) {
|
||||
const Vec d1 = direction_of(entry.azimuth());
|
||||
const Vec d2 = direction_of(exit.azimuth());
|
||||
|
||||
geometry::Pose pose{entry.start().x(), entry.start().y(), d1.east, d1.north};
|
||||
for (const auto& s : segments) {
|
||||
pose = geometry::layout_segment(s.k0, s.k1, s.length, pose, nullptr);
|
||||
}
|
||||
|
||||
const Vec exit_normal{-d2.north, d2.east};
|
||||
const double denominator = d1.east * exit_normal.east + d1.north * exit_normal.north;
|
||||
if (std::abs(denominator) < kEpsilon) {
|
||||
throw std::invalid_argument{"Straights are parallel; nothing to fit"};
|
||||
}
|
||||
const double offset = (pose.x - exit.start().x()) * exit_normal.east +
|
||||
(pose.y - exit.start().y()) * exit_normal.north;
|
||||
const double shift = -offset / denominator;
|
||||
|
||||
return ChainClose{
|
||||
CartesianPosition{entry.start().x() + shift * d1.east,
|
||||
entry.start().y() + shift * d1.north},
|
||||
CartesianPosition{pose.x + shift * d1.east, pose.y + shift * d1.north}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FitResult FittingService::fit_between_straights(const Niweleta& niweleta,
|
||||
const FitParameters& params) {
|
||||
if (params.entry.is_null() || params.exit.is_null()) {
|
||||
throw std::invalid_argument{"Both straights must be identified"};
|
||||
}
|
||||
if (params.entry == params.exit) {
|
||||
throw std::invalid_argument{"Fitting needs two different straights"};
|
||||
}
|
||||
return fit_between_straights(find_straight(niweleta, params.entry),
|
||||
find_straight(niweleta, params.exit), params.radius,
|
||||
params.transition_length);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arc fit (tryb łuku)
|
||||
//
|
||||
// Rounds the corner between two supporting lines with a circular arc, optionally
|
||||
// eased by a symmetric pair of clothoids (spiral–arc–spiral). The straights are
|
||||
// treated as infinite lines: this routine finds the tangent points on those
|
||||
// lines and returns the two straights trimmed to them. Whether those tangent
|
||||
// points fall on the drawn finite segments is a separate, higher-level policy
|
||||
// (see domain::GapFitter) — not this service's concern.
|
||||
//
|
||||
// The geometry is produced by laying the arc chain out with the SAME sampler the
|
||||
// renderer uses (geometry::layout_segment via solve_chain) and sliding it onto
|
||||
// the exit line. That guarantees the returned tangent points coincide with the
|
||||
// drawn curve to the last bit, even for tight radii, so no lateral gap appears.
|
||||
// ---------------------------------------------------------------------------
|
||||
FitResult FittingService::fit_between_straights(const Straight& entry,
|
||||
const Straight& exit,
|
||||
Radius radius_value,
|
||||
std::optional<Length> transition_value) {
|
||||
const double radius = radius_value.metres(); // Radius guarantees radius > 0
|
||||
double transition = 0.0;
|
||||
if (transition_value) {
|
||||
transition = transition_value->metres();
|
||||
if (!(transition > 0.0)) {
|
||||
throw std::invalid_argument{"Transition length must be positive"};
|
||||
}
|
||||
}
|
||||
|
||||
// Signed turn from the entry heading to the exit heading, in (-pi, pi]. Its
|
||||
// sign is the turn direction; its magnitude is the total deflection the curve
|
||||
// must absorb.
|
||||
const Vec d1 = direction_of(entry.azimuth());
|
||||
const Vec d2 = direction_of(exit.azimuth());
|
||||
const double deflection = std::atan2(d1.east * d2.north - d1.north * d2.east,
|
||||
d1.east * d2.east + d1.north * d2.north);
|
||||
const double total_turn = std::abs(deflection);
|
||||
if (total_turn < kEpsilon || std::abs(total_turn - std::numbers::pi) < kEpsilon) {
|
||||
throw std::invalid_argument{
|
||||
"Straights are parallel, collinear or anti-parallel; no arc fits"};
|
||||
}
|
||||
const TurnDirection direction =
|
||||
deflection > 0.0 ? TurnDirection::Left : TurnDirection::Right;
|
||||
const double curvature = (deflection > 0.0 ? 1.0 : -1.0) / radius;
|
||||
|
||||
// A clothoid of length L run into a radius R bends the heading by L / (2R);
|
||||
// the two symmetric spirals therefore consume L / R of the turn, and the
|
||||
// circular arc between them takes whatever remains.
|
||||
const double arc_turn = total_turn - transition / radius;
|
||||
if (arc_turn < -kEpsilon) {
|
||||
throw std::invalid_argument{
|
||||
"Transition curves are too long for this radius and deflection"};
|
||||
}
|
||||
const double arc_length = radius * std::max(arc_turn, 0.0);
|
||||
|
||||
// Chain the elements head-to-tail — clothoid in / arc / clothoid out — using
|
||||
// one definition for both the closure solve and the returned plan elements.
|
||||
std::vector<Seg> chain;
|
||||
std::vector<PlanElement> curve;
|
||||
chain.reserve(3);
|
||||
curve.reserve(3);
|
||||
if (transition > 0.0) {
|
||||
chain.push_back({0.0, curvature, transition});
|
||||
curve.push_back(TransitionCurve{Length::from_metres(transition),
|
||||
std::nullopt, radius_value, direction});
|
||||
}
|
||||
chain.push_back({curvature, curvature, arc_length});
|
||||
curve.push_back(
|
||||
CircularArc{radius_value, direction, Length::from_metres(arc_length)});
|
||||
if (transition > 0.0) {
|
||||
chain.push_back({curvature, 0.0, transition});
|
||||
curve.push_back(TransitionCurve{Length::from_metres(transition),
|
||||
radius_value, std::nullopt, direction});
|
||||
}
|
||||
|
||||
const ChainClose close = solve_chain(entry, exit, chain);
|
||||
return FitResult{
|
||||
Straight{entry.id(), entry.start(), close.tangent_in},
|
||||
std::move(curve),
|
||||
Straight{exit.id(), close.tangent_out, exit.end()},
|
||||
close.tangent_in,
|
||||
close.tangent_out,
|
||||
direction,
|
||||
};
|
||||
}
|
||||
|
||||
FitResult FittingService::fit_uturn(const Straight& entry, const Straight& exit,
|
||||
std::optional<Length> transition) {
|
||||
const Vec d1 = direction_of(entry.azimuth());
|
||||
const Vec d2 = direction_of(exit.azimuth());
|
||||
const double dot = d1.east * d2.east + d1.north * d2.north;
|
||||
if (dot > -1.0 + 1e-6) {
|
||||
throw std::invalid_argument{"A U-turn needs two anti-parallel straights"};
|
||||
}
|
||||
if (transition && !(transition->metres() > 0.0)) {
|
||||
throw std::invalid_argument{"Transition length must be positive"};
|
||||
}
|
||||
|
||||
// Signed perpendicular offset from the entry line to the exit line.
|
||||
const Vec left{-d1.north, d1.east};
|
||||
const double offset = (exit.start().x() - entry.start().x()) * left.east +
|
||||
(exit.start().y() - entry.start().y()) * left.north;
|
||||
if (std::abs(offset) < kEpsilon) {
|
||||
throw std::invalid_argument{"U-turn straights are collinear (zero offset)"};
|
||||
}
|
||||
const double sign = (offset > 0.0) ? 1.0 : -1.0; // turn toward the exit line
|
||||
const TurnDirection direction =
|
||||
(offset > 0.0) ? TurnDirection::Left : TurnDirection::Right;
|
||||
|
||||
// Plain 180° arc spans 2R across the offset, so R = |offset| / 2. (Transition
|
||||
// curves would change the span; solved numerically — not yet supported here.)
|
||||
const double radius = std::abs(offset) / 2.0;
|
||||
const Radius radius_vo = Radius::from_metres(radius);
|
||||
const double arc_length = radius * std::numbers::pi;
|
||||
|
||||
// Start the reversal at the entry straight's end; the 180° arc lands on the
|
||||
// exit line at the diametrically opposite point.
|
||||
const CartesianPosition tangent_in = entry.end();
|
||||
const CartesianPosition tangent_out{tangent_in.x() + std::abs(offset) * sign * left.east,
|
||||
tangent_in.y() + std::abs(offset) * sign * left.north};
|
||||
|
||||
std::vector<PlanElement> curve;
|
||||
curve.push_back(CircularArc{radius_vo, direction, Length::from_metres(arc_length)});
|
||||
(void)transition; // clothoid U-turn: follow-up
|
||||
|
||||
return FitResult{
|
||||
Straight{entry.id(), entry.start(), tangent_in},
|
||||
std::move(curve),
|
||||
Straight{exit.id(), tangent_out, exit.end()},
|
||||
tangent_in,
|
||||
tangent_out,
|
||||
direction,
|
||||
};
|
||||
}
|
||||
|
||||
FitResult FittingService::fit_reverse(const Straight& entry, const Straight& exit,
|
||||
Radius radius_vo) {
|
||||
constexpr double parallel_tolerance = 1e-6;
|
||||
constexpr double angle_epsilon = 1e-5;
|
||||
const double radius = radius_vo.metres();
|
||||
const Vec d1 = direction_of(entry.azimuth());
|
||||
const Vec d2 = direction_of(exit.azimuth());
|
||||
const double cross = d1.east * d2.north - d1.north * d2.east;
|
||||
const double dot = d1.east * d2.east + d1.north * d2.north;
|
||||
|
||||
const auto direction = [](double sign) {
|
||||
return sign > 0.0 ? TurnDirection::Left : TurnDirection::Right;
|
||||
};
|
||||
const auto opposite = [](TurnDirection value) {
|
||||
return value == TurnDirection::Left ? TurnDirection::Right
|
||||
: TurnDirection::Left;
|
||||
};
|
||||
const auto result_from = [&](const ChainClose& close, double first_sign,
|
||||
double first_angle, double second_angle) {
|
||||
const TurnDirection first_direction = direction(first_sign);
|
||||
std::vector<PlanElement> curve;
|
||||
curve.push_back(CircularArc{radius_vo, first_direction,
|
||||
Length::from_metres(radius * first_angle)});
|
||||
curve.push_back(CircularArc{radius_vo, opposite(first_direction),
|
||||
Length::from_metres(radius * second_angle)});
|
||||
return FitResult{
|
||||
Straight{entry.id(), entry.start(), close.tangent_in},
|
||||
std::move(curve),
|
||||
Straight{exit.id(), close.tangent_out, exit.end()},
|
||||
close.tangent_in,
|
||||
close.tangent_out,
|
||||
first_direction,
|
||||
};
|
||||
};
|
||||
|
||||
// Same-direction parallel lines are the canonical S-curve case. Their
|
||||
// lateral displacement fixes the common arc angle for a requested radius.
|
||||
if (std::abs(cross) <= parallel_tolerance) {
|
||||
if (dot < 0.0) {
|
||||
throw std::invalid_argument{
|
||||
"Anti-parallel straights require a U-turn, not a reverse curve"};
|
||||
}
|
||||
const Vec left{-d1.north, d1.east};
|
||||
const double offset =
|
||||
(exit.start().x() - entry.start().x()) * left.east +
|
||||
(exit.start().y() - entry.start().y()) * left.north;
|
||||
if (std::abs(offset) <= kEpsilon) {
|
||||
throw std::invalid_argument{
|
||||
"Collinear straights do not need a reverse curve"};
|
||||
}
|
||||
|
||||
const double cosine = 1.0 - std::abs(offset) / (2.0 * radius);
|
||||
if (cosine < -1.0 || cosine >= 1.0) {
|
||||
throw std::invalid_argument{
|
||||
"Requested radius cannot span the parallel-line offset"};
|
||||
}
|
||||
const double angle = std::acos(std::clamp(cosine, -1.0, 1.0));
|
||||
if (angle <= angle_epsilon) {
|
||||
throw std::invalid_argument{"Reverse-curve angle is too small"};
|
||||
}
|
||||
const double first_sign = offset > 0.0 ? 1.0 : -1.0;
|
||||
const std::vector<Seg> segments{
|
||||
{first_sign / radius, first_sign / radius, radius * angle},
|
||||
{-first_sign / radius, -first_sign / radius, radius * angle},
|
||||
};
|
||||
|
||||
geometry::Pose pose{entry.start().x(), entry.start().y(), d1.east, d1.north};
|
||||
for (const auto& segment : segments) {
|
||||
pose = geometry::layout_segment(segment.k0, segment.k1, segment.length,
|
||||
pose, nullptr);
|
||||
}
|
||||
const double exit_at_zero =
|
||||
(pose.x - exit.start().x()) * d2.east +
|
||||
(pose.y - exit.start().y()) * d2.north;
|
||||
constexpr double length_epsilon = 1e-6;
|
||||
const double lower = std::max(length_epsilon, -exit_at_zero);
|
||||
const double upper =
|
||||
std::min(straight_length(entry),
|
||||
straight_length(exit) - length_epsilon - exit_at_zero);
|
||||
if (lower > upper) {
|
||||
throw std::invalid_argument{
|
||||
"Reverse curve does not fit on the finite parallel straights"};
|
||||
}
|
||||
|
||||
// Prefer tangent points near entry.end() and exit.start().
|
||||
const double unconstrained =
|
||||
0.5 * (straight_length(entry) - exit_at_zero);
|
||||
const double shift = std::clamp(unconstrained, lower, upper);
|
||||
const ChainClose close{
|
||||
CartesianPosition{entry.start().x() + shift * d1.east,
|
||||
entry.start().y() + shift * d1.north},
|
||||
CartesianPosition{pose.x + shift * d1.east,
|
||||
pose.y + shift * d1.north},
|
||||
};
|
||||
if (!valid_tangent_points(entry, exit, close)) {
|
||||
throw std::invalid_argument{
|
||||
"Reverse-curve tangent points lie outside the parallel straights"};
|
||||
}
|
||||
return result_from(close, first_sign, angle, angle);
|
||||
}
|
||||
|
||||
const double deflection = std::atan2(cross, dot);
|
||||
const double delta = std::abs(deflection);
|
||||
if (std::abs(delta - std::numbers::pi) <= parallel_tolerance) {
|
||||
throw std::invalid_argument{
|
||||
"Anti-parallel straights require a U-turn, not a reverse curve"};
|
||||
}
|
||||
const double extra_max = std::numbers::pi - delta - angle_epsilon;
|
||||
if (extra_max <= angle_epsilon) {
|
||||
throw std::invalid_argument{"Not enough angle for a reverse curve"};
|
||||
}
|
||||
|
||||
std::optional<FitResult> best;
|
||||
double best_length = 0.0;
|
||||
constexpr int samples = 720;
|
||||
for (const double first_sign : {1.0, -1.0}) {
|
||||
const double signed_delta = deflection / first_sign;
|
||||
const auto attempt = [&](double extra) -> std::optional<FitResult> {
|
||||
const double first_angle =
|
||||
signed_delta >= 0.0 ? extra + signed_delta : extra;
|
||||
const double second_angle =
|
||||
signed_delta >= 0.0 ? extra : extra - signed_delta;
|
||||
if (first_angle <= angle_epsilon || second_angle <= angle_epsilon ||
|
||||
first_angle >= std::numbers::pi ||
|
||||
second_angle >= std::numbers::pi) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::vector<Seg> segments{
|
||||
{first_sign / radius, first_sign / radius, radius * first_angle},
|
||||
{-first_sign / radius, -first_sign / radius, radius * second_angle},
|
||||
};
|
||||
try {
|
||||
const ChainClose close = solve_chain(entry, exit, segments);
|
||||
if (!valid_tangent_points(entry, exit, close)) return std::nullopt;
|
||||
return result_from(close, first_sign, first_angle, second_angle);
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
};
|
||||
|
||||
double previous = angle_epsilon;
|
||||
for (int sample = 1; sample <= samples; ++sample) {
|
||||
const double extra =
|
||||
angle_epsilon +
|
||||
(extra_max - angle_epsilon) * static_cast<double>(sample) /
|
||||
static_cast<double>(samples);
|
||||
auto candidate = attempt(extra);
|
||||
if (!candidate) {
|
||||
previous = extra;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Refine the first feasible angle boundary to avoid grid-shaped
|
||||
// jumps while dragging a straight in the editor.
|
||||
double low = previous;
|
||||
double high = extra;
|
||||
std::optional<FitResult> refined = std::move(candidate);
|
||||
for (int iteration = 0; iteration < 48; ++iteration) {
|
||||
const double middle = std::midpoint(low, high);
|
||||
if (auto middle_result = attempt(middle)) {
|
||||
high = middle;
|
||||
refined = std::move(middle_result);
|
||||
} else {
|
||||
low = middle;
|
||||
}
|
||||
}
|
||||
const double total_length = radius * (delta + 2.0 * high);
|
||||
if (!best || total_length < best_length) {
|
||||
best = std::move(refined);
|
||||
best_length = total_length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
throw std::invalid_argument{
|
||||
"No equal-radius reverse curve fits the finite straights"};
|
||||
}
|
||||
return *best;
|
||||
}
|
||||
|
||||
FitResult FittingService::fit_compound(const Niweleta& niweleta,
|
||||
const CompoundFitParameters& params) {
|
||||
if (params.entry.is_null() || params.exit.is_null()) {
|
||||
throw std::invalid_argument{"Both straights must be identified"};
|
||||
}
|
||||
if (params.entry == params.exit) {
|
||||
throw std::invalid_argument{"Fitting needs two different straights"};
|
||||
}
|
||||
return fit_compound(find_straight(niweleta, params.entry),
|
||||
find_straight(niweleta, params.exit), params);
|
||||
}
|
||||
|
||||
FitResult FittingService::fit_compound(const Straight& entry, const Straight& exit,
|
||||
const CompoundFitParameters& params) {
|
||||
if (params.arcs.empty()) {
|
||||
throw std::invalid_argument{"A compound curve needs at least one arc"};
|
||||
}
|
||||
for (std::size_t k = 1; k < params.arcs.size(); ++k) {
|
||||
if (params.arcs[k].radius == params.arcs[k - 1].radius) {
|
||||
throw std::invalid_argument{
|
||||
"Adjacent compound arcs must have different radii"};
|
||||
}
|
||||
}
|
||||
|
||||
const Vec d1 = direction_of(entry.azimuth());
|
||||
const Vec d2 = direction_of(exit.azimuth());
|
||||
const double cross = d1.east * d2.north - d1.north * d2.east;
|
||||
const double dot = d1.east * d2.east + d1.north * d2.north;
|
||||
const double deflection = std::atan2(cross, dot);
|
||||
const double delta = std::abs(deflection);
|
||||
if (delta < kEpsilon || std::abs(delta - std::numbers::pi) < kEpsilon) {
|
||||
throw std::invalid_argument{
|
||||
"Straights are parallel or collinear; nothing to fit"};
|
||||
}
|
||||
const double sign = (deflection >= 0.0) ? 1.0 : -1.0;
|
||||
const TurnDirection direction =
|
||||
(deflection > 0.0) ? TurnDirection::Left : TurnDirection::Right;
|
||||
|
||||
const std::size_t arc_count = params.arcs.size();
|
||||
const auto curvature = [&](std::size_t k) {
|
||||
return sign / params.arcs[k].radius.metres();
|
||||
};
|
||||
const auto require_positive = [](const std::optional<Length>& length,
|
||||
const char* message) -> double {
|
||||
const double value = length->metres();
|
||||
if (value <= kEpsilon) throw std::invalid_argument{message};
|
||||
return value;
|
||||
};
|
||||
|
||||
// Heading (deflection) consumed by everything except the last arc's angle:
|
||||
// the fixed-length arcs plus every transition curve. The last arc absorbs
|
||||
// whatever remains.
|
||||
double consumed = 0.0;
|
||||
std::vector<double> arc_angle(arc_count, 0.0);
|
||||
for (std::size_t k = 0; k + 1 < arc_count; ++k) {
|
||||
if (!params.arcs[k].length) {
|
||||
throw std::invalid_argument{"Every arc except the last needs a length"};
|
||||
}
|
||||
const double angle =
|
||||
require_positive(params.arcs[k].length, "Arc length must be positive") /
|
||||
params.arcs[k].radius.metres();
|
||||
arc_angle[k] = angle;
|
||||
consumed += angle;
|
||||
}
|
||||
|
||||
// Transition heading = mean curvature * length = (1/Ra + 1/Rb)/2 * L.
|
||||
const double entry_len =
|
||||
params.entry_transition
|
||||
? require_positive(params.entry_transition, "Transition length must be positive")
|
||||
: 0.0;
|
||||
if (entry_len > 0.0) {
|
||||
consumed += 0.5 * (1.0 / params.arcs.front().radius.metres()) * entry_len;
|
||||
}
|
||||
const double exit_len =
|
||||
params.exit_transition
|
||||
? require_positive(params.exit_transition, "Transition length must be positive")
|
||||
: 0.0;
|
||||
if (exit_len > 0.0) {
|
||||
consumed += 0.5 * (1.0 / params.arcs.back().radius.metres()) * exit_len;
|
||||
}
|
||||
std::vector<double> between(arc_count, 0.0);
|
||||
for (std::size_t k = 0; k + 1 < arc_count; ++k) {
|
||||
if (params.arcs[k].transition_to_next) {
|
||||
const double length = require_positive(params.arcs[k].transition_to_next,
|
||||
"Transition length must be positive");
|
||||
between[k] = length;
|
||||
consumed += 0.5 *
|
||||
(1.0 / params.arcs[k].radius.metres() +
|
||||
1.0 / params.arcs[k + 1].radius.metres()) *
|
||||
length;
|
||||
}
|
||||
}
|
||||
|
||||
arc_angle[arc_count - 1] = delta - consumed;
|
||||
if (arc_angle[arc_count - 1] <= kEpsilon) {
|
||||
throw std::invalid_argument{
|
||||
"Arc lengths and transitions exceed the total deflection"};
|
||||
}
|
||||
|
||||
// Build the chain of segments: entry KP?, arc, (KP?, arc)..., exit KP?.
|
||||
std::vector<Seg> segments;
|
||||
if (entry_len > 0.0) segments.push_back({0.0, curvature(0), entry_len});
|
||||
for (std::size_t k = 0; k < arc_count; ++k) {
|
||||
segments.push_back({curvature(k), curvature(k),
|
||||
params.arcs[k].radius.metres() * arc_angle[k]});
|
||||
if (k + 1 < arc_count && between[k] > 0.0) {
|
||||
segments.push_back({curvature(k), curvature(k + 1), between[k]});
|
||||
}
|
||||
}
|
||||
if (exit_len > 0.0) segments.push_back({curvature(arc_count - 1), 0.0, exit_len});
|
||||
|
||||
const ChainClose close = solve_chain(entry, exit, segments);
|
||||
const CartesianPosition tangent_in = close.tangent_in;
|
||||
const CartesianPosition tangent_out = close.tangent_out;
|
||||
|
||||
// Build the plan elements to splice in, mirroring the segment chain.
|
||||
std::vector<PlanElement> curve;
|
||||
if (entry_len > 0.0) {
|
||||
curve.push_back(TransitionCurve{Length::from_metres(entry_len), std::nullopt,
|
||||
params.arcs.front().radius, direction});
|
||||
}
|
||||
for (std::size_t k = 0; k < arc_count; ++k) {
|
||||
curve.push_back(
|
||||
CircularArc{params.arcs[k].radius, direction,
|
||||
Length::from_metres(params.arcs[k].radius.metres() * arc_angle[k])});
|
||||
if (k + 1 < arc_count && between[k] > 0.0) {
|
||||
curve.push_back(TransitionCurve{Length::from_metres(between[k]),
|
||||
params.arcs[k].radius,
|
||||
params.arcs[k + 1].radius, direction});
|
||||
}
|
||||
}
|
||||
if (exit_len > 0.0) {
|
||||
curve.push_back(TransitionCurve{Length::from_metres(exit_len),
|
||||
params.arcs.back().radius, std::nullopt, direction});
|
||||
}
|
||||
|
||||
return FitResult{
|
||||
Straight{entry.id(), entry.start(), tangent_in},
|
||||
std::move(curve),
|
||||
Straight{exit.id(), tangent_out, exit.end()},
|
||||
tangent_in,
|
||||
tangent_out,
|
||||
direction,
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
215
maj0sted/src/domain/gap_fitter.cpp
Normal file
215
maj0sted/src/domain/gap_fitter.cpp
Normal file
@@ -0,0 +1,215 @@
|
||||
#include "maj0sted/domain/fitting/gap_fitter.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <numeric>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/fitting/fit_parameters.hpp"
|
||||
#include "maj0sted/domain/fitting/fitting_service.hpp"
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
namespace {
|
||||
|
||||
std::optional<Length> optional_length(double metres) {
|
||||
if (metres > 0.0) return Length::from_metres(metres);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// The two straights point in (nearly) opposite directions: the connection is a
|
||||
// 180° reversal ("nawrót"), not an ordinary corner. Matches fit_uturn's own
|
||||
// anti-parallel threshold.
|
||||
bool is_reversal(const Straight& entry, const Straight& exit) {
|
||||
const double cos_between =
|
||||
std::cos(entry.azimuth().radians() - exit.azimuth().radians());
|
||||
return cos_between <= -1.0 + 1e-6;
|
||||
}
|
||||
|
||||
// A tangent point may extend the drawn straight *toward the corner* as far as the
|
||||
// geometry needs (filling the gap between the finite straight and the corner), but
|
||||
// must never cross behind the outer, anchored end — that would flip the straight
|
||||
// around and fling it off-screen. So we only bound the outer side: the point must
|
||||
// lie on the ray from @p outer through @p corner (t >= 0), with no upper limit.
|
||||
bool tangent_ok(CartesianPosition p, CartesianPosition outer,
|
||||
CartesianPosition corner) {
|
||||
const double dx = corner.x() - outer.x();
|
||||
const double dy = corner.y() - outer.y();
|
||||
const double len2 = dx * dx + dy * dy;
|
||||
if (len2 <= 0.0) return false;
|
||||
const double t = ((p.x() - outer.x()) * dx + (p.y() - outer.y()) * dy) / len2;
|
||||
return t >= -1e-3;
|
||||
}
|
||||
|
||||
double deflection(const Straight& entry, const Straight& exit) {
|
||||
const double a1 = entry.azimuth().radians();
|
||||
const double a2 = exit.azimuth().radians();
|
||||
const double d1x = std::sin(a1), d1y = std::cos(a1);
|
||||
const double d2x = std::sin(a2), d2y = std::cos(a2);
|
||||
return std::abs(std::atan2(d1x * d2y - d1y * d2x, d1x * d2x + d1y * d2y));
|
||||
}
|
||||
|
||||
// The single place the "lands on both finite straights" invariant is checked.
|
||||
std::optional<FitResult> attempt_arc(const Straight& entry, const Straight& exit,
|
||||
double radius,
|
||||
std::optional<Length> transition) {
|
||||
if (!(radius > 0.0)) return std::nullopt;
|
||||
try {
|
||||
FitResult fit = FittingService::fit_between_straights(
|
||||
entry, exit, Radius::from_metres(radius), transition);
|
||||
// The corner is at entry.end() and exit.start() (that is where the two
|
||||
// straights meet through the fit); the anchored outer ends are the
|
||||
// opposite endpoints.
|
||||
if (tangent_ok(fit.tangent_in, entry.start(), entry.end()) &&
|
||||
tangent_ok(fit.tangent_out, exit.end(), exit.start())) {
|
||||
return fit;
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Smallest radius the geometry admits: two symmetric clothoids consume the whole
|
||||
// deflection at R = transition / delta, below which the arc angle turns
|
||||
// negative; a plain arc has only a tiny numerical floor.
|
||||
double smallest_radius(const Straight& entry, const Straight& exit,
|
||||
std::optional<Length> transition) {
|
||||
constexpr double kTiny = 1e-3;
|
||||
if (!transition) return kTiny;
|
||||
const double delta = deflection(entry, exit);
|
||||
if (delta < 1e-3) return kTiny;
|
||||
return std::max(kTiny, transition->metres() / delta) * (1.0 + 1e-10);
|
||||
}
|
||||
|
||||
// Bisects a fitting radius toward its non-fitting boundary and returns the fit
|
||||
// at the fitting end. @p fitting fits, @p missing does not.
|
||||
std::optional<FitResult> refine(const Straight& entry, const Straight& exit,
|
||||
std::optional<Length> transition, double fitting,
|
||||
double missing, double& used_radius) {
|
||||
for (int step = 0; step < 60; ++step) {
|
||||
const double mid = std::midpoint(fitting, missing);
|
||||
if (attempt_arc(entry, exit, mid, transition)) {
|
||||
fitting = mid;
|
||||
} else {
|
||||
missing = mid;
|
||||
}
|
||||
}
|
||||
used_radius = fitting;
|
||||
return attempt_arc(entry, exit, fitting, transition);
|
||||
}
|
||||
|
||||
// The largest radius <= @p requested_radius whose arc fits both straights, or
|
||||
// nullopt when none does. A fit NEVER enlarges the user's radius: if the request
|
||||
// is too large to land on the (extendable) straights, we only ever reduce it to
|
||||
// the largest radius that does. A radius that is smaller than the request already
|
||||
// fits (a tighter arc just extends the straights toward the corner), so it is
|
||||
// used verbatim upstream and this reduction path is not even reached.
|
||||
std::optional<FitResult> fit_nearest_arc(const Straight& entry,
|
||||
const Straight& exit,
|
||||
double requested_radius,
|
||||
std::optional<Length> transition,
|
||||
double& used_radius) {
|
||||
const double floor = smallest_radius(entry, exit, transition);
|
||||
constexpr int kSamples = 4096;
|
||||
|
||||
// Scan DOWN from the request for the largest radius that fits, then bisect.
|
||||
if (requested_radius > floor) {
|
||||
const double span = requested_radius - floor;
|
||||
double missing = requested_radius;
|
||||
for (int i = 1; i <= kSamples; ++i) {
|
||||
const double r = requested_radius - span * i / kSamples;
|
||||
if (attempt_arc(entry, exit, r, transition)) {
|
||||
return refine(entry, exit, transition, r, missing, used_radius);
|
||||
}
|
||||
missing = r;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<GapConnection> connect_uturn(const Straight& entry,
|
||||
const Straight& exit,
|
||||
std::optional<Length> transition,
|
||||
double requested_radius) {
|
||||
try {
|
||||
FitResult fit = FittingService::fit_uturn(entry, exit, transition);
|
||||
double applied = requested_radius;
|
||||
for (const auto& element : fit.curve) {
|
||||
if (const auto* arc = std::get_if<CircularArc>(&element)) {
|
||||
applied = arc->radius().metres();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return GapConnection{std::move(fit), applied};
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<GapConnection> connect_compound(const Straight& entry,
|
||||
const Straight& exit,
|
||||
const CompoundGapParameters& c) {
|
||||
if (c.arcs.empty()) return std::nullopt;
|
||||
try {
|
||||
// Every arc except the last carries its fixed length (which fixes its
|
||||
// angle) and an optional clothoid to the next; the last arc absorbs the
|
||||
// remaining deflection. One arc => "łuk + klotoidy", two or more => a
|
||||
// "łuk koszowy".
|
||||
std::vector<CompoundArc> arcs;
|
||||
arcs.reserve(c.arcs.size());
|
||||
for (std::size_t i = 0; i < c.arcs.size(); ++i) {
|
||||
const auto& a = c.arcs[i];
|
||||
if (i + 1 == c.arcs.size()) {
|
||||
arcs.push_back(CompoundArc{Radius::from_metres(a.radius)});
|
||||
} else {
|
||||
arcs.push_back(CompoundArc{Radius::from_metres(a.radius),
|
||||
Length::from_metres(a.length),
|
||||
optional_length(a.transition_to_next)});
|
||||
}
|
||||
}
|
||||
FitResult fit = FittingService::fit_compound(
|
||||
entry, exit,
|
||||
CompoundFitParameters{
|
||||
.entry_transition = optional_length(c.entry_transition),
|
||||
.exit_transition = optional_length(c.exit_transition),
|
||||
.arcs = std::move(arcs)});
|
||||
return GapConnection{std::move(fit), c.arcs.front().radius};
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<GapConnection> GapFitter::connect(
|
||||
const Straight& entry, const Straight& exit,
|
||||
const GapConnectionRequest& request) {
|
||||
// A compound curve is taken as requested (its arcs/clothoids are explicit).
|
||||
if (request.mode == GapConnectionMode::Compound) {
|
||||
return connect_compound(entry, exit, request.compound);
|
||||
}
|
||||
|
||||
// Plain arc. A 180° reversal is geometry-driven and handled transparently.
|
||||
if (is_reversal(entry, exit)) {
|
||||
return connect_uturn(entry, exit, std::nullopt, request.radius);
|
||||
}
|
||||
|
||||
// Use the requested radius verbatim when it fits (a tighter arc fits by
|
||||
// extending the straights toward the corner). Only when the request is too
|
||||
// large to land on them do we reduce it to the largest radius that fits — the
|
||||
// radius is never enlarged.
|
||||
if (auto fit = attempt_arc(entry, exit, request.radius, std::nullopt)) {
|
||||
return GapConnection{std::move(*fit), request.radius};
|
||||
}
|
||||
double applied = request.radius;
|
||||
if (auto fit = fit_nearest_arc(entry, exit, request.radius, std::nullopt, applied)) {
|
||||
return GapConnection{std::move(*fit), applied};
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
95
maj0sted/src/domain/horizontal_alignment.cpp
Normal file
95
maj0sted/src/domain/horizontal_alignment.cpp
Normal file
@@ -0,0 +1,95 @@
|
||||
#include "maj0sted/domain/geometry/horizontal_alignment.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <variant>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
namespace {
|
||||
|
||||
// Two anchored endpoints closer than this are treated as touching.
|
||||
constexpr double kPositionToleranceM = 1e-6;
|
||||
// Azimuths differing by less than this are treated as equal.
|
||||
constexpr double kAzimuthToleranceRad = 1e-9;
|
||||
|
||||
double distance(CartesianPosition a, CartesianPosition b) noexcept {
|
||||
const double dx = b.x() - a.x();
|
||||
const double dy = b.y() - a.y();
|
||||
return std::sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
bool same_arc(const CircularArc& a, const CircularArc& b) noexcept {
|
||||
return a.direction() == b.direction() && a.radius() == b.radius();
|
||||
}
|
||||
|
||||
void validate_joint(const PlanElement& previous, const PlanElement& current,
|
||||
JointContinuity joint) {
|
||||
// Rule: two circular arcs of the same radius and direction must not meet.
|
||||
if (const auto* a = std::get_if<CircularArc>(&previous)) {
|
||||
if (const auto* b = std::get_if<CircularArc>(¤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<Straight>(&previous);
|
||||
const auto* current_straight = std::get_if<Straight>(¤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
|
||||
13
maj0sted/src/domain/map_project.cpp
Normal file
13
maj0sted/src/domain/map_project.cpp
Normal file
@@ -0,0 +1,13 @@
|
||||
#include "maj0sted/domain/map_project.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
MapProject::MapProject(Crs crs) : crs_{crs} {}
|
||||
|
||||
void MapProject::add_niweleta(Niweleta niweleta) {
|
||||
niwelety_.push_back(std::move(niweleta));
|
||||
}
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
90
maj0sted/src/domain/niweleta.cpp
Normal file
90
maj0sted/src/domain/niweleta.cpp
Normal file
@@ -0,0 +1,90 @@
|
||||
#include "maj0sted/domain/niweleta.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
|
||||
Niweleta::Niweleta(NiweletaId id, std::string name)
|
||||
: id_{id}, name_{std::move(name)} {}
|
||||
|
||||
StraightId Niweleta::next_straight_id() noexcept { return StraightId{++straight_seq_}; }
|
||||
|
||||
StraightId Niweleta::add_straight(CartesianPosition start, CartesianPosition end,
|
||||
JointContinuity joint) {
|
||||
const StraightId id = next_straight_id();
|
||||
plan_.append(Straight{id, start, end}, joint);
|
||||
return id;
|
||||
}
|
||||
|
||||
void Niweleta::add_plan_element(PlanElement element, JointContinuity joint) {
|
||||
if (auto* straight = std::get_if<Straight>(&element); straight != nullptr) {
|
||||
if (straight->id().is_null()) {
|
||||
// A straight added without an identity gets one from the niweleta.
|
||||
element = Straight{next_straight_id(), straight->start(), straight->end()};
|
||||
} else if (straight->id().value() > straight_seq_) {
|
||||
// Keep the counter ahead of any explicitly supplied id (e.g. on load).
|
||||
straight_seq_ = straight->id().value();
|
||||
}
|
||||
}
|
||||
plan_.append(std::move(element), joint);
|
||||
}
|
||||
|
||||
void Niweleta::apply_fit(const FitResult& result) {
|
||||
const StraightId entry_id = result.entry.id();
|
||||
const StraightId exit_id = result.exit.id();
|
||||
if (entry_id.is_null() || exit_id.is_null()) {
|
||||
throw std::invalid_argument{"apply_fit: trimmed straights must be identified"};
|
||||
}
|
||||
|
||||
const auto& elements = plan_.elements();
|
||||
const auto index_of = [&](StraightId id) -> std::size_t {
|
||||
for (std::size_t k = 0; k < elements.size(); ++k) {
|
||||
const auto* straight = std::get_if<Straight>(&elements[k]);
|
||||
if (straight != nullptr && !straight->id().is_null() &&
|
||||
straight->id() == id) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
throw std::invalid_argument{"apply_fit: straight not found in plan"};
|
||||
};
|
||||
const std::size_t i = index_of(entry_id);
|
||||
const std::size_t j = index_of(exit_id);
|
||||
if (j != i + 1) {
|
||||
throw std::invalid_argument{
|
||||
"apply_fit: the two straights must be adjacent (entry then exit)"};
|
||||
}
|
||||
|
||||
// Rebuild the plan, re-validating every joint via append().
|
||||
HorizontalAlignment rebuilt;
|
||||
const auto add = [&](PlanElement element, JointContinuity joint) {
|
||||
rebuilt.append(std::move(element), joint);
|
||||
};
|
||||
|
||||
for (std::size_t k = 0; k < i; ++k) {
|
||||
add(elements[k], k == 0 ? JointContinuity::Tangent : plan_.joint(k - 1));
|
||||
}
|
||||
add(result.entry, i == 0 ? JointContinuity::Tangent : plan_.joint(i - 1));
|
||||
for (const auto& curve_element : result.curve) {
|
||||
add(curve_element, JointContinuity::Tangent);
|
||||
}
|
||||
add(result.exit, JointContinuity::Tangent);
|
||||
for (std::size_t k = j + 1; k < elements.size(); ++k) {
|
||||
add(elements[k], plan_.joint(k - 1));
|
||||
}
|
||||
|
||||
plan_ = std::move(rebuilt);
|
||||
}
|
||||
|
||||
void Niweleta::add_profile_element(ProfileElement element) {
|
||||
profile_.append(std::move(element));
|
||||
}
|
||||
|
||||
bool Niweleta::lengths_consistent(double tolerance_m) const {
|
||||
return std::abs(plan_length().metres() - profile_length().metres()) <= tolerance_m;
|
||||
}
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
58
maj0sted/src/domain/parallelism_service.cpp
Normal file
58
maj0sted/src/domain/parallelism_service.cpp
Normal file
@@ -0,0 +1,58 @@
|
||||
#include "maj0sted/domain/parallelism/parallelism_service.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
|
||||
namespace maj0sted::domain {
|
||||
namespace {
|
||||
|
||||
// Unit left-normal of a straight's direction of travel, in (east=x, north=y).
|
||||
// Direction d = (sin az, cos az); rotating it +90 deg (counter-clockwise) gives
|
||||
// the left normal (-cos az, sin az).
|
||||
struct Vec {
|
||||
double east;
|
||||
double north;
|
||||
};
|
||||
|
||||
Vec left_normal(Azimuth azimuth) noexcept {
|
||||
return Vec{-std::cos(azimuth.radians()), std::sin(azimuth.radians())};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TrackParallelism ParallelismService::mark_parallel(const Straight& origin,
|
||||
const Straight& parallel,
|
||||
TrackOffset offset) {
|
||||
// The invariants (both identified, distinct) are enforced by TrackParallelism.
|
||||
return TrackParallelism{origin.id(), parallel.id(), offset};
|
||||
}
|
||||
|
||||
bool ParallelismService::are_parallel(const Straight& a, const Straight& b,
|
||||
double tolerance_rad) {
|
||||
const double difference = a.azimuth().angular_distance(b.azimuth()); // [0, pi]
|
||||
return difference <= tolerance_rad ||
|
||||
std::abs(difference - std::numbers::pi) <= tolerance_rad;
|
||||
}
|
||||
|
||||
Straight ParallelismService::derive_parallel_line(const Straight& origin,
|
||||
TrackOffset offset) {
|
||||
const Vec normal = left_normal(origin.azimuth());
|
||||
const double sign = (offset.side() == Side::Left) ? 1.0 : -1.0;
|
||||
const double shift = offset.distance().metres() * sign;
|
||||
|
||||
const CartesianPosition start{origin.start().x() + normal.east * shift,
|
||||
origin.start().y() + normal.north * shift};
|
||||
const CartesianPosition end{origin.end().x() + normal.east * shift,
|
||||
origin.end().y() + normal.north * shift};
|
||||
return Straight{start, end};
|
||||
}
|
||||
|
||||
double ParallelismService::perpendicular_distance(const Straight& origin,
|
||||
const Straight& other) {
|
||||
const Vec normal = left_normal(origin.azimuth());
|
||||
const double d_east = other.start().x() - origin.start().x();
|
||||
const double d_north = other.start().y() - origin.start().y();
|
||||
return std::abs(d_east * normal.east + d_north * normal.north);
|
||||
}
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
13
maj0sted/src/domain/vertical_alignment.cpp
Normal file
13
maj0sted/src/domain/vertical_alignment.cpp
Normal file
@@ -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
|
||||
291
maj0sted/src/io/project_serializer.cpp
Normal file
291
maj0sted/src/io/project_serializer.cpp
Normal file
@@ -0,0 +1,291 @@
|
||||
#include "maj0sted/io/project_serializer.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
namespace maj0sted::io {
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
template <class... Ts>
|
||||
struct overloaded : Ts... {
|
||||
using Ts::operator()...;
|
||||
};
|
||||
template <class... Ts>
|
||||
overloaded(Ts...) -> overloaded<Ts...>;
|
||||
|
||||
// --- writing -------------------------------------------------------------
|
||||
|
||||
// Shortest round-trippable, locale-independent representation of a double.
|
||||
std::string num(double value) {
|
||||
char buffer[64];
|
||||
const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value);
|
||||
return std::string(buffer, result.ptr);
|
||||
}
|
||||
|
||||
char dir_char(TurnDirection direction) {
|
||||
return direction == TurnDirection::Left ? 'L' : 'R';
|
||||
}
|
||||
char joint_char(JointContinuity joint) {
|
||||
return joint == JointContinuity::AzimuthBreak ? 'B' : 'T';
|
||||
}
|
||||
char curvature_char(VerticalCurvature curvature) {
|
||||
return curvature == VerticalCurvature::Crest ? 'C' : 'S';
|
||||
}
|
||||
|
||||
std::string optional_metres(const std::optional<Length>& length) {
|
||||
return length ? num(length->metres()) : std::string{"-"};
|
||||
}
|
||||
std::string optional_metres(const std::optional<Radius>& radius) {
|
||||
return radius ? num(radius->metres()) : std::string{"-"};
|
||||
}
|
||||
|
||||
// --- reading -------------------------------------------------------------
|
||||
|
||||
std::vector<std::string> tokenize(const std::string& line) {
|
||||
std::vector<std::string> tokens;
|
||||
std::size_t i = 0;
|
||||
const std::size_t n = line.size();
|
||||
while (i < n) {
|
||||
while (i < n && std::isspace(static_cast<unsigned char>(line[i]))) ++i;
|
||||
std::size_t j = i;
|
||||
while (j < n && !std::isspace(static_cast<unsigned char>(line[j]))) ++j;
|
||||
if (j > i) tokens.push_back(line.substr(i, j - i));
|
||||
i = j;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
double to_double(const std::string& text) {
|
||||
double value{};
|
||||
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
if (result.ec != std::errc{}) {
|
||||
throw std::runtime_error{"maj0sted: bad number '" + text + "'"};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::uint64_t to_u64(const std::string& text) {
|
||||
std::uint64_t value{};
|
||||
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
if (result.ec != std::errc{}) {
|
||||
throw std::runtime_error{"maj0sted: bad integer '" + text + "'"};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
TurnDirection to_direction(const std::string& text) {
|
||||
if (text == "L") return TurnDirection::Left;
|
||||
if (text == "R") return TurnDirection::Right;
|
||||
throw std::runtime_error{"maj0sted: bad direction '" + text + "'"};
|
||||
}
|
||||
JointContinuity to_joint(const std::string& text) {
|
||||
if (text == "T") return JointContinuity::Tangent;
|
||||
if (text == "B") return JointContinuity::AzimuthBreak;
|
||||
throw std::runtime_error{"maj0sted: bad joint '" + text + "'"};
|
||||
}
|
||||
VerticalCurvature to_curvature(const std::string& text) {
|
||||
if (text == "C") return VerticalCurvature::Crest;
|
||||
if (text == "S") return VerticalCurvature::Sag;
|
||||
throw std::runtime_error{"maj0sted: bad curvature '" + text + "'"};
|
||||
}
|
||||
|
||||
std::optional<Length> to_optional_length(const std::string& text) {
|
||||
if (text == "-") return std::nullopt;
|
||||
return Length::from_metres(to_double(text));
|
||||
}
|
||||
std::optional<Radius> to_optional_radius(const std::string& text) {
|
||||
if (text == "-") return std::nullopt;
|
||||
return Radius::from_metres(to_double(text));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string serialize(const MapProject& project) {
|
||||
std::string out;
|
||||
out += "maj0sted 1\n";
|
||||
out += "crs " + std::to_string(project.crs().epsg()) + "\n";
|
||||
out += "niwelety " + std::to_string(project.niwelety().size()) + "\n";
|
||||
|
||||
for (const auto& niweleta : project.niwelety()) {
|
||||
out += "niweleta " + std::to_string(niweleta.id().value()) + "\n";
|
||||
out += "name " + niweleta.name() + "\n";
|
||||
|
||||
const auto& plan = niweleta.plan();
|
||||
out += "plan " + std::to_string(plan.size()) + "\n";
|
||||
for (std::size_t k = 0; k < plan.elements().size(); ++k) {
|
||||
const char joint = (k == 0) ? 'T' : joint_char(plan.joint(k - 1));
|
||||
std::visit(
|
||||
overloaded{
|
||||
[&](const Straight& s) {
|
||||
out += "straight " + std::to_string(s.id().value()) + " " +
|
||||
num(s.start().x()) + " " + num(s.start().y()) + " " +
|
||||
num(s.end().x()) + " " + num(s.end().y()) + " " + joint +
|
||||
"\n";
|
||||
},
|
||||
[&](const CircularArc& a) {
|
||||
out += "arc " + num(a.radius().metres()) + " " +
|
||||
dir_char(a.direction()) + " " + optional_metres(a.length()) +
|
||||
" " + joint + "\n";
|
||||
},
|
||||
[&](const TransitionCurve& t) {
|
||||
out += "transition " + num(t.length().metres()) + " " +
|
||||
optional_metres(t.start_radius()) + " " +
|
||||
optional_metres(t.end_radius()) + " " +
|
||||
dir_char(t.direction()) + " " + joint + "\n";
|
||||
},
|
||||
},
|
||||
plan.elements()[k]);
|
||||
}
|
||||
|
||||
const auto& profile = niweleta.profile();
|
||||
out += "profile " + std::to_string(profile.size()) + "\n";
|
||||
for (const auto& element : profile.elements()) {
|
||||
std::visit(
|
||||
overloaded{
|
||||
[&](const Grade& g) {
|
||||
out += "grade " + num(g.length().metres()) + " " +
|
||||
num(g.slope_permille()) + "\n";
|
||||
},
|
||||
[&](const VerticalCurve& v) {
|
||||
out += "vcurve " + num(v.length().metres()) + " " +
|
||||
num(v.radius().metres()) + " " +
|
||||
curvature_char(v.curvature()) + "\n";
|
||||
},
|
||||
},
|
||||
element);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
MapProject deserialize(const std::string& text) {
|
||||
// Split into non-blank lines, stripping any trailing '\r'.
|
||||
std::vector<std::string> lines;
|
||||
{
|
||||
std::istringstream in(text);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
bool only_ws = true;
|
||||
for (const char c : line) {
|
||||
if (!std::isspace(static_cast<unsigned char>(c))) {
|
||||
only_ws = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!only_ws) lines.push_back(line);
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t cursor = 0;
|
||||
const auto next_line = [&]() -> const std::string& {
|
||||
if (cursor >= lines.size()) {
|
||||
throw std::runtime_error{"maj0sted: unexpected end of data"};
|
||||
}
|
||||
return lines[cursor++];
|
||||
};
|
||||
const auto expect = [&](const std::vector<std::string>& tokens, const char* tag,
|
||||
std::size_t min_size) {
|
||||
if (tokens.size() < min_size || tokens[0] != tag) {
|
||||
throw std::runtime_error{std::string{"maj0sted: expected '"} + tag + "'"};
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
const auto header = tokenize(next_line());
|
||||
expect(header, "maj0sted", 2);
|
||||
}
|
||||
const auto crs_line = tokenize(next_line());
|
||||
expect(crs_line, "crs", 2);
|
||||
const Crs crs{static_cast<int>(to_u64(crs_line[1]))};
|
||||
|
||||
const auto count_line = tokenize(next_line());
|
||||
expect(count_line, "niwelety", 2);
|
||||
const std::size_t niweleta_count = static_cast<std::size_t>(to_u64(count_line[1]));
|
||||
|
||||
MapProject project{crs};
|
||||
for (std::size_t index = 0; index < niweleta_count; ++index) {
|
||||
const auto header = tokenize(next_line());
|
||||
expect(header, "niweleta", 2);
|
||||
const NiweletaId id{to_u64(header[1])};
|
||||
|
||||
const std::string& name_line = next_line();
|
||||
if (name_line.rfind("name", 0) != 0) {
|
||||
throw std::runtime_error{"maj0sted: expected 'name'"};
|
||||
}
|
||||
std::string name = name_line.size() > 5 ? name_line.substr(5) : std::string{};
|
||||
Niweleta niweleta{id, std::move(name)};
|
||||
|
||||
const auto plan_line = tokenize(next_line());
|
||||
expect(plan_line, "plan", 2);
|
||||
const std::size_t plan_count = static_cast<std::size_t>(to_u64(plan_line[1]));
|
||||
for (std::size_t k = 0; k < plan_count; ++k) {
|
||||
const auto e = tokenize(next_line());
|
||||
if (e.empty()) throw std::runtime_error{"maj0sted: empty plan element"};
|
||||
if (e[0] == "straight") {
|
||||
expect(e, "straight", 7);
|
||||
niweleta.add_plan_element(
|
||||
Straight{StraightId{to_u64(e[1])},
|
||||
CartesianPosition{to_double(e[2]), to_double(e[3])},
|
||||
CartesianPosition{to_double(e[4]), to_double(e[5])}},
|
||||
to_joint(e[6]));
|
||||
} else if (e[0] == "arc") {
|
||||
expect(e, "arc", 5);
|
||||
niweleta.add_plan_element(
|
||||
CircularArc{Radius::from_metres(to_double(e[1])), to_direction(e[2]),
|
||||
to_optional_length(e[3])},
|
||||
to_joint(e[4]));
|
||||
} else if (e[0] == "transition") {
|
||||
expect(e, "transition", 6);
|
||||
niweleta.add_plan_element(
|
||||
TransitionCurve{Length::from_metres(to_double(e[1])),
|
||||
to_optional_radius(e[2]), to_optional_radius(e[3]),
|
||||
to_direction(e[4])},
|
||||
to_joint(e[5]));
|
||||
} else {
|
||||
throw std::runtime_error{"maj0sted: unknown plan element '" + e[0] + "'"};
|
||||
}
|
||||
}
|
||||
|
||||
const auto profile_line = tokenize(next_line());
|
||||
expect(profile_line, "profile", 2);
|
||||
const std::size_t profile_count =
|
||||
static_cast<std::size_t>(to_u64(profile_line[1]));
|
||||
for (std::size_t k = 0; k < profile_count; ++k) {
|
||||
const auto e = tokenize(next_line());
|
||||
if (e.empty()) throw std::runtime_error{"maj0sted: empty profile element"};
|
||||
if (e[0] == "grade") {
|
||||
expect(e, "grade", 3);
|
||||
niweleta.add_profile_element(
|
||||
Grade{Length::from_metres(to_double(e[1])), to_double(e[2])});
|
||||
} else if (e[0] == "vcurve") {
|
||||
expect(e, "vcurve", 4);
|
||||
niweleta.add_profile_element(
|
||||
VerticalCurve{Length::from_metres(to_double(e[1])),
|
||||
Radius::from_metres(to_double(e[2])),
|
||||
to_curvature(e[3])});
|
||||
} else {
|
||||
throw std::runtime_error{"maj0sted: unknown profile element '" + e[0] +
|
||||
"'"};
|
||||
}
|
||||
}
|
||||
|
||||
project.add_niweleta(std::move(niweleta));
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
} // namespace maj0sted::io
|
||||
63
maj0sted/src/io/svg_writer.cpp
Normal file
63
maj0sted/src/io/svg_writer.cpp
Normal file
@@ -0,0 +1,63 @@
|
||||
#include "maj0sted/io/svg_writer.hpp"
|
||||
|
||||
#include <charconv>
|
||||
#include <string>
|
||||
|
||||
namespace maj0sted::io {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string num(double value) {
|
||||
char buffer[64];
|
||||
const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value);
|
||||
return std::string(buffer, result.ptr);
|
||||
}
|
||||
|
||||
const char* colour(render::ElementKind kind) {
|
||||
switch (kind) {
|
||||
case render::ElementKind::Straight:
|
||||
return "#222222";
|
||||
case render::ElementKind::Arc:
|
||||
return "#1f77b4";
|
||||
case render::ElementKind::Transition:
|
||||
return "#ff7f0e";
|
||||
}
|
||||
return "#000000";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string to_svg(const render::Scene& scene, const SvgOptions& options) {
|
||||
double world_w = scene.max_x - scene.min_x;
|
||||
double world_h = scene.max_y - scene.min_y;
|
||||
if (world_w <= 0.0) world_w = 1.0;
|
||||
if (world_h <= 0.0) world_h = 1.0;
|
||||
|
||||
const double scale = (options.width_px - 2.0 * options.margin_px) / world_w;
|
||||
const double height_px = 2.0 * options.margin_px + world_h * scale;
|
||||
|
||||
// World -> SVG. North is up, so the y axis is flipped.
|
||||
const auto sx = [&](double x) { return options.margin_px + (x - scene.min_x) * scale; };
|
||||
const auto sy = [&](double y) { return options.margin_px + (scene.max_y - y) * scale; };
|
||||
|
||||
std::string out;
|
||||
out += "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"" + num(options.width_px) +
|
||||
"\" height=\"" + num(height_px) + "\" viewBox=\"0 0 " + num(options.width_px) +
|
||||
" " + num(height_px) + "\">\n";
|
||||
out += "<rect width=\"100%\" height=\"100%\" fill=\"#ffffff\"/>\n";
|
||||
|
||||
for (const auto& polyline : scene.polylines) {
|
||||
out += std::string("<polyline fill=\"none\" stroke=\"") + colour(polyline.kind) +
|
||||
"\" stroke-width=\"" + num(options.stroke_px) +
|
||||
"\" stroke-linejoin=\"round\" points=\"";
|
||||
for (const auto& p : polyline.points) {
|
||||
out += num(sx(p.x)) + "," + num(sy(p.y)) + " ";
|
||||
}
|
||||
out += "\"/>\n";
|
||||
}
|
||||
|
||||
out += "</svg>\n";
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace maj0sted::io
|
||||
116
maj0sted/src/render/plan_sampler.cpp
Normal file
116
maj0sted/src/render/plan_sampler.cpp
Normal file
@@ -0,0 +1,116 @@
|
||||
#include "maj0sted/render/plan_sampler.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <variant>
|
||||
|
||||
#include "maj0sted/domain/geometry/segment_layout.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
namespace maj0sted::render {
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
template <class... Ts>
|
||||
struct overloaded : Ts... {
|
||||
using Ts::operator()...;
|
||||
};
|
||||
template <class... Ts>
|
||||
overloaded(Ts...) -> overloaded<Ts...>;
|
||||
|
||||
struct Vec {
|
||||
double east;
|
||||
double north;
|
||||
};
|
||||
|
||||
Vec direction_of(Azimuth azimuth) noexcept {
|
||||
return Vec{std::sin(azimuth.radians()), std::cos(azimuth.radians())};
|
||||
}
|
||||
|
||||
// Samples a segment of linearly varying signed curvature (arc when k0 == k1,
|
||||
// clothoid otherwise), starting at @p pos heading @p heading. Appends points to
|
||||
// @p out and updates @p pos / @p heading to the segment's end.
|
||||
// Samples a segment via the shared layout (identical to the fitting solver, so
|
||||
// the drawn curve coincides exactly with the fitted tangent points).
|
||||
void sample_segment(double k0, double k1, double length, Vec& pos, Vec& heading,
|
||||
std::vector<Point>& out) {
|
||||
geometry::Pose start{pos.east, pos.north, heading.east, heading.north};
|
||||
std::vector<geometry::XY> points;
|
||||
const geometry::Pose end = geometry::layout_segment(k0, k1, length, start, &points);
|
||||
for (const auto& p : points) out.push_back({p.x, p.y});
|
||||
pos = Vec{end.x, end.y};
|
||||
heading = Vec{end.hx, end.hy};
|
||||
}
|
||||
|
||||
void expand(Scene& scene, const Point& p) {
|
||||
if (scene.empty) {
|
||||
scene.min_x = scene.max_x = p.x;
|
||||
scene.min_y = scene.max_y = p.y;
|
||||
scene.empty = false;
|
||||
return;
|
||||
}
|
||||
scene.min_x = std::min(scene.min_x, p.x);
|
||||
scene.min_y = std::min(scene.min_y, p.y);
|
||||
scene.max_x = std::max(scene.max_x, p.x);
|
||||
scene.max_y = std::max(scene.max_y, p.y);
|
||||
}
|
||||
|
||||
void sample_into(const HorizontalAlignment& plan, Scene& scene) {
|
||||
Vec pos{0.0, 0.0};
|
||||
Vec heading{1.0, 0.0};
|
||||
|
||||
for (const auto& element : plan.elements()) {
|
||||
Polyline polyline;
|
||||
std::visit(
|
||||
overloaded{
|
||||
[&](const Straight& s) {
|
||||
polyline.kind = ElementKind::Straight;
|
||||
polyline.points = {{s.start().x(), s.start().y()},
|
||||
{s.end().x(), s.end().y()}};
|
||||
pos = Vec{s.end().x(), s.end().y()};
|
||||
heading = direction_of(s.azimuth());
|
||||
},
|
||||
[&](const CircularArc& a) {
|
||||
polyline.kind = ElementKind::Arc;
|
||||
const double sign = a.direction() == TurnDirection::Left ? 1.0 : -1.0;
|
||||
const double k = sign / a.radius().metres();
|
||||
const double length = a.length() ? a.length()->metres() : 0.0;
|
||||
sample_segment(k, k, length, pos, heading, polyline.points);
|
||||
},
|
||||
[&](const TransitionCurve& t) {
|
||||
polyline.kind = ElementKind::Transition;
|
||||
const double sign = t.direction() == TurnDirection::Left ? 1.0 : -1.0;
|
||||
const double k0 =
|
||||
t.start_radius() ? sign / t.start_radius()->metres() : 0.0;
|
||||
const double k1 =
|
||||
t.end_radius() ? sign / t.end_radius()->metres() : 0.0;
|
||||
sample_segment(k0, k1, t.length().metres(), pos, heading,
|
||||
polyline.points);
|
||||
},
|
||||
},
|
||||
element);
|
||||
|
||||
for (const auto& p : polyline.points) expand(scene, p);
|
||||
scene.polylines.push_back(std::move(polyline));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Scene sample(const HorizontalAlignment& plan) {
|
||||
Scene scene;
|
||||
sample_into(plan, scene);
|
||||
return scene;
|
||||
}
|
||||
|
||||
Scene sample(const MapProject& project) {
|
||||
Scene scene;
|
||||
for (const auto& niweleta : project.niwelety()) {
|
||||
sample_into(niweleta.plan(), scene);
|
||||
}
|
||||
return scene;
|
||||
}
|
||||
|
||||
} // namespace maj0sted::render
|
||||
60
maj0sted/src/render/rail_renderer.cpp
Normal file
60
maj0sted/src/render/rail_renderer.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include "maj0sted/render/rail_renderer.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <utility>
|
||||
|
||||
namespace maj0sted::render {
|
||||
|
||||
namespace {
|
||||
|
||||
// Offsets a centreline polyline sideways by @p dist metres (signed) along the
|
||||
// per-vertex normal. The vertex tangent uses a central difference of the
|
||||
// neighbours (forward/backward at the ends); the left normal of (dx, dy) is
|
||||
// (-dy, dx), matching the project's (east=x, north=y) convention.
|
||||
std::vector<Point> offset(const std::vector<Point>& pts, double dist) {
|
||||
const std::size_t m = pts.size();
|
||||
std::vector<Point> out(m);
|
||||
for (std::size_t i = 0; i < m; ++i) {
|
||||
double dx = 1.0, dy = 0.0;
|
||||
if (m >= 2) {
|
||||
if (i == 0) {
|
||||
dx = pts[1].x - pts[0].x;
|
||||
dy = pts[1].y - pts[0].y;
|
||||
} else if (i + 1 == m) {
|
||||
dx = pts[i].x - pts[i - 1].x;
|
||||
dy = pts[i].y - pts[i - 1].y;
|
||||
} else {
|
||||
dx = pts[i + 1].x - pts[i - 1].x;
|
||||
dy = pts[i + 1].y - pts[i - 1].y;
|
||||
}
|
||||
}
|
||||
const double len = std::hypot(dx, dy);
|
||||
const double nx = len > 0.0 ? -dy / len : 0.0;
|
||||
const double ny = len > 0.0 ? dx / len : 0.0;
|
||||
out[i] = Point{pts[i].x + nx * dist, pts[i].y + ny * dist};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RailRenderer::RailRenderer(double gauge_metres) noexcept
|
||||
: half_gauge_{gauge_metres * 0.5} {}
|
||||
|
||||
void RailRenderer::add(const CentrelineElement& element) {
|
||||
for (const double side : {half_gauge_, -half_gauge_}) {
|
||||
RailPolyline rail;
|
||||
rail.kind = element.kind;
|
||||
rail.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
|
||||
235
maj0sted/src/web/editor.cpp
Normal file
235
maj0sted/src/web/editor.cpp
Normal file
@@ -0,0 +1,235 @@
|
||||
#include "maj0sted/web/editor.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/fitting/gap_fitter.hpp"
|
||||
#include "maj0sted/domain/geometry/segment_layout.hpp"
|
||||
#include "maj0sted/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<Straight> make_straight(const StraightSpec& s, std::uint64_t id) {
|
||||
try {
|
||||
return Straight{StraightId{id}, CartesianPosition{s.x1, s.y1},
|
||||
CartesianPosition{s.x2, s.y2}};
|
||||
} catch (...) {
|
||||
return std::nullopt; // degenerate (zero length)
|
||||
}
|
||||
}
|
||||
|
||||
// Maps an editor gap-fit DTO onto a domain connection request. Returns nullopt
|
||||
// for "no fit" (mode 0) or an unknown mode.
|
||||
std::optional<GapConnectionRequest> to_request(const GapFit& fit) {
|
||||
GapConnectionRequest request;
|
||||
switch (fit.mode) {
|
||||
case 1: // plain arc
|
||||
// Legacy explicit nawrót (mode 4): treated as a plain-arc request; a
|
||||
// genuine 180° reversal is then detected from the geometry in GapFitter.
|
||||
case 4:
|
||||
request.mode = GapConnectionMode::Arc;
|
||||
request.radius = fit.radius;
|
||||
return request;
|
||||
case 2: // legacy "łuk + klotoidy" -> compound with a single eased arc
|
||||
request.mode = GapConnectionMode::Compound;
|
||||
request.compound.entry_transition = fit.transition;
|
||||
request.compound.exit_transition = fit.transition;
|
||||
request.compound.arcs.push_back(CompoundGapArc{fit.radius, 0.0, 0.0});
|
||||
return request;
|
||||
case 3: // unified compound: any number of arcs eased by clothoids
|
||||
request.mode = GapConnectionMode::Compound;
|
||||
request.compound.entry_transition = fit.entry_t;
|
||||
request.compound.exit_transition = fit.exit_t;
|
||||
if (!fit.arcs.empty()) {
|
||||
for (const auto& a : fit.arcs) {
|
||||
request.compound.arcs.push_back(
|
||||
CompoundGapArc{a.radius, a.length, a.transition_to_next});
|
||||
}
|
||||
} else if (fit.r2 > 0.0) { // legacy two-arc basket
|
||||
request.compound.arcs.push_back(
|
||||
CompoundGapArc{fit.r1, fit.arc1_len, fit.between});
|
||||
request.compound.arcs.push_back(CompoundGapArc{fit.r2, 0.0, 0.0});
|
||||
} else { // legacy single arc
|
||||
request.compound.arcs.push_back(CompoundGapArc{fit.r1, 0.0, 0.0});
|
||||
}
|
||||
return request;
|
||||
default:
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
double radius_of(double curvature) {
|
||||
return std::abs(curvature) > 1e-12 ? 1.0 / std::abs(curvature) : 0.0;
|
||||
}
|
||||
|
||||
// Describes one domain plan element as sampling parameters for layout_segment,
|
||||
// returning its render kind. A circular arc has constant curvature; a clothoid
|
||||
// varies linearly; a straight has zero curvature.
|
||||
render::ElementKind sample_params(const PlanElement& element, double& k0, double& k1,
|
||||
double& length) {
|
||||
if (const auto* arc = std::get_if<CircularArc>(&element)) {
|
||||
const double sign = arc->direction() == TurnDirection::Left ? 1.0 : -1.0;
|
||||
k0 = k1 = sign / arc->radius().metres();
|
||||
length = arc->length() ? arc->length()->metres() : 0.0;
|
||||
return render::ElementKind::Arc;
|
||||
}
|
||||
const auto& transition = std::get<TransitionCurve>(element);
|
||||
const double sign = transition.direction() == TurnDirection::Left ? 1.0 : -1.0;
|
||||
k0 = transition.start_radius() ? sign / transition.start_radius()->metres() : 0.0;
|
||||
k1 = transition.end_radius() ? sign / transition.end_radius()->metres() : 0.0;
|
||||
length = transition.length().metres();
|
||||
return render::ElementKind::Transition;
|
||||
}
|
||||
|
||||
// Samples the solved niweleta (trimmed straights + fitted curves) into
|
||||
// rendering-neutral centreline elements, and records the trimmed straights.
|
||||
std::vector<render::CentrelineElement> sample_centrelines(
|
||||
const NiweletaSpec& spec, const std::vector<std::optional<Straight>>& straights,
|
||||
const std::vector<std::optional<FitResult>>& fits,
|
||||
std::vector<StraightSpec>& rendered_straights) {
|
||||
std::vector<render::CentrelineElement> axis;
|
||||
const std::size_t n = straights.size();
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
if (!straights[i]) continue;
|
||||
|
||||
// Trim the straight to the tangent points of its adjacent fits.
|
||||
CartesianPosition a = straights[i]->start();
|
||||
CartesianPosition b = straights[i]->end();
|
||||
if (i > 0 && fits[i - 1]) a = fits[i - 1]->tangent_out;
|
||||
if (i < fits.size() && fits[i]) b = fits[i]->tangent_in;
|
||||
rendered_straights[i] =
|
||||
StraightSpec{a.x(), a.y(), b.x(), b.y(), spec.straights[i].hidden};
|
||||
|
||||
if (!spec.straights[i].hidden) {
|
||||
render::CentrelineElement straight_element;
|
||||
straight_element.kind = render::ElementKind::Straight;
|
||||
straight_element.straight_index = static_cast<int>(i);
|
||||
straight_element.length = std::hypot(b.x() - a.x(), b.y() - a.y());
|
||||
straight_element.points = {render::Point{a.x(), a.y()},
|
||||
render::Point{b.x(), b.y()}};
|
||||
axis.push_back(std::move(straight_element));
|
||||
}
|
||||
|
||||
if (i < fits.size() && fits[i]) {
|
||||
Pose pose{fits[i]->tangent_in.x(), fits[i]->tangent_in.y(),
|
||||
std::sin(straights[i]->azimuth().radians()),
|
||||
std::cos(straights[i]->azimuth().radians())};
|
||||
int element_index = 0;
|
||||
for (const auto& element : fits[i]->curve) {
|
||||
double k0 = 0.0, k1 = 0.0, length = 0.0;
|
||||
render::CentrelineElement curve_element;
|
||||
curve_element.kind = sample_params(element, k0, k1, length);
|
||||
std::vector<XY> pts;
|
||||
pose = layout_segment(k0, k1, length, pose, &pts);
|
||||
curve_element.points.reserve(pts.size());
|
||||
for (const auto& p : pts) {
|
||||
curve_element.points.push_back(render::Point{p.x, p.y});
|
||||
}
|
||||
curve_element.gap = static_cast<int>(i);
|
||||
curve_element.element_index = element_index++;
|
||||
curve_element.length = length;
|
||||
curve_element.radius_start = radius_of(k0);
|
||||
curve_element.radius_end = radius_of(k1);
|
||||
axis.push_back(std::move(curve_element));
|
||||
}
|
||||
}
|
||||
}
|
||||
return axis;
|
||||
}
|
||||
|
||||
// Renders the centreline elements to WebPolylines through the renderer
|
||||
// interface. A different render::TrackRenderer could be dropped in here without
|
||||
// touching anything above.
|
||||
std::vector<WebPolyline> render_rails(
|
||||
const std::vector<render::CentrelineElement>& axis) {
|
||||
render::RailRenderer rail_renderer;
|
||||
render::TrackRenderer& renderer = rail_renderer;
|
||||
for (const auto& element : axis) renderer.add(element);
|
||||
|
||||
std::vector<WebPolyline> polylines;
|
||||
polylines.reserve(rail_renderer.rails().size());
|
||||
for (const auto& rail : rail_renderer.rails()) {
|
||||
WebPolyline poly;
|
||||
poly.kind = static_cast<int>(rail.kind);
|
||||
poly.straight_index = rail.straight_index;
|
||||
poly.gap = rail.gap;
|
||||
poly.element_index = rail.element_index;
|
||||
poly.length = rail.length;
|
||||
poly.radius_start = rail.radius_start;
|
||||
poly.radius_end = rail.radius_end;
|
||||
poly.points.reserve(rail.points.size());
|
||||
for (const auto& p : rail.points) poly.points.push_back(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<std::optional<Straight>> straights(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
straights[i] = make_straight(spec.straights[i], i + 1);
|
||||
}
|
||||
|
||||
NiweletaPolys result;
|
||||
result.rendered_straights = spec.straights;
|
||||
|
||||
// 1. Business: connect each requested gap. The domain decides the curve type
|
||||
// and the radius actually used; a gap that admits no fit is simply left
|
||||
// out (the library, not the host, turns a failed fit into "no fit"). Only
|
||||
// fits that succeeded are echoed in applied_fits for the host to mirror.
|
||||
std::vector<std::optional<FitResult>> fits(n == 0 ? 0 : n - 1);
|
||||
for (const auto& gap : spec.fits) {
|
||||
const int g = gap.gap;
|
||||
if (g < 0 || static_cast<std::size_t>(g) + 1 >= n) continue;
|
||||
if (!straights[g] || !straights[g + 1]) continue;
|
||||
const auto request = to_request(gap);
|
||||
if (!request) continue;
|
||||
const auto connection =
|
||||
GapFitter::connect(*straights[g], *straights[g + 1], *request);
|
||||
if (!connection) continue;
|
||||
fits[g] = connection->fit;
|
||||
GapFit applied = gap;
|
||||
applied.radius = connection->applied_radius;
|
||||
result.applied_fits.push_back(applied);
|
||||
}
|
||||
|
||||
// 2. Geometry: sample trimmed straights and fitted curves into a neutral,
|
||||
// renderer-agnostic centreline.
|
||||
const auto axis =
|
||||
sample_centrelines(spec, straights, fits, result.rendered_straights);
|
||||
|
||||
// 3. Presentation: hand the centreline to a renderer.
|
||||
result.polylines = render_rails(axis);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<NiweletaPolys> solve_project(const std::vector<NiweletaSpec>& niwelety) {
|
||||
std::vector<NiweletaPolys> result;
|
||||
result.reserve(niwelety.size());
|
||||
for (const auto& spec : niwelety) result.push_back(build(spec));
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace maj0sted::web
|
||||
55
maj0sted/src/web/ribbon.cpp
Normal file
55
maj0sted/src/web/ribbon.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#include "maj0sted/web/ribbon.hpp"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#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<WebPolyline> solve_ribbon(const RibbonRequest& request) {
|
||||
std::vector<WebPolyline> result;
|
||||
result.reserve(request.elements.size());
|
||||
|
||||
Pose pose{request.x0, request.y0, std::sin(request.az0), std::cos(request.az0)};
|
||||
|
||||
for (const auto& element : request.elements) {
|
||||
double k0 = 0.0;
|
||||
double k1 = 0.0;
|
||||
double length = 0.0;
|
||||
int kind = element.kind;
|
||||
|
||||
if (element.kind == 1) { // arc
|
||||
const double sign = element.dir >= 0 ? 1.0 : -1.0;
|
||||
const double k = element.radius > 0.0 ? sign / element.radius : 0.0;
|
||||
k0 = k;
|
||||
k1 = k;
|
||||
length = element.radius * element.angle;
|
||||
} else if (element.kind == 2) { // spiral (clothoid)
|
||||
const double sign = element.dir >= 0 ? 1.0 : -1.0;
|
||||
k0 = element.radius_start > 0.0 ? sign / element.radius_start : 0.0;
|
||||
k1 = element.radius > 0.0 ? sign / element.radius : 0.0;
|
||||
length = element.length;
|
||||
} else { // line
|
||||
kind = 0;
|
||||
length = element.length;
|
||||
}
|
||||
|
||||
std::vector<XY> points;
|
||||
pose = layout_segment(k0, k1, length, pose, &points);
|
||||
|
||||
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
|
||||
98
maj0sted/src/web/solve.cpp
Normal file
98
maj0sted/src/web/solve.cpp
Normal file
@@ -0,0 +1,98 @@
|
||||
#include "maj0sted/web/solve.hpp"
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
#include "maj0sted/render/plan_sampler.hpp"
|
||||
|
||||
namespace maj0sted::web {
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
std::optional<Length> optional_length(double metres) {
|
||||
if (metres > 0.0) return Length::from_metres(metres);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
FitResult make_fit(const Niweleta& niweleta, StraightId entry, StraightId exit,
|
||||
const SolveRequest& request) {
|
||||
if (request.mode == 2) {
|
||||
return FittingService::fit_compound(
|
||||
niweleta,
|
||||
CompoundFitParameters{
|
||||
.entry = entry,
|
||||
.exit = exit,
|
||||
.entry_transition = optional_length(request.entry_t),
|
||||
.exit_transition = optional_length(request.exit_t),
|
||||
.arcs = {CompoundArc{Radius::from_metres(request.r1),
|
||||
Length::from_metres(request.arc1_len),
|
||||
optional_length(request.between)},
|
||||
CompoundArc{Radius::from_metres(request.r2)}}});
|
||||
}
|
||||
return FittingService::fit_between_straights(
|
||||
niweleta,
|
||||
FitParameters{.entry = entry,
|
||||
.exit = exit,
|
||||
.radius = Radius::from_metres(request.radius),
|
||||
.transition_length = request.mode == 1
|
||||
? optional_length(request.transition)
|
||||
: std::nullopt});
|
||||
}
|
||||
|
||||
std::vector<WebPolyline> to_polylines(const render::Scene& scene) {
|
||||
std::vector<WebPolyline> result;
|
||||
result.reserve(scene.polylines.size());
|
||||
for (const auto& polyline : scene.polylines) {
|
||||
WebPolyline out;
|
||||
out.kind = static_cast<int>(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<WebPolyline> 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
|
||||
326
maj0sted/src/web/tile_cache.cpp
Normal file
326
maj0sted/src/web/tile_cache.cpp
Normal file
@@ -0,0 +1,326 @@
|
||||
#include "maj0sted/web/tile_cache.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <numeric>
|
||||
#include <utility>
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <cstring>
|
||||
|
||||
#include <emscripten/emscripten.h>
|
||||
#include <emscripten/fetch.h>
|
||||
#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<double>(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<double>(std::numeric_limits<int>::min())) {
|
||||
return std::numeric_limits<int>::min();
|
||||
}
|
||||
if (value >= static_cast<double>(std::numeric_limits<int>::max())) {
|
||||
return std::numeric_limits<int>::max();
|
||||
}
|
||||
return static_cast<int>(value);
|
||||
};
|
||||
return TileKey{kLevel, safe_index(x, kOriginX), safe_index(y, kOriginY)};
|
||||
}
|
||||
|
||||
TileBBox TileGrid::bbox(const TileKey& key) noexcept {
|
||||
const double min_x = kOriginX + static_cast<double>(key.x) * kCellMetres;
|
||||
const double min_y = kOriginY + static_cast<double>(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<TileKey> TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/,
|
||||
int max_tiles) {
|
||||
std::vector<TileKey> tiles;
|
||||
if (max_tiles <= 0 || !std::isfinite(view.min_x) ||
|
||||
!std::isfinite(view.min_y) || !std::isfinite(view.max_x) ||
|
||||
!std::isfinite(view.max_y)) {
|
||||
return tiles;
|
||||
}
|
||||
const double min_x = std::min(view.min_x, view.max_x);
|
||||
const double max_x = std::max(view.min_x, view.max_x);
|
||||
const double min_y = std::min(view.min_y, view.max_y);
|
||||
const double max_y = std::max(view.min_y, view.max_y);
|
||||
|
||||
const TileKey lo = tile_at(min_x, min_y);
|
||||
const TileKey hi = tile_at(max_x, max_y);
|
||||
|
||||
using Wide = std::int64_t;
|
||||
const Wide width = static_cast<Wide>(hi.x) - lo.x + 1;
|
||||
const Wide height = static_cast<Wide>(hi.y) - lo.y + 1;
|
||||
if (width <= 0 || height <= 0) return tiles;
|
||||
|
||||
Wide columns = width;
|
||||
Wide rows = height;
|
||||
const Wide cap = max_tiles;
|
||||
if (height > cap || width > cap / height) {
|
||||
// A fixed 100 m grid can contain millions of cells when zoomed far out.
|
||||
// Return a small, viewport-centred window instead of walking from a
|
||||
// remote corner or allocating/requesting the whole range.
|
||||
const long double aspect =
|
||||
static_cast<long double>(width) / static_cast<long double>(height);
|
||||
columns = std::clamp<Wide>(
|
||||
static_cast<Wide>(std::sqrt(static_cast<long double>(cap) * aspect)),
|
||||
1, std::min(width, cap));
|
||||
rows = std::clamp<Wide>(cap / columns, 1, height);
|
||||
}
|
||||
|
||||
const Wide centre_x =
|
||||
std::midpoint(static_cast<Wide>(lo.x), static_cast<Wide>(hi.x));
|
||||
const Wide centre_y =
|
||||
std::midpoint(static_cast<Wide>(lo.y), static_cast<Wide>(hi.y));
|
||||
const Wide first_col = std::clamp(
|
||||
centre_x - columns / 2, static_cast<Wide>(lo.x),
|
||||
static_cast<Wide>(hi.x) - columns + 1);
|
||||
const Wide first_row = std::clamp(
|
||||
centre_y - rows / 2, static_cast<Wide>(lo.y),
|
||||
static_cast<Wide>(hi.y) - rows + 1);
|
||||
|
||||
tiles.reserve(static_cast<std::size_t>(columns * rows));
|
||||
for (Wide row = first_row; row < first_row + rows; ++row) {
|
||||
for (Wide col = first_col; col < first_col + columns; ++col) {
|
||||
tiles.push_back(
|
||||
TileKey{kLevel, static_cast<int>(col), static_cast<int>(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<std::uint8_t> bytes,
|
||||
std::string content_type) {
|
||||
TileImage& image = cache_[key];
|
||||
image.status = TileStatus::Ready;
|
||||
image.bytes = std::move(bytes);
|
||||
image.content_type = std::move(content_type);
|
||||
}
|
||||
|
||||
TileStatus TileService::request(const TileKey& key) {
|
||||
const auto it = cache_.find(key);
|
||||
if (it != cache_.end()) {
|
||||
// A tile that gave up (10 failed attempts) is retried the next time it is
|
||||
// asked for (a pan/zoom/toggle), so the underlay self-heals once the
|
||||
// endpoint is reachable again — no page reload needed.
|
||||
if (it->second.status == TileStatus::Failed) {
|
||||
it->second.attempts = 0;
|
||||
it->second.status = TileStatus::Loading;
|
||||
begin_fetch(key);
|
||||
}
|
||||
return it->second.status; // otherwise: hit, no network
|
||||
}
|
||||
|
||||
cache_[key].status = TileStatus::Loading;
|
||||
begin_fetch(key);
|
||||
return cache_[key].status;
|
||||
}
|
||||
|
||||
void TileService::complete_fetch(const TileKey& key, bool ok,
|
||||
std::vector<std::uint8_t> bytes,
|
||||
std::string content_type) {
|
||||
if (ok) {
|
||||
store(key, std::move(bytes), std::move(content_type));
|
||||
if (on_ready_) on_ready_(key);
|
||||
return;
|
||||
}
|
||||
// Retry up to kMaxRetries times per tile; after that give up (Failed).
|
||||
TileImage& image = cache_[key];
|
||||
if (++image.attempts <= kMaxRetries) {
|
||||
image.status = TileStatus::Loading;
|
||||
schedule_retry(key);
|
||||
} else {
|
||||
// Give up quietly: do NOT notify the host, so a Failed tile does not by
|
||||
// itself trigger a redraw → re-request → refetch loop in the background.
|
||||
// It is retried only on the next user-driven request (see request()).
|
||||
image.status = TileStatus::Failed;
|
||||
}
|
||||
}
|
||||
|
||||
#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<RetryCtx*>(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<FetchCtx*>(fetch->userData);
|
||||
std::vector<std::uint8_t> bytes(
|
||||
reinterpret_cast<const std::uint8_t*>(fetch->data),
|
||||
reinterpret_cast<const std::uint8_t*>(fetch->data) +
|
||||
static_cast<std::size_t>(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<FetchCtx*>(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<std::uint8_t> 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
|
||||
49
maj0sted/tests/CMakeLists.txt
Normal file
49
maj0sted/tests/CMakeLists.txt
Normal file
@@ -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)
|
||||
51
maj0sted/tests/check.hpp
Normal file
51
maj0sted/tests/check.hpp
Normal file
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
// A tiny zero-dependency check framework so the test suite needs nothing but a
|
||||
// C++20 compiler. Each test executable returns 0 on success, 1 on any failure,
|
||||
// which is exactly what CTest interprets as pass/fail.
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace maj0test {
|
||||
inline int checks = 0;
|
||||
inline int failures = 0;
|
||||
} // namespace maj0test
|
||||
|
||||
// Variadic so that brace-init lists with commas, e.g. CHECK(Point{1, 2} == ...),
|
||||
// are passed through as a single condition instead of several macro arguments.
|
||||
#define CHECK(...) \
|
||||
do { \
|
||||
++::maj0test::checks; \
|
||||
if (!(__VA_ARGS__)) { \
|
||||
++::maj0test::failures; \
|
||||
std::printf(" [FAIL] %s:%d CHECK(%s)\n", __FILE__, __LINE__, \
|
||||
#__VA_ARGS__); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_THROWS(...) \
|
||||
do { \
|
||||
++::maj0test::checks; \
|
||||
bool threw = false; \
|
||||
try { \
|
||||
(void)(__VA_ARGS__); \
|
||||
} catch (...) { \
|
||||
threw = true; \
|
||||
} \
|
||||
if (!threw) { \
|
||||
++::maj0test::failures; \
|
||||
std::printf(" [FAIL] %s:%d CHECK_THROWS(%s)\n", __FILE__, \
|
||||
__LINE__, #__VA_ARGS__); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define RUN(fn) \
|
||||
do { \
|
||||
std::printf("[RUN ] %s\n", #fn); \
|
||||
fn(); \
|
||||
} while (0)
|
||||
|
||||
#define REPORT() \
|
||||
(std::printf("\n%d checks, %d failures\n", ::maj0test::checks, \
|
||||
::maj0test::failures), \
|
||||
::maj0test::failures == 0 ? 0 : 1)
|
||||
123
maj0sted/tests/test_document.cpp
Normal file
123
maj0sted/tests/test_document.cpp
Normal file
@@ -0,0 +1,123 @@
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#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();
|
||||
}
|
||||
346
maj0sted/tests/test_fitting.cpp
Normal file
346
maj0sted/tests/test_fitting.cpp
Normal file
@@ -0,0 +1,346 @@
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <variant>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
// A 90-degree left corner: entry heads east into (100,0), exit leaves north.
|
||||
Niweleta make_corner() {
|
||||
Niweleta n{NiweletaId{1}, "Corner"};
|
||||
n.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}});
|
||||
n.add_plan_element(Straight{StraightId{2}, CartesianPosition{100.0, 0.0},
|
||||
CartesianPosition{100.0, 100.0}},
|
||||
JointContinuity::AzimuthBreak);
|
||||
return n;
|
||||
}
|
||||
|
||||
void fit_plain_arc() {
|
||||
const Niweleta corner = make_corner();
|
||||
const FitParameters params{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.radius = Radius::from_metres(50.0),
|
||||
.transition_length = std::nullopt};
|
||||
const FitResult fit = FittingService::fit_between_straights(corner, params);
|
||||
|
||||
CHECK(fit.direction == TurnDirection::Left);
|
||||
|
||||
// Tangent length = R*tan(45) = 50 -> tangent points at (50,0) and (100,50).
|
||||
CHECK(std::abs(fit.tangent_in.x() - 50.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_out.y() - 50.0) < 1e-6);
|
||||
|
||||
// The trimmed straights keep their ids and outer endpoints.
|
||||
CHECK(fit.entry.id() == StraightId{1});
|
||||
CHECK(std::abs(fit.entry.end().x() - 50.0) < 1e-6);
|
||||
CHECK(fit.exit.id() == StraightId{2});
|
||||
CHECK(std::abs(fit.exit.start().y() - 50.0) < 1e-6);
|
||||
CHECK(std::abs(fit.exit.end().y() - 100.0) < 1e-6);
|
||||
|
||||
// A single circular arc, length R*delta = 50*pi/2.
|
||||
CHECK(fit.curve.size() == 1);
|
||||
const auto* arc = std::get_if<CircularArc>(&fit.curve.front());
|
||||
CHECK(arc != nullptr);
|
||||
CHECK(arc->direction() == TurnDirection::Left);
|
||||
CHECK(arc->length().has_value());
|
||||
CHECK(std::abs(arc->length().value().metres() - 50.0 * std::numbers::pi / 2.0) < 1e-6);
|
||||
}
|
||||
|
||||
void fit_with_symmetric_transitions() {
|
||||
const Niweleta corner = make_corner();
|
||||
const FitParameters params{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.radius = Radius::from_metres(50.0),
|
||||
.transition_length = Length::from_metres(10.0)};
|
||||
const FitResult fit = FittingService::fit_between_straights(corner, params);
|
||||
|
||||
// klotoida -> łuk -> klotoida
|
||||
CHECK(fit.curve.size() == 3);
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[0]));
|
||||
CHECK(std::holds_alternative<CircularArc>(fit.curve[1]));
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[2]));
|
||||
|
||||
// Entry transition runs from an infinite radius (straight) into R.
|
||||
const auto& entry_kp = std::get<TransitionCurve>(fit.curve[0]);
|
||||
CHECK(!entry_kp.start_radius().has_value());
|
||||
CHECK(entry_kp.end_radius().has_value());
|
||||
CHECK(entry_kp.length().metres() == 10.0);
|
||||
|
||||
// Arc central angle = delta - 2*tau, tau = Lk/(2R) = 0.1 -> arc len = 50*(pi/2 - 0.2).
|
||||
const auto& arc = std::get<CircularArc>(fit.curve[1]);
|
||||
const double expected = 50.0 * (std::numbers::pi / 2.0 - 0.2);
|
||||
CHECK(arc.length().has_value());
|
||||
CHECK(std::abs(arc.length().value().metres() - expected) < 1e-6);
|
||||
|
||||
// Entry tangent point from the exact clothoid layout (not the L^2/24R
|
||||
// approximation, which was ~1.8 mm off and caused a visible lateral gap).
|
||||
CHECK(std::abs(fit.tangent_in.x() - 44.9185491305) < 1e-6);
|
||||
}
|
||||
|
||||
void invalid_inputs_are_rejected() {
|
||||
const Niweleta corner = make_corner();
|
||||
const Radius r = Radius::from_metres(50.0);
|
||||
|
||||
// Same straight.
|
||||
CHECK_THROWS(FittingService::fit_between_straights(
|
||||
corner, FitParameters{StraightId{1}, StraightId{1}, r, std::nullopt}));
|
||||
// Unknown id.
|
||||
CHECK_THROWS(FittingService::fit_between_straights(
|
||||
corner, FitParameters{StraightId{1}, StraightId{99}, r, std::nullopt}));
|
||||
|
||||
// Collinear straights -> nothing to fit.
|
||||
Niweleta line{NiweletaId{2}};
|
||||
line.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{50.0, 0.0}});
|
||||
line.add_plan_element(Straight{StraightId{2}, CartesianPosition{50.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}});
|
||||
CHECK_THROWS(FittingService::fit_between_straights(
|
||||
line, FitParameters{StraightId{1}, StraightId{2}, r, std::nullopt}));
|
||||
|
||||
// Transitions too long for this radius/deflection.
|
||||
CHECK_THROWS(FittingService::fit_between_straights(
|
||||
corner, FitParameters{StraightId{1}, StraightId{2}, r,
|
||||
Length::from_metres(200.0)}));
|
||||
}
|
||||
|
||||
void fit_compound_curve_two_arcs() {
|
||||
const Niweleta corner = make_corner(); // 90-degree left corner, IP at (100,0)
|
||||
|
||||
// First arc delta1 = 45 deg -> length = R1 * pi/4 = 60 * pi/4; last arc computed.
|
||||
const CompoundFitParameters params{
|
||||
.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {CompoundArc{Radius::from_metres(60.0),
|
||||
Length::from_metres(60.0 * std::numbers::pi / 4.0)},
|
||||
CompoundArc{Radius::from_metres(40.0), std::nullopt}}};
|
||||
const FitResult fit = FittingService::fit_compound(corner, params);
|
||||
|
||||
CHECK(fit.direction == TurnDirection::Left);
|
||||
CHECK(fit.curve.size() == 2);
|
||||
const auto& arc1 = std::get<CircularArc>(fit.curve[0]);
|
||||
const auto& arc2 = std::get<CircularArc>(fit.curve[1]);
|
||||
CHECK(arc1.radius().metres() == 60.0);
|
||||
CHECK(arc2.radius().metres() == 40.0);
|
||||
|
||||
// Same hand-computed geometry as before (rigid-chain solver reproduces it).
|
||||
CHECK(std::abs(arc1.length().value().metres() - 60.0 * std::numbers::pi / 4.0) < 1e-6);
|
||||
CHECK(std::abs(arc2.length().value().metres() - 40.0 * std::numbers::pi / 4.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_in.x() - 45.85786) < 1e-4);
|
||||
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-4);
|
||||
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-4);
|
||||
CHECK(std::abs(fit.tangent_out.y() - 45.85786) < 1e-4);
|
||||
}
|
||||
|
||||
void fit_compound_curve_three_arcs() {
|
||||
const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100
|
||||
|
||||
// 30 + 30 + (remaining 30) degrees, radii 80 / 50 / 30.
|
||||
const CompoundFitParameters params{
|
||||
.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {CompoundArc{Radius::from_metres(80.0),
|
||||
Length::from_metres(80.0 * std::numbers::pi / 6.0)},
|
||||
CompoundArc{Radius::from_metres(50.0),
|
||||
Length::from_metres(50.0 * std::numbers::pi / 6.0)},
|
||||
CompoundArc{Radius::from_metres(30.0), std::nullopt}}};
|
||||
const FitResult fit = FittingService::fit_compound(corner, params);
|
||||
|
||||
CHECK(fit.curve.size() == 3);
|
||||
CHECK(std::get<CircularArc>(fit.curve[0]).radius().metres() == 80.0);
|
||||
CHECK(std::get<CircularArc>(fit.curve[1]).radius().metres() == 50.0);
|
||||
CHECK(std::get<CircularArc>(fit.curve[2]).radius().metres() == 30.0);
|
||||
|
||||
// Last arc takes the remaining 30 deg -> length = 30 * pi/6.
|
||||
CHECK(std::abs(std::get<CircularArc>(fit.curve[2]).length().value().metres() -
|
||||
30.0 * std::numbers::pi / 6.0) < 1e-6);
|
||||
|
||||
// The chain closes onto both fixed lines: tangent_in on y=0, tangent_out on x=100.
|
||||
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
|
||||
CHECK(fit.direction == TurnDirection::Left);
|
||||
}
|
||||
|
||||
void fit_compound_with_transitions() {
|
||||
const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100
|
||||
|
||||
const CompoundFitParameters params{
|
||||
.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.entry_transition = Length::from_metres(10.0),
|
||||
.exit_transition = Length::from_metres(10.0),
|
||||
.arcs = {CompoundArc{Radius::from_metres(60.0), Length::from_metres(20.0),
|
||||
Length::from_metres(8.0)}, // arc(60), KP 60->40 of 8 m
|
||||
CompoundArc{Radius::from_metres(40.0), std::nullopt, std::nullopt}}};
|
||||
const FitResult fit = FittingService::fit_compound(corner, params);
|
||||
|
||||
// Sequence: KP -> arc(60) -> KP(60->40) -> arc(40) -> KP.
|
||||
CHECK(fit.curve.size() == 5);
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[0]));
|
||||
CHECK(std::holds_alternative<CircularArc>(fit.curve[1]));
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[2]));
|
||||
CHECK(std::holds_alternative<CircularArc>(fit.curve[3]));
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[4]));
|
||||
|
||||
// Entry transition: straight (inf) -> R60.
|
||||
const auto& kp_in = std::get<TransitionCurve>(fit.curve[0]);
|
||||
CHECK(!kp_in.start_radius().has_value());
|
||||
CHECK(kp_in.end_radius().value().metres() == 60.0);
|
||||
CHECK(kp_in.length().metres() == 10.0);
|
||||
|
||||
// Between transition: R60 -> R40.
|
||||
const auto& kp_mid = std::get<TransitionCurve>(fit.curve[2]);
|
||||
CHECK(kp_mid.start_radius().value().metres() == 60.0);
|
||||
CHECK(kp_mid.end_radius().value().metres() == 40.0);
|
||||
CHECK(kp_mid.length().metres() == 8.0);
|
||||
|
||||
// Exit transition: R40 -> straight (inf).
|
||||
const auto& kp_out = std::get<TransitionCurve>(fit.curve[4]);
|
||||
CHECK(kp_out.start_radius().value().metres() == 40.0);
|
||||
CHECK(!kp_out.end_radius().has_value());
|
||||
|
||||
// The chain still closes onto both fixed lines.
|
||||
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
|
||||
CHECK(fit.direction == TurnDirection::Left);
|
||||
}
|
||||
|
||||
void compound_invalid_inputs_are_rejected() {
|
||||
const Niweleta corner = make_corner();
|
||||
const auto arc = [](double r, double len) {
|
||||
return CompoundArc{Radius::from_metres(r), Length::from_metres(len)};
|
||||
};
|
||||
const auto last = [](double r) { return CompoundArc{Radius::from_metres(r)}; };
|
||||
|
||||
// Equal neighbouring radii.
|
||||
CHECK_THROWS(FittingService::fit_compound(
|
||||
corner, CompoundFitParameters{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {arc(50.0, 10.0), last(50.0)}}));
|
||||
|
||||
// Split lengths exceed the total deflection.
|
||||
CHECK_THROWS(FittingService::fit_compound(
|
||||
corner, CompoundFitParameters{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {arc(60.0, 1000.0), last(40.0)}}));
|
||||
|
||||
// A non-last arc without a length.
|
||||
CHECK_THROWS(FittingService::fit_compound(
|
||||
corner, CompoundFitParameters{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {last(60.0), last(40.0)}}));
|
||||
}
|
||||
|
||||
void fit_equal_radius_reverse_curve_between_parallel_straights() {
|
||||
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{200.0, 0.0}};
|
||||
const Straight exit{StraightId{2}, CartesianPosition{0.0, 20.0},
|
||||
CartesianPosition{200.0, 20.0}};
|
||||
|
||||
const FitResult fit =
|
||||
FittingService::fit_reverse(entry, exit, Radius::from_metres(50.0));
|
||||
CHECK(fit.curve.size() == 2);
|
||||
const auto& first = std::get<CircularArc>(fit.curve[0]);
|
||||
const auto& second = std::get<CircularArc>(fit.curve[1]);
|
||||
CHECK(first.radius() == second.radius());
|
||||
CHECK(first.radius() == Radius::from_metres(50.0));
|
||||
CHECK(first.direction() != second.direction());
|
||||
CHECK(first.length().has_value());
|
||||
CHECK(second.length().has_value());
|
||||
CHECK(std::abs(first.length()->metres() - second.length()->metres()) < 1e-9);
|
||||
CHECK(std::abs(fit.tangent_in.y()) < 1e-9);
|
||||
CHECK(std::abs(fit.tangent_out.y() - 20.0) < 1e-6);
|
||||
CHECK(fit.tangent_in.x() > 0.0 && fit.tangent_in.x() <= 200.0);
|
||||
CHECK(fit.tangent_out.x() >= 0.0 && fit.tangent_out.x() < 200.0);
|
||||
}
|
||||
|
||||
void reverse_curve_rejects_collinear_and_antiparallel_straights() {
|
||||
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
const Straight collinear{StraightId{2}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
const Straight antiparallel{StraightId{3}, CartesianPosition{100.0, 20.0},
|
||||
CartesianPosition{0.0, 20.0}};
|
||||
const Radius radius = Radius::from_metres(30.0);
|
||||
|
||||
CHECK_THROWS(FittingService::fit_reverse(entry, collinear, radius));
|
||||
CHECK_THROWS(FittingService::fit_reverse(entry, antiparallel, radius));
|
||||
}
|
||||
|
||||
void fit_reverse_curve_between_nonparallel_straights() {
|
||||
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
// This exit line is constructed around the end pose of a R=30 m chain:
|
||||
// left 60 degrees followed by right 30 degrees.
|
||||
const double heading = std::numbers::pi / 6.0;
|
||||
const double tangent_x = 86.9615242271;
|
||||
const double tangent_y = 25.9807621135;
|
||||
const double dx = std::cos(heading);
|
||||
const double dy = std::sin(heading);
|
||||
const Straight exit{
|
||||
StraightId{2},
|
||||
CartesianPosition{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy},
|
||||
CartesianPosition{tangent_x + 70.0 * dx, tangent_y + 70.0 * dy}};
|
||||
|
||||
const FitResult fit =
|
||||
FittingService::fit_reverse(entry, exit, Radius::from_metres(30.0));
|
||||
CHECK(fit.curve.size() == 2);
|
||||
const auto& first = std::get<CircularArc>(fit.curve[0]);
|
||||
const auto& second = std::get<CircularArc>(fit.curve[1]);
|
||||
CHECK(first.direction() != second.direction());
|
||||
CHECK(first.radius() == second.radius());
|
||||
CHECK(std::abs(fit.tangent_in.y()) < 1e-6);
|
||||
const double exit_cross =
|
||||
(fit.tangent_out.x() - exit.start().x()) * dy -
|
||||
(fit.tangent_out.y() - exit.start().y()) * dx;
|
||||
CHECK(std::abs(exit_cross) < 1e-5);
|
||||
}
|
||||
|
||||
void apply_fit_replaces_straights_in_plan() {
|
||||
Niweleta corner = make_corner(); // straight(1) --break--> straight(2)
|
||||
const FitParameters params{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.radius = Radius::from_metres(50.0),
|
||||
.transition_length = std::nullopt};
|
||||
const FitResult fit = FittingService::fit_between_straights(corner, params);
|
||||
|
||||
corner.apply_fit(fit);
|
||||
|
||||
// Plan is now: straight(1) -> arc -> straight(2), tangent joints, no break.
|
||||
CHECK(corner.plan().size() == 3);
|
||||
CHECK(std::holds_alternative<Straight>(corner.plan().elements()[0]));
|
||||
CHECK(std::holds_alternative<CircularArc>(corner.plan().elements()[1]));
|
||||
CHECK(std::holds_alternative<Straight>(corner.plan().elements()[2]));
|
||||
CHECK(corner.plan().joint(0) == JointContinuity::Tangent);
|
||||
CHECK(corner.plan().joint(1) == JointContinuity::Tangent);
|
||||
|
||||
// Straights keep their ids and are trimmed to the tangent points.
|
||||
const auto& first = std::get<Straight>(corner.plan().elements()[0]);
|
||||
const auto& last = std::get<Straight>(corner.plan().elements()[2]);
|
||||
CHECK(first.id() == StraightId{1});
|
||||
CHECK(last.id() == StraightId{2});
|
||||
CHECK(std::abs(first.end().x() - 50.0) < 1e-6);
|
||||
CHECK(std::abs(last.start().y() - 50.0) < 1e-6);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(fit_plain_arc);
|
||||
RUN(fit_with_symmetric_transitions);
|
||||
RUN(invalid_inputs_are_rejected);
|
||||
RUN(fit_compound_curve_two_arcs);
|
||||
RUN(fit_compound_curve_three_arcs);
|
||||
RUN(fit_compound_with_transitions);
|
||||
RUN(compound_invalid_inputs_are_rejected);
|
||||
RUN(fit_equal_radius_reverse_curve_between_parallel_straights);
|
||||
RUN(reverse_curve_rejects_collinear_and_antiparallel_straights);
|
||||
RUN(fit_reverse_curve_between_nonparallel_straights);
|
||||
RUN(apply_fit_replaces_straights_in_plan);
|
||||
return REPORT();
|
||||
}
|
||||
25
maj0sted/tests/test_map_project.cpp
Normal file
25
maj0sted/tests/test_map_project.cpp
Normal file
@@ -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();
|
||||
}
|
||||
110
maj0sted/tests/test_niweleta.cpp
Normal file
110
maj0sted/tests/test_niweleta.cpp
Normal file
@@ -0,0 +1,110 @@
|
||||
#include <variant>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
void plan_and_profile_element_lengths() {
|
||||
const PlanElement straight =
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
|
||||
const PlanElement arc = CircularArc{Radius::from_metres(300.0),
|
||||
TurnDirection::Left,
|
||||
Length::from_metres(50.0)};
|
||||
const PlanElement transition = TransitionCurve{
|
||||
Length::from_metres(30.0), std::nullopt, Radius::from_metres(300.0),
|
||||
TurnDirection::Left};
|
||||
CHECK(length_of(straight).value().metres() == 100.0);
|
||||
CHECK(length_of(arc).value().metres() == 50.0);
|
||||
CHECK(length_of(transition).value().metres() == 30.0);
|
||||
|
||||
const ProfileElement grade = Grade{Length::from_metres(120.0), 5.0};
|
||||
const ProfileElement vcurve = VerticalCurve{
|
||||
Length::from_metres(60.0), Radius::from_metres(4000.0),
|
||||
VerticalCurvature::Crest};
|
||||
CHECK(length_of(grade).metres() == 120.0);
|
||||
CHECK(length_of(vcurve).metres() == 60.0);
|
||||
}
|
||||
|
||||
void value_objects_guard_invariants() {
|
||||
CHECK_THROWS(Length::from_metres(-1.0));
|
||||
CHECK_THROWS(Radius::from_metres(0.0));
|
||||
CHECK_THROWS(Radius::from_metres(-5.0));
|
||||
}
|
||||
|
||||
void niweleta_aggregates_plan_and_profile() {
|
||||
Niweleta n{NiweletaId{1}, "Odcinek 1"};
|
||||
CHECK(n.name() == "Odcinek 1");
|
||||
CHECK(n.plan().empty());
|
||||
CHECK(n.profile().empty());
|
||||
|
||||
// Plan: prosta -> klotoida -> łuk -> klotoida -> prosta (razem 180 m)
|
||||
n.add_plan_element(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
n.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt,
|
||||
Radius::from_metres(300.0),
|
||||
TurnDirection::Right});
|
||||
n.add_plan_element(CircularArc{Radius::from_metres(300.0),
|
||||
TurnDirection::Right,
|
||||
Length::from_metres(20.0)});
|
||||
n.add_plan_element(TransitionCurve{Length::from_metres(30.0),
|
||||
Radius::from_metres(300.0), std::nullopt,
|
||||
TurnDirection::Right});
|
||||
n.add_plan_element(
|
||||
Straight{CartesianPosition{100.0, 0.0}, CartesianPosition{150.0, 0.0}});
|
||||
|
||||
// Profil: pochylenie -> łuk pionowy -> pochylenie (razem 180 m)
|
||||
n.add_profile_element(Grade{Length::from_metres(80.0), 3.0});
|
||||
n.add_profile_element(VerticalCurve{Length::from_metres(40.0),
|
||||
Radius::from_metres(5000.0),
|
||||
VerticalCurvature::Sag});
|
||||
n.add_profile_element(Grade{Length::from_metres(60.0), -2.0});
|
||||
|
||||
CHECK(n.plan().size() == 5);
|
||||
CHECK(n.profile().size() == 3);
|
||||
CHECK(n.plan_length().metres() == 180.0);
|
||||
CHECK(n.profile_length().metres() == 180.0);
|
||||
CHECK(n.lengths_consistent());
|
||||
}
|
||||
|
||||
void inconsistent_lengths_are_detected() {
|
||||
Niweleta n{NiweletaId{2}};
|
||||
n.add_plan_element(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}});
|
||||
n.add_profile_element(Grade{Length::from_metres(90.0), 0.0});
|
||||
CHECK(!n.lengths_consistent());
|
||||
CHECK(n.plan_length().metres() == 100.0);
|
||||
CHECK(n.profile_length().metres() == 90.0);
|
||||
}
|
||||
|
||||
void niweleta_assigns_straight_ids() {
|
||||
Niweleta n{NiweletaId{3}};
|
||||
// add_straight mints and returns fresh ids.
|
||||
const StraightId a =
|
||||
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0});
|
||||
const StraightId b =
|
||||
n.add_straight(CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0});
|
||||
CHECK(!a.is_null());
|
||||
CHECK(!b.is_null());
|
||||
CHECK(a != b);
|
||||
|
||||
// An anonymous straight added via add_plan_element is identified too.
|
||||
Niweleta m{NiweletaId{4}};
|
||||
m.add_plan_element(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
const auto& element = m.plan().elements().front();
|
||||
CHECK(!std::get<Straight>(element).id().is_null());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(plan_and_profile_element_lengths);
|
||||
RUN(value_objects_guard_invariants);
|
||||
RUN(niweleta_aggregates_plan_and_profile);
|
||||
RUN(inconsistent_lengths_are_detected);
|
||||
RUN(niweleta_assigns_straight_ids);
|
||||
return REPORT();
|
||||
}
|
||||
85
maj0sted/tests/test_parallelism.cpp
Normal file
85
maj0sted/tests/test_parallelism.cpp
Normal file
@@ -0,0 +1,85 @@
|
||||
#include <cmath>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
void mark_parallel_references_two_straights() {
|
||||
// Two identified straights (typically from different niwelety).
|
||||
const Straight origin{StraightId{10}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
const Straight parallel{StraightId{20}, CartesianPosition{0.0, 5.0},
|
||||
CartesianPosition{100.0, 5.0}};
|
||||
|
||||
const TrackOffset offset{Length::from_metres(5.0), Side::Left};
|
||||
const auto relation = ParallelismService::mark_parallel(origin, parallel, offset);
|
||||
|
||||
CHECK(relation.origin() == StraightId{10});
|
||||
CHECK(relation.parallel() == StraightId{20});
|
||||
CHECK(relation.offset().distance().metres() == 5.0);
|
||||
CHECK(relation.offset().side() == Side::Left);
|
||||
}
|
||||
|
||||
void marking_requires_identified_distinct_straights() {
|
||||
const Straight anonymous{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
|
||||
const Straight a{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
const Straight b{StraightId{2}, CartesianPosition{0.0, 5.0},
|
||||
CartesianPosition{100.0, 5.0}};
|
||||
const TrackOffset offset{Length::from_metres(5.0), Side::Left};
|
||||
|
||||
// A straight without an id cannot be referenced.
|
||||
CHECK_THROWS(ParallelismService::mark_parallel(anonymous, b, offset));
|
||||
CHECK_THROWS(ParallelismService::mark_parallel(a, anonymous, offset));
|
||||
|
||||
// A straight cannot be parallel to itself (same id).
|
||||
const Straight a_again{StraightId{1}, CartesianPosition{0.0, 10.0},
|
||||
CartesianPosition{100.0, 10.0}};
|
||||
CHECK_THROWS(ParallelismService::mark_parallel(a, a_again, offset));
|
||||
}
|
||||
|
||||
void offset_must_be_positive() {
|
||||
CHECK_THROWS(TrackOffset(Length::from_metres(0.0), Side::Left));
|
||||
}
|
||||
|
||||
void parallelism_geometry() {
|
||||
const Straight origin{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
|
||||
const Straight same_direction{CartesianPosition{0.0, 5.0},
|
||||
CartesianPosition{100.0, 5.0}};
|
||||
const Straight opposite{CartesianPosition{100.0, 5.0}, CartesianPosition{0.0, 5.0}};
|
||||
const Straight perpendicular{CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{0.0, 50.0}};
|
||||
|
||||
CHECK(ParallelismService::are_parallel(origin, same_direction));
|
||||
CHECK(ParallelismService::are_parallel(origin, opposite)); // anti-parallel lines
|
||||
CHECK(!ParallelismService::are_parallel(origin, perpendicular));
|
||||
|
||||
// Derive a line 5 m to the LEFT of an east-going origin -> to the north (y = +5).
|
||||
const Straight left = ParallelismService::derive_parallel_line(
|
||||
origin, TrackOffset{Length::from_metres(5.0), Side::Left});
|
||||
CHECK(std::abs(left.start().x() - 0.0) < 1e-9);
|
||||
CHECK(std::abs(left.start().y() - 5.0) < 1e-9);
|
||||
CHECK(std::abs(left.end().y() - 5.0) < 1e-9);
|
||||
CHECK(ParallelismService::are_parallel(origin, left));
|
||||
|
||||
// To the RIGHT -> to the south (y = -5).
|
||||
const Straight right = ParallelismService::derive_parallel_line(
|
||||
origin, TrackOffset{Length::from_metres(5.0), Side::Right});
|
||||
CHECK(std::abs(right.start().y() + 5.0) < 1e-9);
|
||||
|
||||
CHECK(std::abs(ParallelismService::perpendicular_distance(origin, same_direction) -
|
||||
5.0) < 1e-9);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(mark_parallel_references_two_straights);
|
||||
RUN(marking_requires_identified_distinct_straights);
|
||||
RUN(offset_must_be_positive);
|
||||
RUN(parallelism_geometry);
|
||||
return REPORT();
|
||||
}
|
||||
132
maj0sted/tests/test_plan_alignment.cpp
Normal file
132
maj0sted/tests/test_plan_alignment.cpp
Normal file
@@ -0,0 +1,132 @@
|
||||
#include <cmath>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
void straight_computes_azimuth_and_length() {
|
||||
// Going east (+x): azimuth = 90 deg (clockwise from north).
|
||||
const Straight east{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
|
||||
CHECK(std::abs(east.length().metres() - 100.0) < 1e-9);
|
||||
CHECK(std::abs(east.azimuth().degrees() - 90.0) < 1e-9);
|
||||
|
||||
// Going north (+y): azimuth = 0 deg.
|
||||
const Straight north{CartesianPosition{0.0, 0.0}, CartesianPosition{0.0, 50.0}};
|
||||
CHECK(std::abs(north.azimuth().degrees() - 0.0) < 1e-9);
|
||||
CHECK(std::abs(north.length().metres() - 50.0) < 1e-9);
|
||||
|
||||
// A zero-length straight has no azimuth and is rejected.
|
||||
CHECK_THROWS(Straight(CartesianPosition{1.0, 1.0}, CartesianPosition{1.0, 1.0}));
|
||||
}
|
||||
|
||||
void arc_carries_radius_direction_and_optional_length() {
|
||||
const CircularArc without_length{Radius::from_metres(300.0), TurnDirection::Left};
|
||||
CHECK(!without_length.length().has_value());
|
||||
|
||||
const CircularArc with_length{Radius::from_metres(300.0), TurnDirection::Left,
|
||||
Length::from_metres(45.0)};
|
||||
CHECK(with_length.length().has_value());
|
||||
CHECK(with_length.length().value().metres() == 45.0);
|
||||
}
|
||||
|
||||
void adjacent_same_arcs_are_rejected() {
|
||||
HorizontalAlignment same;
|
||||
same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
|
||||
// same radius + same direction -> forbidden
|
||||
CHECK_THROWS(
|
||||
same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}));
|
||||
|
||||
HorizontalAlignment opposite;
|
||||
opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
|
||||
// same radius, different direction -> allowed (S-curve)
|
||||
opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Right});
|
||||
CHECK(opposite.size() == 2);
|
||||
|
||||
HorizontalAlignment compound;
|
||||
compound.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
|
||||
// same direction, different radius -> allowed (compound curve)
|
||||
compound.append(CircularArc{Radius::from_metres(500.0), TurnDirection::Left});
|
||||
CHECK(compound.size() == 2);
|
||||
}
|
||||
|
||||
void straight_to_straight_tangent_requires_equal_azimuth() {
|
||||
// Collinear straights, same azimuth, touching -> tangent OK.
|
||||
HorizontalAlignment collinear;
|
||||
collinear.append(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
collinear.append(
|
||||
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}});
|
||||
CHECK(collinear.size() == 2);
|
||||
CHECK(collinear.joint(0) == JointContinuity::Tangent);
|
||||
|
||||
// Touching but different azimuth with a Tangent joint -> must be a break.
|
||||
HorizontalAlignment unmarked;
|
||||
unmarked.append(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
CHECK_THROWS(unmarked.append(
|
||||
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}}));
|
||||
|
||||
// Non-touching tangent straights -> invalid.
|
||||
HorizontalAlignment gap;
|
||||
gap.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
CHECK_THROWS(gap.append(
|
||||
Straight{CartesianPosition{60.0, 0.0}, CartesianPosition{100.0, 0.0}}));
|
||||
}
|
||||
|
||||
void azimuth_break_between_straights_must_be_marked() {
|
||||
// Corner marked as AzimuthBreak -> OK.
|
||||
HorizontalAlignment kink;
|
||||
kink.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
kink.append(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}},
|
||||
JointContinuity::AzimuthBreak);
|
||||
CHECK(kink.size() == 2);
|
||||
CHECK(kink.joint(0) == JointContinuity::AzimuthBreak);
|
||||
|
||||
// AzimuthBreak with equal azimuth is pointless -> invalid.
|
||||
HorizontalAlignment straight_break;
|
||||
straight_break.append(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
CHECK_THROWS(straight_break.append(
|
||||
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}},
|
||||
JointContinuity::AzimuthBreak));
|
||||
|
||||
// AzimuthBreak is only allowed between two straights.
|
||||
HorizontalAlignment arc_break;
|
||||
arc_break.append(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
CHECK_THROWS(arc_break.append(
|
||||
CircularArc{Radius::from_metres(300.0), TurnDirection::Left},
|
||||
JointContinuity::AzimuthBreak));
|
||||
}
|
||||
|
||||
void straight_curve_joints_are_accepted_pending_fit() {
|
||||
// prosta -> klotoida -> łuk -> klotoida -> prosta
|
||||
HorizontalAlignment plan;
|
||||
plan.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
plan.append(TransitionCurve{Length::from_metres(30.0), std::nullopt,
|
||||
Radius::from_metres(300.0), TurnDirection::Left});
|
||||
plan.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left,
|
||||
Length::from_metres(40.0)});
|
||||
plan.append(TransitionCurve{Length::from_metres(30.0),
|
||||
Radius::from_metres(300.0), std::nullopt,
|
||||
TurnDirection::Left});
|
||||
plan.append(Straight{CartesianPosition{200.0, 0.0}, CartesianPosition{260.0, 0.0}});
|
||||
CHECK(plan.size() == 5);
|
||||
// Known lengths: 50 + 30 + 40 + 30 + 60.
|
||||
CHECK(plan.total_length().metres() == 210.0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(straight_computes_azimuth_and_length);
|
||||
RUN(arc_carries_radius_direction_and_optional_length);
|
||||
RUN(adjacent_same_arcs_are_rejected);
|
||||
RUN(straight_to_straight_tangent_requires_equal_azimuth);
|
||||
RUN(azimuth_break_between_straights_must_be_marked);
|
||||
RUN(straight_curve_joints_are_accepted_pending_fit);
|
||||
return REPORT();
|
||||
}
|
||||
78
maj0sted/tests/test_render.cpp
Normal file
78
maj0sted/tests/test_render.cpp
Normal file
@@ -0,0 +1,78 @@
|
||||
#include <cmath>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/io/svg_writer.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
#include "maj0sted/render/plan_sampler.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
void samples_one_polyline_per_element() {
|
||||
Niweleta n{NiweletaId{1}};
|
||||
const StraightId a =
|
||||
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
|
||||
const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0},
|
||||
CartesianPosition{100.0, 100.0},
|
||||
JointContinuity::AzimuthBreak);
|
||||
const FitResult fit = FittingService::fit_between_straights(
|
||||
n, FitParameters{.entry = a,
|
||||
.exit = b,
|
||||
.radius = Radius::from_metres(40.0),
|
||||
.transition_length = Length::from_metres(10.0)});
|
||||
n.apply_fit(fit); // straight -> KP -> arc -> KP -> straight (5 elements)
|
||||
|
||||
const auto scene = maj0sted::render::sample(n.plan());
|
||||
CHECK(scene.polylines.size() == n.plan().size());
|
||||
CHECK(scene.polylines.size() == 5);
|
||||
CHECK(!scene.empty);
|
||||
|
||||
// First polyline is the (straight) entry, drawn from its anchored endpoints.
|
||||
const auto& first = scene.polylines.front();
|
||||
CHECK(first.kind == maj0sted::render::ElementKind::Straight);
|
||||
CHECK(first.points.size() == 2);
|
||||
CHECK(std::abs(first.points.front().x - 0.0) < 1e-9);
|
||||
|
||||
// Curves are sampled into many points.
|
||||
CHECK(scene.polylines[2].kind == maj0sted::render::ElementKind::Arc);
|
||||
CHECK(scene.polylines[2].points.size() > 2);
|
||||
}
|
||||
|
||||
void curve_geometry_is_continuous() {
|
||||
// The sampled points should join up (each element starts where the previous
|
||||
// ended) for a properly fitted, tangent-continuous plan.
|
||||
Niweleta n{NiweletaId{2}};
|
||||
const StraightId a =
|
||||
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
|
||||
const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0},
|
||||
CartesianPosition{100.0, 100.0},
|
||||
JointContinuity::AzimuthBreak);
|
||||
n.apply_fit(FittingService::fit_between_straights(
|
||||
n, FitParameters{.entry = a, .exit = b, .radius = Radius::from_metres(40.0)}));
|
||||
|
||||
const auto scene = maj0sted::render::sample(n.plan());
|
||||
for (std::size_t i = 1; i < scene.polylines.size(); ++i) {
|
||||
const auto& prev_end = scene.polylines[i - 1].points.back();
|
||||
const auto& next_start = scene.polylines[i].points.front();
|
||||
CHECK(std::abs(prev_end.x - next_start.x) < 1e-6);
|
||||
CHECK(std::abs(prev_end.y - next_start.y) < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
void svg_is_produced() {
|
||||
Niweleta n{NiweletaId{3}};
|
||||
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0});
|
||||
const auto svg = maj0sted::io::to_svg(maj0sted::render::sample(n.plan()));
|
||||
CHECK(svg.rfind("<svg", 0) == 0);
|
||||
CHECK(svg.find("<polyline") != std::string::npos);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(samples_one_polyline_per_element);
|
||||
RUN(curve_geometry_is_continuous);
|
||||
RUN(svg_is_produced);
|
||||
return REPORT();
|
||||
}
|
||||
92
maj0sted/tests/test_serialization.cpp
Normal file
92
maj0sted/tests/test_serialization.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
#include <variant>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
MapProject make_project() {
|
||||
MapProject project{Crs{2180}};
|
||||
|
||||
Niweleta a{NiweletaId{5}, "Odcinek A"};
|
||||
const StraightId s1 =
|
||||
a.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
|
||||
(void)s1;
|
||||
a.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt,
|
||||
Radius::from_metres(300.0), TurnDirection::Right});
|
||||
a.add_plan_element(CircularArc{Radius::from_metres(300.0), TurnDirection::Right,
|
||||
Length::from_metres(40.0)});
|
||||
a.add_profile_element(Grade{Length::from_metres(80.0), 3.0});
|
||||
a.add_profile_element(VerticalCurve{Length::from_metres(40.0),
|
||||
Radius::from_metres(5000.0),
|
||||
VerticalCurvature::Sag});
|
||||
project.add_niweleta(std::move(a));
|
||||
|
||||
// A second niweleta with an azimuth break, to exercise joints.
|
||||
Niweleta b{NiweletaId{9}, "Odcinek B z załamaniem"};
|
||||
b.add_plan_element(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
b.add_plan_element(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}},
|
||||
JointContinuity::AzimuthBreak);
|
||||
project.add_niweleta(std::move(b));
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
void round_trip_is_stable() {
|
||||
const MapProject project = make_project();
|
||||
const std::string text = maj0sted::io::serialize(project);
|
||||
const MapProject loaded = maj0sted::io::deserialize(text);
|
||||
|
||||
// Re-serializing the loaded project yields byte-identical text.
|
||||
CHECK(maj0sted::io::serialize(loaded) == text);
|
||||
|
||||
CHECK(loaded.crs().epsg() == 2180);
|
||||
CHECK(loaded.niwelety().size() == 2);
|
||||
}
|
||||
|
||||
void niweleta_and_geometry_survive() {
|
||||
const MapProject loaded = maj0sted::io::deserialize(maj0sted::io::serialize(make_project()));
|
||||
|
||||
const Niweleta& a = loaded.niwelety()[0];
|
||||
CHECK(a.id() == NiweletaId{5});
|
||||
CHECK(a.name() == "Odcinek A");
|
||||
CHECK(a.plan().size() == 3);
|
||||
CHECK(a.profile().size() == 2);
|
||||
|
||||
// Straight kept its id and geometry.
|
||||
const auto& straight = std::get<Straight>(a.plan().elements()[0]);
|
||||
CHECK(straight.id() == StraightId{1});
|
||||
CHECK(straight.length().metres() == 100.0);
|
||||
|
||||
// Arc kept radius, direction and length.
|
||||
const auto& arc = std::get<CircularArc>(a.plan().elements()[2]);
|
||||
CHECK(arc.radius().metres() == 300.0);
|
||||
CHECK(arc.direction() == TurnDirection::Right);
|
||||
CHECK(arc.length().value().metres() == 40.0);
|
||||
|
||||
// Profile survived.
|
||||
const auto& grade = std::get<Grade>(a.profile().elements()[0]);
|
||||
CHECK(grade.slope_permille() == 3.0);
|
||||
|
||||
// Name with spaces and the azimuth-break joint survived on the 2nd niweleta.
|
||||
const Niweleta& b = loaded.niwelety()[1];
|
||||
CHECK(b.name() == "Odcinek B z załamaniem");
|
||||
CHECK(b.plan().joint(0) == JointContinuity::AzimuthBreak);
|
||||
}
|
||||
|
||||
void malformed_input_throws() {
|
||||
CHECK_THROWS(maj0sted::io::deserialize("not a maj0sted file"));
|
||||
CHECK_THROWS(maj0sted::io::deserialize("maj0sted 1\ncrs 2180\nniwelety 3\n"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(round_trip_is_stable);
|
||||
RUN(niweleta_and_geometry_survive);
|
||||
RUN(malformed_input_throws);
|
||||
return REPORT();
|
||||
}
|
||||
208
maj0sted/tests/test_tile_cache.cpp
Normal file
208
maj0sted/tests/test_tile_cache.cpp
Normal file
@@ -0,0 +1,208 @@
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<int>(capped.size()) <= 32);
|
||||
const TileKey centre = TileGrid::tile_at(5e8, 5e8);
|
||||
bool has_centre = false;
|
||||
for (const auto& tile : capped) {
|
||||
if (tile == centre) has_centre = true;
|
||||
}
|
||||
CHECK(has_centre); // cap is centred, not taken from a remote corner
|
||||
|
||||
const double infinity = std::numeric_limits<double>::infinity();
|
||||
CHECK(TileGrid::tiles_for_view({0.0, 0.0, infinity, 1.0}).empty());
|
||||
}
|
||||
|
||||
// --- WMS URL -------------------------------------------------------------
|
||||
|
||||
void getmap_url_is_wms_1_1_1_epsg2180() {
|
||||
WmsConfig cfg = WmsConfig::geoportal_ortho();
|
||||
const TileBBox box{100.0, 200.0, 300.0, 400.0};
|
||||
const std::string url = wms_getmap_url(cfg, box);
|
||||
CHECK(url.find("SERVICE=WMS") != std::string::npos);
|
||||
CHECK(url.find("VERSION=1.1.1") != std::string::npos);
|
||||
CHECK(url.find("REQUEST=GetMap") != std::string::npos);
|
||||
CHECK(url.find("SRS=EPSG:2180") != std::string::npos);
|
||||
// BBOX in minx,miny,maxx,maxy order.
|
||||
CHECK(url.find("BBOX=100,200,300,400") != std::string::npos);
|
||||
CHECK(url.find("WIDTH=1024") != std::string::npos);
|
||||
}
|
||||
|
||||
// --- cache + injected fetch ---------------------------------------------
|
||||
|
||||
void miss_fetches_then_hit_is_served_from_cache() {
|
||||
TileService service;
|
||||
int fetch_calls = 0;
|
||||
// Synchronous injected fetcher that returns three canned bytes.
|
||||
service.set_fetcher([&](const std::string& url,
|
||||
TileService::DoneFn done) {
|
||||
++fetch_calls;
|
||||
CHECK(url.find("BBOX=") != std::string::npos);
|
||||
done(true, std::vector<std::uint8_t>{1, 2, 3}, "image/jpeg");
|
||||
});
|
||||
|
||||
int ready_events = 0;
|
||||
TileKey last_ready{};
|
||||
service.set_on_ready([&](TileKey k) {
|
||||
++ready_events;
|
||||
last_ready = k;
|
||||
});
|
||||
|
||||
const TileKey key{12, 3, 4};
|
||||
CHECK(!service.has(key));
|
||||
|
||||
// First request: a miss, so exactly one fetch and the tile becomes Ready.
|
||||
const TileStatus first = service.request(key);
|
||||
CHECK(first == TileStatus::Ready); // fetcher was synchronous
|
||||
CHECK(fetch_calls == 1);
|
||||
CHECK(ready_events == 1);
|
||||
CHECK(last_ready == key);
|
||||
CHECK(service.has(key));
|
||||
|
||||
const TileImage* image = service.peek(key);
|
||||
CHECK(image != nullptr);
|
||||
CHECK(image->bytes.size() == 3);
|
||||
CHECK(image->content_type == "image/jpeg");
|
||||
|
||||
// Second request for the same tile: a hit, no extra fetch.
|
||||
const TileStatus second = service.request(key);
|
||||
CHECK(second == TileStatus::Ready);
|
||||
CHECK(fetch_calls == 1); // unchanged: served from cache
|
||||
CHECK(service.size() == 1);
|
||||
}
|
||||
|
||||
void failed_fetch_is_retried_until_success() {
|
||||
// A failure is not terminal: the service retries until a tile arrives. With
|
||||
// a synchronous injected fetcher the retries unwind inline, so a fetcher that
|
||||
// fails twice then succeeds must end Ready after exactly three attempts.
|
||||
TileService service;
|
||||
int calls = 0;
|
||||
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
|
||||
++calls;
|
||||
if (calls < 3) {
|
||||
done(false, {}, {}); // fail
|
||||
} else {
|
||||
done(true, std::vector<std::uint8_t>{7}, "image/jpeg"); // then succeed
|
||||
}
|
||||
});
|
||||
|
||||
const TileKey key{10, 1, 1};
|
||||
const TileStatus status = service.request(key);
|
||||
CHECK(status == TileStatus::Ready);
|
||||
CHECK(calls == 3);
|
||||
CHECK(service.has(key));
|
||||
|
||||
const TileImage* image = service.peek(key);
|
||||
CHECK(image != nullptr);
|
||||
CHECK(image->bytes.size() == 1);
|
||||
}
|
||||
|
||||
void retry_gives_up_after_ten_attempts() {
|
||||
// A tile that never loads is retried at most 10 times, then marked Failed.
|
||||
// With a synchronous always-failing fetcher that is 1 initial + 10 retries.
|
||||
TileService service;
|
||||
int calls = 0;
|
||||
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
|
||||
++calls;
|
||||
done(false, {}, {});
|
||||
});
|
||||
|
||||
const TileKey key{2, 2, 2};
|
||||
CHECK(service.request(key) == TileStatus::Failed);
|
||||
CHECK(calls == 11); // initial + kMaxRetries (10)
|
||||
CHECK(!service.has(key));
|
||||
CHECK(service.peek(key) == nullptr);
|
||||
}
|
||||
|
||||
void store_seeds_cache_directly() {
|
||||
TileService service;
|
||||
const TileKey key{9, 2, 2};
|
||||
service.store(key, std::vector<std::uint8_t>{9, 9}, "image/png");
|
||||
CHECK(service.has(key));
|
||||
// A request now is a pure hit even with no fetcher installed.
|
||||
CHECK(service.request(key) == TileStatus::Ready);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(grid_is_fixed_100m_cells);
|
||||
RUN(tile_at_and_bbox_are_consistent);
|
||||
RUN(zoom_for_resolution_is_single_level);
|
||||
RUN(tiles_for_view_covers_and_caps);
|
||||
RUN(getmap_url_is_wms_1_1_1_epsg2180);
|
||||
RUN(miss_fetches_then_hit_is_served_from_cache);
|
||||
RUN(failed_fetch_is_retried_until_success);
|
||||
RUN(retry_gives_up_after_ten_attempts);
|
||||
RUN(store_seeds_cache_directly);
|
||||
return REPORT();
|
||||
}
|
||||
229
maj0sted/tests/test_web.cpp
Normal file
229
maj0sted/tests/test_web.cpp
Normal file
@@ -0,0 +1,229 @@
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/web/editor.hpp"
|
||||
#include "maj0sted/web/solve.hpp"
|
||||
|
||||
using namespace maj0sted::web;
|
||||
|
||||
namespace {
|
||||
|
||||
bool has_kind(const std::vector<WebPolyline>& 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();
|
||||
}
|
||||
Reference in New Issue
Block a user