17
0
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:
maj00r
2026-07-31 18:27:02 +02:00
committed by maj00r
parent 9a0b63a97c
commit a083269867
72 changed files with 6344 additions and 0 deletions

View File

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

@@ -0,0 +1,12 @@
---
Language: Cpp
BasedOnStyle: Google
Standard: c++20
ColumnLimit: 90
IndentWidth: 4
AccessModifierOffset: -4
PointerAlignment: Left
AllowShortFunctionsOnASingleLine: Inline
AllowShortIfStatementsOnASingleLine: false
SortIncludes: true
IncludeBlocks: Preserve

24
maj0sted/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Build output
/build/
/out/
/cmake-build-*/
# Compiled objects / archives
*.o
*.obj
*.a
*.lib
*.so
*.dylib
# Editors / IDEs
.vscode/
.idea/
*.swp
.cache/
compile_commands.json
# WASM / web build artifacts (produced by web/build.sh)
web/maj0sted.js
web/maj0sted.wasm
web/serve

113
maj0sted/CMakeLists.txt Normal file
View 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
View File

@@ -0,0 +1,184 @@
# HANDOFF — maj0sted
Stan prac i punkt zaczepienia dla kolejnej sesji (np. po przejściu na `claude` CLI).
Kod jest samoopisowy; ten plik to skrót "gdzie jesteśmy i co dalej".
## Czym jest projekt
Statyczna biblioteka **C++20** (DDD) do edytora torowiska. Czysty model domenowy,
bez zależności zewnętrznych. Namespace: `maj0sted::domain`.
**Uwaga (sandbox):** na tej maszynie wyłączone są nieuprzywilejowane user
namespaces, więc domyślny sandbox komend pada w preflight (komenda „nie zwraca
statusu”). Uruchamiaj polecenia powłoki **z wyłączonym sandboxem** (w Cursorze:
`required_permissions: ["all"]`). Wtedy `git`, `g++`, `node` działają normalnie.
**Uwaga:** `cmake` NIE jest zainstalowany. Weryfikacja odbywa się przez `g++ 14.2`.
Toolchain WASM: `emsdk` jest w `~/emsdk` (`source ~/emsdk/emsdk_env.sh` dodaje `em++`).
## Jak weryfikować (bez cmake)
```bash
cd /home/maj00r/Projects/maj0sted
FLAGS="-std=c++20 -Iinclude -Wall -Wextra -Wpedantic -Wconversion -Wshadow"
SRC="src/domain/*.cpp src/io/*.cpp src/render/*.cpp src/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
View File

@@ -0,0 +1,86 @@
# maj0sted
Statyczna biblioteka **C++20** do obsługi **edytora torowiska**, zbudowana
zgodnie z **Domain-Driven Design (DDD)**.
Biblioteka dostarcza czysty model domenowy — bez zależności od UI, bazy danych
czy frameworka. Warstwy aplikacji i infrastruktury podpina się do zdefiniowanych
tu typów.
## Model domenowy
```
maj0sted::domain
├── value_objects/ Value Objects (niezmienne, porównywane przez wartość)
│ ├── Crs układ odniesienia (kod EPSG), domyślnie EPSG:2180
│ └── CartesianPosition pozycja kartezjańska (x, y w jednostkach CRS)
└── MapProject projekt mapy — definiuje CRS dla wszystkich pozycji
```
Projekt mapy (`MapProject`) definiuje kartezjański układ odniesienia, w którym
wyrażane są wszystkie pozycje. Domyślnie jest to **EPSG:2180** (PUWG 1992 /
Poland CS92), którego jednostką jest metr.
## Szybki start
```cpp
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
MapProject project; // domyślnie EPSG:2180
int epsg = project.crs().epsg(); // 2180
CartesianPosition p{500000.0, 300000.0}; // x, y w metrach
```
Pełniejszy przykład: [`examples/basic_editor.cpp`](examples/basic_editor.cpp).
## Budowanie
Wymagania: kompilator C++20 (GCC 11+/Clang 14+/MSVC 19.3+) oraz CMake ≥ 3.20.
```bash
cmake -S . -B build # dodaj -G Ninja, jeśli masz ninja
cmake --build build
ctest --test-dir build --output-on-failure
./build/examples/maj0sted_example
```
### Opcje CMake
| Opcja | Domyślnie | Znaczenie |
|------------------------------|-----------|-------------------------------|
| `MAJ0STED_BUILD_TESTS` | `ON` | buduj testy jednostkowe |
| `MAJ0STED_BUILD_EXAMPLES` | `ON` | buduj przykłady |
| `MAJ0STED_WARNINGS_AS_ERRORS`| `OFF` | traktuj ostrzeżenia jak błędy |
### Użycie w innym projekcie
Po `cmake --install build` (albo przez `add_subdirectory`):
```cmake
find_package(maj0sted REQUIRED)
target_link_libraries(twoj_target PRIVATE maj0sted::maj0sted)
```
## Struktura projektu
```
maj0sted/
├── CMakeLists.txt
├── include/maj0sted/ nagłówki publiczne (API)
├── src/domain/ implementacje (.cpp)
├── tests/ testy jednostkowe (CTest, bez zależności)
├── examples/ przykłady użycia
└── cmake/ pliki pakietu find_package
```
## Testy
Testy używają własnego, malutkiego frameworka (`tests/check.hpp`) — nie wymagają
żadnych zewnętrznych bibliotek. Każdy plik testowy to osobny plik wykonywalny
zwracający kod wyjścia dla CTest.
## Licencja
Do uzupełnienia (np. MIT) — dodaj plik `LICENSE`.

View File

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

View File

@@ -0,0 +1,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)

View 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;
}

View 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;
}

View 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

View 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

View 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

View 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

View 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

View File

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

View 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

View File

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

View File

@@ -0,0 +1,90 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <vector>
namespace maj0sted::domain::geometry {
struct XY {
double x;
double y;
};
/// Position plus unit heading, in (east=x, north=y).
struct Pose {
double x;
double y;
double hx;
double hy;
};
/// Lays out one segment of linearly varying signed curvature, starting at
/// @p start: a circular arc when @p k0 == @p k1, a clothoid otherwise, a
/// straight when both are zero. When @p out is non-null the sampled points
/// (including the start point) are appended to it. Returns the segment's end
/// pose.
///
/// Both the fitting solver and the renderer call this so their geometry is
/// bit-for-bit identical — the fitted tangent points then coincide exactly with
/// the drawn curve, with no lateral gap even for tight arcs.
inline Pose layout_segment(double k0, double k1, double length, Pose start,
std::vector<XY>* out) {
const double left_x = -start.hy;
const double left_y = start.hx;
const auto emit = [&](double local_x, double local_y) {
if (out != nullptr) {
out->push_back({start.x + local_x * start.hx + local_y * left_x,
start.y + local_x * start.hy + local_y * left_y});
}
};
emit(0.0, 0.0);
double end_x = 0.0;
double end_y = 0.0;
if (length > 0.0) {
const double turned = std::abs(0.5 * (k0 + k1) * length);
const int steps = std::clamp(
static_cast<int>(std::ceil(std::max(
length / 0.5, turned * 180.0 / std::numbers::pi / 2.0))),
8, 512);
const double ds = length / steps;
if (std::abs(k1 - k0) < 1e-12) {
const double k = k0;
if (std::abs(k) < 1e-12) {
end_x = length; // straight
emit(end_x, 0.0);
} else {
for (int i = 1; i <= steps; ++i) { // circular arc, exact
const double s = ds * i;
end_x = std::sin(k * s) / k;
end_y = (1.0 - std::cos(k * s)) / k;
emit(end_x, end_y);
}
}
} else {
const double slope = (k1 - k0) / (2.0 * length); // clothoid
for (int i = 1; i <= steps; ++i) {
const double s_mid = ds * (i - 0.5);
const double theta = k0 * s_mid + slope * s_mid * s_mid;
end_x += std::cos(theta) * ds;
end_y += std::sin(theta) * ds;
emit(end_x, end_y);
}
}
}
const double dtheta = 0.5 * (k0 + k1) * length;
const double cos_t = std::cos(dtheta);
const double sin_t = std::sin(dtheta);
return Pose{start.x + end_x * start.hx + end_y * left_x,
start.y + end_x * start.hy + end_y * left_y,
start.hx * cos_t - start.hy * sin_t,
start.hx * sin_t + start.hy * cos_t};
}
} // namespace maj0sted::domain::geometry

View File

@@ -0,0 +1,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

View 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

View 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

View File

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

View File

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

View 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

View File

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

View 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

View 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

View File

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

View 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

View File

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

View File

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

View 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

View 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

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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,7 @@
#pragma once
// Version of the maj0sted library. Keep in sync with project() in CMakeLists.txt.
#define MAJ0STED_VERSION_MAJOR 0
#define MAJ0STED_VERSION_MINOR 1
#define MAJ0STED_VERSION_PATCH 0
#define MAJ0STED_VERSION_STRING "0.1.0"

View File

@@ -0,0 +1,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

View 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

View 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

View 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

View 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

View 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

View 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

View 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>(&current)) {
if (same_arc(*a, *b)) {
throw std::logic_error{
"Adjacent circular arcs with the same radius and direction "
"are not allowed"};
}
}
}
const auto* previous_straight = std::get_if<Straight>(&previous);
const auto* current_straight = std::get_if<Straight>(&current);
if (joint == JointContinuity::AzimuthBreak) {
if (previous_straight == nullptr || current_straight == nullptr) {
throw std::logic_error{
"An azimuth break is only allowed between two straights"};
}
if (distance(previous_straight->end(), current_straight->start()) >
kPositionToleranceM) {
throw std::logic_error{"Straights at an azimuth break must touch"};
}
if (previous_straight->azimuth().angular_distance(
current_straight->azimuth()) <= kAzimuthToleranceRad) {
throw std::logic_error{
"An azimuth break requires the two straights to have different "
"azimuths"};
}
return;
}
// Tangent joint. Only straight-to-straight can be verified now; joints that
// involve a floating curve are checked after fitting.
if (previous_straight != nullptr && current_straight != nullptr) {
if (distance(previous_straight->end(), current_straight->start()) >
kPositionToleranceM) {
throw std::logic_error{"Tangent straights must touch"};
}
if (previous_straight->azimuth().angular_distance(
current_straight->azimuth()) > kAzimuthToleranceRad) {
throw std::logic_error{
"Adjacent straights with different azimuths must be marked as an "
"azimuth break"};
}
}
}
} // namespace
void HorizontalAlignment::append(PlanElement element, JointContinuity joint) {
if (!elements_.empty()) {
validate_joint(elements_.back(), element, joint);
joints_.push_back(joint);
}
elements_.push_back(std::move(element));
}
Length HorizontalAlignment::total_length() const {
Length total = Length::zero();
for (const auto& element : elements_) {
if (const auto element_length = length_of(element)) {
total = total + *element_length;
}
}
return total;
}
} // namespace maj0sted::domain

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,60 @@
#include "maj0sted/render/rail_renderer.hpp"
#include <cmath>
#include <cstddef>
#include <utility>
namespace maj0sted::render {
namespace {
// Offsets a centreline polyline sideways by @p dist metres (signed) along the
// per-vertex normal. The vertex tangent uses a central difference of the
// neighbours (forward/backward at the ends); the left normal of (dx, dy) is
// (-dy, dx), matching the project's (east=x, north=y) convention.
std::vector<Point> offset(const std::vector<Point>& pts, double dist) {
const std::size_t m = pts.size();
std::vector<Point> out(m);
for (std::size_t i = 0; i < m; ++i) {
double dx = 1.0, dy = 0.0;
if (m >= 2) {
if (i == 0) {
dx = pts[1].x - pts[0].x;
dy = pts[1].y - pts[0].y;
} else if (i + 1 == m) {
dx = pts[i].x - pts[i - 1].x;
dy = pts[i].y - pts[i - 1].y;
} else {
dx = pts[i + 1].x - pts[i - 1].x;
dy = pts[i + 1].y - pts[i - 1].y;
}
}
const double len = std::hypot(dx, dy);
const double nx = len > 0.0 ? -dy / len : 0.0;
const double ny = len > 0.0 ? dx / len : 0.0;
out[i] = Point{pts[i].x + nx * dist, pts[i].y + ny * dist};
}
return out;
}
} // namespace
RailRenderer::RailRenderer(double gauge_metres) noexcept
: half_gauge_{gauge_metres * 0.5} {}
void RailRenderer::add(const CentrelineElement& element) {
for (const double side : {half_gauge_, -half_gauge_}) {
RailPolyline rail;
rail.kind = element.kind;
rail.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
View 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

View 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

View 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

View 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

View 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
View File

@@ -0,0 +1,51 @@
#pragma once
// A tiny zero-dependency check framework so the test suite needs nothing but a
// C++20 compiler. Each test executable returns 0 on success, 1 on any failure,
// which is exactly what CTest interprets as pass/fail.
#include <cstdio>
namespace maj0test {
inline int checks = 0;
inline int failures = 0;
} // namespace maj0test
// Variadic so that brace-init lists with commas, e.g. CHECK(Point{1, 2} == ...),
// are passed through as a single condition instead of several macro arguments.
#define CHECK(...) \
do { \
++::maj0test::checks; \
if (!(__VA_ARGS__)) { \
++::maj0test::failures; \
std::printf(" [FAIL] %s:%d CHECK(%s)\n", __FILE__, __LINE__, \
#__VA_ARGS__); \
} \
} while (0)
#define CHECK_THROWS(...) \
do { \
++::maj0test::checks; \
bool threw = false; \
try { \
(void)(__VA_ARGS__); \
} catch (...) { \
threw = true; \
} \
if (!threw) { \
++::maj0test::failures; \
std::printf(" [FAIL] %s:%d CHECK_THROWS(%s)\n", __FILE__, \
__LINE__, #__VA_ARGS__); \
} \
} while (0)
#define RUN(fn) \
do { \
std::printf("[RUN ] %s\n", #fn); \
fn(); \
} while (0)
#define REPORT() \
(std::printf("\n%d checks, %d failures\n", ::maj0test::checks, \
::maj0test::failures), \
::maj0test::failures == 0 ? 0 : 1)

View File

@@ -0,0 +1,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();
}

View 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();
}

View 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();
}

View 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();
}

View 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();
}

View 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();
}

View 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();
}

View 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();
}

View 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
View 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();
}