From a083269867154cb059431daccaabeae60fd1d5ee Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 18:27:02 +0200 Subject: [PATCH 01/17] 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. --- CMakeLists.txt | 5 + maj0sted/.clang-format | 12 + maj0sted/.gitignore | 24 + maj0sted/CMakeLists.txt | 113 ++++ maj0sted/HANDOFF.md | 184 ++++++ maj0sted/README.md | 86 +++ maj0sted/cmake/maj0stedConfig.cmake.in | 5 + maj0sted/examples/CMakeLists.txt | 5 + maj0sted/examples/basic_editor.cpp | 20 + maj0sted/examples/plan_to_svg.cpp | 58 ++ .../include/maj0sted/app/editor_document.hpp | 57 ++ .../domain/fitting/fit_parameters.hpp | 55 ++ .../maj0sted/domain/fitting/fit_result.hpp | 25 + .../domain/fitting/fitting_service.hpp | 69 +++ .../maj0sted/domain/fitting/gap_fitter.hpp | 74 +++ .../domain/geometry/horizontal_alignment.hpp | 60 ++ .../maj0sted/domain/geometry/plan_element.hpp | 127 ++++ .../domain/geometry/profile_element.hpp | 53 ++ .../domain/geometry/segment_layout.hpp | 90 +++ .../domain/geometry/vertical_alignment.hpp | 31 + .../include/maj0sted/domain/map_project.hpp | 32 + maj0sted/include/maj0sted/domain/niweleta.hpp | 70 +++ .../parallelism/parallelism_service.hpp | 35 ++ .../domain/parallelism/track_parallelism.hpp | 38 ++ .../maj0sted/domain/value_objects/azimuth.hpp | 67 ++ .../value_objects/cartesian_position.hpp | 27 + .../maj0sted/domain/value_objects/crs.hpp | 29 + .../maj0sted/domain/value_objects/length.hpp | 37 ++ .../domain/value_objects/niweleta_id.hpp | 36 ++ .../maj0sted/domain/value_objects/radius.hpp | 30 + .../domain/value_objects/straight_id.hpp | 36 ++ .../domain/value_objects/track_offset.hpp | 31 + .../maj0sted/io/project_serializer.hpp | 19 + maj0sted/include/maj0sted/io/svg_writer.hpp | 20 + maj0sted/include/maj0sted/maj0sted.hpp | 39 ++ .../include/maj0sted/render/plan_sampler.hpp | 18 + .../include/maj0sted/render/rail_renderer.hpp | 42 ++ maj0sted/include/maj0sted/render/scene.hpp | 33 + .../maj0sted/render/track_renderer.hpp | 38 ++ maj0sted/include/maj0sted/version.hpp | 7 + maj0sted/include/maj0sted/web/editor.hpp | 72 +++ maj0sted/include/maj0sted/web/ribbon.hpp | 32 + maj0sted/include/maj0sted/web/solve.hpp | 56 ++ maj0sted/include/maj0sted/web/tile_cache.hpp | 186 ++++++ maj0sted/src/app/editor_document.cpp | 323 ++++++++++ maj0sted/src/domain/fitting_service.cpp | 582 ++++++++++++++++++ maj0sted/src/domain/gap_fitter.cpp | 215 +++++++ maj0sted/src/domain/horizontal_alignment.cpp | 95 +++ maj0sted/src/domain/map_project.cpp | 13 + maj0sted/src/domain/niweleta.cpp | 90 +++ maj0sted/src/domain/parallelism_service.cpp | 58 ++ maj0sted/src/domain/vertical_alignment.cpp | 13 + maj0sted/src/io/project_serializer.cpp | 291 +++++++++ maj0sted/src/io/svg_writer.cpp | 63 ++ maj0sted/src/render/plan_sampler.cpp | 116 ++++ maj0sted/src/render/rail_renderer.cpp | 60 ++ maj0sted/src/web/editor.cpp | 235 +++++++ maj0sted/src/web/ribbon.cpp | 55 ++ maj0sted/src/web/solve.cpp | 98 +++ maj0sted/src/web/tile_cache.cpp | 326 ++++++++++ maj0sted/tests/CMakeLists.txt | 49 ++ maj0sted/tests/check.hpp | 51 ++ maj0sted/tests/test_document.cpp | 123 ++++ maj0sted/tests/test_fitting.cpp | 346 +++++++++++ maj0sted/tests/test_map_project.cpp | 25 + maj0sted/tests/test_niweleta.cpp | 110 ++++ maj0sted/tests/test_parallelism.cpp | 85 +++ maj0sted/tests/test_plan_alignment.cpp | 132 ++++ maj0sted/tests/test_render.cpp | 78 +++ maj0sted/tests/test_serialization.cpp | 92 +++ maj0sted/tests/test_tile_cache.cpp | 208 +++++++ maj0sted/tests/test_web.cpp | 229 +++++++ 72 files changed, 6344 insertions(+) create mode 100644 maj0sted/.clang-format create mode 100644 maj0sted/.gitignore create mode 100644 maj0sted/CMakeLists.txt create mode 100644 maj0sted/HANDOFF.md create mode 100644 maj0sted/README.md create mode 100644 maj0sted/cmake/maj0stedConfig.cmake.in create mode 100644 maj0sted/examples/CMakeLists.txt create mode 100644 maj0sted/examples/basic_editor.cpp create mode 100644 maj0sted/examples/plan_to_svg.cpp create mode 100644 maj0sted/include/maj0sted/app/editor_document.hpp create mode 100644 maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp create mode 100644 maj0sted/include/maj0sted/domain/fitting/fit_result.hpp create mode 100644 maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp create mode 100644 maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp create mode 100644 maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp create mode 100644 maj0sted/include/maj0sted/domain/geometry/plan_element.hpp create mode 100644 maj0sted/include/maj0sted/domain/geometry/profile_element.hpp create mode 100644 maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp create mode 100644 maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp create mode 100644 maj0sted/include/maj0sted/domain/map_project.hpp create mode 100644 maj0sted/include/maj0sted/domain/niweleta.hpp create mode 100644 maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp create mode 100644 maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp create mode 100644 maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp create mode 100644 maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp create mode 100644 maj0sted/include/maj0sted/domain/value_objects/crs.hpp create mode 100644 maj0sted/include/maj0sted/domain/value_objects/length.hpp create mode 100644 maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp create mode 100644 maj0sted/include/maj0sted/domain/value_objects/radius.hpp create mode 100644 maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp create mode 100644 maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp create mode 100644 maj0sted/include/maj0sted/io/project_serializer.hpp create mode 100644 maj0sted/include/maj0sted/io/svg_writer.hpp create mode 100644 maj0sted/include/maj0sted/maj0sted.hpp create mode 100644 maj0sted/include/maj0sted/render/plan_sampler.hpp create mode 100644 maj0sted/include/maj0sted/render/rail_renderer.hpp create mode 100644 maj0sted/include/maj0sted/render/scene.hpp create mode 100644 maj0sted/include/maj0sted/render/track_renderer.hpp create mode 100644 maj0sted/include/maj0sted/version.hpp create mode 100644 maj0sted/include/maj0sted/web/editor.hpp create mode 100644 maj0sted/include/maj0sted/web/ribbon.hpp create mode 100644 maj0sted/include/maj0sted/web/solve.hpp create mode 100644 maj0sted/include/maj0sted/web/tile_cache.hpp create mode 100644 maj0sted/src/app/editor_document.cpp create mode 100644 maj0sted/src/domain/fitting_service.cpp create mode 100644 maj0sted/src/domain/gap_fitter.cpp create mode 100644 maj0sted/src/domain/horizontal_alignment.cpp create mode 100644 maj0sted/src/domain/map_project.cpp create mode 100644 maj0sted/src/domain/niweleta.cpp create mode 100644 maj0sted/src/domain/parallelism_service.cpp create mode 100644 maj0sted/src/domain/vertical_alignment.cpp create mode 100644 maj0sted/src/io/project_serializer.cpp create mode 100644 maj0sted/src/io/svg_writer.cpp create mode 100644 maj0sted/src/render/plan_sampler.cpp create mode 100644 maj0sted/src/render/rail_renderer.cpp create mode 100644 maj0sted/src/web/editor.cpp create mode 100644 maj0sted/src/web/ribbon.cpp create mode 100644 maj0sted/src/web/solve.cpp create mode 100644 maj0sted/src/web/tile_cache.cpp create mode 100644 maj0sted/tests/CMakeLists.txt create mode 100644 maj0sted/tests/check.hpp create mode 100644 maj0sted/tests/test_document.cpp create mode 100644 maj0sted/tests/test_fitting.cpp create mode 100644 maj0sted/tests/test_map_project.cpp create mode 100644 maj0sted/tests/test_niweleta.cpp create mode 100644 maj0sted/tests/test_parallelism.cpp create mode 100644 maj0sted/tests/test_plan_alignment.cpp create mode 100644 maj0sted/tests/test_render.cpp create mode 100644 maj0sted/tests/test_serialization.cpp create mode 100644 maj0sted/tests/test_tile_cache.cpp create mode 100644 maj0sted/tests/test_web.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 56cf1bce..06f5f6e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -516,6 +516,11 @@ add_subdirectory(ref/json) target_link_libraries(${PROJECT_NAME} nlohmann_json::nlohmann_json) target_include_directories(${PROJECT_NAME} PRIVATE ${NLOHMANN_JSON_INCLUDE_INSTALL_DIR}) +# track layout domain model used by the editor; plain C++20, no dependencies of its own +add_subdirectory(maj0sted) +set_target_properties(maj0sted PROPERTIES FOLDER "libraries") +target_link_libraries(${PROJECT_NAME} maj0sted::maj0sted) + if (WITH_DISCORD_RPC) diff --git a/maj0sted/.clang-format b/maj0sted/.clang-format new file mode 100644 index 00000000..e58a6fea --- /dev/null +++ b/maj0sted/.clang-format @@ -0,0 +1,12 @@ +--- +Language: Cpp +BasedOnStyle: Google +Standard: c++20 +ColumnLimit: 90 +IndentWidth: 4 +AccessModifierOffset: -4 +PointerAlignment: Left +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: false +SortIncludes: true +IncludeBlocks: Preserve diff --git a/maj0sted/.gitignore b/maj0sted/.gitignore new file mode 100644 index 00000000..8e43dbdd --- /dev/null +++ b/maj0sted/.gitignore @@ -0,0 +1,24 @@ +# Build output +/build/ +/out/ +/cmake-build-*/ + +# Compiled objects / archives +*.o +*.obj +*.a +*.lib +*.so +*.dylib + +# Editors / IDEs +.vscode/ +.idea/ +*.swp +.cache/ +compile_commands.json + +# WASM / web build artifacts (produced by web/build.sh) +web/maj0sted.js +web/maj0sted.wasm +web/serve diff --git a/maj0sted/CMakeLists.txt b/maj0sted/CMakeLists.txt new file mode 100644 index 00000000..e91c5677 --- /dev/null +++ b/maj0sted/CMakeLists.txt @@ -0,0 +1,113 @@ +cmake_minimum_required(VERSION 3.20) + +project(maj0sted + VERSION 0.1.0 + DESCRIPTION "Domain-Driven Design library for a railway track-layout editor" + LANGUAGES CXX) + +# ---------------------------------------------------------------------------- +# Options +# ---------------------------------------------------------------------------- +option(MAJ0STED_BUILD_TESTS "Build unit tests" ON) +option(MAJ0STED_BUILD_EXAMPLES "Build usage examples" ON) +option(MAJ0STED_WARNINGS_AS_ERRORS "Treat warnings as errors" OFF) + +# Only drive tests/examples/install defaults when built on its own, not when +# pulled in via add_subdirectory() from a larger project. +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(MAJ0STED_STANDALONE ON) +else() + set(MAJ0STED_STANDALONE OFF) +endif() + +# ---------------------------------------------------------------------------- +# The static library +# ---------------------------------------------------------------------------- +add_library(maj0sted STATIC + src/domain/map_project.cpp + src/domain/horizontal_alignment.cpp + src/domain/vertical_alignment.cpp + src/domain/niweleta.cpp + src/domain/parallelism_service.cpp + src/domain/fitting_service.cpp + src/domain/gap_fitter.cpp + src/io/project_serializer.cpp + src/io/svg_writer.cpp + src/render/plan_sampler.cpp + src/render/rail_renderer.cpp + src/web/solve.cpp + src/web/editor.cpp + src/web/ribbon.cpp + src/web/tile_cache.cpp + src/app/editor_document.cpp +) +add_library(maj0sted::maj0sted ALIAS maj0sted) + +target_compile_features(maj0sted PUBLIC cxx_std_20) + +target_include_directories(maj0sted PUBLIC + $ + $ +) + +if(MSVC) + target_compile_options(maj0sted PRIVATE /W4) +else() + target_compile_options(maj0sted PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wshadow) +endif() + +if(MAJ0STED_WARNINGS_AS_ERRORS) + if(MSVC) + target_compile_options(maj0sted PRIVATE /WX) + else() + target_compile_options(maj0sted PRIVATE -Werror) + endif() +endif() + +# ---------------------------------------------------------------------------- +# Tests +# ---------------------------------------------------------------------------- +if(MAJ0STED_BUILD_TESTS AND MAJ0STED_STANDALONE) + enable_testing() + add_subdirectory(tests) +endif() + +# ---------------------------------------------------------------------------- +# Examples +# ---------------------------------------------------------------------------- +if(MAJ0STED_BUILD_EXAMPLES AND MAJ0STED_STANDALONE) + add_subdirectory(examples) +endif() + +# ---------------------------------------------------------------------------- +# Install / package +# ---------------------------------------------------------------------------- +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +install(TARGETS maj0sted + EXPORT maj0stedTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(EXPORT maj0stedTargets + FILE maj0stedTargets.cmake + NAMESPACE maj0sted:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/maj0sted) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/maj0stedConfig.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfig.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/maj0sted) + +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfigVersion.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfigVersion.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/maj0sted) diff --git a/maj0sted/HANDOFF.md b/maj0sted/HANDOFF.md new file mode 100644 index 00000000..f1f59993 --- /dev/null +++ b/maj0sted/HANDOFF.md @@ -0,0 +1,184 @@ +# HANDOFF — maj0sted + +Stan prac i punkt zaczepienia dla kolejnej sesji (np. po przejściu na `claude` CLI). +Kod jest samoopisowy; ten plik to skrót "gdzie jesteśmy i co dalej". + +## Czym jest projekt +Statyczna biblioteka **C++20** (DDD) do edytora torowiska. Czysty model domenowy, +bez zależności zewnętrznych. Namespace: `maj0sted::domain`. + +**Uwaga (sandbox):** na tej maszynie wyłączone są nieuprzywilejowane user +namespaces, więc domyślny sandbox komend pada w preflight (komenda „nie zwraca +statusu”). Uruchamiaj polecenia powłoki **z wyłączonym sandboxem** (w Cursorze: +`required_permissions: ["all"]`). Wtedy `git`, `g++`, `node` działają normalnie. + +**Uwaga:** `cmake` NIE jest zainstalowany. Weryfikacja odbywa się przez `g++ 14.2`. +Toolchain WASM: `emsdk` jest w `~/emsdk` (`source ~/emsdk/emsdk_env.sh` dodaje `em++`). + +## Jak weryfikować (bez cmake) +```bash +cd /home/maj00r/Projects/maj0sted +FLAGS="-std=c++20 -Iinclude -Wall -Wextra -Wpedantic -Wconversion -Wshadow" +SRC="src/domain/*.cpp src/io/*.cpp src/render/*.cpp src/web/solve.cpp src/web/tile_cache.cpp src/app/editor_document.cpp" +for t in map_project niweleta plan_alignment parallelism fitting serialization render web tile_cache document; do + g++ $FLAGS -Itests $SRC tests/test_$t.cpp -o /tmp/t_$t && /tmp/t_$t | tail -1 +done +``` +Wszystkie testy przechodzą, kompilacja bez ostrzeżeń. Oszczędnie: kompiluj tylko +zmieniony test z minimalnym zestawem źródeł (np. `tile_cache` wymaga tylko +`src/web/tile_cache.cpp`; `document` — `src/domain/*.cpp src/io/project_serializer.cpp +src/app/editor_document.cpp`). Szybki syntax-check: `g++ $FLAGS -fsyntax-only ...`. + +Budowa WASM: `source ~/emsdk/emsdk_env.sh && cd web && ./build.sh` +(produkuje `web/maj0sted.js` + `.wasm`, oba w `.gitignore`). + +## Model — co już jest (✓ = zaimplementowane + testy) + +### Value Objects (`include/maj0sted/domain/value_objects/`) +- `Crs` — układ EPSG, domyślnie **EPSG:2180** (metry) ✓ +- `CartesianPosition` — (x=easting, y=northing), metry, double ✓ +- `Length` (≥0), `Radius` (>0) ✓ +- `Azimuth` — radiany, **zgodnie z ruchem wskazówek zegara od północy (+y)**, znormalizowany [0,2π) ✓ +- `NiweletaId`, `StraightId` — silne tożsamości ✓ +- `TrackOffset` — dystans (>0) + `Side::Left/Right` ✓ + +### Agregaty +- `MapProject` — **projekt**: trzyma `Crs` (domyślnie EPSG:2180) + kolekcję niwelet + (`add_niweleta`, `niwelety()`) ✓ +- `Niweleta` (`domain/niweleta.hpp`) — id, nazwa, plan + profil. **Sama nadaje `StraightId`**: + `add_straight(start,end,joint) -> StraightId`; `add_plan_element` mintuje id anonimowej prostej + (i podbija licznik przy id wczytanym z pliku). `lengths_consistent()`. + **`apply_fit(FitResult)`** — wpina wynik fitowania w plan: podmienia sąsiednie proste + (entry, exit) na `entry → curve... → exit` stykami Tangent; proste zachowują id ✓ + +### Geometria planu (`domain/geometry/`) +- `PlanElement = variant` + - `Straight` — **zakotwiczony w XY (start/end)**, azymut i długość liczone; opcjonalny `StraightId`. + Zasada: prosta trzyma linię nośną (pozycja+azymut), zmienia się tylko długość; krzywe to wypadkowa. + - `CircularArc` — promień + kierunek + **opcjonalna** długość + - `TransitionCurve` (klotoida) — długość + promienie końcowe (`optional`=∞) + kierunek +- `HorizontalAlignment` — lista kierunkowa + `JointContinuity{Tangent, AzimuthBreak}`. Reguły styku ✓: + - sąsiednie proste Tangent: muszą się stykać i mieć równy azymut + - różny azymut prostych → wymaga `AzimuthBreak` (tylko prosta-prosta, muszą się stykać, różne azymuty) + - sąsiednie łuki o tym samym promieniu i kierunku → zabronione + - styki z krzywą akceptowane "pending fit" + +### Geometria profilu +- `ProfileElement = variant`; `VerticalAlignment`. (Profil na razie nietknięty.) + +### Równoległość (`domain/parallelism/`) +- `TrackParallelism { StraightId origin, StraightId parallel, TrackOffset }` — **referencje wprost po StraightId**; + niezmienniki: oba id niepuste, różne ✓ +- `ParallelismService`: `mark_parallel(originStraight, parallelStraight, offset)`, `are_parallel`, + `derive_parallel_line`, `perpendicular_distance` ✓ + +### Fitowanie (`domain/fitting/`) +- `FitParameters { entry, exit, radius, optional transition_length }` — klotoida symetryczna lub sam łuk +- `CompoundFitParameters { entry, exit, radius1, radius2, split_station }` — **łuk koszowy** + (miejsce podziału = kilometraż pierwszego łuku; `δ1 = split_station/R1`) +- `FitResult { entry(przycięta), curve[], exit(przycięta), tangent_in, tangent_out, direction }` +- `FittingService`: + - `fit_between_straights` — klotoida–łuk–klotoida (lub sam łuk), przycina obie proste ✓ + - `fit_compound` — dwa łuki różnych promieni; **obie linie prostych bez zmian**, obie proste + doprzycięte na długości; podział ustala `δ1`. Metoda stycznej pośredniej ✓ + +### Serializacja (`maj0sted::io`, `include/maj0sted/io/project_serializer.hpp`) +- `serialize(MapProject) -> std::string` / `deserialize(std::string) -> MapProject` ✓ +- Format tekstowy, liniowy, bez zależności; liczby przez `to_chars`/`from_chars` (dokładny round-trip, + niezależny od locale). Round-trip jest bajtowo stabilny (test to sprawdza). +- Zapisuje: CRS + niwelety (id, nazwa, plan z elementami i stykami, profil). **NIE** zapisuje relacji + równoległości (patrz niżej — StraightId nie są globalnie unikalne). + +### Rendering: dwie szyny +`src/web/editor.cpp` rysuje **dwie szyny w rozstawie 1500 mm** (`kHalfGauge=0.75`, +±750 mm od osi) zamiast samej osi. `offset_points` przesuwa linię osi wzdłuż +normalnej per-wierzchołek (różnica centralna, normalna lewa `(-dy,dx)`), +`push_rails` emituje lewą i prawą szynę zachowując `kind` (kolor). Uchwyty edycji +(węzły/końce prostych) w JS dalej rysują się z osi (model). + +### Warstwa web (WASM) — biblioteka robi wszystko, JS tylko rysuje +Zasada: **cała logika jest w libce**; `web/` to cienki renderer + przekazywanie +wejścia. `web/maj0sted_bindings.cpp` (embind) to jedyny plik zależny od emscripten. + +- **Kafle podkładu WMS** (`maj0sted::web`, `web/tile_cache.hpp/.cpp`) ✓ + - `TileGrid` — **jeden stały grid kafli 100 m × 100 m** w **EPSG:2180** + (`kCellMetres=100`, **1024 px/kafel** → 100/1024 m/px, origin (0,0), jeden + poziom `kLevel=0`, **bez piramidy per-zoom**); `tile_at`, `bbox`, + `tiles_for_view`, `zoom_for_resolution` (argument zoom ignorowany, sygnatury + zachowane dla boundary). Czysta, testowana natywnie. + - **Retry:** nieudany fetch jest ponawiany **maks. `kMaxRetries=10` razy na + kafel** (licznik `TileImage.attempts`); po przekroczeniu limitu kafel dostaje + status `Failed`. Pod Emscripten retry jest odroczony async + (`emscripten_async_call`, `kRetryDelayMs=1500`), bez blokowania/rekurencji; + z wstrzykniętym fetcherem (testy) powtarza od razu. + - `WmsConfig` + `wms_getmap_url` — WMS **1.1.1** GetMap (BBOX = minx,miny,maxx,maxy), + presety Geoportalu (ortofoto/topo). Liczby przez `to_chars`. + - `TileService` — cache + **fetch**: na trafienie zwraca z cache, na pudło sam + wykonuje GetMap. Pod `__EMSCRIPTEN__` używa **Emscripten Fetch** (`-sFETCH`); + natywnie/w testach przez wstrzykiwany `Fetcher` (bez sieci). Callback + `on_ready` budzi przerysowanie w JS. + - **Proxy WMS (`web/serve.py`):** przeglądarka pobiera kafle **same-origin** + (`/wms?...`), a lokalny serwer pobiera je serwer-serwer z Geoportalu. Powód: + WAF Geoportalu **zwraca 404 dla User-Agentów zawierających „Mozilla"** (czyli + dla prawdziwych przeglądarek), a przepuszcza proste UA (`curl`), więc bezpośredni + `fetch`/`emscripten_fetch` z przeglądarki dostaje 404. Proxy używa UA `curl/8.5.0`. + Frontend ustawia `Module.wmsSetConfig({base_url: origin+'/wms'})` w `finishBoot`. + Libka nadal robi fetch (`emscripten_fetch`) — tylko na `localhost`. + Uruchamianie: `python3 web/serve.py 8080` (zamiast `http.server`). + - **Cache na dysku:** proxy odsyła udane kafle z `Cache-Control: public, + max-age=31536000, immutable`, więc przeglądarka trzyma je w HTTP cache na dysku — + po odświeżeniu strony kafle są brane z cache, bez ponownego pobierania. Błędy + (`502`) idą z `no-store` (nie cache'ują się). Uwaga: cache `TileService` w C++ jest + tylko w pamięci WASM (ginie przy refreshu) — trwałość zapewnia właśnie HTTP cache. + - **Retry w proxy:** WAF Geoportalu bywa niestabilny (sporadyczne 404/reset nawet dla + UA `curl`), więc proxy ponawia pobranie `WMS_PROXY_RETRIES=6` razy zanim zwróci 502; + do tego dochodzi client-side retry libki (10×). +- **Trwałość projektu** (`maj0sted::app`, `app/editor_document.hpp/.cpp`) ✓ + - `EditorDocument` = model GUI (niezależne proste + parametry fitów) — bezstratny. + - `serialize_document`/`deserialize_document` — **reużywa `io::serialize`** (osadza + prawdziwy projekt maj0sted) + sekcja `editor` z prostymi i parametrami fitów. + - `to_map_project`/`from_map_project` — konwersja do/z `MapProject` (best-effort, + nie rzuca). + - `save_project`/`load_project` — **I/O plikowe w libce** (`std::fstream` + + `std::filesystem`), domyślna ścieżka `/data/maj0sted/project.m0s`. +- **Fit nieudany → „brak" (decyzja w libce):** `solve_project` (editor) zwraca per + niweleta `polylines` **oraz `applied_fits`** — autorytatywną listę fitów, które + faktycznie zaszły. Fit, którego nie da się policzyć (zły parametr/geometria, punkty + styczne poza odcinkami prostych), jest **odrzucany w libce** i nie ma go w `applied_fits`. + Frontend tylko lustrzanie przyjmuje tę listę (bez własnej reguły o porażce). +- **Boundary (embind, cienki):** `solveProject`, `wmsTilesForView`, + `wmsZoomForResolution`, `wmsTileBytes`, `wmsSetOnReady`, `wmsSetConfig`, + `saveProject`, `loadDefaultProject`. +- **Frontend (`web/index.html`):** rysuje podkład WMS pod geometrią (georeferencja + EPSG:2180, trzyma się przy pan/zoom); `Ctrl+S` → `saveProject` + `FS.syncfs`; + na starcie montuje **IDBFS** w `/data`, `syncfs(true)` i `loadDefaultProject` + (fallback: demo). Flow budowy: prosta → krzywa (czeka jako „pending”, rysowana + przerywaną) → kolejna prosta kotwiczy ją z obu stron i **dopiero wtedy** liczony + jest fit. + +## Konwencje geometryczne +- EPSG:2180, jednostka = metr; kierunek jazdy `d = (sin az, cos az)`; normalna lewa `(-cos az, sin az)`. +- Tolerancje: pozycja `1e-6 m`, azymut `1e-9 rad`. + +## Znane ograniczenia +- **`StraightId` są lokalne per-niweleta** (licznik startuje od 1 w każdej niwelecie), więc NIE są + globalnie unikalne. `TrackParallelism` referuje proste po samym `StraightId` — działa dla luźnych + obiektów, ale w projekcie z wieloma niweletami trzeba by referować `(NiweletaId, StraightId)`. + Do przemyślenia, jeśli równoległość ma być trwała/serializowana. + +## Następne kroki (do decyzji / do zrobienia) +1. **Klotoidy w łuku koszowym** (między łukami i/lub na końcach). (NIE zrobione.) +2. Asymetryczne klotoidy w `fit_between_straights` (teraz tylko symetryczne). (NIE zrobione.) +3. **Rejestr relacji równoległości** + poprawka referencji na `(NiweletaId, StraightId)` i ich serializacja. (NIE.) +4. **Solver** ustawiający prostą równoległą na `derive_parallel_line` i przeliczający resztę. (NIE.) +5. Instalacja `cmake`/`ninja` i budowa przez CMake (na razie tylko `g++`). + +## Preferencje pracy (ustalone z użytkownikiem) +- **Nie edytować `README.md`** (utrzymuje go użytkownik). +- Robić **tylko to, o co proszono** — bez dokładania funkcji z własnej inicjatywy. +- **Oszczędzać CPU**: kompilować tylko zmieniony test, `-fsyntax-only` do szybkiej walidacji, + pełne testy na koniec/na żądanie; bez procesów w tle, przeglądarki i podagentów. +- Język domeny: **niweleta** = jednolity odcinek toru złożony z wielu krzywych w planie i profilu. + +## Git +Repozytorium zainicjalizowane; pliki dodane do stage'a, **brak commitów** (commit dopiero na wyraźną prośbę). diff --git a/maj0sted/README.md b/maj0sted/README.md new file mode 100644 index 00000000..5f3a916b --- /dev/null +++ b/maj0sted/README.md @@ -0,0 +1,86 @@ +# maj0sted + +Statyczna biblioteka **C++20** do obsługi **edytora torowiska**, zbudowana +zgodnie z **Domain-Driven Design (DDD)**. + +Biblioteka dostarcza czysty model domenowy — bez zależności od UI, bazy danych +czy frameworka. Warstwy aplikacji i infrastruktury podpina się do zdefiniowanych +tu typów. + +## Model domenowy + +``` +maj0sted::domain +├── value_objects/ Value Objects (niezmienne, porównywane przez wartość) +│ ├── Crs układ odniesienia (kod EPSG), domyślnie EPSG:2180 +│ └── CartesianPosition pozycja kartezjańska (x, y w jednostkach CRS) +└── MapProject projekt mapy — definiuje CRS dla wszystkich pozycji +``` + +Projekt mapy (`MapProject`) definiuje kartezjański układ odniesienia, w którym +wyrażane są wszystkie pozycje. Domyślnie jest to **EPSG:2180** (PUWG 1992 / +Poland CS92), którego jednostką jest metr. + +## Szybki start + +```cpp +#include "maj0sted/maj0sted.hpp" +using namespace maj0sted::domain; + +MapProject project; // domyślnie EPSG:2180 +int epsg = project.crs().epsg(); // 2180 + +CartesianPosition p{500000.0, 300000.0}; // x, y w metrach +``` + +Pełniejszy przykład: [`examples/basic_editor.cpp`](examples/basic_editor.cpp). + +## Budowanie + +Wymagania: kompilator C++20 (GCC 11+/Clang 14+/MSVC 19.3+) oraz CMake ≥ 3.20. + +```bash +cmake -S . -B build # dodaj -G Ninja, jeśli masz ninja +cmake --build build +ctest --test-dir build --output-on-failure +./build/examples/maj0sted_example +``` + +### Opcje CMake + +| Opcja | Domyślnie | Znaczenie | +|------------------------------|-----------|-------------------------------| +| `MAJ0STED_BUILD_TESTS` | `ON` | buduj testy jednostkowe | +| `MAJ0STED_BUILD_EXAMPLES` | `ON` | buduj przykłady | +| `MAJ0STED_WARNINGS_AS_ERRORS`| `OFF` | traktuj ostrzeżenia jak błędy | + +### Użycie w innym projekcie + +Po `cmake --install build` (albo przez `add_subdirectory`): + +```cmake +find_package(maj0sted REQUIRED) +target_link_libraries(twoj_target PRIVATE maj0sted::maj0sted) +``` + +## Struktura projektu + +``` +maj0sted/ +├── CMakeLists.txt +├── include/maj0sted/ nagłówki publiczne (API) +├── src/domain/ implementacje (.cpp) +├── tests/ testy jednostkowe (CTest, bez zależności) +├── examples/ przykłady użycia +└── cmake/ pliki pakietu find_package +``` + +## Testy + +Testy używają własnego, malutkiego frameworka (`tests/check.hpp`) — nie wymagają +żadnych zewnętrznych bibliotek. Każdy plik testowy to osobny plik wykonywalny +zwracający kod wyjścia dla CTest. + +## Licencja + +Do uzupełnienia (np. MIT) — dodaj plik `LICENSE`. diff --git a/maj0sted/cmake/maj0stedConfig.cmake.in b/maj0sted/cmake/maj0stedConfig.cmake.in new file mode 100644 index 00000000..33cbabc9 --- /dev/null +++ b/maj0sted/cmake/maj0stedConfig.cmake.in @@ -0,0 +1,5 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/maj0stedTargets.cmake") + +check_required_components(maj0sted) diff --git a/maj0sted/examples/CMakeLists.txt b/maj0sted/examples/CMakeLists.txt new file mode 100644 index 00000000..227f9e31 --- /dev/null +++ b/maj0sted/examples/CMakeLists.txt @@ -0,0 +1,5 @@ +add_executable(maj0sted_example basic_editor.cpp) +target_link_libraries(maj0sted_example PRIVATE maj0sted::maj0sted) + +add_executable(maj0sted_plan_to_svg plan_to_svg.cpp) +target_link_libraries(maj0sted_plan_to_svg PRIVATE maj0sted::maj0sted) diff --git a/maj0sted/examples/basic_editor.cpp b/maj0sted/examples/basic_editor.cpp new file mode 100644 index 00000000..00e4dd84 --- /dev/null +++ b/maj0sted/examples/basic_editor.cpp @@ -0,0 +1,20 @@ +// Minimal walkthrough: create a map project and inspect its CRS. + +#include + +#include "maj0sted/maj0sted.hpp" + +using namespace maj0sted::domain; + +int main() { + std::printf("maj0sted %s\n\n", MAJ0STED_VERSION_STRING); + + const MapProject project; // defaults to EPSG:2180 + std::printf("Map project CRS: EPSG:%d\n", project.crs().epsg()); + + const CartesianPosition position{500000.0, 300000.0}; + std::printf("Sample position: x=%.1f m, y=%.1f m\n", position.x(), + position.y()); + + return 0; +} diff --git a/maj0sted/examples/plan_to_svg.cpp b/maj0sted/examples/plan_to_svg.cpp new file mode 100644 index 00000000..063ce687 --- /dev/null +++ b/maj0sted/examples/plan_to_svg.cpp @@ -0,0 +1,58 @@ +// A simple visual check tool: build a niweleta, fit a compound curve with +// transition curves between the two straights, apply it, and write the plan to +// an SVG file you can open in a browser. +// +// Usage: plan_to_svg [output.svg] (default: plan.svg) +// +// The reusable pieces are render::sample() (geometry -> polylines) and +// io::to_svg(). A different GUI would call render::sample() and paint itself. + +#include +#include +#include + +#include "maj0sted/io/svg_writer.hpp" +#include "maj0sted/maj0sted.hpp" +#include "maj0sted/render/plan_sampler.hpp" + +using namespace maj0sted::domain; + +int main(int argc, char** argv) { + MapProject project; + + // Two straights forming a 90-degree corner. + Niweleta niweleta{NiweletaId{1}, "Demo"}; + const StraightId entry = + niweleta.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{300.0, 0.0}); + const StraightId exit = niweleta.add_straight( + CartesianPosition{300.0, 0.0}, CartesianPosition{300.0, 300.0}, + JointContinuity::AzimuthBreak); + + // A compound curve R120 -> R80 with clothoids at the ends and between arcs. + const CompoundFitParameters params{ + .entry = entry, + .exit = exit, + .entry_transition = Length::from_metres(30.0), + .exit_transition = Length::from_metres(30.0), + .arcs = {CompoundArc{Radius::from_metres(120.0), Length::from_metres(60.0), + Length::from_metres(25.0)}, + CompoundArc{Radius::from_metres(80.0)}}}; + + const FitResult fit = FittingService::fit_compound(niweleta, params); + niweleta.apply_fit(fit); + project.add_niweleta(std::move(niweleta)); + + const maj0sted::render::Scene scene = maj0sted::render::sample(project); + const std::string svg = maj0sted::io::to_svg(scene); + + const std::string path = (argc > 1) ? argv[1] : "plan.svg"; + std::ofstream out(path); + if (!out) { + std::fprintf(stderr, "cannot write %s\n", path.c_str()); + return 1; + } + out << svg; + std::printf("wrote %s (%zu polylines) — open it in a browser\n", path.c_str(), + scene.polylines.size()); + return 0; +} diff --git a/maj0sted/include/maj0sted/app/editor_document.hpp b/maj0sted/include/maj0sted/app/editor_document.hpp new file mode 100644 index 00000000..2a26147c --- /dev/null +++ b/maj0sted/include/maj0sted/app/editor_document.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +#include "maj0sted/domain/map_project.hpp" +#include "maj0sted/web/editor.hpp" // NiweletaSpec (the editor's GUI model) + +namespace maj0sted::app { + +/// The editor's persistable document: the GUI model exactly as the user built +/// it — independent anchored straights plus the parametric fits between them. +/// +/// This is the source of truth for save/restore because it is lossless: it keeps +/// the untrimmed straights and the fit *parameters* (radius, transition lengths, +/// compound arcs, ...), which the resolved domain geometry alone cannot express. +struct EditorDocument { + std::vector niwelety; +}; + +/// Best-effort resolved-domain view of @p document: one Niweleta per editor +/// niweleta, keeping the anchored straights (a floating placeholder arc sits in +/// each gap so the alignment stays valid). Used for interop / inspection and so +/// the on-disk file embeds a real maj0sted project. Never throws. +[[nodiscard]] maj0sted::domain::MapProject to_map_project(const EditorDocument& document); + +/// Imports a plain domain project into the editor model: every Straight in each +/// niweleta's plan becomes an editor straight (fits are left empty — resolved +/// geometry carries no fit parameters to recover). +[[nodiscard]] EditorDocument from_map_project(const maj0sted::domain::MapProject& project); + +/// Serializes @p document to the library's textual format. The document embeds a +/// real maj0sted project (via maj0sted::io::serialize of to_map_project) followed +/// by an `editor` section that captures the straights and fit parameters +/// losslessly. @c deserialize_document restores it exactly. +[[nodiscard]] std::string serialize_document(const EditorDocument& document); + +/// Rebuilds an EditorDocument from text produced by @c serialize_document. +/// @throws std::runtime_error on malformed input. +[[nodiscard]] EditorDocument deserialize_document(const std::string& text); + +/// The default on-disk project path. Under Emscripten this lives beneath the +/// IDBFS mount point (see web/index.html), so it survives reloads once flushed. +[[nodiscard]] std::string default_project_path(); + +/// Serializes @p document and writes it to @p path (creating parent directories). +/// @returns true on success; never throws. +bool save_project(const EditorDocument& document, + const std::string& path = default_project_path()); + +/// Reads and deserializes the project at @p path, or std::nullopt if the file is +/// absent or unreadable/malformed. Never throws. +[[nodiscard]] std::optional load_project( + const std::string& path = default_project_path()); + +} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp b/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp new file mode 100644 index 00000000..3d0e1b87 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include "maj0sted/domain/value_objects/length.hpp" +#include "maj0sted/domain/value_objects/radius.hpp" +#include "maj0sted/domain/value_objects/straight_id.hpp" + +namespace maj0sted::domain { + +/// Input parameters for fitting a curve between two straights of one niweleta. +/// +/// These map directly onto a GUI form: the user picks the entry and exit +/// straights, types a radius, and optionally a (symmetric) transition-curve +/// length. The turn direction is NOT an input — it follows from the geometry of +/// the two straights (their deflection angle). +/// +/// * transition_length absent -> a plain circular arc is fitted; +/// * transition_length present -> a symmetric klotoida-arc-klotoida is fitted, +/// with that length on both ends. +struct FitParameters { + StraightId entry{}; ///< pierwsza (wejściowa) prosta + StraightId exit{}; ///< druga (wyjściowa) prosta + Radius radius; ///< promień łuku kołowego + std::optional transition_length{}; ///< symetryczna klotoida; brak = sam łuk +}; + +/// One arc of a compound curve. +struct CompoundArc { + Radius radius; ///< promień łuku + std::optional length{}; ///< długość łuku; wymagana OPRÓCZ ostatniego + std::optional transition_to_next{}; ///< klotoida do następnego łuku (ignorowana dla ostatniego) +}; + +/// Input parameters for fitting a compound curve — a sequence of one or more +/// circular arcs (of different neighbouring radii, same direction) with optional +/// transition curves (klotoidy) between the arcs and at the ends — between two +/// straights. This single shape covers both a "łuk + klotoidy" (one arc eased by +/// symmetric clothoids) and a "łuk koszowy" (two or more arcs). +/// +/// Both straight lines stay put (position and azimuth). Each arc except the last +/// carries its length, which fixes its central angle (`δ = L / R`); the last (or +/// only) arc takes whatever deflection remains after the other arcs and every +/// transition curve. The two straights are re-trimmed in length so the whole +/// chain closes. +struct CompoundFitParameters { + StraightId entry{}; ///< pierwsza (wejściowa) prosta + StraightId exit{}; ///< druga (wyjściowa) prosta + std::optional entry_transition{}; ///< klotoida prosta -> pierwszy łuk + std::optional exit_transition{}; ///< klotoida ostatni łuk -> prosta + std::vector arcs{}; ///< >= 1 łuk; sąsiednie promienie różne +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp b/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp new file mode 100644 index 00000000..a4d541a5 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include "maj0sted/domain/geometry/plan_element.hpp" +#include "maj0sted/domain/value_objects/cartesian_position.hpp" + +namespace maj0sted::domain { + +/// Result of fitting a curve between two straights. +/// +/// The two straights keep their identity but are trimmed back to the tangent +/// points; @c curve is the klotoida?/łuk/klotoida? to splice between them. The +/// caller (or an aggregate command) replaces the original entry/exit straights +/// with @c entry, then @c curve, then @c exit. +struct FitResult { + Straight entry; ///< trimmed entry straight (ends at tangent_in) + std::vector curve; ///< klotoida? / łuk / klotoida? + Straight exit; ///< trimmed exit straight (starts at tangent_out) + CartesianPosition tangent_in; ///< entry -> curve tangent point + CartesianPosition tangent_out; ///< curve -> exit tangent point + TurnDirection direction; ///< derived turn direction +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp b/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp new file mode 100644 index 00000000..8d1f97fe --- /dev/null +++ b/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include + +#include "maj0sted/domain/fitting/fit_parameters.hpp" +#include "maj0sted/domain/fitting/fit_result.hpp" +#include "maj0sted/domain/niweleta.hpp" +#include "maj0sted/domain/value_objects/length.hpp" +#include "maj0sted/domain/value_objects/radius.hpp" + +namespace maj0sted::domain { + +/// Domain Service: fits a curve between two straights of a single niweleta. +class FittingService { +public: + // --- Overloads that fit two standalone straights (no niweleta) --- + + /// Rounds the corner between two straight lines with an arc (and optional + /// symmetric transition curves). Works for any deflection strictly between + /// 0° and 180°. For a 180° reversal use fit_uturn instead. + static FitResult fit_between_straights( + const Straight& entry, const Straight& exit, Radius radius, + std::optional transition = std::nullopt); + + /// Fits a compound curve between two standalone straight lines. + static FitResult fit_compound(const Straight& entry, const Straight& exit, + const CompoundFitParameters& params); + + /// Connects two (anti-)parallel straights with a 180° reversal ("nawrót"). + /// The radius is derived from the perpendicular offset between the two lines + /// (a plain arc uses R = offset / 2). Optional symmetric transition curves + /// are solved for numerically. + /// @throws std::invalid_argument if the straights are not anti-parallel or + /// their offset is zero. + static FitResult fit_uturn(const Straight& entry, const Straight& exit, + std::optional transition = std::nullopt); + + /// Fits an S-curve made of two tangent circular arcs with the same radius + /// and opposite turn directions. Supports non-parallel straights and + /// same-direction parallel straights with a non-zero lateral offset. + /// Tangent points are required to lie on both finite straight segments. + /// @throws std::invalid_argument if no equal-radius reverse curve fits. + static FitResult fit_reverse(const Straight& entry, const Straight& exit, + Radius radius); + + /// Computes the curve (and the trimmed straights) that connects the two + /// straights named in @p params, both belonging to @p niweleta. + /// + /// @throws std::invalid_argument if a straight is missing, the two are the + /// same, the straights are parallel/collinear, or the transition + /// curves are too long for the chosen radius and deflection. + static FitResult fit_between_straights(const Niweleta& niweleta, + const FitParameters& params); + + /// Computes a compound curve ("łuk koszowy") — a chain of N arcs of + /// different neighbouring radii, same direction — between the two straights + /// named in @p params. Both straight lines stay fixed and are re-trimmed in + /// length; each arc's length sets its central angle, and the last arc takes + /// the remaining deflection. + /// + /// @throws std::invalid_argument if a straight is missing, the two are the + /// same, there are no arcs, neighbouring radii are equal, a non-last + /// arc has no length, the straights are parallel/collinear, or the + /// split lengths exceed the total deflection. + static FitResult fit_compound(const Niweleta& niweleta, + const CompoundFitParameters& params); +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp b/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp new file mode 100644 index 00000000..eb76e5cc --- /dev/null +++ b/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include + +#include "maj0sted/domain/fitting/fit_result.hpp" +#include "maj0sted/domain/geometry/plan_element.hpp" // Straight +#include "maj0sted/domain/value_objects/length.hpp" +#include "maj0sted/domain/value_objects/radius.hpp" + +namespace maj0sted::domain { + +/// How the gap between two independent, finite straights should be connected. +/// A pure domain concept: the editor's numeric fit modes map onto it, but this +/// enum carries no UI knowledge. A plain circular arc (Arc) and a compound curve +/// (Compound) are the only kinds — a "łuk + klotoidy" is just a Compound with a +/// single arc eased by symmetric clothoids. +enum class GapConnectionMode { Arc, Compound }; + +/// One arc of a compound connection. +struct CompoundGapArc { + double radius{0.0}; ///< arc radius (metres) + double length{0.0}; ///< fixed arc length (metres); ignored for the last arc + double transition_to_next{0.0}; ///< clothoid to the next arc (metres, 0 = none) +}; + +/// Extra parameters for a compound connection: entry/exit easing clothoids plus a +/// sequence of one or more arcs. A single arc yields "łuk + klotoidy"; two or +/// more yield a "łuk koszowy". The last arc absorbs the remaining deflection, so +/// its @c length / @c transition_to_next are ignored. +struct CompoundGapParameters { + double entry_transition{0.0}; + double exit_transition{0.0}; + std::vector arcs{}; +}; + +/// A request to connect two finite straights with a curve. +struct GapConnectionRequest { + GapConnectionMode mode{GapConnectionMode::Arc}; + double radius{0.0}; ///< plain-arc radius (metres) + CompoundGapParameters compound{}; ///< compound only +}; + +/// The connection actually produced. +struct GapConnection { + FitResult fit; ///< trimmed straights + spliced curve + double applied_radius{0.0}; ///< radius truly used (may be reduced to fit) +}; + +/// Domain policy connecting two independent, finite straights for the editor. +/// +/// This is the single source of truth for the fitting business rules: +/// * a 180° reversal is detected from the geometry and handled as a nawrót, +/// never surfaced as a user-chosen mode; +/// * a plain arc uses the requested radius verbatim whenever it fits; the +/// radius is NEVER enlarged. A tighter-than-needed arc fits by extending the +/// straights toward the corner; only a radius too large to land on the +/// straights is reduced, to the largest one that fits. No other curve type is +/// ever substituted; +/// * a compound curve (one arc eased by clothoids, or a multi-arc basket) is +/// built as specified; +/// * a tangent point may extend a straight toward the corner, but must not cross +/// behind its outer, anchored end. +/// +/// Returns nullopt when nothing admissible fits (the caller then leaves the gap +/// unconnected). Never throws. +class GapFitter { +public: + [[nodiscard]] static std::optional connect( + const Straight& entry, const Straight& exit, + const GapConnectionRequest& request); +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp b/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp new file mode 100644 index 00000000..fe1e08c8 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +#include "maj0sted/domain/geometry/plan_element.hpp" +#include "maj0sted/domain/value_objects/length.hpp" + +namespace maj0sted::domain { + +/// How two consecutive plan elements meet. +enum class JointContinuity { + /// Uniform azimuth ("styczność") — the rule for every joint. + Tangent, + /// An explicitly marked azimuth discontinuity ("załamanie"). Allowed only + /// between two straights that touch at different azimuths. + AzimuthBreak, +}; + +/// "Niweleta w planie": a directional (ordered) list of plan elements. +/// +/// Straights are anchored in XY; the curves float and are positioned by later +/// fitting. The list enforces the connection rules between consecutive elements: +/// +/// * as a rule consecutive elements meet under a uniform azimuth (Tangent); +/// * two straights may meet at different azimuths only if the joint is +/// explicitly marked as an AzimuthBreak; +/// * two circular arcs with the same radius and the same direction may not +/// be adjacent (it would be a single arc). +/// +/// Joints that involve a floating curve cannot be fully checked yet and are +/// accepted pending the fitting step. +class HorizontalAlignment { +public: + /// Appends an element. The first element ignores @p joint; every later + /// element is validated against the current last element. + /// @throws std::logic_error if the joint violates a connection rule. + void append(PlanElement element, JointContinuity joint = JointContinuity::Tangent); + + [[nodiscard]] const std::vector& elements() const noexcept { + return elements_; + } + /// Joint between element @p index and @p index + 1 (index < size() - 1). + [[nodiscard]] JointContinuity joint(std::size_t index) const { + return joints_.at(index); + } + + [[nodiscard]] bool empty() const noexcept { return elements_.empty(); } + [[nodiscard]] std::size_t size() const noexcept { return elements_.size(); } + + /// Sum of the known element lengths; arcs without a length are skipped. + [[nodiscard]] Length total_length() const; + +private: + std::vector elements_; + std::vector joints_; // size() == elements_.size() - 1 +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp b/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp new file mode 100644 index 00000000..b4281287 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include + +#include "maj0sted/domain/value_objects/azimuth.hpp" +#include "maj0sted/domain/value_objects/cartesian_position.hpp" +#include "maj0sted/domain/value_objects/length.hpp" +#include "maj0sted/domain/value_objects/radius.hpp" +#include "maj0sted/domain/value_objects/straight_id.hpp" + +namespace maj0sted::domain { + +/// Direction of a horizontal turn: left ("w lewo") or right ("w prawo"). +enum class TurnDirection { Left, Right }; + +/// Krzywa w planie: odcinek prosty. +/// +/// A straight is *anchored* by both of its XY endpoints; its azimuth and length +/// are computed from them. During fitting a straight keeps its supporting line +/// (position + azimuth) fixed — only its length may change as an endpoint slides +/// along that line. The curves between straights are positioned as a result of +/// that fitting, not the other way round. +/// +/// A straight also carries an optional identity (StraightId). References between +/// straight segments — e.g. parallelism — point at that id directly. Straights +/// used purely as geometry may stay anonymous (null id). +class Straight { +public: + /// Anonymous straight (null id). + /// @throws std::invalid_argument if @p start and @p end coincide (a + /// zero-length straight has no defined azimuth). + Straight(CartesianPosition start, CartesianPosition end) + : Straight{StraightId{}, start, end} {} + + /// Identified straight. + /// @throws std::invalid_argument if @p start and @p end coincide. + Straight(StraightId id, CartesianPosition start, CartesianPosition end) + : id_{id}, + start_{start}, + end_{end}, + azimuth_{Azimuth::of_direction(start, end)}, + length_{Length::from_metres(distance(start, end))} {} + + [[nodiscard]] StraightId id() const noexcept { return id_; } + [[nodiscard]] CartesianPosition start() const noexcept { return start_; } + [[nodiscard]] CartesianPosition end() const noexcept { return end_; } + [[nodiscard]] Azimuth azimuth() const noexcept { return azimuth_; } + [[nodiscard]] Length length() const noexcept { return length_; } + +private: + static double distance(CartesianPosition a, CartesianPosition b) noexcept { + const double dx = b.x() - a.x(); + const double dy = b.y() - a.y(); + return std::sqrt(dx * dx + dy * dy); + } + + StraightId id_; + CartesianPosition start_; + CartesianPosition end_; + Azimuth azimuth_; + Length length_; +}; + +/// Krzywa w planie: łuk kołowy o stałym promieniu. +/// +/// A floating element: it is defined by its radius and turn direction. Its +/// length is not essential and is therefore optional — typically it becomes +/// known only as a resultant of fitting between the anchored straights. +class CircularArc { +public: + CircularArc(Radius radius, TurnDirection direction, + std::optional length = std::nullopt) noexcept + : radius_{radius}, direction_{direction}, length_{length} {} + + [[nodiscard]] Radius radius() const noexcept { return radius_; } + [[nodiscard]] TurnDirection direction() const noexcept { return direction_; } + [[nodiscard]] std::optional length() const noexcept { return length_; } + +private: + Radius radius_; + TurnDirection direction_; + std::optional length_; +}; + +/// Krzywa w planie: krzywa przejściowa (klotoida). +/// +/// A floating element defined by its length, the radii at each end and its turn +/// direction. A missing radius (nullopt) denotes an infinite radius — a tangent +/// to a straight — so one type covers straight->arc, arc->arc and arc->straight. +class TransitionCurve { +public: + TransitionCurve(Length length, std::optional start_radius, + std::optional end_radius, TurnDirection direction) noexcept + : length_{length}, + start_radius_{start_radius}, + end_radius_{end_radius}, + direction_{direction} {} + + [[nodiscard]] Length length() const noexcept { return length_; } + [[nodiscard]] std::optional start_radius() const noexcept { return start_radius_; } + [[nodiscard]] std::optional end_radius() const noexcept { return end_radius_; } + [[nodiscard]] TurnDirection direction() const noexcept { return direction_; } + +private: + Length length_; + std::optional start_radius_; + std::optional end_radius_; + TurnDirection direction_; +}; + +/// Any single element of the horizontal alignment. +using PlanElement = std::variant; + +/// Length of a plan element, if known. A circular arc may not carry one. +[[nodiscard]] inline std::optional length_of(const PlanElement& element) { + if (const auto* straight = std::get_if(&element)) { + return straight->length(); + } + if (const auto* transition = std::get_if(&element)) { + return transition->length(); + } + return std::get(element).length(); +} + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp b/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp new file mode 100644 index 00000000..9866cb1e --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include + +#include "maj0sted/domain/value_objects/length.hpp" +#include "maj0sted/domain/value_objects/radius.hpp" + +namespace maj0sted::domain { + +/// Sense of a vertical curve: a crest ("wypukły" — hilltop) or a sag +/// ("wklęsły" — valley). +enum class VerticalCurvature { Crest, Sag }; + +/// Krzywa w profilu: pochylenie o stałym nachyleniu, wyrażonym w promilach (‰). +/// Wartość dodatnia oznacza wznoszenie, ujemna — opadanie. +class Grade { +public: + Grade(Length length, double slope_permille) noexcept + : length_{length}, slope_permille_{slope_permille} {} + + [[nodiscard]] Length length() const noexcept { return length_; } + [[nodiscard]] double slope_permille() const noexcept { return slope_permille_; } + +private: + Length length_; + double slope_permille_; +}; + +/// Krzywa w profilu: łuk pionowy o stałym promieniu, łączący dwa pochylenia. +class VerticalCurve { +public: + VerticalCurve(Length length, Radius radius, VerticalCurvature curvature) noexcept + : length_{length}, radius_{radius}, curvature_{curvature} {} + + [[nodiscard]] Length length() const noexcept { return length_; } + [[nodiscard]] Radius radius() const noexcept { return radius_; } + [[nodiscard]] VerticalCurvature curvature() const noexcept { return curvature_; } + +private: + Length length_; + Radius radius_; + VerticalCurvature curvature_; +}; + +/// Any single element of the vertical alignment. +using ProfileElement = std::variant; + +/// Length of a profile element, whatever its concrete kind. +[[nodiscard]] inline Length length_of(const ProfileElement& element) { + return std::visit([](const auto& e) { return e.length(); }, element); +} + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp b/maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp new file mode 100644 index 00000000..4716824f --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace maj0sted::domain::geometry { + +struct XY { + double x; + double y; +}; + +/// Position plus unit heading, in (east=x, north=y). +struct Pose { + double x; + double y; + double hx; + double hy; +}; + +/// Lays out one segment of linearly varying signed curvature, starting at +/// @p start: a circular arc when @p k0 == @p k1, a clothoid otherwise, a +/// straight when both are zero. When @p out is non-null the sampled points +/// (including the start point) are appended to it. Returns the segment's end +/// pose. +/// +/// Both the fitting solver and the renderer call this so their geometry is +/// bit-for-bit identical — the fitted tangent points then coincide exactly with +/// the drawn curve, with no lateral gap even for tight arcs. +inline Pose layout_segment(double k0, double k1, double length, Pose start, + std::vector* out) { + const double left_x = -start.hy; + const double left_y = start.hx; + const auto emit = [&](double local_x, double local_y) { + if (out != nullptr) { + out->push_back({start.x + local_x * start.hx + local_y * left_x, + start.y + local_x * start.hy + local_y * left_y}); + } + }; + + emit(0.0, 0.0); + + double end_x = 0.0; + double end_y = 0.0; + if (length > 0.0) { + const double turned = std::abs(0.5 * (k0 + k1) * length); + const int steps = std::clamp( + static_cast(std::ceil(std::max( + length / 0.5, turned * 180.0 / std::numbers::pi / 2.0))), + 8, 512); + const double ds = length / steps; + + if (std::abs(k1 - k0) < 1e-12) { + const double k = k0; + if (std::abs(k) < 1e-12) { + end_x = length; // straight + emit(end_x, 0.0); + } else { + for (int i = 1; i <= steps; ++i) { // circular arc, exact + const double s = ds * i; + end_x = std::sin(k * s) / k; + end_y = (1.0 - std::cos(k * s)) / k; + emit(end_x, end_y); + } + } + } else { + const double slope = (k1 - k0) / (2.0 * length); // clothoid + for (int i = 1; i <= steps; ++i) { + const double s_mid = ds * (i - 0.5); + const double theta = k0 * s_mid + slope * s_mid * s_mid; + end_x += std::cos(theta) * ds; + end_y += std::sin(theta) * ds; + emit(end_x, end_y); + } + } + } + + const double dtheta = 0.5 * (k0 + k1) * length; + const double cos_t = std::cos(dtheta); + const double sin_t = std::sin(dtheta); + return Pose{start.x + end_x * start.hx + end_y * left_x, + start.y + end_x * start.hy + end_y * left_y, + start.hx * cos_t - start.hy * sin_t, + start.hx * sin_t + start.hy * cos_t}; +} + +} // namespace maj0sted::domain::geometry diff --git a/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp b/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp new file mode 100644 index 00000000..41c23fb3 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +#include "maj0sted/domain/geometry/profile_element.hpp" +#include "maj0sted/domain/value_objects/length.hpp" + +namespace maj0sted::domain { + +/// Geometria w profilu (rzut pionowy toru): uporządkowany ciąg pochyleń i łuków +/// pionowych, gdzie każdy element zaczyna się tam, gdzie kończy się poprzedni. +class VerticalAlignment { +public: + void append(ProfileElement element) { elements_.push_back(std::move(element)); } + + [[nodiscard]] const std::vector& elements() const noexcept { + return elements_; + } + [[nodiscard]] bool empty() const noexcept { return elements_.empty(); } + [[nodiscard]] std::size_t size() const noexcept { return elements_.size(); } + + /// Sumaryczna długość toru w profilu. + [[nodiscard]] Length total_length() const; + +private: + std::vector elements_; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/map_project.hpp b/maj0sted/include/maj0sted/domain/map_project.hpp new file mode 100644 index 00000000..a8879605 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/map_project.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include "maj0sted/domain/niweleta.hpp" +#include "maj0sted/domain/value_objects/crs.hpp" + +namespace maj0sted::domain { + +/// The map project ("projekt mapy"). +/// +/// It defines the Cartesian coordinate reference system in which every position +/// in the project is expressed (by default EPSG:2180, metres) and owns the +/// niwelety that make up the layout. +class MapProject { +public: + /// Creates a project using @p crs, or EPSG:2180 by default. + explicit MapProject(Crs crs = Crs::default_crs()); + + [[nodiscard]] Crs crs() const noexcept { return crs_; } + + void add_niweleta(Niweleta niweleta); + [[nodiscard]] const std::vector& niwelety() const noexcept { + return niwelety_; + } + +private: + Crs crs_; + std::vector niwelety_; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/niweleta.hpp b/maj0sted/include/maj0sted/domain/niweleta.hpp new file mode 100644 index 00000000..c6cafae2 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/niweleta.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include "maj0sted/domain/fitting/fit_result.hpp" +#include "maj0sted/domain/geometry/horizontal_alignment.hpp" +#include "maj0sted/domain/geometry/vertical_alignment.hpp" +#include "maj0sted/domain/value_objects/cartesian_position.hpp" +#include "maj0sted/domain/value_objects/length.hpp" +#include "maj0sted/domain/value_objects/niweleta_id.hpp" +#include "maj0sted/domain/value_objects/straight_id.hpp" + +namespace maj0sted::domain { + +/// Niweleta — jednolity odcinek toru złożony z wielu krzywych w planie i w +/// profilu. +/// +/// Aggregate Root: to granica spójności całego odcinka. Geometria w planie i w +/// profilu opisują ten sam fizyczny tor, więc ich sumaryczne długości muszą być +/// zgodne — pilnuje tego lengths_consistent(). Krzywe dokłada się wyłącznie +/// przez metody agregatu, który utrzymuje ich kolejność. +class Niweleta { +public: + explicit Niweleta(NiweletaId id, std::string name = {}); + + [[nodiscard]] NiweletaId id() const noexcept { return id_; } + [[nodiscard]] const std::string& name() const noexcept { return name_; } + + /// Dodaje prostą na końcu planu, nadając jej automatycznie StraightId, + /// który zwraca (do późniejszych referencji: równoległość, fitowanie). + StraightId add_straight(CartesianPosition start, CartesianPosition end, + JointContinuity joint = JointContinuity::Tangent); + + /// Dokłada krzywą na końcu geometrii w planie, łącząc ją z poprzednim + /// elementem zadanym stykiem (domyślnie stycznym). Jeśli elementem jest + /// prosta bez tożsamości, niweleta nadaje jej StraightId. + void add_plan_element(PlanElement element, + JointContinuity joint = JointContinuity::Tangent); + + /// Wpina wynik fitowania w plan: podmienia dwie sąsiednie proste (entry, + /// exit z @p result) na entry(przyciętą) → curve... → exit(przyciętą), + /// stykami stycznymi (Tangent). Proste zachowują swoje StraightId. + /// @throws std::invalid_argument jeśli proste nie zostaną znalezione lub nie + /// są sąsiednie (entry bezpośrednio przed exit). + void apply_fit(const FitResult& result); + + /// Dokłada krzywą na końcu geometrii w profilu. + void add_profile_element(ProfileElement element); + + [[nodiscard]] const HorizontalAlignment& plan() const noexcept { return plan_; } + [[nodiscard]] const VerticalAlignment& profile() const noexcept { return profile_; } + + [[nodiscard]] Length plan_length() const { return plan_.total_length(); } + [[nodiscard]] Length profile_length() const { return profile_.total_length(); } + + /// Czy plan i profil opisują tor tej samej długości (z zadaną tolerancją)? + [[nodiscard]] bool lengths_consistent(double tolerance_m = 1e-6) const; + +private: + StraightId next_straight_id() noexcept; + + NiweletaId id_; + std::string name_; + HorizontalAlignment plan_; + VerticalAlignment profile_; + std::uint64_t straight_seq_{0}; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp b/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp new file mode 100644 index 00000000..4a308e09 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "maj0sted/domain/geometry/plan_element.hpp" +#include "maj0sted/domain/parallelism/track_parallelism.hpp" +#include "maj0sted/domain/value_objects/track_offset.hpp" + +namespace maj0sted::domain { + +/// Domain Service: creates and reasons about parallelism between straight +/// segments. +class ParallelismService { +public: + /// Marks @p origin as the origin of a parallel relation with @p parallel, + /// separated by @p offset. Both straights must be identified. + /// @throws std::invalid_argument if a straight has no id or the two are the + /// same straight. + static TrackParallelism mark_parallel(const Straight& origin, + const Straight& parallel, + TrackOffset offset); + + /// Are two straights parallel as lines (same or opposite azimuth)? + static bool are_parallel(const Straight& a, const Straight& b, + double tolerance_rad = 1e-9); + + /// The line the parallel straight lies on: @p origin shifted sideways by + /// @p offset, perpendicular to its direction of travel. Same length and + /// azimuth as the origin; the returned straight is anonymous. + static Straight derive_parallel_line(const Straight& origin, TrackOffset offset); + + /// Perpendicular distance (>= 0) from @p other's start point to the + /// (infinite) line carrying @p origin. + static double perpendicular_distance(const Straight& origin, const Straight& other); +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp b/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp new file mode 100644 index 00000000..9a62c0e0 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include "maj0sted/domain/value_objects/straight_id.hpp" +#include "maj0sted/domain/value_objects/track_offset.hpp" + +namespace maj0sted::domain { + +/// A reference-based relation between two straight segments, identified by their +/// StraightId. @c origin is the authoritative reference track; @c parallel runs +/// alongside it at @c offset (a perpendicular distance on a given side). +/// +/// Invariants: both straights must be identified (non-null id), and a straight +/// cannot be parallel to itself. +class TrackParallelism { +public: + TrackParallelism(StraightId origin, StraightId parallel, TrackOffset offset) + : origin_{origin}, parallel_{parallel}, offset_{offset} { + if (origin.is_null() || parallel.is_null()) { + throw std::invalid_argument{"Parallelism requires identified straights"}; + } + if (origin == parallel) { + throw std::invalid_argument{"A straight cannot be parallel to itself"}; + } + } + + [[nodiscard]] StraightId origin() const noexcept { return origin_; } + [[nodiscard]] StraightId parallel() const noexcept { return parallel_; } + [[nodiscard]] TrackOffset offset() const noexcept { return offset_; } + +private: + StraightId origin_; + StraightId parallel_; + TrackOffset offset_; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp b/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp new file mode 100644 index 00000000..8c7c73a8 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include + +#include "maj0sted/domain/value_objects/cartesian_position.hpp" + +namespace maj0sted::domain { + +/// Value Object: a horizontal azimuth — the direction of travel in the plan, +/// measured clockwise from north (the +y / northing axis) and normalised to +/// [0, 2*pi) radians. +class Azimuth { +public: + static Azimuth from_radians(double radians) noexcept { + return Azimuth{normalise(radians)}; + } + + static Azimuth from_degrees(double degrees) noexcept { + return from_radians(degrees * std::numbers::pi / 180.0); + } + + /// Azimuth of the direction from @p from to @p to. + /// @throws std::invalid_argument if the two points coincide. + static Azimuth of_direction(CartesianPosition from, CartesianPosition to) { + const double d_east = to.x() - from.x(); + const double d_north = to.y() - from.y(); + if (d_east == 0.0 && d_north == 0.0) { + throw std::invalid_argument{"Azimuth is undefined for coincident points"}; + } + return from_radians(std::atan2(d_east, d_north)); + } + + [[nodiscard]] double radians() const noexcept { return radians_; } + [[nodiscard]] double degrees() const noexcept { + return radians_ * 180.0 / std::numbers::pi; + } + + /// Smallest absolute angular difference to @p other, in [0, pi]. + [[nodiscard]] double angular_distance(Azimuth other) const noexcept { + double difference = std::fabs(radians_ - other.radians_); + if (difference > std::numbers::pi) { + difference = 2.0 * std::numbers::pi - difference; + } + return difference; + } + + friend auto operator<=>(const Azimuth&, const Azimuth&) noexcept = default; + +private: + explicit Azimuth(double radians) noexcept : radians_{radians} {} + + static double normalise(double radians) noexcept { + const double two_pi = 2.0 * std::numbers::pi; + double result = std::fmod(radians, two_pi); + if (result < 0.0) { + result += two_pi; + } + return result; + } + + double radians_; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp b/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp new file mode 100644 index 00000000..60dbe82d --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace maj0sted::domain { + +/// Value Object: a position in a Cartesian coordinate reference system. +/// +/// Coordinates are expressed in the units of the project's CRS. For the default +/// EPSG:2180 these are metres, with @c x as easting and @c y as northing. +class CartesianPosition { +public: + constexpr CartesianPosition() noexcept = default; + constexpr CartesianPosition(double x, double y) noexcept : x_{x}, y_{y} {} + + [[nodiscard]] constexpr double x() const noexcept { return x_; } + [[nodiscard]] constexpr double y() const noexcept { return y_; } + + friend constexpr bool operator==(const CartesianPosition&, + const CartesianPosition&) noexcept = default; + +private: + double x_{0.0}; + double y_{0.0}; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/crs.hpp b/maj0sted/include/maj0sted/domain/value_objects/crs.hpp new file mode 100644 index 00000000..6ab6775a --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/crs.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace maj0sted::domain { + +/// Value Object: a Coordinate Reference System, identified by its EPSG code. +/// +/// The default for a map project is EPSG:2180 (PUWG 1992 / Poland CS92), a +/// Cartesian, metre-based projected system. +class Crs { +public: + // EPSG code of the default map-project CRS. + static constexpr int kDefaultEpsg = 2180; + + explicit constexpr Crs(int epsg) noexcept : epsg_{epsg} {} + + /// The default CRS: EPSG:2180. + static constexpr Crs default_crs() noexcept { return Crs{kDefaultEpsg}; } + + [[nodiscard]] constexpr int epsg() const noexcept { return epsg_; } + + friend constexpr auto operator<=>(const Crs&, const Crs&) noexcept = default; + +private: + int epsg_; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/length.hpp b/maj0sted/include/maj0sted/domain/value_objects/length.hpp new file mode 100644 index 00000000..a86c10b5 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/length.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +namespace maj0sted::domain { + +/// Value Object: a non-negative length, in metres (the unit of the default +/// EPSG:2180 CRS). Lengths add and compare by value. +class Length { +public: + constexpr Length() noexcept = default; + + static constexpr Length from_metres(double metres) { + if (metres < 0.0) { + throw std::invalid_argument{"Length cannot be negative"}; + } + return Length{metres}; + } + + static constexpr Length zero() noexcept { return Length{}; } + + [[nodiscard]] constexpr double metres() const noexcept { return metres_; } + + [[nodiscard]] constexpr Length operator+(Length other) const noexcept { + return Length{metres_ + other.metres_}; + } + + friend constexpr auto operator<=>(const Length&, const Length&) noexcept = default; + +private: + explicit constexpr Length(double metres) noexcept : metres_{metres} {} + + double metres_{0.0}; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp b/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp new file mode 100644 index 00000000..067367df --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include + +namespace maj0sted::domain { + +/// Value Object: identity of a Niweleta aggregate. Cross-aggregate references +/// (e.g. parallelism) point at niwelety by this id, never by pointer. Value 0 +/// denotes "no identity". +class NiweletaId { +public: + using value_type = std::uint64_t; + + constexpr NiweletaId() noexcept = default; + explicit constexpr NiweletaId(value_type value) noexcept : value_{value} {} + + [[nodiscard]] constexpr value_type value() const noexcept { return value_; } + [[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; } + + friend constexpr auto operator<=>(const NiweletaId&, const NiweletaId&) noexcept = default; + +private: + value_type value_{0}; +}; + +} // namespace maj0sted::domain + +template <> +struct std::hash { + std::size_t operator()(const maj0sted::domain::NiweletaId& id) const noexcept { + return std::hash{}(id.value()); + } +}; diff --git a/maj0sted/include/maj0sted/domain/value_objects/radius.hpp b/maj0sted/include/maj0sted/domain/value_objects/radius.hpp new file mode 100644 index 00000000..e00119fe --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/radius.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace maj0sted::domain { + +/// Value Object: a radius of curvature, in metres. Always strictly positive — +/// the turn direction (plan) or curve sense (profile) is carried separately by +/// the geometric element, so the radius itself is just a magnitude. +class Radius { +public: + static constexpr Radius from_metres(double metres) { + if (!(metres > 0.0)) { + throw std::invalid_argument{"Radius must be positive"}; + } + return Radius{metres}; + } + + [[nodiscard]] constexpr double metres() const noexcept { return metres_; } + + friend constexpr auto operator<=>(const Radius&, const Radius&) noexcept = default; + +private: + explicit constexpr Radius(double metres) noexcept : metres_{metres} {} + + double metres_; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp b/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp new file mode 100644 index 00000000..d61a6724 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include + +namespace maj0sted::domain { + +/// Value Object: identity of a straight segment. A straight that takes part in +/// references (e.g. parallelism) carries a non-null id; anonymous straights used +/// purely as geometry may leave it null. Value 0 denotes "no identity". +class StraightId { +public: + using value_type = std::uint64_t; + + constexpr StraightId() noexcept = default; + explicit constexpr StraightId(value_type value) noexcept : value_{value} {} + + [[nodiscard]] constexpr value_type value() const noexcept { return value_; } + [[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; } + + friend constexpr auto operator<=>(const StraightId&, const StraightId&) noexcept = default; + +private: + value_type value_{0}; +}; + +} // namespace maj0sted::domain + +template <> +struct std::hash { + std::size_t operator()(const maj0sted::domain::StraightId& id) const noexcept { + return std::hash{}(id.value()); + } +}; diff --git a/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp b/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp new file mode 100644 index 00000000..c4e5d69c --- /dev/null +++ b/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "maj0sted/domain/value_objects/length.hpp" + +namespace maj0sted::domain { + +/// Which side a parallel track runs on, relative to the origin straight's +/// direction of travel: left ("po lewej") or right ("po prawej"). +enum class Side { Left, Right }; + +/// Value Object: the sideways offset of a parallel track from its origin — a +/// strictly positive perpendicular distance plus the side it lies on. +class TrackOffset { +public: + TrackOffset(Length distance, Side side) : distance_{distance}, side_{side} { + if (!(distance.metres() > 0.0)) { + throw std::invalid_argument{"Track offset distance must be positive"}; + } + } + + [[nodiscard]] Length distance() const noexcept { return distance_; } + [[nodiscard]] Side side() const noexcept { return side_; } + +private: + Length distance_; + Side side_; +}; + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/io/project_serializer.hpp b/maj0sted/include/maj0sted/io/project_serializer.hpp new file mode 100644 index 00000000..e43c8873 --- /dev/null +++ b/maj0sted/include/maj0sted/io/project_serializer.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include "maj0sted/domain/map_project.hpp" + +namespace maj0sted::io { + +/// Serializes a whole map project (CRS + niwelety, each with its plan and +/// profile) to a self-contained textual format. The format is line-oriented and +/// dependency-free; @c deserialize round-trips it exactly. +[[nodiscard]] std::string serialize(const maj0sted::domain::MapProject& project); + +/// Rebuilds a map project from text produced by @c serialize. +/// @throws std::runtime_error on malformed input; std::invalid_argument if the +/// reconstructed geometry violates a domain invariant. +[[nodiscard]] maj0sted::domain::MapProject deserialize(const std::string& text); + +} // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/io/svg_writer.hpp b/maj0sted/include/maj0sted/io/svg_writer.hpp new file mode 100644 index 00000000..fd9e1168 --- /dev/null +++ b/maj0sted/include/maj0sted/io/svg_writer.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include "maj0sted/render/scene.hpp" + +namespace maj0sted::io { + +struct SvgOptions { + double width_px = 900.0; ///< target drawing width + double margin_px = 24.0; ///< padding around the drawing + double stroke_px = 2.0; ///< line width +}; + +/// Renders a scene to a standalone SVG document (north up). Straights, arcs and +/// transition curves are drawn in distinct colours. Dependency-free — just a +/// simple viewer; a real GUI would consume render::Scene directly. +[[nodiscard]] std::string to_svg(const render::Scene& scene, const SvgOptions& options = {}); + +} // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/maj0sted.hpp b/maj0sted/include/maj0sted/maj0sted.hpp new file mode 100644 index 00000000..aeaab42c --- /dev/null +++ b/maj0sted/include/maj0sted/maj0sted.hpp @@ -0,0 +1,39 @@ +#pragma once + +// maj0sted — a Domain-Driven Design library for a railway track-layout editor +// ("edytor torowiska"). Include this single header to pull in the whole public +// domain model. + +#include "maj0sted/version.hpp" + +// Value Objects +#include "maj0sted/domain/value_objects/azimuth.hpp" +#include "maj0sted/domain/value_objects/cartesian_position.hpp" +#include "maj0sted/domain/value_objects/crs.hpp" +#include "maj0sted/domain/value_objects/length.hpp" +#include "maj0sted/domain/value_objects/niweleta_id.hpp" +#include "maj0sted/domain/value_objects/radius.hpp" +#include "maj0sted/domain/value_objects/straight_id.hpp" +#include "maj0sted/domain/value_objects/track_offset.hpp" + +// Geometry — krzywe w planie i w profilu +#include "maj0sted/domain/geometry/horizontal_alignment.hpp" +#include "maj0sted/domain/geometry/plan_element.hpp" +#include "maj0sted/domain/geometry/profile_element.hpp" +#include "maj0sted/domain/geometry/vertical_alignment.hpp" + +// Aggregates +#include "maj0sted/domain/map_project.hpp" +#include "maj0sted/domain/niweleta.hpp" + +// Parallelism — równoległość torów +#include "maj0sted/domain/parallelism/parallelism_service.hpp" +#include "maj0sted/domain/parallelism/track_parallelism.hpp" + +// Fitting — dopasowanie krzywej między prostymi +#include "maj0sted/domain/fitting/fit_parameters.hpp" +#include "maj0sted/domain/fitting/fit_result.hpp" +#include "maj0sted/domain/fitting/fitting_service.hpp" + +// IO — serializacja projektu +#include "maj0sted/io/project_serializer.hpp" diff --git a/maj0sted/include/maj0sted/render/plan_sampler.hpp b/maj0sted/include/maj0sted/render/plan_sampler.hpp new file mode 100644 index 00000000..7899806c --- /dev/null +++ b/maj0sted/include/maj0sted/render/plan_sampler.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "maj0sted/domain/geometry/horizontal_alignment.hpp" +#include "maj0sted/domain/map_project.hpp" +#include "maj0sted/render/scene.hpp" + +namespace maj0sted::render { + +/// Samples a plan into absolute polylines in the CRS. Straights are +/// authoritative for position; curves (arcs, clothoids) are laid out continuing +/// from the previous element's end and heading. This is the reusable bridge +/// between the domain model and any renderer. +[[nodiscard]] Scene sample(const maj0sted::domain::HorizontalAlignment& plan); + +/// Samples the plans of every niweleta in the project into one scene. +[[nodiscard]] Scene sample(const maj0sted::domain::MapProject& project); + +} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/render/rail_renderer.hpp b/maj0sted/include/maj0sted/render/rail_renderer.hpp new file mode 100644 index 00000000..e43f6d30 --- /dev/null +++ b/maj0sted/include/maj0sted/render/rail_renderer.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include + +#include "maj0sted/render/track_renderer.hpp" + +namespace maj0sted::render { + +/// A drawable polyline produced by RailRenderer: one rail, carrying the logical +/// metadata of the centreline element it came from so the host can colour and +/// hit-test it. +struct RailPolyline { + ElementKind kind{ElementKind::Straight}; + std::vector points; + int straight_index{-1}; + int gap{-1}; + int element_index{-1}; + double length{0.0}; + double radius_start{0.0}; + double radius_end{0.0}; +}; + +/// Renders each centreline as two parallel rails a fixed gauge apart — the track +/// is drawn as its rails, not its axis. A concrete TrackRenderer; substituting a +/// different one (single-axis, SVG, ...) needs no change to the solver. +class RailRenderer : public TrackRenderer { +public: + /// @param gauge_metres distance between the two rails (default 1.5 m). + explicit RailRenderer(double gauge_metres = 1.5) noexcept; + + void add(const CentrelineElement& element) override; + + [[nodiscard]] const std::vector& rails() const noexcept { + return rails_; + } + +private: + double half_gauge_; + std::vector rails_; +}; + +} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/render/scene.hpp b/maj0sted/include/maj0sted/render/scene.hpp new file mode 100644 index 00000000..843e07a8 --- /dev/null +++ b/maj0sted/include/maj0sted/render/scene.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace maj0sted::render { + +/// A point in the project's CRS (metres for EPSG:2180). +struct Point { + double x; + double y; +}; + +/// Which kind of plan element a polyline came from (lets a GUI colour it). +enum class ElementKind { Straight, Arc, Transition }; + +/// A polyline approximating one plan element, in CRS coordinates. +struct Polyline { + ElementKind kind; + std::vector points; +}; + +/// A rendering-agnostic view of one or more plans: polylines plus a bounding box. +/// Any GUI can draw this — the library computes the geometry, the GUI only paints. +struct Scene { + std::vector polylines; + double min_x{0.0}; + double min_y{0.0}; + double max_x{0.0}; + double max_y{0.0}; + bool empty{true}; +}; + +} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/render/track_renderer.hpp b/maj0sted/include/maj0sted/render/track_renderer.hpp new file mode 100644 index 00000000..14210973 --- /dev/null +++ b/maj0sted/include/maj0sted/render/track_renderer.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include "maj0sted/render/scene.hpp" // Point, ElementKind + +namespace maj0sted::render { + +/// One sampled centreline element of a solved niweleta, tagged with its logical +/// origin so a UI can colour, label and hit-test it. Rendering-neutral: it holds +/// the track axis only — no rails, no styling. This is the contract the solver +/// hands to a renderer. +struct CentrelineElement { + ElementKind kind{ElementKind::Straight}; + std::vector points; ///< sampled axis in CRS metres + int straight_index{-1}; ///< owning straight, or -1 for a curve element + int gap{-1}; ///< owning gap, or -1 for a straight + int element_index{-1}; ///< position within a gap's curve chain + double length{0.0}; ///< element length (metres) + double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end) + double radius_end{0.0}; +}; + +/// Interface every renderer must satisfy. +/// +/// The solver emits rendering-neutral CentrelineElements; a renderer turns each +/// into concrete drawable geometry (a single axis line, two rails, an SVG path, +/// ...). A renderer holds NO business logic — it only shapes presentation, so +/// swapping renderers never changes what geometry the solver computed. +class TrackRenderer { +public: + virtual ~TrackRenderer() = default; + + /// Called once per centreline element, in plan order. + virtual void add(const CentrelineElement& element) = 0; +}; + +} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/version.hpp b/maj0sted/include/maj0sted/version.hpp new file mode 100644 index 00000000..4f280447 --- /dev/null +++ b/maj0sted/include/maj0sted/version.hpp @@ -0,0 +1,7 @@ +#pragma once + +// Version of the maj0sted library. Keep in sync with project() in CMakeLists.txt. +#define MAJ0STED_VERSION_MAJOR 0 +#define MAJ0STED_VERSION_MINOR 1 +#define MAJ0STED_VERSION_PATCH 0 +#define MAJ0STED_VERSION_STRING "0.1.0" diff --git a/maj0sted/include/maj0sted/web/editor.hpp b/maj0sted/include/maj0sted/web/editor.hpp new file mode 100644 index 00000000..84b092d5 --- /dev/null +++ b/maj0sted/include/maj0sted/web/editor.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline + +namespace maj0sted::web { + +/// An anchored straight segment (two XY endpoints). +struct StraightSpec { + double x1{0.0}, y1{0.0}, x2{0.0}, y2{0.0}; + /// Internal tangent guide used to solve a terminal curve. It participates + /// in fitting but is not rendered as track or exposed as an editor handle. + bool hidden{false}; +}; + +/// One arc of a compound fit: its radius, its fixed length (ignored for the last +/// arc, which absorbs the remaining deflection) and an optional clothoid to the +/// next arc. +struct CompoundArcSpec { + double radius{0.0}; + double length{0.0}; + double transition_to_next{0.0}; +}; + +/// A fit in the gap between straight @c gap and straight @c gap + 1. +struct GapFit { + int gap{0}; + int mode{0}; ///< 0 none, 1 arc, 2 legacy arc+clothoids, 3 compound, 4 U-turn + + double radius{0.0}; ///< plain arc (mode 1) + double transition{0.0}; ///< legacy mode-2 symmetric clothoid + + // Unified compound (mode 3): end clothoids plus one or more arcs. + double entry_t{0.0}, exit_t{0.0}; + std::vector arcs{}; + + // Legacy fixed two-arc fields, kept so old saved projects still deserialize; + // superseded by @c arcs. Used only when @c arcs is empty. + double r1{0.0}, arc1_len{0.0}, between{0.0}, r2{0.0}; +}; + +/// A niweleta as edited in the GUI: independent anchored straights plus fits +/// connecting consecutive straights. Because the straights are independent, a +/// gap can be a normal corner (straights meet at an intersection) OR a 180° +/// reversal (parallel, offset straights) — no 180° limitation. +struct NiweletaSpec { + std::string name; + std::vector straights; + std::vector fits; +}; + +struct NiweletaPolys { + std::vector polylines; + /// Centreline segments after adjacent fits trim the input straights. Kept + /// index-aligned with NiweletaSpec::straights for editor hit handles. + std::vector rendered_straights; + /// The authoritative fit set after solving: exactly the requested fits that + /// actually succeeded. A requested fit that could not be produced (bad + /// parameters or geometry) is simply absent here — the library, not the host, + /// decides that a failed fit becomes "no fit". The host mirrors this list. + std::vector applied_fits; +}; + +/// Builds each niweleta, fits every requested gap, and returns the sampled plan +/// per niweleta. Never throws: gaps whose parameters don't admit a fit are left +/// unconnected (the two straights simply stay as drawn). +[[nodiscard]] std::vector solve_project( + const std::vector& niwelety); + +} // namespace maj0sted::web diff --git a/maj0sted/include/maj0sted/web/ribbon.hpp b/maj0sted/include/maj0sted/web/ribbon.hpp new file mode 100644 index 00000000..85aa793e --- /dev/null +++ b/maj0sted/include/maj0sted/web/ribbon.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline + +namespace maj0sted::web { + +/// One element of an alignment "ribbon" (element chain), defined intrinsically. +struct RibbonElement { + int kind{0}; ///< 0 = line, 1 = arc, 2 = spiral (clothoid) + double length{0.0}; ///< line / spiral length (arc length is derived) + double radius{0.0}; ///< arc radius; spiral end radius (0 = straight / infinite) + double angle{0.0}; ///< arc central angle (radians) + double radius_start{0.0}; ///< spiral start radius (0 = infinite) + int dir{1}; ///< +1 left, -1 right (arc / spiral) +}; + +/// A whole alignment defined as a start pose plus an ordered element chain, +/// laid out head-to-tail. Handles any arc angle (including 180°+, loops) because +/// nothing depends on tangent intersection. +struct RibbonRequest { + double x0{0.0}; + double y0{0.0}; + double az0{0.0}; ///< start azimuth (radians, clockwise from north) + std::vector elements; +}; + +/// Lays the ribbon out and returns one polyline per element, ready to draw. +[[nodiscard]] std::vector solve_ribbon(const RibbonRequest& request); + +} // namespace maj0sted::web diff --git a/maj0sted/include/maj0sted/web/solve.hpp b/maj0sted/include/maj0sted/web/solve.hpp new file mode 100644 index 00000000..cd4dd8b1 --- /dev/null +++ b/maj0sted/include/maj0sted/web/solve.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include + +namespace maj0sted::web { + +/// A request from the GUI: three draggable vertices (entry straight A->B, exit +/// straight B->C) plus the fit parameters. Plain doubles so it maps trivially +/// onto a JS object / WASM boundary. This layer is pure C++ — the emscripten +/// bindings are a thin wrapper around it, and it is unit-tested natively. +struct SolveRequest { + double ax{0.0}, ay{0.0}; ///< vertex A (entry start) + double bx{0.0}, by{0.0}; ///< vertex B (corner: entry end = exit start) + double cx{0.0}, cy{0.0}; ///< vertex C (exit end) + + int mode{0}; ///< 0 = arc, 1 = arc + symmetric transitions, 2 = compound (łuk koszowy) + + double radius{0.0}; ///< modes 0/1: arc radius + double transition{0.0}; ///< mode 1: symmetric transition-curve length + + // Mode 2 (compound): + double r1{0.0}; ///< first arc radius + double arc1_len{0.0}; ///< first arc length (split place) + double between{0.0}; ///< transition length between the two arcs + double r2{0.0}; ///< second arc radius + double entry_t{0.0}; ///< entry transition length + double exit_t{0.0}; ///< exit transition length +}; + +/// A point in the CRS. +struct WebPoint { + double x{0.0}; + double y{0.0}; +}; + +/// One polyline; @c kind is render::ElementKind (0 straight, 1 arc, 2 transition). +struct WebPolyline { + int kind{0}; + /// Logical owner used by map hit-testing. Exactly one is non-negative. + int straight_index{-1}; + int gap{-1}; + /// Position inside a fitted curve chain (arc/clothoid/arc...). + int element_index{-1}; + double length{0.0}; + /// Zero denotes an infinite radius (straight end of a transition). + double radius_start{0.0}; + double radius_end{0.0}; + std::vector points; +}; + +/// Builds the niweleta, runs the requested fit (falling back to the bare +/// straights if the parameters do not admit a fit) and returns the sampled plan +/// as polylines ready to draw. +[[nodiscard]] std::vector solve_scene(const SolveRequest& request); + +} // namespace maj0sted::web diff --git a/maj0sted/include/maj0sted/web/tile_cache.hpp b/maj0sted/include/maj0sted/web/tile_cache.hpp new file mode 100644 index 00000000..dfc4d163 --- /dev/null +++ b/maj0sted/include/maj0sted/web/tile_cache.hpp @@ -0,0 +1,186 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace maj0sted::web { + +/// A stable tile in the EPSG:2180 tiling pyramid. Tiles are addressed by their +/// zoom level and integer column/row, so the same ground square always maps to +/// the same key regardless of the current viewport — that is what makes the +/// cache reusable across pans and zooms. +struct TileKey { + int zoom{0}; + int x{0}; ///< column (increases with easting) + int y{0}; ///< row (increases with northing) + + friend bool operator==(const TileKey&, const TileKey&) = default; +}; + +struct TileKeyHash { + std::size_t operator()(const TileKey& k) const noexcept { + // Mix the three small integers into one hash. + std::uint64_t h = static_cast(static_cast(k.x)); + h = h * 1099511628211ULL ^ + static_cast(static_cast(k.y)); + h = h * 1099511628211ULL ^ static_cast(k.zoom); + return static_cast(h); + } +}; + +/// A bounding box in EPSG:2180 metres (easting = x, northing = y). +struct TileBBox { + double min_x{0.0}; + double min_y{0.0}; + double max_x{0.0}; + double max_y{0.0}; +}; + +/// The tiling scheme. A pure, static value type defining a single fixed square +/// grid of 100 m × 100 m ground cells over the EPSG:2180 plane — the same grid +/// at every zoom, *not* a per-zoom pyramid. Each cell has a deterministic ground +/// bounding box, so it is cached once and reused across all pans and zooms. +/// Nothing here touches the network — it is trivially unit testable natively. +/// +/// The zoom parameters on the methods below are ignored; they are kept only so +/// the host boundary (bindings/JS) stays unchanged. +class TileGrid { +public: + static constexpr int kTilePixels = 1024; ///< pixels per cell edge (GetMap WIDTH/HEIGHT) + static constexpr double kOriginX = 0.0; ///< easting of cell (0,0)'s left edge + static constexpr double kOriginY = 0.0; ///< northing of cell (0,0)'s bottom edge + /// Fixed cache cell size: every tile is a 100 m × 100 m ground square. + static constexpr double kCellMetres = 100.0; + /// The single grid level stored in every TileKey (there is only one grid). + static constexpr int kLevel = 0; + + /// Ground resolution (metres per pixel) of a cell. Constant (zoom ignored). + [[nodiscard]] static double resolution(int zoom = kLevel) noexcept; + + /// Edge length of a cell in metres — always kCellMetres (zoom ignored). + [[nodiscard]] static double tile_span(int zoom = kLevel) noexcept; + + /// The 100 m cell whose ground square contains (@p x, @p y). Zoom ignored. + [[nodiscard]] static TileKey tile_at(double x, double y, int zoom = kLevel) noexcept; + + /// The ground bounding box (EPSG:2180) of @p key. + [[nodiscard]] static TileBBox bbox(const TileKey& key) noexcept; + + /// The grid level for a target resolution. Always kLevel — the grid has a + /// single fixed 100 m level. Kept for boundary compatibility. + [[nodiscard]] static int zoom_for_resolution(double metres_per_pixel) noexcept; + + /// Every 100 m cell whose ground square intersects @p view, capped at + /// @p max_tiles to guard against absurd requests. Zoom ignored. + [[nodiscard]] static std::vector tiles_for_view(const TileBBox& view, + int zoom = kLevel, + int max_tiles = 64); +}; + +/// Configuration for a WMS GetMap source. Defaults target a public EPSG:2180 +/// endpoint (Polish Geoportal). Every field is data, so a different backend is a +/// pure configuration change with no code edits. +struct WmsConfig { + std::string base_url; + std::string layers; + std::string format{"image/jpeg"}; + std::string version{"1.1.1"}; ///< 1.1.1 keeps BBOX as minx,miny,maxx,maxy + std::string srs{"EPSG:2180"}; + int tile_pixels{TileGrid::kTilePixels}; + + /// Geoportal ortophoto (ortofotomapa), EPSG:2180, JPEG. + [[nodiscard]] static WmsConfig geoportal_ortho(); + + /// Geoportal topographic base map (BDOT/raster), EPSG:2180, PNG. + [[nodiscard]] static WmsConfig geoportal_topo(); +}; + +/// Builds the full WMS 1.1.1 GetMap URL for @p box using @p cfg. Numbers are +/// formatted locale-independently (std::to_chars), matching the rest of the +/// library's serialization. +[[nodiscard]] std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box); + +enum class TileStatus { Missing, Loading, Ready, Failed }; + +/// A cached tile image: raw encoded bytes (JPEG/PNG) plus their MIME type. +struct TileImage { + TileStatus status{TileStatus::Missing}; + std::vector bytes; + std::string content_type; + int attempts{0}; ///< failed fetch attempts so far (see TileService retry cap) +}; + +/// Owns the tile grid, the tile cache and the WMS fetch. This is the single +/// place that decides which tile is needed, whether it is already cached, and — +/// on a miss — issues the network GetMap request itself. Under Emscripten the +/// fetch uses the Emscripten Fetch API; natively (and in tests) a fetcher can be +/// injected, so the tiling/caching logic is exercised without a real network. +class TileService { +public: + /// Called when a fetch finishes: (success, bytes, content_type). + using DoneFn = + std::function, std::string)>; + /// Injectable network backend: given a URL, eventually invoke DoneFn. + using Fetcher = std::function; + + explicit TileService(WmsConfig cfg = WmsConfig::geoportal_ortho()); + + [[nodiscard]] const WmsConfig& config() const noexcept { return cfg_; } + void set_config(WmsConfig cfg) { cfg_ = std::move(cfg); } + + /// Overrides the network backend. When set it is used on both native and + /// Emscripten builds; when unset, Emscripten builds fall back to + /// emscripten_fetch and native builds mark the tile Failed. + void set_fetcher(Fetcher fetcher) { fetcher_ = std::move(fetcher); } + + /// Notified (with the tile's key) whenever a tile transitions to Ready or + /// Failed. Lets the renderer schedule a redraw when an async tile arrives. + void set_on_ready(std::function cb) { on_ready_ = std::move(cb); } + + /// Ensures @p key is cached or being fetched, and returns its current status. + /// A cache hit returns Ready immediately and issues no network request. + TileStatus request(const TileKey& key); + + /// The cached image for @p key, or nullptr if it is not Ready. + [[nodiscard]] const TileImage* peek(const TileKey& key) const; + + /// Stores bytes for @p key directly, marking it Ready. Exposed so a caller + /// (or a test) can seed the cache without going through the fetcher. + void store(const TileKey& key, std::vector bytes, + std::string content_type = {}); + + [[nodiscard]] bool has(const TileKey& key) const; + [[nodiscard]] std::size_t size() const noexcept { return cache_.size(); } + void clear() { cache_.clear(); } + + /// The GetMap URL this service would use for @p key. + [[nodiscard]] std::string url_for(const TileKey& key) const; + + /// Finalises an in-flight fetch: on success stores the bytes (Ready), on + /// failure marks the tile Failed, then fires the on_ready callback. Public so + /// the Emscripten fetch glue (which needs free C callbacks) can delegate here. + void complete_fetch(const TileKey& key, bool ok, std::vector bytes, + std::string content_type); + + /// Re-issues the fetch for @p key. Public so the Emscripten async-retry glue + /// (a free C callback) can delegate here. + void retry_now(const TileKey& key); + +private: + void begin_fetch(const TileKey& key); + /// Re-issues a fetch for @p key after a failure — the service retries up to + /// kMaxRetries times, then marks the tile Failed. Under Emscripten the retry + /// is deferred asynchronously (after a short delay) so it never blocks or + /// recurses; with an injected fetcher it re-issues immediately. + void schedule_retry(const TileKey& key); + + WmsConfig cfg_; + std::unordered_map cache_; + Fetcher fetcher_; + std::function on_ready_; +}; + +} // namespace maj0sted::web diff --git a/maj0sted/src/app/editor_document.cpp b/maj0sted/src/app/editor_document.cpp new file mode 100644 index 00000000..9a500855 --- /dev/null +++ b/maj0sted/src/app/editor_document.cpp @@ -0,0 +1,323 @@ +#include "maj0sted/app/editor_document.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "maj0sted/io/project_serializer.hpp" +#include "maj0sted/maj0sted.hpp" + +namespace maj0sted::app { + +using namespace maj0sted::domain; +using maj0sted::web::GapFit; +using maj0sted::web::NiweletaSpec; +using maj0sted::web::StraightSpec; + +namespace { + +// Shortest round-trippable, locale-independent double — matches maj0sted::io. +std::string num(double value) { + char buffer[64]; + const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value); + return std::string(buffer, result.ptr); +} + +std::vector tokenize(const std::string& line) { + std::vector tokens; + std::size_t i = 0; + const std::size_t n = line.size(); + while (i < n) { + while (i < n && std::isspace(static_cast(line[i]))) ++i; + std::size_t j = i; + while (j < n && !std::isspace(static_cast(line[j]))) ++j; + if (j > i) tokens.push_back(line.substr(i, j - i)); + i = j; + } + return tokens; +} + +double to_double(const std::string& text) { + double value{}; + const auto result = std::from_chars(text.data(), text.data() + text.size(), value); + if (result.ec != std::errc{}) { + throw std::runtime_error{"maj0sted: bad number '" + text + "'"}; + } + return value; +} + +long to_long(const std::string& text) { + long value{}; + const auto result = std::from_chars(text.data(), text.data() + text.size(), value); + if (result.ec != std::errc{}) { + throw std::runtime_error{"maj0sted: bad integer '" + text + "'"}; + } + return value; +} + +// Placeholder turn direction for the resolved-domain view: derived from the two +// straights' headings (sign of the cross product). +TurnDirection gap_direction(const StraightSpec& a, const StraightSpec& b) noexcept { + const double ax = a.x2 - a.x1, ay = a.y2 - a.y1; + const double bx = b.x2 - b.x1, by = b.y2 - b.y1; + return (ax * by - ay * bx) >= 0.0 ? TurnDirection::Left : TurnDirection::Right; +} + +double gap_radius(const GapFit* fit) noexcept { + if (fit != nullptr) { + if (fit->radius > 0.0) return fit->radius; + if (!fit->arcs.empty() && fit->arcs.front().radius > 0.0) { + return fit->arcs.front().radius; + } + if (fit->r1 > 0.0) return fit->r1; + } + return 300.0; // benign placeholder; arcs carry no length in this view +} + +} // namespace + +MapProject to_map_project(const EditorDocument& document) { + MapProject project{Crs::default_crs()}; + + for (std::size_t index = 0; index < document.niwelety.size(); ++index) { + const NiweletaSpec& spec = document.niwelety[index]; + try { + Niweleta niweleta{NiweletaId{static_cast(index + 1)}, + spec.name}; + for (std::size_t i = 0; i < spec.straights.size(); ++i) { + const StraightSpec& s = spec.straights[i]; + if (i > 0) { + // Keep two anchored straights from being adjacent (which the + // alignment forbids) by threading a floating placeholder arc. + const GapFit* fit = nullptr; + for (const auto& f : spec.fits) { + if (static_cast(f.gap) == i - 1 && f.mode > 0) { + fit = &f; + break; + } + } + niweleta.add_plan_element( + CircularArc{Radius::from_metres(gap_radius(fit)), + gap_direction(spec.straights[i - 1], s)}); + } + niweleta.add_plan_element( + Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}}); + } + project.add_niweleta(std::move(niweleta)); + } catch (...) { + // Degenerate/inconsistent niweleta: keep the (named) shell so the + // project still lists it; the editor section restores the details. + project.add_niweleta(Niweleta{ + NiweletaId{static_cast(index + 1)}, spec.name}); + } + } + return project; +} + +EditorDocument from_map_project(const MapProject& project) { + EditorDocument document; + for (const auto& niweleta : project.niwelety()) { + NiweletaSpec spec; + spec.name = niweleta.name(); + for (const auto& element : niweleta.plan().elements()) { + if (const auto* straight = std::get_if(&element)) { + spec.straights.push_back(StraightSpec{ + straight->start().x(), straight->start().y(), + straight->end().x(), straight->end().y()}); + } + } + document.niwelety.push_back(std::move(spec)); + } + return document; +} + +std::string serialize_document(const EditorDocument& document) { + // Reuse the domain serializer for a real, inspectable maj0sted project... + std::string out = maj0sted::io::serialize(to_map_project(document)); + + // ...then the lossless editor section (straights + fit parameters). + out += "editor " + std::to_string(document.niwelety.size()) + "\n"; + for (const auto& spec : document.niwelety) { + out += "eniw " + std::to_string(spec.straights.size()) + " " + + std::to_string(spec.fits.size()) + "\n"; + out += "ename " + spec.name + "\n"; + for (const auto& s : spec.straights) { + out += "estr " + num(s.x1) + " " + num(s.y1) + " " + num(s.x2) + " " + + num(s.y2) + " " + (s.hidden ? "1" : "0") + "\n"; + } + for (const auto& f : spec.fits) { + // efit2: gap mode radius transition entry_t exit_t [r len trans]* + // The variable arc list supersedes the legacy fixed two-arc 'efit'. + out += "efit2 " + std::to_string(f.gap) + " " + + std::to_string(f.mode) + " " + num(f.radius) + " " + + num(f.transition) + " " + num(f.entry_t) + " " + num(f.exit_t) + + " " + std::to_string(f.arcs.size()); + for (const auto& a : f.arcs) { + out += " " + num(a.radius) + " " + num(a.length) + " " + + num(a.transition_to_next); + } + out += "\n"; + } + } + return out; +} + +EditorDocument deserialize_document(const std::string& text) { + std::vector lines; + { + std::istringstream in(text); + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + lines.push_back(line); + } + } + + // Locate the editor section (the domain block before it is interop-only). + std::size_t cursor = lines.size(); + for (std::size_t i = 0; i < lines.size(); ++i) { + const auto tokens = tokenize(lines[i]); + if (!tokens.empty() && tokens[0] == "editor") { + cursor = i; + break; + } + } + if (cursor >= lines.size()) { + throw std::runtime_error{"maj0sted: missing 'editor' section"}; + } + + const auto next = [&]() -> const std::string& { + while (cursor < lines.size()) { + const std::string& l = lines[cursor++]; + bool only_ws = true; + for (const char c : l) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) return l; + } + throw std::runtime_error{"maj0sted: unexpected end of editor section"}; + }; + + EditorDocument document; + const auto header = tokenize(next()); + if (header.size() < 2 || header[0] != "editor") { + throw std::runtime_error{"maj0sted: expected 'editor '"}; + } + const long niweleta_count = to_long(header[1]); + for (long n = 0; n < niweleta_count; ++n) { + const auto eniw = tokenize(next()); + if (eniw.size() < 3 || eniw[0] != "eniw") { + throw std::runtime_error{"maj0sted: expected 'eniw '"}; + } + const long straight_count = to_long(eniw[1]); + const long fit_count = to_long(eniw[2]); + + NiweletaSpec spec; + const std::string& name_line = next(); + if (name_line.rfind("ename", 0) != 0) { + throw std::runtime_error{"maj0sted: expected 'ename'"}; + } + spec.name = name_line.size() > 6 ? name_line.substr(6) : std::string{}; + + for (long i = 0; i < straight_count; ++i) { + const auto e = tokenize(next()); + if (e.size() < 5 || e[0] != "estr") { + throw std::runtime_error{"maj0sted: bad 'estr' line"}; + } + spec.straights.push_back( + StraightSpec{to_double(e[1]), to_double(e[2]), to_double(e[3]), + to_double(e[4]), e.size() >= 6 && to_long(e[5]) != 0}); + } + for (long i = 0; i < fit_count; ++i) { + const auto e = tokenize(next()); + GapFit f; + if (!e.empty() && e[0] == "efit2") { + // New variable-arc format. + if (e.size() < 8) { + throw std::runtime_error{"maj0sted: bad 'efit2' line"}; + } + f.gap = static_cast(to_long(e[1])); + f.mode = static_cast(to_long(e[2])); + f.radius = to_double(e[3]); + f.transition = to_double(e[4]); + f.entry_t = to_double(e[5]); + f.exit_t = to_double(e[6]); + const long arc_count = to_long(e[7]); + for (long a = 0; a < arc_count; ++a) { + const std::size_t base = 8 + static_cast(a) * 3; + if (base + 2 >= e.size()) { + throw std::runtime_error{"maj0sted: truncated 'efit2' arcs"}; + } + f.arcs.push_back(maj0sted::web::CompoundArcSpec{ + to_double(e[base]), to_double(e[base + 1]), + to_double(e[base + 2])}); + } + } else if (!e.empty() && e[0] == "efit" && e.size() >= 11) { + // Legacy fixed two-arc format; arcs stay empty and to_request + // falls back to the r1/r2 scalar fields. + f.gap = static_cast(to_long(e[1])); + f.mode = static_cast(to_long(e[2])); + f.radius = to_double(e[3]); + f.transition = to_double(e[4]); + f.r1 = to_double(e[5]); + f.arc1_len = to_double(e[6]); + f.between = to_double(e[7]); + f.r2 = to_double(e[8]); + f.entry_t = to_double(e[9]); + f.exit_t = to_double(e[10]); + } else { + throw std::runtime_error{"maj0sted: bad 'efit'/'efit2' line"}; + } + spec.fits.push_back(f); + } + document.niwelety.push_back(std::move(spec)); + } + return document; +} + +std::string default_project_path() { + // Under Emscripten the host mounts IDBFS at /data (see web/index.html), so + // this path is persistent across reloads once FS.syncfs has flushed. + return "/data/maj0sted/project.m0s"; +} + +bool save_project(const EditorDocument& document, const std::string& path) { + try { + const std::filesystem::path file{path}; + if (file.has_parent_path()) { + std::filesystem::create_directories(file.parent_path()); + } + std::ofstream out(file, std::ios::binary | std::ios::trunc); + if (!out) return false; + out << serialize_document(document); + return static_cast(out); + } catch (...) { + return false; + } +} + +std::optional load_project(const std::string& path) { + try { + const std::filesystem::path file{path}; + if (!std::filesystem::exists(file)) return std::nullopt; + std::ifstream in(file, std::ios::binary); + if (!in) return std::nullopt; + std::ostringstream buffer; + buffer << in.rdbuf(); + return deserialize_document(buffer.str()); + } catch (...) { + return std::nullopt; + } +} + +} // namespace maj0sted::app diff --git a/maj0sted/src/domain/fitting_service.cpp b/maj0sted/src/domain/fitting_service.cpp new file mode 100644 index 00000000..d7285bb5 --- /dev/null +++ b/maj0sted/src/domain/fitting_service.cpp @@ -0,0 +1,582 @@ +#include "maj0sted/domain/fitting/fitting_service.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" + +namespace maj0sted::domain { +namespace { + +constexpr double kEpsilon = 1e-9; + +struct Vec { + double east; + double north; +}; + +// Unit direction of travel for an azimuth (east=x, north=y). +Vec direction_of(Azimuth azimuth) noexcept { + return Vec{std::sin(azimuth.radians()), std::cos(azimuth.radians())}; +} + +const Straight& find_straight(const Niweleta& niweleta, StraightId id) { + for (const auto& element : niweleta.plan().elements()) { + if (const auto* straight = std::get_if(&element)) { + if (!straight->id().is_null() && straight->id() == id) { + return *straight; + } + } + } + throw std::invalid_argument{"Straight with the given id not found in the niweleta"}; +} + +// A segment of the chain: signed curvature k0 -> k1 over `length`. +struct Seg { + double k0; + double k1; + double length; +}; + +// Result of closing a chain onto the exit straight: the two tangent points that +// the entry/exit straights are trimmed to. +struct ChainClose { + CartesianPosition tangent_in; + CartesianPosition tangent_out; +}; + +double straight_length(const Straight& straight) noexcept { + return straight.length().metres(); +} + +double parameter_on(const Straight& straight, CartesianPosition point) noexcept { + const Vec direction = direction_of(straight.azimuth()); + return (point.x() - straight.start().x()) * direction.east + + (point.y() - straight.start().y()) * direction.north; +} + +bool valid_tangent_points(const Straight& entry, const Straight& exit, + const ChainClose& close) noexcept { + constexpr double tolerance = 1e-6; + const double entry_parameter = parameter_on(entry, close.tangent_in); + const double exit_parameter = parameter_on(exit, close.tangent_out); + // A zero-length trimmed straight cannot be represented by Straight. + return entry_parameter > tolerance && + entry_parameter <= straight_length(entry) + tolerance && + exit_parameter >= -tolerance && + exit_parameter < straight_length(exit) - tolerance; +} + +// Lays out `segments` head-to-tail from entry.start() heading along the entry +// azimuth, then slides the whole rigid chain along the entry line until its end +// lands on the exit line. Uses the SAME layout as the renderer, so the fitted +// tangent points coincide exactly with the drawn curve. +ChainClose solve_chain(const Straight& entry, const Straight& exit, + const std::vector& segments) { + const Vec d1 = direction_of(entry.azimuth()); + const Vec d2 = direction_of(exit.azimuth()); + + geometry::Pose pose{entry.start().x(), entry.start().y(), d1.east, d1.north}; + for (const auto& s : segments) { + pose = geometry::layout_segment(s.k0, s.k1, s.length, pose, nullptr); + } + + const Vec exit_normal{-d2.north, d2.east}; + const double denominator = d1.east * exit_normal.east + d1.north * exit_normal.north; + if (std::abs(denominator) < kEpsilon) { + throw std::invalid_argument{"Straights are parallel; nothing to fit"}; + } + const double offset = (pose.x - exit.start().x()) * exit_normal.east + + (pose.y - exit.start().y()) * exit_normal.north; + const double shift = -offset / denominator; + + return ChainClose{ + CartesianPosition{entry.start().x() + shift * d1.east, + entry.start().y() + shift * d1.north}, + CartesianPosition{pose.x + shift * d1.east, pose.y + shift * d1.north}}; +} + +} // namespace + +FitResult FittingService::fit_between_straights(const Niweleta& niweleta, + const FitParameters& params) { + if (params.entry.is_null() || params.exit.is_null()) { + throw std::invalid_argument{"Both straights must be identified"}; + } + if (params.entry == params.exit) { + throw std::invalid_argument{"Fitting needs two different straights"}; + } + return fit_between_straights(find_straight(niweleta, params.entry), + find_straight(niweleta, params.exit), params.radius, + params.transition_length); +} + +// --------------------------------------------------------------------------- +// Arc fit (tryb łuku) +// +// Rounds the corner between two supporting lines with a circular arc, optionally +// eased by a symmetric pair of clothoids (spiral–arc–spiral). The straights are +// treated as infinite lines: this routine finds the tangent points on those +// lines and returns the two straights trimmed to them. Whether those tangent +// points fall on the drawn finite segments is a separate, higher-level policy +// (see domain::GapFitter) — not this service's concern. +// +// The geometry is produced by laying the arc chain out with the SAME sampler the +// renderer uses (geometry::layout_segment via solve_chain) and sliding it onto +// the exit line. That guarantees the returned tangent points coincide with the +// drawn curve to the last bit, even for tight radii, so no lateral gap appears. +// --------------------------------------------------------------------------- +FitResult FittingService::fit_between_straights(const Straight& entry, + const Straight& exit, + Radius radius_value, + std::optional transition_value) { + const double radius = radius_value.metres(); // Radius guarantees radius > 0 + double transition = 0.0; + if (transition_value) { + transition = transition_value->metres(); + if (!(transition > 0.0)) { + throw std::invalid_argument{"Transition length must be positive"}; + } + } + + // Signed turn from the entry heading to the exit heading, in (-pi, pi]. Its + // sign is the turn direction; its magnitude is the total deflection the curve + // must absorb. + const Vec d1 = direction_of(entry.azimuth()); + const Vec d2 = direction_of(exit.azimuth()); + const double deflection = std::atan2(d1.east * d2.north - d1.north * d2.east, + d1.east * d2.east + d1.north * d2.north); + const double total_turn = std::abs(deflection); + if (total_turn < kEpsilon || std::abs(total_turn - std::numbers::pi) < kEpsilon) { + throw std::invalid_argument{ + "Straights are parallel, collinear or anti-parallel; no arc fits"}; + } + const TurnDirection direction = + deflection > 0.0 ? TurnDirection::Left : TurnDirection::Right; + const double curvature = (deflection > 0.0 ? 1.0 : -1.0) / radius; + + // A clothoid of length L run into a radius R bends the heading by L / (2R); + // the two symmetric spirals therefore consume L / R of the turn, and the + // circular arc between them takes whatever remains. + const double arc_turn = total_turn - transition / radius; + if (arc_turn < -kEpsilon) { + throw std::invalid_argument{ + "Transition curves are too long for this radius and deflection"}; + } + const double arc_length = radius * std::max(arc_turn, 0.0); + + // Chain the elements head-to-tail — clothoid in / arc / clothoid out — using + // one definition for both the closure solve and the returned plan elements. + std::vector chain; + std::vector curve; + chain.reserve(3); + curve.reserve(3); + if (transition > 0.0) { + chain.push_back({0.0, curvature, transition}); + curve.push_back(TransitionCurve{Length::from_metres(transition), + std::nullopt, radius_value, direction}); + } + chain.push_back({curvature, curvature, arc_length}); + curve.push_back( + CircularArc{radius_value, direction, Length::from_metres(arc_length)}); + if (transition > 0.0) { + chain.push_back({curvature, 0.0, transition}); + curve.push_back(TransitionCurve{Length::from_metres(transition), + radius_value, std::nullopt, direction}); + } + + const ChainClose close = solve_chain(entry, exit, chain); + return FitResult{ + Straight{entry.id(), entry.start(), close.tangent_in}, + std::move(curve), + Straight{exit.id(), close.tangent_out, exit.end()}, + close.tangent_in, + close.tangent_out, + direction, + }; +} + +FitResult FittingService::fit_uturn(const Straight& entry, const Straight& exit, + std::optional transition) { + const Vec d1 = direction_of(entry.azimuth()); + const Vec d2 = direction_of(exit.azimuth()); + const double dot = d1.east * d2.east + d1.north * d2.north; + if (dot > -1.0 + 1e-6) { + throw std::invalid_argument{"A U-turn needs two anti-parallel straights"}; + } + if (transition && !(transition->metres() > 0.0)) { + throw std::invalid_argument{"Transition length must be positive"}; + } + + // Signed perpendicular offset from the entry line to the exit line. + const Vec left{-d1.north, d1.east}; + const double offset = (exit.start().x() - entry.start().x()) * left.east + + (exit.start().y() - entry.start().y()) * left.north; + if (std::abs(offset) < kEpsilon) { + throw std::invalid_argument{"U-turn straights are collinear (zero offset)"}; + } + const double sign = (offset > 0.0) ? 1.0 : -1.0; // turn toward the exit line + const TurnDirection direction = + (offset > 0.0) ? TurnDirection::Left : TurnDirection::Right; + + // Plain 180° arc spans 2R across the offset, so R = |offset| / 2. (Transition + // curves would change the span; solved numerically — not yet supported here.) + const double radius = std::abs(offset) / 2.0; + const Radius radius_vo = Radius::from_metres(radius); + const double arc_length = radius * std::numbers::pi; + + // Start the reversal at the entry straight's end; the 180° arc lands on the + // exit line at the diametrically opposite point. + const CartesianPosition tangent_in = entry.end(); + const CartesianPosition tangent_out{tangent_in.x() + std::abs(offset) * sign * left.east, + tangent_in.y() + std::abs(offset) * sign * left.north}; + + std::vector curve; + curve.push_back(CircularArc{radius_vo, direction, Length::from_metres(arc_length)}); + (void)transition; // clothoid U-turn: follow-up + + return FitResult{ + Straight{entry.id(), entry.start(), tangent_in}, + std::move(curve), + Straight{exit.id(), tangent_out, exit.end()}, + tangent_in, + tangent_out, + direction, + }; +} + +FitResult FittingService::fit_reverse(const Straight& entry, const Straight& exit, + Radius radius_vo) { + constexpr double parallel_tolerance = 1e-6; + constexpr double angle_epsilon = 1e-5; + const double radius = radius_vo.metres(); + const Vec d1 = direction_of(entry.azimuth()); + const Vec d2 = direction_of(exit.azimuth()); + const double cross = d1.east * d2.north - d1.north * d2.east; + const double dot = d1.east * d2.east + d1.north * d2.north; + + const auto direction = [](double sign) { + return sign > 0.0 ? TurnDirection::Left : TurnDirection::Right; + }; + const auto opposite = [](TurnDirection value) { + return value == TurnDirection::Left ? TurnDirection::Right + : TurnDirection::Left; + }; + const auto result_from = [&](const ChainClose& close, double first_sign, + double first_angle, double second_angle) { + const TurnDirection first_direction = direction(first_sign); + std::vector curve; + curve.push_back(CircularArc{radius_vo, first_direction, + Length::from_metres(radius * first_angle)}); + curve.push_back(CircularArc{radius_vo, opposite(first_direction), + Length::from_metres(radius * second_angle)}); + return FitResult{ + Straight{entry.id(), entry.start(), close.tangent_in}, + std::move(curve), + Straight{exit.id(), close.tangent_out, exit.end()}, + close.tangent_in, + close.tangent_out, + first_direction, + }; + }; + + // Same-direction parallel lines are the canonical S-curve case. Their + // lateral displacement fixes the common arc angle for a requested radius. + if (std::abs(cross) <= parallel_tolerance) { + if (dot < 0.0) { + throw std::invalid_argument{ + "Anti-parallel straights require a U-turn, not a reverse curve"}; + } + const Vec left{-d1.north, d1.east}; + const double offset = + (exit.start().x() - entry.start().x()) * left.east + + (exit.start().y() - entry.start().y()) * left.north; + if (std::abs(offset) <= kEpsilon) { + throw std::invalid_argument{ + "Collinear straights do not need a reverse curve"}; + } + + const double cosine = 1.0 - std::abs(offset) / (2.0 * radius); + if (cosine < -1.0 || cosine >= 1.0) { + throw std::invalid_argument{ + "Requested radius cannot span the parallel-line offset"}; + } + const double angle = std::acos(std::clamp(cosine, -1.0, 1.0)); + if (angle <= angle_epsilon) { + throw std::invalid_argument{"Reverse-curve angle is too small"}; + } + const double first_sign = offset > 0.0 ? 1.0 : -1.0; + const std::vector segments{ + {first_sign / radius, first_sign / radius, radius * angle}, + {-first_sign / radius, -first_sign / radius, radius * angle}, + }; + + geometry::Pose pose{entry.start().x(), entry.start().y(), d1.east, d1.north}; + for (const auto& segment : segments) { + pose = geometry::layout_segment(segment.k0, segment.k1, segment.length, + pose, nullptr); + } + const double exit_at_zero = + (pose.x - exit.start().x()) * d2.east + + (pose.y - exit.start().y()) * d2.north; + constexpr double length_epsilon = 1e-6; + const double lower = std::max(length_epsilon, -exit_at_zero); + const double upper = + std::min(straight_length(entry), + straight_length(exit) - length_epsilon - exit_at_zero); + if (lower > upper) { + throw std::invalid_argument{ + "Reverse curve does not fit on the finite parallel straights"}; + } + + // Prefer tangent points near entry.end() and exit.start(). + const double unconstrained = + 0.5 * (straight_length(entry) - exit_at_zero); + const double shift = std::clamp(unconstrained, lower, upper); + const ChainClose close{ + CartesianPosition{entry.start().x() + shift * d1.east, + entry.start().y() + shift * d1.north}, + CartesianPosition{pose.x + shift * d1.east, + pose.y + shift * d1.north}, + }; + if (!valid_tangent_points(entry, exit, close)) { + throw std::invalid_argument{ + "Reverse-curve tangent points lie outside the parallel straights"}; + } + return result_from(close, first_sign, angle, angle); + } + + const double deflection = std::atan2(cross, dot); + const double delta = std::abs(deflection); + if (std::abs(delta - std::numbers::pi) <= parallel_tolerance) { + throw std::invalid_argument{ + "Anti-parallel straights require a U-turn, not a reverse curve"}; + } + const double extra_max = std::numbers::pi - delta - angle_epsilon; + if (extra_max <= angle_epsilon) { + throw std::invalid_argument{"Not enough angle for a reverse curve"}; + } + + std::optional best; + double best_length = 0.0; + constexpr int samples = 720; + for (const double first_sign : {1.0, -1.0}) { + const double signed_delta = deflection / first_sign; + const auto attempt = [&](double extra) -> std::optional { + const double first_angle = + signed_delta >= 0.0 ? extra + signed_delta : extra; + const double second_angle = + signed_delta >= 0.0 ? extra : extra - signed_delta; + if (first_angle <= angle_epsilon || second_angle <= angle_epsilon || + first_angle >= std::numbers::pi || + second_angle >= std::numbers::pi) { + return std::nullopt; + } + const std::vector segments{ + {first_sign / radius, first_sign / radius, radius * first_angle}, + {-first_sign / radius, -first_sign / radius, radius * second_angle}, + }; + try { + const ChainClose close = solve_chain(entry, exit, segments); + if (!valid_tangent_points(entry, exit, close)) return std::nullopt; + return result_from(close, first_sign, first_angle, second_angle); + } catch (...) { + return std::nullopt; + } + }; + + double previous = angle_epsilon; + for (int sample = 1; sample <= samples; ++sample) { + const double extra = + angle_epsilon + + (extra_max - angle_epsilon) * static_cast(sample) / + static_cast(samples); + auto candidate = attempt(extra); + if (!candidate) { + previous = extra; + continue; + } + + // Refine the first feasible angle boundary to avoid grid-shaped + // jumps while dragging a straight in the editor. + double low = previous; + double high = extra; + std::optional refined = std::move(candidate); + for (int iteration = 0; iteration < 48; ++iteration) { + const double middle = std::midpoint(low, high); + if (auto middle_result = attempt(middle)) { + high = middle; + refined = std::move(middle_result); + } else { + low = middle; + } + } + const double total_length = radius * (delta + 2.0 * high); + if (!best || total_length < best_length) { + best = std::move(refined); + best_length = total_length; + } + break; + } + } + + if (!best) { + throw std::invalid_argument{ + "No equal-radius reverse curve fits the finite straights"}; + } + return *best; +} + +FitResult FittingService::fit_compound(const Niweleta& niweleta, + const CompoundFitParameters& params) { + if (params.entry.is_null() || params.exit.is_null()) { + throw std::invalid_argument{"Both straights must be identified"}; + } + if (params.entry == params.exit) { + throw std::invalid_argument{"Fitting needs two different straights"}; + } + return fit_compound(find_straight(niweleta, params.entry), + find_straight(niweleta, params.exit), params); +} + +FitResult FittingService::fit_compound(const Straight& entry, const Straight& exit, + const CompoundFitParameters& params) { + if (params.arcs.empty()) { + throw std::invalid_argument{"A compound curve needs at least one arc"}; + } + for (std::size_t k = 1; k < params.arcs.size(); ++k) { + if (params.arcs[k].radius == params.arcs[k - 1].radius) { + throw std::invalid_argument{ + "Adjacent compound arcs must have different radii"}; + } + } + + const Vec d1 = direction_of(entry.azimuth()); + const Vec d2 = direction_of(exit.azimuth()); + const double cross = d1.east * d2.north - d1.north * d2.east; + const double dot = d1.east * d2.east + d1.north * d2.north; + const double deflection = std::atan2(cross, dot); + const double delta = std::abs(deflection); + if (delta < kEpsilon || std::abs(delta - std::numbers::pi) < kEpsilon) { + throw std::invalid_argument{ + "Straights are parallel or collinear; nothing to fit"}; + } + const double sign = (deflection >= 0.0) ? 1.0 : -1.0; + const TurnDirection direction = + (deflection > 0.0) ? TurnDirection::Left : TurnDirection::Right; + + const std::size_t arc_count = params.arcs.size(); + const auto curvature = [&](std::size_t k) { + return sign / params.arcs[k].radius.metres(); + }; + const auto require_positive = [](const std::optional& length, + const char* message) -> double { + const double value = length->metres(); + if (value <= kEpsilon) throw std::invalid_argument{message}; + return value; + }; + + // Heading (deflection) consumed by everything except the last arc's angle: + // the fixed-length arcs plus every transition curve. The last arc absorbs + // whatever remains. + double consumed = 0.0; + std::vector arc_angle(arc_count, 0.0); + for (std::size_t k = 0; k + 1 < arc_count; ++k) { + if (!params.arcs[k].length) { + throw std::invalid_argument{"Every arc except the last needs a length"}; + } + const double angle = + require_positive(params.arcs[k].length, "Arc length must be positive") / + params.arcs[k].radius.metres(); + arc_angle[k] = angle; + consumed += angle; + } + + // Transition heading = mean curvature * length = (1/Ra + 1/Rb)/2 * L. + const double entry_len = + params.entry_transition + ? require_positive(params.entry_transition, "Transition length must be positive") + : 0.0; + if (entry_len > 0.0) { + consumed += 0.5 * (1.0 / params.arcs.front().radius.metres()) * entry_len; + } + const double exit_len = + params.exit_transition + ? require_positive(params.exit_transition, "Transition length must be positive") + : 0.0; + if (exit_len > 0.0) { + consumed += 0.5 * (1.0 / params.arcs.back().radius.metres()) * exit_len; + } + std::vector between(arc_count, 0.0); + for (std::size_t k = 0; k + 1 < arc_count; ++k) { + if (params.arcs[k].transition_to_next) { + const double length = require_positive(params.arcs[k].transition_to_next, + "Transition length must be positive"); + between[k] = length; + consumed += 0.5 * + (1.0 / params.arcs[k].radius.metres() + + 1.0 / params.arcs[k + 1].radius.metres()) * + length; + } + } + + arc_angle[arc_count - 1] = delta - consumed; + if (arc_angle[arc_count - 1] <= kEpsilon) { + throw std::invalid_argument{ + "Arc lengths and transitions exceed the total deflection"}; + } + + // Build the chain of segments: entry KP?, arc, (KP?, arc)..., exit KP?. + std::vector segments; + if (entry_len > 0.0) segments.push_back({0.0, curvature(0), entry_len}); + for (std::size_t k = 0; k < arc_count; ++k) { + segments.push_back({curvature(k), curvature(k), + params.arcs[k].radius.metres() * arc_angle[k]}); + if (k + 1 < arc_count && between[k] > 0.0) { + segments.push_back({curvature(k), curvature(k + 1), between[k]}); + } + } + if (exit_len > 0.0) segments.push_back({curvature(arc_count - 1), 0.0, exit_len}); + + const ChainClose close = solve_chain(entry, exit, segments); + const CartesianPosition tangent_in = close.tangent_in; + const CartesianPosition tangent_out = close.tangent_out; + + // Build the plan elements to splice in, mirroring the segment chain. + std::vector curve; + if (entry_len > 0.0) { + curve.push_back(TransitionCurve{Length::from_metres(entry_len), std::nullopt, + params.arcs.front().radius, direction}); + } + for (std::size_t k = 0; k < arc_count; ++k) { + curve.push_back( + CircularArc{params.arcs[k].radius, direction, + Length::from_metres(params.arcs[k].radius.metres() * arc_angle[k])}); + if (k + 1 < arc_count && between[k] > 0.0) { + curve.push_back(TransitionCurve{Length::from_metres(between[k]), + params.arcs[k].radius, + params.arcs[k + 1].radius, direction}); + } + } + if (exit_len > 0.0) { + curve.push_back(TransitionCurve{Length::from_metres(exit_len), + params.arcs.back().radius, std::nullopt, direction}); + } + + return FitResult{ + Straight{entry.id(), entry.start(), tangent_in}, + std::move(curve), + Straight{exit.id(), tangent_out, exit.end()}, + tangent_in, + tangent_out, + direction, + }; +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/gap_fitter.cpp b/maj0sted/src/domain/gap_fitter.cpp new file mode 100644 index 00000000..8d4dd5d3 --- /dev/null +++ b/maj0sted/src/domain/gap_fitter.cpp @@ -0,0 +1,215 @@ +#include "maj0sted/domain/fitting/gap_fitter.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "maj0sted/domain/fitting/fit_parameters.hpp" +#include "maj0sted/domain/fitting/fitting_service.hpp" + +namespace maj0sted::domain { + +namespace { + +std::optional optional_length(double metres) { + if (metres > 0.0) return Length::from_metres(metres); + return std::nullopt; +} + +// The two straights point in (nearly) opposite directions: the connection is a +// 180° reversal ("nawrót"), not an ordinary corner. Matches fit_uturn's own +// anti-parallel threshold. +bool is_reversal(const Straight& entry, const Straight& exit) { + const double cos_between = + std::cos(entry.azimuth().radians() - exit.azimuth().radians()); + return cos_between <= -1.0 + 1e-6; +} + +// A tangent point may extend the drawn straight *toward the corner* as far as the +// geometry needs (filling the gap between the finite straight and the corner), but +// must never cross behind the outer, anchored end — that would flip the straight +// around and fling it off-screen. So we only bound the outer side: the point must +// lie on the ray from @p outer through @p corner (t >= 0), with no upper limit. +bool tangent_ok(CartesianPosition p, CartesianPosition outer, + CartesianPosition corner) { + const double dx = corner.x() - outer.x(); + const double dy = corner.y() - outer.y(); + const double len2 = dx * dx + dy * dy; + if (len2 <= 0.0) return false; + const double t = ((p.x() - outer.x()) * dx + (p.y() - outer.y()) * dy) / len2; + return t >= -1e-3; +} + +double deflection(const Straight& entry, const Straight& exit) { + const double a1 = entry.azimuth().radians(); + const double a2 = exit.azimuth().radians(); + const double d1x = std::sin(a1), d1y = std::cos(a1); + const double d2x = std::sin(a2), d2y = std::cos(a2); + return std::abs(std::atan2(d1x * d2y - d1y * d2x, d1x * d2x + d1y * d2y)); +} + +// The single place the "lands on both finite straights" invariant is checked. +std::optional attempt_arc(const Straight& entry, const Straight& exit, + double radius, + std::optional transition) { + if (!(radius > 0.0)) return std::nullopt; + try { + FitResult fit = FittingService::fit_between_straights( + entry, exit, Radius::from_metres(radius), transition); + // The corner is at entry.end() and exit.start() (that is where the two + // straights meet through the fit); the anchored outer ends are the + // opposite endpoints. + if (tangent_ok(fit.tangent_in, entry.start(), entry.end()) && + tangent_ok(fit.tangent_out, exit.end(), exit.start())) { + return fit; + } + } catch (...) { + } + return std::nullopt; +} + +// Smallest radius the geometry admits: two symmetric clothoids consume the whole +// deflection at R = transition / delta, below which the arc angle turns +// negative; a plain arc has only a tiny numerical floor. +double smallest_radius(const Straight& entry, const Straight& exit, + std::optional transition) { + constexpr double kTiny = 1e-3; + if (!transition) return kTiny; + const double delta = deflection(entry, exit); + if (delta < 1e-3) return kTiny; + return std::max(kTiny, transition->metres() / delta) * (1.0 + 1e-10); +} + +// Bisects a fitting radius toward its non-fitting boundary and returns the fit +// at the fitting end. @p fitting fits, @p missing does not. +std::optional refine(const Straight& entry, const Straight& exit, + std::optional transition, double fitting, + double missing, double& used_radius) { + for (int step = 0; step < 60; ++step) { + const double mid = std::midpoint(fitting, missing); + if (attempt_arc(entry, exit, mid, transition)) { + fitting = mid; + } else { + missing = mid; + } + } + used_radius = fitting; + return attempt_arc(entry, exit, fitting, transition); +} + +// The largest radius <= @p requested_radius whose arc fits both straights, or +// nullopt when none does. A fit NEVER enlarges the user's radius: if the request +// is too large to land on the (extendable) straights, we only ever reduce it to +// the largest radius that does. A radius that is smaller than the request already +// fits (a tighter arc just extends the straights toward the corner), so it is +// used verbatim upstream and this reduction path is not even reached. +std::optional fit_nearest_arc(const Straight& entry, + const Straight& exit, + double requested_radius, + std::optional transition, + double& used_radius) { + const double floor = smallest_radius(entry, exit, transition); + constexpr int kSamples = 4096; + + // Scan DOWN from the request for the largest radius that fits, then bisect. + if (requested_radius > floor) { + const double span = requested_radius - floor; + double missing = requested_radius; + for (int i = 1; i <= kSamples; ++i) { + const double r = requested_radius - span * i / kSamples; + if (attempt_arc(entry, exit, r, transition)) { + return refine(entry, exit, transition, r, missing, used_radius); + } + missing = r; + } + } + return std::nullopt; +} + +std::optional connect_uturn(const Straight& entry, + const Straight& exit, + std::optional transition, + double requested_radius) { + try { + FitResult fit = FittingService::fit_uturn(entry, exit, transition); + double applied = requested_radius; + for (const auto& element : fit.curve) { + if (const auto* arc = std::get_if(&element)) { + applied = arc->radius().metres(); + break; + } + } + return GapConnection{std::move(fit), applied}; + } catch (...) { + return std::nullopt; + } +} + +std::optional connect_compound(const Straight& entry, + const Straight& exit, + const CompoundGapParameters& c) { + if (c.arcs.empty()) return std::nullopt; + try { + // Every arc except the last carries its fixed length (which fixes its + // angle) and an optional clothoid to the next; the last arc absorbs the + // remaining deflection. One arc => "łuk + klotoidy", two or more => a + // "łuk koszowy". + std::vector arcs; + arcs.reserve(c.arcs.size()); + for (std::size_t i = 0; i < c.arcs.size(); ++i) { + const auto& a = c.arcs[i]; + if (i + 1 == c.arcs.size()) { + arcs.push_back(CompoundArc{Radius::from_metres(a.radius)}); + } else { + arcs.push_back(CompoundArc{Radius::from_metres(a.radius), + Length::from_metres(a.length), + optional_length(a.transition_to_next)}); + } + } + FitResult fit = FittingService::fit_compound( + entry, exit, + CompoundFitParameters{ + .entry_transition = optional_length(c.entry_transition), + .exit_transition = optional_length(c.exit_transition), + .arcs = std::move(arcs)}); + return GapConnection{std::move(fit), c.arcs.front().radius}; + } catch (...) { + return std::nullopt; + } +} + +} // namespace + +std::optional GapFitter::connect( + const Straight& entry, const Straight& exit, + const GapConnectionRequest& request) { + // A compound curve is taken as requested (its arcs/clothoids are explicit). + if (request.mode == GapConnectionMode::Compound) { + return connect_compound(entry, exit, request.compound); + } + + // Plain arc. A 180° reversal is geometry-driven and handled transparently. + if (is_reversal(entry, exit)) { + return connect_uturn(entry, exit, std::nullopt, request.radius); + } + + // Use the requested radius verbatim when it fits (a tighter arc fits by + // extending the straights toward the corner). Only when the request is too + // large to land on them do we reduce it to the largest radius that fits — the + // radius is never enlarged. + if (auto fit = attempt_arc(entry, exit, request.radius, std::nullopt)) { + return GapConnection{std::move(*fit), request.radius}; + } + double applied = request.radius; + if (auto fit = fit_nearest_arc(entry, exit, request.radius, std::nullopt, applied)) { + return GapConnection{std::move(*fit), applied}; + } + return std::nullopt; +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/horizontal_alignment.cpp b/maj0sted/src/domain/horizontal_alignment.cpp new file mode 100644 index 00000000..a554a651 --- /dev/null +++ b/maj0sted/src/domain/horizontal_alignment.cpp @@ -0,0 +1,95 @@ +#include "maj0sted/domain/geometry/horizontal_alignment.hpp" + +#include +#include +#include + +namespace maj0sted::domain { +namespace { + +// Two anchored endpoints closer than this are treated as touching. +constexpr double kPositionToleranceM = 1e-6; +// Azimuths differing by less than this are treated as equal. +constexpr double kAzimuthToleranceRad = 1e-9; + +double distance(CartesianPosition a, CartesianPosition b) noexcept { + const double dx = b.x() - a.x(); + const double dy = b.y() - a.y(); + return std::sqrt(dx * dx + dy * dy); +} + +bool same_arc(const CircularArc& a, const CircularArc& b) noexcept { + return a.direction() == b.direction() && a.radius() == b.radius(); +} + +void validate_joint(const PlanElement& previous, const PlanElement& current, + JointContinuity joint) { + // Rule: two circular arcs of the same radius and direction must not meet. + if (const auto* a = std::get_if(&previous)) { + if (const auto* b = std::get_if(¤t)) { + if (same_arc(*a, *b)) { + throw std::logic_error{ + "Adjacent circular arcs with the same radius and direction " + "are not allowed"}; + } + } + } + + const auto* previous_straight = std::get_if(&previous); + const auto* current_straight = std::get_if(¤t); + + if (joint == JointContinuity::AzimuthBreak) { + if (previous_straight == nullptr || current_straight == nullptr) { + throw std::logic_error{ + "An azimuth break is only allowed between two straights"}; + } + if (distance(previous_straight->end(), current_straight->start()) > + kPositionToleranceM) { + throw std::logic_error{"Straights at an azimuth break must touch"}; + } + if (previous_straight->azimuth().angular_distance( + current_straight->azimuth()) <= kAzimuthToleranceRad) { + throw std::logic_error{ + "An azimuth break requires the two straights to have different " + "azimuths"}; + } + return; + } + + // Tangent joint. Only straight-to-straight can be verified now; joints that + // involve a floating curve are checked after fitting. + if (previous_straight != nullptr && current_straight != nullptr) { + if (distance(previous_straight->end(), current_straight->start()) > + kPositionToleranceM) { + throw std::logic_error{"Tangent straights must touch"}; + } + if (previous_straight->azimuth().angular_distance( + current_straight->azimuth()) > kAzimuthToleranceRad) { + throw std::logic_error{ + "Adjacent straights with different azimuths must be marked as an " + "azimuth break"}; + } + } +} + +} // namespace + +void HorizontalAlignment::append(PlanElement element, JointContinuity joint) { + if (!elements_.empty()) { + validate_joint(elements_.back(), element, joint); + joints_.push_back(joint); + } + elements_.push_back(std::move(element)); +} + +Length HorizontalAlignment::total_length() const { + Length total = Length::zero(); + for (const auto& element : elements_) { + if (const auto element_length = length_of(element)) { + total = total + *element_length; + } + } + return total; +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/map_project.cpp b/maj0sted/src/domain/map_project.cpp new file mode 100644 index 00000000..aede9de8 --- /dev/null +++ b/maj0sted/src/domain/map_project.cpp @@ -0,0 +1,13 @@ +#include "maj0sted/domain/map_project.hpp" + +#include + +namespace maj0sted::domain { + +MapProject::MapProject(Crs crs) : crs_{crs} {} + +void MapProject::add_niweleta(Niweleta niweleta) { + niwelety_.push_back(std::move(niweleta)); +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/niweleta.cpp b/maj0sted/src/domain/niweleta.cpp new file mode 100644 index 00000000..3006c4f7 --- /dev/null +++ b/maj0sted/src/domain/niweleta.cpp @@ -0,0 +1,90 @@ +#include "maj0sted/domain/niweleta.hpp" + +#include +#include +#include +#include +#include + +namespace maj0sted::domain { + +Niweleta::Niweleta(NiweletaId id, std::string name) + : id_{id}, name_{std::move(name)} {} + +StraightId Niweleta::next_straight_id() noexcept { return StraightId{++straight_seq_}; } + +StraightId Niweleta::add_straight(CartesianPosition start, CartesianPosition end, + JointContinuity joint) { + const StraightId id = next_straight_id(); + plan_.append(Straight{id, start, end}, joint); + return id; +} + +void Niweleta::add_plan_element(PlanElement element, JointContinuity joint) { + if (auto* straight = std::get_if(&element); straight != nullptr) { + if (straight->id().is_null()) { + // A straight added without an identity gets one from the niweleta. + element = Straight{next_straight_id(), straight->start(), straight->end()}; + } else if (straight->id().value() > straight_seq_) { + // Keep the counter ahead of any explicitly supplied id (e.g. on load). + straight_seq_ = straight->id().value(); + } + } + plan_.append(std::move(element), joint); +} + +void Niweleta::apply_fit(const FitResult& result) { + const StraightId entry_id = result.entry.id(); + const StraightId exit_id = result.exit.id(); + if (entry_id.is_null() || exit_id.is_null()) { + throw std::invalid_argument{"apply_fit: trimmed straights must be identified"}; + } + + const auto& elements = plan_.elements(); + const auto index_of = [&](StraightId id) -> std::size_t { + for (std::size_t k = 0; k < elements.size(); ++k) { + const auto* straight = std::get_if(&elements[k]); + if (straight != nullptr && !straight->id().is_null() && + straight->id() == id) { + return k; + } + } + throw std::invalid_argument{"apply_fit: straight not found in plan"}; + }; + const std::size_t i = index_of(entry_id); + const std::size_t j = index_of(exit_id); + if (j != i + 1) { + throw std::invalid_argument{ + "apply_fit: the two straights must be adjacent (entry then exit)"}; + } + + // Rebuild the plan, re-validating every joint via append(). + HorizontalAlignment rebuilt; + const auto add = [&](PlanElement element, JointContinuity joint) { + rebuilt.append(std::move(element), joint); + }; + + for (std::size_t k = 0; k < i; ++k) { + add(elements[k], k == 0 ? JointContinuity::Tangent : plan_.joint(k - 1)); + } + add(result.entry, i == 0 ? JointContinuity::Tangent : plan_.joint(i - 1)); + for (const auto& curve_element : result.curve) { + add(curve_element, JointContinuity::Tangent); + } + add(result.exit, JointContinuity::Tangent); + for (std::size_t k = j + 1; k < elements.size(); ++k) { + add(elements[k], plan_.joint(k - 1)); + } + + plan_ = std::move(rebuilt); +} + +void Niweleta::add_profile_element(ProfileElement element) { + profile_.append(std::move(element)); +} + +bool Niweleta::lengths_consistent(double tolerance_m) const { + return std::abs(plan_length().metres() - profile_length().metres()) <= tolerance_m; +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/parallelism_service.cpp b/maj0sted/src/domain/parallelism_service.cpp new file mode 100644 index 00000000..874326a8 --- /dev/null +++ b/maj0sted/src/domain/parallelism_service.cpp @@ -0,0 +1,58 @@ +#include "maj0sted/domain/parallelism/parallelism_service.hpp" + +#include +#include + +namespace maj0sted::domain { +namespace { + +// Unit left-normal of a straight's direction of travel, in (east=x, north=y). +// Direction d = (sin az, cos az); rotating it +90 deg (counter-clockwise) gives +// the left normal (-cos az, sin az). +struct Vec { + double east; + double north; +}; + +Vec left_normal(Azimuth azimuth) noexcept { + return Vec{-std::cos(azimuth.radians()), std::sin(azimuth.radians())}; +} + +} // namespace + +TrackParallelism ParallelismService::mark_parallel(const Straight& origin, + const Straight& parallel, + TrackOffset offset) { + // The invariants (both identified, distinct) are enforced by TrackParallelism. + return TrackParallelism{origin.id(), parallel.id(), offset}; +} + +bool ParallelismService::are_parallel(const Straight& a, const Straight& b, + double tolerance_rad) { + const double difference = a.azimuth().angular_distance(b.azimuth()); // [0, pi] + return difference <= tolerance_rad || + std::abs(difference - std::numbers::pi) <= tolerance_rad; +} + +Straight ParallelismService::derive_parallel_line(const Straight& origin, + TrackOffset offset) { + const Vec normal = left_normal(origin.azimuth()); + const double sign = (offset.side() == Side::Left) ? 1.0 : -1.0; + const double shift = offset.distance().metres() * sign; + + const CartesianPosition start{origin.start().x() + normal.east * shift, + origin.start().y() + normal.north * shift}; + const CartesianPosition end{origin.end().x() + normal.east * shift, + origin.end().y() + normal.north * shift}; + return Straight{start, end}; +} + +double ParallelismService::perpendicular_distance(const Straight& origin, + const Straight& other) { + const Vec normal = left_normal(origin.azimuth()); + const double d_east = other.start().x() - origin.start().x(); + const double d_north = other.start().y() - origin.start().y(); + return std::abs(d_east * normal.east + d_north * normal.north); +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/vertical_alignment.cpp b/maj0sted/src/domain/vertical_alignment.cpp new file mode 100644 index 00000000..fc576e10 --- /dev/null +++ b/maj0sted/src/domain/vertical_alignment.cpp @@ -0,0 +1,13 @@ +#include "maj0sted/domain/geometry/vertical_alignment.hpp" + +namespace maj0sted::domain { + +Length VerticalAlignment::total_length() const { + Length total = Length::zero(); + for (const auto& element : elements_) { + total = total + length_of(element); + } + return total; +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/io/project_serializer.cpp b/maj0sted/src/io/project_serializer.cpp new file mode 100644 index 00000000..f52ab1fe --- /dev/null +++ b/maj0sted/src/io/project_serializer.cpp @@ -0,0 +1,291 @@ +#include "maj0sted/io/project_serializer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "maj0sted/maj0sted.hpp" + +namespace maj0sted::io { + +using namespace maj0sted::domain; + +namespace { + +template +struct overloaded : Ts... { + using Ts::operator()...; +}; +template +overloaded(Ts...) -> overloaded; + +// --- writing ------------------------------------------------------------- + +// Shortest round-trippable, locale-independent representation of a double. +std::string num(double value) { + char buffer[64]; + const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value); + return std::string(buffer, result.ptr); +} + +char dir_char(TurnDirection direction) { + return direction == TurnDirection::Left ? 'L' : 'R'; +} +char joint_char(JointContinuity joint) { + return joint == JointContinuity::AzimuthBreak ? 'B' : 'T'; +} +char curvature_char(VerticalCurvature curvature) { + return curvature == VerticalCurvature::Crest ? 'C' : 'S'; +} + +std::string optional_metres(const std::optional& length) { + return length ? num(length->metres()) : std::string{"-"}; +} +std::string optional_metres(const std::optional& radius) { + return radius ? num(radius->metres()) : std::string{"-"}; +} + +// --- reading ------------------------------------------------------------- + +std::vector tokenize(const std::string& line) { + std::vector tokens; + std::size_t i = 0; + const std::size_t n = line.size(); + while (i < n) { + while (i < n && std::isspace(static_cast(line[i]))) ++i; + std::size_t j = i; + while (j < n && !std::isspace(static_cast(line[j]))) ++j; + if (j > i) tokens.push_back(line.substr(i, j - i)); + i = j; + } + return tokens; +} + +double to_double(const std::string& text) { + double value{}; + const auto result = std::from_chars(text.data(), text.data() + text.size(), value); + if (result.ec != std::errc{}) { + throw std::runtime_error{"maj0sted: bad number '" + text + "'"}; + } + return value; +} + +std::uint64_t to_u64(const std::string& text) { + std::uint64_t value{}; + const auto result = std::from_chars(text.data(), text.data() + text.size(), value); + if (result.ec != std::errc{}) { + throw std::runtime_error{"maj0sted: bad integer '" + text + "'"}; + } + return value; +} + +TurnDirection to_direction(const std::string& text) { + if (text == "L") return TurnDirection::Left; + if (text == "R") return TurnDirection::Right; + throw std::runtime_error{"maj0sted: bad direction '" + text + "'"}; +} +JointContinuity to_joint(const std::string& text) { + if (text == "T") return JointContinuity::Tangent; + if (text == "B") return JointContinuity::AzimuthBreak; + throw std::runtime_error{"maj0sted: bad joint '" + text + "'"}; +} +VerticalCurvature to_curvature(const std::string& text) { + if (text == "C") return VerticalCurvature::Crest; + if (text == "S") return VerticalCurvature::Sag; + throw std::runtime_error{"maj0sted: bad curvature '" + text + "'"}; +} + +std::optional to_optional_length(const std::string& text) { + if (text == "-") return std::nullopt; + return Length::from_metres(to_double(text)); +} +std::optional to_optional_radius(const std::string& text) { + if (text == "-") return std::nullopt; + return Radius::from_metres(to_double(text)); +} + +} // namespace + +std::string serialize(const MapProject& project) { + std::string out; + out += "maj0sted 1\n"; + out += "crs " + std::to_string(project.crs().epsg()) + "\n"; + out += "niwelety " + std::to_string(project.niwelety().size()) + "\n"; + + for (const auto& niweleta : project.niwelety()) { + out += "niweleta " + std::to_string(niweleta.id().value()) + "\n"; + out += "name " + niweleta.name() + "\n"; + + const auto& plan = niweleta.plan(); + out += "plan " + std::to_string(plan.size()) + "\n"; + for (std::size_t k = 0; k < plan.elements().size(); ++k) { + const char joint = (k == 0) ? 'T' : joint_char(plan.joint(k - 1)); + std::visit( + overloaded{ + [&](const Straight& s) { + out += "straight " + std::to_string(s.id().value()) + " " + + num(s.start().x()) + " " + num(s.start().y()) + " " + + num(s.end().x()) + " " + num(s.end().y()) + " " + joint + + "\n"; + }, + [&](const CircularArc& a) { + out += "arc " + num(a.radius().metres()) + " " + + dir_char(a.direction()) + " " + optional_metres(a.length()) + + " " + joint + "\n"; + }, + [&](const TransitionCurve& t) { + out += "transition " + num(t.length().metres()) + " " + + optional_metres(t.start_radius()) + " " + + optional_metres(t.end_radius()) + " " + + dir_char(t.direction()) + " " + joint + "\n"; + }, + }, + plan.elements()[k]); + } + + const auto& profile = niweleta.profile(); + out += "profile " + std::to_string(profile.size()) + "\n"; + for (const auto& element : profile.elements()) { + std::visit( + overloaded{ + [&](const Grade& g) { + out += "grade " + num(g.length().metres()) + " " + + num(g.slope_permille()) + "\n"; + }, + [&](const VerticalCurve& v) { + out += "vcurve " + num(v.length().metres()) + " " + + num(v.radius().metres()) + " " + + curvature_char(v.curvature()) + "\n"; + }, + }, + element); + } + } + return out; +} + +MapProject deserialize(const std::string& text) { + // Split into non-blank lines, stripping any trailing '\r'. + std::vector lines; + { + std::istringstream in(text); + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + bool only_ws = true; + for (const char c : line) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) lines.push_back(line); + } + } + + std::size_t cursor = 0; + const auto next_line = [&]() -> const std::string& { + if (cursor >= lines.size()) { + throw std::runtime_error{"maj0sted: unexpected end of data"}; + } + return lines[cursor++]; + }; + const auto expect = [&](const std::vector& tokens, const char* tag, + std::size_t min_size) { + if (tokens.size() < min_size || tokens[0] != tag) { + throw std::runtime_error{std::string{"maj0sted: expected '"} + tag + "'"}; + } + }; + + { + const auto header = tokenize(next_line()); + expect(header, "maj0sted", 2); + } + const auto crs_line = tokenize(next_line()); + expect(crs_line, "crs", 2); + const Crs crs{static_cast(to_u64(crs_line[1]))}; + + const auto count_line = tokenize(next_line()); + expect(count_line, "niwelety", 2); + const std::size_t niweleta_count = static_cast(to_u64(count_line[1])); + + MapProject project{crs}; + for (std::size_t index = 0; index < niweleta_count; ++index) { + const auto header = tokenize(next_line()); + expect(header, "niweleta", 2); + const NiweletaId id{to_u64(header[1])}; + + const std::string& name_line = next_line(); + if (name_line.rfind("name", 0) != 0) { + throw std::runtime_error{"maj0sted: expected 'name'"}; + } + std::string name = name_line.size() > 5 ? name_line.substr(5) : std::string{}; + Niweleta niweleta{id, std::move(name)}; + + const auto plan_line = tokenize(next_line()); + expect(plan_line, "plan", 2); + const std::size_t plan_count = static_cast(to_u64(plan_line[1])); + for (std::size_t k = 0; k < plan_count; ++k) { + const auto e = tokenize(next_line()); + if (e.empty()) throw std::runtime_error{"maj0sted: empty plan element"}; + if (e[0] == "straight") { + expect(e, "straight", 7); + niweleta.add_plan_element( + Straight{StraightId{to_u64(e[1])}, + CartesianPosition{to_double(e[2]), to_double(e[3])}, + CartesianPosition{to_double(e[4]), to_double(e[5])}}, + to_joint(e[6])); + } else if (e[0] == "arc") { + expect(e, "arc", 5); + niweleta.add_plan_element( + CircularArc{Radius::from_metres(to_double(e[1])), to_direction(e[2]), + to_optional_length(e[3])}, + to_joint(e[4])); + } else if (e[0] == "transition") { + expect(e, "transition", 6); + niweleta.add_plan_element( + TransitionCurve{Length::from_metres(to_double(e[1])), + to_optional_radius(e[2]), to_optional_radius(e[3]), + to_direction(e[4])}, + to_joint(e[5])); + } else { + throw std::runtime_error{"maj0sted: unknown plan element '" + e[0] + "'"}; + } + } + + const auto profile_line = tokenize(next_line()); + expect(profile_line, "profile", 2); + const std::size_t profile_count = + static_cast(to_u64(profile_line[1])); + for (std::size_t k = 0; k < profile_count; ++k) { + const auto e = tokenize(next_line()); + if (e.empty()) throw std::runtime_error{"maj0sted: empty profile element"}; + if (e[0] == "grade") { + expect(e, "grade", 3); + niweleta.add_profile_element( + Grade{Length::from_metres(to_double(e[1])), to_double(e[2])}); + } else if (e[0] == "vcurve") { + expect(e, "vcurve", 4); + niweleta.add_profile_element( + VerticalCurve{Length::from_metres(to_double(e[1])), + Radius::from_metres(to_double(e[2])), + to_curvature(e[3])}); + } else { + throw std::runtime_error{"maj0sted: unknown profile element '" + e[0] + + "'"}; + } + } + + project.add_niweleta(std::move(niweleta)); + } + return project; +} + +} // namespace maj0sted::io diff --git a/maj0sted/src/io/svg_writer.cpp b/maj0sted/src/io/svg_writer.cpp new file mode 100644 index 00000000..5a32b9e4 --- /dev/null +++ b/maj0sted/src/io/svg_writer.cpp @@ -0,0 +1,63 @@ +#include "maj0sted/io/svg_writer.hpp" + +#include +#include + +namespace maj0sted::io { + +namespace { + +std::string num(double value) { + char buffer[64]; + const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value); + return std::string(buffer, result.ptr); +} + +const char* colour(render::ElementKind kind) { + switch (kind) { + case render::ElementKind::Straight: + return "#222222"; + case render::ElementKind::Arc: + return "#1f77b4"; + case render::ElementKind::Transition: + return "#ff7f0e"; + } + return "#000000"; +} + +} // namespace + +std::string to_svg(const render::Scene& scene, const SvgOptions& options) { + double world_w = scene.max_x - scene.min_x; + double world_h = scene.max_y - scene.min_y; + if (world_w <= 0.0) world_w = 1.0; + if (world_h <= 0.0) world_h = 1.0; + + const double scale = (options.width_px - 2.0 * options.margin_px) / world_w; + const double height_px = 2.0 * options.margin_px + world_h * scale; + + // World -> SVG. North is up, so the y axis is flipped. + const auto sx = [&](double x) { return options.margin_px + (x - scene.min_x) * scale; }; + const auto sy = [&](double y) { return options.margin_px + (scene.max_y - y) * scale; }; + + std::string out; + out += "\n"; + out += "\n"; + + for (const auto& polyline : scene.polylines) { + out += std::string("\n"; + } + + out += "\n"; + return out; +} + +} // namespace maj0sted::io diff --git a/maj0sted/src/render/plan_sampler.cpp b/maj0sted/src/render/plan_sampler.cpp new file mode 100644 index 00000000..f9687909 --- /dev/null +++ b/maj0sted/src/render/plan_sampler.cpp @@ -0,0 +1,116 @@ +#include "maj0sted/render/plan_sampler.hpp" + +#include +#include +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/maj0sted.hpp" + +namespace maj0sted::render { + +using namespace maj0sted::domain; + +namespace { + +template +struct overloaded : Ts... { + using Ts::operator()...; +}; +template +overloaded(Ts...) -> overloaded; + +struct Vec { + double east; + double north; +}; + +Vec direction_of(Azimuth azimuth) noexcept { + return Vec{std::sin(azimuth.radians()), std::cos(azimuth.radians())}; +} + +// Samples a segment of linearly varying signed curvature (arc when k0 == k1, +// clothoid otherwise), starting at @p pos heading @p heading. Appends points to +// @p out and updates @p pos / @p heading to the segment's end. +// Samples a segment via the shared layout (identical to the fitting solver, so +// the drawn curve coincides exactly with the fitted tangent points). +void sample_segment(double k0, double k1, double length, Vec& pos, Vec& heading, + std::vector& out) { + geometry::Pose start{pos.east, pos.north, heading.east, heading.north}; + std::vector points; + const geometry::Pose end = geometry::layout_segment(k0, k1, length, start, &points); + for (const auto& p : points) out.push_back({p.x, p.y}); + pos = Vec{end.x, end.y}; + heading = Vec{end.hx, end.hy}; +} + +void expand(Scene& scene, const Point& p) { + if (scene.empty) { + scene.min_x = scene.max_x = p.x; + scene.min_y = scene.max_y = p.y; + scene.empty = false; + return; + } + scene.min_x = std::min(scene.min_x, p.x); + scene.min_y = std::min(scene.min_y, p.y); + scene.max_x = std::max(scene.max_x, p.x); + scene.max_y = std::max(scene.max_y, p.y); +} + +void sample_into(const HorizontalAlignment& plan, Scene& scene) { + Vec pos{0.0, 0.0}; + Vec heading{1.0, 0.0}; + + for (const auto& element : plan.elements()) { + Polyline polyline; + std::visit( + overloaded{ + [&](const Straight& s) { + polyline.kind = ElementKind::Straight; + polyline.points = {{s.start().x(), s.start().y()}, + {s.end().x(), s.end().y()}}; + pos = Vec{s.end().x(), s.end().y()}; + heading = direction_of(s.azimuth()); + }, + [&](const CircularArc& a) { + polyline.kind = ElementKind::Arc; + const double sign = a.direction() == TurnDirection::Left ? 1.0 : -1.0; + const double k = sign / a.radius().metres(); + const double length = a.length() ? a.length()->metres() : 0.0; + sample_segment(k, k, length, pos, heading, polyline.points); + }, + [&](const TransitionCurve& t) { + polyline.kind = ElementKind::Transition; + const double sign = t.direction() == TurnDirection::Left ? 1.0 : -1.0; + const double k0 = + t.start_radius() ? sign / t.start_radius()->metres() : 0.0; + const double k1 = + t.end_radius() ? sign / t.end_radius()->metres() : 0.0; + sample_segment(k0, k1, t.length().metres(), pos, heading, + polyline.points); + }, + }, + element); + + for (const auto& p : polyline.points) expand(scene, p); + scene.polylines.push_back(std::move(polyline)); + } +} + +} // namespace + +Scene sample(const HorizontalAlignment& plan) { + Scene scene; + sample_into(plan, scene); + return scene; +} + +Scene sample(const MapProject& project) { + Scene scene; + for (const auto& niweleta : project.niwelety()) { + sample_into(niweleta.plan(), scene); + } + return scene; +} + +} // namespace maj0sted::render diff --git a/maj0sted/src/render/rail_renderer.cpp b/maj0sted/src/render/rail_renderer.cpp new file mode 100644 index 00000000..f1ece92b --- /dev/null +++ b/maj0sted/src/render/rail_renderer.cpp @@ -0,0 +1,60 @@ +#include "maj0sted/render/rail_renderer.hpp" + +#include +#include +#include + +namespace maj0sted::render { + +namespace { + +// Offsets a centreline polyline sideways by @p dist metres (signed) along the +// per-vertex normal. The vertex tangent uses a central difference of the +// neighbours (forward/backward at the ends); the left normal of (dx, dy) is +// (-dy, dx), matching the project's (east=x, north=y) convention. +std::vector offset(const std::vector& pts, double dist) { + const std::size_t m = pts.size(); + std::vector out(m); + for (std::size_t i = 0; i < m; ++i) { + double dx = 1.0, dy = 0.0; + if (m >= 2) { + if (i == 0) { + dx = pts[1].x - pts[0].x; + dy = pts[1].y - pts[0].y; + } else if (i + 1 == m) { + dx = pts[i].x - pts[i - 1].x; + dy = pts[i].y - pts[i - 1].y; + } else { + dx = pts[i + 1].x - pts[i - 1].x; + dy = pts[i + 1].y - pts[i - 1].y; + } + } + const double len = std::hypot(dx, dy); + const double nx = len > 0.0 ? -dy / len : 0.0; + const double ny = len > 0.0 ? dx / len : 0.0; + out[i] = Point{pts[i].x + nx * dist, pts[i].y + ny * dist}; + } + return out; +} + +} // namespace + +RailRenderer::RailRenderer(double gauge_metres) noexcept + : half_gauge_{gauge_metres * 0.5} {} + +void RailRenderer::add(const CentrelineElement& element) { + for (const double side : {half_gauge_, -half_gauge_}) { + RailPolyline rail; + rail.kind = element.kind; + rail.straight_index = element.straight_index; + rail.gap = element.gap; + rail.element_index = element.element_index; + rail.length = element.length; + rail.radius_start = element.radius_start; + rail.radius_end = element.radius_end; + rail.points = offset(element.points, side); + rails_.push_back(std::move(rail)); + } +} + +} // namespace maj0sted::render diff --git a/maj0sted/src/web/editor.cpp b/maj0sted/src/web/editor.cpp new file mode 100644 index 00000000..953832ff --- /dev/null +++ b/maj0sted/src/web/editor.cpp @@ -0,0 +1,235 @@ +#include "maj0sted/web/editor.hpp" + +#include +#include +#include +#include +#include +#include + +#include "maj0sted/domain/fitting/gap_fitter.hpp" +#include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/maj0sted.hpp" +#include "maj0sted/render/rail_renderer.hpp" +#include "maj0sted/render/track_renderer.hpp" + +// This file is orchestration only: it turns the editor's request DTOs into +// domain calls, samples the solved geometry into rendering-neutral centreline +// elements, and drives a render::TrackRenderer to produce the drawable output. +// All fitting business logic lives in maj0sted::domain::GapFitter; all rail +// geometry lives in maj0sted::render::RailRenderer. Nothing here decides how a +// curve is fitted or how it is drawn. +namespace maj0sted::web { + +using namespace maj0sted::domain; +using maj0sted::domain::geometry::layout_segment; +using maj0sted::domain::geometry::Pose; +using maj0sted::domain::geometry::XY; + +namespace { + +std::optional make_straight(const StraightSpec& s, std::uint64_t id) { + try { + return Straight{StraightId{id}, CartesianPosition{s.x1, s.y1}, + CartesianPosition{s.x2, s.y2}}; + } catch (...) { + return std::nullopt; // degenerate (zero length) + } +} + +// Maps an editor gap-fit DTO onto a domain connection request. Returns nullopt +// for "no fit" (mode 0) or an unknown mode. +std::optional to_request(const GapFit& fit) { + GapConnectionRequest request; + switch (fit.mode) { + case 1: // plain arc + // Legacy explicit nawrót (mode 4): treated as a plain-arc request; a + // genuine 180° reversal is then detected from the geometry in GapFitter. + case 4: + request.mode = GapConnectionMode::Arc; + request.radius = fit.radius; + return request; + case 2: // legacy "łuk + klotoidy" -> compound with a single eased arc + request.mode = GapConnectionMode::Compound; + request.compound.entry_transition = fit.transition; + request.compound.exit_transition = fit.transition; + request.compound.arcs.push_back(CompoundGapArc{fit.radius, 0.0, 0.0}); + return request; + case 3: // unified compound: any number of arcs eased by clothoids + request.mode = GapConnectionMode::Compound; + request.compound.entry_transition = fit.entry_t; + request.compound.exit_transition = fit.exit_t; + if (!fit.arcs.empty()) { + for (const auto& a : fit.arcs) { + request.compound.arcs.push_back( + CompoundGapArc{a.radius, a.length, a.transition_to_next}); + } + } else if (fit.r2 > 0.0) { // legacy two-arc basket + request.compound.arcs.push_back( + CompoundGapArc{fit.r1, fit.arc1_len, fit.between}); + request.compound.arcs.push_back(CompoundGapArc{fit.r2, 0.0, 0.0}); + } else { // legacy single arc + request.compound.arcs.push_back(CompoundGapArc{fit.r1, 0.0, 0.0}); + } + return request; + default: + return std::nullopt; + } +} + +double radius_of(double curvature) { + return std::abs(curvature) > 1e-12 ? 1.0 / std::abs(curvature) : 0.0; +} + +// Describes one domain plan element as sampling parameters for layout_segment, +// returning its render kind. A circular arc has constant curvature; a clothoid +// varies linearly; a straight has zero curvature. +render::ElementKind sample_params(const PlanElement& element, double& k0, double& k1, + double& length) { + if (const auto* arc = std::get_if(&element)) { + const double sign = arc->direction() == TurnDirection::Left ? 1.0 : -1.0; + k0 = k1 = sign / arc->radius().metres(); + length = arc->length() ? arc->length()->metres() : 0.0; + return render::ElementKind::Arc; + } + const auto& transition = std::get(element); + const double sign = transition.direction() == TurnDirection::Left ? 1.0 : -1.0; + k0 = transition.start_radius() ? sign / transition.start_radius()->metres() : 0.0; + k1 = transition.end_radius() ? sign / transition.end_radius()->metres() : 0.0; + length = transition.length().metres(); + return render::ElementKind::Transition; +} + +// Samples the solved niweleta (trimmed straights + fitted curves) into +// rendering-neutral centreline elements, and records the trimmed straights. +std::vector sample_centrelines( + const NiweletaSpec& spec, const std::vector>& straights, + const std::vector>& fits, + std::vector& rendered_straights) { + std::vector axis; + const std::size_t n = straights.size(); + for (std::size_t i = 0; i < n; ++i) { + if (!straights[i]) continue; + + // Trim the straight to the tangent points of its adjacent fits. + CartesianPosition a = straights[i]->start(); + CartesianPosition b = straights[i]->end(); + if (i > 0 && fits[i - 1]) a = fits[i - 1]->tangent_out; + if (i < fits.size() && fits[i]) b = fits[i]->tangent_in; + rendered_straights[i] = + StraightSpec{a.x(), a.y(), b.x(), b.y(), spec.straights[i].hidden}; + + if (!spec.straights[i].hidden) { + render::CentrelineElement straight_element; + straight_element.kind = render::ElementKind::Straight; + straight_element.straight_index = static_cast(i); + straight_element.length = std::hypot(b.x() - a.x(), b.y() - a.y()); + straight_element.points = {render::Point{a.x(), a.y()}, + render::Point{b.x(), b.y()}}; + axis.push_back(std::move(straight_element)); + } + + if (i < fits.size() && fits[i]) { + Pose pose{fits[i]->tangent_in.x(), fits[i]->tangent_in.y(), + std::sin(straights[i]->azimuth().radians()), + std::cos(straights[i]->azimuth().radians())}; + int element_index = 0; + for (const auto& element : fits[i]->curve) { + double k0 = 0.0, k1 = 0.0, length = 0.0; + render::CentrelineElement curve_element; + curve_element.kind = sample_params(element, k0, k1, length); + std::vector pts; + pose = layout_segment(k0, k1, length, pose, &pts); + curve_element.points.reserve(pts.size()); + for (const auto& p : pts) { + curve_element.points.push_back(render::Point{p.x, p.y}); + } + curve_element.gap = static_cast(i); + curve_element.element_index = element_index++; + curve_element.length = length; + curve_element.radius_start = radius_of(k0); + curve_element.radius_end = radius_of(k1); + axis.push_back(std::move(curve_element)); + } + } + } + return axis; +} + +// Renders the centreline elements to WebPolylines through the renderer +// interface. A different render::TrackRenderer could be dropped in here without +// touching anything above. +std::vector render_rails( + const std::vector& axis) { + render::RailRenderer rail_renderer; + render::TrackRenderer& renderer = rail_renderer; + for (const auto& element : axis) renderer.add(element); + + std::vector polylines; + polylines.reserve(rail_renderer.rails().size()); + for (const auto& rail : rail_renderer.rails()) { + WebPolyline poly; + poly.kind = static_cast(rail.kind); + poly.straight_index = rail.straight_index; + poly.gap = rail.gap; + poly.element_index = rail.element_index; + poly.length = rail.length; + poly.radius_start = rail.radius_start; + poly.radius_end = rail.radius_end; + poly.points.reserve(rail.points.size()); + for (const auto& p : rail.points) poly.points.push_back(WebPoint{p.x, p.y}); + polylines.push_back(std::move(poly)); + } + return polylines; +} + +NiweletaPolys build(const NiweletaSpec& spec) { + const std::size_t n = spec.straights.size(); + std::vector> straights(n); + for (std::size_t i = 0; i < n; ++i) { + straights[i] = make_straight(spec.straights[i], i + 1); + } + + NiweletaPolys result; + result.rendered_straights = spec.straights; + + // 1. Business: connect each requested gap. The domain decides the curve type + // and the radius actually used; a gap that admits no fit is simply left + // out (the library, not the host, turns a failed fit into "no fit"). Only + // fits that succeeded are echoed in applied_fits for the host to mirror. + std::vector> fits(n == 0 ? 0 : n - 1); + for (const auto& gap : spec.fits) { + const int g = gap.gap; + if (g < 0 || static_cast(g) + 1 >= n) continue; + if (!straights[g] || !straights[g + 1]) continue; + const auto request = to_request(gap); + if (!request) continue; + const auto connection = + GapFitter::connect(*straights[g], *straights[g + 1], *request); + if (!connection) continue; + fits[g] = connection->fit; + GapFit applied = gap; + applied.radius = connection->applied_radius; + result.applied_fits.push_back(applied); + } + + // 2. Geometry: sample trimmed straights and fitted curves into a neutral, + // renderer-agnostic centreline. + const auto axis = + sample_centrelines(spec, straights, fits, result.rendered_straights); + + // 3. Presentation: hand the centreline to a renderer. + result.polylines = render_rails(axis); + return result; +} + +} // namespace + +std::vector solve_project(const std::vector& niwelety) { + std::vector result; + result.reserve(niwelety.size()); + for (const auto& spec : niwelety) result.push_back(build(spec)); + return result; +} + +} // namespace maj0sted::web diff --git a/maj0sted/src/web/ribbon.cpp b/maj0sted/src/web/ribbon.cpp new file mode 100644 index 00000000..21d82334 --- /dev/null +++ b/maj0sted/src/web/ribbon.cpp @@ -0,0 +1,55 @@ +#include "maj0sted/web/ribbon.hpp" + +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" + +namespace maj0sted::web { + +using maj0sted::domain::geometry::layout_segment; +using maj0sted::domain::geometry::Pose; +using maj0sted::domain::geometry::XY; + +std::vector solve_ribbon(const RibbonRequest& request) { + std::vector result; + result.reserve(request.elements.size()); + + Pose pose{request.x0, request.y0, std::sin(request.az0), std::cos(request.az0)}; + + for (const auto& element : request.elements) { + double k0 = 0.0; + double k1 = 0.0; + double length = 0.0; + int kind = element.kind; + + if (element.kind == 1) { // arc + const double sign = element.dir >= 0 ? 1.0 : -1.0; + const double k = element.radius > 0.0 ? sign / element.radius : 0.0; + k0 = k; + k1 = k; + length = element.radius * element.angle; + } else if (element.kind == 2) { // spiral (clothoid) + const double sign = element.dir >= 0 ? 1.0 : -1.0; + k0 = element.radius_start > 0.0 ? sign / element.radius_start : 0.0; + k1 = element.radius > 0.0 ? sign / element.radius : 0.0; + length = element.length; + } else { // line + kind = 0; + length = element.length; + } + + std::vector points; + pose = layout_segment(k0, k1, length, pose, &points); + + WebPolyline polyline; + polyline.kind = kind; + polyline.points.reserve(points.size()); + for (const auto& p : points) { + polyline.points.push_back(WebPoint{p.x, p.y}); + } + result.push_back(std::move(polyline)); + } + return result; +} + +} // namespace maj0sted::web diff --git a/maj0sted/src/web/solve.cpp b/maj0sted/src/web/solve.cpp new file mode 100644 index 00000000..19caa322 --- /dev/null +++ b/maj0sted/src/web/solve.cpp @@ -0,0 +1,98 @@ +#include "maj0sted/web/solve.hpp" + +#include + +#include "maj0sted/maj0sted.hpp" +#include "maj0sted/render/plan_sampler.hpp" + +namespace maj0sted::web { + +using namespace maj0sted::domain; + +namespace { + +std::optional optional_length(double metres) { + if (metres > 0.0) return Length::from_metres(metres); + return std::nullopt; +} + +FitResult make_fit(const Niweleta& niweleta, StraightId entry, StraightId exit, + const SolveRequest& request) { + if (request.mode == 2) { + return FittingService::fit_compound( + niweleta, + CompoundFitParameters{ + .entry = entry, + .exit = exit, + .entry_transition = optional_length(request.entry_t), + .exit_transition = optional_length(request.exit_t), + .arcs = {CompoundArc{Radius::from_metres(request.r1), + Length::from_metres(request.arc1_len), + optional_length(request.between)}, + CompoundArc{Radius::from_metres(request.r2)}}}); + } + return FittingService::fit_between_straights( + niweleta, + FitParameters{.entry = entry, + .exit = exit, + .radius = Radius::from_metres(request.radius), + .transition_length = request.mode == 1 + ? optional_length(request.transition) + : std::nullopt}); +} + +std::vector to_polylines(const render::Scene& scene) { + std::vector result; + result.reserve(scene.polylines.size()); + for (const auto& polyline : scene.polylines) { + WebPolyline out; + out.kind = static_cast(polyline.kind); + out.points.reserve(polyline.points.size()); + for (const auto& p : polyline.points) { + out.points.push_back(WebPoint{p.x, p.y}); + } + result.push_back(std::move(out)); + } + return result; +} + +} // namespace + +std::vector solve_scene(const SolveRequest& request) { + Niweleta niweleta{NiweletaId{1}}; + const CartesianPosition a{request.ax, request.ay}; + const CartesianPosition b{request.bx, request.by}; + const CartesianPosition c{request.cx, request.cy}; + + StraightId entry; + try { + entry = niweleta.add_straight(a, b); + } catch (...) { + return {}; // degenerate entry straight + } + + bool has_exit = false; + StraightId exit; + try { + exit = niweleta.add_straight(b, c, JointContinuity::AzimuthBreak); + has_exit = true; + } catch (...) { + try { + exit = niweleta.add_straight(b, c, JointContinuity::Tangent); + has_exit = true; + } catch (...) { + } + } + + if (has_exit) { + try { + niweleta.apply_fit(make_fit(niweleta, entry, exit, request)); + } catch (...) { + // Parameters don't admit a fit — show the bare straights instead. + } + } + + return to_polylines(render::sample(niweleta.plan())); +} + +} // namespace maj0sted::web diff --git a/maj0sted/src/web/tile_cache.cpp b/maj0sted/src/web/tile_cache.cpp new file mode 100644 index 00000000..4022984a --- /dev/null +++ b/maj0sted/src/web/tile_cache.cpp @@ -0,0 +1,326 @@ +#include "maj0sted/web/tile_cache.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include + +#include +#include +#endif + +namespace maj0sted::web { + +namespace { + +// Shortest round-trippable, locale-independent representation of a double — +// same convention as maj0sted::io so URLs are stable and precise. +std::string num(double value) { + char buffer[64]; + const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value); + return std::string(buffer, result.ptr); +} + +// Maximum number of retry attempts per tile after the initial fetch fails. +constexpr int kMaxRetries = 10; + +} // namespace + +// --- TileGrid ------------------------------------------------------------ + +double TileGrid::resolution(int /*zoom*/) noexcept { + return kCellMetres / static_cast(kTilePixels); // constant, single grid +} + +double TileGrid::tile_span(int /*zoom*/) noexcept { + return kCellMetres; // every cell is 100 m × 100 m +} + +TileKey TileGrid::tile_at(double x, double y, int /*zoom*/) noexcept { + const auto safe_index = [](double coordinate, double origin) { + const double value = std::floor((coordinate - origin) / kCellMetres); + if (!std::isfinite(value)) return 0; + if (value <= static_cast(std::numeric_limits::min())) { + return std::numeric_limits::min(); + } + if (value >= static_cast(std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + return static_cast(value); + }; + return TileKey{kLevel, safe_index(x, kOriginX), safe_index(y, kOriginY)}; +} + +TileBBox TileGrid::bbox(const TileKey& key) noexcept { + const double min_x = kOriginX + static_cast(key.x) * kCellMetres; + const double min_y = kOriginY + static_cast(key.y) * kCellMetres; + return TileBBox{min_x, min_y, min_x + kCellMetres, min_y + kCellMetres}; +} + +int TileGrid::zoom_for_resolution(double /*metres_per_pixel*/) noexcept { + return kLevel; // one fixed 100 m grid, no pyramid +} + +std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/, + int max_tiles) { + std::vector tiles; + if (max_tiles <= 0 || !std::isfinite(view.min_x) || + !std::isfinite(view.min_y) || !std::isfinite(view.max_x) || + !std::isfinite(view.max_y)) { + return tiles; + } + const double min_x = std::min(view.min_x, view.max_x); + const double max_x = std::max(view.min_x, view.max_x); + const double min_y = std::min(view.min_y, view.max_y); + const double max_y = std::max(view.min_y, view.max_y); + + const TileKey lo = tile_at(min_x, min_y); + const TileKey hi = tile_at(max_x, max_y); + + using Wide = std::int64_t; + const Wide width = static_cast(hi.x) - lo.x + 1; + const Wide height = static_cast(hi.y) - lo.y + 1; + if (width <= 0 || height <= 0) return tiles; + + Wide columns = width; + Wide rows = height; + const Wide cap = max_tiles; + if (height > cap || width > cap / height) { + // A fixed 100 m grid can contain millions of cells when zoomed far out. + // Return a small, viewport-centred window instead of walking from a + // remote corner or allocating/requesting the whole range. + const long double aspect = + static_cast(width) / static_cast(height); + columns = std::clamp( + static_cast(std::sqrt(static_cast(cap) * aspect)), + 1, std::min(width, cap)); + rows = std::clamp(cap / columns, 1, height); + } + + const Wide centre_x = + std::midpoint(static_cast(lo.x), static_cast(hi.x)); + const Wide centre_y = + std::midpoint(static_cast(lo.y), static_cast(hi.y)); + const Wide first_col = std::clamp( + centre_x - columns / 2, static_cast(lo.x), + static_cast(hi.x) - columns + 1); + const Wide first_row = std::clamp( + centre_y - rows / 2, static_cast(lo.y), + static_cast(hi.y) - rows + 1); + + tiles.reserve(static_cast(columns * rows)); + for (Wide row = first_row; row < first_row + rows; ++row) { + for (Wide col = first_col; col < first_col + columns; ++col) { + tiles.push_back( + TileKey{kLevel, static_cast(col), static_cast(row)}); + } + } + return tiles; +} + +// --- WmsConfig ----------------------------------------------------------- + +WmsConfig WmsConfig::geoportal_ortho() { + WmsConfig cfg; + // Public Geoportal ortophoto WMS (EPSG:2180). If this endpoint blocks CORS + // for emscripten_fetch, swap base_url/layers via set_config() from the host. + cfg.base_url = + "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution"; + cfg.layers = "Raster"; + cfg.format = "image/jpeg"; + return cfg; +} + +WmsConfig WmsConfig::geoportal_topo() { + WmsConfig cfg; + cfg.base_url = + "https://mapy.geoportal.gov.pl/wss/service/img/guest/TOPO/MapServer/WMSServer"; + cfg.layers = "Raster"; + cfg.format = "image/png"; + return cfg; +} + +std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box) { + std::string url = cfg.base_url; + url += (url.find('?') == std::string::npos) ? '?' : '&'; + url += "SERVICE=WMS"; + url += "&VERSION=" + cfg.version; + url += "&REQUEST=GetMap"; + url += "&LAYERS=" + cfg.layers; + url += "&STYLES="; + url += "&SRS=" + cfg.srs; + url += "&FORMAT=" + cfg.format; + url += "&WIDTH=" + std::to_string(cfg.tile_pixels); + url += "&HEIGHT=" + std::to_string(cfg.tile_pixels); + // WMS 1.1.1: BBOX = minx,miny,maxx,maxy in the SRS axis order (easting, + // northing for EPSG:2180). + url += "&BBOX=" + num(box.min_x) + "," + num(box.min_y) + "," + num(box.max_x) + + "," + num(box.max_y); + return url; +} + +// --- TileService --------------------------------------------------------- + +TileService::TileService(WmsConfig cfg) : cfg_{std::move(cfg)} {} + +std::string TileService::url_for(const TileKey& key) const { + return wms_getmap_url(cfg_, TileGrid::bbox(key)); +} + +bool TileService::has(const TileKey& key) const { + const auto it = cache_.find(key); + return it != cache_.end() && it->second.status == TileStatus::Ready; +} + +const TileImage* TileService::peek(const TileKey& key) const { + const auto it = cache_.find(key); + if (it != cache_.end() && it->second.status == TileStatus::Ready) { + return &it->second; + } + return nullptr; +} + +void TileService::store(const TileKey& key, std::vector bytes, + std::string content_type) { + TileImage& image = cache_[key]; + image.status = TileStatus::Ready; + image.bytes = std::move(bytes); + image.content_type = std::move(content_type); +} + +TileStatus TileService::request(const TileKey& key) { + const auto it = cache_.find(key); + if (it != cache_.end()) { + // A tile that gave up (10 failed attempts) is retried the next time it is + // asked for (a pan/zoom/toggle), so the underlay self-heals once the + // endpoint is reachable again — no page reload needed. + if (it->second.status == TileStatus::Failed) { + it->second.attempts = 0; + it->second.status = TileStatus::Loading; + begin_fetch(key); + } + return it->second.status; // otherwise: hit, no network + } + + cache_[key].status = TileStatus::Loading; + begin_fetch(key); + return cache_[key].status; +} + +void TileService::complete_fetch(const TileKey& key, bool ok, + std::vector bytes, + std::string content_type) { + if (ok) { + store(key, std::move(bytes), std::move(content_type)); + if (on_ready_) on_ready_(key); + return; + } + // Retry up to kMaxRetries times per tile; after that give up (Failed). + TileImage& image = cache_[key]; + if (++image.attempts <= kMaxRetries) { + image.status = TileStatus::Loading; + schedule_retry(key); + } else { + // Give up quietly: do NOT notify the host, so a Failed tile does not by + // itself trigger a redraw → re-request → refetch loop in the background. + // It is retried only on the next user-driven request (see request()). + image.status = TileStatus::Failed; + } +} + +#ifdef __EMSCRIPTEN__ +namespace { +// Delay between retry attempts, so a persistently failing endpoint is retried +// steadily rather than in a tight loop. +constexpr int kRetryDelayMs = 1500; + +struct FetchCtx { + TileService* self; + TileKey key; +}; + +struct RetryCtx { + TileService* self; + TileKey key; +}; + +void on_retry(void* user_data) { + auto* ctx = static_cast(user_data); + TileService* self = ctx->self; + const TileKey key = ctx->key; + delete ctx; + self->retry_now(key); +} + +void on_fetch_success(emscripten_fetch_t* fetch) { + auto* ctx = static_cast(fetch->userData); + std::vector bytes( + reinterpret_cast(fetch->data), + reinterpret_cast(fetch->data) + + static_cast(fetch->numBytes)); + TileService* self = ctx->self; + const TileKey key = ctx->key; + delete ctx; + emscripten_fetch_close(fetch); + self->complete_fetch(key, true, std::move(bytes), {}); +} + +void on_fetch_error(emscripten_fetch_t* fetch) { + auto* ctx = static_cast(fetch->userData); + TileService* self = ctx->self; + const TileKey key = ctx->key; + delete ctx; + emscripten_fetch_close(fetch); + self->complete_fetch(key, false, {}, {}); +} +} // namespace +#endif + +void TileService::begin_fetch(const TileKey& key) { + const std::string url = url_for(key); + + // An injected fetcher wins on every platform (used by native tests and any + // host that wants to route the request itself). + if (fetcher_) { + const TileKey captured = key; + fetcher_(url, [this, captured](bool ok, std::vector bytes, + std::string content_type) { + complete_fetch(captured, ok, std::move(bytes), std::move(content_type)); + }); + return; + } + +#ifdef __EMSCRIPTEN__ + emscripten_fetch_attr_t attr; + emscripten_fetch_attr_init(&attr); + std::strcpy(attr.requestMethod, "GET"); + attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY; + attr.userData = new FetchCtx{this, key}; + attr.onsuccess = on_fetch_success; + attr.onerror = on_fetch_error; + emscripten_fetch(&attr, url.c_str()); +#else + // No network backend available (native, no injected fetcher): mark failed. + complete_fetch(key, false, {}, {}); +#endif +} + +void TileService::retry_now(const TileKey& key) { begin_fetch(key); } + +void TileService::schedule_retry(const TileKey& key) { +#ifdef __EMSCRIPTEN__ + // Defer asynchronously: infinite retry without blocking or recursion. + emscripten_async_call(&on_retry, new RetryCtx{this, key}, kRetryDelayMs); +#else + // Injected-fetcher path (native/tests): re-issue immediately. The fetcher + // controls timing, so a fetcher that eventually succeeds ends the loop. + begin_fetch(key); +#endif +} + +} // namespace maj0sted::web diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt new file mode 100644 index 00000000..3b908224 --- /dev/null +++ b/maj0sted/tests/CMakeLists.txt @@ -0,0 +1,49 @@ +add_executable(maj0sted_test_map_project test_map_project.cpp) +target_link_libraries(maj0sted_test_map_project PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_map_project PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME map_project COMMAND maj0sted_test_map_project) + +add_executable(maj0sted_test_niweleta test_niweleta.cpp) +target_link_libraries(maj0sted_test_niweleta PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_niweleta PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME niweleta COMMAND maj0sted_test_niweleta) + +add_executable(maj0sted_test_plan_alignment test_plan_alignment.cpp) +target_link_libraries(maj0sted_test_plan_alignment PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_plan_alignment PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME plan_alignment COMMAND maj0sted_test_plan_alignment) + +add_executable(maj0sted_test_parallelism test_parallelism.cpp) +target_link_libraries(maj0sted_test_parallelism PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_parallelism PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME parallelism COMMAND maj0sted_test_parallelism) + +add_executable(maj0sted_test_fitting test_fitting.cpp) +target_link_libraries(maj0sted_test_fitting PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_fitting PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME fitting COMMAND maj0sted_test_fitting) + +add_executable(maj0sted_test_serialization test_serialization.cpp) +target_link_libraries(maj0sted_test_serialization PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_serialization PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME serialization COMMAND maj0sted_test_serialization) + +add_executable(maj0sted_test_render test_render.cpp) +target_link_libraries(maj0sted_test_render PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_render PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME render COMMAND maj0sted_test_render) + +add_executable(maj0sted_test_web test_web.cpp) +target_link_libraries(maj0sted_test_web PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_web PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME web COMMAND maj0sted_test_web) + +add_executable(maj0sted_test_tile_cache test_tile_cache.cpp) +target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME tile_cache COMMAND maj0sted_test_tile_cache) + +add_executable(maj0sted_test_document test_document.cpp) +target_link_libraries(maj0sted_test_document PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_document PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME document COMMAND maj0sted_test_document) diff --git a/maj0sted/tests/check.hpp b/maj0sted/tests/check.hpp new file mode 100644 index 00000000..3f500df1 --- /dev/null +++ b/maj0sted/tests/check.hpp @@ -0,0 +1,51 @@ +#pragma once + +// A tiny zero-dependency check framework so the test suite needs nothing but a +// C++20 compiler. Each test executable returns 0 on success, 1 on any failure, +// which is exactly what CTest interprets as pass/fail. + +#include + +namespace maj0test { +inline int checks = 0; +inline int failures = 0; +} // namespace maj0test + +// Variadic so that brace-init lists with commas, e.g. CHECK(Point{1, 2} == ...), +// are passed through as a single condition instead of several macro arguments. +#define CHECK(...) \ + do { \ + ++::maj0test::checks; \ + if (!(__VA_ARGS__)) { \ + ++::maj0test::failures; \ + std::printf(" [FAIL] %s:%d CHECK(%s)\n", __FILE__, __LINE__, \ + #__VA_ARGS__); \ + } \ + } while (0) + +#define CHECK_THROWS(...) \ + do { \ + ++::maj0test::checks; \ + bool threw = false; \ + try { \ + (void)(__VA_ARGS__); \ + } catch (...) { \ + threw = true; \ + } \ + if (!threw) { \ + ++::maj0test::failures; \ + std::printf(" [FAIL] %s:%d CHECK_THROWS(%s)\n", __FILE__, \ + __LINE__, #__VA_ARGS__); \ + } \ + } while (0) + +#define RUN(fn) \ + do { \ + std::printf("[RUN ] %s\n", #fn); \ + fn(); \ + } while (0) + +#define REPORT() \ + (std::printf("\n%d checks, %d failures\n", ::maj0test::checks, \ + ::maj0test::failures), \ + ::maj0test::failures == 0 ? 0 : 1) diff --git a/maj0sted/tests/test_document.cpp b/maj0sted/tests/test_document.cpp new file mode 100644 index 00000000..074347ba --- /dev/null +++ b/maj0sted/tests/test_document.cpp @@ -0,0 +1,123 @@ +#include +#include + +#include "check.hpp" +#include "maj0sted/app/editor_document.hpp" + +using namespace maj0sted::app; +using maj0sted::web::GapFit; +using maj0sted::web::NiweletaSpec; +using maj0sted::web::StraightSpec; + +namespace { + +EditorDocument sample_document() { + EditorDocument doc; + + NiweletaSpec a; + a.name = "Niweleta 1"; + a.straights = {StraightSpec{0.0, 0.0, 120.0, 0.0}, + StraightSpec{120.0, 0.0, 120.0, 120.0, true}}; + GapFit fit; + fit.gap = 0; + fit.mode = 2; // arc + transitions + fit.radius = 40.0; + fit.transition = 12.5; + a.fits = {fit}; + + NiweletaSpec b; + b.name = "Bocznica z spacją"; + b.straights = {StraightSpec{500000.25, 300000.75, 500200.0, 300000.0}}; + + doc.niwelety = {a, b}; + return doc; +} + +bool same(const StraightSpec& x, const StraightSpec& y) { + return x.x1 == y.x1 && x.y1 == y.y1 && x.x2 == y.x2 && x.y2 == y.y2 && + x.hidden == y.hidden; +} + +bool same(const GapFit& x, const GapFit& y) { + return x.gap == y.gap && x.mode == y.mode && x.radius == y.radius && + x.transition == y.transition && x.r1 == y.r1 && x.arc1_len == y.arc1_len && + x.between == y.between && x.r2 == y.r2 && x.entry_t == y.entry_t && + x.exit_t == y.exit_t; +} + +void check_equal(const EditorDocument& x, const EditorDocument& y) { + CHECK(x.niwelety.size() == y.niwelety.size()); + if (x.niwelety.size() != y.niwelety.size()) return; + for (std::size_t n = 0; n < x.niwelety.size(); ++n) { + const auto& a = x.niwelety[n]; + const auto& b = y.niwelety[n]; + CHECK(a.name == b.name); + CHECK(a.straights.size() == b.straights.size()); + for (std::size_t i = 0; i < a.straights.size() && i < b.straights.size(); ++i) { + CHECK(same(a.straights[i], b.straights[i])); + } + CHECK(a.fits.size() == b.fits.size()); + for (std::size_t i = 0; i < a.fits.size() && i < b.fits.size(); ++i) { + CHECK(same(a.fits[i], b.fits[i])); + } + } +} + +void serialize_round_trips_exactly() { + const EditorDocument doc = sample_document(); + const std::string text = serialize_document(doc); + const EditorDocument back = deserialize_document(text); + check_equal(doc, back); + + // The file really embeds a maj0sted project (reuses io::serialize). + CHECK(text.rfind("maj0sted", 0) == 0); + CHECK(text.find("\neditor 2\n") != std::string::npos); + + // Serialization is stable (byte-for-byte) across a round trip. + CHECK(serialize_document(back) == text); +} + +void embedded_project_is_valid_and_readable() { + const EditorDocument doc = sample_document(); + // to_map_project keeps one niweleta per editor niweleta and the straights. + const auto project = to_map_project(doc); + CHECK(project.niwelety().size() == 2); + const EditorDocument imported = from_map_project(project); + CHECK(imported.niwelety.size() == 2); + CHECK(imported.niwelety[0].straights.size() == 2); + CHECK(imported.niwelety[0].name == "Niweleta 1"); +} + +void save_then_load_from_disk() { + const EditorDocument doc = sample_document(); + const std::filesystem::path dir = + std::filesystem::temp_directory_path() / "maj0sted_test"; + std::filesystem::remove_all(dir); + const std::string path = (dir / "sub" / "proj.m0s").string(); + + CHECK(save_project(doc, path)); // creates parent dirs + CHECK(std::filesystem::exists(path)); + + const auto loaded = load_project(path); + CHECK(loaded.has_value()); + if (loaded) check_equal(doc, *loaded); + + std::filesystem::remove_all(dir); +} + +void load_missing_file_is_nullopt() { + const std::string path = + (std::filesystem::temp_directory_path() / "maj0sted_nope_12345.m0s").string(); + std::filesystem::remove(path); + CHECK(!load_project(path).has_value()); +} + +} // namespace + +int main() { + RUN(serialize_round_trips_exactly); + RUN(embedded_project_is_valid_and_readable); + RUN(save_then_load_from_disk); + RUN(load_missing_file_is_nullopt); + return REPORT(); +} diff --git a/maj0sted/tests/test_fitting.cpp b/maj0sted/tests/test_fitting.cpp new file mode 100644 index 00000000..27b75048 --- /dev/null +++ b/maj0sted/tests/test_fitting.cpp @@ -0,0 +1,346 @@ +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/maj0sted.hpp" + +using namespace maj0sted::domain; + +namespace { + +// A 90-degree left corner: entry heads east into (100,0), exit leaves north. +Niweleta make_corner() { + Niweleta n{NiweletaId{1}, "Corner"}; + n.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0}, + CartesianPosition{100.0, 0.0}}); + n.add_plan_element(Straight{StraightId{2}, CartesianPosition{100.0, 0.0}, + CartesianPosition{100.0, 100.0}}, + JointContinuity::AzimuthBreak); + return n; +} + +void fit_plain_arc() { + const Niweleta corner = make_corner(); + const FitParameters params{.entry = StraightId{1}, + .exit = StraightId{2}, + .radius = Radius::from_metres(50.0), + .transition_length = std::nullopt}; + const FitResult fit = FittingService::fit_between_straights(corner, params); + + CHECK(fit.direction == TurnDirection::Left); + + // Tangent length = R*tan(45) = 50 -> tangent points at (50,0) and (100,50). + CHECK(std::abs(fit.tangent_in.x() - 50.0) < 1e-6); + CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6); + CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6); + CHECK(std::abs(fit.tangent_out.y() - 50.0) < 1e-6); + + // The trimmed straights keep their ids and outer endpoints. + CHECK(fit.entry.id() == StraightId{1}); + CHECK(std::abs(fit.entry.end().x() - 50.0) < 1e-6); + CHECK(fit.exit.id() == StraightId{2}); + CHECK(std::abs(fit.exit.start().y() - 50.0) < 1e-6); + CHECK(std::abs(fit.exit.end().y() - 100.0) < 1e-6); + + // A single circular arc, length R*delta = 50*pi/2. + CHECK(fit.curve.size() == 1); + const auto* arc = std::get_if(&fit.curve.front()); + CHECK(arc != nullptr); + CHECK(arc->direction() == TurnDirection::Left); + CHECK(arc->length().has_value()); + CHECK(std::abs(arc->length().value().metres() - 50.0 * std::numbers::pi / 2.0) < 1e-6); +} + +void fit_with_symmetric_transitions() { + const Niweleta corner = make_corner(); + const FitParameters params{.entry = StraightId{1}, + .exit = StraightId{2}, + .radius = Radius::from_metres(50.0), + .transition_length = Length::from_metres(10.0)}; + const FitResult fit = FittingService::fit_between_straights(corner, params); + + // klotoida -> łuk -> klotoida + CHECK(fit.curve.size() == 3); + CHECK(std::holds_alternative(fit.curve[0])); + CHECK(std::holds_alternative(fit.curve[1])); + CHECK(std::holds_alternative(fit.curve[2])); + + // Entry transition runs from an infinite radius (straight) into R. + const auto& entry_kp = std::get(fit.curve[0]); + CHECK(!entry_kp.start_radius().has_value()); + CHECK(entry_kp.end_radius().has_value()); + CHECK(entry_kp.length().metres() == 10.0); + + // Arc central angle = delta - 2*tau, tau = Lk/(2R) = 0.1 -> arc len = 50*(pi/2 - 0.2). + const auto& arc = std::get(fit.curve[1]); + const double expected = 50.0 * (std::numbers::pi / 2.0 - 0.2); + CHECK(arc.length().has_value()); + CHECK(std::abs(arc.length().value().metres() - expected) < 1e-6); + + // Entry tangent point from the exact clothoid layout (not the L^2/24R + // approximation, which was ~1.8 mm off and caused a visible lateral gap). + CHECK(std::abs(fit.tangent_in.x() - 44.9185491305) < 1e-6); +} + +void invalid_inputs_are_rejected() { + const Niweleta corner = make_corner(); + const Radius r = Radius::from_metres(50.0); + + // Same straight. + CHECK_THROWS(FittingService::fit_between_straights( + corner, FitParameters{StraightId{1}, StraightId{1}, r, std::nullopt})); + // Unknown id. + CHECK_THROWS(FittingService::fit_between_straights( + corner, FitParameters{StraightId{1}, StraightId{99}, r, std::nullopt})); + + // Collinear straights -> nothing to fit. + Niweleta line{NiweletaId{2}}; + line.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0}, + CartesianPosition{50.0, 0.0}}); + line.add_plan_element(Straight{StraightId{2}, CartesianPosition{50.0, 0.0}, + CartesianPosition{100.0, 0.0}}); + CHECK_THROWS(FittingService::fit_between_straights( + line, FitParameters{StraightId{1}, StraightId{2}, r, std::nullopt})); + + // Transitions too long for this radius/deflection. + CHECK_THROWS(FittingService::fit_between_straights( + corner, FitParameters{StraightId{1}, StraightId{2}, r, + Length::from_metres(200.0)})); +} + +void fit_compound_curve_two_arcs() { + const Niweleta corner = make_corner(); // 90-degree left corner, IP at (100,0) + + // First arc delta1 = 45 deg -> length = R1 * pi/4 = 60 * pi/4; last arc computed. + const CompoundFitParameters params{ + .entry = StraightId{1}, + .exit = StraightId{2}, + .arcs = {CompoundArc{Radius::from_metres(60.0), + Length::from_metres(60.0 * std::numbers::pi / 4.0)}, + CompoundArc{Radius::from_metres(40.0), std::nullopt}}}; + const FitResult fit = FittingService::fit_compound(corner, params); + + CHECK(fit.direction == TurnDirection::Left); + CHECK(fit.curve.size() == 2); + const auto& arc1 = std::get(fit.curve[0]); + const auto& arc2 = std::get(fit.curve[1]); + CHECK(arc1.radius().metres() == 60.0); + CHECK(arc2.radius().metres() == 40.0); + + // Same hand-computed geometry as before (rigid-chain solver reproduces it). + CHECK(std::abs(arc1.length().value().metres() - 60.0 * std::numbers::pi / 4.0) < 1e-6); + CHECK(std::abs(arc2.length().value().metres() - 40.0 * std::numbers::pi / 4.0) < 1e-6); + CHECK(std::abs(fit.tangent_in.x() - 45.85786) < 1e-4); + CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-4); + CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-4); + CHECK(std::abs(fit.tangent_out.y() - 45.85786) < 1e-4); +} + +void fit_compound_curve_three_arcs() { + const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100 + + // 30 + 30 + (remaining 30) degrees, radii 80 / 50 / 30. + const CompoundFitParameters params{ + .entry = StraightId{1}, + .exit = StraightId{2}, + .arcs = {CompoundArc{Radius::from_metres(80.0), + Length::from_metres(80.0 * std::numbers::pi / 6.0)}, + CompoundArc{Radius::from_metres(50.0), + Length::from_metres(50.0 * std::numbers::pi / 6.0)}, + CompoundArc{Radius::from_metres(30.0), std::nullopt}}}; + const FitResult fit = FittingService::fit_compound(corner, params); + + CHECK(fit.curve.size() == 3); + CHECK(std::get(fit.curve[0]).radius().metres() == 80.0); + CHECK(std::get(fit.curve[1]).radius().metres() == 50.0); + CHECK(std::get(fit.curve[2]).radius().metres() == 30.0); + + // Last arc takes the remaining 30 deg -> length = 30 * pi/6. + CHECK(std::abs(std::get(fit.curve[2]).length().value().metres() - + 30.0 * std::numbers::pi / 6.0) < 1e-6); + + // The chain closes onto both fixed lines: tangent_in on y=0, tangent_out on x=100. + CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6); + CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6); + CHECK(fit.direction == TurnDirection::Left); +} + +void fit_compound_with_transitions() { + const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100 + + const CompoundFitParameters params{ + .entry = StraightId{1}, + .exit = StraightId{2}, + .entry_transition = Length::from_metres(10.0), + .exit_transition = Length::from_metres(10.0), + .arcs = {CompoundArc{Radius::from_metres(60.0), Length::from_metres(20.0), + Length::from_metres(8.0)}, // arc(60), KP 60->40 of 8 m + CompoundArc{Radius::from_metres(40.0), std::nullopt, std::nullopt}}}; + const FitResult fit = FittingService::fit_compound(corner, params); + + // Sequence: KP -> arc(60) -> KP(60->40) -> arc(40) -> KP. + CHECK(fit.curve.size() == 5); + CHECK(std::holds_alternative(fit.curve[0])); + CHECK(std::holds_alternative(fit.curve[1])); + CHECK(std::holds_alternative(fit.curve[2])); + CHECK(std::holds_alternative(fit.curve[3])); + CHECK(std::holds_alternative(fit.curve[4])); + + // Entry transition: straight (inf) -> R60. + const auto& kp_in = std::get(fit.curve[0]); + CHECK(!kp_in.start_radius().has_value()); + CHECK(kp_in.end_radius().value().metres() == 60.0); + CHECK(kp_in.length().metres() == 10.0); + + // Between transition: R60 -> R40. + const auto& kp_mid = std::get(fit.curve[2]); + CHECK(kp_mid.start_radius().value().metres() == 60.0); + CHECK(kp_mid.end_radius().value().metres() == 40.0); + CHECK(kp_mid.length().metres() == 8.0); + + // Exit transition: R40 -> straight (inf). + const auto& kp_out = std::get(fit.curve[4]); + CHECK(kp_out.start_radius().value().metres() == 40.0); + CHECK(!kp_out.end_radius().has_value()); + + // The chain still closes onto both fixed lines. + CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6); + CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6); + CHECK(fit.direction == TurnDirection::Left); +} + +void compound_invalid_inputs_are_rejected() { + const Niweleta corner = make_corner(); + const auto arc = [](double r, double len) { + return CompoundArc{Radius::from_metres(r), Length::from_metres(len)}; + }; + const auto last = [](double r) { return CompoundArc{Radius::from_metres(r)}; }; + + // Equal neighbouring radii. + CHECK_THROWS(FittingService::fit_compound( + corner, CompoundFitParameters{.entry = StraightId{1}, + .exit = StraightId{2}, + .arcs = {arc(50.0, 10.0), last(50.0)}})); + + // Split lengths exceed the total deflection. + CHECK_THROWS(FittingService::fit_compound( + corner, CompoundFitParameters{.entry = StraightId{1}, + .exit = StraightId{2}, + .arcs = {arc(60.0, 1000.0), last(40.0)}})); + + // A non-last arc without a length. + CHECK_THROWS(FittingService::fit_compound( + corner, CompoundFitParameters{.entry = StraightId{1}, + .exit = StraightId{2}, + .arcs = {last(60.0), last(40.0)}})); +} + +void fit_equal_radius_reverse_curve_between_parallel_straights() { + const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0}, + CartesianPosition{200.0, 0.0}}; + const Straight exit{StraightId{2}, CartesianPosition{0.0, 20.0}, + CartesianPosition{200.0, 20.0}}; + + const FitResult fit = + FittingService::fit_reverse(entry, exit, Radius::from_metres(50.0)); + CHECK(fit.curve.size() == 2); + const auto& first = std::get(fit.curve[0]); + const auto& second = std::get(fit.curve[1]); + CHECK(first.radius() == second.radius()); + CHECK(first.radius() == Radius::from_metres(50.0)); + CHECK(first.direction() != second.direction()); + CHECK(first.length().has_value()); + CHECK(second.length().has_value()); + CHECK(std::abs(first.length()->metres() - second.length()->metres()) < 1e-9); + CHECK(std::abs(fit.tangent_in.y()) < 1e-9); + CHECK(std::abs(fit.tangent_out.y() - 20.0) < 1e-6); + CHECK(fit.tangent_in.x() > 0.0 && fit.tangent_in.x() <= 200.0); + CHECK(fit.tangent_out.x() >= 0.0 && fit.tangent_out.x() < 200.0); +} + +void reverse_curve_rejects_collinear_and_antiparallel_straights() { + const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0}, + CartesianPosition{100.0, 0.0}}; + const Straight collinear{StraightId{2}, CartesianPosition{0.0, 0.0}, + CartesianPosition{100.0, 0.0}}; + const Straight antiparallel{StraightId{3}, CartesianPosition{100.0, 20.0}, + CartesianPosition{0.0, 20.0}}; + const Radius radius = Radius::from_metres(30.0); + + CHECK_THROWS(FittingService::fit_reverse(entry, collinear, radius)); + CHECK_THROWS(FittingService::fit_reverse(entry, antiparallel, radius)); +} + +void fit_reverse_curve_between_nonparallel_straights() { + const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0}, + CartesianPosition{100.0, 0.0}}; + // This exit line is constructed around the end pose of a R=30 m chain: + // left 60 degrees followed by right 30 degrees. + const double heading = std::numbers::pi / 6.0; + const double tangent_x = 86.9615242271; + const double tangent_y = 25.9807621135; + const double dx = std::cos(heading); + const double dy = std::sin(heading); + const Straight exit{ + StraightId{2}, + CartesianPosition{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy}, + CartesianPosition{tangent_x + 70.0 * dx, tangent_y + 70.0 * dy}}; + + const FitResult fit = + FittingService::fit_reverse(entry, exit, Radius::from_metres(30.0)); + CHECK(fit.curve.size() == 2); + const auto& first = std::get(fit.curve[0]); + const auto& second = std::get(fit.curve[1]); + CHECK(first.direction() != second.direction()); + CHECK(first.radius() == second.radius()); + CHECK(std::abs(fit.tangent_in.y()) < 1e-6); + const double exit_cross = + (fit.tangent_out.x() - exit.start().x()) * dy - + (fit.tangent_out.y() - exit.start().y()) * dx; + CHECK(std::abs(exit_cross) < 1e-5); +} + +void apply_fit_replaces_straights_in_plan() { + Niweleta corner = make_corner(); // straight(1) --break--> straight(2) + const FitParameters params{.entry = StraightId{1}, + .exit = StraightId{2}, + .radius = Radius::from_metres(50.0), + .transition_length = std::nullopt}; + const FitResult fit = FittingService::fit_between_straights(corner, params); + + corner.apply_fit(fit); + + // Plan is now: straight(1) -> arc -> straight(2), tangent joints, no break. + CHECK(corner.plan().size() == 3); + CHECK(std::holds_alternative(corner.plan().elements()[0])); + CHECK(std::holds_alternative(corner.plan().elements()[1])); + CHECK(std::holds_alternative(corner.plan().elements()[2])); + CHECK(corner.plan().joint(0) == JointContinuity::Tangent); + CHECK(corner.plan().joint(1) == JointContinuity::Tangent); + + // Straights keep their ids and are trimmed to the tangent points. + const auto& first = std::get(corner.plan().elements()[0]); + const auto& last = std::get(corner.plan().elements()[2]); + CHECK(first.id() == StraightId{1}); + CHECK(last.id() == StraightId{2}); + CHECK(std::abs(first.end().x() - 50.0) < 1e-6); + CHECK(std::abs(last.start().y() - 50.0) < 1e-6); +} + +} // namespace + +int main() { + RUN(fit_plain_arc); + RUN(fit_with_symmetric_transitions); + RUN(invalid_inputs_are_rejected); + RUN(fit_compound_curve_two_arcs); + RUN(fit_compound_curve_three_arcs); + RUN(fit_compound_with_transitions); + RUN(compound_invalid_inputs_are_rejected); + RUN(fit_equal_radius_reverse_curve_between_parallel_straights); + RUN(reverse_curve_rejects_collinear_and_antiparallel_straights); + RUN(fit_reverse_curve_between_nonparallel_straights); + RUN(apply_fit_replaces_straights_in_plan); + return REPORT(); +} diff --git a/maj0sted/tests/test_map_project.cpp b/maj0sted/tests/test_map_project.cpp new file mode 100644 index 00000000..5cd09d1b --- /dev/null +++ b/maj0sted/tests/test_map_project.cpp @@ -0,0 +1,25 @@ +#include "check.hpp" +#include "maj0sted/maj0sted.hpp" + +using namespace maj0sted::domain; + +int main() { + // A map project defaults to the Cartesian CRS EPSG:2180. + const MapProject project; + CHECK(project.crs() == Crs::default_crs()); + CHECK(project.crs().epsg() == 2180); + + // An explicit CRS is honoured. + const MapProject wgs84{Crs{4326}}; + CHECK(wgs84.crs().epsg() == 4326); + + // Cartesian position value object. + const CartesianPosition p{100.5, 200.25}; + CHECK(p.x() == 100.5); + CHECK(p.y() == 200.25); + CHECK(CartesianPosition{1.0, 2.0} == CartesianPosition{1.0, 2.0}); + CHECK(CartesianPosition{1.0, 2.0} != CartesianPosition{2.0, 1.0}); + CHECK(CartesianPosition{}.x() == 0.0); + + return REPORT(); +} diff --git a/maj0sted/tests/test_niweleta.cpp b/maj0sted/tests/test_niweleta.cpp new file mode 100644 index 00000000..7c99a978 --- /dev/null +++ b/maj0sted/tests/test_niweleta.cpp @@ -0,0 +1,110 @@ +#include + +#include "check.hpp" +#include "maj0sted/maj0sted.hpp" + +using namespace maj0sted::domain; + +namespace { + +void plan_and_profile_element_lengths() { + const PlanElement straight = + Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}; + const PlanElement arc = CircularArc{Radius::from_metres(300.0), + TurnDirection::Left, + Length::from_metres(50.0)}; + const PlanElement transition = TransitionCurve{ + Length::from_metres(30.0), std::nullopt, Radius::from_metres(300.0), + TurnDirection::Left}; + CHECK(length_of(straight).value().metres() == 100.0); + CHECK(length_of(arc).value().metres() == 50.0); + CHECK(length_of(transition).value().metres() == 30.0); + + const ProfileElement grade = Grade{Length::from_metres(120.0), 5.0}; + const ProfileElement vcurve = VerticalCurve{ + Length::from_metres(60.0), Radius::from_metres(4000.0), + VerticalCurvature::Crest}; + CHECK(length_of(grade).metres() == 120.0); + CHECK(length_of(vcurve).metres() == 60.0); +} + +void value_objects_guard_invariants() { + CHECK_THROWS(Length::from_metres(-1.0)); + CHECK_THROWS(Radius::from_metres(0.0)); + CHECK_THROWS(Radius::from_metres(-5.0)); +} + +void niweleta_aggregates_plan_and_profile() { + Niweleta n{NiweletaId{1}, "Odcinek 1"}; + CHECK(n.name() == "Odcinek 1"); + CHECK(n.plan().empty()); + CHECK(n.profile().empty()); + + // Plan: prosta -> klotoida -> łuk -> klotoida -> prosta (razem 180 m) + n.add_plan_element( + Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + n.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt, + Radius::from_metres(300.0), + TurnDirection::Right}); + n.add_plan_element(CircularArc{Radius::from_metres(300.0), + TurnDirection::Right, + Length::from_metres(20.0)}); + n.add_plan_element(TransitionCurve{Length::from_metres(30.0), + Radius::from_metres(300.0), std::nullopt, + TurnDirection::Right}); + n.add_plan_element( + Straight{CartesianPosition{100.0, 0.0}, CartesianPosition{150.0, 0.0}}); + + // Profil: pochylenie -> łuk pionowy -> pochylenie (razem 180 m) + n.add_profile_element(Grade{Length::from_metres(80.0), 3.0}); + n.add_profile_element(VerticalCurve{Length::from_metres(40.0), + Radius::from_metres(5000.0), + VerticalCurvature::Sag}); + n.add_profile_element(Grade{Length::from_metres(60.0), -2.0}); + + CHECK(n.plan().size() == 5); + CHECK(n.profile().size() == 3); + CHECK(n.plan_length().metres() == 180.0); + CHECK(n.profile_length().metres() == 180.0); + CHECK(n.lengths_consistent()); +} + +void inconsistent_lengths_are_detected() { + Niweleta n{NiweletaId{2}}; + n.add_plan_element( + Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}); + n.add_profile_element(Grade{Length::from_metres(90.0), 0.0}); + CHECK(!n.lengths_consistent()); + CHECK(n.plan_length().metres() == 100.0); + CHECK(n.profile_length().metres() == 90.0); +} + +void niweleta_assigns_straight_ids() { + Niweleta n{NiweletaId{3}}; + // add_straight mints and returns fresh ids. + const StraightId a = + n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}); + const StraightId b = + n.add_straight(CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}); + CHECK(!a.is_null()); + CHECK(!b.is_null()); + CHECK(a != b); + + // An anonymous straight added via add_plan_element is identified too. + Niweleta m{NiweletaId{4}}; + m.add_plan_element( + Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + const auto& element = m.plan().elements().front(); + CHECK(!std::get(element).id().is_null()); +} + +} // namespace + +int main() { + RUN(plan_and_profile_element_lengths); + RUN(value_objects_guard_invariants); + RUN(niweleta_aggregates_plan_and_profile); + RUN(inconsistent_lengths_are_detected); + RUN(niweleta_assigns_straight_ids); + return REPORT(); +} diff --git a/maj0sted/tests/test_parallelism.cpp b/maj0sted/tests/test_parallelism.cpp new file mode 100644 index 00000000..a603f88c --- /dev/null +++ b/maj0sted/tests/test_parallelism.cpp @@ -0,0 +1,85 @@ +#include + +#include "check.hpp" +#include "maj0sted/maj0sted.hpp" + +using namespace maj0sted::domain; + +namespace { + +void mark_parallel_references_two_straights() { + // Two identified straights (typically from different niwelety). + const Straight origin{StraightId{10}, CartesianPosition{0.0, 0.0}, + CartesianPosition{100.0, 0.0}}; + const Straight parallel{StraightId{20}, CartesianPosition{0.0, 5.0}, + CartesianPosition{100.0, 5.0}}; + + const TrackOffset offset{Length::from_metres(5.0), Side::Left}; + const auto relation = ParallelismService::mark_parallel(origin, parallel, offset); + + CHECK(relation.origin() == StraightId{10}); + CHECK(relation.parallel() == StraightId{20}); + CHECK(relation.offset().distance().metres() == 5.0); + CHECK(relation.offset().side() == Side::Left); +} + +void marking_requires_identified_distinct_straights() { + const Straight anonymous{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}; + const Straight a{StraightId{1}, CartesianPosition{0.0, 0.0}, + CartesianPosition{100.0, 0.0}}; + const Straight b{StraightId{2}, CartesianPosition{0.0, 5.0}, + CartesianPosition{100.0, 5.0}}; + const TrackOffset offset{Length::from_metres(5.0), Side::Left}; + + // A straight without an id cannot be referenced. + CHECK_THROWS(ParallelismService::mark_parallel(anonymous, b, offset)); + CHECK_THROWS(ParallelismService::mark_parallel(a, anonymous, offset)); + + // A straight cannot be parallel to itself (same id). + const Straight a_again{StraightId{1}, CartesianPosition{0.0, 10.0}, + CartesianPosition{100.0, 10.0}}; + CHECK_THROWS(ParallelismService::mark_parallel(a, a_again, offset)); +} + +void offset_must_be_positive() { + CHECK_THROWS(TrackOffset(Length::from_metres(0.0), Side::Left)); +} + +void parallelism_geometry() { + const Straight origin{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}; + const Straight same_direction{CartesianPosition{0.0, 5.0}, + CartesianPosition{100.0, 5.0}}; + const Straight opposite{CartesianPosition{100.0, 5.0}, CartesianPosition{0.0, 5.0}}; + const Straight perpendicular{CartesianPosition{0.0, 0.0}, + CartesianPosition{0.0, 50.0}}; + + CHECK(ParallelismService::are_parallel(origin, same_direction)); + CHECK(ParallelismService::are_parallel(origin, opposite)); // anti-parallel lines + CHECK(!ParallelismService::are_parallel(origin, perpendicular)); + + // Derive a line 5 m to the LEFT of an east-going origin -> to the north (y = +5). + const Straight left = ParallelismService::derive_parallel_line( + origin, TrackOffset{Length::from_metres(5.0), Side::Left}); + CHECK(std::abs(left.start().x() - 0.0) < 1e-9); + CHECK(std::abs(left.start().y() - 5.0) < 1e-9); + CHECK(std::abs(left.end().y() - 5.0) < 1e-9); + CHECK(ParallelismService::are_parallel(origin, left)); + + // To the RIGHT -> to the south (y = -5). + const Straight right = ParallelismService::derive_parallel_line( + origin, TrackOffset{Length::from_metres(5.0), Side::Right}); + CHECK(std::abs(right.start().y() + 5.0) < 1e-9); + + CHECK(std::abs(ParallelismService::perpendicular_distance(origin, same_direction) - + 5.0) < 1e-9); +} + +} // namespace + +int main() { + RUN(mark_parallel_references_two_straights); + RUN(marking_requires_identified_distinct_straights); + RUN(offset_must_be_positive); + RUN(parallelism_geometry); + return REPORT(); +} diff --git a/maj0sted/tests/test_plan_alignment.cpp b/maj0sted/tests/test_plan_alignment.cpp new file mode 100644 index 00000000..353a6bd0 --- /dev/null +++ b/maj0sted/tests/test_plan_alignment.cpp @@ -0,0 +1,132 @@ +#include + +#include "check.hpp" +#include "maj0sted/maj0sted.hpp" + +using namespace maj0sted::domain; + +namespace { + +void straight_computes_azimuth_and_length() { + // Going east (+x): azimuth = 90 deg (clockwise from north). + const Straight east{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}; + CHECK(std::abs(east.length().metres() - 100.0) < 1e-9); + CHECK(std::abs(east.azimuth().degrees() - 90.0) < 1e-9); + + // Going north (+y): azimuth = 0 deg. + const Straight north{CartesianPosition{0.0, 0.0}, CartesianPosition{0.0, 50.0}}; + CHECK(std::abs(north.azimuth().degrees() - 0.0) < 1e-9); + CHECK(std::abs(north.length().metres() - 50.0) < 1e-9); + + // A zero-length straight has no azimuth and is rejected. + CHECK_THROWS(Straight(CartesianPosition{1.0, 1.0}, CartesianPosition{1.0, 1.0})); +} + +void arc_carries_radius_direction_and_optional_length() { + const CircularArc without_length{Radius::from_metres(300.0), TurnDirection::Left}; + CHECK(!without_length.length().has_value()); + + const CircularArc with_length{Radius::from_metres(300.0), TurnDirection::Left, + Length::from_metres(45.0)}; + CHECK(with_length.length().has_value()); + CHECK(with_length.length().value().metres() == 45.0); +} + +void adjacent_same_arcs_are_rejected() { + HorizontalAlignment same; + same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}); + // same radius + same direction -> forbidden + CHECK_THROWS( + same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left})); + + HorizontalAlignment opposite; + opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}); + // same radius, different direction -> allowed (S-curve) + opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Right}); + CHECK(opposite.size() == 2); + + HorizontalAlignment compound; + compound.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}); + // same direction, different radius -> allowed (compound curve) + compound.append(CircularArc{Radius::from_metres(500.0), TurnDirection::Left}); + CHECK(compound.size() == 2); +} + +void straight_to_straight_tangent_requires_equal_azimuth() { + // Collinear straights, same azimuth, touching -> tangent OK. + HorizontalAlignment collinear; + collinear.append( + Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + collinear.append( + Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}}); + CHECK(collinear.size() == 2); + CHECK(collinear.joint(0) == JointContinuity::Tangent); + + // Touching but different azimuth with a Tangent joint -> must be a break. + HorizontalAlignment unmarked; + unmarked.append( + Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + CHECK_THROWS(unmarked.append( + Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}})); + + // Non-touching tangent straights -> invalid. + HorizontalAlignment gap; + gap.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + CHECK_THROWS(gap.append( + Straight{CartesianPosition{60.0, 0.0}, CartesianPosition{100.0, 0.0}})); +} + +void azimuth_break_between_straights_must_be_marked() { + // Corner marked as AzimuthBreak -> OK. + HorizontalAlignment kink; + kink.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + kink.append(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}}, + JointContinuity::AzimuthBreak); + CHECK(kink.size() == 2); + CHECK(kink.joint(0) == JointContinuity::AzimuthBreak); + + // AzimuthBreak with equal azimuth is pointless -> invalid. + HorizontalAlignment straight_break; + straight_break.append( + Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + CHECK_THROWS(straight_break.append( + Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}}, + JointContinuity::AzimuthBreak)); + + // AzimuthBreak is only allowed between two straights. + HorizontalAlignment arc_break; + arc_break.append( + Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + CHECK_THROWS(arc_break.append( + CircularArc{Radius::from_metres(300.0), TurnDirection::Left}, + JointContinuity::AzimuthBreak)); +} + +void straight_curve_joints_are_accepted_pending_fit() { + // prosta -> klotoida -> łuk -> klotoida -> prosta + HorizontalAlignment plan; + plan.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + plan.append(TransitionCurve{Length::from_metres(30.0), std::nullopt, + Radius::from_metres(300.0), TurnDirection::Left}); + plan.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left, + Length::from_metres(40.0)}); + plan.append(TransitionCurve{Length::from_metres(30.0), + Radius::from_metres(300.0), std::nullopt, + TurnDirection::Left}); + plan.append(Straight{CartesianPosition{200.0, 0.0}, CartesianPosition{260.0, 0.0}}); + CHECK(plan.size() == 5); + // Known lengths: 50 + 30 + 40 + 30 + 60. + CHECK(plan.total_length().metres() == 210.0); +} + +} // namespace + +int main() { + RUN(straight_computes_azimuth_and_length); + RUN(arc_carries_radius_direction_and_optional_length); + RUN(adjacent_same_arcs_are_rejected); + RUN(straight_to_straight_tangent_requires_equal_azimuth); + RUN(azimuth_break_between_straights_must_be_marked); + RUN(straight_curve_joints_are_accepted_pending_fit); + return REPORT(); +} diff --git a/maj0sted/tests/test_render.cpp b/maj0sted/tests/test_render.cpp new file mode 100644 index 00000000..58261a2a --- /dev/null +++ b/maj0sted/tests/test_render.cpp @@ -0,0 +1,78 @@ +#include + +#include "check.hpp" +#include "maj0sted/io/svg_writer.hpp" +#include "maj0sted/maj0sted.hpp" +#include "maj0sted/render/plan_sampler.hpp" + +using namespace maj0sted::domain; + +namespace { + +void samples_one_polyline_per_element() { + Niweleta n{NiweletaId{1}}; + const StraightId a = + n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}); + const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0}, + CartesianPosition{100.0, 100.0}, + JointContinuity::AzimuthBreak); + const FitResult fit = FittingService::fit_between_straights( + n, FitParameters{.entry = a, + .exit = b, + .radius = Radius::from_metres(40.0), + .transition_length = Length::from_metres(10.0)}); + n.apply_fit(fit); // straight -> KP -> arc -> KP -> straight (5 elements) + + const auto scene = maj0sted::render::sample(n.plan()); + CHECK(scene.polylines.size() == n.plan().size()); + CHECK(scene.polylines.size() == 5); + CHECK(!scene.empty); + + // First polyline is the (straight) entry, drawn from its anchored endpoints. + const auto& first = scene.polylines.front(); + CHECK(first.kind == maj0sted::render::ElementKind::Straight); + CHECK(first.points.size() == 2); + CHECK(std::abs(first.points.front().x - 0.0) < 1e-9); + + // Curves are sampled into many points. + CHECK(scene.polylines[2].kind == maj0sted::render::ElementKind::Arc); + CHECK(scene.polylines[2].points.size() > 2); +} + +void curve_geometry_is_continuous() { + // The sampled points should join up (each element starts where the previous + // ended) for a properly fitted, tangent-continuous plan. + Niweleta n{NiweletaId{2}}; + const StraightId a = + n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}); + const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0}, + CartesianPosition{100.0, 100.0}, + JointContinuity::AzimuthBreak); + n.apply_fit(FittingService::fit_between_straights( + n, FitParameters{.entry = a, .exit = b, .radius = Radius::from_metres(40.0)})); + + const auto scene = maj0sted::render::sample(n.plan()); + for (std::size_t i = 1; i < scene.polylines.size(); ++i) { + const auto& prev_end = scene.polylines[i - 1].points.back(); + const auto& next_start = scene.polylines[i].points.front(); + CHECK(std::abs(prev_end.x - next_start.x) < 1e-6); + CHECK(std::abs(prev_end.y - next_start.y) < 1e-6); + } +} + +void svg_is_produced() { + Niweleta n{NiweletaId{3}}; + n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}); + const auto svg = maj0sted::io::to_svg(maj0sted::render::sample(n.plan())); + CHECK(svg.rfind(" + +#include "check.hpp" +#include "maj0sted/maj0sted.hpp" + +using namespace maj0sted::domain; + +namespace { + +MapProject make_project() { + MapProject project{Crs{2180}}; + + Niweleta a{NiweletaId{5}, "Odcinek A"}; + const StraightId s1 = + a.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}); + (void)s1; + a.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt, + Radius::from_metres(300.0), TurnDirection::Right}); + a.add_plan_element(CircularArc{Radius::from_metres(300.0), TurnDirection::Right, + Length::from_metres(40.0)}); + a.add_profile_element(Grade{Length::from_metres(80.0), 3.0}); + a.add_profile_element(VerticalCurve{Length::from_metres(40.0), + Radius::from_metres(5000.0), + VerticalCurvature::Sag}); + project.add_niweleta(std::move(a)); + + // A second niweleta with an azimuth break, to exercise joints. + Niweleta b{NiweletaId{9}, "Odcinek B z załamaniem"}; + b.add_plan_element( + Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); + b.add_plan_element(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}}, + JointContinuity::AzimuthBreak); + project.add_niweleta(std::move(b)); + + return project; +} + +void round_trip_is_stable() { + const MapProject project = make_project(); + const std::string text = maj0sted::io::serialize(project); + const MapProject loaded = maj0sted::io::deserialize(text); + + // Re-serializing the loaded project yields byte-identical text. + CHECK(maj0sted::io::serialize(loaded) == text); + + CHECK(loaded.crs().epsg() == 2180); + CHECK(loaded.niwelety().size() == 2); +} + +void niweleta_and_geometry_survive() { + const MapProject loaded = maj0sted::io::deserialize(maj0sted::io::serialize(make_project())); + + const Niweleta& a = loaded.niwelety()[0]; + CHECK(a.id() == NiweletaId{5}); + CHECK(a.name() == "Odcinek A"); + CHECK(a.plan().size() == 3); + CHECK(a.profile().size() == 2); + + // Straight kept its id and geometry. + const auto& straight = std::get(a.plan().elements()[0]); + CHECK(straight.id() == StraightId{1}); + CHECK(straight.length().metres() == 100.0); + + // Arc kept radius, direction and length. + const auto& arc = std::get(a.plan().elements()[2]); + CHECK(arc.radius().metres() == 300.0); + CHECK(arc.direction() == TurnDirection::Right); + CHECK(arc.length().value().metres() == 40.0); + + // Profile survived. + const auto& grade = std::get(a.profile().elements()[0]); + CHECK(grade.slope_permille() == 3.0); + + // Name with spaces and the azimuth-break joint survived on the 2nd niweleta. + const Niweleta& b = loaded.niwelety()[1]; + CHECK(b.name() == "Odcinek B z załamaniem"); + CHECK(b.plan().joint(0) == JointContinuity::AzimuthBreak); +} + +void malformed_input_throws() { + CHECK_THROWS(maj0sted::io::deserialize("not a maj0sted file")); + CHECK_THROWS(maj0sted::io::deserialize("maj0sted 1\ncrs 2180\nniwelety 3\n")); +} + +} // namespace + +int main() { + RUN(round_trip_is_stable); + RUN(niweleta_and_geometry_survive); + RUN(malformed_input_throws); + return REPORT(); +} diff --git a/maj0sted/tests/test_tile_cache.cpp b/maj0sted/tests/test_tile_cache.cpp new file mode 100644 index 00000000..0ac3da77 --- /dev/null +++ b/maj0sted/tests/test_tile_cache.cpp @@ -0,0 +1,208 @@ +#include +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/web/tile_cache.hpp" + +using namespace maj0sted::web; + +namespace { + +// --- tile grid geometry -------------------------------------------------- + +void grid_is_fixed_100m_cells() { + // One fixed grid: every cell is 100 m × 100 m regardless of the zoom arg. + CHECK(std::abs(TileGrid::kCellMetres - 100.0) < 1e-9); + CHECK(std::abs(TileGrid::tile_span() - 100.0) < 1e-6); + CHECK(std::abs(TileGrid::tile_span(14) - 100.0) < 1e-6); // zoom ignored + // Resolution is constant: 100 m over 1024 px. + CHECK(std::abs(TileGrid::resolution() - 100.0 / 1024.0) < 1e-9); + CHECK(std::abs(TileGrid::resolution(7) - 100.0 / 1024.0) < 1e-9); +} + +void tile_at_and_bbox_are_consistent() { + const double x = 500123.0; // somewhere in EPSG:2180 (Poland) + const double y = 300456.0; + const TileKey key = TileGrid::tile_at(x, y); + const TileBBox box = TileGrid::bbox(key); + + // Every tile key sits on the single fixed level. + CHECK(key.zoom == TileGrid::kLevel); + + // The point must fall inside its own cell's ground box. + CHECK(box.min_x <= x && x < box.max_x); + CHECK(box.min_y <= y && y < box.max_y); + + // The box edge length must equal the fixed 100 m cell span. + CHECK(std::abs((box.max_x - box.min_x) - 100.0) < 1e-6); + CHECK(std::abs((box.max_y - box.min_y) - 100.0) < 1e-6); + + // Neighbouring cells are exactly one span (100 m) apart. + const TileBBox right = TileGrid::bbox(TileKey{TileGrid::kLevel, key.x + 1, key.y}); + CHECK(std::abs(right.min_x - box.max_x) < 1e-6); +} + +void zoom_for_resolution_is_single_level() { + // There is no pyramid: any target resolution maps to the one fixed level. + for (double mpp : {50.0, 8.0, 1.0, 0.25}) { + CHECK(TileGrid::zoom_for_resolution(mpp) == TileGrid::kLevel); + } +} + +void tiles_for_view_covers_and_caps() { + const double span = 100.0; // fixed cell + // A view roughly 3x2 cells. + TileBBox view{500000.0, 300000.0, 500000.0 + 2.5 * span, 300000.0 + 1.5 * span}; + const auto tiles = TileGrid::tiles_for_view(view); + CHECK(!tiles.empty()); + // Every corner cell of the view must be present. + const TileKey lo = TileGrid::tile_at(view.min_x, view.min_y); + const TileKey hi = TileGrid::tile_at(view.max_x, view.max_y); + bool has_lo = false, has_hi = false; + for (const auto& t : tiles) { + if (t == lo) has_lo = true; + if (t == hi) has_hi = true; + } + CHECK(has_lo && has_hi); + + // The cap is honoured. + TileBBox huge{0.0, 0.0, 1e9, 1e9}; + const auto capped = TileGrid::tiles_for_view(huge, TileGrid::kLevel, 32); + CHECK(static_cast(capped.size()) <= 32); + const TileKey centre = TileGrid::tile_at(5e8, 5e8); + bool has_centre = false; + for (const auto& tile : capped) { + if (tile == centre) has_centre = true; + } + CHECK(has_centre); // cap is centred, not taken from a remote corner + + const double infinity = std::numeric_limits::infinity(); + CHECK(TileGrid::tiles_for_view({0.0, 0.0, infinity, 1.0}).empty()); +} + +// --- WMS URL ------------------------------------------------------------- + +void getmap_url_is_wms_1_1_1_epsg2180() { + WmsConfig cfg = WmsConfig::geoportal_ortho(); + const TileBBox box{100.0, 200.0, 300.0, 400.0}; + const std::string url = wms_getmap_url(cfg, box); + CHECK(url.find("SERVICE=WMS") != std::string::npos); + CHECK(url.find("VERSION=1.1.1") != std::string::npos); + CHECK(url.find("REQUEST=GetMap") != std::string::npos); + CHECK(url.find("SRS=EPSG:2180") != std::string::npos); + // BBOX in minx,miny,maxx,maxy order. + CHECK(url.find("BBOX=100,200,300,400") != std::string::npos); + CHECK(url.find("WIDTH=1024") != std::string::npos); +} + +// --- cache + injected fetch --------------------------------------------- + +void miss_fetches_then_hit_is_served_from_cache() { + TileService service; + int fetch_calls = 0; + // Synchronous injected fetcher that returns three canned bytes. + service.set_fetcher([&](const std::string& url, + TileService::DoneFn done) { + ++fetch_calls; + CHECK(url.find("BBOX=") != std::string::npos); + done(true, std::vector{1, 2, 3}, "image/jpeg"); + }); + + int ready_events = 0; + TileKey last_ready{}; + service.set_on_ready([&](TileKey k) { + ++ready_events; + last_ready = k; + }); + + const TileKey key{12, 3, 4}; + CHECK(!service.has(key)); + + // First request: a miss, so exactly one fetch and the tile becomes Ready. + const TileStatus first = service.request(key); + CHECK(first == TileStatus::Ready); // fetcher was synchronous + CHECK(fetch_calls == 1); + CHECK(ready_events == 1); + CHECK(last_ready == key); + CHECK(service.has(key)); + + const TileImage* image = service.peek(key); + CHECK(image != nullptr); + CHECK(image->bytes.size() == 3); + CHECK(image->content_type == "image/jpeg"); + + // Second request for the same tile: a hit, no extra fetch. + const TileStatus second = service.request(key); + CHECK(second == TileStatus::Ready); + CHECK(fetch_calls == 1); // unchanged: served from cache + CHECK(service.size() == 1); +} + +void failed_fetch_is_retried_until_success() { + // A failure is not terminal: the service retries until a tile arrives. With + // a synchronous injected fetcher the retries unwind inline, so a fetcher that + // fails twice then succeeds must end Ready after exactly three attempts. + TileService service; + int calls = 0; + service.set_fetcher([&](const std::string&, TileService::DoneFn done) { + ++calls; + if (calls < 3) { + done(false, {}, {}); // fail + } else { + done(true, std::vector{7}, "image/jpeg"); // then succeed + } + }); + + const TileKey key{10, 1, 1}; + const TileStatus status = service.request(key); + CHECK(status == TileStatus::Ready); + CHECK(calls == 3); + CHECK(service.has(key)); + + const TileImage* image = service.peek(key); + CHECK(image != nullptr); + CHECK(image->bytes.size() == 1); +} + +void retry_gives_up_after_ten_attempts() { + // A tile that never loads is retried at most 10 times, then marked Failed. + // With a synchronous always-failing fetcher that is 1 initial + 10 retries. + TileService service; + int calls = 0; + service.set_fetcher([&](const std::string&, TileService::DoneFn done) { + ++calls; + done(false, {}, {}); + }); + + const TileKey key{2, 2, 2}; + CHECK(service.request(key) == TileStatus::Failed); + CHECK(calls == 11); // initial + kMaxRetries (10) + CHECK(!service.has(key)); + CHECK(service.peek(key) == nullptr); +} + +void store_seeds_cache_directly() { + TileService service; + const TileKey key{9, 2, 2}; + service.store(key, std::vector{9, 9}, "image/png"); + CHECK(service.has(key)); + // A request now is a pure hit even with no fetcher installed. + CHECK(service.request(key) == TileStatus::Ready); +} + +} // namespace + +int main() { + RUN(grid_is_fixed_100m_cells); + RUN(tile_at_and_bbox_are_consistent); + RUN(zoom_for_resolution_is_single_level); + RUN(tiles_for_view_covers_and_caps); + RUN(getmap_url_is_wms_1_1_1_epsg2180); + RUN(miss_fetches_then_hit_is_served_from_cache); + RUN(failed_fetch_is_retried_until_success); + RUN(retry_gives_up_after_ten_attempts); + RUN(store_seeds_cache_directly); + return REPORT(); +} diff --git a/maj0sted/tests/test_web.cpp b/maj0sted/tests/test_web.cpp new file mode 100644 index 00000000..b8a848cc --- /dev/null +++ b/maj0sted/tests/test_web.cpp @@ -0,0 +1,229 @@ +#include +#include + +#include "check.hpp" +#include "maj0sted/web/editor.hpp" +#include "maj0sted/web/solve.hpp" + +using namespace maj0sted::web; + +namespace { + +bool has_kind(const std::vector& polylines, int kind) { + for (const auto& p : polylines) { + if (p.kind == kind) return true; + } + return false; +} + +void arc_with_transitions_produces_full_chain() { + SolveRequest r{}; + r.ax = 0.0; r.ay = 0.0; + r.bx = 300.0; r.by = 0.0; + r.cx = 300.0; r.cy = 300.0; + r.mode = 1; + r.radius = 80.0; + r.transition = 20.0; + + const auto polylines = solve_scene(r); + CHECK(!polylines.empty()); + CHECK(has_kind(polylines, 0)); // straight + CHECK(has_kind(polylines, 1)); // arc + CHECK(has_kind(polylines, 2)); // transition + // Each polyline has at least two points. + for (const auto& p : polylines) CHECK(p.points.size() >= 2); +} + +void compound_mode_produces_two_arcs() { + SolveRequest r{}; + r.ax = 0.0; r.ay = 0.0; + r.bx = 300.0; r.by = 0.0; + r.cx = 300.0; r.cy = 300.0; + r.mode = 2; + r.r1 = 120.0; r.arc1_len = 60.0; r.between = 25.0; r.r2 = 80.0; + r.entry_t = 30.0; r.exit_t = 30.0; + + const auto polylines = solve_scene(r); + int arcs = 0; + for (const auto& p : polylines) { + if (p.kind == 1) ++arcs; + } + CHECK(arcs == 2); + CHECK(has_kind(polylines, 2)); // transitions present +} + +void degenerate_input_falls_back_to_straights() { + // Collinear vertices -> no fit; still returns the straights, never throws. + SolveRequest r{}; + r.ax = 0.0; r.ay = 0.0; + r.bx = 100.0; r.by = 0.0; + r.cx = 200.0; r.cy = 0.0; + r.mode = 0; + r.radius = 50.0; + + const auto polylines = solve_scene(r); + CHECK(!polylines.empty()); + CHECK(has_kind(polylines, 0)); // straights + CHECK(!has_kind(polylines, 1)); // no arc +} + +NiweletaSpec short_corner(double radius, double transition = 0.0) { + NiweletaSpec spec; + spec.straights = { + StraightSpec{0.0, 0.0, 10.0, 0.0}, + StraightSpec{10.0, 0.0, 10.0, 10.0}, + }; + spec.fits = {GapFit{.gap = 0, + .mode = transition > 0.0 ? 2 : 1, + .radius = radius, + .transition = transition}}; + return spec; +} + +void editor_clamps_oversized_arc_to_fitting_radius() { + const auto solved = solve_project({short_corner(60.0)}); + CHECK(solved.size() == 1); + CHECK(solved[0].applied_fits.size() == 1); + const double applied = solved[0].applied_fits[0].radius; + CHECK(applied > 9.9); + CHECK(applied < 10.1); +} + +void editor_leaves_gap_unfitted_when_radius_below_floor() { + // A radius too small for the requested transitions is NEVER enlarged (the + // fitting rule only ever reduces). When nothing at or below the request fits, + // the gap is simply left unconnected. + NiweletaSpec spec; + spec.straights = { + StraightSpec{0.0, 0.0, 100.0, 0.0}, + StraightSpec{100.0, 0.0, 100.0, 100.0}, + }; + spec.fits = {GapFit{.gap = 0, .mode = 2, .radius = 1.0, .transition = 20.0}}; + + const auto solved = solve_project({spec}); + CHECK(solved.size() == 1); + CHECK(solved[0].applied_fits.empty()); + CHECK(!has_kind(solved[0].polylines, 1)); // no arc rendered +} + +void editor_emits_two_rails_at_standard_gauge() { + const auto solved = solve_project({short_corner(5.0)}); + CHECK(solved[0].polylines.size() == 6); + CHECK(solved[0].rendered_straights.size() == 2); + CHECK(std::abs(solved[0].rendered_straights[0].x2 - 5.0) < 1e-6); + CHECK(std::abs(solved[0].rendered_straights[1].y1 - 5.0) < 1e-6); + const auto& left = solved[0].polylines[0].points; + const auto& right = solved[0].polylines[1].points; + CHECK(left.size() == right.size()); + CHECK(std::abs(std::hypot(left[0].x - right[0].x, left[0].y - right[0].y) - + 1.5) < 1e-9); + CHECK(solved[0].polylines[0].straight_index == 0); + CHECK(solved[0].polylines[0].gap == -1); + CHECK(std::abs(solved[0].polylines[0].length - 5.0) < 1e-9); + const WebPolyline* arc = nullptr; + for (const auto& polyline : solved[0].polylines) { + if (polyline.kind == 1) { + arc = &polyline; + break; + } + } + CHECK(arc != nullptr); + CHECK(arc->straight_index == -1); + CHECK(arc->gap == 0); + CHECK(arc->element_index == 0); + CHECK(std::abs(arc->radius_start - 5.0) < 1e-9); + CHECK(std::abs(arc->radius_end - 5.0) < 1e-9); + CHECK(std::abs(arc->length - 5.0 * std::numbers::pi / 2.0) < 1e-9); +} + +void editor_exposes_transition_radius_range() { + const auto solved = solve_project({short_corner(5.0, 2.0)}); + const WebPolyline* transition = nullptr; + for (const auto& polyline : solved[0].polylines) { + if (polyline.kind == 2) { + transition = &polyline; + break; + } + } + CHECK(transition != nullptr); + CHECK(transition->gap == 0); + CHECK(std::abs(transition->length - 2.0) < 1e-9); + CHECK(transition->radius_start == 0.0); + CHECK(std::abs(transition->radius_end - 5.0) < 1e-9); +} + +void editor_leaves_parallel_offset_unfitted() { + // Two parallel, laterally offset straights admit no plain arc (a single arc + // cannot connect parallels) and reverse S-curves are never substituted, so the + // gap stays unconnected rather than inventing a curve. + NiweletaSpec spec; + spec.straights = { + StraightSpec{0.0, 0.0, 200.0, 0.0}, + StraightSpec{0.0, 20.0, 200.0, 20.0}, + }; + spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 50.0}}; + + const auto solved = solve_project({spec}); + CHECK(solved[0].applied_fits.empty()); + CHECK(!has_kind(solved[0].polylines, 1)); // no arc, no reverse pair +} + +void editor_honours_requested_radius_via_single_arc() { + // A requested radius that lands on the straights (extending them toward the + // corner if needed) is used verbatim as a single arc — never enlarged and + // never swapped for a reverse pair. + const double heading = std::numbers::pi / 6.0; + const double tangent_x = 86.9615242271; + const double tangent_y = 25.9807621135; + const double dx = std::cos(heading); + const double dy = std::sin(heading); + NiweletaSpec spec; + spec.straights = { + StraightSpec{0.0, 0.0, 100.0, 0.0}, + StraightSpec{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy, + tangent_x + 70.0 * dx, tangent_y + 70.0 * dy}, + }; + spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 30.0}}; + + const auto solved = solve_project({spec}); + CHECK(solved[0].applied_fits.size() == 1); + CHECK(std::abs(solved[0].applied_fits[0].radius - 30.0) < 1e-9); + int arc_rails = 0; + for (const auto& polyline : solved[0].polylines) { + if (polyline.kind == 1) ++arc_rails; + } + CHECK(arc_rails == 2); // a single arc (two rails), not a reverse pair +} + +void hidden_tangent_guide_fits_curve_without_rendering_track() { + NiweletaSpec spec = short_corner(5.0); + spec.straights[1].hidden = true; + + const auto solved = solve_project({spec}); + CHECK(solved[0].applied_fits.size() == 1); + CHECK(solved[0].rendered_straights[1].hidden); + int straight_rails = 0; + int arc_rails = 0; + for (const auto& polyline : solved[0].polylines) { + if (polyline.kind == 0) ++straight_rails; + if (polyline.kind == 1) ++arc_rails; + } + CHECK(straight_rails == 2); // only the visible entry straight + CHECK(arc_rails == 2); +} + +} // namespace + +int main() { + RUN(arc_with_transitions_produces_full_chain); + RUN(compound_mode_produces_two_arcs); + RUN(degenerate_input_falls_back_to_straights); + RUN(editor_clamps_oversized_arc_to_fitting_radius); + RUN(editor_leaves_gap_unfitted_when_radius_below_floor); + RUN(editor_emits_two_rails_at_standard_gauge); + RUN(editor_exposes_transition_radius_range); + RUN(editor_leaves_parallel_offset_unfitted); + RUN(editor_honours_requested_radius_via_single_arc); + RUN(hidden_tangent_guide_fits_curve_without_rendering_track); + return REPORT(); +} From 94b01c8ca395e5617e698663d98ec7a1ce39837c Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 18:45:05 +0200 Subject: [PATCH 02/17] Add -editor startup so the exe comes up straight in the editor The editor was reachable only from the driver mode with F11, which meant loading a scenario and starting the whole simulation first. With -editor the scenario loader hands over to the editor instead of the driver, so physics, AI and timetables never run - only the loaded world is there to be worked on. Without -s the scenery from eu07.ini is used, falling back to pusta.scn when the ini names none. F11 quits instead of popping the mode, since the editor is now the bottom of the mode stack and popping it would leave the rest of the frame reading an empty one. Editor entry forces free-fly even with no vehicle to leave. --- application/application.cpp | 14 +++++++++++++- application/editormode.cpp | 12 ++++++++++-- application/scenarioloadermode.cpp | 7 ++++++- utilities/Globals.h | 2 ++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/application/application.cpp b/application/application.cpp index e5d0b6ba..72fdda0a 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -1140,14 +1140,26 @@ int eu07_application::init_settings(int Argc, char *Argv[]) Global.local_start_vehicle = ToLower(Argv[++i]); } } + else if (token == "-editor") + { + Global.editor_startup = true; + } else { std::cout << "usage: " << std::string(Argv[0]) << " [-s sceneryfilepath]" - << " [-v vehiclename]" << std::endl; + << " [-v vehiclename]" + << " [-editor]" << std::endl; return -1; } } + if (Global.editor_startup && Global.SceneryFile.empty()) + { + // edytor potrzebuje wczytanego świata, ale niekoniecznie czegokolwiek w nim - pusta sceneria daje + // sam region, oświetlenie i zegar, bez taboru i rozkładów + Global.SceneryFile = "pusta.scn"; + } + return 0; } diff --git a/application/editormode.cpp b/application/editormode.cpp index 46a8edad..89d54453 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -1362,8 +1362,11 @@ void editor_mode::enter() Camera.m_owner = nullptr; Camera.LookAt = vehicle->GetPosition(); Camera.RaLook(); // single camera reposition - FreeFlyModeFlag = true; } + // edytor pracuje wyłącznie kamerą swobodną. Przy starcie prosto do edytora nie ma pojazdu, z + // którego można by wysiąść, a flagę i tak trzeba podnieść - inaczej kamera zostaje w trybie + // kabinowym, nie mając kabiny + FreeFlyModeFlag = true; } Global.ControlPicking = true; @@ -1455,7 +1458,12 @@ void editor_mode::on_key(int const Key, int const Scancode, int const Action, in if (!Global.ctrlState && !Global.shiftState) { - Application.pop_mode(); + // przy starcie do edytora nie ma trybu, do którego można wrócić - zdjęcie ostatniego trybu + // zostawiłoby pusty stos, z którego reszta klatki nadal by czytała + if (Global.editor_startup) + Application.queue_quit(true); + else + Application.pop_mode(); } else if (Global.ctrlState && Global.shiftState) { diff --git a/application/scenarioloadermode.cpp b/application/scenarioloadermode.cpp index f05c3869..34c91652 100644 --- a/application/scenarioloadermode.cpp +++ b/application/scenarioloadermode.cpp @@ -60,7 +60,12 @@ bool scenarioloader_mode::update() { // TODO: implement and use next mode cue Application.pop_mode(); - Application.push_mode( eu07_application::mode::driver ); + // przy starcie do edytora symulacja w ogóle nie rusza: nie ma trybu prowadzenia pod spodem, więc nie + // liczy się fizyki, AI ani rozkładów - zostaje sam wczytany świat do obrabiania + Application.push_mode( + Global.editor_startup ? + eu07_application::mode::editor : + eu07_application::mode::driver ); return true; } diff --git a/utilities/Globals.h b/utilities/Globals.h index 729b7cbc..0ee9cce5 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -77,6 +77,8 @@ struct global_settings { std::string szTexturesDDS{ ".dds" }; // lista tekstur od DDS std::string szDefaultExt{ szTexturesDDS }; std::string SceneryFile; + // uruchomienie prosto do edytora, z pominięciem symulacji; edytor jest wtedy trybem podstawowym + bool editor_startup{ false }; std::string local_start_vehicle{ "EU07-424" }; int iConvertModels{ 0 }; // tworzenie plików binarnych int iConvertIndexRange{ 1000 }; // range of duplicate vertex scan From e3237034dbea2acba8485cfc712d041f28c202f8 Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 18:51:14 +0200 Subject: [PATCH 03/17] Give the editor a camera when it starts on its own The scenery camera is set up by the driver mode, which never runs when the exe starts straight in the editor - the editor then copied an untouched global camera and rendered a black view with only the UI on top. Entering the editor as the startup mode now initialises the camera itself, falling back on a view above the origin for sceneries that name no camera, and resets the timers so the loading time does not land in the first frame. --- application/editormode.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/application/editormode.cpp b/application/editormode.cpp index 89d54453..01a17aca 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -1347,6 +1347,20 @@ void editor_mode::update_camera(double const Deltatime) void editor_mode::enter() { + if (Global.editor_startup) + { + // kamerę ze scenerii ustawia tryb prowadzenia, a przy starcie prosto do edytora on w ogóle nie + // wstaje - bez tego kopiujemy niżej pustą kamerę globalną i widok jest czarny + auto const scenerycamera = Global.FreeCameraInit[0] != glm::dvec3(0.0); + // sceneria nie musi podawać kamery; wtedy zostaje domyślny widok edytora, znad punktu zerowego + Camera.Init(scenerycamera ? Global.FreeCameraInit[0] : glm::dvec3{0.0, 15.0, 0.0}, + scenerycamera ? Global.FreeCameraInitAngle[0] : glm::vec3{glm::radians(-30.0f), glm::radians(180.0f), 0.0f}, nullptr); + Global.pCamera = Camera; + Global.pDebugCamera = Camera; + FreeFlyModeFlag = true; + Timer::ResetTimers(); // czas wczytywania scenerii nie ma trafić w deltę pierwszej klatki + } + m_statebackup = {Global.pCamera, FreeFlyModeFlag, Global.ControlPicking}; Camera = Global.pCamera; From 4bef28cf945b9e5faa599c02f9b49a2188b1494d Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 19:00:35 +0200 Subject: [PATCH 04/17] Add a track layout drawing board to the editor A "Plan" panel, opened from Track layout in the editor's menu bar, draws a niweleta the way the web frontend did: click to lay chained straights, drag a corner to move it, and pick a fit for each gap between them. The geometry is entirely maj0sted's - the panel gathers the input, hands the document to solve_project and paints the rails that come back, coloured by whether they came from a straight, an arc or a clothoid. Scenery nodes are untouched. A fit the library cannot produce is simply absent from its answer, so the gap is labelled as not fitting rather than left looking as though its parameters took effect. Save/load go through the library's own document format, so a project written here opens in the same state. Also translates the comments added with -editor into English, matching the files they sit in. --- CMakeLists.txt | 1 + application/application.cpp | 4 +- application/editormode.cpp | 17 +- application/editoruilayer.cpp | 12 + application/editoruilayer.h | 5 + application/planpanel.cpp | 585 +++++++++++++++++++++++++++++ application/planpanel.h | 69 ++++ application/scenarioloadermode.cpp | 4 +- 8 files changed, 684 insertions(+), 13 deletions(-) create mode 100644 application/planpanel.cpp create mode 100644 application/planpanel.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 06f5f6e4..e420d41a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -173,6 +173,7 @@ set(SOURCES "input/editormouseinput.cpp" "application/editoruilayer.cpp" "application/editoruipanels.cpp" +"application/planpanel.cpp" "application/scenarioloadermode.cpp" "application/scenarioloaderuilayer.cpp" "scene/scenenodegroups.cpp" diff --git a/application/application.cpp b/application/application.cpp index 72fdda0a..156bb04a 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -1155,8 +1155,8 @@ int eu07_application::init_settings(int Argc, char *Argv[]) if (Global.editor_startup && Global.SceneryFile.empty()) { - // edytor potrzebuje wczytanego świata, ale niekoniecznie czegokolwiek w nim - pusta sceneria daje - // sam region, oświetlenie i zegar, bez taboru i rozkładów + // the editor needs a loaded world, but not necessarily anything in it - an empty scenery gives + // just the region, the lighting and the clock, with no rolling stock and no timetables Global.SceneryFile = "pusta.scn"; } diff --git a/application/editormode.cpp b/application/editormode.cpp index 01a17aca..8358ce11 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -1349,16 +1349,16 @@ void editor_mode::enter() { if (Global.editor_startup) { - // kamerę ze scenerii ustawia tryb prowadzenia, a przy starcie prosto do edytora on w ogóle nie - // wstaje - bez tego kopiujemy niżej pustą kamerę globalną i widok jest czarny + // the scenery camera is set up by the driver mode, which never runs when we start straight in + // the editor; without this we'd copy an untouched global camera below and render a black view auto const scenerycamera = Global.FreeCameraInit[0] != glm::dvec3(0.0); - // sceneria nie musi podawać kamery; wtedy zostaje domyślny widok edytora, znad punktu zerowego + // a scenery doesn't have to name a camera; fall back on the editor's own view above the origin Camera.Init(scenerycamera ? Global.FreeCameraInit[0] : glm::dvec3{0.0, 15.0, 0.0}, scenerycamera ? Global.FreeCameraInitAngle[0] : glm::vec3{glm::radians(-30.0f), glm::radians(180.0f), 0.0f}, nullptr); Global.pCamera = Camera; Global.pDebugCamera = Camera; FreeFlyModeFlag = true; - Timer::ResetTimers(); // czas wczytywania scenerii nie ma trafić w deltę pierwszej klatki + Timer::ResetTimers(); // keep the scenery loading time out of the first frame's delta } m_statebackup = {Global.pCamera, FreeFlyModeFlag, Global.ControlPicking}; @@ -1377,9 +1377,8 @@ void editor_mode::enter() Camera.LookAt = vehicle->GetPosition(); Camera.RaLook(); // single camera reposition } - // edytor pracuje wyłącznie kamerą swobodną. Przy starcie prosto do edytora nie ma pojazdu, z - // którego można by wysiąść, a flagę i tak trzeba podnieść - inaczej kamera zostaje w trybie - // kabinowym, nie mając kabiny + // the editor always works with a free camera. starting straight in it there's no vehicle to + // step out of, but the flag still has to go up or the camera stays in cab mode without a cab FreeFlyModeFlag = true; } @@ -1472,8 +1471,8 @@ void editor_mode::on_key(int const Key, int const Scancode, int const Action, in if (!Global.ctrlState && !Global.shiftState) { - // przy starcie do edytora nie ma trybu, do którego można wrócić - zdjęcie ostatniego trybu - // zostawiłoby pusty stos, z którego reszta klatki nadal by czytała + // when started straight in the editor there's no mode to fall back to, and popping the last + // one would leave the rest of the frame reading an empty stack if (Global.editor_startup) Application.queue_quit(true); else diff --git a/application/editoruilayer.cpp b/application/editoruilayer.cpp index d87dd8fa..469a844c 100644 --- a/application/editoruilayer.cpp +++ b/application/editoruilayer.cpp @@ -25,6 +25,7 @@ editor_ui::editor_ui() add_external_panel(&m_nodebankpanel); add_external_panel(&m_functionspanel); add_external_panel(&m_brushobjects); + add_external_panel(&m_planpanel); } // updates state of UI elements @@ -49,6 +50,17 @@ void editor_ui::update() m_brushobjects.update(*ptr); } +void editor_ui::render_menu_contents() +{ + ui_layer::render_menu_contents(); + + if (ImGui::BeginMenu("Track layout")) + { + ImGui::MenuItem(m_planpanel.name().c_str(), nullptr, &m_planpanel.is_open); + ImGui::EndMenu(); + } +} + void editor_ui::toggleBrushSettings(bool isVisible) { if (m_brushobjects.is_open != isVisible) diff --git a/application/editoruilayer.h b/application/editoruilayer.h index ad3d5447..8033deff 100644 --- a/application/editoruilayer.h +++ b/application/editoruilayer.h @@ -11,6 +11,7 @@ http://mozilla.org/MPL/2.0/. #include "application/uilayer.h" #include "application/editoruipanels.h" +#include "application/planpanel.h" namespace scene { @@ -38,11 +39,15 @@ class editor_ui : public ui_layer float getSpacing(); void toggleBrushSettings(bool isVisible); + protected: + void render_menu_contents() override; + private: // members itemproperties_panel m_itempropertiespanel{"Node Properties", true}; functions_panel m_functionspanel{"Functions", true}; nodebank_panel m_nodebankpanel{"Node Bank", true}; brush_object_list m_brushobjects{"Brush properties", false}; + plan_panel m_planpanel{"Plan", false}; scene::basic_node *m_node{nullptr}; // currently bound scene node, if any }; diff --git a/application/planpanel.cpp b/application/planpanel.cpp new file mode 100644 index 00000000..bb104c1f --- /dev/null +++ b/application/planpanel.cpp @@ -0,0 +1,585 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "application/planpanel.h" + +#include "utilities/Globals.h" + +namespace +{ + +// screen placement of the canvas together with the plan point it is centred on; rebuilt every frame +// from the panel's view state, so the transform never goes stale against a resized window +struct canvas_view +{ + ImVec2 origin{0.f, 0.f}; + ImVec2 size{0.f, 0.f}; + double viewx{0.0}; + double viewy{0.0}; + double scale{1.0}; + + ImVec2 centre() const { return {origin.x + size.x * 0.5f, origin.y + size.y * 0.5f}; } + + // northing grows towards the top of the screen, so the vertical axis is flipped + ImVec2 to_screen(double const X, double const Y) const + { + auto const middle{centre()}; + return {middle.x + static_cast((X - viewx) * scale), middle.y - static_cast((Y - viewy) * scale)}; + } + + void to_plan(ImVec2 const &Point, double &X, double &Y) const + { + auto const middle{centre()}; + X = viewx + (Point.x - middle.x) / scale; + Y = viewy - (Point.y - middle.y) / scale; + } +}; + +// grid spacing is picked so the cells stay roughly the same size on screen whatever the zoom, and +// always lands on a round number of metres a surveyor would recognise +double grid_step(double const Scale) +{ + auto const target{80.0 / std::max(Scale, 1e-6)}; // metres per cell at the wanted screen size + auto const magnitude{std::pow(10.0, std::floor(std::log10(std::max(target, 1e-6))))}; + auto const steps = {1.0, 2.0, 5.0, 10.0}; + for (auto const step : steps) + { + if (magnitude * step >= target) + { + return magnitude * step; + } + } + return magnitude * 10.0; +} + +ImU32 element_colour(int const Kind, bool const Active) +{ + auto const alpha{Active ? 255 : 90}; + switch (Kind) + { + case 1: // arc + return IM_COL32(255, 190, 90, alpha); + case 2: // transition curve + return IM_COL32(140, 245, 150, alpha); + default: // straight + return IM_COL32(125, 200, 255, alpha); + } +} + +} // namespace + +plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) +{ + size_min = {520, 460}; + size_max = {2000, 1600}; + m_document.niwelety.push_back({"niweleta 1", {}, {}}); + solve(); +} + +void plan_panel::render_contents() +{ + render_toolbar(); + render_canvas(); + render_gaps(); + render_storage(); +} + +void plan_panel::render_toolbar() +{ + auto *niweleta{current_niweleta()}; + + if (ImGui::Button("New niweleta")) + { + m_document.niwelety.push_back({"niweleta " + std::to_string(m_document.niwelety.size() + 1), {}, {}}); + m_niweleta = m_document.niwelety.size() - 1; + m_pending = false; + solve(); + } + ImGui::SameLine(); + if (ImGui::Button("Undo point")) + { + drop_last_vertex(); + } + ImGui::SameLine(); + if (ImGui::Button("Frame all")) + { + frame_all(); + } + ImGui::SameLine(); + ImGui::Checkbox("Draw", &m_drawing); + + if (m_document.niwelety.size() > 1) + { + auto const preview{m_document.niwelety[m_niweleta].name}; + ImGui::SetNextItemWidth(220.0f); + if (ImGui::BeginCombo("Edited", preview.c_str())) + { + for (std::size_t i = 0; i < m_document.niwelety.size(); ++i) + { + if (ImGui::Selectable(m_document.niwelety[i].name.c_str(), i == m_niweleta)) + { + m_niweleta = i; + m_pending = false; + } + } + ImGui::EndCombo(); + } + } + + ImGui::Text("straights: %d, points: %d", niweleta != nullptr ? static_cast(niweleta->straights.size()) : 0, static_cast(vertex_count())); + ImGui::Separator(); +} + +void plan_panel::render_canvas() +{ + auto const available{ImGui::GetContentRegionAvail()}; + // the tables below the canvas need room of their own, but the canvas takes whatever is left + ImVec2 const canvassize{std::max(available.x, 240.0f), std::max(available.y - 210.0f, 200.0f)}; + + canvas_view view; + view.origin = ImGui::GetCursorScreenPos(); + view.size = canvassize; + view.viewx = m_viewx; + view.viewy = m_viewy; + view.scale = m_scale; + + ImGui::InvisibleButton("plan_canvas", canvassize); + auto const hovered{ImGui::IsItemHovered()}; + auto const mouse{ImGui::GetIO().MousePos}; + + double mousex{0.0}; + double mousey{0.0}; + view.to_plan(mouse, mousex, mousey); + + // zoom around the cursor, so the ground under it stays put + if (hovered && ImGui::GetIO().MouseWheel != 0.0f) + { + m_scale = std::clamp(m_scale * std::pow(1.15, ImGui::GetIO().MouseWheel), 0.002, 40.0); + view.scale = m_scale; + auto const middle{view.centre()}; + m_viewx = mousex - (mouse.x - middle.x) / m_scale; + m_viewy = mousey + (mouse.y - middle.y) / m_scale; + view.viewx = m_viewx; + view.viewy = m_viewy; + } + + // panning latches on, so the view keeps following the cursor once it leaves the canvas + if (hovered && ImGui::IsMouseDragging(1)) + { + m_panning = true; + } + if (m_panning) + { + if (false == ImGui::IsMouseDragging(1)) + { + m_panning = false; + } + else + { + auto const delta{ImGui::GetIO().MouseDelta}; + m_viewx -= delta.x / m_scale; + m_viewy += delta.y / m_scale; + view.viewx = m_viewx; + view.viewy = m_viewy; + } + } + + // grab an existing point before laying a new one, so corners can be nudged into place + if (hovered && ImGui::IsMouseClicked(0)) + { + m_draggedvertex = -1; + for (std::size_t i = 0; i < vertex_count(); ++i) + { + double x{0.0}; + double y{0.0}; + vertex_position(i, x, y); + auto const point{view.to_screen(x, y)}; + if (std::abs(point.x - mouse.x) <= 6.0f && std::abs(point.y - mouse.y) <= 6.0f) + { + m_draggedvertex = static_cast(i); + break; + } + } + if (m_draggedvertex == -1 && m_drawing) + { + append_vertex(mousex, mousey); + } + } + if (m_draggedvertex != -1) + { + if (ImGui::IsMouseDown(0)) + { + move_vertex(static_cast(m_draggedvertex), mousex, mousey); + } + else + { + m_draggedvertex = -1; + } + } + + auto *drawlist{ImGui::GetWindowDrawList()}; + auto const canvasend{ImVec2(view.origin.x + canvassize.x, view.origin.y + canvassize.y)}; + drawlist->PushClipRect(view.origin, canvasend, true); + drawlist->AddRectFilled(view.origin, canvasend, IM_COL32(24, 26, 30, 255)); + + // grid, with the project origin picked out + auto const step{grid_step(m_scale)}; + double left{0.0}; + double top{0.0}; + double right{0.0}; + double bottom{0.0}; + view.to_plan(view.origin, left, top); + view.to_plan(canvasend, right, bottom); + for (auto x = std::floor(left / step) * step; x <= right; x += step) + { + auto const colour{std::abs(x) < step * 0.5 ? IM_COL32(90, 90, 110, 255) : IM_COL32(48, 50, 58, 255)}; + drawlist->AddLine(view.to_screen(x, top), view.to_screen(x, bottom), colour); + } + for (auto y = std::floor(bottom / step) * step; y <= top; y += step) + { + auto const colour{std::abs(y) < step * 0.5 ? IM_COL32(90, 90, 110, 255) : IM_COL32(48, 50, 58, 255)}; + drawlist->AddLine(view.to_screen(left, y), view.to_screen(right, y), colour); + } + + // solved geometry: two rails per centreline, coloured by the kind of element they came from + std::vector points; + for (std::size_t n = 0; n < m_solved.size(); ++n) + { + auto const active{n == m_niweleta}; + for (auto const &polyline : m_solved[n].polylines) + { + if (polyline.points.size() < 2) + { + continue; + } + points.clear(); + points.reserve(polyline.points.size()); + for (auto const &point : polyline.points) + { + points.push_back(view.to_screen(point.x, point.y)); + } + drawlist->AddPolyline(points.data(), static_cast(points.size()), element_colour(polyline.kind, active), false, active ? 2.0f : 1.0f); + } + } + + // editable points of the niweleta being drawn, plus the corner still waiting for its partner + for (std::size_t i = 0; i < vertex_count(); ++i) + { + double x{0.0}; + double y{0.0}; + vertex_position(i, x, y); + auto const point{view.to_screen(x, y)}; + drawlist->AddCircleFilled(point, 4.0f, static_cast(i) == m_draggedvertex ? IM_COL32(255, 240, 120, 255) : IM_COL32(235, 235, 235, 255)); + } + if (m_pending) + { + auto const point{view.to_screen(m_pendingx, m_pendingy)}; + drawlist->AddCircleFilled(point, 4.0f, IM_COL32(255, 240, 120, 255)); + if (hovered) + { + drawlist->AddLine(point, mouse, IM_COL32(255, 240, 120, 160)); + } + } + else if (hovered && m_drawing && m_draggedvertex == -1 && vertex_count() > 0) + { + double x{0.0}; + double y{0.0}; + vertex_position(vertex_count() - 1, x, y); + drawlist->AddLine(view.to_screen(x, y), mouse, IM_COL32(255, 240, 120, 160)); + } + + drawlist->PopClipRect(); + + ImGui::Text("cursor: %.1f, %.1f m grid: %g m scale: %.3g px/m", mousex, mousey, step, m_scale); + ImGui::TextDisabled("left click lays a point, drag a point to move it, right drag pans, wheel zooms"); +} + +void plan_panel::render_gaps() +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || niweleta->straights.size() < 2) + { + return; + } + + ImGui::Separator(); + if (false == ImGui::CollapsingHeader("Fits", ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 120.0f), false); + for (std::size_t gap = 0; gap < niweleta->fits.size(); ++gap) + { + auto &fit{niweleta->fits[gap]}; + ImGui::PushID(static_cast(gap)); + + ImGui::Text("gap %d", static_cast(gap)); + ImGui::SameLine(70.0f); + ImGui::SetNextItemWidth(150.0f); + if (ImGui::Combo("##mode", &fit.mode, "none\0arc\0arc + clothoids\0")) + { + if (fit.mode != 0 && fit.radius <= 0.0) + { + fit.radius = 300.0; // something drawable to start from, rather than a rejected fit + } + solve(); + } + if (fit.mode != 0) + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("R", &fit.radius, 10.0, 100.0, "%.1f")) + { + solve(); + } + } + if (fit.mode == 2) + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("L", &fit.transition, 5.0, 20.0, "%.1f")) + { + solve(); + } + } + // a fit the library could not produce is simply absent from its answer; say so rather than + // leaving the parameters looking as though they took effect + if (fit.mode != 0 && false == fit_applied(gap)) + { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "does not fit"); + } + + ImGui::PopID(); + } + ImGui::EndChild(); +} + +void plan_panel::render_storage() +{ + ImGui::Separator(); + ImGui::SetNextItemWidth(280.0f); + ImGui::InputText("##path", m_path, sizeof(m_path)); + ImGui::SameLine(); + if (ImGui::Button("Save")) + { + m_status = maj0sted::app::save_project(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path); + } + ImGui::SameLine(); + if (ImGui::Button("Load")) + { + auto const loaded{maj0sted::app::load_project(m_path)}; + if (loaded) + { + m_document = *loaded; + if (m_document.niwelety.empty()) + { + m_document.niwelety.push_back({"niweleta 1", {}, {}}); + } + m_niweleta = 0; + m_pending = false; + m_draggedvertex = -1; + solve(); + frame_all(); + m_status = "loaded " + std::string(m_path); + } + else + { + m_status = "could not read " + std::string(m_path); + } + } + if (false == m_status.empty()) + { + ImGui::TextDisabled("%s", m_status.c_str()); + } +} + +void plan_panel::solve() +{ + align_fits(); + m_solved = maj0sted::web::solve_project(m_document.niwelety); +} + +maj0sted::web::NiweletaSpec *plan_panel::current_niweleta() +{ + return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; +} + +maj0sted::web::NiweletaSpec const *plan_panel::current_niweleta() const +{ + return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; +} + +void plan_panel::align_fits() +{ + for (auto &niweleta : m_document.niwelety) + { + auto const gaps{niweleta.straights.empty() ? std::size_t{0} : niweleta.straights.size() - 1}; + std::vector slots(gaps); + for (std::size_t gap = 0; gap < gaps; ++gap) + { + slots[gap].gap = static_cast(gap); + } + // a document read from disk carries only the gaps that were actually fitted + for (auto const &fit : niweleta.fits) + { + if (fit.gap >= 0 && static_cast(fit.gap) < gaps) + { + slots[fit.gap] = fit; + } + } + niweleta.fits = std::move(slots); + } +} + +bool plan_panel::fit_applied(std::size_t const Gap) const +{ + if (m_niweleta >= m_solved.size()) + { + return false; + } + for (auto const &fit : m_solved[m_niweleta].applied_fits) + { + if (fit.gap >= 0 && static_cast(fit.gap) == Gap) + { + return true; + } + } + return false; +} + +std::size_t plan_panel::vertex_count() const +{ + auto const *niweleta{current_niweleta()}; + return (niweleta == nullptr || niweleta->straights.empty()) ? 0 : niweleta->straights.size() + 1; +} + +void plan_panel::vertex_position(std::size_t const Index, double &X, double &Y) const +{ + auto const *niweleta{current_niweleta()}; + if (niweleta == nullptr || niweleta->straights.empty() || Index >= niweleta->straights.size() + 1) + { + return; + } + if (Index == 0) + { + X = niweleta->straights.front().x1; + Y = niweleta->straights.front().y1; + return; + } + X = niweleta->straights[Index - 1].x2; + Y = niweleta->straights[Index - 1].y2; +} + +void plan_panel::append_vertex(double const X, double const Y) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr) + { + return; + } + + if (niweleta->straights.empty()) + { + if (false == m_pending) + { + // nothing to attach to yet, so the first corner waits for the one that closes the straight + m_pending = true; + m_pendingx = X; + m_pendingy = Y; + return; + } + m_pending = false; + niweleta->straights.push_back({m_pendingx, m_pendingy, X, Y, false}); + } + else + { + auto const &last{niweleta->straights.back()}; + niweleta->straights.push_back({last.x2, last.y2, X, Y, false}); + } + + solve(); +} + +void plan_panel::move_vertex(std::size_t const Index, double const X, double const Y) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || Index >= niweleta->straights.size() + 1) + { + return; + } + + // the point belongs to both straights meeting at it, so both ends follow it + if (Index > 0) + { + niweleta->straights[Index - 1].x2 = X; + niweleta->straights[Index - 1].y2 = Y; + } + if (Index < niweleta->straights.size()) + { + niweleta->straights[Index].x1 = X; + niweleta->straights[Index].y1 = Y; + } + + solve(); +} + +void plan_panel::drop_last_vertex() +{ + if (m_pending) + { + m_pending = false; + return; + } + + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || niweleta->straights.empty()) + { + return; + } + + niweleta->straights.pop_back(); + solve(); +} + +void plan_panel::frame_all() +{ + auto minx{std::numeric_limits::max()}; + auto miny{std::numeric_limits::max()}; + auto maxx{std::numeric_limits::lowest()}; + auto maxy{std::numeric_limits::lowest()}; + + for (auto const &niweleta : m_document.niwelety) + { + for (auto const &straight : niweleta.straights) + { + minx = std::min({minx, straight.x1, straight.x2}); + maxx = std::max({maxx, straight.x1, straight.x2}); + miny = std::min({miny, straight.y1, straight.y2}); + maxy = std::max({maxy, straight.y1, straight.y2}); + } + } + + if (minx > maxx) + { + // nothing drawn yet: sit on the project origin at a scale that shows a few hundred metres + m_viewx = 0.0; + m_viewy = 0.0; + m_scale = 2.0; + return; + } + + m_viewx = (minx + maxx) * 0.5; + m_viewy = (miny + maxy) * 0.5; + + auto const span{std::max({maxx - minx, maxy - miny, 50.0})}; + m_scale = std::clamp(400.0 / span, 0.002, 40.0); +} diff --git a/application/planpanel.h b/application/planpanel.h new file mode 100644 index 00000000..4a40f63c --- /dev/null +++ b/application/planpanel.h @@ -0,0 +1,69 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include "application/uilayer.h" + +#include "maj0sted/app/editor_document.hpp" +#include "maj0sted/web/editor.hpp" + +// drawing board for track layout: straights laid down by hand and the curves fitted into the gaps +// between them, in the project's own cartesian frame (EPSG:2180, metres). none of the geometry is +// computed here - the panel collects the input, hands the whole document to maj0sted and paints +// what comes back. scenery nodes are not touched. +class plan_panel : public ui_panel +{ + + public: + plan_panel(std::string const &Name, bool const Isopen); + + void render_contents() override; + + private: + // methods + void render_toolbar(); + void render_canvas(); + void render_gaps(); + void render_storage(); + // re-runs the solver over the whole document; cheap enough to do on every edit + void solve(); + maj0sted::web::NiweletaSpec *current_niweleta(); + maj0sted::web::NiweletaSpec const *current_niweleta() const; + // keeps exactly one fit slot per gap, so a gap's parameters survive edits to its neighbours + void align_fits(); + // true when the solver actually produced the fit requested for the given gap + bool fit_applied(std::size_t const Gap) const; + // the panel edits a chained polyline, so a vertex is shared by the two straights meeting at it. + // the library allows fully independent straights; that freedom just isn't exposed here yet + std::size_t vertex_count() const; + void vertex_position(std::size_t const Index, double &X, double &Y) const; + void append_vertex(double const X, double const Y); + void move_vertex(std::size_t const Index, double const X, double const Y); + void drop_last_vertex(); + // centres the view on the drawing, or on a default patch of ground when nothing is drawn yet + void frame_all(); + // members + maj0sted::app::EditorDocument m_document; + std::vector m_solved; + std::size_t m_niweleta{0}; // niweleta being drawn into + int m_draggedvertex{-1}; + bool m_drawing{true}; // whether a click on the canvas lays track or only picks vertices + bool m_panning{false}; + // first corner of a niweleta has nothing to attach to, so it waits for the second click + bool m_pending{false}; + double m_pendingx{0.0}; + double m_pendingy{0.0}; + // view: the plan point sitting at the centre of the canvas, and the zoom in pixels per metre + double m_viewx{0.0}; + double m_viewy{0.0}; + double m_scale{2.0}; + std::string m_status; + char m_path[256]{"editor/plan.m0s"}; +}; diff --git a/application/scenarioloadermode.cpp b/application/scenarioloadermode.cpp index 34c91652..4d554cf9 100644 --- a/application/scenarioloadermode.cpp +++ b/application/scenarioloadermode.cpp @@ -60,8 +60,8 @@ bool scenarioloader_mode::update() { // TODO: implement and use next mode cue Application.pop_mode(); - // przy starcie do edytora symulacja w ogóle nie rusza: nie ma trybu prowadzenia pod spodem, więc nie - // liczy się fizyki, AI ani rozkładów - zostaje sam wczytany świat do obrabiania + // starting into the editor the simulation never runs at all: there's no driver mode underneath, so + // no physics, no AI and no timetables - just the loaded world to work on Application.push_mode( Global.editor_startup ? eu07_application::mode::editor : From db49039b9da32b39c79067ec62622904942817ad Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 19:15:17 +0200 Subject: [PATCH 05/17] Put the editor viewport into a top-down orthographic plan view Opening the Plan tool now switches the whole colour pass to an orthographic projection and pins the camera looking straight down, so the plan is drawn against the loaded scenery rather than in a window of its own. Closing the tool hands the camera's orientation back; leaving the editor clears the view outright. The wheel changes how much ground fits on screen instead of moving the camera, which with no perspective would change nothing but the clipping. Both configured backends follow: the experimental renderer swaps the pass projection (and fits its shadow cascades to it), the modern GL renderer uses its existing ortho helpers. Everything reading the pass projection - the mouse ray, the frustum test - picks the change up on its own. The experimental renderer also publishes its colour pass camera now, which it previously left at the interface defaults, so overlays can place world points on screen. --- application/editormode.cpp | 32 +++++++++++++++++ application/editormode.h | 9 ++--- application/planpanel.cpp | 11 ++++++ application/planpanel.h | 1 + .../renderer/include/nvrenderer/nvrenderer.h | 16 +++++++++ betterRenderer/renderer/source/nvrenderer.cpp | 35 +++++++++++++++---- rendering/opengl33renderer.cpp | 14 +++++++- utilities/Globals.h | 3 ++ 8 files changed, 109 insertions(+), 12 deletions(-) diff --git a/application/editormode.cpp b/application/editormode.cpp index 8358ce11..31b78e82 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -1307,10 +1307,41 @@ void editor_mode::render_gizmo() } } +void editor_mode::on_scroll(double const Xoffset, double const Yoffset) +{ + if (false == Global.editor_ortho) + { + return; + } + // in the plan view the wheel decides how much ground fits on screen; the camera itself stays put, + // because with no perspective its height changes nothing but the clipping + Global.editor_ortho_extent = std::clamp(Global.editor_ortho_extent * std::pow(0.85f, static_cast(Yoffset)), 5.0f, 20000.0f); +} + void editor_mode::update_camera(double const Deltatime) { Camera.Update(); + // the plan view looks straight down. the camera keeps its ground position, so panning works just + // as it does in the perspective view, but the orientation is pinned and handed back on the way out + if (Global.editor_ortho) + { + if (false == m_orthoactive) + { + m_orthoangle = Camera.Angle; + m_orthoactive = true; + } + Camera.Angle.x = glm::radians(-90.0f); + Camera.Angle.z = 0.0f; + // high enough to clear anything the scenery may have under the cursor + Camera.Pos.y = std::max(Camera.Pos.y, 500.0); + } + else if (m_orthoactive) + { + Camera.Angle = m_orthoangle; + m_orthoactive = false; + } + // focus animation runs after Camera.Update() so it overrides any residual velocity/rotation; // it smoothly drives both position and orientation toward the framed object if (m_focus_active) @@ -1391,6 +1422,7 @@ void editor_mode::enter() void editor_mode::exit() { EditorModeFlag = false; + Global.editor_ortho = false; // the plan view must not follow us out of the editor Global.ControlPicking = m_statebackup.picking; FreeFlyModeFlag = m_statebackup.freefly; Global.pCamera = m_statebackup.camera; diff --git a/application/editormode.h b/application/editormode.h index 29b1ad69..0aad5fb4 100644 --- a/application/editormode.h +++ b/application/editormode.h @@ -39,10 +39,7 @@ class editor_mode : public application_mode void on_key(int Key, int Scancode, int Action, int Mods) override; void on_cursor_pos(double Horizontal, double Vertical) override; void on_mouse_button(int Button, int Action, int Mods) override; - void on_scroll(double const Xoffset, double const Yoffset) override - { - ; - } + void on_scroll(double const Xoffset, double const Yoffset) override; void on_window_resize(int w, int h) override { ; @@ -108,6 +105,10 @@ class editor_mode : public application_mode editormode_input m_input; static TCamera Camera; + // camera orientation from before the plan view took over, to be handed back when it is left + bool m_orthoactive{false}; + glm::vec3 m_orthoangle{0.0f}; + // focus (smooth camera fly-to) state static bool m_focus_active; glm::dvec3 m_focus_start_pos{0.0,0.0,0.0}; diff --git a/application/planpanel.cpp b/application/planpanel.cpp index bb104c1f..87e31124 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -83,8 +83,19 @@ plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Na solve(); } +void plan_panel::update() +{ + // the plan belongs on the scenery itself, so opening the tool puts the viewport into the + // distortion-free view from above and closing it hands the camera back + Global.editor_ortho = is_open; +} + void plan_panel::render_contents() { + ImGui::Text("view: top-down, %.0f m across", Global.editor_ortho_extent * 2.0f); + ImGui::SameLine(); + ImGui::TextDisabled("(wheel over the scenery zooms it)"); + render_toolbar(); render_canvas(); render_gaps(); diff --git a/application/planpanel.h b/application/planpanel.h index 4a40f63c..61d751bf 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -24,6 +24,7 @@ class plan_panel : public ui_panel public: plan_panel(std::string const &Name, bool const Isopen); + void update() override; void render_contents() override; private: diff --git a/betterRenderer/renderer/include/nvrenderer/nvrenderer.h b/betterRenderer/renderer/include/nvrenderer/nvrenderer.h index a572c6dc..26a99fca 100644 --- a/betterRenderer/renderer/include/nvrenderer/nvrenderer.h +++ b/betterRenderer/renderer/include/nvrenderer/nvrenderer.h @@ -129,6 +129,16 @@ class NvRenderer : public gfx_renderer, public MaResourceRegistry { virtual TSubModel const *Pick_Control() const override; virtual scene::basic_node const *Pick_Node() const override; virtual glm::dvec3 Mouse_Position() const override; + // matrices of the most recent colour pass, for overlays drawn on top of the rendered world + virtual glm::mat4 Camera_View_Matrix() const override { + return static_cast(m_camera_view); + } + virtual glm::mat4 Camera_Projection_Matrix() const override { + return static_cast(m_camera_projection); + } + virtual glm::dvec3 Camera_Position() const override { + return m_camera_position; + } virtual void Update(double const Deltatime) override; virtual void Update_Pick_Control() override; virtual void Update_Pick_Node() override; @@ -222,6 +232,12 @@ class NvRenderer : public gfx_renderer, public MaResourceRegistry { glm::dvec3 m_mouse_ro; glm::dvec3 m_mouse_rd; + + // colour pass camera, kept so overlays can place world points on screen. the view matrix is + // camera-relative (rotation only), matching the camera-relative rendering used throughout + glm::dmat4 m_camera_view{1.}; + glm::dmat4 m_camera_projection{1.}; + glm::dvec3 m_camera_position{0.}; TSubModel const *m_picked_submodel = nullptr; using section_sequence = std::vector; diff --git a/betterRenderer/renderer/source/nvrenderer.cpp b/betterRenderer/renderer/source/nvrenderer.cpp index 926666d5..d4f04c82 100644 --- a/betterRenderer/renderer/source/nvrenderer.cpp +++ b/betterRenderer/renderer/source/nvrenderer.cpp @@ -352,9 +352,26 @@ bool NvRenderer::Render() { double fov = glm::radians(static_cast(Global.FieldOfView) / Global.ZoomFactor); - glm::dmat4 projection = glm::perspectiveFovRH_ZO( - fov, static_cast(Global.window_size.x), - static_cast(Global.window_size.y), pass.m_draw_range, .1); + + // the track plan is drawn on a distortion-free view from above, so the whole pass switches to + // an orthographic projection. everything downstream - the mouse ray below, the frustum test, + // the shadow fitting - reads this matrix, so nothing else needs to know which one it is + double const orthoheight = Global.editor_ortho_extent; + double const orthowidth = + orthoheight * static_cast(Global.window_size.x) / + std::max(1., static_cast(Global.window_size.y)); + glm::dmat4 projection = + Global.editor_ortho + ? glm::orthoRH_ZO(-orthowidth, orthowidth, -orthoheight, + orthoheight, pass.m_draw_range, .1) + : glm::perspectiveFovRH_ZO( + fov, static_cast(Global.window_size.x), + static_cast(Global.window_size.y), + pass.m_draw_range, .1); + + m_camera_view = transform; + m_camera_projection = projection; + m_camera_position = pass.m_origin; SingleFrustumTester frustum_tester{projection, transform}; DrawConstants draw_constants{}; @@ -471,11 +488,15 @@ bool NvRenderer::Render() { glm::vec3 light_color, light_direction; m_sky->CalcLighting(light_direction, light_color); + // cascades are fitted to what the camera actually sees, so they follow the plan view too m_shadow_map->CalcCascades( - glm::perspectiveFovRH_ZO(fov, - static_cast(Global.window_size.x), - static_cast(Global.window_size.y), - .1, pass.m_draw_range), + Global.editor_ortho + ? glm::orthoRH_ZO(-orthowidth, orthowidth, -orthoheight, + orthoheight, .1, pass.m_draw_range) + : glm::perspectiveFovRH_ZO( + fov, static_cast(Global.window_size.x), + static_cast(Global.window_size.y), .1, + pass.m_draw_range), transform, -light_direction); m_shadow_map->UpdateConstants(command_list); m_gbuffer_shadow->Clear(command_list); diff --git a/rendering/opengl33renderer.cpp b/rendering/opengl33renderer.cpp index 576cf404..426925df 100644 --- a/rendering/opengl33renderer.cpp +++ b/rendering/opengl33renderer.cpp @@ -1591,7 +1591,19 @@ void opengl33_renderer::setup_pass(viewport_config &Viewport, renderpass_config auto const zfar = ( Zfar > 1.f ? Zfar : Config.draw_range * Zfar ); auto const znear = ( Znear > 1.f ? Znear : Znear > 0.f ? Znear * zfar : 0.1f * Global.ZoomFactor); - camera.projection() = perspective_projection(Viewport.projection, znear, zfar, frustumtest_proj); + // the editor's top-down plan view drops the perspective, so distances read the same wherever + // they sit on screen; the off-axis virtual screen above has no meaning then + if (Global.editor_ortho && Viewport.main && Viewport.proj_type == viewport_config::normal) + { + auto const halfheight = Global.editor_ortho_extent; + auto const halfwidth = halfheight * std::max(1.f, (float)Global.window_size.x) / std::max(1.f, (float)Global.window_size.y); + camera.projection() = ortho_projection(-halfwidth, halfwidth, -halfheight, halfheight, znear, zfar); + frustumtest_proj = ortho_frustumtest_projection(-halfwidth, halfwidth, -halfheight, halfheight, znear, zfar); + } + else + { + camera.projection() = perspective_projection(Viewport.projection, znear, zfar, frustumtest_proj); + } break; } case rendermode::shadows: diff --git a/utilities/Globals.h b/utilities/Globals.h index 0ee9cce5..4ad3c0eb 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -79,6 +79,9 @@ struct global_settings { std::string SceneryFile; // uruchomienie prosto do edytora, z pominięciem symulacji; edytor jest wtedy trybem podstawowym bool editor_startup{ false }; + // rzut z góry bez perspektywy, do kreślenia planu torowiska na tle wczytanej scenerii + bool editor_ortho{ false }; + float editor_ortho_extent{ 250.f }; // połowa wysokości widoku w metrach, czyli zoom rzutu std::string local_start_vehicle{ "EU07-424" }; int iConvertModels{ 0 }; // tworzenie plików binarnych int iConvertIndexRange{ 1000 }; // range of duplicate vertex scan From 3fbc1aaaaf92f559f9de34dd7e53aa7bc5b63bd8 Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 19:21:05 +0200 Subject: [PATCH 06/17] Draw the track plan on the scenery instead of on a canvas The plan panel had its own 2D canvas, which was the wrong place for it: the layout is drawn on the scenery. The canvas is gone. Points are laid by clicking the ground under the cursor, the solved geometry is painted over the rendered world through the colour pass camera, and the panel keeps only the controls - fits, storage and what is being edited. The left button belongs to the plan tool while it is up, so a click no longer also picks or places a scenery node. Clicks landing on a panel stay with the panel. With no georeference set the plan frame and the world share an origin, so easting runs along world x and northing against world z. --- application/editormode.cpp | 8 + application/planpanel.cpp | 345 ++++++++++++++++--------------------- application/planpanel.h | 23 ++- 3 files changed, 169 insertions(+), 207 deletions(-) diff --git a/application/editormode.cpp b/application/editormode.cpp index 31b78e82..35fcea4f 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -1591,6 +1591,14 @@ void editor_mode::on_mouse_button(int const Button, int const Action, int const return; } + // while the plan tool is up the left button belongs to it: it lays and moves the points of the + // track layout, so it must not also pick or place scenery nodes + if (Global.editor_ortho && Button == GLFW_MOUSE_BUTTON_LEFT) + { + m_input.mouse.button(Button, Action); + return; + } + // in chunk-edit mode the left button adds a neighbouring chunk (Shift = delete the clicked one) if (m_chunk_edit && Button == GLFW_MOUSE_BUTTON_LEFT) { diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 87e31124..e0bf2b9a 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -10,58 +10,49 @@ http://mozilla.org/MPL/2.0/. #include "stdafx.h" #include "application/planpanel.h" +#include "application/editormode.h" +#include "rendering/renderer.h" #include "utilities/Globals.h" namespace { -// screen placement of the canvas together with the plan point it is centred on; rebuilt every frame -// from the panel's view state, so the transform never goes stale against a resized window -struct canvas_view +// the plan works in the project's cartesian frame, the scenery in the engine's world space. with no +// georeference set the two share an origin, so the mapping is just the naming of the axes: easting +// runs along world x, northing against world z +glm::dvec3 plan_to_world(double const X, double const Y) { - ImVec2 origin{0.f, 0.f}; - ImVec2 size{0.f, 0.f}; - double viewx{0.0}; - double viewy{0.0}; - double scale{1.0}; + return {X, 0.0, -Y}; +} - ImVec2 centre() const { return {origin.x + size.x * 0.5f, origin.y + size.y * 0.5f}; } - - // northing grows towards the top of the screen, so the vertical axis is flipped - ImVec2 to_screen(double const X, double const Y) const - { - auto const middle{centre()}; - return {middle.x + static_cast((X - viewx) * scale), middle.y - static_cast((Y - viewy) * scale)}; - } - - void to_plan(ImVec2 const &Point, double &X, double &Y) const - { - auto const middle{centre()}; - X = viewx + (Point.x - middle.x) / scale; - Y = viewy - (Point.y - middle.y) / scale; - } -}; - -// grid spacing is picked so the cells stay roughly the same size on screen whatever the zoom, and -// always lands on a round number of metres a surveyor would recognise -double grid_step(double const Scale) +void world_to_plan(glm::dvec3 const &World, double &X, double &Y) { - auto const target{80.0 / std::max(Scale, 1e-6)}; // metres per cell at the wanted screen size - auto const magnitude{std::pow(10.0, std::floor(std::log10(std::max(target, 1e-6))))}; - auto const steps = {1.0, 2.0, 5.0, 10.0}; - for (auto const step : steps) + X = World.x; + Y = -World.z; +} + +// places a world point on screen using the camera of the most recent colour pass. the engine renders +// camera-relative, so the view matrix carries rotation only and the position is subtracted here +bool world_to_screen(glm::dvec3 const &World, ImVec2 &Screen) +{ + auto const relative{glm::vec3(World - GfxRenderer->Camera_Position())}; + auto const clip{GfxRenderer->Camera_Projection_Matrix() * GfxRenderer->Camera_View_Matrix() * glm::vec4(relative, 1.0f)}; + + if (clip.w <= 0.0f) { - if (magnitude * step >= target) - { - return magnitude * step; - } + return false; // behind the camera } - return magnitude * 10.0; + + auto const ndc{glm::vec3(clip) / clip.w}; + auto const display{ImGui::GetIO().DisplaySize}; + Screen = {(ndc.x * 0.5f + 0.5f) * display.x, (0.5f - ndc.y * 0.5f) * display.y}; + + return true; } ImU32 element_colour(int const Kind, bool const Active) { - auto const alpha{Active ? 255 : 90}; + auto const alpha{Active ? 255 : 110}; switch (Kind) { case 1: // arc @@ -77,8 +68,8 @@ ImU32 element_colour(int const Kind, bool const Active) plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) { - size_min = {520, 460}; - size_max = {2000, 1600}; + size_min = {420, 240}; + size_max = {900, 900}; m_document.niwelety.push_back({"niweleta 1", {}, {}}); solve(); } @@ -92,118 +83,31 @@ void plan_panel::update() void plan_panel::render_contents() { - ImGui::Text("view: top-down, %.0f m across", Global.editor_ortho_extent * 2.0f); - ImGui::SameLine(); - ImGui::TextDisabled("(wheel over the scenery zooms it)"); + handle_scene(); + draw_on_scene(); render_toolbar(); - render_canvas(); render_gaps(); render_storage(); } -void plan_panel::render_toolbar() +void plan_panel::handle_scene() { - auto *niweleta{current_niweleta()}; - - if (ImGui::Button("New niweleta")) + // clicks that land on a panel belong to the panel, not to the ground under it + if (ImGui::GetIO().WantCaptureMouse) { - m_document.niwelety.push_back({"niweleta " + std::to_string(m_document.niwelety.size() + 1), {}, {}}); - m_niweleta = m_document.niwelety.size() - 1; - m_pending = false; - solve(); - } - ImGui::SameLine(); - if (ImGui::Button("Undo point")) - { - drop_last_vertex(); - } - ImGui::SameLine(); - if (ImGui::Button("Frame all")) - { - frame_all(); - } - ImGui::SameLine(); - ImGui::Checkbox("Draw", &m_drawing); - - if (m_document.niwelety.size() > 1) - { - auto const preview{m_document.niwelety[m_niweleta].name}; - ImGui::SetNextItemWidth(220.0f); - if (ImGui::BeginCombo("Edited", preview.c_str())) - { - for (std::size_t i = 0; i < m_document.niwelety.size(); ++i) - { - if (ImGui::Selectable(m_document.niwelety[i].name.c_str(), i == m_niweleta)) - { - m_niweleta = i; - m_pending = false; - } - } - ImGui::EndCombo(); - } + m_draggedvertex = -1; + return; } - ImGui::Text("straights: %d, points: %d", niweleta != nullptr ? static_cast(niweleta->straights.size()) : 0, static_cast(vertex_count())); - ImGui::Separator(); -} + double cursorx{0.0}; + double cursory{0.0}; + world_to_plan(Global.pCamera.Pos + GfxRenderer->Mouse_Position(), cursorx, cursory); -void plan_panel::render_canvas() -{ - auto const available{ImGui::GetContentRegionAvail()}; - // the tables below the canvas need room of their own, but the canvas takes whatever is left - ImVec2 const canvassize{std::max(available.x, 240.0f), std::max(available.y - 210.0f, 200.0f)}; - - canvas_view view; - view.origin = ImGui::GetCursorScreenPos(); - view.size = canvassize; - view.viewx = m_viewx; - view.viewy = m_viewy; - view.scale = m_scale; - - ImGui::InvisibleButton("plan_canvas", canvassize); - auto const hovered{ImGui::IsItemHovered()}; auto const mouse{ImGui::GetIO().MousePos}; - double mousex{0.0}; - double mousey{0.0}; - view.to_plan(mouse, mousex, mousey); - - // zoom around the cursor, so the ground under it stays put - if (hovered && ImGui::GetIO().MouseWheel != 0.0f) - { - m_scale = std::clamp(m_scale * std::pow(1.15, ImGui::GetIO().MouseWheel), 0.002, 40.0); - view.scale = m_scale; - auto const middle{view.centre()}; - m_viewx = mousex - (mouse.x - middle.x) / m_scale; - m_viewy = mousey + (mouse.y - middle.y) / m_scale; - view.viewx = m_viewx; - view.viewy = m_viewy; - } - - // panning latches on, so the view keeps following the cursor once it leaves the canvas - if (hovered && ImGui::IsMouseDragging(1)) - { - m_panning = true; - } - if (m_panning) - { - if (false == ImGui::IsMouseDragging(1)) - { - m_panning = false; - } - else - { - auto const delta{ImGui::GetIO().MouseDelta}; - m_viewx -= delta.x / m_scale; - m_viewy += delta.y / m_scale; - view.viewx = m_viewx; - view.viewy = m_viewy; - } - } - - // grab an existing point before laying a new one, so corners can be nudged into place - if (hovered && ImGui::IsMouseClicked(0)) + // grabbing an existing point comes first, so corners can be nudged into place + if (ImGui::IsMouseClicked(0)) { m_draggedvertex = -1; for (std::size_t i = 0; i < vertex_count(); ++i) @@ -211,8 +115,12 @@ void plan_panel::render_canvas() double x{0.0}; double y{0.0}; vertex_position(i, x, y); - auto const point{view.to_screen(x, y)}; - if (std::abs(point.x - mouse.x) <= 6.0f && std::abs(point.y - mouse.y) <= 6.0f) + ImVec2 point; + if (false == world_to_screen(plan_to_world(x, y), point)) + { + continue; + } + if (std::abs(point.x - mouse.x) <= 8.0f && std::abs(point.y - mouse.y) <= 8.0f) { m_draggedvertex = static_cast(i); break; @@ -220,46 +128,29 @@ void plan_panel::render_canvas() } if (m_draggedvertex == -1 && m_drawing) { - append_vertex(mousex, mousey); + append_vertex(cursorx, cursory); } } + if (m_draggedvertex != -1) { if (ImGui::IsMouseDown(0)) { - move_vertex(static_cast(m_draggedvertex), mousex, mousey); + move_vertex(static_cast(m_draggedvertex), cursorx, cursory); } else { m_draggedvertex = -1; } } +} - auto *drawlist{ImGui::GetWindowDrawList()}; - auto const canvasend{ImVec2(view.origin.x + canvassize.x, view.origin.y + canvassize.y)}; - drawlist->PushClipRect(view.origin, canvasend, true); - drawlist->AddRectFilled(view.origin, canvasend, IM_COL32(24, 26, 30, 255)); +void plan_panel::draw_on_scene() +{ + // the background list paints over the rendered scenery but under every window, so the controls + // stay readable on top of the drawing + auto *drawlist{ImGui::GetBackgroundDrawList()}; - // grid, with the project origin picked out - auto const step{grid_step(m_scale)}; - double left{0.0}; - double top{0.0}; - double right{0.0}; - double bottom{0.0}; - view.to_plan(view.origin, left, top); - view.to_plan(canvasend, right, bottom); - for (auto x = std::floor(left / step) * step; x <= right; x += step) - { - auto const colour{std::abs(x) < step * 0.5 ? IM_COL32(90, 90, 110, 255) : IM_COL32(48, 50, 58, 255)}; - drawlist->AddLine(view.to_screen(x, top), view.to_screen(x, bottom), colour); - } - for (auto y = std::floor(bottom / step) * step; y <= top; y += step) - { - auto const colour{std::abs(y) < step * 0.5 ? IM_COL32(90, 90, 110, 255) : IM_COL32(48, 50, 58, 255)}; - drawlist->AddLine(view.to_screen(left, y), view.to_screen(right, y), colour); - } - - // solved geometry: two rails per centreline, coloured by the kind of element they came from std::vector points; for (std::size_t n = 0; n < m_solved.size(); ++n) { @@ -274,42 +165,109 @@ void plan_panel::render_canvas() points.reserve(polyline.points.size()); for (auto const &point : polyline.points) { - points.push_back(view.to_screen(point.x, point.y)); + ImVec2 screen; + if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) + { + continue; + } + points.push_back(screen); } - drawlist->AddPolyline(points.data(), static_cast(points.size()), element_colour(polyline.kind, active), false, active ? 2.0f : 1.0f); + if (points.size() < 2) + { + continue; + } + drawlist->AddPolyline(points.data(), static_cast(points.size()), element_colour(polyline.kind, active), false, active ? 2.5f : 1.5f); } } - // editable points of the niweleta being drawn, plus the corner still waiting for its partner + // editable points of the niweleta being drawn for (std::size_t i = 0; i < vertex_count(); ++i) { double x{0.0}; double y{0.0}; vertex_position(i, x, y); - auto const point{view.to_screen(x, y)}; - drawlist->AddCircleFilled(point, 4.0f, static_cast(i) == m_draggedvertex ? IM_COL32(255, 240, 120, 255) : IM_COL32(235, 235, 235, 255)); + ImVec2 screen; + if (false == world_to_screen(plan_to_world(x, y), screen)) + { + continue; + } + drawlist->AddCircleFilled(screen, 5.0f, static_cast(i) == m_draggedvertex ? IM_COL32(255, 240, 120, 255) : IM_COL32(240, 240, 240, 255)); } + + // the corner still waiting for the one that closes its straight, and the rubber band to the cursor + auto const mouse{ImGui::GetIO().MousePos}; + auto const overscenery{false == ImGui::GetIO().WantCaptureMouse}; if (m_pending) { - auto const point{view.to_screen(m_pendingx, m_pendingy)}; - drawlist->AddCircleFilled(point, 4.0f, IM_COL32(255, 240, 120, 255)); - if (hovered) + ImVec2 screen; + if (world_to_screen(plan_to_world(m_pendingx, m_pendingy), screen)) { - drawlist->AddLine(point, mouse, IM_COL32(255, 240, 120, 160)); + drawlist->AddCircleFilled(screen, 5.0f, IM_COL32(255, 240, 120, 255)); + if (overscenery) + { + drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); + } } } - else if (hovered && m_drawing && m_draggedvertex == -1 && vertex_count() > 0) + else if (overscenery && m_drawing && m_draggedvertex == -1 && vertex_count() > 0) { double x{0.0}; double y{0.0}; vertex_position(vertex_count() - 1, x, y); - drawlist->AddLine(view.to_screen(x, y), mouse, IM_COL32(255, 240, 120, 160)); + ImVec2 screen; + if (world_to_screen(plan_to_world(x, y), screen)) + { + drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); + } + } +} + +void plan_panel::render_toolbar() +{ + auto *niweleta{current_niweleta()}; + + ImGui::Text("top-down view, %.0f m across", Global.editor_ortho_extent * 2.0f); + ImGui::TextDisabled("click the scenery to lay points, drag a point to move it, wheel zooms"); + ImGui::Separator(); + + if (ImGui::Button("New niweleta")) + { + m_document.niwelety.push_back({"niweleta " + std::to_string(m_document.niwelety.size() + 1), {}, {}}); + m_niweleta = m_document.niwelety.size() - 1; + m_pending = false; + solve(); + } + ImGui::SameLine(); + if (ImGui::Button("Undo point")) + { + drop_last_vertex(); + } + ImGui::SameLine(); + if (ImGui::Button("Go to plan")) + { + go_to_plan(); + } + ImGui::SameLine(); + ImGui::Checkbox("Draw", &m_drawing); + + if (m_document.niwelety.size() > 1) + { + ImGui::SetNextItemWidth(220.0f); + if (ImGui::BeginCombo("Edited", m_document.niwelety[m_niweleta].name.c_str())) + { + for (std::size_t i = 0; i < m_document.niwelety.size(); ++i) + { + if (ImGui::Selectable(m_document.niwelety[i].name.c_str(), i == m_niweleta)) + { + m_niweleta = i; + m_pending = false; + } + } + ImGui::EndCombo(); + } } - drawlist->PopClipRect(); - - ImGui::Text("cursor: %.1f, %.1f m grid: %g m scale: %.3g px/m", mousex, mousey, step, m_scale); - ImGui::TextDisabled("left click lays a point, drag a point to move it, right drag pans, wheel zooms"); + ImGui::Text("straights: %d, points: %d", niweleta != nullptr ? static_cast(niweleta->straights.size()) : 0, static_cast(vertex_count())); } void plan_panel::render_gaps() @@ -326,7 +284,7 @@ void plan_panel::render_gaps() return; } - ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 120.0f), false); + ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 130.0f), false); for (std::size_t gap = 0; gap < niweleta->fits.size(); ++gap) { auto &fit{niweleta->fits[gap]}; @@ -377,7 +335,7 @@ void plan_panel::render_gaps() void plan_panel::render_storage() { ImGui::Separator(); - ImGui::SetNextItemWidth(280.0f); + ImGui::SetNextItemWidth(240.0f); ImGui::InputText("##path", m_path, sizeof(m_path)); ImGui::SameLine(); if (ImGui::Button("Save")) @@ -399,7 +357,7 @@ void plan_panel::render_storage() m_pending = false; m_draggedvertex = -1; solve(); - frame_all(); + go_to_plan(); m_status = "loaded " + std::string(m_path); } else @@ -561,7 +519,7 @@ void plan_panel::drop_last_vertex() solve(); } -void plan_panel::frame_all() +void plan_panel::go_to_plan() { auto minx{std::numeric_limits::max()}; auto miny{std::numeric_limits::max()}; @@ -581,16 +539,15 @@ void plan_panel::frame_all() if (minx > maxx) { - // nothing drawn yet: sit on the project origin at a scale that shows a few hundred metres - m_viewx = 0.0; - m_viewy = 0.0; - m_scale = 2.0; - return; + return; // nothing drawn yet, so there is nowhere to go } - m_viewx = (minx + maxx) * 0.5; - m_viewy = (miny + maxy) * 0.5; + auto &camera{editor_mode::get_camera()}; + auto const centre{plan_to_world((minx + maxx) * 0.5, (miny + maxy) * 0.5)}; + camera.Pos.x = centre.x; + camera.Pos.z = centre.z; + // show the whole drawing with a little room around it auto const span{std::max({maxx - minx, maxy - miny, 50.0})}; - m_scale = std::clamp(400.0 / span, 0.002, 40.0); + Global.editor_ortho_extent = std::clamp(static_cast(span * 0.6), 5.0f, 20000.0f); } diff --git a/application/planpanel.h b/application/planpanel.h index 61d751bf..d5976cab 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -14,10 +14,10 @@ http://mozilla.org/MPL/2.0/. #include "maj0sted/app/editor_document.hpp" #include "maj0sted/web/editor.hpp" -// drawing board for track layout: straights laid down by hand and the curves fitted into the gaps -// between them, in the project's own cartesian frame (EPSG:2180, metres). none of the geometry is -// computed here - the panel collects the input, hands the whole document to maj0sted and paints -// what comes back. scenery nodes are not touched. +// track layout tool: straights laid down by hand on the scenery itself and the curves fitted into +// the gaps between them. the panel holds only the controls - the drawing happens in the viewport, +// which the tool puts into a top-down orthographic view for the purpose. none of the geometry is +// computed here: maj0sted solves the whole document and the panel paints what comes back. class plan_panel : public ui_panel { @@ -30,9 +30,11 @@ class plan_panel : public ui_panel private: // methods void render_toolbar(); - void render_canvas(); void render_gaps(); void render_storage(); + // paints the solved plan over the rendered scenery and takes the clicks that build it + void handle_scene(); + void draw_on_scene(); // re-runs the solver over the whole document; cheap enough to do on every edit void solve(); maj0sted::web::NiweletaSpec *current_niweleta(); @@ -48,23 +50,18 @@ class plan_panel : public ui_panel void append_vertex(double const X, double const Y); void move_vertex(std::size_t const Index, double const X, double const Y); void drop_last_vertex(); - // centres the view on the drawing, or on a default patch of ground when nothing is drawn yet - void frame_all(); + // takes the camera to the middle of the drawing + void go_to_plan(); // members maj0sted::app::EditorDocument m_document; std::vector m_solved; std::size_t m_niweleta{0}; // niweleta being drawn into int m_draggedvertex{-1}; - bool m_drawing{true}; // whether a click on the canvas lays track or only picks vertices - bool m_panning{false}; + bool m_drawing{true}; // whether a click on the scenery lays track or only picks existing points // first corner of a niweleta has nothing to attach to, so it waits for the second click bool m_pending{false}; double m_pendingx{0.0}; double m_pendingy{0.0}; - // view: the plan point sitting at the centre of the canvas, and the zoom in pixels per metre - double m_viewx{0.0}; - double m_viewy{0.0}; - double m_scale{2.0}; std::string m_status; char m_path[256]{"editor/plan.m0s"}; }; From bf82c35734cbf0f39894ec98ef83ac8f36c99571 Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 19:32:00 +0200 Subject: [PATCH 07/17] Georeference a new map against a point in Poland "New map" now asks what is being started. A fictional scenery keeps its own frame, where coordinates mean nothing outside it. A real one pins the scenery's zero to a point picked off a map of Poland, so the plan is drawn in EPSG:2180 - the frame the layout library already works in, and the one Geoportal serves imagery in. The projection is the standard transverse Mercator series for PUWG 1992 on GRS80. Checked against known values: the central meridian comes out at easting 500000 exactly, Warszawa at 637382/486757, Krakow at 567614/244588, Gdansk at 477038/720937. The map behind the picker is a schematic border outline plus the larger cities, projected from geographic coordinates on first use. It is coarse on purpose - it is there to point at a region, not to measure against - and the dialog says so. No network is involved. --- CMakeLists.txt | 2 + application/planpanel.cpp | 221 ++++++++++++++++++++++++++++++++++++-- application/planpanel.h | 12 +++ editor/polandmap.cpp | 92 ++++++++++++++++ editor/polandmap.h | 34 ++++++ editor/puwg1992.cpp | 73 +++++++++++++ editor/puwg1992.h | 22 ++++ utilities/Globals.h | 4 + 8 files changed, 454 insertions(+), 6 deletions(-) create mode 100644 editor/polandmap.cpp create mode 100644 editor/polandmap.h create mode 100644 editor/puwg1992.cpp create mode 100644 editor/puwg1992.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e420d41a..27c46871 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -167,6 +167,8 @@ set(SOURCES "application/driveruipanels.cpp" "input/editorkeyboardinput.cpp" "editor/editorSettings.cpp" +"editor/puwg1992.cpp" +"editor/polandmap.cpp" "editor/editorTerrain.cpp" "editor/editorTerrainStreamer.cpp" "application/editormode.cpp" diff --git a/application/planpanel.cpp b/application/planpanel.cpp index e0bf2b9a..81fd1d60 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -11,24 +11,26 @@ http://mozilla.org/MPL/2.0/. #include "application/planpanel.h" #include "application/editormode.h" +#include "editor/polandmap.h" #include "rendering/renderer.h" #include "utilities/Globals.h" namespace { -// the plan works in the project's cartesian frame, the scenery in the engine's world space. with no -// georeference set the two share an origin, so the mapping is just the naming of the axes: easting -// runs along world x, northing against world z +// the plan works in EPSG:2180, the scenery in the engine's world space around its own zero. the +// georeference says which point of the projection that zero stands for; a fictional scenery leaves +// it at the origin, so the two frames differ only in the naming of the axes - easting runs along +// world x, northing against world z glm::dvec3 plan_to_world(double const X, double const Y) { - return {X, 0.0, -Y}; + return {X - Global.scenery_origin.x, 0.0, -(Y - Global.scenery_origin.y)}; } void world_to_plan(glm::dvec3 const &World, double &X, double &Y) { - X = World.x; - Y = -World.z; + X = World.x + Global.scenery_origin.x; + Y = -World.z + Global.scenery_origin.y; } // places a world point on screen using the camera of the most recent colour pass. the engine renders @@ -89,6 +91,200 @@ void plan_panel::render_contents() render_toolbar(); render_gaps(); render_storage(); + render_newmap_dialog(); + render_location_dialog(); +} + +void plan_panel::render_newmap_dialog() +{ + if (false == ImGui::BeginPopupModal("New map", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + return; + } + + ImGui::TextUnformatted("What is this scenery going to be?"); + ImGui::Spacing(); + + if (ImGui::Button("Fictional", ImVec2(160.0f, 0.0f))) + { + start_map(false, 0.0, 0.0); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + ImGui::TextDisabled("coordinates mean nothing outside the scenery"); + + if (ImGui::Button("Real place", ImVec2(160.0f, 0.0f))) + { + // start the picker on the middle of the country, showing the whole of it + m_pickingplace = true; + m_pickx = 500000.0; + m_picky = 350000.0; + m_mapviewx = m_pickx; + m_mapviewy = m_picky; + m_mapscale = 0.0; + ImGui::CloseCurrentPopup(); + ImGui::OpenPopup("Scenery location"); + } + ImGui::SameLine(); + ImGui::TextDisabled("pin the scenery's zero to a point in Poland"); + + ImGui::Spacing(); + if (ImGui::Button("Cancel")) + { + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); +} + +void plan_panel::render_location_dialog() +{ + if (m_pickingplace) + { + // the popup has to be opened from the same stack level it is drawn on + ImGui::OpenPopup("Scenery location"); + m_pickingplace = false; + } + + if (false == ImGui::BeginPopupModal("Scenery location", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + return; + } + + ImVec2 const mapsize{560.0f, 460.0f}; + auto const origin{ImGui::GetCursorScreenPos()}; + ImGui::InvisibleButton("poland_map", mapsize); + auto const hovered{ImGui::IsItemHovered()}; + + auto const &outline{editor::poland_outline()}; + auto const &places{editor::poland_places()}; + + // fit the whole country the first time round; afterwards the wheel decides + if (m_mapscale <= 0.0) + { + auto minx{std::numeric_limits::max()}; + auto miny{std::numeric_limits::max()}; + auto maxx{std::numeric_limits::lowest()}; + auto maxy{std::numeric_limits::lowest()}; + for (auto const &point : outline) + { + minx = std::min(minx, point.x); + maxx = std::max(maxx, point.x); + miny = std::min(miny, point.y); + maxy = std::max(maxy, point.y); + } + m_mapviewx = (minx + maxx) * 0.5; + m_mapviewy = (miny + maxy) * 0.5; + m_mapscale = std::min(mapsize.x / std::max(1.0, maxx - minx), mapsize.y / std::max(1.0, maxy - miny)) * 0.92; + } + + ImVec2 const centre{origin.x + mapsize.x * 0.5f, origin.y + mapsize.y * 0.5f}; + auto const to_screen = [&](double const X, double const Y) { + return ImVec2{centre.x + static_cast((X - m_mapviewx) * m_mapscale), centre.y - static_cast((Y - m_mapviewy) * m_mapscale)}; + }; + auto const to_map = [&](ImVec2 const &Point, double &X, double &Y) { + X = m_mapviewx + (Point.x - centre.x) / m_mapscale; + Y = m_mapviewy - (Point.y - centre.y) / m_mapscale; + }; + + auto const mouse{ImGui::GetIO().MousePos}; + if (hovered && ImGui::GetIO().MouseWheel != 0.0f) + { + double anchorx{0.0}; + double anchory{0.0}; + to_map(mouse, anchorx, anchory); + m_mapscale = std::clamp(m_mapscale * std::pow(1.2, ImGui::GetIO().MouseWheel), 1e-5, 1.0); + m_mapviewx = anchorx - (mouse.x - centre.x) / m_mapscale; + m_mapviewy = anchory + (mouse.y - centre.y) / m_mapscale; + } + if (hovered && ImGui::IsMouseClicked(0)) + { + to_map(mouse, m_pickx, m_picky); + } + + auto *drawlist{ImGui::GetWindowDrawList()}; + drawlist->PushClipRect(origin, ImVec2(origin.x + mapsize.x, origin.y + mapsize.y), true); + drawlist->AddRectFilled(origin, ImVec2(origin.x + mapsize.x, origin.y + mapsize.y), IM_COL32(22, 30, 38, 255)); + + std::vector border; + border.reserve(outline.size()); + for (auto const &point : outline) + { + border.push_back(to_screen(point.x, point.y)); + } + drawlist->AddPolyline(border.data(), static_cast(border.size()), IM_COL32(120, 190, 140, 255), true, 1.5f); + + for (auto const &place : places) + { + auto const point{to_screen(place.position.x, place.position.y)}; + drawlist->AddCircleFilled(point, 3.0f, IM_COL32(200, 200, 200, 255)); + drawlist->AddText(ImVec2(point.x + 5.0f, point.y - 6.0f), IM_COL32(190, 190, 190, 255), place.name.c_str()); + } + + auto const picked{to_screen(m_pickx, m_picky)}; + drawlist->AddCircleFilled(picked, 5.0f, IM_COL32(255, 210, 90, 255)); + drawlist->AddLine(ImVec2(picked.x - 12.0f, picked.y), ImVec2(picked.x + 12.0f, picked.y), IM_COL32(255, 210, 90, 200)); + drawlist->AddLine(ImVec2(picked.x, picked.y - 12.0f), ImVec2(picked.x, picked.y + 12.0f), IM_COL32(255, 210, 90, 200)); + + drawlist->PopClipRect(); + + ImGui::TextDisabled("schematic outline - click to place the scenery's zero, wheel zooms"); + + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("easting", &m_pickx, 100.0, 1000.0, "%.0f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("northing", &m_picky, 100.0, 1000.0, "%.0f"); + + ImGui::SetNextItemWidth(220.0f); + if (ImGui::BeginCombo("go to", "pick a city")) + { + for (auto const &place : places) + { + if (ImGui::Selectable(place.name.c_str())) + { + m_pickx = place.position.x; + m_picky = place.position.y; + m_mapviewx = m_pickx; + m_mapviewy = m_picky; + } + } + ImGui::EndCombo(); + } + + ImGui::Spacing(); + if (ImGui::Button("Use this place", ImVec2(160.0f, 0.0f))) + { + start_map(true, m_pickx, m_picky); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + { + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); +} + +void plan_panel::start_map(bool const Georeferenced, double const Originx, double const Originy) +{ + Global.scenery_georeferenced = Georeferenced; + Global.scenery_origin = {Originx, Originy}; + + m_document.niwelety.clear(); + m_document.niwelety.push_back({"niweleta 1", {}, {}}); + m_niweleta = 0; + m_pending = false; + m_draggedvertex = -1; + solve(); + + // the scenery's zero is where the work starts, whichever frame it stands for + auto &camera{editor_mode::get_camera()}; + camera.Pos.x = 0.0; + camera.Pos.z = 0.0; + + m_status = Georeferenced ? "map pinned at " + to_string(Originx, 0) + ", " + to_string(Originy, 0) + " (EPSG:2180)" : "fictional map"; } void plan_panel::handle_scene() @@ -228,8 +424,21 @@ void plan_panel::render_toolbar() ImGui::Text("top-down view, %.0f m across", Global.editor_ortho_extent * 2.0f); ImGui::TextDisabled("click the scenery to lay points, drag a point to move it, wheel zooms"); + if (Global.scenery_georeferenced) + { + ImGui::Text("zero at %.0f, %.0f (EPSG:2180)", Global.scenery_origin.x, Global.scenery_origin.y); + } + else + { + ImGui::TextDisabled("fictional map, no georeference"); + } ImGui::Separator(); + if (ImGui::Button("New map")) + { + ImGui::OpenPopup("New map"); + } + ImGui::SameLine(); if (ImGui::Button("New niweleta")) { m_document.niwelety.push_back({"niweleta " + std::to_string(m_document.niwelety.size() + 1), {}, {}}); diff --git a/application/planpanel.h b/application/planpanel.h index d5976cab..4a579931 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -32,6 +32,11 @@ class plan_panel : public ui_panel void render_toolbar(); void render_gaps(); void render_storage(); + // asks what kind of scenery is being started, and where on the map it sits + void render_newmap_dialog(); + void render_location_dialog(); + // clears the drawing and sets the point of EPSG:2180 that the scenery's zero stands for + void start_map(bool const Georeferenced, double const Originx, double const Originy); // paints the solved plan over the rendered scenery and takes the clicks that build it void handle_scene(); void draw_on_scene(); @@ -64,4 +69,11 @@ class plan_panel : public ui_panel double m_pendingy{0.0}; std::string m_status; char m_path[256]{"editor/plan.m0s"}; + // location picker: the point being considered, and the view over the schematic map of Poland + bool m_pickingplace{false}; + double m_pickx{0.0}; + double m_picky{0.0}; + double m_mapviewx{0.0}; + double m_mapviewy{0.0}; + double m_mapscale{0.0}; }; diff --git a/editor/polandmap.cpp b/editor/polandmap.cpp new file mode 100644 index 00000000..65b97fef --- /dev/null +++ b/editor/polandmap.cpp @@ -0,0 +1,92 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "editor/polandmap.h" + +#include "editor/puwg1992.h" + +namespace editor +{ + +namespace +{ + +// geographic coordinates, degrees, as (latitude, longitude); projected to EPSG:2180 on first use so +// the numbers stay readable and there is a single place where the frame is decided +struct geo_point +{ + double latitude; + double longitude; +}; + +// the border walked clockwise from the north-west corner: Baltic coast, the Kaliningrad and +// Lithuanian borders, the Bug, the Carpathians and Sudetes, then the Nysa and Odra back north. +// tens of kilometres of error in places - this is a locator sketch, nothing more +constexpr geo_point kOutline[]{ + {53.93, 14.27}, {54.05, 14.60}, {54.18, 15.58}, {54.42, 16.41}, {54.58, 16.86}, {54.76, 17.55}, + {54.79, 18.40}, {54.60, 18.65}, {54.52, 18.55}, {54.35, 18.95}, {54.37, 19.35}, {54.45, 19.64}, + {54.37, 20.50}, {54.35, 21.50}, {54.36, 22.00}, {54.36, 22.79}, {54.25, 23.20}, {54.15, 23.48}, + {53.95, 23.90}, {53.20, 23.93}, {52.70, 23.85}, {52.30, 23.60}, {52.07, 23.62}, {51.55, 23.55}, + {50.87, 24.15}, {50.72, 24.15}, {50.40, 23.70}, {50.28, 23.50}, {49.80, 22.92}, {49.20, 22.68}, + {49.09, 22.55}, {49.25, 22.00}, {49.42, 21.75}, {49.35, 21.00}, {49.40, 20.50}, {49.20, 20.10}, + {49.40, 19.80}, {49.40, 19.50}, {49.40, 19.20}, {49.51, 18.85}, {49.75, 18.63}, {49.95, 18.05}, + {50.10, 17.70}, {50.31, 17.38}, {50.45, 16.90}, {50.20, 16.60}, {50.40, 16.35}, {50.44, 16.25}, + {50.75, 15.80}, {50.75, 15.55}, {50.85, 15.00}, {50.87, 14.82}, {51.30, 14.95}, {51.60, 14.75}, + {52.00, 14.70}, {52.35, 14.55}, {52.59, 14.65}, {52.85, 14.40}, {53.10, 14.35}, {53.55, 14.30}, + {53.75, 14.28}}; + +struct named_geo_point +{ + char const *name; + geo_point position; +}; + +constexpr named_geo_point kPlaces[]{ + {"Warszawa", {52.23, 21.01}}, {"Krakow", {50.06, 19.94}}, {"Lodz", {51.77, 19.46}}, + {"Wroclaw", {51.11, 17.03}}, {"Poznan", {52.41, 16.93}}, {"Gdansk", {54.35, 18.65}}, + {"Szczecin", {53.43, 14.55}}, {"Bydgoszcz", {53.12, 18.01}}, {"Lublin", {51.25, 22.57}}, + {"Bialystok", {53.13, 23.16}}, {"Katowice", {50.26, 19.02}}, {"Rzeszow", {50.04, 22.00}}, + {"Olsztyn", {53.78, 20.49}}, {"Kielce", {50.87, 20.63}}, {"Zielona Gora", {51.94, 15.51}}, + {"Opole", {50.67, 17.93}}, {"Torun", {53.02, 18.61}}, {"Gorzow Wlkp.", {52.73, 15.24}}, + {"Koszalin", {54.19, 16.18}}, {"Czestochowa", {50.81, 19.12}}}; + +} // namespace + +std::vector const &poland_outline() +{ + static std::vector const outline = []() { + std::vector points; + points.reserve(std::size(kOutline)); + for (auto const &point : kOutline) + { + points.push_back(puwg1992(point.latitude, point.longitude)); + } + return points; + }(); + + return outline; +} + +std::vector const &poland_places() +{ + static std::vector const places = []() { + std::vector result; + result.reserve(std::size(kPlaces)); + for (auto const &place : kPlaces) + { + result.push_back({place.name, puwg1992(place.position.latitude, place.position.longitude)}); + } + return result; + }(); + + return places; +} + +} // namespace editor diff --git a/editor/polandmap.h b/editor/polandmap.h new file mode 100644 index 00000000..f237e040 --- /dev/null +++ b/editor/polandmap.h @@ -0,0 +1,34 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include +#include + +#include + +namespace editor +{ + +// a named point to start a scenery from, in EPSG:2180 metres +struct map_place +{ + std::string name; + glm::dvec2 position; +}; + +// schematic outline of the country's border, in EPSG:2180 metres. coarse on purpose: it is here so +// a region can be pointed at, not so anything can be measured against it +std::vector const &poland_outline(); + +// larger cities, to put the view somewhere recognisable before picking the exact spot +std::vector const &poland_places(); + +} // namespace editor diff --git a/editor/puwg1992.cpp b/editor/puwg1992.cpp new file mode 100644 index 00000000..599e0dbe --- /dev/null +++ b/editor/puwg1992.cpp @@ -0,0 +1,73 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "editor/puwg1992.h" + +namespace editor +{ + +namespace +{ + +// PUWG 1992: a single transverse Mercator zone over the whole country, on the GRS80 ellipsoid +double const kSemiMajor = 6378137.0; +double const kFlattening = 1.0 / 298.257222101; +double const kCentralMeridian = 19.0; // degrees east +double const kScaleFactor = 0.9993; +double const kFalseEasting = 500000.0; +double const kFalseNorthing = -5300000.0; + +} // namespace + +glm::dvec2 puwg1992(double const Latitude, double const Longitude) +{ + auto const e2 = 2.0 * kFlattening - kFlattening * kFlattening; + auto const ep2 = e2 / (1.0 - e2); + + auto const lat = glm::radians(Latitude); + auto const dlon = glm::radians(Longitude - kCentralMeridian); + + auto const sinlat = std::sin(lat); + auto const coslat = std::cos(lat); + auto const tanlat = std::tan(lat); + + // radius of curvature in the prime vertical, and the series terms of the standard + // transverse Mercator expansion (Snyder); good to millimetres over a zone this size + auto const n = kSemiMajor / std::sqrt(1.0 - e2 * sinlat * sinlat); + auto const t = tanlat * tanlat; + auto const c = ep2 * coslat * coslat; + auto const a = dlon * coslat; + + // meridional arc from the equator + auto const m = + kSemiMajor * ((1.0 - e2 / 4.0 - 3.0 * e2 * e2 / 64.0 - 5.0 * e2 * e2 * e2 / 256.0) * lat - + (3.0 * e2 / 8.0 + 3.0 * e2 * e2 / 32.0 + 45.0 * e2 * e2 * e2 / 1024.0) * std::sin(2.0 * lat) + + (15.0 * e2 * e2 / 256.0 + 45.0 * e2 * e2 * e2 / 1024.0) * std::sin(4.0 * lat) - + (35.0 * e2 * e2 * e2 / 3072.0) * std::sin(6.0 * lat)); + + auto const a2 = a * a; + auto const a3 = a2 * a; + auto const a4 = a3 * a; + auto const a5 = a4 * a; + auto const a6 = a5 * a; + + auto const easting = kFalseEasting + kScaleFactor * n * + (a + (1.0 - t + c) * a3 / 6.0 + + (5.0 - 18.0 * t + t * t + 72.0 * c - 58.0 * ep2) * a5 / 120.0); + + auto const northing = kFalseNorthing + kScaleFactor * + (m + n * tanlat * + (a2 / 2.0 + (5.0 - t + 9.0 * c + 4.0 * c * c) * a4 / 24.0 + + (61.0 - 58.0 * t + t * t + 600.0 * c - 330.0 * ep2) * a6 / 720.0)); + + return {easting, northing}; +} + +} // namespace editor diff --git a/editor/puwg1992.h b/editor/puwg1992.h new file mode 100644 index 00000000..a5c38098 --- /dev/null +++ b/editor/puwg1992.h @@ -0,0 +1,22 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include + +namespace editor +{ + +// projects geographic coordinates onto PUWG 1992 (EPSG:2180) - the frame the track layout library +// works in, and the one Geoportal serves its imagery in. latitude and longitude in degrees, result +// in metres as (easting, northing). +glm::dvec2 puwg1992(double const Latitude, double const Longitude); + +} // namespace editor diff --git a/utilities/Globals.h b/utilities/Globals.h index 4ad3c0eb..d218d535 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -82,6 +82,10 @@ struct global_settings { // rzut z góry bez perspektywy, do kreślenia planu torowiska na tle wczytanej scenerii bool editor_ortho{ false }; float editor_ortho_extent{ 250.f }; // połowa wysokości widoku w metrach, czyli zoom rzutu + // punkt EPSG:2180 odpowiadający zeru scenerii. Bez tego sceneria jest fikcyjna i jej współrzędne + // nie znaczą nic poza nią samą + bool scenery_georeferenced{ false }; + glm::dvec2 scenery_origin{ 0.0, 0.0 }; std::string local_start_vehicle{ "EU07-424" }; int iConvertModels{ 0 }; // tworzenie plików binarnych int iConvertIndexRange{ 1000 }; // range of duplicate vertex scan From 8d007291e2cfb6e354b62231b9a039c1be921fc2 Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 19:41:32 +0200 Subject: [PATCH 08/17] Put Geoportal imagery behind the plan and clear the scenery on a new map The location picker now shows the real topographic map instead of a hand-drawn outline, fetched as one WMS image for whatever box is on screen; the schematic border is gone and only the city list remains, to jump the view somewhere recognisable. Under the plan itself a georeferenced map gets the orthophoto, tiled through the layout library's own EPSG:2180 grid and cache. What a native build was missing is here: the download (WinHTTP, on worker threads), the decode and the texture. Finished transfers are handed back on the thread that drains them, so both the library's cache and the graphics API stay on one thread. The imagery service turns down browser user agents and fails the odd request even for a plain one, so requests go out as curl and are retried a few times. Starting a new map now drops the loaded scenery and comes back on empty ground, instead of leaving the previous scenery's contents under a plan that has nothing to do with them. The reload is deferred to the top of the frame: taking the editor off the mode stack while it is drawing its own panels would pull the ground out from under them. --- CMakeLists.txt | 1 + application/editormode.cpp | 13 ++ application/planpanel.cpp | 89 ++++++-- application/planpanel.h | 5 + editor/orthophoto.cpp | 440 +++++++++++++++++++++++++++++++++++++ editor/orthophoto.h | 124 +++++++++++ editor/polandmap.cpp | 39 ++-- editor/polandmap.h | 7 +- utilities/Globals.h | 3 + 9 files changed, 667 insertions(+), 54 deletions(-) create mode 100644 editor/orthophoto.cpp create mode 100644 editor/orthophoto.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 27c46871..02bf5e21 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -169,6 +169,7 @@ set(SOURCES "editor/editorSettings.cpp" "editor/puwg1992.cpp" "editor/polandmap.cpp" +"editor/orthophoto.cpp" "editor/editorTerrain.cpp" "editor/editorTerrainStreamer.cpp" "application/editormode.cpp" diff --git a/application/editormode.cpp b/application/editormode.cpp index 35fcea4f..486278fd 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -609,6 +609,19 @@ void editor_mode::redo_last() bool editor_mode::update() { + if (Global.editor_reset_scenery) + { + // starting a new map: drop whatever scenery is loaded and come back on empty ground. done + // here rather than where it was asked for, because taking the editor off the mode stack in + // the middle of drawing its own panels would pull the ground out from under them + Global.editor_reset_scenery = false; + Global.editor_startup = true; // the editor is the base mode from here on, whatever started it + Global.SceneryFile = "pusta.scn"; + Application.pop_mode(); + Application.push_mode(eu07_application::mode::scenarioloader); + return true; + } + Timer::UpdateTimers(true); simulation::State.update_clocks(); diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 81fd1d60..a92b4b70 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -151,31 +151,24 @@ void plan_panel::render_location_dialog() return; } - ImVec2 const mapsize{560.0f, 460.0f}; + // the request is one square image, so the view is square too and the pixels map one to one + int const mappixels = 512; + ImVec2 const mapsize{static_cast(mappixels), static_cast(mappixels)}; auto const origin{ImGui::GetCursorScreenPos()}; ImGui::InvisibleButton("poland_map", mapsize); auto const hovered{ImGui::IsItemHovered()}; - auto const &outline{editor::poland_outline()}; auto const &places{editor::poland_places()}; // fit the whole country the first time round; afterwards the wheel decides if (m_mapscale <= 0.0) { - auto minx{std::numeric_limits::max()}; - auto miny{std::numeric_limits::max()}; - auto maxx{std::numeric_limits::lowest()}; - auto maxy{std::numeric_limits::lowest()}; - for (auto const &point : outline) - { - minx = std::min(minx, point.x); - maxx = std::max(maxx, point.x); - miny = std::min(miny, point.y); - maxy = std::max(maxy, point.y); - } - m_mapviewx = (minx + maxx) * 0.5; - m_mapviewy = (miny + maxy) * 0.5; - m_mapscale = std::min(mapsize.x / std::max(1.0, maxx - minx), mapsize.y / std::max(1.0, maxy - miny)) * 0.92; + glm::dvec2 min; + glm::dvec2 max; + editor::poland_extent(min, max); + m_mapviewx = (min.x + max.x) * 0.5; + m_mapviewy = (min.y + max.y) * 0.5; + m_mapscale = mapsize.x / std::max({max.x - min.x, max.y - min.y, 1.0}); } ImVec2 const centre{origin.x + mapsize.x * 0.5f, origin.y + mapsize.y * 0.5f}; @@ -203,16 +196,25 @@ void plan_panel::render_location_dialog() } auto *drawlist{ImGui::GetWindowDrawList()}; - drawlist->PushClipRect(origin, ImVec2(origin.x + mapsize.x, origin.y + mapsize.y), true); - drawlist->AddRectFilled(origin, ImVec2(origin.x + mapsize.x, origin.y + mapsize.y), IM_COL32(22, 30, 38, 255)); + auto const mapend{ImVec2(origin.x + mapsize.x, origin.y + mapsize.y)}; + drawlist->PushClipRect(origin, mapend, true); + drawlist->AddRectFilled(origin, mapend, IM_COL32(22, 30, 38, 255)); - std::vector border; - border.reserve(outline.size()); - for (auto const &point : outline) + // the topographic base map behind everything, asked for at exactly the box on screen + double viewminx{0.0}; + double viewminy{0.0}; + double viewmaxx{0.0}; + double viewmaxy{0.0}; + to_map(origin, viewminx, viewmaxy); + to_map(mapend, viewmaxx, viewminy); + auto const backdrop{m_topomap.texture_for({viewminx, viewminy, viewmaxx, viewmaxy}, mappixels)}; + if (backdrop != 0) { - border.push_back(to_screen(point.x, point.y)); + // the picture on screen is the one that was fetched, which may lag the current view by a + // request; drawing it against its own box keeps it registered while the new one arrives + auto const &covered{m_topomap.covered()}; + drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y)); } - drawlist->AddPolyline(border.data(), static_cast(border.size()), IM_COL32(120, 190, 140, 255), true, 1.5f); for (auto const &place : places) { @@ -228,7 +230,7 @@ void plan_panel::render_location_dialog() drawlist->PopClipRect(); - ImGui::TextDisabled("schematic outline - click to place the scenery's zero, wheel zooms"); + ImGui::TextDisabled(m_topomap.loading() ? "fetching the topographic map..." : "click to place the scenery's zero, wheel zooms"); ImGui::SetNextItemWidth(180.0f); ImGui::InputDouble("easting", &m_pickx, 100.0, 1000.0, "%.0f"); @@ -284,6 +286,9 @@ void plan_panel::start_map(bool const Georeferenced, double const Originx, doubl camera.Pos.x = 0.0; camera.Pos.z = 0.0; + // a new map starts on empty ground - whatever scenery was loaded has nothing to do with it + Global.editor_reset_scenery = true; + m_status = Georeferenced ? "map pinned at " + to_string(Originx, 0) + ", " + to_string(Originy, 0) + " (EPSG:2180)" : "fictional map"; } @@ -347,6 +352,32 @@ void plan_panel::draw_on_scene() // stay readable on top of the drawing auto *drawlist{ImGui::GetBackgroundDrawList()}; + // orthophoto under everything else. only a georeferenced map knows where on the ground it sits, + // so a fictional one gets no imagery + if (m_showortho && Global.scenery_georeferenced) + { + auto const centre{Global.pCamera.Pos}; + double planx{0.0}; + double plany{0.0}; + world_to_plan(centre, planx, plany); + auto const reach{static_cast(Global.editor_ortho_extent) * 1.4}; + + for (auto const &tile : m_ortho.collect({planx - reach, plany - reach, planx + reach, plany + reach})) + { + ImVec2 corners[4]; + // the tile is a ground rectangle; on screen it is whatever the camera makes of it + auto const ok = world_to_screen(plan_to_world(tile.box.min_x, tile.box.max_y), corners[0]) && + world_to_screen(plan_to_world(tile.box.max_x, tile.box.max_y), corners[1]) && + world_to_screen(plan_to_world(tile.box.max_x, tile.box.min_y), corners[2]) && + world_to_screen(plan_to_world(tile.box.min_x, tile.box.min_y), corners[3]); + if (false == ok) + { + continue; + } + drawlist->AddImageQuad(reinterpret_cast(static_cast(tile.texture)), corners[0], corners[1], corners[2], corners[3]); + } + } + std::vector points; for (std::size_t n = 0; n < m_solved.size(); ++n) { @@ -458,6 +489,16 @@ void plan_panel::render_toolbar() } ImGui::SameLine(); ImGui::Checkbox("Draw", &m_drawing); + if (Global.scenery_georeferenced) + { + ImGui::SameLine(); + ImGui::Checkbox("Orthophoto", &m_showortho); + if (m_showortho && m_ortho.pending() > 0) + { + ImGui::SameLine(); + ImGui::TextDisabled("(%d tiles on the way)", static_cast(m_ortho.pending())); + } + } if (m_document.niwelety.size() > 1) { diff --git a/application/planpanel.h b/application/planpanel.h index 4a579931..23a15e02 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -11,6 +11,7 @@ http://mozilla.org/MPL/2.0/. #include "application/uilayer.h" +#include "editor/orthophoto.h" #include "maj0sted/app/editor_document.hpp" #include "maj0sted/web/editor.hpp" @@ -76,4 +77,8 @@ class plan_panel : public ui_panel double m_mapviewx{0.0}; double m_mapviewy{0.0}; double m_mapscale{0.0}; + // topographic base map behind the picker, and the orthophoto under the plan itself + editor::wms_image m_topomap{maj0sted::web::WmsConfig::geoportal_topo()}; + editor::orthophoto_source m_ortho; + bool m_showortho{true}; }; diff --git a/editor/orthophoto.cpp b/editor/orthophoto.cpp new file mode 100644 index 00000000..e17af2e2 --- /dev/null +++ b/editor/orthophoto.cpp @@ -0,0 +1,440 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "editor/orthophoto.h" + +#include "stb/stb_image.h" +#include "utilities/Logs.h" + +#ifdef _WIN32 +#include +#include +#pragma comment(lib, "winhttp.lib") +#endif + +namespace editor +{ + +namespace +{ + +int const kWorkers = 4; +// the imagery service sits behind a filter that turns down browser user agents and, even for a +// plain one, fails a request now and then; a handful of immediate retries gets past both +int const kAttempts = 4; +char const *kUserAgent = "curl/8.5.0"; + +#ifdef _WIN32 + +// plain HTTPS GET. deliberately small: one request, one answer, no keep-alive - tiles are large +// enough that the connection setup does not dominate, and this way a stuck transfer cannot poison +// anything but itself +bool http_get(std::string const &Url, std::vector &Bytes, std::string &Contenttype) +{ + std::wstring const url(Url.begin(), Url.end()); + + URL_COMPONENTS parts{}; + parts.dwStructSize = sizeof(parts); + parts.dwHostNameLength = 1; + parts.dwUrlPathLength = 1; + parts.dwExtraInfoLength = 1; + if (false == WinHttpCrackUrl(url.c_str(), static_cast(url.size()), 0, &parts)) + { + return false; + } + + std::wstring const host(parts.lpszHostName, parts.dwHostNameLength); + std::wstring const resource = std::wstring(parts.lpszUrlPath, parts.dwUrlPathLength) + std::wstring(parts.lpszExtraInfo, parts.dwExtraInfoLength); + + auto const wideagent = std::wstring(kUserAgent, kUserAgent + std::strlen(kUserAgent)); + HINTERNET session = WinHttpOpen(wideagent.c_str(), WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (session == nullptr) + { + return false; + } + + auto const cleanup = [](HINTERNET handle) { + if (handle != nullptr) + { + WinHttpCloseHandle(handle); + } + }; + + HINTERNET connection = WinHttpConnect(session, host.c_str(), parts.nPort, 0); + if (connection == nullptr) + { + cleanup(session); + return false; + } + + DWORD const flags = (parts.nScheme == INTERNET_SCHEME_HTTPS ? WINHTTP_FLAG_SECURE : 0); + HINTERNET request = WinHttpOpenRequest(connection, L"GET", resource.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); + if (request == nullptr) + { + cleanup(connection); + cleanup(session); + return false; + } + + auto ok = false; + if (WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0) && WinHttpReceiveResponse(request, nullptr)) + { + DWORD status = 0; + DWORD statussize = sizeof(status); + WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &status, &statussize, WINHTTP_NO_HEADER_INDEX); + + if (status == 200) + { + wchar_t typebuffer[128]{}; + DWORD typesize = sizeof(typebuffer); + if (WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_TYPE, WINHTTP_HEADER_NAME_BY_INDEX, typebuffer, &typesize, WINHTTP_NO_HEADER_INDEX)) + { + std::wstring const wide(typebuffer); + Contenttype.assign(wide.begin(), wide.end()); + } + + Bytes.clear(); + DWORD available = 0; + while (WinHttpQueryDataAvailable(request, &available) && available > 0) + { + auto const offset = Bytes.size(); + Bytes.resize(offset + available); + DWORD read = 0; + if (false == WinHttpReadData(request, Bytes.data() + offset, available, &read)) + { + Bytes.clear(); + break; + } + Bytes.resize(offset + read); + } + ok = (false == Bytes.empty()); + } + } + + cleanup(request); + cleanup(connection); + cleanup(session); + + return ok; +} + +#else + +bool http_get(std::string const &, std::vector &, std::string &) +{ + return false; // no native transport on this platform yet +} + +#endif + +} // namespace + +orthophoto_source::orthophoto_source() +{ + // the library asks for a URL and expects the answer whenever it arrives; the transfer is queued + // here and handed back on the thread that drains it, so the service stays single threaded + m_service.set_fetcher([this](std::string const &Url, maj0sted::web::TileService::DoneFn Done) { + { + std::lock_guard lock(m_mutex); + m_queued.push_back({Url, std::move(Done), false, {}, {}}); + } + ++m_inflight; + m_wakeup.notify_one(); + }); + + m_workers.reserve(kWorkers); + for (auto i = 0; i < kWorkers; ++i) + { + m_workers.emplace_back([this]() { worker(); }); + } +} + +orthophoto_source::~orthophoto_source() +{ + m_quitting = true; + m_wakeup.notify_all(); + for (auto &worker : m_workers) + { + if (worker.joinable()) + { + worker.join(); + } + } + + for (auto const &texture : m_textures) + { + if (texture.second != 0) + { + auto const id = texture.second; + glDeleteTextures(1, &id); + } + } +} + +void orthophoto_source::worker() +{ + while (false == m_quitting) + { + transfer job; + { + std::unique_lock lock(m_mutex); + m_wakeup.wait(lock, [this]() { return m_quitting || (false == m_queued.empty()); }); + if (m_quitting) + { + return; + } + job = std::move(m_queued.front()); + m_queued.pop_front(); + } + + for (auto attempt = 0; attempt < kAttempts && false == m_quitting; ++attempt) + { + if (http_get(job.url, job.bytes, job.content_type)) + { + job.ok = true; + break; + } + } + + { + std::lock_guard lock(m_mutex); + m_finished.push_back(std::move(job)); + } + } +} + +std::vector orthophoto_source::collect(maj0sted::web::TileBBox const &View, int const Maxtiles) +{ + // hand finished transfers to the library here, on the caller's thread + std::deque arrived; + { + std::lock_guard lock(m_mutex); + arrived.swap(m_finished); + } + for (auto &job : arrived) + { + if (job.done) + { + job.done(job.ok, std::move(job.bytes), std::move(job.content_type)); + } + --m_inflight; + } + + std::vector tiles; + for (auto const &key : maj0sted::web::TileGrid::tiles_for_view(View, maj0sted::web::TileGrid::kLevel, Maxtiles)) + { + auto const status = m_service.request(key); + if (status == maj0sted::web::TileStatus::Failed) + { + ++m_failed; + continue; + } + if (status != maj0sted::web::TileStatus::Ready) + { + continue; + } + + auto found = m_textures.find(key); + if (found == m_textures.end()) + { + found = m_textures.emplace(key, upload(key)).first; + } + if (found->second != 0) + { + tiles.push_back({maj0sted::web::TileGrid::bbox(key), found->second}); + } + } + + return tiles; +} + +unsigned int orthophoto_source::upload(maj0sted::web::TileKey const &Key) +{ + auto const *image = m_service.peek(Key); + if (image == nullptr || image->bytes.empty()) + { + return 0; + } + + int width = 0; + int height = 0; + int channels = 0; + auto *pixels = stbi_load_from_memory(image->bytes.data(), static_cast(image->bytes.size()), &width, &height, &channels, 3); + if (pixels == nullptr) + { + ErrorLog("Orthophoto: could not decode tile imagery"); + return 0; + } + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, pixels); + + stbi_image_free(pixels); + + return texture; +} + +std::size_t orthophoto_source::pending() const +{ + return m_inflight.load(); +} + +std::size_t orthophoto_source::failed() const +{ + return m_failed; +} + +namespace +{ + +// decodes encoded image bytes straight onto a fresh texture; 0 when they are not an image +unsigned int upload_image(std::vector const &Bytes) +{ + int width = 0; + int height = 0; + int channels = 0; + auto *pixels = stbi_load_from_memory(Bytes.data(), static_cast(Bytes.size()), &width, &height, &channels, 3); + if (pixels == nullptr) + { + return 0; + } + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, pixels); + + stbi_image_free(pixels); + + return texture; +} + +bool same_box(maj0sted::web::TileBBox const &Left, maj0sted::web::TileBBox const &Right) +{ + auto const close = [](double const A, double const B) { return std::abs(A - B) < 1.0; }; + return close(Left.min_x, Right.min_x) && close(Left.min_y, Right.min_y) && close(Left.max_x, Right.max_x) && close(Left.max_y, Right.max_y); +} + +} // namespace + +wms_image::wms_image(maj0sted::web::WmsConfig Config) : m_config(std::move(Config)) +{ + m_worker = std::thread([this]() { worker(); }); +} + +wms_image::~wms_image() +{ + m_quitting = true; + m_wakeup.notify_all(); + if (m_worker.joinable()) + { + m_worker.join(); + } + if (m_texture != 0) + { + auto const id = m_texture; + glDeleteTextures(1, &id); + } +} + +void wms_image::worker() +{ + while (false == m_quitting) + { + std::string url; + { + std::unique_lock lock(m_mutex); + m_wakeup.wait(lock, [this]() { return m_quitting || m_haswork; }); + if (m_quitting) + { + return; + } + url = m_url; + m_haswork = false; + } + + std::vector bytes; + std::string contenttype; + for (auto attempt = 0; attempt < kAttempts && false == m_quitting; ++attempt) + { + if (http_get(url, bytes, contenttype)) + { + break; + } + bytes.clear(); + } + + { + std::lock_guard lock(m_mutex); + m_bytes = std::move(bytes); + m_hasresult = true; + } + } +} + +unsigned int wms_image::texture_for(maj0sted::web::TileBBox const &Box, int const Pixels) +{ + // a finished download becomes the texture on this thread, where the graphics API is safe to touch + { + std::lock_guard lock(m_mutex); + if (m_hasresult) + { + m_hasresult = false; + m_loading = false; + if (false == m_bytes.empty()) + { + auto const uploaded = upload_image(m_bytes); + if (uploaded != 0) + { + if (m_texture != 0) + { + auto const old = m_texture; + glDeleteTextures(1, &old); + } + m_texture = uploaded; + m_covered = m_inflightbox; + } + } + m_bytes.clear(); + } + } + + // only chase a genuinely different view, and only once the previous one has landed + if (false == same_box(Box, m_requested) && false == m_loading.load()) + { + m_requested = Box; + m_inflightbox = Box; + auto config = m_config; + config.tile_pixels = Pixels; + { + std::lock_guard lock(m_mutex); + m_url = maj0sted::web::wms_getmap_url(config, Box); + m_haswork = true; + } + m_loading = true; + m_wakeup.notify_one(); + } + + return m_texture; +} + +} // namespace editor diff --git a/editor/orthophoto.h b/editor/orthophoto.h new file mode 100644 index 00000000..51dd19d6 --- /dev/null +++ b/editor/orthophoto.h @@ -0,0 +1,124 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "maj0sted/web/tile_cache.hpp" + +namespace editor +{ + +// orthophoto backdrop for the plan view. the tiling scheme, the cache and the retry policy come +// from the track layout library, which already speaks EPSG:2180; this adds what a native build +// needs and the web one got from the browser - the download itself, the decode and the texture. +// +// downloads run on worker threads, but nothing else does: finished transfers are handed back on +// the thread that calls collect(), so both the library's cache and the graphics API are only ever +// touched from there. +class orthophoto_source +{ + + public: + struct ready_tile + { + maj0sted::web::TileBBox box; + unsigned int texture{0}; + }; + + orthophoto_source(); + ~orthophoto_source(); + orthophoto_source(orthophoto_source const &) = delete; + orthophoto_source &operator=(orthophoto_source const &) = delete; + + // asks for every tile covering the view and returns those that are already drawable. tiles still + // on their way simply aren't in the answer yet; call again next frame + std::vector collect(maj0sted::web::TileBBox const &View, int const Maxtiles = 48); + // how many tiles are still being fetched, for the panel to report + std::size_t pending() const; + // how many tiles gave up after exhausting their retries + std::size_t failed() const; + + private: + // types + struct transfer + { + std::string url; + maj0sted::web::TileService::DoneFn done; + bool ok{false}; + std::vector bytes; + std::string content_type; + }; + // methods + void worker(); + // decodes the cached bytes of a tile and uploads them; returns 0 when the image is unusable + unsigned int upload(maj0sted::web::TileKey const &Key); + // members + maj0sted::web::TileService m_service; + std::unordered_map m_textures; + + mutable std::mutex m_mutex; + std::condition_variable m_wakeup; + std::deque m_queued; + std::deque m_finished; + std::vector m_workers; + std::atomic m_quitting{false}; + std::atomic m_inflight{0}; + std::size_t m_failed{0}; +}; + +// a single WMS image covering an arbitrary box, for views too coarse for the tile grid - the whole +// country will not fit into 100 m cells. one request is in flight at a time: asking for a different +// box supersedes whatever was being fetched +class wms_image +{ + + public: + explicit wms_image(maj0sted::web::WmsConfig Config); + ~wms_image(); + wms_image(wms_image const &) = delete; + wms_image &operator=(wms_image const &) = delete; + + // asks for the given box at the given size and returns the texture currently available, which + // may still be the previous box's until the new one arrives; 0 when there is nothing to show yet + unsigned int texture_for(maj0sted::web::TileBBox const &Box, int const Pixels); + // the box the returned texture actually covers + maj0sted::web::TileBBox const &covered() const { return m_covered; } + bool loading() const { return m_loading.load(); } + + private: + void worker(); + + maj0sted::web::WmsConfig m_config; + maj0sted::web::TileBBox m_requested{}; + maj0sted::web::TileBBox m_inflightbox{}; + maj0sted::web::TileBBox m_covered{}; + unsigned int m_texture{0}; + + mutable std::mutex m_mutex; + std::condition_variable m_wakeup; + std::string m_url; + bool m_haswork{false}; + std::vector m_bytes; + bool m_hasresult{false}; + std::thread m_worker; + std::atomic m_quitting{false}; + std::atomic m_loading{false}; +}; + +} // namespace editor diff --git a/editor/polandmap.cpp b/editor/polandmap.cpp index 65b97fef..315611bd 100644 --- a/editor/polandmap.cpp +++ b/editor/polandmap.cpp @@ -26,21 +26,10 @@ struct geo_point double longitude; }; -// the border walked clockwise from the north-west corner: Baltic coast, the Kaliningrad and -// Lithuanian borders, the Bug, the Carpathians and Sudetes, then the Nysa and Odra back north. -// tens of kilometres of error in places - this is a locator sketch, nothing more -constexpr geo_point kOutline[]{ - {53.93, 14.27}, {54.05, 14.60}, {54.18, 15.58}, {54.42, 16.41}, {54.58, 16.86}, {54.76, 17.55}, - {54.79, 18.40}, {54.60, 18.65}, {54.52, 18.55}, {54.35, 18.95}, {54.37, 19.35}, {54.45, 19.64}, - {54.37, 20.50}, {54.35, 21.50}, {54.36, 22.00}, {54.36, 22.79}, {54.25, 23.20}, {54.15, 23.48}, - {53.95, 23.90}, {53.20, 23.93}, {52.70, 23.85}, {52.30, 23.60}, {52.07, 23.62}, {51.55, 23.55}, - {50.87, 24.15}, {50.72, 24.15}, {50.40, 23.70}, {50.28, 23.50}, {49.80, 22.92}, {49.20, 22.68}, - {49.09, 22.55}, {49.25, 22.00}, {49.42, 21.75}, {49.35, 21.00}, {49.40, 20.50}, {49.20, 20.10}, - {49.40, 19.80}, {49.40, 19.50}, {49.40, 19.20}, {49.51, 18.85}, {49.75, 18.63}, {49.95, 18.05}, - {50.10, 17.70}, {50.31, 17.38}, {50.45, 16.90}, {50.20, 16.60}, {50.40, 16.35}, {50.44, 16.25}, - {50.75, 15.80}, {50.75, 15.55}, {50.85, 15.00}, {50.87, 14.82}, {51.30, 14.95}, {51.60, 14.75}, - {52.00, 14.70}, {52.35, 14.55}, {52.59, 14.65}, {52.85, 14.40}, {53.10, 14.35}, {53.55, 14.30}, - {53.75, 14.28}}; +// the country's corners, wide enough to hold all of it: the Odra in the west, the Bug in the east, +// the Tatras in the south and the coast in the north +constexpr geo_point kSouthWest{49.00, 14.10}; +constexpr geo_point kNorthEast{54.90, 24.20}; struct named_geo_point { @@ -59,19 +48,17 @@ constexpr named_geo_point kPlaces[]{ } // namespace -std::vector const &poland_outline() +void poland_extent(glm::dvec2 &Min, glm::dvec2 &Max) { - static std::vector const outline = []() { - std::vector points; - points.reserve(std::size(kOutline)); - for (auto const &point : kOutline) - { - points.push_back(puwg1992(point.latitude, point.longitude)); - } - return points; - }(); + // the projection is not axis-aligned with the graticule, so the extreme corners come from + // projecting all four of them and taking the outermost values + auto const sw = puwg1992(kSouthWest.latitude, kSouthWest.longitude); + auto const se = puwg1992(kSouthWest.latitude, kNorthEast.longitude); + auto const nw = puwg1992(kNorthEast.latitude, kSouthWest.longitude); + auto const ne = puwg1992(kNorthEast.latitude, kNorthEast.longitude); - return outline; + Min = {std::min({sw.x, se.x, nw.x, ne.x}), std::min({sw.y, se.y, nw.y, ne.y})}; + Max = {std::max({sw.x, se.x, nw.x, ne.x}), std::max({sw.y, se.y, nw.y, ne.y})}; } std::vector const &poland_places() diff --git a/editor/polandmap.h b/editor/polandmap.h index f237e040..7ee65f63 100644 --- a/editor/polandmap.h +++ b/editor/polandmap.h @@ -24,11 +24,10 @@ struct map_place glm::dvec2 position; }; -// schematic outline of the country's border, in EPSG:2180 metres. coarse on purpose: it is here so -// a region can be pointed at, not so anything can be measured against it -std::vector const &poland_outline(); - // larger cities, to put the view somewhere recognisable before picking the exact spot std::vector const &poland_places(); +// the whole country's extent in EPSG:2180 metres, as the starting view of a location picker +void poland_extent(glm::dvec2 &Min, glm::dvec2 &Max); + } // namespace editor diff --git a/utilities/Globals.h b/utilities/Globals.h index d218d535..57964617 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -86,6 +86,9 @@ struct global_settings { // nie znaczą nic poza nią samą bool scenery_georeferenced{ false }; glm::dvec2 scenery_origin{ 0.0, 0.0 }; + // prośba o zrzucenie wczytanej scenerii i zaczęcie na pustym terenie; obsługiwana na początku + // klatki, bo zmiana trybu w środku rysowania panelu wyrwałaby grunt spod niego samego + bool editor_reset_scenery{ false }; std::string local_start_vehicle{ "EU07-424" }; int iConvertModels{ 0 }; // tworzenie plików binarnych int iConvertIndexRange{ 1000 }; // range of duplicate vertex scan From 32a4df6111ab26b31550099cbfb0935aec8eb4a9 Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 19:47:11 +0200 Subject: [PATCH 09/17] Fix the base map orientation, drop the city overlay, keep td.scn out of the editor The Geoportal images arrive with their first row at the north edge, which is the opposite of what the default texture coordinates assume - the vertical ones are now swapped, for the picker's base map and the orthophoto alike. With the real map carrying its own detail as the view zooms in, the drawn city markers had nothing to add and are gone, along with the coordinate table behind them. Only the country's extent remains, to open the picker on. -editor no longer inherits the scenery named in eu07.ini. That is whatever was last driven, which has nothing to do with editing; without an explicit -s the editor opens on empty ground. --- application/application.cpp | 9 ++++-- application/planpanel.cpp | 34 +++++---------------- editor/polandmap.cpp | 60 ++++++++----------------------------- editor/polandmap.h | 16 ++-------- 4 files changed, 27 insertions(+), 92 deletions(-) diff --git a/application/application.cpp b/application/application.cpp index 156bb04a..37a8bab6 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -1121,6 +1121,7 @@ int eu07_application::init_settings(int Argc, char *Argv[]) } // process command line arguments + auto scenerywasasked = false; for (int i = 1; i < Argc; ++i) { @@ -1131,6 +1132,7 @@ int eu07_application::init_settings(int Argc, char *Argv[]) if (i + 1 < Argc) { Global.SceneryFile = ToLower(Argv[++i]); + scenerywasasked = true; } } else if (token == "-v") @@ -1153,10 +1155,11 @@ int eu07_application::init_settings(int Argc, char *Argv[]) } } - if (Global.editor_startup && Global.SceneryFile.empty()) + if (Global.editor_startup && false == scenerywasasked) { - // the editor needs a loaded world, but not necessarily anything in it - an empty scenery gives - // just the region, the lighting and the clock, with no rolling stock and no timetables + // the editor opens on empty ground unless a scenery was actually asked for. whatever the ini + // happens to name is the last thing that was driven, which has nothing to do with editing; an + // empty scenery still gives the region, the lighting and the clock, just nothing standing on it Global.SceneryFile = "pusta.scn"; } diff --git a/application/planpanel.cpp b/application/planpanel.cpp index a92b4b70..03be0607 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -158,8 +158,6 @@ void plan_panel::render_location_dialog() ImGui::InvisibleButton("poland_map", mapsize); auto const hovered{ImGui::IsItemHovered()}; - auto const &places{editor::poland_places()}; - // fit the whole country the first time round; afterwards the wheel decides if (m_mapscale <= 0.0) { @@ -213,14 +211,10 @@ void plan_panel::render_location_dialog() // the picture on screen is the one that was fetched, which may lag the current view by a // request; drawing it against its own box keeps it registered while the new one arrives auto const &covered{m_topomap.covered()}; - drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y)); - } - - for (auto const &place : places) - { - auto const point{to_screen(place.position.x, place.position.y)}; - drawlist->AddCircleFilled(point, 3.0f, IM_COL32(200, 200, 200, 255)); - drawlist->AddText(ImVec2(point.x + 5.0f, point.y - 6.0f), IM_COL32(190, 190, 190, 255), place.name.c_str()); + // the image arrives with its first row at the north edge, which is the opposite of what the + // default texture coordinates assume, so the vertical ones are swapped + drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y), ImVec2(0.0f, 1.0f), + ImVec2(1.0f, 0.0f)); } auto const picked{to_screen(m_pickx, m_picky)}; @@ -238,22 +232,6 @@ void plan_panel::render_location_dialog() ImGui::SetNextItemWidth(180.0f); ImGui::InputDouble("northing", &m_picky, 100.0, 1000.0, "%.0f"); - ImGui::SetNextItemWidth(220.0f); - if (ImGui::BeginCombo("go to", "pick a city")) - { - for (auto const &place : places) - { - if (ImGui::Selectable(place.name.c_str())) - { - m_pickx = place.position.x; - m_picky = place.position.y; - m_mapviewx = m_pickx; - m_mapviewy = m_picky; - } - } - ImGui::EndCombo(); - } - ImGui::Spacing(); if (ImGui::Button("Use this place", ImVec2(160.0f, 0.0f))) { @@ -374,7 +352,9 @@ void plan_panel::draw_on_scene() { continue; } - drawlist->AddImageQuad(reinterpret_cast(static_cast(tile.texture)), corners[0], corners[1], corners[2], corners[3]); + // same north-first row order as the picker's base map, so the same swap applies + drawlist->AddImageQuad(reinterpret_cast(static_cast(tile.texture)), corners[0], corners[1], corners[2], corners[3], ImVec2(0.0f, 1.0f), ImVec2(1.0f, 1.0f), + ImVec2(1.0f, 0.0f), ImVec2(0.0f, 0.0f)); } } diff --git a/editor/polandmap.cpp b/editor/polandmap.cpp index 315611bd..ea3ff16c 100644 --- a/editor/polandmap.cpp +++ b/editor/polandmap.cpp @@ -18,62 +18,26 @@ namespace editor namespace { -// geographic coordinates, degrees, as (latitude, longitude); projected to EPSG:2180 on first use so -// the numbers stay readable and there is a single place where the frame is decided -struct geo_point -{ - double latitude; - double longitude; -}; - -// the country's corners, wide enough to hold all of it: the Odra in the west, the Bug in the east, -// the Tatras in the south and the coast in the north -constexpr geo_point kSouthWest{49.00, 14.10}; -constexpr geo_point kNorthEast{54.90, 24.20}; - -struct named_geo_point -{ - char const *name; - geo_point position; -}; - -constexpr named_geo_point kPlaces[]{ - {"Warszawa", {52.23, 21.01}}, {"Krakow", {50.06, 19.94}}, {"Lodz", {51.77, 19.46}}, - {"Wroclaw", {51.11, 17.03}}, {"Poznan", {52.41, 16.93}}, {"Gdansk", {54.35, 18.65}}, - {"Szczecin", {53.43, 14.55}}, {"Bydgoszcz", {53.12, 18.01}}, {"Lublin", {51.25, 22.57}}, - {"Bialystok", {53.13, 23.16}}, {"Katowice", {50.26, 19.02}}, {"Rzeszow", {50.04, 22.00}}, - {"Olsztyn", {53.78, 20.49}}, {"Kielce", {50.87, 20.63}}, {"Zielona Gora", {51.94, 15.51}}, - {"Opole", {50.67, 17.93}}, {"Torun", {53.02, 18.61}}, {"Gorzow Wlkp.", {52.73, 15.24}}, - {"Koszalin", {54.19, 16.18}}, {"Czestochowa", {50.81, 19.12}}}; +// the country's corners in degrees, wide enough to hold all of it: the Odra in the west, the Bug in +// the east, the Tatras in the south and the coast in the north +double const kSouthLatitude = 49.00; +double const kNorthLatitude = 54.90; +double const kWestLongitude = 14.10; +double const kEastLongitude = 24.20; } // namespace void poland_extent(glm::dvec2 &Min, glm::dvec2 &Max) { - // the projection is not axis-aligned with the graticule, so the extreme corners come from - // projecting all four of them and taking the outermost values - auto const sw = puwg1992(kSouthWest.latitude, kSouthWest.longitude); - auto const se = puwg1992(kSouthWest.latitude, kNorthEast.longitude); - auto const nw = puwg1992(kNorthEast.latitude, kSouthWest.longitude); - auto const ne = puwg1992(kNorthEast.latitude, kNorthEast.longitude); + // the projection is not axis-aligned with the graticule, so the extremes come from projecting + // all four corners and taking the outermost values + auto const sw = puwg1992(kSouthLatitude, kWestLongitude); + auto const se = puwg1992(kSouthLatitude, kEastLongitude); + auto const nw = puwg1992(kNorthLatitude, kWestLongitude); + auto const ne = puwg1992(kNorthLatitude, kEastLongitude); Min = {std::min({sw.x, se.x, nw.x, ne.x}), std::min({sw.y, se.y, nw.y, ne.y})}; Max = {std::max({sw.x, se.x, nw.x, ne.x}), std::max({sw.y, se.y, nw.y, ne.y})}; } -std::vector const &poland_places() -{ - static std::vector const places = []() { - std::vector result; - result.reserve(std::size(kPlaces)); - for (auto const &place : kPlaces) - { - result.push_back({place.name, puwg1992(place.position.latitude, place.position.longitude)}); - } - return result; - }(); - - return places; -} - } // namespace editor diff --git a/editor/polandmap.h b/editor/polandmap.h index 7ee65f63..ded458dc 100644 --- a/editor/polandmap.h +++ b/editor/polandmap.h @@ -9,25 +9,13 @@ http://mozilla.org/MPL/2.0/. #pragma once -#include -#include - #include namespace editor { -// a named point to start a scenery from, in EPSG:2180 metres -struct map_place -{ - std::string name; - glm::dvec2 position; -}; - -// larger cities, to put the view somewhere recognisable before picking the exact spot -std::vector const &poland_places(); - -// the whole country's extent in EPSG:2180 metres, as the starting view of a location picker +// the whole country's extent in EPSG:2180 metres, as the opening view of a location picker. from +// there the base map itself carries the detail, so nothing else has to be drawn on top of it void poland_extent(glm::dvec2 &Min, glm::dvec2 &Max); } // namespace editor From d4b1d73338fb461217e0f79098bdda43269d84f9 Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 31 Jul 2026 19:52:06 +0200 Subject: [PATCH 10/17] Keep tiles on disk, add basket curves, undo the image flip The flip was my mistake. Measured against the service instead of guessed: fetching a box and its northern half shows the half matching the top of the whole, so the images arrive with their first row at the north edge and the default texture coordinates were right all along. Both the base map and the orthophoto go back to them. Imagery of a given piece of ground does not change, so a fetched tile is now kept under editor/cache/orto and the next session starts from it. Until now the cache lived only in memory, which meant going back to a slow and flaky service for ground that had already been fetched once. Gaps can be fitted with a basket curve as well: the library's compound mode, with clothoids at both ends and a chain of arcs between them. Arcs can be added and removed; the closing one takes whatever deflection the earlier ones leave, so its length is not offered for editing. --- application/planpanel.cpp | 108 ++++++++++++++++++++++++++++++++++---- application/planpanel.h | 2 + editor/orthophoto.cpp | 55 +++++++++++++++++++ editor/orthophoto.h | 5 ++ 4 files changed, 159 insertions(+), 11 deletions(-) diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 03be0607..61afd573 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -210,11 +210,10 @@ void plan_panel::render_location_dialog() { // the picture on screen is the one that was fetched, which may lag the current view by a // request; drawing it against its own box keeps it registered while the new one arrives + // the image arrives with its first row at the north edge, so the default texture coordinates + // already line up: the first row belongs at the top, against the box's northern edge auto const &covered{m_topomap.covered()}; - // the image arrives with its first row at the north edge, which is the opposite of what the - // default texture coordinates assume, so the vertical ones are swapped - drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y), ImVec2(0.0f, 1.0f), - ImVec2(1.0f, 0.0f)); + drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y)); } auto const picked{to_screen(m_pickx, m_picky)}; @@ -352,9 +351,8 @@ void plan_panel::draw_on_scene() { continue; } - // same north-first row order as the picker's base map, so the same swap applies - drawlist->AddImageQuad(reinterpret_cast(static_cast(tile.texture)), corners[0], corners[1], corners[2], corners[3], ImVec2(0.0f, 1.0f), ImVec2(1.0f, 1.0f), - ImVec2(1.0f, 0.0f), ImVec2(0.0f, 0.0f)); + // first row is the northern edge, which is the corner the default coordinates start from + drawlist->AddImageQuad(reinterpret_cast(static_cast(tile.texture)), corners[0], corners[1], corners[2], corners[3]); } } @@ -514,7 +512,7 @@ void plan_panel::render_gaps() return; } - ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 130.0f), false); + ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 220.0f), false); for (std::size_t gap = 0; gap < niweleta->fits.size(); ++gap) { auto &fit{niweleta->fits[gap]}; @@ -522,16 +520,23 @@ void plan_panel::render_gaps() ImGui::Text("gap %d", static_cast(gap)); ImGui::SameLine(70.0f); - ImGui::SetNextItemWidth(150.0f); - if (ImGui::Combo("##mode", &fit.mode, "none\0arc\0arc + clothoids\0")) + ImGui::SetNextItemWidth(170.0f); + // the library numbers these; 3 is the unified compound, any number of arcs eased by clothoids + if (ImGui::Combo("##mode", &fit.mode, "none\0arc\0arc + clothoids\0compound\0")) { if (fit.mode != 0 && fit.radius <= 0.0) { fit.radius = 300.0; // something drawable to start from, rather than a rejected fit } + if (fit.mode == 3 && fit.arcs.empty()) + { + // a basket needs at least two arcs to be one; the last absorbs the leftover deflection + fit.arcs.push_back({600.0, 60.0, 0.0}); + fit.arcs.push_back({300.0, 0.0, 0.0}); + } solve(); } - if (fit.mode != 0) + if (fit.mode == 1 || fit.mode == 2) { ImGui::SameLine(); ImGui::SetNextItemWidth(120.0f); @@ -549,6 +554,10 @@ void plan_panel::render_gaps() solve(); } } + if (fit.mode == 3) + { + render_compound(fit); + } // a fit the library could not produce is simply absent from its answer; say so rather than // leaving the parameters looking as though they took effect if (fit.mode != 0 && false == fit_applied(gap)) @@ -562,6 +571,83 @@ void plan_panel::render_gaps() ImGui::EndChild(); } +void plan_panel::render_compound(maj0sted::web::GapFit &Fit) +{ + ImGui::Indent(); + + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("entry", &Fit.entry_t, 5.0, 20.0, "%.1f")) + { + solve(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("exit", &Fit.exit_t, 5.0, 20.0, "%.1f")) + { + solve(); + } + ImGui::SameLine(); + ImGui::TextDisabled("end clothoids"); + + for (std::size_t i = 0; i < Fit.arcs.size(); ++i) + { + auto &arc{Fit.arcs[i]}; + auto const last{i + 1 == Fit.arcs.size()}; + ImGui::PushID(static_cast(i)); + + ImGui::Text("%d.", static_cast(i + 1)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("R", &arc.radius, 10.0, 100.0, "%.1f")) + { + solve(); + } + // the closing arc takes up whatever deflection the earlier ones leave, so its length is not + // ours to set, and there is no arc after it to ease into + if (false == last) + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("len", &arc.length, 10.0, 50.0, "%.1f")) + { + solve(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("ease", &arc.transition_to_next, 5.0, 20.0, "%.1f")) + { + solve(); + } + } + else + { + ImGui::SameLine(); + ImGui::TextDisabled("closes the curve"); + } + + ImGui::PopID(); + } + + if (ImGui::SmallButton("Add arc")) + { + // inserted before the closing arc, so that one stays the one that closes the curve + auto const radius{Fit.arcs.empty() ? 300.0 : Fit.arcs.back().radius}; + Fit.arcs.insert(Fit.arcs.end() - (Fit.arcs.empty() ? 0 : 1), {radius, 60.0, 0.0}); + solve(); + } + if (Fit.arcs.size() > 2) + { + ImGui::SameLine(); + if (ImGui::SmallButton("Remove arc")) + { + Fit.arcs.erase(Fit.arcs.end() - 2); + solve(); + } + } + + ImGui::Unindent(); +} + void plan_panel::render_storage() { ImGui::Separator(); diff --git a/application/planpanel.h b/application/planpanel.h index 23a15e02..a5d98bfe 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -32,6 +32,8 @@ class plan_panel : public ui_panel // methods void render_toolbar(); void render_gaps(); + // parameters of a basket curve: the end clothoids and the chain of arcs between them + void render_compound(maj0sted::web::GapFit &Fit); void render_storage(); // asks what kind of scenery is being started, and where on the map it sits void render_newmap_dialog(); diff --git a/editor/orthophoto.cpp b/editor/orthophoto.cpp index e17af2e2..9bc1335f 100644 --- a/editor/orthophoto.cpp +++ b/editor/orthophoto.cpp @@ -230,6 +230,12 @@ std::vector orthophoto_source::collect(maj0sted:: std::vector tiles; for (auto const &key : maj0sted::web::TileGrid::tiles_for_view(View, maj0sted::web::TileGrid::kLevel, Maxtiles)) { + // a tile kept from an earlier session seeds the cache, so no request goes out for it at all + if (false == m_service.has(key)) + { + load_from_disk(key); + } + auto const status = m_service.request(key); if (status == maj0sted::web::TileStatus::Failed) { @@ -244,6 +250,8 @@ std::vector orthophoto_source::collect(maj0sted:: auto found = m_textures.find(key); if (found == m_textures.end()) { + // the first time a tile is drawn is also the moment its bytes are known to be good + save_to_disk(key); found = m_textures.emplace(key, upload(key)).first; } if (found->second != 0) @@ -288,6 +296,53 @@ unsigned int orthophoto_source::upload(maj0sted::web::TileKey const &Key) return texture; } +std::string orthophoto_source::tile_path(maj0sted::web::TileKey const &Key) +{ + return "editor/cache/orto/" + std::to_string(Key.zoom) + "_" + std::to_string(Key.x) + "_" + std::to_string(Key.y) + ".img"; +} + +bool orthophoto_source::load_from_disk(maj0sted::web::TileKey const &Key) +{ + std::ifstream file(tile_path(Key), std::ios::binary); + if (false == file.is_open()) + { + return false; + } + + std::vector bytes{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + if (bytes.empty()) + { + return false; + } + + m_service.store(Key, std::move(bytes)); + + return true; +} + +void orthophoto_source::save_to_disk(maj0sted::web::TileKey const &Key) +{ + auto const *image = m_service.peek(Key); + if (image == nullptr || image->bytes.empty()) + { + return; + } + + auto const path = tile_path(Key); + std::error_code error; + std::filesystem::create_directories(std::filesystem::path(path).parent_path(), error); + if (std::filesystem::exists(path, error)) + { + return; // came from the disk in the first place + } + + std::ofstream file(path, std::ios::binary); + if (file.is_open()) + { + file.write(reinterpret_cast(image->bytes.data()), static_cast(image->bytes.size())); + } +} + std::size_t orthophoto_source::pending() const { return m_inflight.load(); diff --git a/editor/orthophoto.h b/editor/orthophoto.h index 51dd19d6..d11dc887 100644 --- a/editor/orthophoto.h +++ b/editor/orthophoto.h @@ -68,6 +68,11 @@ class orthophoto_source void worker(); // decodes the cached bytes of a tile and uploads them; returns 0 when the image is unusable unsigned int upload(maj0sted::web::TileKey const &Key); + // imagery of a given piece of ground never changes, so a fetched tile is kept on disk and the + // next session starts with it instead of going back to a service that is slow and flaky + static std::string tile_path(maj0sted::web::TileKey const &Key); + bool load_from_disk(maj0sted::web::TileKey const &Key); + void save_to_disk(maj0sted::web::TileKey const &Key); // members maj0sted::web::TileService m_service; std::unordered_map m_textures; From d069743265d3e48869ddd6a1d8d1ba110573c4bc Mon Sep 17 00:00:00 2001 From: maj00r Date: Sat, 1 Aug 2026 07:44:58 +0200 Subject: [PATCH 11/17] Switches: enforced branch tangency, internal basket, Polish turnout templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A switch (rozjazd) is a relation on a through niweleta, not a niweleta - the through track is never split. It marks the theoretical point on the through centreline; two tangent lines cross there (the through heading and that heading turned by the crossing mark 1:n), and the internal diverging curve is fitted to round the corner between them, reusing the same GapFitter the gap fits go through. So the curve is free: a plain arc, or a łuk koszowy right inside the switch. The theoretical tangent lengths and the curve length follow from it and are read off, not set. The branch niweleta's leaving straight is now locked to the frog: it starts at the frog and runs along the frog heading every solve, only its length is the user's. That enforces tangency no matter what is drawn, and because the first straight is pinned tangent, any fit in the branch's first gap - including a basket - meets the switch tangentially by construction. solve_layout solves the niwelety and the switches together in two passes: solve, read each frog, pin the branches, solve again. Switches serialize as an ejuncs / ejunc section; older files without it still load. Typical Polish turnouts (rozjazdy zwyczajne) are offered as templates that set the crossing mark and radius together; the length each spans is computed from them (arc = R*atan(1/n)) and shown, so how long the switch is is known. --- CMakeLists.txt | 1 + application/planpanel.cpp | 2936 ++++++++++++++++- application/planpanel.h | 128 +- editor/turnouts.cpp | 30 + editor/turnouts.h | 33 + .../include/maj0sted/app/editor_document.hpp | 41 +- maj0sted/include/maj0sted/web/editor.hpp | 63 + maj0sted/src/app/editor_document.cpp | 190 ++ maj0sted/src/web/editor.cpp | 173 + 9 files changed, 3381 insertions(+), 214 deletions(-) create mode 100644 editor/turnouts.cpp create mode 100644 editor/turnouts.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 02bf5e21..9e7ab124 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -169,6 +169,7 @@ set(SOURCES "editor/editorSettings.cpp" "editor/puwg1992.cpp" "editor/polandmap.cpp" +"editor/turnouts.cpp" "editor/orthophoto.cpp" "editor/editorTerrain.cpp" "editor/editorTerrainStreamer.cpp" diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 61afd573..1c45aee2 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -12,9 +12,20 @@ http://mozilla.org/MPL/2.0/. #include "application/editormode.h" #include "editor/polandmap.h" +#include "editor/turnouts.h" #include "rendering/renderer.h" #include "utilities/Globals.h" +#include "maj0sted/web/ribbon.hpp" + +#include +#include +#include +#include +#include +#include +#include + namespace { @@ -52,9 +63,24 @@ bool world_to_screen(glm::dvec3 const &World, ImVec2 &Screen) return true; } +// ground under the cursor in the top-down plan view. depth picking is useless here: empty scenery +// has nothing to hit, so the last (or zero) offset would pin every click to the same spot and the +// solver would drop the resulting zero-length straight +glm::dvec3 ortho_cursor_world() +{ + auto const width = std::max(1, Global.window_size.x); + auto const height = std::max(1, Global.window_size.y); + auto const ndcx = (static_cast(Global.cursor_pos.x) / width) * 2.0 - 1.0; + auto const ndcy = 1.0 - (static_cast(Global.cursor_pos.y) / height) * 2.0; + auto const halfheight = static_cast(Global.editor_ortho_extent); + auto const halfwidth = halfheight * static_cast(width) / static_cast(height); + // yaw is pinned to north: view x = world x, view y = -world z + return {Global.pCamera.Pos.x + ndcx * halfwidth, 0.0, Global.pCamera.Pos.z - ndcy * halfheight}; +} + ImU32 element_colour(int const Kind, bool const Active) { - auto const alpha{Active ? 255 : 110}; + auto const alpha{Active ? 255 : 210}; switch (Kind) { case 1: // arc @@ -66,6 +92,396 @@ ImU32 element_colour(int const Kind, bool const Active) } } +// one centreline element recovered from a left/right rail pair +// dual rails from a centreline, same 1.5 m gauge as maj0sted::render::RailRenderer +std::vector rails_from_centreline(std::vector const &Centre, double const Gauge = 1.5) +{ + std::vector out; + auto const half{Gauge * 0.5}; + for (auto const &poly : Centre) + { + if (poly.points.size() < 2) + { + continue; + } + for (double const side : {half, -half}) + { + maj0sted::web::WebPolyline rail; + rail.kind = poly.kind; + rail.length = poly.length; + rail.radius_start = poly.radius_start; + rail.radius_end = poly.radius_end; + rail.points.resize(poly.points.size()); + auto const m{poly.points.size()}; + for (std::size_t i = 0; i < m; ++i) + { + double dx{1.0}; + double dy{0.0}; + if (i == 0) + { + dx = poly.points[1].x - poly.points[0].x; + dy = poly.points[1].y - poly.points[0].y; + } + else if (i + 1 == m) + { + dx = poly.points[i].x - poly.points[i - 1].x; + dy = poly.points[i].y - poly.points[i - 1].y; + } + else + { + dx = poly.points[i + 1].x - poly.points[i - 1].x; + dy = poly.points[i + 1].y - poly.points[i - 1].y; + } + auto const len{std::hypot(dx, dy)}; + auto const nx{len > 0.0 ? -dy / len : 0.0}; + auto const ny{len > 0.0 ? dx / len : 0.0}; + rail.points[i] = {poly.points[i].x + nx * side, poly.points[i].y + ny * side}; + } + out.push_back(std::move(rail)); + } + } + return out; +} + +void draw_label(ImDrawList *Drawlist, ImVec2 const &Anchor, char const *Text, ImU32 const Colour) +{ + auto const size{ImGui::CalcTextSize(Text)}; + ImVec2 const pad{4.0f, 2.0f}; + ImVec2 const origin{Anchor.x + 6.0f, Anchor.y - size.y * 0.5f}; + Drawlist->AddRectFilled({origin.x - pad.x, origin.y - pad.y}, {origin.x + size.x + pad.x, origin.y + size.y + pad.y}, IM_COL32(15, 20, 32, 210), 3.0f); + Drawlist->AddRect({origin.x - pad.x, origin.y - pad.y}, {origin.x + size.x + pad.x, origin.y + size.y + pad.y}, Colour, 3.0f); + Drawlist->AddText(origin, Colour, Text); +} + +// leader from the curve midpoint outward, then a boxed label (R= / KP L=) +void draw_curve_callout(ImDrawList *Drawlist, std::vector const &Screen, char const *Text, ImU32 const Colour) +{ + if (Screen.size() < 2) + { + return; + } + auto const mid{Screen[Screen.size() / 2]}; + auto const i{static_cast(Screen.size() / 2)}; + auto const a{Screen[std::max(0, i - 1)]}; + auto const c{Screen[std::min(static_cast(Screen.size()) - 1, i + 1)]}; + auto ox{-(c.y - a.y)}; + auto oy{c.x - a.x}; + auto const len{std::hypot(ox, oy)}; + if (len > 1e-3f) + { + ox /= len; + oy /= len; + } + else + { + ox = 0.0f; + oy = -1.0f; + } + constexpr float kLead = 22.0f; + ImVec2 const tip{mid.x + ox * kLead, mid.y + oy * kLead}; + Drawlist->AddLine(mid, tip, Colour, 1.2f); + draw_label(Drawlist, tip, Text, Colour); +} + +// short perpendicular tick across the track — marks an arc end / radius break +void draw_station_tick(ImDrawList *Drawlist, ImVec2 const &AlongA, ImVec2 const &AlongB, ImU32 const Colour, float const Half = 8.0f) +{ + auto tx{AlongB.x - AlongA.x}; + auto ty{AlongB.y - AlongA.y}; + auto const tl{std::hypot(tx, ty)}; + if (tl < 1e-3f) + { + return; + } + tx /= tl; + ty /= tl; + ImVec2 const mid{(AlongA.x + AlongB.x) * 0.5f, (AlongA.y + AlongB.y) * 0.5f}; + ImVec2 const px{-ty * Half, tx * Half}; + Drawlist->AddLine({mid.x - px.x, mid.y - px.y}, {mid.x + px.x, mid.y + px.y}, Colour, 2.0f); +} + +void draw_arc_end_tick(ImDrawList *Drawlist, std::vector const &Screen, bool const AtStart, ImU32 const Colour) +{ + if (Screen.size() < 2) + { + return; + } + if (AtStart) + { + draw_station_tick(Drawlist, Screen[0], Screen[1], Colour); + } + else + { + draw_station_tick(Drawlist, Screen[Screen.size() - 2], Screen[Screen.size() - 1], Colour); + } +} + +double point_segment_distance(double const Px, double const Py, double const Ax, double const Ay, double const Bx, double const By) +{ + auto const dx{Bx - Ax}; + auto const dy{By - Ay}; + auto const len2{dx * dx + dy * dy}; + if (len2 < 1e-18) + { + return std::hypot(Px - Ax, Py - Ay); + } + auto const t{std::clamp(((Px - Ax) * dx + (Py - Ay) * dy) / len2, 0.0, 1.0)}; + return std::hypot(Px - (Ax + t * dx), Py - (Ay + t * dy)); +} + +// distance from (X,Y) to the gap track: curve elements plus the two bounding straights +double distance_to_gap_track(maj0sted::web::NiweletaPolys const &Solved, int const Gap, double const X, double const Y) +{ + auto best{std::numeric_limits::infinity()}; + for (auto const &poly : Solved.polylines) + { + auto const on_curve{poly.gap == Gap}; + auto const on_straight{poly.straight_index == Gap || poly.straight_index == Gap + 1}; + if ((false == on_curve && false == on_straight) || poly.points.size() < 2) + { + continue; + } + for (std::size_t i = 1; i < poly.points.size(); ++i) + { + best = std::min(best, point_segment_distance(X, Y, poly.points[i - 1].x, poly.points[i - 1].y, poly.points[i].x, poly.points[i].y)); + } + } + return best; +} + +struct GapSample +{ + double x{0.0}; + double y{0.0}; + double along{0.0}; +}; + +// centreline of a fitted gap, sampled in element order +std::vector sample_gap_centreline(maj0sted::web::NiweletaPolys const &Solved, int const Gap) +{ + std::map by_el; + for (auto const &poly : Solved.polylines) + { + if (poly.gap != Gap || poly.element_index < 0 || poly.points.size() < 2) + { + continue; + } + if (by_el.find(poly.element_index) == by_el.end()) + { + by_el.emplace(poly.element_index, &poly); + } + } + std::vector out; + double along{0.0}; + for (auto const &[idx, poly] : by_el) + { + (void)idx; + for (std::size_t i = 0; i < poly->points.size(); ++i) + { + auto const &p{poly->points[i]}; + if (false == out.empty()) + { + auto const &prev{out.back()}; + if (std::hypot(p.x - prev.x, p.y - prev.y) < 1e-9) + { + continue; + } + along += std::hypot(p.x - prev.x, p.y - prev.y); + } + out.push_back({p.x, p.y, along}); + } + } + return out; +} + +bool project_on_gap(std::vector const &Samples, double const X, double const Y, double &OutStation, double &OutX, double &OutY) +{ + if (Samples.size() < 2) + { + return false; + } + auto best_d{std::numeric_limits::infinity()}; + double best_s{0.0}; + double best_x{Samples.front().x}; + double best_y{Samples.front().y}; + for (std::size_t i = 1; i < Samples.size(); ++i) + { + auto const &a{Samples[i - 1]}; + auto const &b{Samples[i]}; + auto const dx{b.x - a.x}; + auto const dy{b.y - a.y}; + auto const len2{dx * dx + dy * dy}; + auto t{0.0}; + if (len2 > 1e-18) + { + t = std::clamp(((X - a.x) * dx + (Y - a.y) * dy) / len2, 0.0, 1.0); + } + auto const px{a.x + t * dx}; + auto const py{a.y + t * dy}; + auto const d{std::hypot(X - px, Y - py)}; + if (d < best_d) + { + best_d = d; + best_s = a.along + t * (b.along - a.along); + best_x = px; + best_y = py; + } + } + OutStation = best_s; + OutX = best_x; + OutY = best_y; + return std::isfinite(best_d); +} + +bool point_at_station(std::vector const &Samples, double Station, double &OutX, double &OutY) +{ + if (Samples.empty()) + { + return false; + } + if (Station <= Samples.front().along) + { + OutX = Samples.front().x; + OutY = Samples.front().y; + return true; + } + if (Station >= Samples.back().along) + { + OutX = Samples.back().x; + OutY = Samples.back().y; + return true; + } + for (std::size_t i = 1; i < Samples.size(); ++i) + { + auto const &a{Samples[i - 1]}; + auto const &b{Samples[i]}; + if (Station > b.along) + { + continue; + } + auto const span{std::max(1e-12, b.along - a.along)}; + auto const t{(Station - a.along) / span}; + OutX = a.x + (b.x - a.x) * t; + OutY = a.y + (b.y - a.y) * t; + return true; + } + return false; +} + +// kind: 0 entry KP end, 1 free-arc end, 2 between-KP end, 3 exit KP start +struct CmpStationHandle +{ + int gap{0}; + int kind{0}; + int arc{0}; + double x{0.0}; + double y{0.0}; +}; + +std::vector compound_station_handles(maj0sted::web::GapFit const &Fit, maj0sted::web::NiweletaPolys const &Solved, int const Gap) +{ + std::vector handles; + if (Fit.mode != 3 || Fit.arcs.empty()) + { + return handles; + } + auto const samples{sample_gap_centreline(Solved, Gap)}; + if (samples.size() < 2) + { + return handles; + } + auto const total{samples.back().along}; + + auto place = [&](int const Kind, int const Arc, double const Station) { + double x{0.0}; + double y{0.0}; + if (point_at_station(samples, std::clamp(Station, 0.0, total), x, y)) + { + handles.push_back({Gap, Kind, Arc, x, y}); + } + }; + + auto s{0.0}; + place(0, 0, std::max(0.0, Fit.entry_t)); // entry KP end (at 0 when no entry KP yet) + s = std::max(0.0, Fit.entry_t); + for (std::size_t i = 0; i + 1 < Fit.arcs.size(); ++i) + { + s += std::max(0.0, Fit.arcs[i].length); + place(1, static_cast(i), s); // free circular-arc end + auto const kp{std::max(0.0, Fit.arcs[i].transition_to_next)}; + if (kp > 1e-6) + { + s += kp; + place(2, static_cast(i), s); // between-KP end + } + } + // exit KP start: measured from the end of the solved curve + place(3, 0, std::max(0.0, total - std::max(0.0, Fit.exit_t))); + return handles; +} + +std::vector sample_draft_centreline(std::vector const &Polys) +{ + std::vector out; + double along{0.0}; + for (auto const &poly : Polys) + { + for (auto const &p : poly.points) + { + if (false == out.empty()) + { + auto const &prev{out.back()}; + if (std::hypot(p.x - prev.x, p.y - prev.y) < 1e-9) + { + continue; + } + along += std::hypot(p.x - prev.x, p.y - prev.y); + } + out.push_back({p.x, p.y, along}); + } + } + return out; +} + +// draft stations: every arc has L (including the last), unlike GapFit +std::vector draft_station_handles(maj0sted::app::BasketDraft const &Draft, std::vector const &Polys) +{ + std::vector handles; + if (false == Draft.active || Draft.arcs.empty()) + { + return handles; + } + auto const samples{sample_draft_centreline(Polys)}; + if (samples.size() < 2) + { + return handles; + } + auto const total{samples.back().along}; + auto place = [&](int const Kind, int const Arc, double const Station) { + double x{0.0}; + double y{0.0}; + if (point_at_station(samples, std::clamp(Station, 0.0, total), x, y)) + { + handles.push_back({-1, Kind, Arc, x, y}); + } + }; + auto s{std::max(0.0, Draft.entry_t)}; + place(0, 0, s); + for (std::size_t i = 0; i < Draft.arcs.size(); ++i) + { + s += std::max(0.0, Draft.arcs[i].length); + place(1, static_cast(i), s); + auto const kp{std::max(0.0, Draft.arcs[i].transition_to_next)}; + if (i + 1 < Draft.arcs.size() && kp > 1e-6) + { + s += kp; + place(2, static_cast(i), s); + } + } + place(3, 0, std::max(0.0, total - std::max(0.0, Draft.exit_t))); + return handles; +} + } // namespace plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) @@ -73,6 +489,7 @@ plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Na size_min = {420, 240}; size_max = {900, 900}; m_document.niwelety.push_back({"niweleta 1", {}, {}}); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety.front().name.c_str()); solve(); } @@ -89,6 +506,8 @@ void plan_panel::render_contents() draw_on_scene(); render_toolbar(); + render_draft(); + render_switches(); render_gaps(); render_storage(); render_newmap_dialog(); @@ -210,10 +629,12 @@ void plan_panel::render_location_dialog() { // the picture on screen is the one that was fetched, which may lag the current view by a // request; drawing it against its own box keeps it registered while the new one arrives - // the image arrives with its first row at the north edge, so the default texture coordinates - // already line up: the first row belongs at the top, against the box's northern edge + // the image arrives with its first row at the north edge; the vertical texture coordinates are + // swapped to put that row against the box's northern edge. the orthophoto is handed the same + // swap - both are placed north-west corner first, so they cannot want different conventions auto const &covered{m_topomap.covered()}; - drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y)); + drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y), ImVec2(0.0f, 1.0f), + ImVec2(1.0f, 0.0f)); } auto const picked{to_screen(m_pickx, m_picky)}; @@ -250,12 +671,21 @@ void plan_panel::start_map(bool const Georeferenced, double const Originx, doubl { Global.scenery_georeferenced = Georeferenced; Global.scenery_origin = {Originx, Originy}; + m_document.origin_set = true; + m_document.georeferenced = Georeferenced; + m_document.origin_x = Originx; + m_document.origin_y = Originy; m_document.niwelety.clear(); m_document.niwelety.push_back({"niweleta 1", {}, {}}); m_niweleta = 0; m_pending = false; - m_draggedvertex = -1; + clear_selection(); + m_pick_rel = 0; + m_ask_rel_param = false; + m_sel_gap = -1; + m_pick_compound_point = false; + m_fit_guides.clear(); solve(); // the scenery's zero is where the work starts, whichever frame it stands for @@ -274,51 +704,277 @@ void plan_panel::handle_scene() // clicks that land on a panel belong to the panel, not to the ground under it if (ImGui::GetIO().WantCaptureMouse) { - m_draggedvertex = -1; + m_dragging = false; + m_dragging_cmp = false; + m_dragging_draft = false; return; } double cursorx{0.0}; double cursory{0.0}; - world_to_plan(Global.pCamera.Pos + GfxRenderer->Mouse_Position(), cursorx, cursory); + // the plan view must not lean on the depth buffer: on empty ground it has nothing to read + world_to_plan(Global.editor_ortho ? ortho_cursor_world() : Global.pCamera.Pos + GfxRenderer->Mouse_Position(), cursorx, cursory); auto const mouse{ImGui::GetIO().MousePos}; - // grabbing an existing point comes first, so corners can be nudged into place - if (ImGui::IsMouseClicked(0)) + if (ImGui::IsMouseDoubleClicked(0) && m_draft_place == 0 && m_anchor_pick == 0 && m_pick_rel == 0 && false == m_pick_compound_point) { - m_draggedvertex = -1; - for (std::size_t i = 0; i < vertex_count(); ++i) + int hit_niw{-1}; + int hit_str{-1}; + if (hit_straight(mouse, hit_niw, hit_str, true) && hit_niw >= 0 && static_cast(hit_niw) < m_document.niwelety.size()) { - double x{0.0}; - double y{0.0}; - vertex_position(i, x, y); - ImVec2 point; - if (false == world_to_screen(plan_to_world(x, y), point)) - { - continue; - } - if (std::abs(point.x - mouse.x) <= 8.0f && std::abs(point.y - mouse.y) <= 8.0f) - { - m_draggedvertex = static_cast(i); - break; - } - } - if (m_draggedvertex == -1 && m_drawing) - { - append_vertex(cursorx, cursory); + m_dragging = false; + m_dragging_cmp = false; + m_dragging_draft = false; + m_niweleta = static_cast(hit_niw); + m_sel_straight = hit_str; + m_sel_end = -1; + m_pending = false; + m_draft.active = false; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); + m_status = m_document.niwelety[m_niweleta].name; + return; } } - if (m_draggedvertex != -1) + if (ImGui::IsMouseClicked(0)) { - if (ImGui::IsMouseDown(0)) + m_dragging = false; + + if (m_draft_place == 1) { - move_vertex(static_cast(m_draggedvertex), cursorx, cursory); + int hit_niw{-1}; + int hit_str{-1}; + int hit_end{-1}; + if (hit_endpoint(mouse, hit_niw, hit_str, hit_end)) + { + m_niweleta = static_cast(hit_niw); + start_draft_from_end(static_cast(hit_str), hit_end); + } + else + { + m_status = "kliknij koniec prostej"; + } + return; + } + + if (m_anchor_pick != 0) + { + int hit_niw{-1}; + int hit_str{-1}; + if (hit_straight(mouse, hit_niw, hit_str, false)) + { + auto *niweleta{current_niweleta()}; + if (niweleta != nullptr && niweleta->straights.size() >= 2) + { + auto gap{static_cast(hit_str)}; + if (gap + 1 >= niweleta->straights.size()) + { + gap = niweleta->straights.size() - 2; + } + m_anchor_pick = 0; + anchor_draft_to_gap(gap); + return; + } + } + m_status = "kliknij prostą przy luce"; + return; + } + + if (m_pick_compound_point) + { + m_fit_guides.push_back({cursorx, cursory}); + m_status = std::to_string(m_fit_guides.size()) + " pts"; + return; + } + + // dropping a switch on a through niweleta + if (m_pick_switch != 0) + { + int hit_niw{-1}; + int hit_str{-1}; + if (hit_straight(mouse, hit_niw, hit_str, true) && hit_niw >= 0 && static_cast(hit_niw) < m_solved.size()) + { + add_switch_on(static_cast(hit_niw), cursorx, cursory); + m_pick_switch = 0; + } + else + { + m_status = "kliknij niweletę na wprost"; + } + return; + } + + // picking a reference straight for a parallel/skew constraint + if (m_pick_rel != 0) + { + int hit_niw{-1}; + int hit_str{-1}; + if (hit_straight(mouse, hit_niw, hit_str, true) && m_sel_straight >= 0 && m_niweleta < m_document.niwelety.size() && + static_cast(m_sel_straight) < m_document.niwelety[m_niweleta].straights.size()) + { + auto &dep{m_document.niwelety[m_niweleta].straights[static_cast(m_sel_straight)]}; + if (!(hit_niw == static_cast(m_niweleta) && hit_str == m_sel_straight)) + { + dep.rel_kind = m_pick_rel; + dep.rel_niw = hit_niw; + dep.rel_str = hit_str; + if (m_pick_rel == 1) + { + dep.rel_offset = m_ask_rel_value; + m_status = "parallel → n" + std::to_string(hit_niw + 1) + " s" + std::to_string(hit_str); + } + else + { + auto const dx{dep.x2 - dep.x1}; + auto const dy{dep.y2 - dep.y1}; + dep.rel_length = std::max(1.0, std::hypot(dx, dy)); + dep.rel_cot = std::abs(m_ask_rel_value); + dep.rel_side = m_ask_rel_value < 0.0 ? -1 : 1; + m_status = "skew 1:" + to_string(dep.rel_cot, 0) + " → n" + std::to_string(hit_niw + 1) + " s" + std::to_string(hit_str); + } + solve(); + } + } + m_pick_rel = 0; + return; + } + + int draft_kind{-1}; + int draft_arc{-1}; + if (m_draft.active && hit_draft_station(mouse, draft_kind, draft_arc)) + { + clear_selection(); + m_dragging_draft = true; + m_draft_kind = draft_kind; + m_draft_arc = draft_arc; + m_drawing = false; + m_status = draft_kind == 0 ? "KP wejście" + : draft_kind == 1 ? "L łuku" + : draft_kind == 2 ? "KP między" + : "KP wyjście"; + return; + } + + int cmp_gap{-1}; + int cmp_kind{-1}; + int cmp_arc{-1}; + if (hit_compound_station(mouse, cmp_gap, cmp_kind, cmp_arc)) + { + clear_selection(); + m_dragging_cmp = true; + m_cmp_gap = cmp_gap; + m_cmp_kind = cmp_kind; + m_cmp_arc = cmp_arc; + m_sel_gap = cmp_gap; + m_drawing = false; + m_status = cmp_kind == 0 ? "KP wejście" + : cmp_kind == 1 ? "L łuku" + : cmp_kind == 2 ? "KP między" + : "KP wyjście"; + return; + } + + int hit_niw{-1}; + int hit_str{-1}; + int hit_end{-1}; + if (hit_endpoint(mouse, hit_niw, hit_str, hit_end)) + { + m_dragging_cmp = false; + m_dragging_draft = false; + m_sel_straight = hit_str; + m_sel_end = hit_end; + m_dragging = true; + auto *niweleta{current_niweleta()}; + if (niweleta != nullptr && static_cast(hit_str) < niweleta->straights.size()) + { + auto const &raw{niweleta->straights[static_cast(hit_str)]}; + m_drag_fixed_x = hit_end == 0 ? raw.x2 : raw.x1; + m_drag_fixed_y = hit_end == 0 ? raw.y2 : raw.y1; + auto const gx{hit_end == 0 ? raw.x1 : raw.x2}; + auto const gy{hit_end == 0 ? raw.y1 : raw.y2}; + auto const dl{std::max(1e-9, std::hypot(gx - m_drag_fixed_x, gy - m_drag_fixed_y))}; + m_drag_ux = (gx - m_drag_fixed_x) / dl; + m_drag_uy = (gy - m_drag_fixed_y) / dl; + } + } + else if (hit_straight(mouse, hit_niw, hit_str, false)) + { + m_dragging_cmp = false; + m_dragging_draft = false; + m_sel_straight = hit_str; + m_sel_end = -1; + } + else if (m_drawing && false == m_draft.active && m_draft_place == 0 && m_anchor_pick == 0) + { + clear_selection(); + append_vertex(cursorx, cursory); } else { - m_draggedvertex = -1; + clear_selection(); + } + } + + if (m_dragging_draft) + { + if (ImGui::IsMouseDown(0)) + { + drag_draft_station(cursorx, cursory); + } + else + { + m_dragging_draft = false; + } + } + else if (m_dragging_cmp) + { + if (ImGui::IsMouseDown(0)) + { + drag_compound_station(cursorx, cursory); + } + else + { + m_dragging_cmp = false; + } + } + else if (m_dragging && m_sel_straight >= 0 && m_sel_end >= 0) + { + if (ImGui::IsMouseDown(0)) + { + auto *niweleta{current_niweleta()}; + if (niweleta != nullptr && static_cast(m_sel_straight) < niweleta->straights.size()) + { + auto &st{niweleta->straights[static_cast(m_sel_straight)]}; + double x{cursorx}; + double y{cursory}; + // Shift: slide along the straight's axis — length changes, angle stays + if (ImGui::GetIO().KeyShift) + { + auto const t{std::max(1.0, (x - m_drag_fixed_x) * m_drag_ux + (y - m_drag_fixed_y) * m_drag_uy)}; + x = m_drag_fixed_x + m_drag_ux * t; + y = m_drag_fixed_y + m_drag_uy * t; + } + if (m_sel_end == 0) + { + st.x1 = x; + st.y1 = y; + } + else + { + st.x2 = x; + st.y2 = y; + } + if (st.rel_kind == 2) + { + st.rel_length = std::max(1.0, std::hypot(st.x2 - st.x1, st.y2 - st.y1)); + } + solve(); + } + } + else + { + m_dragging = false; } } } @@ -333,26 +989,46 @@ void plan_panel::draw_on_scene() // so a fictional one gets no imagery if (m_showortho && Global.scenery_georeferenced) { - auto const centre{Global.pCamera.Pos}; double planx{0.0}; double plany{0.0}; - world_to_plan(centre, planx, plany); - auto const reach{static_cast(Global.editor_ortho_extent) * 1.4}; + world_to_plan(Global.pCamera.Pos, planx, plany); - for (auto const &tile : m_ortho.collect({planx - reach, plany - reach, planx + reach, plany + reach})) - { + // yaw is pinned north-up, so the screen is an axis-aligned box in plan space + auto const display{ImGui::GetIO().DisplaySize}; + auto const halfheight{static_cast(Global.editor_ortho_extent)}; + auto const halfwidth{halfheight * std::max(1.0f, display.x) / std::max(1.0f, display.y)}; + maj0sted::web::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; + + auto const draw_box = [&](maj0sted::web::TileBBox const &Box, unsigned int const Texture) { ImVec2 corners[4]; - // the tile is a ground rectangle; on screen it is whatever the camera makes of it - auto const ok = world_to_screen(plan_to_world(tile.box.min_x, tile.box.max_y), corners[0]) && - world_to_screen(plan_to_world(tile.box.max_x, tile.box.max_y), corners[1]) && - world_to_screen(plan_to_world(tile.box.max_x, tile.box.min_y), corners[2]) && - world_to_screen(plan_to_world(tile.box.min_x, tile.box.min_y), corners[3]); + auto const ok = world_to_screen(plan_to_world(Box.min_x, Box.max_y), corners[0]) && world_to_screen(plan_to_world(Box.max_x, Box.max_y), corners[1]) && + world_to_screen(plan_to_world(Box.max_x, Box.min_y), corners[2]) && world_to_screen(plan_to_world(Box.min_x, Box.min_y), corners[3]); if (false == ok) { - continue; + return; + } + // north-west corner first; the texture's first row is its northern edge + drawlist->AddImageQuad(reinterpret_cast(static_cast(Texture)), corners[0], corners[1], corners[2], corners[3], ImVec2(0.0f, 1.0f), ImVec2(1.0f, 1.0f), + ImVec2(1.0f, 0.0f), ImVec2(0.0f, 0.0f)); + }; + + // once the view is wider than a kilometre the 100 m orto cells are the wrong scale: + // switch to a 1 km topo grid that can cover the whole screen + constexpr double kTopoPreviewMetres = 1000.0; + auto const span{std::max(halfwidth, halfheight) * 2.0}; + if (span > kTopoPreviewMetres) + { + for (auto const &tile : m_topo.collect(view, 80)) + { + draw_box(tile.box, tile.texture); + } + } + else + { + for (auto const &tile : m_ortho.collect(view)) + { + draw_box(tile.box, tile.texture); } - // first row is the northern edge, which is the corner the default coordinates start from - drawlist->AddImageQuad(reinterpret_cast(static_cast(tile.texture)), corners[0], corners[1], corners[2], corners[3]); } } @@ -381,28 +1057,176 @@ void plan_panel::draw_on_scene() { continue; } - drawlist->AddPolyline(points.data(), static_cast(points.size()), element_colour(polyline.kind, active), false, active ? 2.5f : 1.5f); + drawlist->AddPolyline(points.data(), static_cast(points.size()), element_colour(polyline.kind, active), false, 2.5f); } } - // editable points of the niweleta being drawn - for (std::size_t i = 0; i < vertex_count(); ++i) + // switches: the diverging curve in its own colour, with a marker at the switch point and the frog + for (auto const &junction : m_junctions) { - double x{0.0}; - double y{0.0}; - vertex_position(i, x, y); - ImVec2 screen; - if (false == world_to_screen(plan_to_world(x, y), screen)) + if (false == junction.valid) { continue; } - drawlist->AddCircleFilled(screen, 5.0f, static_cast(i) == m_draggedvertex ? IM_COL32(255, 240, 120, 255) : IM_COL32(240, 240, 240, 255)); + for (auto const &polyline : junction.polylines) + { + if (polyline.points.size() < 2) + { + continue; + } + points.clear(); + for (auto const &point : polyline.points) + { + ImVec2 screen; + if (world_to_screen(plan_to_world(point.x, point.y), screen)) + { + points.push_back(screen); + } + } + if (points.size() >= 2) + { + drawlist->AddPolyline(points.data(), static_cast(points.size()), IM_COL32(255, 150, 40, 255), false, 2.5f); + } + } + ImVec2 marker; + if (world_to_screen(plan_to_world(junction.px, junction.py), marker)) + { + drawlist->AddCircleFilled(marker, 4.0f, IM_COL32(255, 210, 90, 255)); + } + if (world_to_screen(plan_to_world(junction.fx, junction.fy), marker)) + { + drawlist->AddCircleFilled(marker, 4.0f, IM_COL32(255, 150, 40, 255)); + } + } + + // arc / KP annotations: R= and KP labels, plus station ticks at each circular-arc end + // (and a stronger tick where consecutive arcs of a compound meet with different R) + if (m_niweleta < m_solved.size()) + { + auto const &solved{m_solved[m_niweleta]}; + std::set gaps; + for (auto const &poly : solved.polylines) + { + if (poly.gap >= 0) + { + gaps.insert(poly.gap); + } + } + for (auto const gap : gaps) + { + std::map by_el; + for (auto const &poly : solved.polylines) + { + if (poly.gap != gap || poly.points.size() < 2 || poly.element_index < 0) + { + continue; + } + if (by_el.find(poly.element_index) == by_el.end()) + { + by_el.emplace(poly.element_index, &poly); + } + } + + std::vector arcs; + for (auto const &[idx, poly] : by_el) + { + (void)idx; + std::vector screen; + screen.reserve(poly->points.size()); + for (auto const &point : poly->points) + { + ImVec2 s; + if (world_to_screen(plan_to_world(point.x, point.y), s)) + { + screen.push_back(s); + } + } + if (poly->kind == 1) + { + arcs.push_back(poly); + auto const r{poly->radius_start > 0.0 ? poly->radius_start : poly->radius_end}; + if (r > 0.0) + { + char buf[48]; + std::snprintf(buf, sizeof(buf), "R = %.1f m", r); + draw_curve_callout(drawlist, screen, buf, IM_COL32(255, 209, 102, 255)); + } + // ends of this circular arc + draw_arc_end_tick(drawlist, screen, true, IM_COL32(255, 100, 100, 230)); + draw_arc_end_tick(drawlist, screen, false, IM_COL32(255, 100, 100, 230)); + } + else if (poly->kind == 2 && poly->length > 0.0) + { + char buf[48]; + std::snprintf(buf, sizeof(buf), "KP L = %.1f m", poly->length); + draw_curve_callout(drawlist, screen, buf, IM_COL32(140, 245, 150, 255)); + } + } + + // compound: thicker tick where two consecutive arcs meet (radius break) + for (std::size_t i = 0; i + 1 < arcs.size(); ++i) + { + auto const r1{arcs[i]->radius_start > 0.0 ? arcs[i]->radius_start : arcs[i]->radius_end}; + auto const r2{arcs[i + 1]->radius_start > 0.0 ? arcs[i + 1]->radius_start : arcs[i + 1]->radius_end}; + if (std::abs(r1 - r2) < 1e-6) + { + continue; + } + auto const &a{arcs[i]->points.back()}; + auto const &b{arcs[i + 1]->points.front()}; + ImVec2 sa; + ImVec2 sb; + if (world_to_screen(plan_to_world(a.x, a.y), sa) && world_to_screen(plan_to_world(b.x, b.y), sb)) + { + draw_station_tick(drawlist, sa, sb, IM_COL32(255, 80, 80, 255), 10.0f); + } + } + } + } + + // independent endpoints of the active niweleta (prefer solved/trimmed positions) + if (auto const *niweleta{current_niweleta()}; niweleta != nullptr) + { + for (std::size_t i = 0; i < niweleta->straights.size(); ++i) + { + auto const *st{handle_straight(m_niweleta, i)}; + if (st == nullptr || st->hidden) + { + continue; + } + auto const selected{static_cast(i) == m_sel_straight}; + for (int end = 0; end < 2; ++end) + { + auto const x{end == 0 ? st->x1 : st->x2}; + auto const y{end == 0 ? st->y1 : st->y2}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(x, y), screen)) + { + continue; + } + auto const hot{selected && end == m_sel_end}; + drawlist->AddCircleFilled(screen, hot ? 6.0f : 5.0f, hot ? IM_COL32(255, 240, 120, 255) : selected ? IM_COL32(255, 220, 140, 220) : IM_COL32(240, 240, 240, 255)); + } + if (selected) + { + ImVec2 a; + ImVec2 b; + if (world_to_screen(plan_to_world(st->x1, st->y1), a) && world_to_screen(plan_to_world(st->x2, st->y2), b)) + { + drawlist->AddLine(a, b, IM_COL32(255, 240, 120, 200), 3.0f); + } + } + } } // the corner still waiting for the one that closes its straight, and the rubber band to the cursor auto const mouse{ImGui::GetIO().MousePos}; auto const overscenery{false == ImGui::GetIO().WantCaptureMouse}; - if (m_pending) + if (m_draft_place == 1 && overscenery) + { + drawlist->AddCircleFilled(mouse, 5.0f, IM_COL32(255, 180, 90, 255)); + } + else if (m_pending) { ImVec2 screen; if (world_to_screen(plan_to_world(m_pendingx, m_pendingy), screen)) @@ -414,17 +1238,252 @@ void plan_panel::draw_on_scene() } } } - else if (overscenery && m_drawing && m_draggedvertex == -1 && vertex_count() > 0) + else if (overscenery && m_drawing && false == m_dragging && m_draft_place == 0 && m_anchor_pick == 0) { - double x{0.0}; - double y{0.0}; - vertex_position(vertex_count() - 1, x, y); - ImVec2 screen; - if (world_to_screen(plan_to_world(x, y), screen)) + if (auto const *niweleta{current_niweleta()}; niweleta != nullptr && false == niweleta->straights.empty()) { - drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); + auto const &last{niweleta->straights.back()}; + ImVec2 screen; + if (world_to_screen(plan_to_world(last.x2, last.y2), screen)) + { + drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); + } } } + + // basket draft: dual rails + same station handles as compound + if (m_draft.active) + { + std::vector pts; + for (auto const &polyline : rails_from_centreline(m_draft_polys)) + { + if (polyline.points.size() < 2) + { + continue; + } + pts.clear(); + pts.reserve(polyline.points.size()); + for (auto const &point : polyline.points) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) + { + continue; + } + pts.push_back(screen); + } + if (pts.size() < 2) + { + continue; + } + drawlist->AddPolyline(pts.data(), static_cast(pts.size()), element_colour(polyline.kind, true), false, 2.5f); + } + for (auto const &h : draft_station_handles(m_draft, m_draft_polys)) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) + { + continue; + } + auto const hot{m_dragging_draft && h.kind == m_draft_kind && h.arc == m_draft_arc}; + auto const kp{h.kind == 0 || h.kind == 2 || h.kind == 3}; + auto const fill{kp ? (hot ? IM_COL32(120, 255, 160, 255) : IM_COL32(90, 230, 140, 230)) + : (hot ? IM_COL32(255, 200, 90, 255) : IM_COL32(255, 160, 70, 230))}; + drawlist->AddCircleFilled(screen, hot ? 6.5f : 5.0f, fill); + drawlist->AddCircle(screen, hot ? 6.5f : 5.0f, IM_COL32(245, 245, 245, 255)); + } + } + + // draggable compound stations (KP / arc ends) on the active niweleta + if (auto const *niweleta{current_niweleta()}; niweleta != nullptr && m_niweleta < m_solved.size()) + { + for (auto const &fit : niweleta->fits) + { + if (fit.mode != 3 || fit.arcs.empty()) + { + continue; + } + for (auto const &h : compound_station_handles(fit, m_solved[m_niweleta], fit.gap)) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) + { + continue; + } + auto const hot{m_dragging_cmp && h.gap == m_cmp_gap && h.kind == m_cmp_kind && h.arc == m_cmp_arc}; + auto const kp{h.kind == 0 || h.kind == 2 || h.kind == 3}; + auto const fill{kp ? (hot ? IM_COL32(120, 255, 160, 255) : IM_COL32(90, 230, 140, 230)) + : (hot ? IM_COL32(255, 200, 90, 255) : IM_COL32(255, 160, 70, 230))}; + drawlist->AddCircleFilled(screen, hot ? 6.5f : 5.0f, fill); + drawlist->AddCircle(screen, hot ? 6.5f : 5.0f, IM_COL32(245, 245, 245, 255)); + } + } + } + + // guide points for multi-point compound fit + for (std::size_t i = 0; i < m_fit_guides.size(); ++i) + { + auto const &g{m_fit_guides[i]}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(g.x, g.y), screen)) + { + continue; + } + drawlist->AddCircleFilled(screen, 4.5f, IM_COL32(80, 220, 255, 240)); + drawlist->AddCircle(screen, 4.5f, IM_COL32(230, 245, 255, 255)); + char buf[16]; + std::snprintf(buf, sizeof(buf), "%zu", i + 1); + drawlist->AddText({screen.x + 6.0f, screen.y - 8.0f}, IM_COL32(160, 230, 255, 255), buf); + } + if (m_pick_compound_point && overscenery && false == m_fit_guides.empty()) + { + ImVec2 prev; + if (world_to_screen(plan_to_world(m_fit_guides.back().x, m_fit_guides.back().y), prev)) + { + drawlist->AddLine(prev, mouse, IM_COL32(80, 220, 255, 140), 1.2f); + } + } +} + +void plan_panel::render_draft() +{ + ImGui::Separator(); + if (m_draft_place == 1) + { + ImGui::Text("Kosz: kliknij koniec prostej"); + ImGui::SameLine(); + if (ImGui::SmallButton("Anuluj##draftplace")) + { + m_draft_place = 0; + m_status.clear(); + } + return; + } + + if (false == m_draft.active) + { + if (ImGui::Button("Kosz")) + { + begin_draft(); + } + return; + } + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Kosz"); + ImGui::SameLine(); + bool const left{m_draft.dir >= 0}; + if (ImGui::RadioButton("Left##ddir", left)) + { + m_draft.dir = 1; + rebuild_draft_preview(); + } + ImGui::SameLine(); + if (ImGui::RadioButton("Right##ddir", false == left)) + { + m_draft.dir = -1; + rebuild_draft_preview(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Anuluj##draft")) + { + discard_draft(); + m_status.clear(); + } + + ImGui::Indent(); + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("wejscie##dent", &m_draft.entry_t, 5.0, 20.0, "%.1f")) + { + rebuild_draft_preview(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("wyjscie##dex", &m_draft.exit_t, 5.0, 20.0, "%.1f")) + { + rebuild_draft_preview(); + } + + ImGui::Spacing(); + double station{m_draft.entry_t}; + for (std::size_t i = 0; i < m_draft.arcs.size(); ++i) + { + auto &arc{m_draft.arcs[i]}; + ImGui::PushID(static_cast(i) + 9000); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Luk %d", static_cast(i + 1)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("R##dar", &arc.radius, 10.0, 100.0, "%.1f")) + { + rebuild_draft_preview(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("L##dal", &arc.length, 5.0, 20.0, "%.1f")) + { + rebuild_draft_preview(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("KP##dat", &arc.transition_to_next, 5.0, 20.0, "%.1f")) + { + rebuild_draft_preview(); + } + station += std::max(0.0, arc.length) + std::max(0.0, arc.transition_to_next); + ImGui::TextDisabled("~%.0f m", station); + ImGui::PopID(); + } + + if (ImGui::SmallButton("Add arc##draft")) + { + auto const radius{m_draft.arcs.empty() ? 300.0 : std::max(30.0, m_draft.arcs.back().radius * 0.75)}; + if (false == m_draft.arcs.empty() && m_draft.arcs.back().transition_to_next < 1e-6) + { + m_draft.arcs.back().transition_to_next = 40.0; + } + m_draft.arcs.push_back({radius, 60.0, 0.0}); + m_draft_r = radius; + m_draft.preview_next_r = radius; + rebuild_draft_preview(); + } + if (m_draft.arcs.size() > 1) + { + ImGui::SameLine(); + if (ImGui::SmallButton("Remove arc##draft")) + { + m_draft.arcs.pop_back(); + if (false == m_draft.arcs.empty()) + { + m_draft.arcs.back().transition_to_next = 0.0; + } + rebuild_draft_preview(); + } + } + + ImGui::Spacing(); + if (m_draft.reedit_gap >= 0) + { + if (ImGui::Button("Zatwierdź##dfin") && false == m_draft.arcs.empty()) + { + finish_draft(); + } + } + else + { + ImGui::SetNextItemWidth(80.0f); + ImGui::InputDouble("L prostej##dfin", &m_draft_finish_straight, 10.0, 50.0, "%.0f"); + if (m_draft_finish_straight < 20.0) + { + m_draft_finish_straight = 20.0; + } + ImGui::SameLine(); + if (ImGui::Button("Zatwierdź##dfin") && false == m_draft.arcs.empty()) + { + finish_draft(); + } + } + ImGui::Unindent(); } void plan_panel::render_toolbar() @@ -432,7 +1491,7 @@ void plan_panel::render_toolbar() auto *niweleta{current_niweleta()}; ImGui::Text("top-down view, %.0f m across", Global.editor_ortho_extent * 2.0f); - ImGui::TextDisabled("click the scenery to lay points, drag a point to move it, wheel zooms"); + ImGui::TextDisabled("Shift = długość, double-click = inna niweleta"); if (Global.scenery_georeferenced) { ImGui::Text("zero at %.0f, %.0f (EPSG:2180)", Global.scenery_origin.x, Global.scenery_origin.y); @@ -453,6 +1512,8 @@ void plan_panel::render_toolbar() m_document.niwelety.push_back({"niweleta " + std::to_string(m_document.niwelety.size() + 1), {}, {}}); m_niweleta = m_document.niwelety.size() - 1; m_pending = false; + clear_selection(); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); solve(); } ImGui::SameLine(); @@ -471,10 +1532,14 @@ void plan_panel::render_toolbar() { ImGui::SameLine(); ImGui::Checkbox("Orthophoto", &m_showortho); - if (m_showortho && m_ortho.pending() > 0) + if (m_showortho) { - ImGui::SameLine(); - ImGui::TextDisabled("(%d tiles on the way)", static_cast(m_ortho.pending())); + auto const pending{static_cast(m_ortho.pending() + m_topo.pending())}; + if (pending > 0) + { + ImGui::SameLine(); + ImGui::TextDisabled("(%d tiles on the way)", pending); + } } } @@ -489,13 +1554,148 @@ void plan_panel::render_toolbar() { m_niweleta = i; m_pending = false; + clear_selection(); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); } } ImGui::EndCombo(); } } - ImGui::Text("straights: %d, points: %d", niweleta != nullptr ? static_cast(niweleta->straights.size()) : 0, static_cast(vertex_count())); + if (niweleta != nullptr) + { + ImGui::SetNextItemWidth(220.0f); + if (ImGui::InputText("Name", m_namebuf, sizeof(m_namebuf))) + { + niweleta->name = m_namebuf; + } + else if (false == ImGui::IsItemActive() && niweleta->name != m_namebuf) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", niweleta->name.c_str()); + } + ImGui::SameLine(); + if (m_document.niwelety.size() > 1 && ImGui::Button("Delete niweleta")) + { + delete_current_niweleta(); + niweleta = current_niweleta(); + } + } + + { + auto const sel_label{m_sel_straight >= 0 ? " | selected #" + std::to_string(m_sel_straight) : std::string{}}; + ImGui::Text("straights: %d%s", niweleta != nullptr ? static_cast(niweleta->straights.size()) : 0, sel_label.c_str()); + } + + auto const has_sel{niweleta != nullptr && m_sel_straight >= 0 && static_cast(m_sel_straight) < niweleta->straights.size()}; + if (has_sel && ImGui::Button("Delete straight")) + { + delete_selected_straight(); + niweleta = current_niweleta(); + } + if (has_sel) + { + auto &st{niweleta->straights[static_cast(m_sel_straight)]}; + ImGui::SameLine(); + if (ImGui::Button(st.rel_kind == 1 ? "Re-pick parallel..." : "Parallel...")) + { + m_ask_rel_value = st.rel_kind == 1 ? st.rel_offset : 4.5; + m_pick_rel = 1; + m_status = "referencja: parallel"; + } + ImGui::SameLine(); + if (ImGui::Button(st.rel_kind == 2 ? "Re-pick skew..." : "Skew 1:n...")) + { + m_ask_rel_value = st.rel_kind == 2 ? st.rel_cot * (st.rel_side < 0 ? -1.0 : 1.0) : 9.0; + m_pick_rel = 2; + m_status = "referencja: skew"; + } + if (st.rel_kind == 1) + { + ImGui::Text("parallel → n%d s%d", st.rel_niw + 1, st.rel_str); + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("offset m##parlive", &st.rel_offset, 0.1, 1.0, "%.2f")) + { + solve(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Clear##parlive")) + { + st.rel_kind = 0; + m_status = "cleared parallel"; + solve(); + } + } + else if (st.rel_kind == 2) + { + auto skew_n{st.rel_cot * (st.rel_side < 0 ? -1.0 : 1.0)}; + ImGui::Text("skew → n%d s%d", st.rel_niw + 1, st.rel_str); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("1:n##skewlive", &skew_n, 1.0, 1.0, "%.2f")) + { + if (std::abs(skew_n) < 1e-9) + { + st.rel_kind = 0; + m_status = "cleared skew"; + } + else + { + st.rel_cot = std::abs(skew_n); + st.rel_side = skew_n < 0.0 ? -1 : 1; + } + solve(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("L##skewlive", &st.rel_length, 5.0, 20.0, "%.1f")) + { + if (st.rel_length < 1.0) + { + st.rel_length = 1.0; + } + solve(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Clear##skewlive")) + { + st.rel_kind = 0; + m_status = "cleared skew"; + solve(); + } + } + } + + if (m_pick_rel != 0) + { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.4f, 1.0f), "%s", m_pick_rel == 1 ? "referencja: parallel" : "referencja: skew"); + ImGui::SameLine(); + if (ImGui::SmallButton("Cancel pick")) + { + m_pick_rel = 0; + } + } + if (m_pick_compound_point) + { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.4f, 1.0f), "punkty: %zu", m_fit_guides.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Fit##cmpick") && false == m_fit_guides.empty()) + { + fit_compound_to_guides(); + m_pick_compound_point = false; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Clear##cmpick")) + { + clear_fit_guides(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Cancel##cmpick")) + { + m_pick_compound_point = false; + clear_fit_guides(); + } + } } void plan_panel::render_gaps() @@ -512,18 +1712,26 @@ void plan_panel::render_gaps() return; } - ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 220.0f), false); + ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 280.0f), false); + bool dirty{false}; for (std::size_t gap = 0; gap < niweleta->fits.size(); ++gap) { auto &fit{niweleta->fits[gap]}; ImGui::PushID(static_cast(gap)); - ImGui::Text("gap %d", static_cast(gap)); + auto const selected_gap{m_sel_gap == static_cast(gap)}; + if (ImGui::Selectable(selected_gap ? "gap##sel" : "gap##unsel", selected_gap, ImGuiSelectableFlags_AllowItemOverlap, ImVec2(36.0f, 0.0f))) + { + m_sel_gap = static_cast(gap); + } + ImGui::SameLine(); + ImGui::Text("%d", static_cast(gap)); ImGui::SameLine(70.0f); ImGui::SetNextItemWidth(170.0f); // the library numbers these; 3 is the unified compound, any number of arcs eased by clothoids if (ImGui::Combo("##mode", &fit.mode, "none\0arc\0arc + clothoids\0compound\0")) { + m_sel_gap = static_cast(gap); if (fit.mode != 0 && fit.radius <= 0.0) { fit.radius = 300.0; // something drawable to start from, rather than a rejected fit @@ -534,7 +1742,7 @@ void plan_panel::render_gaps() fit.arcs.push_back({600.0, 60.0, 0.0}); fit.arcs.push_back({300.0, 0.0, 0.0}); } - solve(); + dirty = true; } if (fit.mode == 1 || fit.mode == 2) { @@ -542,7 +1750,7 @@ void plan_panel::render_gaps() ImGui::SetNextItemWidth(120.0f); if (ImGui::InputDouble("R", &fit.radius, 10.0, 100.0, "%.1f")) { - solve(); + dirty = true; } } if (fit.mode == 2) @@ -551,12 +1759,12 @@ void plan_panel::render_gaps() ImGui::SetNextItemWidth(120.0f); if (ImGui::InputDouble("L", &fit.transition, 5.0, 20.0, "%.1f")) { - solve(); + dirty = true; } } if (fit.mode == 3) { - render_compound(fit); + render_compound(fit, gap, dirty); } // a fit the library could not produce is simply absent from its answer; say so rather than // leaving the parameters looking as though they took effect @@ -565,64 +1773,152 @@ void plan_panel::render_gaps() ImGui::SameLine(); ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "does not fit"); } + else if (fit.mode != 0 && m_niweleta < m_solved.size()) + { + auto const &solved{m_solved[m_niweleta]}; + double total{0.0}; + double kp_len{0.0}; + int kp_count{0}; + std::vector radii; + for (auto const &poly : solved.polylines) + { + if (poly.gap != static_cast(gap) || poly.element_index < 0) + { + continue; + } + // count each element once (rails may duplicate polylines) + bool seen{false}; + for (auto const &other : solved.polylines) + { + if (&other == &poly) + { + break; + } + if (other.gap == poly.gap && other.element_index == poly.element_index) + { + seen = true; + break; + } + } + if (seen) + { + continue; + } + total += poly.length; + if (poly.kind == 2) + { + kp_len += poly.length; + ++kp_count; + } + if (poly.kind == 1) + { + auto const r{poly.radius_start > 0.0 ? poly.radius_start : poly.radius_end}; + if (r > 0.0) + { + radii.push_back(r); + } + } + } + if (false == radii.empty()) + { + std::string rtxt{"R lukow: "}; + for (std::size_t i = 0; i < radii.size(); ++i) + { + if (i > 0) + { + rtxt += " / "; + } + char piece[32]; + std::snprintf(piece, sizeof(piece), "%.1f", radii[i]); + rtxt += piece; + } + rtxt += " m"; + ImGui::TextDisabled("%s", rtxt.c_str()); + } + if (kp_count > 0) + { + ImGui::TextDisabled("KP: %d L = %.1f m (krzywa lacznie %.1f m)", kp_count, kp_len, total); + } + else if (total > 0.0) + { + ImGui::TextDisabled("krzywa L = %.1f m", total); + } + } ImGui::PopID(); } ImGui::EndChild(); + + if (dirty) + { + solve(); + } } -void plan_panel::render_compound(maj0sted::web::GapFit &Fit) +void plan_panel::render_compound(maj0sted::web::GapFit &Fit, std::size_t const Gap, bool &Dirty) { ImGui::Indent(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("entry", &Fit.entry_t, 5.0, 20.0, "%.1f")) + if (ImGui::SmallButton("Kosz##reopen") && false == m_draft.active) { - solve(); + if (reopen_draft_from_gap(Gap)) + { + Dirty = false; + ImGui::Unindent(); + return; + } } - ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("exit", &Fit.exit_t, 5.0, 20.0, "%.1f")) - { - solve(); - } - ImGui::SameLine(); - ImGui::TextDisabled("end clothoids"); + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("wejscie##ent", &Fit.entry_t, 5.0, 20.0, "%.1f")) + { + Dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("wyjscie##ex", &Fit.exit_t, 5.0, 20.0, "%.1f")) + { + Dirty = true; + } + + ImGui::Spacing(); + double station{Fit.entry_t}; for (std::size_t i = 0; i < Fit.arcs.size(); ++i) { auto &arc{Fit.arcs[i]}; auto const last{i + 1 == Fit.arcs.size()}; ImGui::PushID(static_cast(i)); - ImGui::Text("%d.", static_cast(i + 1)); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Luk %d", static_cast(i + 1)); ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("R", &arc.radius, 10.0, 100.0, "%.1f")) + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("R##ar", &arc.radius, 10.0, 100.0, "%.1f")) { - solve(); + Dirty = true; } - // the closing arc takes up whatever deflection the earlier ones leave, so its length is not - // ours to set, and there is no arc after it to ease into + if (false == last) { ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("len", &arc.length, 10.0, 50.0, "%.1f")) + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("L##al", &arc.length, 5.0, 20.0, "%.1f")) { - solve(); + Dirty = true; } ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("ease", &arc.transition_to_next, 5.0, 20.0, "%.1f")) + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("KP##at", &arc.transition_to_next, 5.0, 20.0, "%.1f")) { - solve(); + Dirty = true; } + station += std::max(0.0, arc.length) + std::max(0.0, arc.transition_to_next); + ImGui::TextDisabled("~%.0f m", station); } else { ImGui::SameLine(); - ImGui::TextDisabled("closes the curve"); + ImGui::TextDisabled("L auto"); } ImGui::PopID(); @@ -630,10 +1926,13 @@ void plan_panel::render_compound(maj0sted::web::GapFit &Fit) if (ImGui::SmallButton("Add arc")) { - // inserted before the closing arc, so that one stays the one that closes the curve auto const radius{Fit.arcs.empty() ? 300.0 : Fit.arcs.back().radius}; + if (false == Fit.arcs.empty() && !(Fit.arcs.back().length > 0.0)) + { + Fit.arcs.back().length = 60.0; + } Fit.arcs.insert(Fit.arcs.end() - (Fit.arcs.empty() ? 0 : 1), {radius, 60.0, 0.0}); - solve(); + Dirty = true; } if (Fit.arcs.size() > 2) { @@ -641,13 +1940,613 @@ void plan_panel::render_compound(maj0sted::web::GapFit &Fit) if (ImGui::SmallButton("Remove arc")) { Fit.arcs.erase(Fit.arcs.end() - 2); - solve(); + Dirty = true; } } + ImGui::Spacing(); + if (ImGui::Button("Fit through points")) + { + m_sel_gap = static_cast(Gap); + m_pick_compound_point = true; + m_pick_rel = 0; + m_drawing = false; + m_fit_guides.clear(); + m_status = "click points, then Fit"; + } + ImGui::Unindent(); } +void plan_panel::clear_fit_guides() +{ + m_fit_guides.clear(); + m_status.clear(); +} + +void plan_panel::fit_compound_to_guides() +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || m_sel_gap < 0 || static_cast(m_sel_gap) >= niweleta->fits.size()) + { + return; + } + if (m_fit_guides.empty()) + { + return; + } + auto const gap{static_cast(m_sel_gap)}; + if (gap + 1 >= niweleta->straights.size()) + { + return; + } + auto &fit{niweleta->fits[gap]}; + if (fit.mode != 3) + { + return; + } + if (fit.arcs.empty()) + { + fit.arcs.push_back({fit.radius > 30.0 ? fit.radius : 300.0, 0.0, 0.0}); + } + for (auto &arc : fit.arcs) + { + if (!(arc.radius >= 30.0)) + { + arc.radius = 300.0; + } + } + for (std::size_t i = 1; i < fit.arcs.size(); ++i) + { + if (std::abs(fit.arcs[i].radius - fit.arcs[i - 1].radius) < 1.0) + { + fit.arcs[i].radius = std::max(30.0, fit.arcs[i - 1].radius * 0.75); + } + } + for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) + { + if (!(fit.arcs[i].length > 0.0)) + { + fit.arcs[i].length = 60.0; + } + } + + auto &entry{niweleta->straights[gap]}; + auto &exit{niweleta->straights[gap + 1]}; + + // search window for straight ends: big enough to reach the guide cloud + double gx{0.0}; + double gy{0.0}; + for (auto const &g : m_fit_guides) + { + gx += g.x; + gy += g.y; + } + gx /= static_cast(m_fit_guides.size()); + gy /= static_cast(m_fit_guides.size()); + auto guide_span{80.0}; + for (auto const &g : m_fit_guides) + { + guide_span = std::max(guide_span, std::hypot(g.x - gx, g.y - gy) * 1.6); + } + auto const near_span{std::max(guide_span, 120.0)}; + auto const far_span{std::max(guide_span * 0.5, 60.0)}; + + enum class KnobKind + { + EntryT, + ExitT, + Radius, + Length, + Between, + EntryNearX, + EntryNearY, + ExitNearX, + ExitNearY, + EntryFarX, + EntryFarY, + ExitFarX, + ExitFarY + }; + struct Knob + { + KnobKind kind{}; + std::size_t arc{0}; + double lo{0.0}; + double hi{0.0}; + int priority{0}; + }; + + std::vector knobs; + knobs.push_back({KnobKind::EntryT, 0, 0.0, std::max(fit.entry_t * 4.0, 180.0), 2}); + knobs.push_back({KnobKind::ExitT, 0, 0.0, std::max(fit.exit_t * 4.0, 180.0), 2}); + for (std::size_t i = 0; i < fit.arcs.size(); ++i) + { + auto const &arc{fit.arcs[i]}; + knobs.push_back({KnobKind::Radius, i, 30.0, std::max(arc.radius * 5.0, 1200.0), 3}); + if (i + 1 < fit.arcs.size()) + { + knobs.push_back({KnobKind::Length, i, 5.0, std::max(arc.length * 6.0, 400.0), 3}); + knobs.push_back({KnobKind::Between, i, 0.0, std::max(arc.transition_to_next * 4.0, 160.0), 2}); + } + } + knobs.push_back({KnobKind::EntryNearX, 0, entry.x2 - near_span, entry.x2 + near_span, 4}); + knobs.push_back({KnobKind::EntryNearY, 0, entry.y2 - near_span, entry.y2 + near_span, 4}); + knobs.push_back({KnobKind::ExitNearX, 0, exit.x1 - near_span, exit.x1 + near_span, 4}); + knobs.push_back({KnobKind::ExitNearY, 0, exit.y1 - near_span, exit.y1 + near_span, 4}); + knobs.push_back({KnobKind::EntryFarX, 0, entry.x1 - far_span, entry.x1 + far_span, 1}); + knobs.push_back({KnobKind::EntryFarY, 0, entry.y1 - far_span, entry.y1 + far_span, 1}); + knobs.push_back({KnobKind::ExitFarX, 0, exit.x2 - far_span, exit.x2 + far_span, 1}); + knobs.push_back({KnobKind::ExitFarY, 0, exit.y2 - far_span, exit.y2 + far_span, 1}); + std::stable_sort(knobs.begin(), knobs.end(), [](Knob const &A, Knob const &B) { return A.priority > B.priority; }); + + auto trial{m_document}; + auto &trial_niw{trial.niwelety[m_niweleta]}; + auto &trial_fit{trial_niw.fits[gap]}; + auto &trial_entry{trial_niw.straights[gap]}; + auto &trial_exit{trial_niw.straights[gap + 1]}; + + auto get_value = [&](Knob const &K) -> double { + switch (K.kind) + { + case KnobKind::EntryT: + return trial_fit.entry_t; + case KnobKind::ExitT: + return trial_fit.exit_t; + case KnobKind::Radius: + return trial_fit.arcs[K.arc].radius; + case KnobKind::Length: + return trial_fit.arcs[K.arc].length; + case KnobKind::Between: + return trial_fit.arcs[K.arc].transition_to_next; + case KnobKind::EntryNearX: + return trial_entry.x2; + case KnobKind::EntryNearY: + return trial_entry.y2; + case KnobKind::ExitNearX: + return trial_exit.x1; + case KnobKind::ExitNearY: + return trial_exit.y1; + case KnobKind::EntryFarX: + return trial_entry.x1; + case KnobKind::EntryFarY: + return trial_entry.y1; + case KnobKind::ExitFarX: + return trial_exit.x2; + case KnobKind::ExitFarY: + return trial_exit.y2; + } + return 0.0; + }; + auto set_value = [&](Knob const &K, double const V) { + switch (K.kind) + { + case KnobKind::EntryT: + trial_fit.entry_t = V; + break; + case KnobKind::ExitT: + trial_fit.exit_t = V; + break; + case KnobKind::Radius: + trial_fit.arcs[K.arc].radius = V; + break; + case KnobKind::Length: + trial_fit.arcs[K.arc].length = V; + break; + case KnobKind::Between: + trial_fit.arcs[K.arc].transition_to_next = V; + break; + case KnobKind::EntryNearX: + trial_entry.x2 = V; + break; + case KnobKind::EntryNearY: + trial_entry.y2 = V; + break; + case KnobKind::ExitNearX: + trial_exit.x1 = V; + break; + case KnobKind::ExitNearY: + trial_exit.y1 = V; + break; + case KnobKind::EntryFarX: + trial_entry.x1 = V; + break; + case KnobKind::EntryFarY: + trial_entry.y1 = V; + break; + case KnobKind::ExitFarX: + trial_exit.x2 = V; + break; + case KnobKind::ExitFarY: + trial_exit.y2 = V; + break; + } + }; + auto radii_legal = [&]() { + for (std::size_t i = 0; i < trial_fit.arcs.size(); ++i) + { + if (!(trial_fit.arcs[i].radius >= 30.0)) + { + return false; + } + if (i > 0 && std::abs(trial_fit.arcs[i].radius - trial_fit.arcs[i - 1].radius) < 1.0) + { + return false; + } + } + return true; + }; + + auto evaluate = [&](double &OutMaxMiss) { + OutMaxMiss = std::numeric_limits::infinity(); + if (false == radii_legal()) + { + return std::numeric_limits::infinity(); + } + // tiny straights break the fitter + if (std::hypot(trial_entry.x2 - trial_entry.x1, trial_entry.y2 - trial_entry.y1) < 1.0 || + std::hypot(trial_exit.x2 - trial_exit.x1, trial_exit.y2 - trial_exit.y1) < 1.0) + { + return std::numeric_limits::infinity(); + } + auto const solved{maj0sted::web::solve_project(trial.niwelety)}; + if (m_niweleta >= solved.size()) + { + return std::numeric_limits::infinity(); + } + double sum2{0.0}; + double max_miss{0.0}; + for (auto const &g : m_fit_guides) + { + auto const d{distance_to_gap_track(solved[m_niweleta], m_sel_gap, g.x, g.y)}; + if (false == std::isfinite(d)) + { + return std::numeric_limits::infinity(); + } + sum2 += d * d; + max_miss = std::max(max_miss, d); + } + OutMaxMiss = max_miss; + return sum2; + }; + + double best_max{0.0}; + auto best_cost{evaluate(best_max)}; + auto best_niw{trial_niw}; + + auto scan_knob = [&](Knob const &K, int const Samples) { + for (int s = 0; s < Samples; ++s) + { + auto const v{K.lo + (K.hi - K.lo) * static_cast(s) / static_cast(std::max(1, Samples - 1))}; + set_value(K, v); + double max_miss{0.0}; + auto const cost{evaluate(max_miss)}; + if (cost < best_cost) + { + best_cost = cost; + best_max = max_miss; + best_niw = trial_niw; + } + } + trial_niw = best_niw; + }; + + for (int round = 0; round < 3; ++round) + { + for (auto const &knob : knobs) + { + auto const samples{knob.priority >= 4 ? 22 : (knob.priority >= 3 ? 20 : (knob.priority >= 2 ? 14 : 10))}; + scan_knob(knob, samples); + } + } + + for (auto const &knob : knobs) + { + trial_niw = best_niw; + auto const centre{get_value(knob)}; + auto const span{(knob.hi - knob.lo) / 16.0}; + auto const lo{std::max(knob.lo, centre - span)}; + auto const hi{std::min(knob.hi, centre + span)}; + for (int s = 0; s < 14; ++s) + { + set_value(knob, lo + (hi - lo) * static_cast(s) / 13.0); + double max_miss{0.0}; + auto const cost{evaluate(max_miss)}; + if (cost < best_cost) + { + best_cost = cost; + best_max = max_miss; + best_niw = trial_niw; + } + } + } + + *niweleta = best_niw; + // drop constraints that would yank the ends back after we moved them on purpose + niweleta->straights[gap].rel_kind = 0; + niweleta->straights[gap + 1].rel_kind = 0; + solve(); + auto const rms{std::sqrt(best_cost / static_cast(m_fit_guides.size()))}; + m_status = "fit " + std::to_string(m_fit_guides.size()) + " pts rms " + to_string(rms, 2) + " max " + to_string(best_max, 2) + " m"; +} + +void plan_panel::render_switches() +{ + if (false == ImGui::CollapsingHeader("Rozjazdy")) + { + return; + } + + // typical Polish turnout templates: pick one to set the crossing mark and radius at once + auto const &presets{editor::turnout_presets()}; + ImGui::SetNextItemWidth(160.0f); + if (ImGui::BeginCombo("typ", "wybierz szablon")) + { + for (auto const &preset : presets) + { + if (ImGui::Selectable(preset.name.c_str())) + { + m_sw_crossing = preset.crossing_n; + m_sw_radius = preset.radius; + } + } + ImGui::EndCombo(); + } + // each single-arc template spans R*atan(1/n) between its tangent points; shown so its length is known + ImGui::SameLine(); + ImGui::TextDisabled("dl. ~%.1f m", m_sw_radius * std::atan(1.0 / std::max(1.0, m_sw_crossing))); + + ImGui::SetNextItemWidth(90.0f); + ImGui::Combo("strona", &m_sw_side, "w lewo\0w prawo\0"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("skos 1:", &m_sw_crossing, 1.0, 1.0, "%.1f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("R", &m_sw_radius, 10.0, 50.0, "%.0f"); + if (m_sw_crossing < 4.0) + { + m_sw_crossing = 4.0; + } + if (m_sw_radius < 50.0) + { + m_sw_radius = 50.0; + } + + if (m_pick_switch != 0) + { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "kliknij niweletę na wprost..."); + ImGui::SameLine(); + if (ImGui::Button("anuluj")) + { + m_pick_switch = 0; + } + } + else if (ImGui::Button("Dodaj rozjazd")) + { + m_pick_switch = 1; + m_status = "kliknij niweletę na wprost"; + } + + ImGui::TextDisabled("odnoga wychodzi wymuszenie stycznie; luk koszowy zaraz za rozjazdem = luka 0 odnogi"); + + // existing switches: the theoretical tangent lengths the solver worked out, plus the internal + // curve (a plain arc, or a łuk koszowy right inside the switch) + for (std::size_t i = 0; i < m_document.junctions.size(); ++i) + { + auto &junction{m_document.junctions[i]}; + ImGui::PushID(static_cast(i)); + char const *through_name{junction.through >= 0 && static_cast(junction.through) < m_document.niwelety.size() + ? m_document.niwelety[static_cast(junction.through)].name.c_str() + : "?"}; + bool const valid{i < m_junctions.size() && m_junctions[i].valid}; + + ImGui::AlignTextToFramePadding(); + ImGui::Text("%zu. na %s, 1:%.1f %s", i + 1, through_name, junction.crossing_n, junction.side == 0 ? "L" : "P"); + if (valid) + { + ImGui::SameLine(); + ImGui::TextDisabled("dl=%.1f m, styczne t=%.1f / %.1f m", m_junctions[i].length, m_junctions[i].tangent_front, m_junctions[i].tangent_back); + } + else + { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "nie wpasowany"); + } + ImGui::SameLine(); + if (ImGui::SmallButton("usuń")) + { + m_document.junctions.erase(m_document.junctions.begin() + static_cast(i)); + solve(); + ImGui::PopID(); + break; + } + + // internal diverging curve of the switch: a single arc, or a basket of arcs + ImGui::Indent(); + int curve_mode{junction.curve.mode == 3 ? 1 : 0}; + ImGui::SetNextItemWidth(110.0f); + if (ImGui::Combo("krzywa", &curve_mode, "luk\0koszowy\0")) + { + if (curve_mode == 1) + { + junction.curve.mode = 3; + if (junction.curve.arcs.empty()) + { + // a basket needs at least two arcs; the last absorbs the rest of the turn + junction.curve.arcs.push_back({junction.curve.radius > 0.0 ? junction.curve.radius * 2.0 : 600.0, 40.0, 0.0}); + junction.curve.arcs.push_back({junction.curve.radius > 0.0 ? junction.curve.radius : 300.0, 0.0, 0.0}); + } + } + else + { + junction.curve.mode = 1; + } + solve(); + } + + if (junction.curve.mode == 3) + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("wej##je", &junction.curve.entry_t, 5.0, 20.0, "%.1f")) + { + solve(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("wyj##jx", &junction.curve.exit_t, 5.0, 20.0, "%.1f")) + { + solve(); + } + for (std::size_t a = 0; a < junction.curve.arcs.size(); ++a) + { + auto &arc{junction.curve.arcs[a]}; + bool const last{a + 1 == junction.curve.arcs.size()}; + ImGui::PushID(static_cast(a)); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Luk %d", static_cast(a + 1)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("R##jar", &arc.radius, 10.0, 100.0, "%.1f")) + { + solve(); + } + if (false == last) + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("L##jal", &arc.length, 5.0, 20.0, "%.1f")) + { + solve(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("KP##jat", &arc.transition_to_next, 5.0, 20.0, "%.1f")) + { + solve(); + } + } + else + { + ImGui::SameLine(); + ImGui::TextDisabled("domyka"); + } + ImGui::PopID(); + } + if (ImGui::SmallButton("+ luk")) + { + junction.curve.arcs.insert(junction.curve.arcs.end() - 1, {junction.curve.arcs.back().radius, 40.0, 0.0}); + solve(); + } + if (junction.curve.arcs.size() > 2) + { + ImGui::SameLine(); + if (ImGui::SmallButton("- luk")) + { + junction.curve.arcs.erase(junction.curve.arcs.end() - 2); + solve(); + } + } + } + else + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("R##jr", &junction.curve.radius, 10.0, 50.0, "%.0f")) + { + solve(); + } + } + + if (junction.branch >= 0 && static_cast(junction.branch) < m_document.niwelety.size()) + { + ImGui::SameLine(); + if (ImGui::SmallButton("edytuj odnoge")) + { + m_niweleta = static_cast(junction.branch); + clear_selection(); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); + } + } + ImGui::Unindent(); + ImGui::PopID(); + } +} + +void plan_panel::add_switch_on(std::size_t const Through, double const Wx, double const Wy) +{ + if (Through >= m_solved.size()) + { + return; + } + auto const ¢re{m_solved[Through].centreline}; + if (centre.size() < 2) + { + m_status = "niweleta bez osi"; + return; + } + + // nearest station: walk the centreline, project the click onto each segment + double best_station{0.0}; + double best_dist{std::numeric_limits::max()}; + double travelled{0.0}; + for (std::size_t i = 0; i + 1 < centre.size(); ++i) + { + double const ax{centre[i].x}; + double const ay{centre[i].y}; + double const dx{centre[i + 1].x - ax}; + double const dy{centre[i + 1].y - ay}; + double const seg{std::hypot(dx, dy)}; + if (seg < 1e-9) + { + continue; + } + double const t{std::clamp(((Wx - ax) * dx + (Wy - ay) * dy) / (seg * seg), 0.0, 1.0)}; + double const px{ax + dx * t}; + double const py{ay + dy * t}; + double const d{std::hypot(Wx - px, Wy - py)}; + if (d < best_dist) + { + best_dist = d; + best_station = travelled + seg * t; + } + travelled += seg; + } + + maj0sted::web::Junction junction; + junction.through = static_cast(Through); + junction.station = best_station; + junction.side = m_sw_side; + junction.facing = true; + junction.crossing_n = m_sw_crossing; + junction.curve.mode = 1; // plain arc; the internal curve can be made compound later + junction.curve.radius = m_sw_radius; + junction.branch = static_cast(m_document.niwelety.size()); + + // a branch niweleta to draw the diverging track into, pinned to the frog by the solver + maj0sted::web::NiweletaSpec branch; + branch.name = "odnoga " + std::to_string(m_document.niwelety.size() + 1); + branch.straights.push_back({Wx, Wy, Wx + 10.0, Wy, false}); // placeholder, repositioned below + m_document.niwelety.push_back(std::move(branch)); + m_document.junctions.push_back(junction); + + // solve once to learn where the frog lands, then seed the branch to leave it tangent + solve(); + if (false == m_junctions.empty() && m_junctions.back().valid) + { + auto const &geom{m_junctions.back()}; + auto &seed{m_document.niwelety[static_cast(junction.branch)].straights.front()}; + seed = {geom.fx, geom.fy, geom.fx + geom.fhx * 50.0, geom.fy + geom.fhy * 50.0, false}; + } + + m_niweleta = static_cast(junction.branch); + clear_selection(); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); + solve(); + m_status = "rozjazd 1:" + to_string(m_sw_crossing, 1) + " dodany"; +} + void plan_panel::render_storage() { ImGui::Separator(); @@ -656,6 +2555,16 @@ void plan_panel::render_storage() ImGui::SameLine(); if (ImGui::Button("Save")) { + double planx{0.0}; + double plany{0.0}; + world_to_plan(editor_mode::get_camera().Pos, planx, plany); + m_document.view_x = planx; + m_document.view_y = plany; + m_document.view_extent = static_cast(Global.editor_ortho_extent); + m_document.origin_set = true; + m_document.georeferenced = Global.scenery_georeferenced; + m_document.origin_x = Global.scenery_origin.x; + m_document.origin_y = Global.scenery_origin.y; m_status = maj0sted::app::save_project(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path); } ImGui::SameLine(); @@ -669,11 +2578,55 @@ void plan_panel::render_storage() { m_document.niwelety.push_back({"niweleta 1", {}, {}}); } + auto looks_puwg = [](maj0sted::app::EditorDocument const &Doc) { + if (std::abs(Doc.view_x) > 10000.0 || std::abs(Doc.view_y) > 10000.0) + { + return true; + } + for (auto const &n : Doc.niwelety) + { + for (auto const &s : n.straights) + { + if (std::abs(s.x1) > 10000.0 || std::abs(s.y1) > 10000.0 || std::abs(s.x2) > 10000.0 || std::abs(s.y2) > 10000.0) + { + return true; + } + } + } + return false; + }; + if (m_document.origin_set) + { + Global.scenery_georeferenced = m_document.georeferenced; + Global.scenery_origin = {m_document.origin_x, m_document.origin_y}; + } + if (false == Global.scenery_georeferenced && looks_puwg(m_document)) + { + Global.scenery_georeferenced = true; + } + m_document.origin_set = true; + m_document.georeferenced = Global.scenery_georeferenced; + m_document.origin_x = Global.scenery_origin.x; + m_document.origin_y = Global.scenery_origin.y; m_niweleta = 0; m_pending = false; - m_draggedvertex = -1; + clear_selection(); + m_pick_rel = 0; + m_ask_rel_param = false; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[0].name.c_str()); solve(); - go_to_plan(); + if (m_document.view_extent > 0.0) + { + auto &camera{editor_mode::get_camera()}; + auto const centre{plan_to_world(m_document.view_x, m_document.view_y)}; + camera.Pos.x = centre.x; + camera.Pos.z = centre.z; + Global.editor_ortho_extent = std::clamp(static_cast(m_document.view_extent), 5.0f, 20000.0f); + } + else + { + go_to_plan(); + } m_status = "loaded " + std::string(m_path); } else @@ -681,18 +2634,58 @@ void plan_panel::render_storage() m_status = "could not read " + std::string(m_path); } } + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##scnpath", m_scn_path, sizeof(m_scn_path)); + ImGui::SameLine(); + if (ImGui::Button("Export SCN")) + { + export_scn(); + } if (false == m_status.empty()) { ImGui::TextDisabled("%s", m_status.c_str()); } } +void plan_panel::export_scn() +{ + solve(); + maj0sted::io::ScnExportOptions opt; + opt.origin_east = Global.scenery_origin.x; + opt.origin_north = Global.scenery_origin.y; + std::ofstream out(m_scn_path, std::ios::binary | std::ios::trunc); + if (!out) + { + m_status = "could not write " + std::string(m_scn_path); + return; + } + auto const result{maj0sted::io::export_scn(m_solved, opt, out)}; + out.flush(); + m_status = out.good() ? ("exported " + std::to_string(result.tracks) + " tracks") + : ("could not write " + std::string(m_scn_path)); +} + + void plan_panel::solve() { - align_fits(); - m_solved = maj0sted::web::solve_project(m_document.niwelety); + for (auto &niweleta : m_document.niwelety) + { + maj0sted::app::align_gap_fits(niweleta); + } + maj0sted::app::apply_straight_constraints(m_document.niwelety); + // switches pin their branches, so the whole layout is solved together + auto layout{maj0sted::web::solve_layout(m_document.niwelety, m_document.junctions)}; + m_solved = std::move(layout.niwelety); + m_junctions = std::move(layout.junctions); } + +void plan_panel::apply_constraints() +{ + maj0sted::app::apply_straight_constraints(m_document.niwelety); +} + + maj0sted::web::NiweletaSpec *plan_panel::current_niweleta() { return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; @@ -707,24 +2700,11 @@ void plan_panel::align_fits() { for (auto &niweleta : m_document.niwelety) { - auto const gaps{niweleta.straights.empty() ? std::size_t{0} : niweleta.straights.size() - 1}; - std::vector slots(gaps); - for (std::size_t gap = 0; gap < gaps; ++gap) - { - slots[gap].gap = static_cast(gap); - } - // a document read from disk carries only the gaps that were actually fitted - for (auto const &fit : niweleta.fits) - { - if (fit.gap >= 0 && static_cast(fit.gap) < gaps) - { - slots[fit.gap] = fit; - } - } - niweleta.fits = std::move(slots); + maj0sted::app::align_gap_fits(niweleta); } } + bool plan_panel::fit_applied(std::size_t const Gap) const { if (m_niweleta >= m_solved.size()) @@ -741,27 +2721,22 @@ bool plan_panel::fit_applied(std::size_t const Gap) const return false; } -std::size_t plan_panel::vertex_count() const +maj0sted::web::StraightSpec const *plan_panel::handle_straight(std::size_t const Niw, std::size_t const Index) const { - auto const *niweleta{current_niweleta()}; - return (niweleta == nullptr || niweleta->straights.empty()) ? 0 : niweleta->straights.size() + 1; -} - -void plan_panel::vertex_position(std::size_t const Index, double &X, double &Y) const -{ - auto const *niweleta{current_niweleta()}; - if (niweleta == nullptr || niweleta->straights.empty() || Index >= niweleta->straights.size() + 1) + if (Niw >= m_document.niwelety.size()) { - return; + return nullptr; } - if (Index == 0) + auto const &raw{m_document.niwelety[Niw].straights}; + if (Index >= raw.size()) { - X = niweleta->straights.front().x1; - Y = niweleta->straights.front().y1; - return; + return nullptr; } - X = niweleta->straights[Index - 1].x2; - Y = niweleta->straights[Index - 1].y2; + if (Niw < m_solved.size() && Index < m_solved[Niw].rendered_straights.size()) + { + return &m_solved[Niw].rendered_straights[Index]; + } + return &raw[Index]; } void plan_panel::append_vertex(double const X, double const Y) @@ -787,6 +2762,7 @@ void plan_panel::append_vertex(double const X, double const Y) } else { + // seed from the previous end, but the joint is not locked afterwards auto const &last{niweleta->straights.back()}; niweleta->straights.push_back({last.x2, last.y2, X, Y, false}); } @@ -794,29 +2770,6 @@ void plan_panel::append_vertex(double const X, double const Y) solve(); } -void plan_panel::move_vertex(std::size_t const Index, double const X, double const Y) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || Index >= niweleta->straights.size() + 1) - { - return; - } - - // the point belongs to both straights meeting at it, so both ends follow it - if (Index > 0) - { - niweleta->straights[Index - 1].x2 = X; - niweleta->straights[Index - 1].y2 = Y; - } - if (Index < niweleta->straights.size()) - { - niweleta->straights[Index].x1 = X; - niweleta->straights[Index].y1 = Y; - } - - solve(); -} - void plan_panel::drop_last_vertex() { if (m_pending) @@ -831,10 +2784,673 @@ void plan_panel::drop_last_vertex() return; } - niweleta->straights.pop_back(); + m_sel_straight = static_cast(niweleta->straights.size() - 1); + delete_selected_straight(); +} + +void plan_panel::clear_selection() +{ + m_sel_straight = -1; + m_sel_end = -1; + m_dragging = false; + m_dragging_cmp = false; + m_cmp_gap = -1; + m_cmp_kind = -1; + m_cmp_arc = -1; + m_dragging_draft = false; + m_draft_kind = -1; + m_draft_arc = -1; +} + +void plan_panel::delete_selected_straight() +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || m_sel_straight < 0 || static_cast(m_sel_straight) >= niweleta->straights.size()) + { + return; + } + + auto const sel{m_sel_straight}; + niweleta->straights.erase(niweleta->straights.begin() + sel); + + // gaps touching the removed straight disappear; later gaps shift down + std::vector kept; + kept.reserve(niweleta->fits.size()); + for (auto const &fit : niweleta->fits) + { + if (fit.gap < sel - 1) + { + kept.push_back(fit); + } + else if (fit.gap >= sel + 1) + { + auto copy{fit}; + copy.gap = fit.gap - 1; + kept.push_back(copy); + } + } + niweleta->fits = std::move(kept); + + // remap constraint references across every niweleta + for (std::size_t n = 0; n < m_document.niwelety.size(); ++n) + { + for (auto &st : m_document.niwelety[n].straights) + { + if (st.rel_kind == 0) + { + continue; + } + if (st.rel_niw == static_cast(m_niweleta)) + { + if (st.rel_str == sel) + { + st.rel_kind = 0; + } + else if (st.rel_str > sel) + { + --st.rel_str; + } + } + } + } + + clear_selection(); solve(); } +void plan_panel::delete_current_niweleta() +{ + if (m_document.niwelety.size() <= 1 || m_niweleta >= m_document.niwelety.size()) + { + return; + } + + auto const removed{static_cast(m_niweleta)}; + m_document.niwelety.erase(m_document.niwelety.begin() + static_cast(m_niweleta)); + + for (auto &niweleta : m_document.niwelety) + { + for (auto &st : niweleta.straights) + { + if (st.rel_kind == 0) + { + continue; + } + if (st.rel_niw == removed) + { + st.rel_kind = 0; + } + else if (st.rel_niw > removed) + { + --st.rel_niw; + } + } + } + + if (m_niweleta >= m_document.niwelety.size()) + { + m_niweleta = m_document.niwelety.size() - 1; + } + m_pending = false; + clear_selection(); + m_pick_rel = 0; + m_ask_rel_param = false; + if (auto *niweleta{current_niweleta()}; niweleta != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", niweleta->name.c_str()); + } + solve(); +} + +bool plan_panel::hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const +{ + auto const *niweleta{current_niweleta()}; + if (niweleta == nullptr || m_niweleta >= m_solved.size()) + { + return false; + } + auto best{14.0f}; + bool found{false}; + for (auto const &fit : niweleta->fits) + { + if (fit.mode != 3 || fit.arcs.empty()) + { + continue; + } + for (auto const &h : compound_station_handles(fit, m_solved[m_niweleta], fit.gap)) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) + { + continue; + } + auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; + if (d <= best) + { + best = d; + OutGap = h.gap; + OutKind = h.kind; + OutArc = h.arc; + found = true; + } + } + } + return found; +} + +void plan_panel::begin_draft() +{ + discard_draft(); + m_draft.reedit_gap = -1; + m_draft.reedit_backup = {}; + m_draft_place = 1; + m_anchor_pick = 0; + m_drawing = false; + m_pick_compound_point = false; + m_pick_rel = 0; + m_dragging_draft = false; + m_draft_append_kind = 0; + m_fit_guides.clear(); + m_status = "kliknij koniec prostej"; +} + +void plan_panel::discard_draft() +{ + auto *niweleta{current_niweleta()}; + maj0sted::app::discard_basket_draft(m_draft, niweleta); + if (niweleta != nullptr) + { + solve(); + } + m_draft_polys.clear(); + m_draft_place = 0; + m_anchor_pick = 0; + m_dragging_draft = false; + m_draft_kind = -1; + m_draft_arc = -1; + m_draft_append_kind = 0; + m_status.clear(); +} + + +bool plan_panel::reopen_draft_from_gap(std::size_t const Gap) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr) + { + return false; + } + auto const *solved{m_niweleta < m_solved.size() ? &m_solved[m_niweleta] : nullptr}; + if (false == maj0sted::app::reopen_basket_draft(m_draft, *niweleta, Gap, solved)) + { + m_status = "nie compound"; + return false; + } + m_draft_place = 0; + m_anchor_pick = 0; + m_dragging_draft = false; + m_drawing = false; + m_sel_gap = static_cast(Gap); + m_sel_straight = static_cast(Gap); + m_sel_end = 1; + solve(); + rebuild_draft_preview(); + m_status = "luka " + std::to_string(Gap); + return true; +} + + +void plan_panel::start_draft_from_end(std::size_t const Str, int const End) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || Str >= niweleta->straights.size() || (End != 0 && End != 1)) + { + return; + } + maj0sted::app::start_basket_draft_from_end(m_draft, niweleta->straights[Str], static_cast(Str), End, m_draft_r); + m_draft_place = 0; + m_draft_append_kind = 0; + m_drawing = false; + m_sel_straight = static_cast(Str); + m_sel_end = End; + rebuild_draft_preview(); + m_status.clear(); +} + + +void plan_panel::append_draft_at(double const X, double const Y) +{ + if (false == m_draft.active) + { + return; + } + double tipx{0.0}; + double tipy{0.0}; + if (false == draft_tip(tipx, tipy)) + { + return; + } + auto const len{std::max(5.0, std::hypot(X - tipx, Y - tipy))}; + m_draft.preview_next_r = std::max(30.0, m_draft_r); + + if (m_draft_append_kind == 0) + { + m_draft.arcs.push_back({std::max(30.0, m_draft_r), len, 0.0}); + m_status = "luk L=" + to_string(len, 1) + " R=" + to_string(m_draft_r, 0); + } + else if (m_draft_append_kind == 1) + { + if (m_draft.arcs.empty()) + { + m_draft.entry_t = len; + m_status = "KP wejście " + to_string(len, 1) + " m"; + } + else + { + m_draft.arcs.back().transition_to_next = len; + m_draft.exit_t = 0.0; + m_status = "KP między " + to_string(len, 1) + " m"; + } + } + else + { + if (m_draft.arcs.empty()) + { + m_status = "najpierw łuk"; + return; + } + m_draft.exit_t = len; + rebuild_draft_preview(); + finish_draft(); + return; + } + rebuild_draft_preview(); +} + +void plan_panel::undo_draft_fragment() +{ + if (false == m_draft.active) + { + return; + } + if (m_draft.exit_t > 1e-6) + { + m_draft.exit_t = 0.0; + } + else if (false == m_draft.arcs.empty() && m_draft.arcs.back().transition_to_next > 1e-6) + { + m_draft.arcs.back().transition_to_next = 0.0; + } + else if (false == m_draft.arcs.empty()) + { + m_draft.arcs.pop_back(); + } + else if (m_draft.entry_t > 1e-6) + { + m_draft.entry_t = 0.0; + } + else + { + m_status = "nic do cofniecia"; + return; + } + rebuild_draft_preview(); + m_status = "cofnieto fragment"; +} + +bool plan_panel::draft_tip(double &OutX, double &OutY) const +{ + double az{0.0}; + return draft_end_pose(OutX, OutY, az); +} + +bool plan_panel::draft_end_pose(double &OutX, double &OutY, double &OutAz) const +{ + if (false == m_draft.active) + { + return false; + } + OutAz = m_draft.az0; + if (false == m_draft_polys.empty() && false == m_draft_polys.back().points.empty()) + { + auto const &pts{m_draft_polys.back().points}; + OutX = pts.back().x; + OutY = pts.back().y; + if (pts.size() >= 2) + { + OutAz = std::atan2(pts.back().x - pts[pts.size() - 2].x, pts.back().y - pts[pts.size() - 2].y); + } + return true; + } + OutX = m_draft.x0; + OutY = m_draft.y0; + return true; +} + +bool plan_panel::finish_draft() +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr) + { + m_status = "brak niwelety"; + return false; + } + auto const result{maj0sted::app::finish_basket_draft(m_draft, *niweleta, m_document.niwelety, static_cast(m_niweleta), m_draft_finish_straight)}; + m_status = result.status; + if (result.ok) + { + m_draft_polys.clear(); + m_draft_place = 0; + m_anchor_pick = 0; + m_dragging_draft = false; + solve(); + } + else + { + rebuild_draft_preview(); + solve(); + } + return result.ok; +} + + +void plan_panel::rebuild_draft_preview() +{ + m_draft_polys = maj0sted::app::preview_basket_draft(m_draft, m_draft_r); +} + + +bool plan_panel::hit_draft_station(ImVec2 const &Mouse, int &OutKind, int &OutArc) const +{ + if (false == m_draft.active) + { + return false; + } + auto best{14.0f}; + bool found{false}; + for (auto const &h : draft_station_handles(m_draft, m_draft_polys)) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) + { + continue; + } + auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; + if (d <= best) + { + best = d; + OutKind = h.kind; + OutArc = h.arc; + found = true; + } + } + return found; +} + +void plan_panel::drag_draft_station(double const X, double const Y) +{ + if (false == m_draft.active || m_draft.arcs.empty()) + { + return; + } + auto const samples{sample_draft_centreline(m_draft_polys)}; + double station{0.0}; + double px{0.0}; + double py{0.0}; + if (false == project_on_gap(samples, X, Y, station, px, py)) + { + return; + } + auto const total{samples.empty() ? 0.0 : samples.back().along}; + + auto station_before_arc = [&](int const Arc) { + auto s{std::max(0.0, m_draft.entry_t)}; + for (int i = 0; i < Arc && i < static_cast(m_draft.arcs.size()); ++i) + { + s += std::max(0.0, m_draft.arcs[static_cast(i)].length); + if (i + 1 < static_cast(m_draft.arcs.size())) + { + s += std::max(0.0, m_draft.arcs[static_cast(i)].transition_to_next); + } + } + return s; + }; + + constexpr double kMinL = 5.0; + constexpr double kMinKp = 0.0; + + if (m_draft_kind == 0) + { + m_draft.entry_t = std::clamp(station, 0.0, std::max(0.0, total - 20.0)); + } + else if (m_draft_kind == 1 && m_draft_arc >= 0 && m_draft_arc < static_cast(m_draft.arcs.size())) + { + auto const s0{station_before_arc(m_draft_arc)}; + auto &arc{m_draft.arcs[static_cast(m_draft_arc)]}; + if (ImGui::GetIO().KeyShift && m_draft_arc + 1 < static_cast(m_draft.arcs.size())) + { + auto const arc_end{s0 + std::max(kMinL, arc.length)}; + arc.transition_to_next = std::max(kMinKp, station - arc_end); + } + else + { + arc.length = std::max(kMinL, station - s0); + } + } + else if (m_draft_kind == 2 && m_draft_arc >= 0 && m_draft_arc + 1 < static_cast(m_draft.arcs.size())) + { + auto const s0{station_before_arc(m_draft_arc) + std::max(0.0, m_draft.arcs[static_cast(m_draft_arc)].length)}; + m_draft.arcs[static_cast(m_draft_arc)].transition_to_next = std::max(kMinKp, station - s0); + } + else if (m_draft_kind == 3) + { + m_draft.exit_t = std::clamp(total - station, 0.0, std::max(0.0, total - 20.0)); + } + + rebuild_draft_preview(); +} + +bool plan_panel::anchor_draft_to_gap(std::size_t const Gap) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr) + { + return false; + } + if (false == maj0sted::app::anchor_basket_draft(m_draft, *niweleta, Gap)) + { + m_status = "nie da się (kierunek / L+KP)"; + rebuild_draft_preview(); + solve(); + return false; + } + m_draft_polys.clear(); + m_draft_place = 0; + m_anchor_pick = 0; + m_dragging_draft = false; + m_sel_gap = static_cast(Gap); + solve(); + m_status = "luka " + std::to_string(Gap); + return true; +} + + +void plan_panel::drag_compound_station(double const X, double const Y) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || m_cmp_gap < 0 || static_cast(m_cmp_gap) >= niweleta->fits.size() || m_niweleta >= m_solved.size()) + { + return; + } + auto &fit{niweleta->fits[static_cast(m_cmp_gap)]}; + if (fit.mode != 3 || fit.arcs.empty()) + { + return; + } + + auto const samples{sample_gap_centreline(m_solved[m_niweleta], m_cmp_gap)}; + double station{0.0}; + double px{0.0}; + double py{0.0}; + if (false == project_on_gap(samples, X, Y, station, px, py)) + { + return; + } + auto const total{samples.empty() ? 0.0 : samples.back().along}; + + if (fit.arcs.empty()) + { + fit.arcs.push_back({300.0, 0.0, 0.0}); + } + for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) + { + if (!(fit.arcs[i].length > 0.0)) + { + fit.arcs[i].length = 40.0; + } + } + + auto station_before_arc = [&](int const Arc) { + auto s{std::max(0.0, fit.entry_t)}; + for (int i = 0; i < Arc && i + 1 < static_cast(fit.arcs.size()); ++i) + { + s += std::max(0.0, fit.arcs[static_cast(i)].length); + s += std::max(0.0, fit.arcs[static_cast(i)].transition_to_next); + } + return s; + }; + + constexpr double kMinL = 5.0; + constexpr double kMinKp = 0.0; + + if (m_cmp_kind == 0) + { + // entry KP end: station from the start of the curve + fit.entry_t = std::clamp(station, 0.0, std::max(0.0, total - 20.0)); + } + else if (m_cmp_kind == 1 && m_cmp_arc >= 0 && m_cmp_arc + 1 < static_cast(fit.arcs.size())) + { + auto const s0{station_before_arc(m_cmp_arc)}; + auto &arc{fit.arcs[static_cast(m_cmp_arc)]}; + if (ImGui::GetIO().KeyShift) + { + // Shift: pull a between-KP out past the arc end (L stays) + auto const arc_end{s0 + std::max(kMinL, arc.length)}; + arc.transition_to_next = std::max(kMinKp, station - arc_end); + } + else + { + arc.length = std::max(kMinL, station - s0); + } + } + else if (m_cmp_kind == 2 && m_cmp_arc >= 0 && m_cmp_arc + 1 < static_cast(fit.arcs.size())) + { + auto const s0{station_before_arc(m_cmp_arc) + std::max(0.0, fit.arcs[static_cast(m_cmp_arc)].length)}; + fit.arcs[static_cast(m_cmp_arc)].transition_to_next = std::max(kMinKp, station - s0); + } + else if (m_cmp_kind == 3) + { + fit.exit_t = std::clamp(total - station, 0.0, std::max(0.0, total - 20.0)); + } + + solve(); +} + +bool plan_panel::hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const +{ + auto best{12.0f}; + bool found{false}; + auto const niw{m_niweleta}; + auto const *niweleta{current_niweleta()}; + if (niweleta == nullptr) + { + return false; + } + for (std::size_t i = 0; i < niweleta->straights.size(); ++i) + { + auto const *st{handle_straight(niw, i)}; + if (st == nullptr || st->hidden) + { + continue; + } + for (int end = 0; end < 2; ++end) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(end == 0 ? st->x1 : st->x2, end == 0 ? st->y1 : st->y2), screen)) + { + continue; + } + auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; + if (d <= best) + { + best = d; + OutNiw = static_cast(niw); + OutStr = static_cast(i); + OutEnd = end; + found = true; + } + } + } + return found; +} + +bool plan_panel::hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const +{ + auto best{10.0f}; + bool found{false}; + auto const consider = [&](std::size_t const Niw) { + if (Niw >= m_document.niwelety.size()) + { + return; + } + auto const &straights{m_document.niwelety[Niw].straights}; + for (std::size_t i = 0; i < straights.size(); ++i) + { + auto const *st{handle_straight(Niw, i)}; + if (st == nullptr || st->hidden) + { + continue; + } + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(st->x1, st->y1), a) || false == world_to_screen(plan_to_world(st->x2, st->y2), b)) + { + continue; + } + auto const dx{b.x - a.x}; + auto const dy{b.y - a.y}; + auto const len2{dx * dx + dy * dy}; + if (len2 <= 0.0f) + { + continue; + } + auto const t{std::clamp(((Mouse.x - a.x) * dx + (Mouse.y - a.y) * dy) / len2, 0.0f, 1.0f)}; + auto const px{a.x + t * dx}; + auto const py{a.y + t * dy}; + auto const d{std::hypot(Mouse.x - px, Mouse.y - py)}; + if (d <= best) + { + best = d; + OutNiw = static_cast(Niw); + OutStr = static_cast(i); + found = true; + } + } + }; + if (AllNiwelety) + { + for (std::size_t n = 0; n < m_document.niwelety.size(); ++n) + { + consider(n); + } + } + else + { + consider(m_niweleta); + } + return found; +} + void plan_panel::go_to_plan() { auto minx{std::numeric_limits::max()}; diff --git a/application/planpanel.h b/application/planpanel.h index a5d98bfe..581b3c57 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -12,13 +12,19 @@ http://mozilla.org/MPL/2.0/. #include "application/uilayer.h" #include "editor/orthophoto.h" +#include "maj0sted/app/basket_draft.hpp" #include "maj0sted/app/editor_document.hpp" +#include "maj0sted/app/straight_constraints.hpp" +#include "maj0sted/io/scn_export.hpp" #include "maj0sted/web/editor.hpp" +#include "maj0sted/web/ribbon.hpp" + +#include // track layout tool: straights laid down by hand on the scenery itself and the curves fitted into // the gaps between them. the panel holds only the controls - the drawing happens in the viewport, -// which the tool puts into a top-down orthographic view for the purpose. none of the geometry is -// computed here: maj0sted solves the whole document and the panel paints what comes back. +// which the tool puts into a top-down orthographic plan view. none of the geometry is computed +// here: maj0sted solves the whole document and the panel paints what comes back. class plan_panel : public ui_panel { @@ -31,56 +37,134 @@ class plan_panel : public ui_panel private: // methods void render_toolbar(); + void render_draft(); void render_gaps(); - // parameters of a basket curve: the end clothoids and the chain of arcs between them - void render_compound(maj0sted::web::GapFit &Fit); + void render_compound(maj0sted::web::GapFit &Fit, std::size_t const Gap, bool &Dirty); + void fit_compound_to_guides(); + void clear_fit_guides(); void render_storage(); - // asks what kind of scenery is being started, and where on the map it sits + void export_scn(); void render_newmap_dialog(); void render_location_dialog(); - // clears the drawing and sets the point of EPSG:2180 that the scenery's zero stands for void start_map(bool const Georeferenced, double const Originx, double const Originy); - // paints the solved plan over the rendered scenery and takes the clicks that build it void handle_scene(); void draw_on_scene(); - // re-runs the solver over the whole document; cheap enough to do on every edit void solve(); + void apply_constraints(); maj0sted::web::NiweletaSpec *current_niweleta(); maj0sted::web::NiweletaSpec const *current_niweleta() const; - // keeps exactly one fit slot per gap, so a gap's parameters survive edits to its neighbours void align_fits(); - // true when the solver actually produced the fit requested for the given gap bool fit_applied(std::size_t const Gap) const; - // the panel edits a chained polyline, so a vertex is shared by the two straights meeting at it. - // the library allows fully independent straights; that freedom just isn't exposed here yet - std::size_t vertex_count() const; - void vertex_position(std::size_t const Index, double &X, double &Y) const; + // independent anchored straights: each endpoint is its own handle + maj0sted::web::StraightSpec const *handle_straight(std::size_t const Niw, std::size_t const Index) const; void append_vertex(double const X, double const Y); - void move_vertex(std::size_t const Index, double const X, double const Y); void drop_last_vertex(); - // takes the camera to the middle of the drawing + void delete_selected_straight(); + void delete_current_niweleta(); + void clear_selection(); + // hit tests (screen pixels). niw=-1 means "current niweleta only" + bool hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const; + bool hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const; + // compound basket: draggable ends of entry/exit KP, free arcs and between-KP + bool hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const; + void drag_compound_station(double const X, double const Y); + // arc-first draft: continue from a straight end, click appends luk/KP by length + void begin_draft(); + void discard_draft(); + void start_draft_from_end(std::size_t const Str, int const End); + void append_draft_at(double const X, double const Y); + void undo_draft_fragment(); + bool finish_draft(); + bool reopen_draft_from_gap(std::size_t const Gap); + void rebuild_draft_preview(); + bool draft_tip(double &OutX, double &OutY) const; + bool draft_end_pose(double &OutX, double &OutY, double &OutAz) const; + bool hit_draft_station(ImVec2 const &Mouse, int &OutKind, int &OutArc) const; + void drag_draft_station(double const X, double const Y); + bool anchor_draft_to_gap(std::size_t const Gap); + // switches (rozjazdy): a branch leaves a through niweleta without splitting it. clicking the + // through track drops a switch there and starts a branch niweleta pinned to its frog + void add_switch_on(std::size_t const Through, double const Wx, double const Wy); + void render_switches(); void go_to_plan(); + // members maj0sted::app::EditorDocument m_document; std::vector m_solved; - std::size_t m_niweleta{0}; // niweleta being drawn into - int m_draggedvertex{-1}; - bool m_drawing{true}; // whether a click on the scenery lays track or only picks existing points - // first corner of a niweleta has nothing to attach to, so it waits for the second click + std::vector m_junctions; + std::size_t m_niweleta{0}; + + // switch placement: 0 idle, 1 waiting for a click on the through track + int m_pick_switch{0}; + int m_sw_side{0}; // 0 left, 1 right + double m_sw_crossing{9.0}; // skos 1:n + double m_sw_radius{190.0}; // internal arc radius (metres) + + int m_sel_straight{-1}; + int m_sel_end{-1}; + bool m_dragging{false}; + // captured at drag start so Shift keeps the angle while the length changes + double m_drag_fixed_x{0.0}; + double m_drag_fixed_y{0.0}; + double m_drag_ux{1.0}; + double m_drag_uy{0.0}; + + // compound station drag: kind 0=entry KP end, 1=free-arc end, 2=between-KP end, 3=exit KP start + bool m_dragging_cmp{false}; + int m_cmp_gap{-1}; + int m_cmp_kind{-1}; + int m_cmp_arc{-1}; + + bool m_drawing{true}; bool m_pending{false}; double m_pendingx{0.0}; double m_pendingy{0.0}; + + // 0 = idle, 1 = picking parallel reference, 2 = picking skew reference + int m_pick_rel{0}; + // after a reference is chosen, ask for the numeric parameter + bool m_ask_rel_param{false}; + double m_ask_rel_value{4.5}; + // compound: gap being edited; pick mode accumulates guide points, then Fit runs the solver + int m_sel_gap{-1}; + bool m_pick_compound_point{false}; + struct FitGuide + { + double x{0.0}; + double y{0.0}; + }; + std::vector m_fit_guides; + + maj0sted::app::BasketDraft m_draft; + std::vector m_draft_polys; + // 0 idle, 1 pick straight end to continue from + int m_draft_place{0}; + // next click appends: 0 = luk, 1 = KP (wej/między), 2 = KP wyjście (kończy: prosta + kotwica) + int m_draft_append_kind{0}; + double m_draft_r{300.0}; + double m_draft_finish_straight{100.0}; + // 0 idle, 1 click a straight to choose the gap to anchor into + int m_anchor_pick{0}; + bool m_dragging_draft{false}; + int m_draft_kind{-1}; + int m_draft_arc{-1}; + + char m_namebuf[128]{}; + std::string m_status; char m_path[256]{"editor/plan.m0s"}; - // location picker: the point being considered, and the view over the schematic map of Poland + char m_scn_path[256]{"scenery/plan_export.scn"}; + bool m_pickingplace{false}; double m_pickx{0.0}; double m_picky{0.0}; double m_mapviewx{0.0}; double m_mapviewy{0.0}; double m_mapscale{0.0}; - // topographic base map behind the picker, and the orthophoto under the plan itself + editor::wms_image m_topomap{maj0sted::web::WmsConfig::geoportal_topo()}; editor::orthophoto_source m_ortho; + // 1 km topo cells for views wider than a kilometre (orto's 100 m grid is too dense there) + editor::orthophoto_source m_topo{maj0sted::web::WmsConfig::geoportal_topo(), 1000, "topo"}; bool m_showortho{true}; }; diff --git a/editor/turnouts.cpp b/editor/turnouts.cpp new file mode 100644 index 00000000..0d19c897 --- /dev/null +++ b/editor/turnouts.cpp @@ -0,0 +1,30 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "editor/turnouts.h" + +namespace editor +{ + +std::vector const &turnout_presets() +{ + // the everyday PKP rozjazdy zwyczajne. the crossing mark and radius are the defining pair; + // the length the switch spans follows from them and is shown in the editor + static std::vector const presets{ + {"Rz 1:9 R190", 9.0, 190.0}, + {"Rz 1:9 R300", 9.0, 300.0}, + {"Rz 1:12 R300", 12.0, 300.0}, + {"Rz 1:14 R500", 14.0, 500.0}, + {"Rz 1:18,5 R1200", 18.5, 1200.0}, + {"Rz 1:26,5 R2500", 26.5, 2500.0}}; + return presets; +} + +} // namespace editor diff --git a/editor/turnouts.h b/editor/turnouts.h new file mode 100644 index 00000000..ecefdef3 --- /dev/null +++ b/editor/turnouts.h @@ -0,0 +1,33 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include +#include + +namespace editor +{ + +// a standard turnout template: its designation and the defining pair a catalogue entry is built on - +// the crossing mark 1:n (skos) and the radius of the diverging arc. the actual length the switch +// works out to is computed geometrically from these (arc = R * atan(1/n)), shown next to it; the +// exact catalogue length can be pinned later if the Id-1 tables call for a different figure +struct turnout_preset +{ + std::string name; + double crossing_n; + double radius; +}; + +// typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard; +// confirm against Id-1 before treating the derived length as the catalogue length +std::vector const &turnout_presets(); + +} // namespace editor diff --git a/maj0sted/include/maj0sted/app/editor_document.hpp b/maj0sted/include/maj0sted/app/editor_document.hpp index 2a26147c..668a7d59 100644 --- a/maj0sted/include/maj0sted/app/editor_document.hpp +++ b/maj0sted/include/maj0sted/app/editor_document.hpp @@ -5,52 +5,29 @@ #include #include "maj0sted/domain/map_project.hpp" -#include "maj0sted/web/editor.hpp" // NiweletaSpec (the editor's GUI model) +#include "maj0sted/web/editor.hpp" namespace maj0sted::app { -/// The editor's persistable document: the GUI model exactly as the user built -/// it — independent anchored straights plus the parametric fits between them. -/// -/// This is the source of truth for save/restore because it is lossless: it keeps -/// the untrimmed straights and the fit *parameters* (radius, transition lengths, -/// compound arcs, ...), which the resolved domain geometry alone cannot express. struct EditorDocument { std::vector niwelety; + std::vector junctions; + double view_x{0.0}; + double view_y{0.0}; + double view_extent{0.0}; + bool origin_set{false}; + bool georeferenced{false}; + double origin_x{0.0}; + double origin_y{0.0}; }; -/// Best-effort resolved-domain view of @p document: one Niweleta per editor -/// niweleta, keeping the anchored straights (a floating placeholder arc sits in -/// each gap so the alignment stays valid). Used for interop / inspection and so -/// the on-disk file embeds a real maj0sted project. Never throws. [[nodiscard]] maj0sted::domain::MapProject to_map_project(const EditorDocument& document); - -/// Imports a plain domain project into the editor model: every Straight in each -/// niweleta's plan becomes an editor straight (fits are left empty — resolved -/// geometry carries no fit parameters to recover). [[nodiscard]] EditorDocument from_map_project(const maj0sted::domain::MapProject& project); - -/// Serializes @p document to the library's textual format. The document embeds a -/// real maj0sted project (via maj0sted::io::serialize of to_map_project) followed -/// by an `editor` section that captures the straights and fit parameters -/// losslessly. @c deserialize_document restores it exactly. [[nodiscard]] std::string serialize_document(const EditorDocument& document); - -/// Rebuilds an EditorDocument from text produced by @c serialize_document. -/// @throws std::runtime_error on malformed input. [[nodiscard]] EditorDocument deserialize_document(const std::string& text); - -/// The default on-disk project path. Under Emscripten this lives beneath the -/// IDBFS mount point (see web/index.html), so it survives reloads once flushed. [[nodiscard]] std::string default_project_path(); - -/// Serializes @p document and writes it to @p path (creating parent directories). -/// @returns true on success; never throws. bool save_project(const EditorDocument& document, const std::string& path = default_project_path()); - -/// Reads and deserializes the project at @p path, or std::nullopt if the file is -/// absent or unreadable/malformed. Never throws. [[nodiscard]] std::optional load_project( const std::string& path = default_project_path()); diff --git a/maj0sted/include/maj0sted/web/editor.hpp b/maj0sted/include/maj0sted/web/editor.hpp index 84b092d5..93988e8f 100644 --- a/maj0sted/include/maj0sted/web/editor.hpp +++ b/maj0sted/include/maj0sted/web/editor.hpp @@ -13,6 +13,17 @@ struct StraightSpec { /// Internal tangent guide used to solve a terminal curve. It participates /// in fitting but is not rendered as track or exposed as an editor handle. bool hidden{false}; + + /// Live constraint the host reapplies before each solve (same model as the + /// web editor's `par` / `skew`). 0 = none, 1 = parallel offset from another + /// straight, 2 = skew 1:n from another straight. + int rel_kind{0}; + int rel_niw{-1}; ///< index of the reference niweleta + int rel_str{-1}; ///< index of the reference straight + double rel_offset{0.0}; ///< parallel: signed metres (+ = left of travel) + double rel_cot{0.0}; ///< skew: n of a 1:n turnout + int rel_side{1}; ///< skew: +1 left / -1 right of the reference + double rel_length{0.0}; ///< skew: length of the dependent arm }; /// One arc of a compound fit: its radius, its fixed length (ignored for the last @@ -61,6 +72,9 @@ struct NiweletaPolys { /// parameters or geometry) is simply absent here — the library, not the host, /// decides that a failed fit becomes "no fit". The host mirrors this list. std::vector applied_fits; + /// The solved axis as one polyline, in order, with no duplicate join points. + /// Lets a station along the niweleta be resolved to a pose (see pose_at). + std::vector centreline; }; /// Builds each niweleta, fits every requested gap, and returns the sampled plan @@ -69,4 +83,53 @@ struct NiweletaPolys { [[nodiscard]] std::vector solve_project( const std::vector& niwelety); +/// A switch (rozjazd): the point where a branch leaves a through niweleta. It is +/// a relation, not a niweleta — the through track is not split. The switch is +/// described the surveyor's way: a crossing mark 1:n (skos) that fixes the angle +/// the branch leaves at, and an interior curve that is free to be anything — a +/// single arc, or a compound basket (łuk koszowy). @c station marks the switch's +/// theoretical point (środek rozjazdu, where the through and diverging tangent +/// lines cross); the curve is fitted around it and the branch niweleta is pinned +/// to the frog it ends on. The theoretical tangent lengths follow from the curve +/// (see JunctionGeom) — they are read off, not set. +struct Junction { + int through{-1}; ///< index of the through niweleta (not modified) + double station{0.0}; ///< theoretical point: arc length along the through centreline + int side{0}; ///< 0 = diverges left, 1 = diverges right + bool facing{true}; ///< true opens toward increasing station, false back + double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n) + GapFit curve; ///< the internal diverging curve: arc / compound / basket + int branch{-1}; ///< index of the branch niweleta, pinned to the frog +}; + +/// The solved geometry of one switch: the switch point where the branch leaves +/// the through track, the frog it ends on, the diverging curve between them and +/// the theoretical tangent lengths that curve works out to. +struct JunctionGeom { + bool valid{false}; + double px{0.0}, py{0.0}; ///< switch point (diverging leaves the through here) + double fx{0.0}, fy{0.0}; ///< frog: where the branch niweleta is pinned + double fhx{0.0}, fhy{0.0}; ///< unit heading the branch leaves the frog on + double tangent_front{0.0}; ///< theoretical tangent length before the curve + double tangent_back{0.0}; ///< theoretical tangent length after the curve + double length{0.0}; ///< diverging curve length (switch point to frog), metres + /// The diverging curve, as rails ready to draw. + std::vector polylines; +}; + +/// A whole layout solved at once: the niwelety plus every switch. Returned by +/// solve_layout, which additionally pins each branch's start to its frog. +struct LayoutSolution { + std::vector niwelety; + std::vector junctions; ///< index-aligned with the input switches +}; + +/// Solves @p niwelety together with @p junctions: each branch niweleta's start is +/// pinned to its switch's frog before solving, and every switch's diverging arc +/// is computed from the through niweleta's centreline. Never throws; a switch +/// whose through/branch is out of range or whose geometry is degenerate comes +/// back with valid == false. Takes @p niwelety by value because it repins them. +[[nodiscard]] LayoutSolution solve_layout(std::vector niwelety, + const std::vector& junctions); + } // namespace maj0sted::web diff --git a/maj0sted/src/app/editor_document.cpp b/maj0sted/src/app/editor_document.cpp index 9a500855..cec9818f 100644 --- a/maj0sted/src/app/editor_document.cpp +++ b/maj0sted/src/app/editor_document.cpp @@ -144,6 +144,14 @@ std::string serialize_document(const EditorDocument& document) { // ...then the lossless editor section (straights + fit parameters). out += "editor " + std::to_string(document.niwelety.size()) + "\n"; + if (document.view_extent > 0.0) { + out += "eview " + num(document.view_x) + " " + num(document.view_y) + " " + + num(document.view_extent) + "\n"; + } + if (document.origin_set) { + out += "eorigin " + (document.georeferenced ? std::string{"1"} : "0") + " " + + num(document.origin_x) + " " + num(document.origin_y) + "\n"; + } for (const auto& spec : document.niwelety) { out += "eniw " + std::to_string(spec.straights.size()) + " " + std::to_string(spec.fits.size()) + "\n"; @@ -152,6 +160,19 @@ std::string serialize_document(const EditorDocument& document) { out += "estr " + num(s.x1) + " " + num(s.y1) + " " + num(s.x2) + " " + num(s.y2) + " " + (s.hidden ? "1" : "0") + "\n"; } + for (std::size_t i = 0; i < spec.straights.size(); ++i) { + const auto& s = spec.straights[i]; + if (s.rel_kind == 1) { + out += "epar " + std::to_string(i) + " " + + std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) + + " " + num(s.rel_offset) + "\n"; + } else if (s.rel_kind == 2) { + out += "eskew " + std::to_string(i) + " " + + std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) + + " " + num(s.rel_cot) + " " + std::to_string(s.rel_side) + " " + + num(s.rel_length) + "\n"; + } + } for (const auto& f : spec.fits) { // efit2: gap mode radius transition entry_t exit_t [r len trans]* // The variable arc list supersedes the legacy fixed two-arc 'efit'. @@ -166,6 +187,22 @@ std::string serialize_document(const EditorDocument& document) { out += "\n"; } } + // switches: through/station/side/facing/skew/branch, then the internal curve + // inline in the same shape as an efit2 tail (mode radius trans entry exit …) + out += "ejuncs " + std::to_string(document.junctions.size()) + "\n"; + for (const auto& j : document.junctions) { + out += "ejunc " + std::to_string(j.through) + " " + num(j.station) + " " + + std::to_string(j.side) + " " + (j.facing ? std::string{"1"} : "0") + + " " + num(j.crossing_n) + " " + std::to_string(j.branch) + " " + + std::to_string(j.curve.mode) + " " + num(j.curve.radius) + " " + + num(j.curve.transition) + " " + num(j.curve.entry_t) + " " + + num(j.curve.exit_t) + " " + std::to_string(j.curve.arcs.size()); + for (const auto& a : j.curve.arcs) { + out += " " + num(a.radius) + " " + num(a.length) + " " + + num(a.transition_to_next); + } + out += "\n"; + } return out; } @@ -214,6 +251,57 @@ EditorDocument deserialize_document(const std::string& text) { throw std::runtime_error{"maj0sted: expected 'editor '"}; } const long niweleta_count = to_long(header[1]); + // Optional camera/zoom line (absent in older files). + { + std::size_t peek = cursor; + while (peek < lines.size()) { + bool only_ws = true; + for (const char c : lines[peek]) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) { + break; + } + ++peek; + } + if (peek < lines.size()) { + const auto view_tok = tokenize(lines[peek]); + if (view_tok.size() >= 4 && view_tok[0] == "eview") { + next(); + document.view_x = to_double(view_tok[1]); + document.view_y = to_double(view_tok[2]); + document.view_extent = to_double(view_tok[3]); + } + } + // Optional local-zero line (absent in older files). May follow eview. + peek = cursor; + while (peek < lines.size()) { + bool only_ws = true; + for (const char c : lines[peek]) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) { + break; + } + ++peek; + } + if (peek < lines.size()) { + const auto origin_tok = tokenize(lines[peek]); + if (origin_tok.size() >= 4 && origin_tok[0] == "eorigin") { + next(); + document.origin_set = true; + document.georeferenced = to_long(origin_tok[1]) != 0; + document.origin_x = to_double(origin_tok[2]); + document.origin_y = to_double(origin_tok[3]); + } + } + } for (long n = 0; n < niweleta_count; ++n) { const auto eniw = tokenize(next()); if (eniw.size() < 3 || eniw[0] != "eniw") { @@ -238,6 +326,57 @@ EditorDocument deserialize_document(const std::string& text) { StraightSpec{to_double(e[1]), to_double(e[2]), to_double(e[3]), to_double(e[4]), e.size() >= 6 && to_long(e[5]) != 0}); } + // Optional parallel/skew lines sit between straights and fits; older files omit them. + auto apply_relation = [&](const std::vector& e) -> bool { + if (e.empty()) return false; + if (e[0] == "epar" && e.size() >= 5) { + const long idx = to_long(e[1]); + if (idx < 0 || static_cast(idx) >= spec.straights.size()) { + throw std::runtime_error{"maj0sted: bad 'epar' index"}; + } + auto& s = spec.straights[static_cast(idx)]; + s.rel_kind = 1; + s.rel_niw = static_cast(to_long(e[2])); + s.rel_str = static_cast(to_long(e[3])); + s.rel_offset = to_double(e[4]); + return true; + } + if (e[0] == "eskew" && e.size() >= 7) { + const long idx = to_long(e[1]); + if (idx < 0 || static_cast(idx) >= spec.straights.size()) { + throw std::runtime_error{"maj0sted: bad 'eskew' index"}; + } + auto& s = spec.straights[static_cast(idx)]; + s.rel_kind = 2; + s.rel_niw = static_cast(to_long(e[2])); + s.rel_str = static_cast(to_long(e[3])); + s.rel_cot = to_double(e[4]); + s.rel_side = static_cast(to_long(e[5])); + s.rel_length = to_double(e[6]); + return true; + } + return false; + }; + auto peek_tokens = [&]() -> std::vector { + std::size_t i = cursor; + while (i < lines.size()) { + bool only_ws = true; + for (const char c : lines[i]) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) { + return tokenize(lines[i]); + } + ++i; + } + return {}; + }; + while (apply_relation(peek_tokens())) { + next(); // consume the relation line + } for (long i = 0; i < fit_count; ++i) { const auto e = tokenize(next()); GapFit f; @@ -282,6 +421,57 @@ EditorDocument deserialize_document(const std::string& text) { } document.niwelety.push_back(std::move(spec)); } + // Optional switches section (absent in older files); mirrors the eview/eorigin peek. + { + std::size_t peek = cursor; + while (peek < lines.size()) { + bool only_ws = true; + for (const char c : lines[peek]) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) break; + ++peek; + } + if (peek < lines.size()) { + const auto head = tokenize(lines[peek]); + if (head.size() >= 2 && head[0] == "ejuncs") { + next(); // consume the 'ejuncs ' header + const long junction_count = to_long(head[1]); + for (long j = 0; j < junction_count; ++j) { + const auto e = tokenize(next()); + if (e.size() < 12 || e[0] != "ejunc") { + throw std::runtime_error{"maj0sted: bad 'ejunc' line"}; + } + maj0sted::web::Junction junction; + junction.through = static_cast(to_long(e[1])); + junction.station = to_double(e[2]); + junction.side = static_cast(to_long(e[3])); + junction.facing = to_long(e[4]) != 0; + junction.crossing_n = to_double(e[5]); + junction.branch = static_cast(to_long(e[6])); + junction.curve.mode = static_cast(to_long(e[7])); + junction.curve.radius = to_double(e[8]); + junction.curve.transition = to_double(e[9]); + junction.curve.entry_t = to_double(e[10]); + junction.curve.exit_t = to_double(e[11]); + const long arc_count = e.size() >= 13 ? to_long(e[12]) : 0; + for (long a = 0; a < arc_count; ++a) { + const std::size_t base = 13 + static_cast(a) * 3; + if (base + 2 >= e.size()) { + throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"}; + } + junction.curve.arcs.push_back(maj0sted::web::CompoundArcSpec{ + to_double(e[base]), to_double(e[base + 1]), + to_double(e[base + 2])}); + } + document.junctions.push_back(std::move(junction)); + } + } + } + } return document; } diff --git a/maj0sted/src/web/editor.cpp b/maj0sted/src/web/editor.cpp index 953832ff..d8710607 100644 --- a/maj0sted/src/web/editor.cpp +++ b/maj0sted/src/web/editor.cpp @@ -218,11 +218,144 @@ NiweletaPolys build(const NiweletaSpec& spec) { const auto axis = sample_centrelines(spec, straights, fits, result.rendered_straights); + // Flatten the axis into one polyline for station lookups, dropping the join + // points each element repeats at its start. + for (const auto& element : axis) { + for (const auto& p : element.points) { + if (!result.centreline.empty()) { + const auto& last = result.centreline.back(); + if (std::hypot(p.x - last.x, p.y - last.y) < 1e-9) continue; + } + result.centreline.push_back(WebPoint{p.x, p.y}); + } + } + // 3. Presentation: hand the centreline to a renderer. result.polylines = render_rails(axis); return result; } +// The pose (position + unit heading) at arc length @p station along a centreline +// polyline. Clamped to the ends; heading comes from the segment the station falls +// on. Returns false only for a centreline too short to have a direction. +bool pose_at(const std::vector& centreline, double station, Pose& out) { + if (centreline.size() < 2) return false; + + double travelled = 0.0; + for (std::size_t i = 0; i + 1 < centreline.size(); ++i) { + const auto& a = centreline[i]; + const auto& b = centreline[i + 1]; + const double dx = b.x - a.x; + const double dy = b.y - a.y; + const double seg = std::hypot(dx, dy); + if (seg < 1e-12) continue; + + // the last segment also catches any station beyond the centreline's end + if (station <= travelled + seg || i + 2 == centreline.size()) { + const double t = std::clamp((station - travelled) / seg, 0.0, 1.0); + out = Pose{a.x + dx * t, a.y + dy * t, dx / seg, dy / seg}; + return true; + } + travelled += seg; + } + return false; +} + +// Samples a fitted curve chain into centreline elements, starting from @p start. +// Same layout the gap fits go through, so the switch curve is drawn identically. +std::vector sample_curve( + const std::vector& curve, Pose start) { + std::vector axis; + Pose pose = start; + int element_index = 0; + for (const auto& element : curve) { + double k0 = 0.0, k1 = 0.0, length = 0.0; + render::CentrelineElement out; + out.kind = sample_params(element, k0, k1, length); + std::vector pts; + pose = layout_segment(k0, k1, length, pose, &pts); + out.points.reserve(pts.size()); + for (const auto& p : pts) out.points.push_back(render::Point{p.x, p.y}); + out.element_index = element_index++; + out.length = length; + out.radius_start = radius_of(k0); + out.radius_end = radius_of(k1); + axis.push_back(std::move(out)); + } + return axis; +} + +// The diverging curve and frog of one switch, from the solved through niweleta. +// The switch's theoretical point sits at @p station on the through track; two +// tangent lines cross there (the through heading and that heading turned by the +// crossing angle), and the internal curve is fitted to round the corner between +// them — a plain arc, or a compound basket, whatever the switch asks for. The +// branch begins where the curve ends. +JunctionGeom solve_junction(const Junction& junction, + const std::vector& solved) { + JunctionGeom geom; + if (junction.through < 0 || + static_cast(junction.through) >= solved.size()) { + return geom; + } + if (junction.crossing_n <= 0.0) return geom; + + Pose theoretical{}; + if (!pose_at(solved[junction.through].centreline, junction.station, + theoretical)) { + return geom; + } + + // a trailing switch opens against the running direction: flip the heading + double thx = theoretical.hx; + double thy = theoretical.hy; + if (!junction.facing) { + thx = -thx; + thy = -thy; + } + + // the diverging tangent line: the through heading turned by the crossing + // angle, left (positive) or right (negative) + const double angle = + (junction.side == 0 ? 1.0 : -1.0) * std::atan(1.0 / junction.crossing_n); + const double dhx = thx * std::cos(angle) - thy * std::sin(angle); + const double dhy = thx * std::sin(angle) + thy * std::cos(angle); + + // two long guide straights meeting at the theoretical point X; the fitter + // rounds the corner and hands back the tangent points that become the switch + // start and the frog. long enough that the tangent points land on them. + const double guide = 5000.0; + const CartesianPosition x{theoretical.x, theoretical.y}; + Straight entry{CartesianPosition{x.x() - thx * guide, x.y() - thy * guide}, x}; + Straight exit{x, CartesianPosition{x.x() + dhx * guide, x.y() + dhy * guide}}; + + const auto request = to_request(junction.curve); + if (!request) return geom; // a switch with no curve is not a switch + const auto connection = GapFitter::connect(entry, exit, *request); + if (!connection) return geom; + + const FitResult& fit = connection->fit; + const CartesianPosition s = fit.tangent_in; // switch start + const CartesianPosition f = fit.tangent_out; // frog + + geom.valid = true; + geom.px = s.x(); + geom.py = s.y(); + geom.fx = f.x(); + geom.fy = f.y(); + geom.fhx = dhx; + geom.fhy = dhy; + // theoretical tangent lengths: from the crossing point to each tangent point + geom.tangent_front = std::hypot(s.x() - x.x(), s.y() - x.y()); + geom.tangent_back = std::hypot(f.x() - x.x(), f.y() - x.y()); + + const auto axis = sample_curve(fit.curve, Pose{s.x(), s.y(), thx, thy}); + for (const auto& element : axis) geom.length += element.length; + geom.polylines = render_rails(axis); + + return geom; +} + } // namespace std::vector solve_project(const std::vector& niwelety) { @@ -232,4 +365,44 @@ std::vector solve_project(const std::vector& niwele return result; } +LayoutSolution solve_layout(std::vector niwelety, + const std::vector& junctions) { + // Pass 1: solve unpinned, to read the through centrelines the frogs sit on. + auto solved = solve_project(niwelety); + + // Lock each branch's leaving straight to the frog: it starts at the frog and + // runs along the frog heading, so the branch is tangent to the switch curve + // no matter what the user draws. Only its length is theirs — the projection of + // wherever they dragged its far end onto the frog heading. Because the first + // straight is pinned tangent, any fit in the branch's first gap (a plain arc + // or a łuk koszowy) meets the switch tangentially by construction. + for (const auto& junction : junctions) { + const auto geom = solve_junction(junction, solved); + if (!geom.valid) continue; + if (junction.branch < 0 || + static_cast(junction.branch) >= niwelety.size()) { + continue; + } + auto& branch = niwelety[junction.branch]; + if (branch.straights.empty()) continue; + auto& first = branch.straights.front(); + double length = (first.x2 - geom.fx) * geom.fhx + (first.y2 - geom.fy) * geom.fhy; + if (!(length > 1.0)) length = 1.0; // keep a usable minimum, also catches NaN + first.x1 = geom.fx; + first.y1 = geom.fy; + first.x2 = geom.fx + geom.fhx * length; + first.y2 = geom.fy + geom.fhy * length; + } + + // Pass 2: solve with the branches pinned, then read the switches off the + // final centrelines (the through track may itself be a pinned branch). + LayoutSolution out; + out.niwelety = solve_project(niwelety); + out.junctions.reserve(junctions.size()); + for (const auto& junction : junctions) { + out.junctions.push_back(solve_junction(junction, out.niwelety)); + } + return out; +} + } // namespace maj0sted::web From 707b4de2f45bef4b6ebbd9edb3dc09225a9fa08c Mon Sep 17 00:00:00 2001 From: maj00r Date: Sat, 1 Aug 2026 19:55:33 +0200 Subject: [PATCH 12/17] Editor checkpoint: turnout domain service, web->editor rename, Id-1 templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint of the editor branch so work isn't carried loose across a branch switch. Builds. - maj0sted turnout geometry moved into the domain: domain::lay_turnout lays the diverging path (pre-blade lead, blade at beta, arc of alfa-beta, frog rail) and solve_junction is a thin adapter onto it. - the maj0sted web/ tree renamed to editor/ (namespace and paths) - there is no WASM here, it's plain native C++ the editor calls. - click places the switch at its start (początek rozjazdu); the branch's leaving straight is locked tangent to the frog; switches can be slid along the through track and flipped L/P. - Polish turnout templates carry catalogue length (Id-1-consistent lengths), plus blade-angle and pre-blade fields (left 0 until the catalogue figures are in). - TURNOUT_SPEC.md: the Bezier-based runtime/model contract. - also folds in other pending branch changes (scenery, tests) to leave a clean tree. --- TURNOUT_SPEC.md | 176 ++++++++ application/application.cpp | 70 ++-- application/editormode.cpp | 30 +- application/planpanel.cpp | 233 ++++++++--- application/planpanel.h | 28 +- application/scenarioloadermode.cpp | 10 + editor/orthophoto.cpp | 76 ++-- editor/orthophoto.h | 49 ++- editor/turnouts.cpp | 22 +- editor/turnouts.h | 7 +- maj0sted/CMakeLists.txt | 12 +- maj0sted/HANDOFF.md | 8 +- .../include/maj0sted/app/basket_draft.hpp | 58 +++ .../include/maj0sted/app/editor_document.hpp | 6 +- .../maj0sted/app/straight_constraints.hpp | 12 + .../maj0sted/domain/geometry/turnout.hpp | 85 ++++ .../parallelism/parallelism_service.hpp | 4 + .../maj0sted/{web => editor}/editor.hpp | 13 +- .../maj0sted/{web => editor}/ribbon.hpp | 8 +- .../maj0sted/{web => editor}/solve.hpp | 17 +- .../maj0sted/{web => editor}/tile_cache.hpp | 60 ++- maj0sted/include/maj0sted/io/scn_export.hpp | 33 ++ maj0sted/src/app/basket_draft.cpp | 389 ++++++++++++++++++ maj0sted/src/app/editor_document.cpp | 25 +- maj0sted/src/app/straight_constraints.cpp | 112 +++++ maj0sted/src/domain/parallelism_service.cpp | 25 ++ maj0sted/src/domain/turnout.cpp | 127 ++++++ maj0sted/src/{web => editor}/editor.cpp | 148 +++---- maj0sted/src/{web => editor}/ribbon.cpp | 14 +- maj0sted/src/{web => editor}/solve.cpp | 16 +- maj0sted/src/{web => editor}/tile_cache.cpp | 129 ++---- maj0sted/src/io/scn_export.cpp | 367 +++++++++++++++++ maj0sted/tests/CMakeLists.txt | 8 +- maj0sted/tests/test_document.cpp | 29 +- .../tests/{test_web.cpp => test_editor.cpp} | 12 +- maj0sted/tests/test_tile_cache.cpp | 4 +- scene/scenenodegroups.cpp | 10 + scene/scenenodegroups.h | 4 + scene/scenerybinary_format.md | 228 ++++++++++ simulation/simulationstateserializer.cpp | 4 + world/Track.cpp | 27 -- 41 files changed, 2224 insertions(+), 471 deletions(-) create mode 100644 TURNOUT_SPEC.md create mode 100644 maj0sted/include/maj0sted/app/basket_draft.hpp create mode 100644 maj0sted/include/maj0sted/app/straight_constraints.hpp create mode 100644 maj0sted/include/maj0sted/domain/geometry/turnout.hpp rename maj0sted/include/maj0sted/{web => editor}/editor.hpp (94%) rename maj0sted/include/maj0sted/{web => editor}/ribbon.hpp (83%) rename maj0sted/include/maj0sted/{web => editor}/solve.hpp (78%) rename maj0sted/include/maj0sted/{web => editor}/tile_cache.hpp (73%) create mode 100644 maj0sted/include/maj0sted/io/scn_export.hpp create mode 100644 maj0sted/src/app/basket_draft.cpp create mode 100644 maj0sted/src/app/straight_constraints.cpp create mode 100644 maj0sted/src/domain/turnout.cpp rename maj0sted/src/{web => editor}/editor.cpp (78%) rename maj0sted/src/{web => editor}/ribbon.cpp (83%) rename maj0sted/src/{web => editor}/solve.cpp (88%) rename maj0sted/src/{web => editor}/tile_cache.cpp (70%) create mode 100644 maj0sted/src/io/scn_export.cpp rename maj0sted/tests/{test_web.cpp => test_editor.cpp} (96%) create mode 100644 scene/scenerybinary_format.md diff --git a/TURNOUT_SPEC.md b/TURNOUT_SPEC.md new file mode 100644 index 00000000..adbafbab --- /dev/null +++ b/TURNOUT_SPEC.md @@ -0,0 +1,176 @@ +# Rozjazd — kontrakt geometrii dla runtime i modeli + +Jak edytor przekazuje rozjazd, a runtime ustawia z tego elementy. Podstawą są +**krzywe Béziera** (kubiczne). To jest warstwa graniczna: wewnątrz edytor liczy +łuki i klotoidy (kolejowo dokładne), a na wyjściu przybliża je Bézierami z +zadaną tolerancją. Runtime nie zna „typów" rozjazdów — dostaje instancję i sadzi +po niej elementy. + +## Zasada + +Runtime **nie bazuje na szablonie** (Rz 1:9 R190 itd.). Szablony to tylko preset +w edytorze, który wypełnia liczby. Każdy rozjazd to instancja o dowolnej +geometrii (własny skos, promień, przesunięty początek, łuk koszowy w środku). +Runtime dostaje **rozwiązaną geometrię** i z niej wszystko wyprowadza. + +## Układ i jednostki + +- metry; krzywe w XY (płaszczyzna planu), +- rozstaw toru 1435 mm → półrozstaw **0,7175 m**, +- pozy podawane jako pozycja + jednostkowy kierunek `(hx, hy)` (styczna, „w + kierunku jazdy"). Normalna lewa = `(-hy, hx)`, +- każdy element modelu ma origin w punkcie wstawienia, **+X = kierunek jazdy**, + **+Y = w lewo**, **+Z = w górę**. Runtime wstawia go podając tylko pozę — + żadnego per-model obrotu. + +## Instancja rozjazdu + +``` +Turnout { + side : Left | Right // strona odgałęzienia (mirror) + crossing_mark : n // skos 1:n -> dobór odlewu krzyżownicy + rail_profile : "49E1" | "60E1" // -> dobór iglic / profilu szyny + gauge : 1.435 + + PR : Pose // początek rozjazdu: styczny do toru zasadniczego + KR : Pose // koniec (krzyżownica): kierunek = tor zasadniczy obrócony o alfa + crossing_angle : alfa = atan(1/n) // radiany + + blade_angle : beta // kąt nagięcia iglicy (kąt zwrotnicowy) + pre_blade_length : d // odcinek przediglicowy: prosty, PR -> ostrze iglicy + + through_axis : [Bezier...] // tor zasadniczy przez rozjazd (zwykle prosty) + diverging_axis: [Bezier...] // przediglicowy -> iglica(prosta @ beta) -> luk -> prosta -> KR +} + +Pose { x, y, hx, hy } // pozycja + jednostkowa styczna +Bezier { p0, p1, p2, p3 } // kubiczna, punkty kontrolne w XY (metry) +``` + +Tor zasadniczy nie jest dzielony — `through_axis` jest podany dla kompletności +(runtime często ma już tor główny). Rozjazd wnosi przede wszystkim +`diverging_axis` + osprzęt + podrozjazdnice spinające oba tory. + +## Początek odnogi: odcinek przediglicowy + iglica + +Odnoga **nie zaczyna się łukiem**. To zwrotnica sieczna z iglicą prostą: + +1. **odcinek przediglicowy** — prosta w kierunku toru zasadniczego (kąt 0), od + `PR` do ostrza iglicy. Tu rozjazd jest jeszcze równoległy do toru głównego; + tu siedzi napęd. +2. **iglica** — **prosta odcięta pod kątem `beta`** („prosta dogięta do + prostego"). Odnoga odchodzi od toru zasadniczego dopiero tutaj, o kąt nagięcia + iglicy `beta`. +3. **łuk** — promień `R`, skręca od `beta` do kąta krzyżownicy `alfa`, czyli o + **`alfa − beta`** (nie o całe `alfa`). +4. **prosta krzyżownicowa** — pod kątem `alfa`, do `KR`. + +Stąd `diverging_axis` = `[przediglicowy] [iglica @ beta] [łuk] [prosta] `. Iglice +łukowe (styczne, `beta ≈ 0`, ostrze wtopione w łuk) to inny wariant — na razie +modelujemy iglicę prostą. + +**Model:** zespół iglic kotwiony w ostrzu (`PR` + `pre_blade_length` wzdłuż toru +głównego), zorientowany wzdłuż toru zasadniczego, nadaje `beta`; długość iglicy +jest po stronie modelu. + +## Bézier — definicja i próbkowanie + +Kubiczna: + +``` +B(t) = (1-t)^3 p0 + 3(1-t)^2 t p1 + 3(1-t) t^2 p2 + t^3 p3, t in [0,1] +B'(t) = 3(1-t)^2 (p1-p0) + 6(1-t)t (p2-p1) + 3t^2 (p3-p2) +``` + +- **pozycja** = `B(t)`, **kierunek** = `normalize(B'(t))`, `heading = atan2(B'y, B'x)`. +- styczna na końcach: w `t=0` wzdłuż `p1-p0`, w `t=1` wzdłuż `p3-p2` — dlatego + uchwyty `p1`, `p2` leżą **na stycznych toru** w węzłach, +- kolejne segmenty są **G1** (styczne się zgadzają) — geometria edytora jest + styczna z konstrukcji, więc łączenia są gładkie. + +**Uwaga o parametryzacji:** `t` **nie jest** długością łuku. Do równego rozstawu +podrozjazdnic/podkładów trzeba reparametryzować po długości: całkować `|B'(t)|` +(albo próbkować adaptacyjnie) i szukać `t` dla zadanej odległości. Nie rozstawiaj +elementów po stałym kroku `t`. + +## Skąd biorą się Béziery (aproksymacja) + +**Reguła: jeden element osi = jeden Bézier.** Bez dzielenia. Węzły wyznaczają +`p0`/`p3` i styczne w węzłach, uchwyty leżą na tych stycznych: + +- **prosta** → `p1`, `p2` na linii między `p0` a `p3` (uchwyty w 1/3 długości). +- **łuk** kąta `theta`, promień `R` → uchwyty wzdłuż stycznych o długości + + ``` + k = (4/3) * R * tan(theta/4) + ``` + + Dla kątów rozjazdowych (`theta < 8°`) błąd jest rzędu ułamka milimetra. +- **klotoida** (krzywa przejściowa, jeśli użyta) → też **jeden Bézier**: `p0`, + `p3` i styczne z końców klotoidy, uchwyty na stycznych w 1/3 cięciwy. Kubiczna + nie odda liniowo zmiennej krzywizny co do joty, ale przy długościach + przejściówek w rozjeździe odchyłka jest mała i to akceptujemy — jeden segment, + bez podziału. + +Styczne w węzłach są wspólne dla sąsiednich elementów, więc Béziery łączą się G1. + +## Szyny — krzywe przesunięte + +Szyny to osie przesunięte o `±0,7175 m` wzdłuż normalnej lewej. **Nie** trzymaj +ich jako osobnych Bézierów — przesunięcie Béziera nie jest Bézierem. Runtime +próbkuje pozę osi i offsetuje per-próbka: + +``` +lewa = B(t) + (-hy, hx) * 0.7175 +prawa = B(t) - (-hy, hx) * 0.7175 +``` + +Na promieniach rozjazdowych rozjeżdżanie się offsetu jest pomijalne. + +## Podział elementów + +**Ciągłe — sadzone z pozy wzdłuż osi (zero zależności od typu):** + +- **szyny** — po obu osiach, offset jak wyżej; potrzebny profil przekroju, +- **podrozjazdnice** — po długości (reparametryzacja!), prostopadle do + dwusiecznej obu osi, wydłużane w strefie iglic i krzyżownicy tak, by objąć oba + tory. Parametryczne (skalowana długość) albo zestaw dyskretny — do ustalenia. + +**Dyskretne odlewy — dobierane parametrem, nie nazwą szablonu:** + +- **krzyżownica** — fizyczny odlew o kącie `alfa`; dobierana po **`crossing_mark` + (skos 1:n)**, wstawiana w `KR`, ustawiona pod `crossing_angle`, +- **iglice (zwrotnica)** — dobierane po **`rail_profile`** (i ew. `R`), wstawiane + w `PR`, +- **kierownice** — dowiązane do `KR`, +- **napęd + latarnia** — w `PR`, odsunięte w bok o ustalony offset. + +Runtime: geometrię szyn/podrozjazdnic robi z Bézierów, a krzyżownicę i iglice +**wybiera po (`crossing_mark`, `rail_profile`)** i wstawia w `PR`/`KR`. + +## Iglice — stany + +Rozjazd się przekłada. Iglice potrzebują dwóch stanów (na wprost / na bok) albo +animacji przełożenia — do ustalenia z modelarzem. Pozycja bazowa: `PR`. + +## Do ustalenia z modelarzem + +1. origin w pionie: główka szyny czy spód podkładu, +2. profil: 49E1 / 60E1 / oba, +3. podrozjazdnice: parametryczne czy zestaw dyskretny, +4. iglice: ruchome (dwa stany / animacja) czy statyczne, +5. które elementy bierze modelarz, a które generuje runtime. + +## Do zrobienia po stronie edytora + +`JunctionGeom` ma dziś `PR (px,py)`, `KR (fx,fy)`, kierunek wyjścia `(fhx,fhy)`, +długość i osie w postaci polilinii. Do kontraktu trzeba dołożyć: + +- **`crossing_mark` i `rail_profile`** w danych eksportu, +- **`blade_angle` (beta) i `pre_blade_length`** — dziś `lay_turnout` kładzie łuk + styczny od `PR` (beta = 0, brak przediglicowego). Trzeba: odcinek przediglicowy + (prosty), iglicę prostą pod `beta`, łuk o `alfa − beta`, prostą do `KR`, +- te dwa parametry **w szablonach** (`turnout_preset`) per typ, +- osie jako **Béziery** (dziś polilinie) — konwersja łuk/klotoida → kubiczne wg + powyższego, +- jawne oznaczenie „to rozjazd" (nie zwykły łuk), z `PR`/`KR`/`alfa`. diff --git a/application/application.cpp b/application/application.cpp index 37a8bab6..c647438c 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -218,77 +218,74 @@ void eu07_application::DiscordRPCService() discord_rpc.startTimestamp = static_cast(now_c); discord_rpc.largeImageText = "MaSzyna"; - // run loop - while (!glfwWindowShouldClose(m_windows.front()) && !m_modestack.empty() && !Global.applicationQuitOrder) + // run until quit — do NOT require a non-empty mode stack: pop/push during scenery reload + // briefly empties the stack, and exiting here would Discord_Shutdown() mid-session (or race on top()) + while (!Global.applicationQuitOrder && !m_windows.empty() && !glfwWindowShouldClose(m_windows.front())) { - auto currentMode = m_modestack.top(); + if (m_modestack.empty()) + { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + + auto const currentMode = m_modestack.top(); if (currentMode == mode::launcher) { - // in launcher mode - discord_rpc.state = Translations.lookup_c("In main menu"); discord_rpc.details = Translations.lookup_c("Browsing scenarios..."); discord_rpc.largeImageKey = ""; discord_rpc.largeImageText = "MaSzyna"; - // RPC upload Discord_UpdatePresence(&discord_rpc); - - std::this_thread::sleep_for(std::chrono::milliseconds(5000)); // update RPC every 5 secs - continue; } else if (currentMode == mode::scenarioloader) { std::string rpcScnName = Global.SceneryFile; - if (rpcScnName[0] == '$') + if (!rpcScnName.empty() && rpcScnName.front() == '$') rpcScnName.erase(0, 1); - rpcScnName.erase(rpcScnName.size() - 4, 4); - if (rpcScnName.find('_') != std::string::npos) - { - std::replace(rpcScnName.begin(), rpcScnName.end(), '_', ' '); - } + if (rpcScnName.size() >= 4) + rpcScnName.erase(rpcScnName.size() - 4, 4); + std::replace(rpcScnName.begin(), rpcScnName.end(), '_', ' '); - // realworld timestamp from datetime - static std::string state = Translations.lookup_s("Scenery: ") + rpcScnName; + static std::string state; + state = Translations.lookup_s("Scenery: ") + rpcScnName; discord_rpc.state = state.c_str(); discord_rpc.details = Translations.lookup_c("Loading scenery..."); discord_rpc.largeImageKey = "logo"; discord_rpc.largeImageText = "MaSzyna"; - - // RPC upload Discord_UpdatePresence(&discord_rpc); - - std::this_thread::sleep_for(std::chrono::milliseconds(5000)); // update RPC every 5 secs - continue; } - - if (currentMode != mode::driver) - continue; - - // Discord RPC updater - if (simulation::is_ready) + else if (currentMode == mode::editor) + { + discord_rpc.state = "Dłubie w edytorze"; + discord_rpc.details = Global.SceneryFile.c_str(); + discord_rpc.largeImageKey = "logo"; + discord_rpc.largeImageText = "MaSzyna"; + discord_rpc.smallImageKey = ""; + discord_rpc.smallImageText = ""; + Discord_UpdatePresence(&discord_rpc); + } + else if (currentMode == mode::driver && simulation::is_ready) { std::string PlayerVehicle; - if (simulation::Train != nullptr) + if (simulation::Train != nullptr && simulation::Train->Dynamic() != nullptr) { PlayerVehicle = simulation::Train->name(); - // make to upper for (auto &c : PlayerVehicle) - c = toupper(c); + c = static_cast(toupper(static_cast(c))); PlayerVehicle = Translations.lookup_s("Driving: ") + PlayerVehicle; discord_rpc.details = PlayerVehicle.c_str(); - uint16_t playerTrainVelocity = simulation::Train->Dynamic()->GetVelocity(); + auto const playerTrainVelocity = static_cast(simulation::Train->Dynamic()->GetVelocity()); if (playerTrainVelocity > 1) { - // ikonka ze jedziemy i nie spimy discord_rpc.smallImageKey = "driving"; - std::string smallText = Translations.lookup_s("Speed: ") + std::to_string(playerTrainVelocity) + " km/h"; + static std::string smallText; + smallText = Translations.lookup_s("Speed: ") + std::to_string(playerTrainVelocity) + " km/h"; discord_rpc.smallImageText = smallText.c_str(); } else { - // krecimy postoj discord_rpc.smallImageKey = "halt"; discord_rpc.smallImageText = Translations.lookup_c("Stopped"); } @@ -296,7 +293,8 @@ void eu07_application::DiscordRPCService() Discord_UpdatePresence(&discord_rpc); } - std::this_thread::sleep_for(std::chrono::milliseconds(5000)); // update RPC every 5 secs + + std::this_thread::sleep_for(std::chrono::milliseconds(5000)); } Discord_Shutdown(); diff --git a/application/editormode.cpp b/application/editormode.cpp index 486278fd..666170e9 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -617,6 +617,7 @@ bool editor_mode::update() Global.editor_reset_scenery = false; Global.editor_startup = true; // the editor is the base mode from here on, whatever started it Global.SceneryFile = "pusta.scn"; + simulation::is_ready = false; Application.pop_mode(); Application.push_mode(eu07_application::mode::scenarioloader); return true; @@ -1326,9 +1327,29 @@ void editor_mode::on_scroll(double const Xoffset, double const Yoffset) { return; } - // in the plan view the wheel decides how much ground fits on screen; the camera itself stays put, - // because with no perspective its height changes nothing but the clipping - Global.editor_ortho_extent = std::clamp(Global.editor_ortho_extent * std::pow(0.85f, static_cast(Yoffset)), 5.0f, 20000.0f); + + // the wheel decides how much ground fits on screen. shift the camera so the point under the + // cursor stays put - same idea as zooming a map, rather than scaling around the view centre + auto const oldextent = Global.editor_ortho_extent; + auto const newextent = std::clamp(oldextent * std::pow(0.85f, static_cast(Yoffset)), 5.0f, 20000.0f); + if (newextent == oldextent) + { + return; + } + + auto const width = std::max(1, Global.window_size.x); + auto const height = std::max(1, Global.window_size.y); + auto const ndcx = (static_cast(Global.cursor_pos.x) / width) * 2.0 - 1.0; + auto const ndcy = 1.0 - (static_cast(Global.cursor_pos.y) / height) * 2.0; + auto const halfheight = static_cast(oldextent); + auto const halfwidth = halfheight * static_cast(width) / static_cast(height); + auto const scale = static_cast(newextent) / static_cast(oldextent); + + // top-down with yaw pinned to north: view x follows world x, view y follows -world z + Camera.Pos.x += ndcx * halfwidth * (1.0 - scale); + Camera.Pos.z -= ndcy * halfheight * (1.0 - scale); + + Global.editor_ortho_extent = newextent; } void editor_mode::update_camera(double const Deltatime) @@ -1346,6 +1367,9 @@ void editor_mode::update_camera(double const Deltatime) } Camera.Angle.x = glm::radians(-90.0f); Camera.Angle.z = 0.0f; + // a plan is read with north up. the editor's own camera starts turned around, which left the + // whole view - scenery, imagery and drawing alike - standing on its head + Camera.Angle.y = 0.0f; // high enough to clear anything the scenery may have under the cursor Camera.Pos.y = std::max(Camera.Pos.y, 500.0); } diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 1c45aee2..09bd0b0c 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -16,7 +16,7 @@ http://mozilla.org/MPL/2.0/. #include "rendering/renderer.h" #include "utilities/Globals.h" -#include "maj0sted/web/ribbon.hpp" +#include "maj0sted/editor/ribbon.hpp" #include #include @@ -94,9 +94,9 @@ ImU32 element_colour(int const Kind, bool const Active) // one centreline element recovered from a left/right rail pair // dual rails from a centreline, same 1.5 m gauge as maj0sted::render::RailRenderer -std::vector rails_from_centreline(std::vector const &Centre, double const Gauge = 1.5) +std::vector rails_from_centreline(std::vector const &Centre, double const Gauge = 1.5) { - std::vector out; + std::vector out; auto const half{Gauge * 0.5}; for (auto const &poly : Centre) { @@ -106,7 +106,7 @@ std::vector rails_from_centreline(std::vector::infinity()}; for (auto const &poly : Solved.polylines) @@ -257,9 +257,9 @@ struct GapSample }; // centreline of a fitted gap, sampled in element order -std::vector sample_gap_centreline(maj0sted::web::NiweletaPolys const &Solved, int const Gap) +std::vector sample_gap_centreline(maj0sted::editor::NiweletaPolys const &Solved, int const Gap) { - std::map by_el; + std::map by_el; for (auto const &poly : Solved.polylines) { if (poly.gap != Gap || poly.element_index < 0 || poly.points.size() < 2) @@ -378,7 +378,7 @@ struct CmpStationHandle double y{0.0}; }; -std::vector compound_station_handles(maj0sted::web::GapFit const &Fit, maj0sted::web::NiweletaPolys const &Solved, int const Gap) +std::vector compound_station_handles(maj0sted::editor::GapFit const &Fit, maj0sted::editor::NiweletaPolys const &Solved, int const Gap) { std::vector handles; if (Fit.mode != 3 || Fit.arcs.empty()) @@ -420,7 +420,7 @@ std::vector compound_station_handles(maj0sted::web::GapFit con return handles; } -std::vector sample_draft_centreline(std::vector const &Polys) +std::vector sample_draft_centreline(std::vector const &Polys) { std::vector out; double along{0.0}; @@ -444,7 +444,7 @@ std::vector sample_draft_centreline(std::vector draft_station_handles(maj0sted::app::BasketDraft const &Draft, std::vector const &Polys) +std::vector draft_station_handles(maj0sted::app::BasketDraft const &Draft, std::vector const &Polys) { std::vector handles; if (false == Draft.active || Draft.arcs.empty()) @@ -707,6 +707,7 @@ void plan_panel::handle_scene() m_dragging = false; m_dragging_cmp = false; m_dragging_draft = false; + m_drag_junction = -1; return; } @@ -721,7 +722,16 @@ void plan_panel::handle_scene() { int hit_niw{-1}; int hit_str{-1}; - if (hit_straight(mouse, hit_niw, hit_str, true) && hit_niw >= 0 && static_cast(hit_niw) < m_document.niwelety.size()) + // straights catch only their trimmed remainders; fall back to the whole solved axis (curves + // included) so a double-click anywhere on the visible track still selects its niweleta + bool hit{hit_straight(mouse, hit_niw, hit_str, true)}; + if (false == hit) + { + hit_niw = nearest_niweleta_axis(mouse, 18.0f); + hit_str = -1; + hit = hit_niw >= 0; + } + if (hit && hit_niw >= 0 && static_cast(hit_niw) < m_document.niwelety.size()) { m_dragging = false; m_dragging_cmp = false; @@ -875,6 +885,26 @@ void plan_panel::handle_scene() return; } + // grabbing a switch's start to slide it along the through track + for (std::size_t j = 0; j < m_junctions.size(); ++j) + { + if (false == m_junctions[j].valid) + { + continue; + } + ImVec2 sw; + if (world_to_screen(plan_to_world(m_junctions[j].px, m_junctions[j].py), sw) && std::hypot(sw.x - mouse.x, sw.y - mouse.y) <= 9.0f) + { + clear_selection(); + m_dragging_cmp = false; + m_dragging_draft = false; + m_drag_junction = static_cast(j); + m_drawing = false; + m_status = "przesuwanie rozjazdu"; + return; + } + } + int hit_niw{-1}; int hit_str{-1}; int hit_end{-1}; @@ -916,7 +946,25 @@ void plan_panel::handle_scene() } } - if (m_dragging_draft) + if (m_drag_junction >= 0) + { + if (ImGui::IsMouseDown(0) && static_cast(m_drag_junction) < m_document.junctions.size()) + { + auto &junction{m_document.junctions[static_cast(m_drag_junction)]}; + double station{junction.station}; + // slide along the through track: the cursor's nearest station on its centreline + if (junction.through >= 0 && station_on_through(static_cast(junction.through), cursorx, cursory, station)) + { + junction.station = station; + solve(); + } + } + else + { + m_drag_junction = -1; + } + } + else if (m_dragging_draft) { if (ImGui::IsMouseDown(0)) { @@ -997,9 +1045,9 @@ void plan_panel::draw_on_scene() auto const display{ImGui::GetIO().DisplaySize}; auto const halfheight{static_cast(Global.editor_ortho_extent)}; auto const halfwidth{halfheight * std::max(1.0f, display.x) / std::max(1.0f, display.y)}; - maj0sted::web::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; + maj0sted::editor::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; - auto const draw_box = [&](maj0sted::web::TileBBox const &Box, unsigned int const Texture) { + auto const draw_box = [&](maj0sted::editor::TileBBox const &Box, unsigned int const Texture) { ImVec2 corners[4]; auto const ok = world_to_screen(plan_to_world(Box.min_x, Box.max_y), corners[0]) && world_to_screen(plan_to_world(Box.max_x, Box.max_y), corners[1]) && world_to_screen(plan_to_world(Box.max_x, Box.min_y), corners[2]) && world_to_screen(plan_to_world(Box.min_x, Box.min_y), corners[3]); @@ -1114,7 +1162,7 @@ void plan_panel::draw_on_scene() } for (auto const gap : gaps) { - std::map by_el; + std::map by_el; for (auto const &poly : solved.polylines) { if (poly.gap != gap || poly.points.size() < 2 || poly.element_index < 0) @@ -1127,7 +1175,7 @@ void plan_panel::draw_on_scene() } } - std::vector arcs; + std::vector arcs; for (auto const &[idx, poly] : by_el) { (void)idx; @@ -1855,7 +1903,7 @@ void plan_panel::render_gaps() } } -void plan_panel::render_compound(maj0sted::web::GapFit &Fit, std::size_t const Gap, bool &Dirty) +void plan_panel::render_compound(maj0sted::editor::GapFit &Fit, std::size_t const Gap, bool &Dirty) { ImGui::Indent(); @@ -2189,7 +2237,7 @@ void plan_panel::fit_compound_to_guides() { return std::numeric_limits::infinity(); } - auto const solved{maj0sted::web::solve_project(trial.niwelety)}; + auto const solved{maj0sted::editor::solve_project(trial.niwelety)}; if (m_niweleta >= solved.size()) { return std::numeric_limits::infinity(); @@ -2288,13 +2336,21 @@ void plan_panel::render_switches() { m_sw_crossing = preset.crossing_n; m_sw_radius = preset.radius; + m_sw_length = preset.length; } } ImGui::EndCombo(); } - // each single-arc template spans R*atan(1/n) between its tangent points; shown so its length is known + // the template's catalogue length (PR->KR); custom switches with no template just span their curve ImGui::SameLine(); - ImGui::TextDisabled("dl. ~%.1f m", m_sw_radius * std::atan(1.0 / std::max(1.0, m_sw_crossing))); + if (m_sw_length > 0.0) + { + ImGui::TextDisabled("dl. %.3f m (Id-1)", m_sw_length); + } + else + { + ImGui::TextDisabled("dl. ~%.1f m (sam luk)", m_sw_radius * std::atan(1.0 / std::max(1.0, m_sw_crossing))); + } ImGui::SetNextItemWidth(90.0f); ImGui::Combo("strona", &m_sw_side, "w lewo\0w prawo\0"); @@ -2354,6 +2410,12 @@ void plan_panel::render_switches() ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "nie wpasowany"); } ImGui::SameLine(); + if (ImGui::SmallButton(junction.side == 0 ? "L->P" : "P->L")) + { + junction.side = junction.side == 0 ? 1 : 0; + solve(); + } + ImGui::SameLine(); if (ImGui::SmallButton("usuń")) { m_document.junctions.erase(m_document.junctions.begin() + static_cast(i)); @@ -2373,9 +2435,12 @@ void plan_panel::render_switches() junction.curve.mode = 3; if (junction.curve.arcs.empty()) { - // a basket needs at least two arcs; the last absorbs the rest of the turn - junction.curve.arcs.push_back({junction.curve.radius > 0.0 ? junction.curve.radius * 2.0 : 600.0, 40.0, 0.0}); - junction.curve.arcs.push_back({junction.curve.radius > 0.0 ? junction.curve.radius : 300.0, 0.0, 0.0}); + // a basket needs at least two arcs; a gentler entry radius eases into the nominal + // one, whose closing arc absorbs the rest of the turn. a short fixed length keeps + // the whole curve inside the turnout rather than far past its end + auto const nominal{junction.curve.radius > 0.0 ? junction.curve.radius : 300.0}; + junction.curve.arcs.push_back({nominal * 2.0, 6.0, 0.0}); + junction.curve.arcs.push_back({nominal, 0.0, 0.0}); } } else @@ -2480,52 +2545,26 @@ void plan_panel::add_switch_on(std::size_t const Through, double const Wx, doubl { return; } - auto const ¢re{m_solved[Through].centreline}; - if (centre.size() < 2) + double best_station{0.0}; + if (false == station_on_through(Through, Wx, Wy, best_station)) { m_status = "niweleta bez osi"; return; } - // nearest station: walk the centreline, project the click onto each segment - double best_station{0.0}; - double best_dist{std::numeric_limits::max()}; - double travelled{0.0}; - for (std::size_t i = 0; i + 1 < centre.size(); ++i) - { - double const ax{centre[i].x}; - double const ay{centre[i].y}; - double const dx{centre[i + 1].x - ax}; - double const dy{centre[i + 1].y - ay}; - double const seg{std::hypot(dx, dy)}; - if (seg < 1e-9) - { - continue; - } - double const t{std::clamp(((Wx - ax) * dx + (Wy - ay) * dy) / (seg * seg), 0.0, 1.0)}; - double const px{ax + dx * t}; - double const py{ay + dy * t}; - double const d{std::hypot(Wx - px, Wy - py)}; - if (d < best_dist) - { - best_dist = d; - best_station = travelled + seg * t; - } - travelled += seg; - } - - maj0sted::web::Junction junction; + maj0sted::editor::Junction junction; junction.through = static_cast(Through); junction.station = best_station; junction.side = m_sw_side; junction.facing = true; junction.crossing_n = m_sw_crossing; + junction.length = m_sw_length; // catalogue length from the template, 0 for a custom switch junction.curve.mode = 1; // plain arc; the internal curve can be made compound later junction.curve.radius = m_sw_radius; junction.branch = static_cast(m_document.niwelety.size()); // a branch niweleta to draw the diverging track into, pinned to the frog by the solver - maj0sted::web::NiweletaSpec branch; + maj0sted::editor::NiweletaSpec branch; branch.name = "odnoga " + std::to_string(m_document.niwelety.size() + 1); branch.straights.push_back({Wx, Wy, Wx + 10.0, Wy, false}); // placeholder, repositioned below m_document.niwelety.push_back(std::move(branch)); @@ -2674,7 +2713,7 @@ void plan_panel::solve() } maj0sted::app::apply_straight_constraints(m_document.niwelety); // switches pin their branches, so the whole layout is solved together - auto layout{maj0sted::web::solve_layout(m_document.niwelety, m_document.junctions)}; + auto layout{maj0sted::editor::solve_layout(m_document.niwelety, m_document.junctions)}; m_solved = std::move(layout.niwelety); m_junctions = std::move(layout.junctions); } @@ -2686,12 +2725,12 @@ void plan_panel::apply_constraints() } -maj0sted::web::NiweletaSpec *plan_panel::current_niweleta() +maj0sted::editor::NiweletaSpec *plan_panel::current_niweleta() { return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; } -maj0sted::web::NiweletaSpec const *plan_panel::current_niweleta() const +maj0sted::editor::NiweletaSpec const *plan_panel::current_niweleta() const { return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; } @@ -2721,7 +2760,7 @@ bool plan_panel::fit_applied(std::size_t const Gap) const return false; } -maj0sted::web::StraightSpec const *plan_panel::handle_straight(std::size_t const Niw, std::size_t const Index) const +maj0sted::editor::StraightSpec const *plan_panel::handle_straight(std::size_t const Niw, std::size_t const Index) const { if (Niw >= m_document.niwelety.size()) { @@ -2814,7 +2853,7 @@ void plan_panel::delete_selected_straight() niweleta->straights.erase(niweleta->straights.begin() + sel); // gaps touching the removed straight disappear; later gaps shift down - std::vector kept; + std::vector kept; kept.reserve(niweleta->fits.size()); for (auto const &fit : niweleta->fits) { @@ -3394,6 +3433,82 @@ bool plan_panel::hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int return found; } +bool plan_panel::station_on_through(std::size_t const Through, double const Wx, double const Wy, double &OutStation) const +{ + if (Through >= m_solved.size()) + { + return false; + } + auto const ¢re{m_solved[Through].centreline}; + if (centre.size() < 2) + { + return false; + } + // walk the axis, project the point onto each segment, keep the nearest station (arc length) + double best_station{0.0}; + double best_dist{std::numeric_limits::max()}; + double travelled{0.0}; + for (std::size_t i = 0; i + 1 < centre.size(); ++i) + { + double const ax{centre[i].x}; + double const ay{centre[i].y}; + double const dx{centre[i + 1].x - ax}; + double const dy{centre[i + 1].y - ay}; + double const seg{std::hypot(dx, dy)}; + if (seg < 1e-9) + { + continue; + } + double const t{std::clamp(((Wx - ax) * dx + (Wy - ay) * dy) / (seg * seg), 0.0, 1.0)}; + double const d{std::hypot(Wx - (ax + dx * t), Wy - (ay + dy * t))}; + if (d < best_dist) + { + best_dist = d; + best_station = travelled + seg * t; + } + travelled += seg; + } + OutStation = best_station; + return true; +} + +int plan_panel::nearest_niweleta_axis(ImVec2 const &Mouse, float const Tolerance) const +{ + auto best{Tolerance}; + int found{-1}; + for (std::size_t n = 0; n < m_solved.size(); ++n) + { + for (auto const &polyline : m_solved[n].polylines) + { + for (std::size_t i = 0; i + 1 < polyline.points.size(); ++i) + { + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(polyline.points[i].x, polyline.points[i].y), a) || + false == world_to_screen(plan_to_world(polyline.points[i + 1].x, polyline.points[i + 1].y), b)) + { + continue; + } + auto const dx{b.x - a.x}; + auto const dy{b.y - a.y}; + auto const len2{dx * dx + dy * dy}; + if (len2 <= 0.0f) + { + continue; + } + auto const t{std::clamp(((Mouse.x - a.x) * dx + (Mouse.y - a.y) * dy) / len2, 0.0f, 1.0f)}; + auto const d{std::hypot(Mouse.x - (a.x + t * dx), Mouse.y - (a.y + t * dy))}; + if (d <= best) + { + best = d; + found = static_cast(n); + } + } + } + } + return found; +} + bool plan_panel::hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const { auto best{10.0f}; diff --git a/application/planpanel.h b/application/planpanel.h index 581b3c57..1046df1b 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -16,8 +16,8 @@ http://mozilla.org/MPL/2.0/. #include "maj0sted/app/editor_document.hpp" #include "maj0sted/app/straight_constraints.hpp" #include "maj0sted/io/scn_export.hpp" -#include "maj0sted/web/editor.hpp" -#include "maj0sted/web/ribbon.hpp" +#include "maj0sted/editor/editor.hpp" +#include "maj0sted/editor/ribbon.hpp" #include @@ -39,7 +39,7 @@ class plan_panel : public ui_panel void render_toolbar(); void render_draft(); void render_gaps(); - void render_compound(maj0sted::web::GapFit &Fit, std::size_t const Gap, bool &Dirty); + void render_compound(maj0sted::editor::GapFit &Fit, std::size_t const Gap, bool &Dirty); void fit_compound_to_guides(); void clear_fit_guides(); void render_storage(); @@ -51,12 +51,12 @@ class plan_panel : public ui_panel void draw_on_scene(); void solve(); void apply_constraints(); - maj0sted::web::NiweletaSpec *current_niweleta(); - maj0sted::web::NiweletaSpec const *current_niweleta() const; + maj0sted::editor::NiweletaSpec *current_niweleta(); + maj0sted::editor::NiweletaSpec const *current_niweleta() const; void align_fits(); bool fit_applied(std::size_t const Gap) const; // independent anchored straights: each endpoint is its own handle - maj0sted::web::StraightSpec const *handle_straight(std::size_t const Niw, std::size_t const Index) const; + maj0sted::editor::StraightSpec const *handle_straight(std::size_t const Niw, std::size_t const Index) const; void append_vertex(double const X, double const Y); void drop_last_vertex(); void delete_selected_straight(); @@ -65,6 +65,10 @@ class plan_panel : public ui_panel // hit tests (screen pixels). niw=-1 means "current niweleta only" bool hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const; bool hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const; + // nearest niweleta by its solved axis (curves included), for a forgiving double-click select + int nearest_niweleta_axis(ImVec2 const &Mouse, float const Tolerance) const; + // nearest station (arc length) on a through niweleta's axis to a plan point; for placing/moving switches + bool station_on_through(std::size_t const Through, double const Wx, double const Wy, double &OutStation) const; // compound basket: draggable ends of entry/exit KP, free arcs and between-KP bool hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const; void drag_compound_station(double const X, double const Y); @@ -90,8 +94,8 @@ class plan_panel : public ui_panel // members maj0sted::app::EditorDocument m_document; - std::vector m_solved; - std::vector m_junctions; + std::vector m_solved; + std::vector m_junctions; std::size_t m_niweleta{0}; // switch placement: 0 idle, 1 waiting for a click on the through track @@ -99,6 +103,8 @@ class plan_panel : public ui_panel int m_sw_side{0}; // 0 left, 1 right double m_sw_crossing{9.0}; // skos 1:n double m_sw_radius{190.0}; // internal arc radius (metres) + double m_sw_length{0.0}; // catalogue length from the chosen template (0 = just the curve) + int m_drag_junction{-1}; // index of the switch being slid along its through track, or -1 int m_sel_straight{-1}; int m_sel_end{-1}; @@ -136,7 +142,7 @@ class plan_panel : public ui_panel std::vector m_fit_guides; maj0sted::app::BasketDraft m_draft; - std::vector m_draft_polys; + std::vector m_draft_polys; // 0 idle, 1 pick straight end to continue from int m_draft_place{0}; // next click appends: 0 = luk, 1 = KP (wej/między), 2 = KP wyjście (kończy: prosta + kotwica) @@ -162,9 +168,9 @@ class plan_panel : public ui_panel double m_mapviewy{0.0}; double m_mapscale{0.0}; - editor::wms_image m_topomap{maj0sted::web::WmsConfig::geoportal_topo()}; + editor::wms_image m_topomap{maj0sted::editor::WmsConfig::geoportal_topo()}; editor::orthophoto_source m_ortho; // 1 km topo cells for views wider than a kilometre (orto's 100 m grid is too dense there) - editor::orthophoto_source m_topo{maj0sted::web::WmsConfig::geoportal_topo(), 1000, "topo"}; + editor::orthophoto_source m_topo{maj0sted::editor::WmsConfig::geoportal_topo(), 1000, "topo"}; bool m_showortho{true}; }; diff --git a/application/scenarioloadermode.cpp b/application/scenarioloadermode.cpp index 4d554cf9..acb0d3dc 100644 --- a/application/scenarioloadermode.cpp +++ b/application/scenarioloadermode.cpp @@ -53,12 +53,17 @@ bool scenarioloader_mode::update() { } catch (invalid_scenery_exception &e) { ErrorLog( "Bad init: scenario loading failed" ); + state.reset(); Application.pop_mode(); } WriteLog( "Scenario loading time: " + std::to_string( std::chrono::duration_cast( std::chrono::system_clock::now() - timestart ).count() ) + " seconds" ); // TODO: implement and use next mode cue + // the mode object is kept for the next time a scenery is loaded; without dropping the context + // here, a later enter() would skip deserialize_begin and finish an already-spent parser again + state.reset(); + Application.pop_mode(); // starting into the editor the simulation never runs at all: there's no driver mode underneath, so // no physics, no AI and no timetables - just the loaded world to work on @@ -79,6 +84,11 @@ void scenarioloader_mode::enter() { // TBD: hide cursor in fullscreen mode? Application.set_cursor( GLFW_CURSOR_NORMAL ); + // a new map from the editor re-enters this mode while the previous load's context is still + // sitting in `state`; clear it so update() opens the file again instead of re-finishing an + // exhausted parser (Groups.close / create_map_geometry on an already-built region) + state.reset(); + simulation::is_ready = false; Application.set_title( Global.AppName + " (" + Global.SceneryFile + ")" ); diff --git a/editor/orthophoto.cpp b/editor/orthophoto.cpp index 9bc1335f..7fc09ef1 100644 --- a/editor/orthophoto.cpp +++ b/editor/orthophoto.cpp @@ -136,11 +136,19 @@ bool http_get(std::string const &, std::vector &, std::string &) } // namespace -orthophoto_source::orthophoto_source() +orthophoto_source::orthophoto_source(maj0sted::editor::WmsConfig Config, int const Gridzoom, std::string Cachesubdir) + : m_service([&] { + if (Gridzoom >= maj0sted::editor::TileGrid::kCellZoomMin) + { + Config.tile_pixels = 512; // coarse cells do not need orto's 1024 px + } + return std::move(Config); + }()), + m_gridzoom(Gridzoom), m_cachesubdir(std::move(Cachesubdir)) { // the library asks for a URL and expects the answer whenever it arrives; the transfer is queued // here and handed back on the thread that drains it, so the service stays single threaded - m_service.set_fetcher([this](std::string const &Url, maj0sted::web::TileService::DoneFn Done) { + m_service.set_fetcher([this](std::string const &Url, maj0sted::editor::TileService::DoneFn Done) { { std::lock_guard lock(m_mutex); m_queued.push_back({Url, std::move(Done), false, {}, {}}); @@ -210,7 +218,7 @@ void orthophoto_source::worker() } } -std::vector orthophoto_source::collect(maj0sted::web::TileBBox const &View, int const Maxtiles) +std::vector orthophoto_source::collect(maj0sted::editor::TileBBox const &View, int const Maxtiles) { // hand finished transfers to the library here, on the caller's thread std::deque arrived; @@ -227,8 +235,10 @@ std::vector orthophoto_source::collect(maj0sted:: --m_inflight; } - std::vector tiles; - for (auto const &key : maj0sted::web::TileGrid::tiles_for_view(View, maj0sted::web::TileGrid::kLevel, Maxtiles)) + // fetch only what fits the budget for the current view. the window is recentred when the + // view is larger than the cap, and zoom-to-cursor moves the camera, so the set of keys asked + // for changes from frame to frame - that must not decide what is drawn + for (auto const &key : maj0sted::editor::TileGrid::tiles_for_view(View, m_gridzoom, Maxtiles)) { // a tile kept from an earlier session seeds the cache, so no request goes out for it at all if (false == m_service.has(key)) @@ -237,33 +247,51 @@ std::vector orthophoto_source::collect(maj0sted:: } auto const status = m_service.request(key); - if (status == maj0sted::web::TileStatus::Failed) + if (status == maj0sted::editor::TileStatus::Failed) { - ++m_failed; + // counted once per tile, not once per frame it stays unavailable + m_failedkeys.insert(key); continue; } - if (status != maj0sted::web::TileStatus::Ready) + if (status != maj0sted::editor::TileStatus::Ready) { continue; } - auto found = m_textures.find(key); - if (found == m_textures.end()) + if (m_textures.find(key) == m_textures.end()) { - // the first time a tile is drawn is also the moment its bytes are known to be good + // the first time a tile is ready is also the moment its bytes are known to be good save_to_disk(key); - found = m_textures.emplace(key, upload(key)).first; + m_textures.emplace(key, upload(key)); } - if (found->second != 0) + } + + // draw every texture we already hold that still covers the view. returning only the fetch + // window made tiles blink out during zoom as soon as they fell outside the cap, then blink + // back in once the window moved over them again + auto const overlaps = [](maj0sted::editor::TileBBox const &Left, maj0sted::editor::TileBBox const &Right) { + return Left.min_x < Right.max_x && Left.max_x > Right.min_x && Left.min_y < Right.max_y && Left.max_y > Right.min_y; + }; + + std::vector tiles; + tiles.reserve(m_textures.size()); + for (auto const &[key, texture] : m_textures) + { + if (texture == 0) { - tiles.push_back({maj0sted::web::TileGrid::bbox(key), found->second}); + continue; + } + auto const box = maj0sted::editor::TileGrid::bbox(key); + if (overlaps(box, View)) + { + tiles.push_back({box, texture}); } } return tiles; } -unsigned int orthophoto_source::upload(maj0sted::web::TileKey const &Key) +unsigned int orthophoto_source::upload(maj0sted::editor::TileKey const &Key) { auto const *image = m_service.peek(Key); if (image == nullptr || image->bytes.empty()) @@ -296,12 +324,12 @@ unsigned int orthophoto_source::upload(maj0sted::web::TileKey const &Key) return texture; } -std::string orthophoto_source::tile_path(maj0sted::web::TileKey const &Key) +std::string orthophoto_source::tile_path(maj0sted::editor::TileKey const &Key) const { - return "editor/cache/orto/" + std::to_string(Key.zoom) + "_" + std::to_string(Key.x) + "_" + std::to_string(Key.y) + ".img"; + return "editor/cache/" + m_cachesubdir + "/" + std::to_string(Key.zoom) + "_" + std::to_string(Key.x) + "_" + std::to_string(Key.y) + ".img"; } -bool orthophoto_source::load_from_disk(maj0sted::web::TileKey const &Key) +bool orthophoto_source::load_from_disk(maj0sted::editor::TileKey const &Key) { std::ifstream file(tile_path(Key), std::ios::binary); if (false == file.is_open()) @@ -320,7 +348,7 @@ bool orthophoto_source::load_from_disk(maj0sted::web::TileKey const &Key) return true; } -void orthophoto_source::save_to_disk(maj0sted::web::TileKey const &Key) +void orthophoto_source::save_to_disk(maj0sted::editor::TileKey const &Key) { auto const *image = m_service.peek(Key); if (image == nullptr || image->bytes.empty()) @@ -350,7 +378,7 @@ std::size_t orthophoto_source::pending() const std::size_t orthophoto_source::failed() const { - return m_failed; + return m_failedkeys.size(); } namespace @@ -383,7 +411,7 @@ unsigned int upload_image(std::vector const &Bytes) return texture; } -bool same_box(maj0sted::web::TileBBox const &Left, maj0sted::web::TileBBox const &Right) +bool same_box(maj0sted::editor::TileBBox const &Left, maj0sted::editor::TileBBox const &Right) { auto const close = [](double const A, double const B) { return std::abs(A - B) < 1.0; }; return close(Left.min_x, Right.min_x) && close(Left.min_y, Right.min_y) && close(Left.max_x, Right.max_x) && close(Left.max_y, Right.max_y); @@ -391,7 +419,7 @@ bool same_box(maj0sted::web::TileBBox const &Left, maj0sted::web::TileBBox const } // namespace -wms_image::wms_image(maj0sted::web::WmsConfig Config) : m_config(std::move(Config)) +wms_image::wms_image(maj0sted::editor::WmsConfig Config) : m_config(std::move(Config)) { m_worker = std::thread([this]() { worker(); }); } @@ -446,7 +474,7 @@ void wms_image::worker() } } -unsigned int wms_image::texture_for(maj0sted::web::TileBBox const &Box, int const Pixels) +unsigned int wms_image::texture_for(maj0sted::editor::TileBBox const &Box, int const Pixels) { // a finished download becomes the texture on this thread, where the graphics API is safe to touch { @@ -482,7 +510,7 @@ unsigned int wms_image::texture_for(maj0sted::web::TileBBox const &Box, int cons config.tile_pixels = Pixels; { std::lock_guard lock(m_mutex); - m_url = maj0sted::web::wms_getmap_url(config, Box); + m_url = maj0sted::editor::wms_getmap_url(config, Box); m_haswork = true; } m_loading = true; diff --git a/editor/orthophoto.h b/editor/orthophoto.h index d11dc887..4954fb31 100644 --- a/editor/orthophoto.h +++ b/editor/orthophoto.h @@ -17,9 +17,10 @@ http://mozilla.org/MPL/2.0/. #include #include #include +#include #include -#include "maj0sted/web/tile_cache.hpp" +#include "maj0sted/editor/tile_cache.hpp" namespace editor { @@ -37,18 +38,22 @@ class orthophoto_source public: struct ready_tile { - maj0sted::web::TileBBox box; + maj0sted::editor::TileBBox box; unsigned int texture{0}; }; - orthophoto_source(); + // default: Geoportal ortophoto on the fine 100 m grid. pass topo config and a coarse + // grid zoom (e.g. 1000) for the zoomed-out topographic underlay + explicit orthophoto_source(maj0sted::editor::WmsConfig Config = maj0sted::editor::WmsConfig::geoportal_ortho(), int const Gridzoom = maj0sted::editor::TileGrid::kLevel, + std::string Cachesubdir = "orto"); ~orthophoto_source(); orthophoto_source(orthophoto_source const &) = delete; orthophoto_source &operator=(orthophoto_source const &) = delete; - // asks for every tile covering the view and returns those that are already drawable. tiles still - // on their way simply aren't in the answer yet; call again next frame - std::vector collect(maj0sted::web::TileBBox const &View, int const Maxtiles = 48); + // kicks off fetches for the view (capped) and returns every already-uploaded tile that still + // intersects it - including ones outside the fetch window, so a zoom cannot blink them away. + // tiles still on their way simply aren't in the answer yet; call again next frame + std::vector collect(maj0sted::editor::TileBBox const &View, int const Maxtiles = 400); // how many tiles are still being fetched, for the panel to report std::size_t pending() const; // how many tiles gave up after exhausting their retries @@ -59,7 +64,7 @@ class orthophoto_source struct transfer { std::string url; - maj0sted::web::TileService::DoneFn done; + maj0sted::editor::TileService::DoneFn done; bool ok{false}; std::vector bytes; std::string content_type; @@ -67,15 +72,17 @@ class orthophoto_source // methods void worker(); // decodes the cached bytes of a tile and uploads them; returns 0 when the image is unusable - unsigned int upload(maj0sted::web::TileKey const &Key); + unsigned int upload(maj0sted::editor::TileKey const &Key); // imagery of a given piece of ground never changes, so a fetched tile is kept on disk and the // next session starts with it instead of going back to a service that is slow and flaky - static std::string tile_path(maj0sted::web::TileKey const &Key); - bool load_from_disk(maj0sted::web::TileKey const &Key); - void save_to_disk(maj0sted::web::TileKey const &Key); + std::string tile_path(maj0sted::editor::TileKey const &Key) const; + bool load_from_disk(maj0sted::editor::TileKey const &Key); + void save_to_disk(maj0sted::editor::TileKey const &Key); // members - maj0sted::web::TileService m_service; - std::unordered_map m_textures; + maj0sted::editor::TileService m_service; + int m_gridzoom{maj0sted::editor::TileGrid::kLevel}; + std::string m_cachesubdir{"orto"}; + std::unordered_map m_textures; mutable std::mutex m_mutex; std::condition_variable m_wakeup; @@ -84,7 +91,7 @@ class orthophoto_source std::vector m_workers; std::atomic m_quitting{false}; std::atomic m_inflight{0}; - std::size_t m_failed{0}; + std::unordered_set m_failedkeys; }; // a single WMS image covering an arbitrary box, for views too coarse for the tile grid - the whole @@ -94,25 +101,25 @@ class wms_image { public: - explicit wms_image(maj0sted::web::WmsConfig Config); + explicit wms_image(maj0sted::editor::WmsConfig Config); ~wms_image(); wms_image(wms_image const &) = delete; wms_image &operator=(wms_image const &) = delete; // asks for the given box at the given size and returns the texture currently available, which // may still be the previous box's until the new one arrives; 0 when there is nothing to show yet - unsigned int texture_for(maj0sted::web::TileBBox const &Box, int const Pixels); + unsigned int texture_for(maj0sted::editor::TileBBox const &Box, int const Pixels); // the box the returned texture actually covers - maj0sted::web::TileBBox const &covered() const { return m_covered; } + maj0sted::editor::TileBBox const &covered() const { return m_covered; } bool loading() const { return m_loading.load(); } private: void worker(); - maj0sted::web::WmsConfig m_config; - maj0sted::web::TileBBox m_requested{}; - maj0sted::web::TileBBox m_inflightbox{}; - maj0sted::web::TileBBox m_covered{}; + maj0sted::editor::WmsConfig m_config; + maj0sted::editor::TileBBox m_requested{}; + maj0sted::editor::TileBBox m_inflightbox{}; + maj0sted::editor::TileBBox m_covered{}; unsigned int m_texture{0}; mutable std::mutex m_mutex; diff --git a/editor/turnouts.cpp b/editor/turnouts.cpp index 0d19c897..a9726e3f 100644 --- a/editor/turnouts.cpp +++ b/editor/turnouts.cpp @@ -15,15 +15,21 @@ namespace editor std::vector const &turnout_presets() { - // the everyday PKP rozjazdy zwyczajne. the crossing mark and radius are the defining pair; - // the length the switch spans follows from them and is shown in the editor + // the standard PKP rozjazdy zwyczajne (49E1 / 60E1). crossing mark, radius and catalogue length + // (PR→KR along the through track). the crossing angles check out as atan(1/n); the lengths follow + // the type tables (metres). the diverging leaves not tangent but at the blade angle (beta) after a + // straight pre-blade lead, then arcs by (alfa - beta) to the frog. + // + // blade_angle and pre_blade are NOT yet from Id-1 - left at 0 (a tangent arc from PR) until the + // catalogue figures are in. Fill them per type when we have them; the geometry already supports it. static std::vector const presets{ - {"Rz 1:9 R190", 9.0, 190.0}, - {"Rz 1:9 R300", 9.0, 300.0}, - {"Rz 1:12 R300", 12.0, 300.0}, - {"Rz 1:14 R500", 14.0, 500.0}, - {"Rz 1:18,5 R1200", 18.5, 1200.0}, - {"Rz 1:26,5 R2500", 26.5, 2500.0}}; + {"Rz 1:7,5 R190", 7.5, 190.0, 25.222, 0.0, 0.0}, + {"Rz 1:9 R190", 9.0, 190.0, 27.138, 0.0, 0.0}, + {"Rz 1:9 R300", 9.0, 300.0, 33.230, 0.0, 0.0}, + {"Rz 1:12 R500", 12.0, 500.0, 41.594, 0.0, 0.0}, + {"Rz 1:14 R500", 14.0, 500.0, 42.371, 0.0, 0.0}, + {"Rz 1:14 R760", 14.0, 760.0, 54.216, 0.0, 0.0}, + {"Rz 1:18,5 R1200", 18.5, 1200.0, 64.818, 0.0, 0.0}}; return presets; } diff --git a/editor/turnouts.h b/editor/turnouts.h index ecefdef3..b4a3e442 100644 --- a/editor/turnouts.h +++ b/editor/turnouts.h @@ -22,8 +22,11 @@ namespace editor struct turnout_preset { std::string name; - double crossing_n; - double radius; + double crossing_n; // skos 1:n + double radius; // metres + double length; // catalogue length PR->KR along the through track, metres + double blade_angle; // kąt nagięcia iglicy (beta), radians; the diverging leaves the straight here + double pre_blade; // odcinek przediglicowy: straight lead PR -> blade tip, metres }; // typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard; diff --git a/maj0sted/CMakeLists.txt b/maj0sted/CMakeLists.txt index e91c5677..8feb4546 100644 --- a/maj0sted/CMakeLists.txt +++ b/maj0sted/CMakeLists.txt @@ -31,15 +31,19 @@ add_library(maj0sted STATIC src/domain/parallelism_service.cpp src/domain/fitting_service.cpp src/domain/gap_fitter.cpp + src/domain/turnout.cpp src/io/project_serializer.cpp src/io/svg_writer.cpp + src/io/scn_export.cpp src/render/plan_sampler.cpp src/render/rail_renderer.cpp - src/web/solve.cpp - src/web/editor.cpp - src/web/ribbon.cpp - src/web/tile_cache.cpp + src/editor/solve.cpp + src/editor/editor.cpp + src/editor/ribbon.cpp + src/editor/tile_cache.cpp src/app/editor_document.cpp + src/app/straight_constraints.cpp + src/app/basket_draft.cpp ) add_library(maj0sted::maj0sted ALIAS maj0sted) diff --git a/maj0sted/HANDOFF.md b/maj0sted/HANDOFF.md index f1f59993..b0786fae 100644 --- a/maj0sted/HANDOFF.md +++ b/maj0sted/HANDOFF.md @@ -19,14 +19,14 @@ Toolchain WASM: `emsdk` jest w `~/emsdk` (`source ~/emsdk/emsdk_env.sh` dodaje ` ```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" +SRC="src/domain/*.cpp src/io/*.cpp src/render/*.cpp src/editor/solve.cpp src/editor/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/editor/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` @@ -90,7 +90,7 @@ Budowa WASM: `source ~/emsdk/emsdk_env.sh && cd web && ./build.sh` 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`, +`src/editor/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 @@ -100,7 +100,7 @@ normalnej per-wierzchołek (różnica centralna, normalna lewa `(-dy,dx)`), 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`) ✓ +- **Kafle podkładu WMS** (`maj0sted::editor`, `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`, diff --git a/maj0sted/include/maj0sted/app/basket_draft.hpp b/maj0sted/include/maj0sted/app/basket_draft.hpp new file mode 100644 index 00000000..6214bfbc --- /dev/null +++ b/maj0sted/include/maj0sted/app/basket_draft.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +#include "maj0sted/editor/editor.hpp" +#include "maj0sted/editor/ribbon.hpp" +#include "maj0sted/editor/solve.hpp" + +namespace maj0sted::app { + +struct BasketDraft { + bool active{false}; + double x0{0.0}; + double y0{0.0}; + double az0{0.0}; + int dir{1}; + double entry_t{0.0}; + double exit_t{0.0}; + std::vector arcs; + double preview_next_r{300.0}; + int attach_str{-1}; + int attach_end{-1}; + int reedit_gap{-1}; + maj0sted::editor::GapFit reedit_backup{}; +}; + +[[nodiscard]] maj0sted::editor::RibbonRequest draft_to_ribbon(const BasketDraft& draft); + +[[nodiscard]] std::vector preview_basket_draft( + BasketDraft& draft, double fallback_next_r = 300.0); + +void start_basket_draft_from_end(BasketDraft& draft, + const maj0sted::editor::StraightSpec& straight, + int straight_index, int end, double default_r = 300.0); + +[[nodiscard]] bool reopen_basket_draft(BasketDraft& draft, + maj0sted::editor::NiweletaSpec& niweleta, + std::size_t gap, + const maj0sted::editor::NiweletaPolys* solved); + +void discard_basket_draft(BasketDraft& draft, maj0sted::editor::NiweletaSpec* niweleta); + +[[nodiscard]] bool anchor_basket_draft(BasketDraft& draft, + maj0sted::editor::NiweletaSpec& niweleta, + std::size_t gap); + +struct FinishBasketDraftResult { + bool ok{false}; + std::string status; +}; + +[[nodiscard]] FinishBasketDraftResult finish_basket_draft( + BasketDraft& draft, maj0sted::editor::NiweletaSpec& niweleta, + std::vector& all_niwelety, int niweleta_index, + double finish_straight_m); + +} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/app/editor_document.hpp b/maj0sted/include/maj0sted/app/editor_document.hpp index 668a7d59..d24aad3a 100644 --- a/maj0sted/include/maj0sted/app/editor_document.hpp +++ b/maj0sted/include/maj0sted/app/editor_document.hpp @@ -5,13 +5,13 @@ #include #include "maj0sted/domain/map_project.hpp" -#include "maj0sted/web/editor.hpp" +#include "maj0sted/editor/editor.hpp" namespace maj0sted::app { struct EditorDocument { - std::vector niwelety; - std::vector junctions; + std::vector niwelety; + std::vector junctions; double view_x{0.0}; double view_y{0.0}; double view_extent{0.0}; diff --git a/maj0sted/include/maj0sted/app/straight_constraints.hpp b/maj0sted/include/maj0sted/app/straight_constraints.hpp new file mode 100644 index 00000000..ddf2659f --- /dev/null +++ b/maj0sted/include/maj0sted/app/straight_constraints.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +#include "maj0sted/editor/editor.hpp" + +namespace maj0sted::app { + +void apply_straight_constraints(std::vector& niwelety); +void align_gap_fits(maj0sted::editor::NiweletaSpec& niweleta); + +} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp new file mode 100644 index 00000000..7fc1326b --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" // Pose + +namespace maj0sted::domain { + +/// The crossing mark of a turnout — the skos 1:n. It is the tangent of the angle +/// between the through and diverging tracks, so it fixes the total angle the +/// diverging curve must turn through: a turnout is 1:9, 1:12, 1:18,5 and so on. +class CrossingMark { +public: + explicit CrossingMark(double denominator) noexcept : denominator_{denominator} {} + + [[nodiscard]] double denominator() const noexcept { return denominator_; } + /// The angle between the tracks, radians. Zero for a non-positive mark. + [[nodiscard]] double angle() const noexcept; + +private: + double denominator_; +}; + +/// Which way the branch leaves the through track. +enum class DivergeSide { Left, Right }; + +/// One arc of a turnout's diverging curve: its radius and — unless it is the +/// closing arc — its fixed length and the clothoid easing it into the next arc. +struct TurnoutArc { + double radius{0.0}; + double length{0.0}; ///< fixed length; ignored for the closing arc + double transition_to_next{0.0}; ///< clothoid to the next arc (0 = none) +}; + +/// The shape of a turnout's diverging curve. One arc is the everyday turnout; two +/// or more make a łuk koszowy. Whatever the shape, the curve turns through exactly +/// the crossing angle — the closing (last) arc spends whatever deflection the +/// earlier arcs and clothoids leave. +struct DivergingCurve { + std::vector arcs; ///< at least one; the last one closes the turn + double entry_transition{0.0}; ///< clothoid from the through track into the first arc + double exit_transition{0.0}; ///< clothoid from the last arc back toward tangent +}; + +/// A turnout (rozjazd), described the way a catalogue does: a crossing mark, the +/// side it opens to, the free diverging curve inside it, and the length it spans +/// from its start (początek rozjazdu) to its end (koniec rozjazdu) measured along +/// the through track. The curve turns to the crossing angle and a straight frog +/// rail carries the track on to that length. +struct Turnout { + CrossingMark mark; + DivergeSide side{DivergeSide::Left}; + DivergingCurve curve; + double length{0.0}; ///< catalogue length along the through track; 0 = a tangent-length rail +}; + +/// One laid segment of the diverging path, as the constant/linear curvature the +/// renderer and the fitter already share (see segment_layout): a straight when +/// both curvatures are zero, an arc when they are equal, a clothoid otherwise. +struct TurnoutSegment { + double k0{0.0}; + double k1{0.0}; + double length{0.0}; +}; + +/// What a turnout works out to when laid from its start pose. +struct TurnoutGeometry { + bool valid{false}; + std::vector path; ///< diverging segments from the switch start to the frog + geometry::Pose frog{}; ///< the end (koniec rozjazdu): position + heading at the crossing angle + double tangent_front{0.0}; ///< theoretical tangent length, switch start to the crossing point + double tangent_back{0.0}; ///< theoretical tangent length, crossing point to the frog + double diverging_length{0.0}; ///< total length of the diverging path (curve + frog rail) +}; + +/// Lays a turnout forward from @p start — the switch start, tangent to the through +/// track — turning toward its side until the heading has swung by the crossing +/// angle, then running straight to the catalogue length. Never throws; returns +/// `valid == false` when the parameters admit no geometry: a non-positive radius, +/// or a curve whose fixed parts already overshoot the crossing angle before the +/// closing arc. +[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, + const Turnout& turnout); + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp b/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp index 4a308e09..77ee03b1 100644 --- a/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp +++ b/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp @@ -30,6 +30,10 @@ public: /// Perpendicular distance (>= 0) from @p other's start point to the /// (infinite) line carrying @p origin. static double perpendicular_distance(const Straight& origin, const Straight& other); + + [[nodiscard]] static Straight project_onto_offset_line(const Straight& origin, + double offset_m, + const Straight& segment); }; } // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/web/editor.hpp b/maj0sted/include/maj0sted/editor/editor.hpp similarity index 94% rename from maj0sted/include/maj0sted/web/editor.hpp rename to maj0sted/include/maj0sted/editor/editor.hpp index 93988e8f..15ff6a9b 100644 --- a/maj0sted/include/maj0sted/web/editor.hpp +++ b/maj0sted/include/maj0sted/editor/editor.hpp @@ -3,9 +3,9 @@ #include #include -#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline +#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline -namespace maj0sted::web { +namespace maj0sted::editor { /// An anchored straight segment (two XY endpoints). struct StraightSpec { @@ -63,7 +63,7 @@ struct NiweletaSpec { }; struct NiweletaPolys { - std::vector polylines; + std::vector polylines; /// Centreline segments after adjacent fits trim the input straights. Kept /// index-aligned with NiweletaSpec::straights for editor hit handles. std::vector rendered_straights; @@ -74,7 +74,7 @@ struct NiweletaPolys { std::vector applied_fits; /// The solved axis as one polyline, in order, with no duplicate join points. /// Lets a station along the niweleta be resolved to a pose (see pose_at). - std::vector centreline; + std::vector centreline; }; /// Builds each niweleta, fits every requested gap, and returns the sampled plan @@ -98,6 +98,7 @@ struct Junction { int side{0}; ///< 0 = diverges left, 1 = diverges right bool facing{true}; ///< true opens toward increasing station, false back double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n) + double length{0.0}; ///< catalogue length (PR→KR along the through track); 0 = just the curve GapFit curve; ///< the internal diverging curve: arc / compound / basket int branch{-1}; ///< index of the branch niweleta, pinned to the frog }; @@ -114,7 +115,7 @@ struct JunctionGeom { double tangent_back{0.0}; ///< theoretical tangent length after the curve double length{0.0}; ///< diverging curve length (switch point to frog), metres /// The diverging curve, as rails ready to draw. - std::vector polylines; + std::vector polylines; }; /// A whole layout solved at once: the niwelety plus every switch. Returned by @@ -132,4 +133,4 @@ struct LayoutSolution { [[nodiscard]] LayoutSolution solve_layout(std::vector niwelety, const std::vector& junctions); -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/web/ribbon.hpp b/maj0sted/include/maj0sted/editor/ribbon.hpp similarity index 83% rename from maj0sted/include/maj0sted/web/ribbon.hpp rename to maj0sted/include/maj0sted/editor/ribbon.hpp index 85aa793e..2f39a0ad 100644 --- a/maj0sted/include/maj0sted/web/ribbon.hpp +++ b/maj0sted/include/maj0sted/editor/ribbon.hpp @@ -2,9 +2,9 @@ #include -#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline +#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline -namespace maj0sted::web { +namespace maj0sted::editor { /// One element of an alignment "ribbon" (element chain), defined intrinsically. struct RibbonElement { @@ -27,6 +27,6 @@ struct RibbonRequest { }; /// Lays the ribbon out and returns one polyline per element, ready to draw. -[[nodiscard]] std::vector solve_ribbon(const RibbonRequest& request); +[[nodiscard]] std::vector solve_ribbon(const RibbonRequest& request); -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/web/solve.hpp b/maj0sted/include/maj0sted/editor/solve.hpp similarity index 78% rename from maj0sted/include/maj0sted/web/solve.hpp rename to maj0sted/include/maj0sted/editor/solve.hpp index cd4dd8b1..35a73647 100644 --- a/maj0sted/include/maj0sted/web/solve.hpp +++ b/maj0sted/include/maj0sted/editor/solve.hpp @@ -2,12 +2,11 @@ #include -namespace maj0sted::web { +namespace maj0sted::editor { /// A request from the GUI: three draggable vertices (entry straight A->B, exit -/// straight B->C) plus the fit parameters. Plain doubles 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. +/// straight B->C) plus the fit parameters. Plain doubles, a flat DTO across the +/// boundary between the editor UI and this solver. struct SolveRequest { double ax{0.0}, ay{0.0}; ///< vertex A (entry start) double bx{0.0}, by{0.0}; ///< vertex B (corner: entry end = exit start) @@ -28,13 +27,13 @@ struct SolveRequest { }; /// A point in the CRS. -struct WebPoint { +struct PlanPoint { double x{0.0}; double y{0.0}; }; /// One polyline; @c kind is render::ElementKind (0 straight, 1 arc, 2 transition). -struct WebPolyline { +struct PlanPolyline { int kind{0}; /// Logical owner used by map hit-testing. Exactly one is non-negative. int straight_index{-1}; @@ -45,12 +44,12 @@ struct WebPolyline { /// Zero denotes an infinite radius (straight end of a transition). double radius_start{0.0}; double radius_end{0.0}; - std::vector points; + std::vector points; }; /// Builds the niweleta, runs the requested fit (falling back to the bare /// straights if the parameters do not admit a fit) and returns the sampled plan /// as polylines ready to draw. -[[nodiscard]] std::vector solve_scene(const SolveRequest& request); +[[nodiscard]] std::vector solve_scene(const SolveRequest& request); -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/web/tile_cache.hpp b/maj0sted/include/maj0sted/editor/tile_cache.hpp similarity index 73% rename from maj0sted/include/maj0sted/web/tile_cache.hpp rename to maj0sted/include/maj0sted/editor/tile_cache.hpp index dfc4d163..6fe60f41 100644 --- a/maj0sted/include/maj0sted/web/tile_cache.hpp +++ b/maj0sted/include/maj0sted/editor/tile_cache.hpp @@ -6,7 +6,7 @@ #include #include -namespace maj0sted::web { +namespace maj0sted::editor { /// 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 @@ -39,42 +39,43 @@ struct TileBBox { 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. +/// The tiling scheme. A pure, static value type defining a square grid over the +/// EPSG:2180 plane. The default grid is fixed 100 m × 100 m cells (key.zoom = +/// kLevel). Coarser overview grids encode the cell size in metres in +/// @c TileKey::zoom when that value is >= 100 (e.g. zoom 1000 → 1 km cells for +/// topo underlays). Nothing here touches the network. 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. + /// Default cache cell size: 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). + /// Default grid level stored in TileKey::zoom for the 100 m grid. static constexpr int kLevel = 0; + /// Zoom values at or above this are treated as the cell edge in metres. + static constexpr int kCellZoomMin = 100; - /// Ground resolution (metres per pixel) of a cell. Constant (zoom ignored). + /// Cell edge in metres for @p key (100 m default, or key.zoom when coarse). + [[nodiscard]] static double cell_metres(const TileKey& key) noexcept; + + /// Ground resolution (metres per pixel) of a cell. [[nodiscard]] static double resolution(int zoom = kLevel) noexcept; - /// Edge length of a cell in metres — always kCellMetres (zoom ignored). + /// Edge length of a cell in metres for the given zoom/cell encoding. [[nodiscard]] static double tile_span(int zoom = kLevel) noexcept; - /// The 100 m cell whose ground square contains (@p x, @p y). Zoom ignored. + /// The cell whose ground square contains (@p x, @p y). [[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. + /// The grid level for a target resolution. Always kLevel for the fine grid. [[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. + /// Every cell whose ground square intersects @p view, capped at @p max_tiles. + /// Pass a coarse cell size (e.g. 1000) as @p zoom to get a 1 km overview grid. [[nodiscard]] static std::vector tiles_for_view(const TileBBox& view, int zoom = kLevel, int max_tiles = 64); @@ -114,10 +115,9 @@ struct TileImage { }; /// 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. +/// place that decides which tile is needed and whether it is already cached. The +/// download itself is the host's: it injects a Fetcher, so the tiling and caching +/// logic here stays transport-free and is exercised in tests without a network. class TileService { public: /// Called when a fetch finishes: (success, bytes, content_type). @@ -131,9 +131,8 @@ public: [[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. + /// Sets the transport. When unset, a requested tile fails at once (there is + /// no built-in downloader — the host owns that). void set_fetcher(Fetcher fetcher) { fetcher_ = std::move(fetcher); } /// Notified (with the tile's key) whenever a tile transitions to Ready or @@ -161,20 +160,17 @@ public: /// 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. + /// the host's fetcher callback can hand the result back here. void complete_fetch(const TileKey& key, bool ok, std::vector bytes, std::string content_type); - /// Re-issues the fetch for @p key. Public so the Emscripten async-retry glue - /// (a free C callback) can delegate here. + /// Re-issues the fetch for @p key. 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. + /// kMaxRetries times, then marks the tile Failed. The fetcher owns the timing. void schedule_retry(const TileKey& key); WmsConfig cfg_; @@ -183,4 +179,4 @@ private: std::function on_ready_; }; -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/io/scn_export.hpp b/maj0sted/include/maj0sted/io/scn_export.hpp new file mode 100644 index 00000000..42b4edd2 --- /dev/null +++ b/maj0sted/include/maj0sted/io/scn_export.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +#include "maj0sted/editor/editor.hpp" + +namespace maj0sted::io { + +struct ScnExportOptions { + double origin_east{0.0}; + double origin_north{0.0}; + double rail_y{0.2}; + double max_arc_angle{1.5707963267948966}; +}; + +struct ScnExportResult { + int tracks{0}; + std::string first_track_name; + double origin_east{0.0}; + double origin_north{0.0}; +}; + +[[nodiscard]] ScnExportResult export_scn( + const std::vector& solved, + const ScnExportOptions& options, std::ostream& out); + +[[nodiscard]] ScnExportOptions resolve_scn_origin( + const std::vector& solved, + ScnExportOptions options); + +} // namespace maj0sted::io diff --git a/maj0sted/src/app/basket_draft.cpp b/maj0sted/src/app/basket_draft.cpp new file mode 100644 index 00000000..0bbef566 --- /dev/null +++ b/maj0sted/src/app/basket_draft.cpp @@ -0,0 +1,389 @@ +#include "maj0sted/app/basket_draft.hpp" + +#include +#include + +#include "maj0sted/app/straight_constraints.hpp" + +namespace maj0sted::app { +namespace { + +using maj0sted::editor::CompoundArcSpec; +using maj0sted::editor::GapFit; +using maj0sted::editor::NiweletaPolys; +using maj0sted::editor::NiweletaSpec; +using maj0sted::editor::RibbonElement; +using maj0sted::editor::RibbonRequest; +using maj0sted::editor::StraightSpec; +using maj0sted::editor::PlanPolyline; + +void sanitize_draft_arcs(BasketDraft& draft) { + for (auto& arc : draft.arcs) { + if (!(arc.radius >= 30.0)) { + arc.radius = 30.0; + } + if (!(arc.length > 0.0)) { + arc.length = 5.0; + } + if (arc.transition_to_next < 0.0) { + arc.transition_to_next = 0.0; + } + } + if (draft.entry_t < 0.0) { + draft.entry_t = 0.0; + } + if (draft.exit_t < 0.0) { + draft.exit_t = 0.0; + } +} + +bool draft_end_pose(const BasketDraft& draft, + const std::vector& polys, double& out_x, + double& out_y, double& out_az) { + if (!draft.active) { + return false; + } + out_az = draft.az0; + if (!polys.empty() && !polys.back().points.empty()) { + const auto& pts = polys.back().points; + out_x = pts.back().x; + out_y = pts.back().y; + if (pts.size() >= 2) { + out_az = std::atan2(pts.back().x - pts[pts.size() - 2].x, + pts.back().y - pts[pts.size() - 2].y); + } + return true; + } + out_x = draft.x0; + out_y = draft.y0; + return true; +} + +} // namespace + +RibbonRequest draft_to_ribbon(const BasketDraft& draft) { + RibbonRequest req; + req.x0 = draft.x0; + req.y0 = draft.y0; + req.az0 = draft.az0; + if (draft.arcs.empty()) { + return req; + } + const int dir = draft.dir >= 0 ? 1 : -1; + if (draft.entry_t > 1e-6) { + RibbonElement el; + el.kind = 2; + el.length = draft.entry_t; + el.radius_start = 0.0; + el.radius = draft.arcs.front().radius; + el.dir = dir; + req.elements.push_back(el); + } + for (std::size_t i = 0; i < draft.arcs.size(); ++i) { + const auto& arc = draft.arcs[i]; + const double r = std::max(30.0, arc.radius); + const double len = std::max(1e-3, arc.length); + RibbonElement a; + a.kind = 1; + a.radius = r; + a.angle = len / r; + a.dir = dir; + req.elements.push_back(a); + if (arc.transition_to_next > 1e-6) { + RibbonElement kp; + kp.kind = 2; + kp.length = arc.transition_to_next; + kp.radius_start = r; + if (i + 1 < draft.arcs.size()) { + kp.radius = std::max(30.0, draft.arcs[i + 1].radius); + } else { + kp.radius = std::max(30.0, draft.preview_next_r); + } + kp.dir = dir; + req.elements.push_back(kp); + } + } + if (draft.exit_t > 1e-6) { + RibbonElement el; + el.kind = 2; + el.length = draft.exit_t; + el.radius_start = draft.arcs.back().radius; + el.radius = 0.0; + el.dir = dir; + req.elements.push_back(el); + } + return req; +} + +std::vector preview_basket_draft(BasketDraft& draft, + double fallback_next_r) { + if (!draft.active || draft.arcs.empty()) { + return {}; + } + sanitize_draft_arcs(draft); + draft.preview_next_r = std::max(30.0, fallback_next_r); + return maj0sted::editor::solve_ribbon(draft_to_ribbon(draft)); +} + +void start_basket_draft_from_end(BasketDraft& draft, const StraightSpec& straight, + int straight_index, int end, double default_r) { + draft = {}; + draft.active = true; + draft.attach_str = straight_index; + draft.attach_end = end; + draft.dir = 1; + draft.preview_next_r = default_r; + draft.reedit_gap = -1; + draft.reedit_backup = {}; + if (end == 1) { + draft.x0 = straight.x2; + draft.y0 = straight.y2; + draft.az0 = std::atan2(straight.x2 - straight.x1, straight.y2 - straight.y1); + } else { + draft.x0 = straight.x1; + draft.y0 = straight.y1; + draft.az0 = std::atan2(straight.x1 - straight.x2, straight.y1 - straight.y2); + } + draft.arcs.push_back({600.0, 60.0, 40.0}); + draft.arcs.push_back({std::max(30.0, default_r), 80.0, 0.0}); +} + +bool reopen_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta, + std::size_t gap, const NiweletaPolys* solved) { + if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) { + return false; + } + const auto& fit = niweleta.fits[gap]; + if (fit.mode != 3 || fit.arcs.empty()) { + return false; + } + + std::vector solved_arc_lengths; + if (solved != nullptr) { + for (const auto& poly : solved->polylines) { + if (poly.gap != static_cast(gap) || poly.kind != 1 || + poly.element_index < 0) { + continue; + } + bool seen = false; + for (const auto& other : solved->polylines) { + if (&other == &poly) { + break; + } + if (other.gap == poly.gap && + other.element_index == poly.element_index) { + seen = true; + break; + } + } + if (!seen) { + solved_arc_lengths.push_back(std::max(5.0, poly.length)); + } + } + } + + const auto& entry = niweleta.straights[gap]; + const auto& exit = niweleta.straights[gap + 1]; + const double edx = entry.x2 - entry.x1; + const double edy = entry.y2 - entry.y1; + const double xdx = exit.x2 - exit.x1; + const double xdy = exit.y2 - exit.y1; + const double turn = edx * xdy - edy * xdx; + + draft = {}; + draft.active = true; + draft.reedit_backup = fit; + draft.reedit_gap = static_cast(gap); + draft.attach_str = static_cast(gap); + draft.attach_end = 1; + draft.x0 = entry.x2; + draft.y0 = entry.y2; + draft.az0 = std::atan2(edx, edy); + draft.dir = turn >= 0.0 ? 1 : -1; + draft.entry_t = std::max(0.0, fit.entry_t); + draft.exit_t = std::max(0.0, fit.exit_t); + draft.arcs = fit.arcs; + for (std::size_t i = 0; i < draft.arcs.size(); ++i) { + if (!(draft.arcs[i].radius >= 30.0)) { + draft.arcs[i].radius = 30.0; + } + if (draft.arcs[i].length <= 1e-6) { + draft.arcs[i].length = + i < solved_arc_lengths.size() ? solved_arc_lengths[i] : 60.0; + } + if (draft.arcs[i].transition_to_next < 0.0) { + draft.arcs[i].transition_to_next = 0.0; + } + } + if (!draft.arcs.empty()) { + draft.arcs.back().transition_to_next = 0.0; + draft.preview_next_r = draft.arcs.back().radius; + } + + niweleta.fits[gap] = {}; + niweleta.fits[gap].gap = static_cast(gap); + niweleta.fits[gap].mode = 0; + return true; +} + +void discard_basket_draft(BasketDraft& draft, NiweletaSpec* niweleta) { + if (draft.reedit_gap >= 0 && niweleta != nullptr && + static_cast(draft.reedit_gap) < niweleta->fits.size()) { + niweleta->fits[static_cast(draft.reedit_gap)] = + draft.reedit_backup; + } + draft = {}; +} + +bool anchor_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta, + std::size_t gap) { + if (!draft.active || draft.arcs.empty() || niweleta.straights.size() < 2) { + return false; + } + if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) { + return false; + } + + const GapFit previous = niweleta.fits[gap]; + const BasketDraft draft_backup = draft; + + GapFit fit{}; + fit.gap = static_cast(gap); + fit.mode = 3; + fit.entry_t = std::max(0.0, draft.entry_t); + fit.exit_t = std::max(0.0, draft.exit_t); + fit.arcs = draft.arcs; + for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) { + if (!(fit.arcs[i].radius >= 30.0)) { + fit.arcs[i].radius = 30.0; + } + if (!(fit.arcs[i].length > 0.0)) { + fit.arcs[i].length = 40.0; + } + } + fit.arcs.back().length = 0.0; + fit.arcs.back().transition_to_next = 0.0; + if (!(fit.arcs.back().radius >= 30.0)) { + fit.arcs.back().radius = 30.0; + } + for (std::size_t i = 1; i < fit.arcs.size(); ++i) { + if (std::abs(fit.arcs[i].radius - fit.arcs[i - 1].radius) < 1.0) { + fit.arcs[i].radius = std::max(30.0, fit.arcs[i - 1].radius * 0.75); + } + } + + niweleta.fits[gap] = fit; + niweleta.straights[gap].rel_kind = 0; + niweleta.straights[gap + 1].rel_kind = 0; + + const auto probe = maj0sted::editor::solve_project( + std::vector{niweleta}); + bool applied = false; + if (!probe.empty()) { + for (const auto& af : probe.front().applied_fits) { + if (af.gap == static_cast(gap) && af.mode == 3) { + applied = true; + break; + } + } + } + if (!applied) { + niweleta.fits[gap] = previous; + draft = draft_backup; + return false; + } + + draft = {}; + return true; +} + +FinishBasketDraftResult finish_basket_draft( + BasketDraft& draft, NiweletaSpec& niweleta, + std::vector& all_niwelety, int niweleta_index, + double finish_straight_m) { + FinishBasketDraftResult result; + if (!draft.active || draft.arcs.empty() || draft.attach_str < 0 || + static_cast(draft.attach_str) >= niweleta.straights.size()) { + result.status = "brak łuku"; + return result; + } + + if (draft.reedit_gap >= 0) { + const auto gap = static_cast(draft.reedit_gap); + const GapFit backup = draft.reedit_backup; + niweleta.fits[gap] = backup; + if (!anchor_basket_draft(draft, niweleta, gap)) { + draft.reedit_gap = static_cast(gap); + draft.reedit_backup = backup; + result.status = "nie da się (kierunek / L+KP)"; + return result; + } + result.ok = true; + result.status = "luka " + std::to_string(gap); + return result; + } + + auto polys = preview_basket_draft(draft); + double tipx = 0.0, tipy = 0.0, tipaz = 0.0; + if (!draft_end_pose(draft, polys, tipx, tipy, tipaz)) { + result.status = "brak końca kosza"; + return result; + } + + const NiweletaSpec backup = niweleta; + const BasketDraft draft_backup = draft; + const int attach = draft.attach_str; + + auto& entry = niweleta.straights[static_cast(attach)]; + if (draft.attach_end == 0) { + std::swap(entry.x1, entry.x2); + std::swap(entry.y1, entry.y2); + draft.attach_end = 1; + } + entry.x2 = draft.x0; + entry.y2 = draft.y0; + entry.rel_kind = 0; + + const double sl = std::max(20.0, finish_straight_m); + StraightSpec exit_st{}; + exit_st.x1 = tipx; + exit_st.y1 = tipy; + exit_st.x2 = tipx + std::sin(tipaz) * sl; + exit_st.y2 = tipy + std::cos(tipaz) * sl; + + const auto insert_at = static_cast(attach + 1); + for (auto& fit : niweleta.fits) { + if (fit.gap >= attach) { + ++fit.gap; + } + } + auto bump_rel = [&](int delta) { + for (std::size_t n = 0; n < all_niwelety.size(); ++n) { + for (auto& st : all_niwelety[n].straights) { + if (st.rel_kind != 0 && st.rel_niw == niweleta_index && + st.rel_str >= static_cast(insert_at)) { + st.rel_str += delta; + } + } + } + }; + bump_rel(+1); + niweleta.straights.insert( + niweleta.straights.begin() + static_cast(insert_at), + exit_st); + align_gap_fits(niweleta); + + if (!anchor_basket_draft(draft, niweleta, static_cast(attach))) { + bump_rel(-1); + niweleta = backup; + draft = draft_backup; + result.status = "nie da się (kierunek / L+KP)"; + return result; + } + + result.ok = true; + result.status = "kosz + " + std::to_string(static_cast(sl)) + " m"; + return result; +} + +} // namespace maj0sted::app diff --git a/maj0sted/src/app/editor_document.cpp b/maj0sted/src/app/editor_document.cpp index cec9818f..759bcc01 100644 --- a/maj0sted/src/app/editor_document.cpp +++ b/maj0sted/src/app/editor_document.cpp @@ -17,9 +17,9 @@ namespace maj0sted::app { using namespace maj0sted::domain; -using maj0sted::web::GapFit; -using maj0sted::web::NiweletaSpec; -using maj0sted::web::StraightSpec; +using maj0sted::editor::GapFit; +using maj0sted::editor::NiweletaSpec; +using maj0sted::editor::StraightSpec; namespace { @@ -201,6 +201,8 @@ std::string serialize_document(const EditorDocument& document) { out += " " + num(a.radius) + " " + num(a.length) + " " + num(a.transition_to_next); } + // catalogue length trails the arcs, so files written before it still parse (length stays 0) + out += " " + num(j.length); out += "\n"; } return out; @@ -397,7 +399,7 @@ EditorDocument deserialize_document(const std::string& text) { if (base + 2 >= e.size()) { throw std::runtime_error{"maj0sted: truncated 'efit2' arcs"}; } - f.arcs.push_back(maj0sted::web::CompoundArcSpec{ + f.arcs.push_back(maj0sted::editor::CompoundArcSpec{ to_double(e[base]), to_double(e[base + 1]), to_double(e[base + 2])}); } @@ -445,7 +447,7 @@ EditorDocument deserialize_document(const std::string& text) { if (e.size() < 12 || e[0] != "ejunc") { throw std::runtime_error{"maj0sted: bad 'ejunc' line"}; } - maj0sted::web::Junction junction; + maj0sted::editor::Junction junction; junction.through = static_cast(to_long(e[1])); junction.station = to_double(e[2]); junction.side = static_cast(to_long(e[3])); @@ -463,10 +465,15 @@ EditorDocument deserialize_document(const std::string& text) { if (base + 2 >= e.size()) { throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"}; } - junction.curve.arcs.push_back(maj0sted::web::CompoundArcSpec{ + junction.curve.arcs.push_back(maj0sted::editor::CompoundArcSpec{ to_double(e[base]), to_double(e[base + 1]), to_double(e[base + 2])}); } + // optional catalogue length after the arcs (absent in the earliest files) + const std::size_t after_arcs = 13 + static_cast(arc_count) * 3; + if (e.size() > after_arcs) { + junction.length = to_double(e[after_arcs]); + } document.junctions.push_back(std::move(junction)); } } @@ -476,9 +483,9 @@ EditorDocument deserialize_document(const std::string& text) { } 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"; + // Fallback only; the editor passes its own path (see plan_panel). Relative, so + // it lands under the working directory rather than at a filesystem root. + return "editor/project.m0s"; } bool save_project(const EditorDocument& document, const std::string& path) { diff --git a/maj0sted/src/app/straight_constraints.cpp b/maj0sted/src/app/straight_constraints.cpp new file mode 100644 index 00000000..65ce03c7 --- /dev/null +++ b/maj0sted/src/app/straight_constraints.cpp @@ -0,0 +1,112 @@ +#include "maj0sted/app/straight_constraints.hpp" + +#include +#include + +#include "maj0sted/domain/geometry/plan_element.hpp" +#include "maj0sted/domain/parallelism/parallelism_service.hpp" +#include "maj0sted/domain/value_objects/cartesian_position.hpp" + +namespace maj0sted::app { +namespace { + +using maj0sted::domain::CartesianPosition; +using maj0sted::domain::ParallelismService; +using maj0sted::domain::Straight; +using maj0sted::editor::NiweletaSpec; +using maj0sted::editor::StraightSpec; + +Straight to_straight(const StraightSpec& s) { + return Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}}; +} + +} // namespace + +void align_gap_fits(NiweletaSpec& niweleta) { + const auto gaps = niweleta.straights.empty() ? std::size_t{0} + : niweleta.straights.size() - 1; + std::vector slots(gaps); + for (std::size_t gap = 0; gap < gaps; ++gap) { + slots[gap].gap = static_cast(gap); + } + for (const auto& fit : niweleta.fits) { + if (fit.gap >= 0 && static_cast(fit.gap) < gaps) { + slots[static_cast(fit.gap)] = fit; + } + } + niweleta.fits = std::move(slots); +} + +void apply_straight_constraints(std::vector& niwelety) { + for (int pass = 0; pass < 3; ++pass) { + bool changed = false; + for (auto& niweleta : niwelety) { + for (auto& st : niweleta.straights) { + if (st.rel_kind == 0 || st.rel_niw < 0 || st.rel_str < 0) { + continue; + } + if (static_cast(st.rel_niw) >= niwelety.size()) { + continue; + } + const auto& ref_niw = niwelety[static_cast(st.rel_niw)]; + if (static_cast(st.rel_str) >= ref_niw.straights.size()) { + continue; + } + const auto& ref = ref_niw.straights[static_cast(st.rel_str)]; + if (&ref == &st) { + continue; + } + + const double dx = ref.x2 - ref.x1; + const double dy = ref.y2 - ref.y1; + const double len = std::hypot(dx, dy); + if (len < 1e-6) { + continue; + } + + double x1 = st.x1, y1 = st.y1, x2 = st.x2, y2 = st.y2; + if (st.rel_kind == 1) { + const Straight projected = ParallelismService::project_onto_offset_line( + to_straight(ref), st.rel_offset, to_straight(st)); + x1 = projected.start().x(); + y1 = projected.start().y(); + x2 = projected.end().x(); + y2 = projected.end().y(); + } else if (st.rel_kind == 2) { + const double a = + std::atan2(1.0, std::max(1e-9, st.rel_cot)) * + (st.rel_side < 0 ? -1.0 : 1.0); + const double ux = dx / len; + const double uy = dy / len; + const double ca = std::cos(a); + const double sa = std::sin(a); + const double rx = ux * ca - uy * sa; + const double ry = ux * sa + uy * ca; + double L = std::hypot(st.x2 - st.x1, st.y2 - st.y1); + if (L < 1.0) { + L = st.rel_length > 0.0 ? st.rel_length : len; + } + st.rel_length = L; + x1 = st.x1; + y1 = st.y1; + x2 = st.x1 + rx * L; + y2 = st.y1 + ry * L; + } + + if (std::abs(st.x1 - x1) > 1e-9 || std::abs(st.y1 - y1) > 1e-9 || + std::abs(st.x2 - x2) > 1e-9 || std::abs(st.y2 - y2) > 1e-9) { + st.x1 = x1; + st.y1 = y1; + st.x2 = x2; + st.y2 = y2; + changed = true; + } + } + } + if (!changed) { + break; + } + } +} + +} // namespace maj0sted::app diff --git a/maj0sted/src/domain/parallelism_service.cpp b/maj0sted/src/domain/parallelism_service.cpp index 874326a8..34f93e2c 100644 --- a/maj0sted/src/domain/parallelism_service.cpp +++ b/maj0sted/src/domain/parallelism_service.cpp @@ -55,4 +55,29 @@ double ParallelismService::perpendicular_distance(const Straight& origin, return std::abs(d_east * normal.east + d_north * normal.north); } +Straight ParallelismService::project_onto_offset_line(const Straight& origin, + double offset_m, + const Straight& segment) { + const double az = origin.azimuth().radians(); + const double ux = std::sin(az); + const double uy = std::cos(az); + const Vec normal = left_normal(origin.azimuth()); + const double ox = origin.start().x() + normal.east * offset_m; + const double oy = origin.start().y() + normal.north * offset_m; + + auto station = [&](double x, double y) { + return (x - ox) * ux + (y - oy) * uy; + }; + double t1 = station(segment.start().x(), segment.start().y()); + double t2 = station(segment.end().x(), segment.end().y()); + if (std::abs(t2 - t1) < 1.0) { + const double tm = 0.5 * (t1 + t2); + const double half = (t2 >= t1) ? 0.5 : -0.5; + t1 = tm - half; + t2 = tm + half; + } + return Straight{CartesianPosition{ox + ux * t1, oy + uy * t1}, + CartesianPosition{ox + ux * t2, oy + uy * t2}}; +} + } // namespace maj0sted::domain diff --git a/maj0sted/src/domain/turnout.cpp b/maj0sted/src/domain/turnout.cpp new file mode 100644 index 00000000..d87e6c4f --- /dev/null +++ b/maj0sted/src/domain/turnout.cpp @@ -0,0 +1,127 @@ +#include "maj0sted/domain/geometry/turnout.hpp" + +#include + +namespace maj0sted::domain { + +using geometry::layout_segment; +using geometry::Pose; +using geometry::XY; + +double CrossingMark::angle() const noexcept { + return denominator_ > 0.0 ? std::atan(1.0 / denominator_) : 0.0; +} + +namespace { + +// The deflection (radians) a segment of linearly varying curvature turns through. +double deflection(const TurnoutSegment& segment) { + return 0.5 * (segment.k0 + segment.k1) * segment.length; +} + +// Builds the diverging curve as signed-curvature segments that together turn +// through exactly @p total_angle. Every part but the closing arc is fixed; the +// closing arc's length is chosen to spend whatever deflection is left. Returns +// false when a radius is non-positive or the fixed parts overshoot the angle. +bool build_curve(const DivergingCurve& curve, double total_angle, double sign, + std::vector& out) { + if (curve.arcs.empty()) return false; + const double first_radius = curve.arcs.front().radius; + const double last_radius = curve.arcs.back().radius; + if (first_radius <= 0.0 || last_radius <= 0.0) return false; + + double consumed = 0.0; // deflection of everything before the closing arc + + if (curve.entry_transition > 0.0) { + out.push_back({0.0, sign / first_radius, curve.entry_transition}); + consumed += std::abs(deflection(out.back())); + } + + // every arc but the last contributes its fixed length, eased to the next + for (std::size_t i = 0; i + 1 < curve.arcs.size(); ++i) { + const double radius = curve.arcs[i].radius; + if (radius <= 0.0) return false; + out.push_back({sign / radius, sign / radius, curve.arcs[i].length}); + consumed += std::abs(deflection(out.back())); + + const double next_radius = curve.arcs[i + 1].radius; + if (curve.arcs[i].transition_to_next > 0.0 && next_radius > 0.0) { + out.push_back({sign / radius, sign / next_radius, + curve.arcs[i].transition_to_next}); + consumed += std::abs(deflection(out.back())); + } + } + + const double exit_deflection = 0.5 * curve.exit_transition / last_radius; + const double closing = total_angle - consumed - exit_deflection; + if (closing <= 0.0) return false; // the fixed parts already reach the crossing angle + + out.push_back({sign / last_radius, sign / last_radius, closing * last_radius}); + if (curve.exit_transition > 0.0) { + out.push_back({sign / last_radius, 0.0, curve.exit_transition}); + } + return true; +} + +// Advances @p pose along @p segment (without sampling points; that is the +// renderer's job) and returns the end pose. +Pose advance(const TurnoutSegment& segment, const Pose& pose) { + return layout_segment(segment.k0, segment.k1, segment.length, pose, nullptr); +} + +// Where the through tangent (through @p a along @p a heading) meets the diverging +// tangent (through @p b along @p b heading). Writes the two tangent lengths; both +// stay zero when the tangents are parallel (a straight-through, angle-less turnout). +void tangent_lengths(const Pose& a, const Pose& b, double& front, double& back) { + const double denominator = a.hx * b.hy - a.hy * b.hx; + if (std::abs(denominator) < 1e-9) return; + const double u = ((b.x - a.x) * b.hy - (b.y - a.y) * b.hx) / denominator; + const double cx = a.x + a.hx * u; + const double cy = a.y + a.hy * u; + front = std::hypot(cx - a.x, cy - a.y); + back = std::hypot(cx - b.x, cy - b.y); +} + +} // namespace + +TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout) { + TurnoutGeometry geometry; + + const double angle = turnout.mark.angle(); + if (angle <= 0.0) return geometry; + const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0; + + if (!build_curve(turnout.curve, angle, sign, geometry.path)) { + geometry.path.clear(); + return geometry; + } + + // walk the curve to the frog direction (the crossing angle) + Pose pose = start; + for (const auto& segment : geometry.path) { + pose = advance(segment, pose); + } + + // a turnout always runs on past the curve as a straight frog rail. its length + // takes the switch to its catalogue length, measured along the through track; + // without a catalogue length a tangent-length rail keeps a frog rail present. + const double through_projection = + (pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy; + const double cos_angle = pose.hx * start.hx + pose.hy * start.hy; + double rail = turnout.length > 0.0 && cos_angle > 1e-6 + ? (turnout.length - through_projection) / cos_angle + : turnout.curve.arcs.back().radius * std::tan(angle * 0.5); + if (rail > 0.0) { + const TurnoutSegment frog_rail{0.0, 0.0, rail}; + geometry.path.push_back(frog_rail); + pose = advance(frog_rail, pose); + } + + geometry.valid = true; + geometry.frog = pose; + for (const auto& segment : geometry.path) geometry.diverging_length += segment.length; + tangent_lengths(start, pose, geometry.tangent_front, geometry.tangent_back); + return geometry; +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/web/editor.cpp b/maj0sted/src/editor/editor.cpp similarity index 78% rename from maj0sted/src/web/editor.cpp rename to maj0sted/src/editor/editor.cpp index d8710607..5338a486 100644 --- a/maj0sted/src/web/editor.cpp +++ b/maj0sted/src/editor/editor.cpp @@ -1,4 +1,4 @@ -#include "maj0sted/web/editor.hpp" +#include "maj0sted/editor/editor.hpp" #include #include @@ -9,6 +9,7 @@ #include "maj0sted/domain/fitting/gap_fitter.hpp" #include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/domain/geometry/turnout.hpp" #include "maj0sted/maj0sted.hpp" #include "maj0sted/render/rail_renderer.hpp" #include "maj0sted/render/track_renderer.hpp" @@ -19,7 +20,7 @@ // 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 { +namespace maj0sted::editor { using namespace maj0sted::domain; using maj0sted::domain::geometry::layout_segment; @@ -156,19 +157,19 @@ std::vector sample_centrelines( return axis; } -// Renders the centreline elements to WebPolylines through the renderer +// Renders the centreline elements to PlanPolylines through the renderer // interface. A different render::TrackRenderer could be dropped in here without // touching anything above. -std::vector render_rails( +std::vector render_rails( const std::vector& axis) { render::RailRenderer rail_renderer; render::TrackRenderer& renderer = rail_renderer; for (const auto& element : axis) renderer.add(element); - std::vector polylines; + std::vector polylines; polylines.reserve(rail_renderer.rails().size()); for (const auto& rail : rail_renderer.rails()) { - WebPolyline poly; + PlanPolyline poly; poly.kind = static_cast(rail.kind); poly.straight_index = rail.straight_index; poly.gap = rail.gap; @@ -177,7 +178,7 @@ std::vector render_rails( 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}); + for (const auto& p : rail.points) poly.points.push_back(PlanPoint{p.x, p.y}); polylines.push_back(std::move(poly)); } return polylines; @@ -226,7 +227,7 @@ NiweletaPolys build(const NiweletaSpec& spec) { const auto& last = result.centreline.back(); if (std::hypot(p.x - last.x, p.y - last.y) < 1e-9) continue; } - result.centreline.push_back(WebPoint{p.x, p.y}); + result.centreline.push_back(PlanPoint{p.x, p.y}); } } @@ -238,7 +239,7 @@ NiweletaPolys build(const NiweletaSpec& spec) { // The pose (position + unit heading) at arc length @p station along a centreline // polyline. Clamped to the ends; heading comes from the segment the station falls // on. Returns false only for a centreline too short to have a direction. -bool pose_at(const std::vector& centreline, double station, Pose& out) { +bool pose_at(const std::vector& centreline, double station, Pose& out) { if (centreline.size() < 2) return false; double travelled = 0.0; @@ -261,36 +262,32 @@ bool pose_at(const std::vector& centreline, double station, Pose& out) return false; } -// Samples a fitted curve chain into centreline elements, starting from @p start. -// Same layout the gap fits go through, so the switch curve is drawn identically. -std::vector sample_curve( - const std::vector& curve, Pose start) { - std::vector axis; - Pose pose = start; - int element_index = 0; - for (const auto& element : curve) { - double k0 = 0.0, k1 = 0.0, length = 0.0; - render::CentrelineElement out; - out.kind = sample_params(element, k0, k1, length); - std::vector pts; - pose = layout_segment(k0, k1, length, pose, &pts); - out.points.reserve(pts.size()); - for (const auto& p : pts) out.points.push_back(render::Point{p.x, p.y}); - out.element_index = element_index++; - out.length = length; - out.radius_start = radius_of(k0); - out.radius_end = radius_of(k1); - axis.push_back(std::move(out)); +// Translates the editor's switch DTO into a domain Turnout: a plain-arc curve +// (mode 1) or a basket of arcs eased by clothoids (mode 3). All the turnout +// business — turning to the crossing angle, closing the last arc, the frog rail — +// lives in the domain; nothing here decides geometry. +domain::Turnout to_turnout(const Junction& junction) { + domain::DivergingCurve curve; + if (junction.curve.mode == 3 && junction.curve.arcs.size() >= 2) { + for (const auto& arc : junction.curve.arcs) { + curve.arcs.push_back( + domain::TurnoutArc{arc.radius, arc.length, arc.transition_to_next}); + } + curve.entry_transition = junction.curve.entry_t; + curve.exit_transition = junction.curve.exit_t; + } else { + curve.arcs.push_back(domain::TurnoutArc{junction.curve.radius, 0.0, 0.0}); } - return axis; + return domain::Turnout{ + domain::CrossingMark{junction.crossing_n}, + junction.side == 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right, + std::move(curve), junction.length}; } -// The diverging curve and frog of one switch, from the solved through niweleta. -// The switch's theoretical point sits at @p station on the through track; two -// tangent lines cross there (the through heading and that heading turned by the -// crossing angle), and the internal curve is fitted to round the corner between -// them — a plain arc, or a compound basket, whatever the switch asks for. The -// branch begins where the curve ends. +// The drawable geometry of one switch. @p station is the switch's start on the +// through track (początek rozjazdu) — the point the user clicks. The diverging +// path and frog come from the domain; here we only place them on the through +// track and sample them into rails. JunctionGeom solve_junction(const Junction& junction, const std::vector& solved) { JunctionGeom geom; @@ -298,61 +295,50 @@ JunctionGeom solve_junction(const Junction& junction, static_cast(junction.through) >= solved.size()) { return geom; } - if (junction.crossing_n <= 0.0) return geom; - Pose theoretical{}; - if (!pose_at(solved[junction.through].centreline, junction.station, - theoretical)) { + Pose start{}; + if (!pose_at(solved[junction.through].centreline, junction.station, start)) { return geom; } - // a trailing switch opens against the running direction: flip the heading - double thx = theoretical.hx; - double thy = theoretical.hy; if (!junction.facing) { - thx = -thx; - thy = -thy; + start.hx = -start.hx; + start.hy = -start.hy; } - // the diverging tangent line: the through heading turned by the crossing - // angle, left (positive) or right (negative) - const double angle = - (junction.side == 0 ? 1.0 : -1.0) * std::atan(1.0 / junction.crossing_n); - const double dhx = thx * std::cos(angle) - thy * std::sin(angle); - const double dhy = thx * std::sin(angle) + thy * std::cos(angle); + const auto turnout = domain::lay_turnout(start, to_turnout(junction)); + if (!turnout.valid) return geom; - // two long guide straights meeting at the theoretical point X; the fitter - // rounds the corner and hands back the tangent points that become the switch - // start and the frog. long enough that the tangent points land on them. - const double guide = 5000.0; - const CartesianPosition x{theoretical.x, theoretical.y}; - Straight entry{CartesianPosition{x.x() - thx * guide, x.y() - thy * guide}, x}; - Straight exit{x, CartesianPosition{x.x() + dhx * guide, x.y() + dhy * guide}}; - - const auto request = to_request(junction.curve); - if (!request) return geom; // a switch with no curve is not a switch - const auto connection = GapFitter::connect(entry, exit, *request); - if (!connection) return geom; - - const FitResult& fit = connection->fit; - const CartesianPosition s = fit.tangent_in; // switch start - const CartesianPosition f = fit.tangent_out; // frog + // sample the domain segments into drawable rails + std::vector axis; + Pose pose = start; + for (const auto& segment : turnout.path) { + std::vector pts; + render::CentrelineElement element; + element.kind = + std::abs(segment.k1 - segment.k0) >= 1e-12 ? render::ElementKind::Transition + : std::abs(segment.k0) >= 1e-12 ? render::ElementKind::Arc + : render::ElementKind::Straight; + pose = layout_segment(segment.k0, segment.k1, segment.length, pose, &pts); + element.points.reserve(pts.size()); + for (const auto& p : pts) element.points.push_back(render::Point{p.x, p.y}); + element.length = segment.length; + element.radius_start = radius_of(segment.k0); + element.radius_end = radius_of(segment.k1); + axis.push_back(std::move(element)); + } geom.valid = true; - geom.px = s.x(); - geom.py = s.y(); - geom.fx = f.x(); - geom.fy = f.y(); - geom.fhx = dhx; - geom.fhy = dhy; - // theoretical tangent lengths: from the crossing point to each tangent point - geom.tangent_front = std::hypot(s.x() - x.x(), s.y() - x.y()); - geom.tangent_back = std::hypot(f.x() - x.x(), f.y() - x.y()); - - const auto axis = sample_curve(fit.curve, Pose{s.x(), s.y(), thx, thy}); - for (const auto& element : axis) geom.length += element.length; + geom.px = start.x; + geom.py = start.y; + geom.fx = turnout.frog.x; + geom.fy = turnout.frog.y; + geom.fhx = turnout.frog.hx; + geom.fhy = turnout.frog.hy; + geom.tangent_front = turnout.tangent_front; + geom.tangent_back = turnout.tangent_back; + geom.length = turnout.diverging_length; geom.polylines = render_rails(axis); - return geom; } @@ -405,4 +391,4 @@ LayoutSolution solve_layout(std::vector niwelety, return out; } -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/src/web/ribbon.cpp b/maj0sted/src/editor/ribbon.cpp similarity index 83% rename from maj0sted/src/web/ribbon.cpp rename to maj0sted/src/editor/ribbon.cpp index 21d82334..e6db4c1a 100644 --- a/maj0sted/src/web/ribbon.cpp +++ b/maj0sted/src/editor/ribbon.cpp @@ -1,17 +1,17 @@ -#include "maj0sted/web/ribbon.hpp" +#include "maj0sted/editor/ribbon.hpp" #include #include "maj0sted/domain/geometry/segment_layout.hpp" -namespace maj0sted::web { +namespace maj0sted::editor { using maj0sted::domain::geometry::layout_segment; using maj0sted::domain::geometry::Pose; using maj0sted::domain::geometry::XY; -std::vector solve_ribbon(const RibbonRequest& request) { - std::vector result; +std::vector solve_ribbon(const RibbonRequest& request) { + std::vector result; result.reserve(request.elements.size()); Pose pose{request.x0, request.y0, std::sin(request.az0), std::cos(request.az0)}; @@ -41,15 +41,15 @@ std::vector solve_ribbon(const RibbonRequest& request) { std::vector points; pose = layout_segment(k0, k1, length, pose, &points); - WebPolyline polyline; + PlanPolyline polyline; polyline.kind = kind; polyline.points.reserve(points.size()); for (const auto& p : points) { - polyline.points.push_back(WebPoint{p.x, p.y}); + polyline.points.push_back(PlanPoint{p.x, p.y}); } result.push_back(std::move(polyline)); } return result; } -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/src/web/solve.cpp b/maj0sted/src/editor/solve.cpp similarity index 88% rename from maj0sted/src/web/solve.cpp rename to maj0sted/src/editor/solve.cpp index 19caa322..dfc05aeb 100644 --- a/maj0sted/src/web/solve.cpp +++ b/maj0sted/src/editor/solve.cpp @@ -1,11 +1,11 @@ -#include "maj0sted/web/solve.hpp" +#include "maj0sted/editor/solve.hpp" #include #include "maj0sted/maj0sted.hpp" #include "maj0sted/render/plan_sampler.hpp" -namespace maj0sted::web { +namespace maj0sted::editor { using namespace maj0sted::domain; @@ -41,15 +41,15 @@ FitResult make_fit(const Niweleta& niweleta, StraightId entry, StraightId exit, : std::nullopt}); } -std::vector to_polylines(const render::Scene& scene) { - std::vector result; +std::vector to_polylines(const render::Scene& scene) { + std::vector result; result.reserve(scene.polylines.size()); for (const auto& polyline : scene.polylines) { - WebPolyline out; + PlanPolyline out; out.kind = static_cast(polyline.kind); out.points.reserve(polyline.points.size()); for (const auto& p : polyline.points) { - out.points.push_back(WebPoint{p.x, p.y}); + out.points.push_back(PlanPoint{p.x, p.y}); } result.push_back(std::move(out)); } @@ -58,7 +58,7 @@ std::vector to_polylines(const render::Scene& scene) { } // namespace -std::vector solve_scene(const SolveRequest& request) { +std::vector solve_scene(const SolveRequest& request) { Niweleta niweleta{NiweletaId{1}}; const CartesianPosition a{request.ax, request.ay}; const CartesianPosition b{request.bx, request.by}; @@ -95,4 +95,4 @@ std::vector solve_scene(const SolveRequest& request) { return to_polylines(render::sample(niweleta.plan())); } -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/src/web/tile_cache.cpp b/maj0sted/src/editor/tile_cache.cpp similarity index 70% rename from maj0sted/src/web/tile_cache.cpp rename to maj0sted/src/editor/tile_cache.cpp index 4022984a..b5dae0c8 100644 --- a/maj0sted/src/web/tile_cache.cpp +++ b/maj0sted/src/editor/tile_cache.cpp @@ -1,4 +1,4 @@ -#include "maj0sted/web/tile_cache.hpp" +#include "maj0sted/editor/tile_cache.hpp" #include #include @@ -7,14 +7,7 @@ #include #include -#ifdef __EMSCRIPTEN__ -#include - -#include -#include -#endif - -namespace maj0sted::web { +namespace maj0sted::editor { namespace { @@ -33,17 +26,23 @@ constexpr int kMaxRetries = 10; // --- TileGrid ------------------------------------------------------------ -double TileGrid::resolution(int /*zoom*/) noexcept { - return kCellMetres / static_cast(kTilePixels); // constant, single grid +double TileGrid::cell_metres(const TileKey& key) noexcept { + return key.zoom >= kCellZoomMin ? static_cast(key.zoom) : kCellMetres; } -double TileGrid::tile_span(int /*zoom*/) noexcept { - return kCellMetres; // every cell is 100 m × 100 m +double TileGrid::resolution(int zoom) noexcept { + return tile_span(zoom) / static_cast(kTilePixels); } -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); +double TileGrid::tile_span(int zoom) noexcept { + return zoom >= kCellZoomMin ? static_cast(zoom) : kCellMetres; +} + +TileKey TileGrid::tile_at(double x, double y, int zoom) noexcept { + const double cell = tile_span(zoom); + const int key_zoom = zoom >= kCellZoomMin ? zoom : kLevel; + const auto safe_index = [cell](double coordinate, double origin) { + const double value = std::floor((coordinate - origin) / cell); if (!std::isfinite(value)) return 0; if (value <= static_cast(std::numeric_limits::min())) { return std::numeric_limits::min(); @@ -53,20 +52,21 @@ TileKey TileGrid::tile_at(double x, double y, int /*zoom*/) noexcept { } return static_cast(value); }; - return TileKey{kLevel, safe_index(x, kOriginX), safe_index(y, kOriginY)}; + return TileKey{key_zoom, safe_index(x, kOriginX), safe_index(y, kOriginY)}; } TileBBox TileGrid::bbox(const TileKey& key) noexcept { - const double min_x = kOriginX + static_cast(key.x) * kCellMetres; - const double min_y = kOriginY + static_cast(key.y) * kCellMetres; - return TileBBox{min_x, min_y, min_x + kCellMetres, min_y + kCellMetres}; + const double cell = cell_metres(key); + const double min_x = kOriginX + static_cast(key.x) * cell; + const double min_y = kOriginY + static_cast(key.y) * cell; + return TileBBox{min_x, min_y, min_x + cell, min_y + cell}; } int TileGrid::zoom_for_resolution(double /*metres_per_pixel*/) noexcept { return kLevel; // one fixed 100 m grid, no pyramid } -std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/, +std::vector TileGrid::tiles_for_view(const TileBBox& view, int zoom, int max_tiles) { std::vector tiles; if (max_tiles <= 0 || !std::isfinite(view.min_x) || @@ -79,8 +79,8 @@ std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/ 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); + const TileKey lo = tile_at(min_x, min_y, zoom); + const TileKey hi = tile_at(max_x, max_y, zoom); using Wide = std::int64_t; const Wide width = static_cast(hi.x) - lo.x + 1; @@ -113,11 +113,12 @@ std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/ centre_y - rows / 2, static_cast(lo.y), static_cast(hi.y) - rows + 1); + const int key_zoom = zoom >= kCellZoomMin ? zoom : kLevel; tiles.reserve(static_cast(columns * rows)); for (Wide row = first_row; row < first_row + rows; ++row) { for (Wide col = first_col; col < first_col + columns; ++col) { tiles.push_back( - TileKey{kLevel, static_cast(col), static_cast(row)}); + TileKey{key_zoom, static_cast(col), static_cast(row)}); } } return tiles; @@ -127,8 +128,8 @@ std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/ 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. + // Public Geoportal ortophoto WMS (EPSG:2180). Swap base_url/layers via + // set_config() from the host to point at a different backend. cfg.base_url = "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution"; cfg.layers = "Raster"; @@ -233,59 +234,11 @@ void TileService::complete_fetch(const TileKey& key, bool ok, } } -#ifdef __EMSCRIPTEN__ -namespace { -// Delay between retry attempts, so a persistently failing endpoint is retried -// steadily rather than in a tight loop. -constexpr int kRetryDelayMs = 1500; - -struct FetchCtx { - TileService* self; - TileKey key; -}; - -struct RetryCtx { - TileService* self; - TileKey key; -}; - -void on_retry(void* user_data) { - auto* ctx = static_cast(user_data); - TileService* self = ctx->self; - const TileKey key = ctx->key; - delete ctx; - self->retry_now(key); -} - -void on_fetch_success(emscripten_fetch_t* fetch) { - auto* ctx = static_cast(fetch->userData); - std::vector bytes( - reinterpret_cast(fetch->data), - reinterpret_cast(fetch->data) + - static_cast(fetch->numBytes)); - TileService* self = ctx->self; - const TileKey key = ctx->key; - delete ctx; - emscripten_fetch_close(fetch); - self->complete_fetch(key, true, std::move(bytes), {}); -} - -void on_fetch_error(emscripten_fetch_t* fetch) { - auto* ctx = static_cast(fetch->userData); - TileService* self = ctx->self; - const TileKey key = ctx->key; - delete ctx; - emscripten_fetch_close(fetch); - self->complete_fetch(key, false, {}, {}); -} -} // namespace -#endif - void TileService::begin_fetch(const TileKey& key) { const std::string url = url_for(key); - // An injected fetcher wins on every platform (used by native tests and any - // host that wants to route the request itself). + // The host injects the fetcher (the editor's threaded downloader, or a test + // stub). Without one there is no transport, so the tile simply fails. if (fetcher_) { const TileKey captured = key; fetcher_(url, [this, captured](bool ok, std::vector bytes, @@ -294,33 +247,15 @@ void TileService::begin_fetch(const TileKey& key) { }); 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. + // Re-issue at once; the injected fetcher owns the timing, so a fetcher that + // eventually succeeds ends the retry loop. begin_fetch(key); -#endif } -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/src/io/scn_export.cpp b/maj0sted/src/io/scn_export.cpp new file mode 100644 index 00000000..bd51bfb5 --- /dev/null +++ b/maj0sted/src/io/scn_export.cpp @@ -0,0 +1,367 @@ +#include "maj0sted/io/scn_export.hpp" + +#include +#include +#include +#include +#include + +namespace maj0sted::io { +namespace { + +using maj0sted::editor::NiweletaPolys; +using maj0sted::editor::PlanPoint; +using maj0sted::editor::PlanPolyline; + +struct AxisElement { + int kind{0}; + 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}; + std::vector points; +}; + +struct WorldXYZ { + double x{0.0}; + double y{0.0}; + double z{0.0}; +}; + +std::vector recover_axis(const NiweletaPolys& solved) { + std::vector out; + const auto& polys = solved.polylines; + for (std::size_t i = 0; i < polys.size();) { + const auto& a = polys[i]; + if (a.points.size() < 2) { + ++i; + continue; + } + if (i + 1 < polys.size()) { + const auto& b = polys[i + 1]; + if (b.straight_index == a.straight_index && b.gap == a.gap && + b.element_index == a.element_index && + b.points.size() == a.points.size()) { + AxisElement el; + el.kind = a.kind; + el.straight_index = a.straight_index; + el.gap = a.gap; + el.element_index = a.element_index; + el.length = a.length; + el.radius_start = a.radius_start; + el.radius_end = a.radius_end; + el.points.resize(a.points.size()); + for (std::size_t k = 0; k < a.points.size(); ++k) { + el.points[k] = {(a.points[k].x + b.points[k].x) * 0.5, + (a.points[k].y + b.points[k].y) * 0.5}; + } + out.push_back(std::move(el)); + i += 2; + continue; + } + } + AxisElement el; + el.kind = a.kind; + el.straight_index = a.straight_index; + el.gap = a.gap; + el.element_index = a.element_index; + el.length = a.length; + el.radius_start = a.radius_start; + el.radius_end = a.radius_end; + el.points = a.points; + out.push_back(std::move(el)); + ++i; + } + return out; +} + +WorldXYZ to_scn(double east, double north, double height, double ox, double oy) { + return {east - ox, height, -(north - oy)}; +} + +void plan_tangent_to_scn(double de, double dn, double& out_x, double& out_z) { + const double len = std::hypot(de, dn); + if (len < 1e-12) { + out_x = 0.0; + out_z = 1.0; + return; + } + out_x = de / len; + out_z = -dn / len; +} + +bool axis_pose_at(const std::vector& pts, double along, PlanPoint& out_p, + double& out_tx, double& out_ty) { + if (pts.size() < 2) { + return false; + } + double a = 0.0; + for (std::size_t i = 1; i < pts.size(); ++i) { + const double dx = pts[i].x - pts[i - 1].x; + const double dy = pts[i].y - pts[i - 1].y; + const double seg = std::hypot(dx, dy); + if (a + seg >= along || i + 1 == pts.size()) { + const double t = seg > 1e-12 ? std::clamp((along - a) / seg, 0.0, 1.0) : 0.0; + out_p = {pts[i - 1].x + dx * t, pts[i - 1].y + dy * t}; + if (seg > 1e-12) { + out_tx = dx / seg; + out_ty = dy / seg; + } else { + out_tx = 1.0; + out_ty = 0.0; + } + return true; + } + a += seg; + } + return false; +} + +double axis_total_length(const std::vector& pts) { + double along = 0.0; + for (std::size_t i = 1; i < pts.size(); ++i) { + along += std::hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); + } + return along; +} + +double circular_bezier_handle(double radius, double phi) { + const double p = std::clamp(phi, 1e-6, std::numbers::pi); + return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(p * 0.25); +} + +bool fit_clothoid_bezier_handles(const std::vector& pts, double s0, + double s1, const PlanPoint& p0, const PlanPoint& p3, + double t0x, double t0y, double t1x, double t1y, + double& out_l0, double& out_l1) { + const double span = s1 - s0; + if (span < 1e-6) { + return false; + } + const int samples = std::max(8, static_cast(std::ceil(span / 2.0))); + double m00 = 0.0, m01 = 0.0, m11 = 0.0, b0 = 0.0, b1 = 0.0; + for (int i = 0; i <= samples; ++i) { + const double t = static_cast(i) / static_cast(samples); + PlanPoint q{}; + double tx = 1.0, ty = 0.0; + if (!axis_pose_at(pts, s0 + span * t, q, tx, ty)) { + continue; + } + const double omt = 1.0 - t; + const double c0 = 3.0 * omt * omt * t; + const double c1 = 3.0 * omt * t * t; + const double ax = + omt * omt * (1.0 + 2.0 * t) * p0.x + t * t * (3.0 - 2.0 * t) * p3.x; + const double ay = + omt * omt * (1.0 + 2.0 * t) * p0.y + t * t * (3.0 - 2.0 * t) * p3.y; + const double rx = q.x - ax; + const double ry = q.y - ay; + const double a0x = c0 * t0x, a0y = c0 * t0y; + const double a1x = -c1 * t1x, a1y = -c1 * t1y; + m00 += a0x * a0x + a0y * a0y; + m01 += a0x * a1x + a0y * a1y; + m11 += a1x * a1x + a1y * a1y; + b0 += a0x * rx + a0y * ry; + b1 += a1x * rx + a1y * ry; + } + const double det = m00 * m11 - m01 * m01; + if (std::abs(det) < 1e-12) { + return false; + } + const double l0 = (m11 * b0 - m01 * b1) / det; + const double l1 = (-m01 * b0 + m00 * b1) / det; + if (l0 < 0.05 || l1 < 0.05) { + return false; + } + out_l0 = std::clamp(l0, 0.05, span); + out_l1 = std::clamp(l1, 0.05, span); + return true; +} + +void clothoid_handles_from_radii(double len, double phi, double r0, double r1, + double& out_l0, double& out_l1) { + auto handle = [&](double r) { + if (r < 1.0) { + return len / 3.0; + } + return circular_bezier_handle(r, phi); + }; + out_l0 = handle(r0); + out_l1 = handle(r1); +} + +void write_scn_track(std::ostream& out, const std::string& name, const WorldXYZ& p1, + const WorldXYZ& cv1, const WorldXYZ& p2, const WorldXYZ& cv2, + double length, double radius) { + out << "node -1 0 " << name << " track normal " << length + << " 1.435 0.15 25.0 20 0 flat vis\n"; + out << " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n"; + auto fmt = [](double v) { + std::ostringstream s; + s.setf(std::ios::fixed); + s.precision(4); + s << v; + return s.str(); + }; + out << fmt(p1.x) << ' ' << fmt(p1.y) << ' ' << fmt(p1.z) << " 0\n"; + out << fmt(cv1.x) << ' ' << fmt(cv1.y) << ' ' << fmt(cv1.z) << '\n'; + out << fmt(cv2.x) << ' ' << fmt(cv2.y) << ' ' << fmt(cv2.z) << '\n'; + out << fmt(p2.x) << ' ' << fmt(p2.y) << ' ' << fmt(p2.z) << " 0\n"; + out << fmt(radius) << '\n'; + out << "endtrack\n\n"; +} + +} // namespace + +ScnExportOptions resolve_scn_origin(const std::vector& solved, + ScnExportOptions options) { + if (std::abs(options.origin_east) >= 1.0 || + std::abs(options.origin_north) >= 1.0) { + return options; + } + for (const auto& s : solved) { + const auto axis = recover_axis(s); + if (!axis.empty() && !axis.front().points.empty()) { + const auto& p = axis.front().points.front(); + if (std::abs(p.x) > 10000.0 || std::abs(p.y) > 10000.0) { + options.origin_east = p.x; + options.origin_north = p.y; + } + break; + } + } + return options; +} + +ScnExportResult export_scn(const std::vector& solved, + const ScnExportOptions& options, std::ostream& out) { + const auto opt = resolve_scn_origin(solved, options); + ScnExportResult result; + result.origin_east = opt.origin_east; + result.origin_north = opt.origin_north; + + out << "//$n plan\n"; + out << "//$d origin " << opt.origin_east << ' ' << opt.origin_north << "\n\n"; + + const double kRailY = opt.rail_y; + const double kMaxArcAngle = opt.max_arc_angle; + + for (std::size_t n = 0; n < solved.size(); ++n) { + const auto axis = recover_axis(solved[n]); + for (std::size_t ei = 0; ei < axis.size(); ++ei) { + const auto& el = axis[ei]; + if (el.points.size() < 2) { + continue; + } + const double total = + el.length > 1e-6 ? el.length : axis_total_length(el.points); + if (total < 0.05) { + continue; + } + + auto curvature_at = [&](double along) { + const double k0 = el.radius_start > 1.0 ? 1.0 / el.radius_start : 0.0; + const double k1 = el.radius_end > 1.0 ? 1.0 / el.radius_end : 0.0; + const double t = + total > 1e-9 ? std::clamp(along / total, 0.0, 1.0) : 0.0; + return k0 + (k1 - k0) * t; + }; + auto radius_from_k = [](double k) { + return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0; + }; + + auto emit_piece = [&](double s0, double s1, double piece_len, + double radius, const char* tag) { + PlanPoint p0{}, p1{}; + double t0x = 1.0, t0y = 0.0, t1x = 1.0, t1y = 0.0; + if (!axis_pose_at(el.points, s0, p0, t0x, t0y) || + !axis_pose_at(el.points, s1, p1, t1x, t1y)) { + return; + } + const auto w0 = to_scn(p0.x, p0.y, kRailY, opt.origin_east, + opt.origin_north); + const auto w1 = to_scn(p1.x, p1.y, kRailY, opt.origin_east, + opt.origin_north); + double tx0 = 0.0, tz0 = 0.0, tx1 = 0.0, tz1 = 0.0; + plan_tangent_to_scn(t0x, t0y, tx0, tz0); + plan_tangent_to_scn(t1x, t1y, tx1, tz1); + + WorldXYZ cv1{}, cv2{}; + double radius_out = 0.0; + if (el.kind == 1 && radius >= 1.0) { + const double phi = std::clamp(piece_len / std::max(radius, 1.0), + 1e-6, std::numbers::pi); + const double handle = circular_bezier_handle(radius, phi); + cv1 = {tx0 * handle, 0.0, tz0 * handle}; + cv2 = {-tx1 * handle, 0.0, -tz1 * handle}; + radius_out = radius; + } else if (el.kind == 2) { + const double cross = t0x * t1y - t0y * t1x; + const double dot = t0x * t1x + t0y * t1y; + const double phi = std::clamp(std::abs(std::atan2(cross, dot)), + 1e-6, std::numbers::pi); + const double r0 = radius_from_k(curvature_at(s0)); + const double r1 = radius_from_k(curvature_at(s1)); + double l0 = piece_len / 3.0; + double l1 = piece_len / 3.0; + if (!fit_clothoid_bezier_handles(el.points, s0, s1, p0, p1, t0x, + t0y, t1x, t1y, l0, l1)) { + clothoid_handles_from_radii(piece_len, phi, r0, r1, l0, l1); + } + cv1 = {tx0 * l0, 0.0, tz0 * l0}; + cv2 = {-tx1 * l1, 0.0, -tz1 * l1}; + } + + std::ostringstream name; + name << "plan_n" << n; + if (el.straight_index >= 0) { + name << "_s" << el.straight_index; + } else { + name << "_g" << el.gap << "_e" << el.element_index; + } + name << '_' << tag << '_' << result.tracks; + const auto track_name = name.str(); + if (result.first_track_name.empty()) { + result.first_track_name = track_name; + } + write_scn_track(out, track_name, w0, cv1, w1, cv2, piece_len, + radius_out); + ++result.tracks; + }; + + if (el.kind == 0 || (el.kind == 1 && el.radius_start < 1.0)) { + emit_piece(0.0, total, total, 0.0, "str"); + continue; + } + if (el.kind == 2) { + emit_piece(0.0, total, total, 0.0, "kp"); + continue; + } + + const double r = std::max(el.radius_start, el.radius_end); + const double phi = total / std::max(r, 1.0); + const int pieces = + std::max(1, static_cast(std::ceil(phi / kMaxArcAngle))); + const double piece_len = total / static_cast(pieces); + for (int p = 0; p < pieces; ++p) { + const double s0 = piece_len * static_cast(p); + const double s1 = + p + 1 == pieces ? total : piece_len * static_cast(p + 1); + emit_piece(s0, s1, s1 - s0, r, "arc"); + } + } + } + + out << "FirstInit\n\n"; + if (!result.first_track_name.empty()) { + out << "trainset none " << result.first_track_name << " 5 0\n"; + out << "node -1 0 plan_eu07 dynamic PKP\\303E_V1 303E-EP-TV-424-HIST " + "303E-EP-TV 0 headdriver 35.WH25 0 enddynamic\n"; + out << "endtrainset\n"; + } + return result; +} + +} // namespace maj0sted::io diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt index 3b908224..3d187671 100644 --- a/maj0sted/tests/CMakeLists.txt +++ b/maj0sted/tests/CMakeLists.txt @@ -33,10 +33,10 @@ 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_editor test_editor.cpp) +target_link_libraries(maj0sted_test_editor PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_editor PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME editor COMMAND maj0sted_test_editor) add_executable(maj0sted_test_tile_cache test_tile_cache.cpp) target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted) diff --git a/maj0sted/tests/test_document.cpp b/maj0sted/tests/test_document.cpp index 074347ba..cb59e5d2 100644 --- a/maj0sted/tests/test_document.cpp +++ b/maj0sted/tests/test_document.cpp @@ -5,9 +5,9 @@ #include "maj0sted/app/editor_document.hpp" using namespace maj0sted::app; -using maj0sted::web::GapFit; -using maj0sted::web::NiweletaSpec; -using maj0sted::web::StraightSpec; +using maj0sted::editor::GapFit; +using maj0sted::editor::NiweletaSpec; +using maj0sted::editor::StraightSpec; namespace { @@ -28,14 +28,28 @@ EditorDocument sample_document() { NiweletaSpec b; b.name = "Bocznica z spacją"; b.straights = {StraightSpec{500000.25, 300000.75, 500200.0, 300000.0}}; + b.straights[0].rel_kind = 1; + b.straights[0].rel_niw = 0; + b.straights[0].rel_str = 0; + b.straights[0].rel_offset = -4.5; doc.niwelety = {a, b}; + doc.view_x = 500100.5; + doc.view_y = 300050.25; + doc.view_extent = 420.0; + doc.origin_set = true; + doc.georeferenced = true; + doc.origin_x = 500000.0; + doc.origin_y = 300000.0; return doc; } bool same(const StraightSpec& x, const StraightSpec& y) { return x.x1 == y.x1 && x.y1 == y.y1 && x.x2 == y.x2 && x.y2 == y.y2 && - x.hidden == y.hidden; + x.hidden == y.hidden && x.rel_kind == y.rel_kind && + x.rel_niw == y.rel_niw && x.rel_str == y.rel_str && + x.rel_offset == y.rel_offset && x.rel_cot == y.rel_cot && + x.rel_side == y.rel_side && x.rel_length == y.rel_length; } bool same(const GapFit& x, const GapFit& y) { @@ -46,6 +60,13 @@ bool same(const GapFit& x, const GapFit& y) { } void check_equal(const EditorDocument& x, const EditorDocument& y) { + CHECK(x.view_x == y.view_x); + CHECK(x.view_y == y.view_y); + CHECK(x.view_extent == y.view_extent); + CHECK(x.origin_set == y.origin_set); + CHECK(x.georeferenced == y.georeferenced); + CHECK(x.origin_x == y.origin_x); + CHECK(x.origin_y == y.origin_y); CHECK(x.niwelety.size() == y.niwelety.size()); if (x.niwelety.size() != y.niwelety.size()) return; for (std::size_t n = 0; n < x.niwelety.size(); ++n) { diff --git a/maj0sted/tests/test_web.cpp b/maj0sted/tests/test_editor.cpp similarity index 96% rename from maj0sted/tests/test_web.cpp rename to maj0sted/tests/test_editor.cpp index b8a848cc..69b88367 100644 --- a/maj0sted/tests/test_web.cpp +++ b/maj0sted/tests/test_editor.cpp @@ -2,14 +2,14 @@ #include #include "check.hpp" -#include "maj0sted/web/editor.hpp" -#include "maj0sted/web/solve.hpp" +#include "maj0sted/editor/editor.hpp" +#include "maj0sted/editor/solve.hpp" -using namespace maj0sted::web; +using namespace maj0sted::editor; namespace { -bool has_kind(const std::vector& polylines, int kind) { +bool has_kind(const std::vector& polylines, int kind) { for (const auto& p : polylines) { if (p.kind == kind) return true; } @@ -120,7 +120,7 @@ void editor_emits_two_rails_at_standard_gauge() { 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; + const PlanPolyline* arc = nullptr; for (const auto& polyline : solved[0].polylines) { if (polyline.kind == 1) { arc = &polyline; @@ -138,7 +138,7 @@ void editor_emits_two_rails_at_standard_gauge() { void editor_exposes_transition_radius_range() { const auto solved = solve_project({short_corner(5.0, 2.0)}); - const WebPolyline* transition = nullptr; + const PlanPolyline* transition = nullptr; for (const auto& polyline : solved[0].polylines) { if (polyline.kind == 2) { transition = &polyline; diff --git a/maj0sted/tests/test_tile_cache.cpp b/maj0sted/tests/test_tile_cache.cpp index 0ac3da77..c2634670 100644 --- a/maj0sted/tests/test_tile_cache.cpp +++ b/maj0sted/tests/test_tile_cache.cpp @@ -4,9 +4,9 @@ #include #include "check.hpp" -#include "maj0sted/web/tile_cache.hpp" +#include "maj0sted/editor/tile_cache.hpp" -using namespace maj0sted::web; +using namespace maj0sted::editor; namespace { diff --git a/scene/scenenodegroups.cpp b/scene/scenenodegroups.cpp index 20ac524b..7ce89fc1 100644 --- a/scene/scenenodegroups.cpp +++ b/scene/scenenodegroups.cpp @@ -53,6 +53,16 @@ node_groups::close() return handle(); } +void +node_groups::clear() +{ + // nodes/events are owned by the region (or already gone with it); do not dereference them + m_groupmap.clear(); + while( false == m_activegroup.empty() ) { + m_activegroup.pop(); + } +} + bool node_groups::assign_cross_switch(map::track_switch& sw, std::string &sw_name, std::string const &id, size_t idx) { sw.action[idx] = simulation::Events.FindEvent(sw_name + ":" + id); diff --git a/scene/scenenodegroups.h b/scene/scenenodegroups.h index 350fa3d3..36968529 100644 --- a/scene/scenenodegroups.h +++ b/scene/scenenodegroups.h @@ -34,6 +34,10 @@ public: // indicates creation of current group ended. returns: handle to the parent group or null_handle if group stack is empty group_handle close(); + // drops every group without touching node pointers — call when the region that owned + // those nodes is about to be (or has just been) destroyed, before a scenery reload + void + clear(); // update minimap objects void update_map(); diff --git a/scene/scenerybinary_format.md b/scene/scenerybinary_format.md new file mode 100644 index 00000000..9623ff07 --- /dev/null +++ b/scene/scenerybinary_format.md @@ -0,0 +1,228 @@ +# Binarny format scenerii („eu7") — specyfikacja zapisu na dysku + +Źródło autorytatywne: [`scene/scenerybinary.h`](scenerybinary.h) i +[`scene/scenerybinary.cpp`](scenerybinary.cpp). Ten dokument opisuje format tak, jak kod go +aktualnie czyta i zapisuje (wersja na dysku: **10**). Jeśli kiedykolwiek się rozjadą, +rozstrzyga kod — wtedy zaktualizuj ten plik. + +## 1. Cel + +Format binarny zastępuje stare formaty tekstowe (`.scn` / `.scm` / `.inc` / `.ctr`) oraz dawny +blob terenu `.sbt`. To **przezroczysty, modularny cache**: każdy tekstowy plik scenerii +kompiluje się do dokładnie jednego binarnego „twina", konsumowanego na warstwie plikowej +`cParser`, więc deserializer scenerii nigdy nie widzi formatu. Twin przechowuje własny, w pełni +rozwinięty strumień tokenów pliku źródłowego (komentarze usunięte, liczby otypowane, stringi +zinternowane) i zachowuje dyrektywy `include` jako referencje — twin jednego pliku wskazuje na +twiny plików, które tamten dołącza. + +Cele projektowe: małe pliki, **odczyt strumieniowy z bufora** (cały twin wczytany raz, potem +parsowany wskaźnikiem, a tokeny-stringi serwowane jako widoki — bez wywołań strumienia +per-bajt, bez materializacji pośredniej), równoległy/offline'owy baking oraz **progresywne +ładowanie** (najpierw infrastruktura, żeby gra mogła ruszyć, potem strumieniowanie węzłów +wizualnych w kolejności odległości od kamery). + +## 2. Nazewnictwo plików (mapowanie twina) + +Jeden plik źródłowy → jeden twin; rozszerzenie binarne wynika z rodzaju źródła +(`scenerybinary_extension_for()`): + +| źródło | twin | `scenery_file_kind` | +|---------|---------|---------------------| +| `.scn` | `.scnb` | `scn` (0) | +| `.inc` | `.incb` | `inc` (1) | +| `.scm` | `.scmb` | `scm` (2) | +| `.ctr` | `.ctrb` | `scn` (0)¹ | + +¹ `.ctr` dostaje własne rozszerzenie `.ctrb`, ale jest tokenizowany jako rodzaj `scn` (enum +`kind` nie ma osobnej wartości dla `ctr`). + +Twin jest **odtwarzany tylko, jeśli jest co najmniej tak nowy jak źródło tekstowe** (porównanie +czasu modyfikacji); edycja pliku scenerii wymusza rekompilację zamiast odtworzenia +nieaktualnego twina. + +## 3. Ogólny układ pliku + +``` ++-----------------------------------------------------------+ +| Nagłówek | +| magic : uint32 LE = MAKE_ID4('e','u','7',10) | 4 bajty +| kind : uint8 = scenery_file_kind | 1 bajt +| flags : uint32 LE (bit0 = infra-relevant) | 4 bajty ++-----------------------------------------------------------+ +| Tablica stringów | +| count : varint | +| powtórz count razy: | +| length : varint | +| bytes : surowych bajtów (bez NUL) | ++-----------------------------------------------------------+ +| Strumień wpisów | +| wpisy jeden po drugim aż do końca pliku | ++-----------------------------------------------------------+ +``` + +* **magic** — `"eu7"` plus jednobajtowa wersja (obecnie `10`), little-endian przez `MAKE_ID4`. + Reader odrzuca każdy bufor, którego magic/wersja nie zgadza się dokładnie (starsze twiny są + rekompilowane, nie odczytywane błędnie). Minimalny poprawny plik to 9 bajtów (nagłówek). +* **kind** — z jakiego formatu tekstowego ten twin został skompilowany (tabela wyżej). +* **flags bit0 (`infra-relevant`)** — ustawiony, jeśli plik ma co najmniej jeden węzeł + infrastruktury lub jakikolwiek `include`. Czysto wizualny liść (np. `.incb` z florą: same + kształty + dyrektywy transformacji) ma go wyzerowany, więc pass infrastrukturalny może w ogóle + nie otwierać pliku, zamiast przechodzić go po to, żeby odrzucić jego treść wizualną. +* **tablica stringów** — każdy odrębny tekst tokenu jest internowany raz i referowany dalej + przez swój zerobazowy indeks. Stringi mają prefiks długości (bez terminatora NUL), więc reader + może serwować `std::string_view` wprost w bufor. + +## 4. Kodowania prymitywów + +* **varint** — LEB128 bez znaku: 7 bitów danych na bajt, najwyższy bit = „kolejny bajt + następuje". Jeden bajt obejmuje wartości < 128 — to przypadek typowy dla indeksów tablicy + stringów. +* **zig-zag** — liczby ze znakiem są mapowane na bez znaku przed varintem + (`(n << 1) ^ (n >> 63)`), więc małe wartości ujemne pozostają krótkie. +* **f32 / f64** — IEEE-754, little-endian (4 / 8 bajtów), zgodnie z `sn_utils`. + +Wszystkie skalary wielobajtowe są little-endian. + +## 5. Strumień wpisów + +Każdy wpis zaczyna się od **head varint**. Jego dolne 3 bity to tag (`TAG_MASK = 0x7`); pozostałe +górne bity (`head >> 3`) niosą ładunek wpisów-tokenów (indeks w tablicy stringów). Przypadek +typowy — token — kosztuje więc tylko ten jeden varint. + +| wartość tagu | nazwa | ładunek po head | +|--------------|-----------|-------------------------------------------------------------------| +| 0 | `TOKEN` | brak — `head >> 3` to indeks w tablicy stringów. Małymi literami per-konsument przy odtwarzaniu. | +| 1 | `INCLUDE` | `varint(fileexpr_count)` + tyle `varint(string_idx)`, potem `varint(param_count)` + tyle `varint(string_idx)`. | +| 2 | `INT` | jeden zig-zag varint (wartość całkowita ze znakiem). | +| 3 | `F32` | 4 bajty, float little-endian. | +| 4 | `F64` | 8 bajtów, double little-endian. | +| 5 | `QTOKEN` | brak — `head >> 3` to indeks w tablicy stringów. **Token cytowany: case zachowany dosłownie** przy odtwarzaniu (nigdy nie zmniejszany). | +| 6 | `NODE` | marker węzła — patrz §6. Po nim następuje ciało węzła. | + +Logiczne typy wpisów widziane przez konsumenta (`scenery_entry_type`): `token`, `qtoken`, +`number` (dowolny z INT/F32/F64), `include`. Podział INT/F32/F64 to szczegół kodowania ukryty +wewnątrz readera/writera. + +### Kodowanie liczb (`write_number`) + +Token liczbowy zapisywany jest w najbardziej zwartej formie, która wystarczająco dobrze +round-trip'uje: + +1. Wartość **całkowita** w zakresie ±2⁵³ (`±9007199254740992`) → `INT` (zig-zag varint). Pokrywa + wiele małych liczb w jednym–dwóch bajtach. +2. W przeciwnym razie **`F32`**, jeśli float jest skończony i reprezentuje wartość albo dokładnie, + albo z błędem względnym ≤ `1e-6` (`|float(v) − v| ≤ 1e-6·|v|`). +3. W przeciwnym razie pełny **`F64`**. + +### Wpisy include + +Include zapisuje **dosłowne wyrażenie nazwy pliku** (pojedynczy token albo zbiór losowy, np. +`[ a.inc b.inc ]` — z tokenami nawiasów) plus listę parametrów, wszystko jako indeksy +zinternowanych stringów. Przy odtwarzaniu wyrażenie jest re-ewaluowane (z ponownym losowaniem +zbioru losowego), a plik-dziecko wchodzony przezroczyście; własne tokeny dziecka pochodzą z jego +twina (lub tekstu). To właśnie zachowuje `cParser::Name()`, grupowanie węzłów per-`.inc` oraz +substytucję parametrów `(pN)` dokładnie jak ścieżka tekstowa. + +## 6. Markery węzłów (progresywne ładowanie) + +Każdy **węzeł najwyższego poziomu** (blok `node … end`; węzły nigdy nie są zagnieżdżone) +jest opakowany w marker `NODE`, żeby reader mógł serwować lub pomijać cały węzeł per-pass. +Układ markera: + +``` +varint(TAG_NODE) # head, tag = 6 +varint(class) # NODECLASS_* (poniżej) +varint(byte_span) # długość w bajtach buforowanego ciała następującego po markerze +[ f32 X, f32 Y, f32 Z, f32 Range ] # TYLKO gdy class == NODECLASS_VISUAL_POS (4 floaty) + # `byte_span` bajtów: własne wpisy węzła (tokeny/liczby/...) +``` + +Klasy węzłów (`NODECLASS_*`): + +| wartość | nazwa | znaczenie | +|---------|----------------|------------------------------------------------------------------------| +| 0 | `INFRA` | węzeł infrastruktury (tor, trakcja, memcell, event, dźwięk, …). | +| 1 | `VISUAL` | węzeł wizualny bez pozycji w markerze (kształty / starsze twiny). | +| 2 | `VISUAL_POS` | wizualny węzeł modelu, którego marker niesie pozycję lokalną **oraz zasięg widoczności** — 4 f32: `X, Y, Z, Range` (`Range` = `range_max`, `-1` = nieograniczony). | + +> Uwaga: komentarz w kodzie przy `NODECLASS_VISUAL_POS` wciąż mówi „3 f32"; writer realnie +> emituje **4** floaty (X, Y, Z, Range). Na dysku jest układ 4-floatowy powyżej. + +`Range`/pozycja pozwalają ładowaniu wizualnemu sterowanemu odległością od kamery („ring") +**przetestować dystans i pominąć węzeł w O(1)** — bez dekodowania żadnego z jego tokenów — oraz +budować eagerly modele dalekie, ale o dużym zasięgu (range poza promieniem strumieniowania), +zamiast porzucać je na dystansie. Kształty (triangles/lines) nie mają pozycji w markerze i +spadają do testu sekcyjnego w deserializerze. + +## 7. Passy ładowania (`scenery_load_pass`) + +Readerowi mówi się, którą klasę węzłów serwować; `next()` pomija markery węzłów i ciała węzłów +spoza passu (przesuwając kursor o `byte_span` w O(1)), aż dojdzie do wpisu do zaserwowania. +**Wpisy nie-węzłowe (dyrektywy, include'y) są serwowane zawsze**, w każdym passie, by utrzymać +spójny stan transformacji/grup. + +| pass | serwuje | +|------------------|-------------------------------------------------------------------| +| `all` (0) | wszystko (ładowanie jednoprzebiegowe, == zachowanie sprzed v6). | +| `infrastructure` | dyrektywy/include + węzły `INFRA`; węzły wizualne pominięte. | +| `visual` (2) | dyrektywy/include + węzły `VISUAL`/`VISUAL_POS`; infra pominięta. | + +Ten sam twin jest otwierany raz, potem przechodzony ponownie: najpierw infrastruktura eagerly +(gra może ruszyć), potem wizuale strumieniowane po sekcjach kamery. + +## 8. Powierzchnia API readera (semantyka) + +`scenery_binary_reader` (bufor musi przeżyć reader): + +* `open(string_view)` — waliduje nagłówek, indeksuje tablicę stringów, ustawia na pierwszym + wpisie. +* `set_pass(pass)` / `next(out)` — strumieniuje kolejny serwowany wpis (§7). +* `node_position(x, y, z, range)` — pozycja lokalna + `range_max` aktualnie serwowanego węzła, + jeśli marker je niósł (`VISUAL_POS`); w przeciwnym razie false. +* `skip_to_node_end()` — przeskakuje kursor na koniec aktualnie serwowanego węzła (O(1)), by + porzucić węzeł po podejrzeniu tylko jego pierwszych wpisów (ring skip kamery). +* `node_offset()` / `seek_node(offset)` — bajtowy offset markera bieżącego węzła oraz + repozycjonowanie tam, żeby kolejne `next()` zaserwowało ten węzeł (odbudowa jednego węzła na + żądanie bez re-skanowania twina). +* `infra_relevant()` — flaga z nagłówka (§3); pozwala passowi infrastrukturalnemu w ogóle nie + otwierać czysto wizualnych liści. +* `progress()` — ułamek skonsumowanej sekcji wpisów (0..100), dla paska ładowania. + +Odczyt jest **kontrolowany pod kątem granic**: przy przekroczeniu kursor parkuje na końcu i +zwracane jest zero, więc obcięty twin dekoduje się do pustki zamiast czytać poza bufor. + +## 9. Wersjonowanie + +`SCENERYBINARY_MAGIC` osadza wersję w 4. bajcie magic (obecnie `10`). Podbicie unieważnia +wszystkie starsze twiny (nie przechodzą `open()` i są rekompilowane) — tak bezpiecznie wdraża +się zmiany formatu. Skrócona historia (szczegóły w nagłówku): + +* **v4** — reader strumieniowy z bufora, spakowane tagi wpisów, liczby całkowite jako zig-zag + varint. +* **v5** — tokeny w oryginalnym case + flaga cytowania, więc baking jest niezależny od gramatyki + (umożliwia headless/standalone baker). +* **v6** — węzły najwyższego poziomu opakowane w marker (klasa + byte span) → progresywne + ładowanie. +* **v7** — marker modelu wizualnego niesie też pozycję lokalną modelu. +* **v8+** — poprawka terminatora eventlauncher; marker niesie też zasięg widoczności modelu + (`range_max`), dając obecny 4-floatowy ładunek `VISUAL_POS`. Aktualna wersja na dysku: **10**. + +## 10. Baking + +Serializacja i zapis twina to praca samodzielna, zdejmowana ze ścieżki budowy sceny: + +* **Asynchronicznie** (`scenerybinary_write_async` / `scenerybinary_wait_all`) — podczas + normalnego ładowania tekstowego gotowe writery trafiają do małej puli wątków, więc I/O nakłada + się na resztę budowy sceny; wyniki logowane są na wątku głównym po zakończeniu ładowania. +* **Headless** (`scenerybinary_bake_headless`, tryb linii poleceń `-bake`) — tokenizuje + scenariusz i, rekurencyjnie, każdy dołączany plik, kompilując każdy twin **bez sceny, renderera + i okna**. Graf plików odkrywany jest przez tokenizację każdego pliku w izolacji (include'y + zapisane, ale nieotwierane), a każdy twin kompilowany na wątku roboczym; każdy plik bakowany + jest dokładnie raz (deduplikacja), więc zapisy twinów nigdy się nie ścigają. Świeże twiny są + nietknięte; rekompilowane są tylko brakujące/nieaktualne. + +## 11. Kto używa maszynerii odtwarzania + +Tylko deserializer scenerii (`simulation/simulationstateserializer.cpp`) steruje API +replay/pass/streaming binarnego twina. Każdy inny konsument `cParser` (modele, eventy, pojazdy, +audio, config, …) korzysta ze zwykłego tokenizera tekstu i nie wie nic o formacie binarnym. diff --git a/simulation/simulationstateserializer.cpp b/simulation/simulationstateserializer.cpp index ef7cd7db..e2ab7883 100644 --- a/simulation/simulationstateserializer.cpp +++ b/simulation/simulationstateserializer.cpp @@ -40,6 +40,10 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) { // its sections, which those chunks referenced) is destroyed below EditorTerrain.reset(); + // groups hold raw pointers into the region; wipe them before the region goes away so a reload + // cannot walk dangling nodes in update_map() / erase() + scene::Groups.clear(); + // TODO: move initialization to separate routine so we can reuse it SafeDelete( Region ); Region = new scene::basic_region(); diff --git a/world/Track.cpp b/world/Track.cpp index 6155116c..c95dfd6a 100644 --- a/world/Track.cpp +++ b/world/Track.cpp @@ -549,13 +549,6 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin) // na przechyłce doliczyć jeszcze pół przechyłki } - // length2 is better than length for comparing because it does not require sqrt function - if( glm::length2((p1 + p1 + p2) / 3.0 - p1 - cp1) < sq(0.02) - || glm::length2((p1 + p2 + p2) / 3.0 - p2 + cp1) < sq(0.02) ) { - // "prostowanie" prostych z kontrolnymi, dokładność 2cm - cp1 = cp2 = glm::dvec3(0, 0, 0); - } - if( fRadius != 0 ) { // gdy podany promień segsize = @@ -644,16 +637,6 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin) // na przechyłce doliczyć jeszcze pół przechyłki? } - if( eType != tt_Cross ) { - // dla skrzyżowań muszą być podane kontrolne - // length2 is better than length for comparing because it does not require sqrt function - if( glm::length2(( p1 + p1 + p2 ) / 3.0 - p1 - cp1 ) < sq(0.02) - || glm::length2(( p1 + p2 + p2 ) / 3.0 - p2 + cp1 ) < sq(0.02) ) { - // "prostowanie" prostych z kontrolnymi, dokładność 2cm - cp1 = cp2 = glm::dvec3(0, 0, 0); - } - } - if( fRadiusTable[ 0 ] != 0 ) { // gdy podany promień segsize = @@ -709,16 +692,6 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin) // na przechyłce doliczyć jeszcze pół przechyłki? } - if( eType != tt_Cross ) { - // dla skrzyżowań muszą być podane kontrolne - // length2 is better than length for comparing because it does not require sqrt function - if( glm::length2((p3 + p3 + p4) / 3.0 - p3 - cp3) < sq(0.02) - || glm::length2((p3 + p4 + p4) / 3.0 - p4 + cp3) < sq(0.02) ) { - // "prostowanie" prostych z kontrolnymi, dokładność 2cm - cp3 = cp4 = glm::dvec3(0, 0, 0); - } - } - if( fRadiusTable[ 1 ] != 0 ) { // gdy podany promień segsize = From 4ae24114bafc2ff01da3c7227e9938992d791fe8 Mon Sep 17 00:00:00 2001 From: maj00r Date: Thu, 20 Aug 2026 20:42:28 +0200 Subject: [PATCH 13/17] Editor: pin the whole branch rigidly to its frog, not just the leaving straight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning previously rewrote only the branch's first (leaving) straight to start at the frog and run along the frog heading. Any straight appended behind it stayed where the user drew it, so its gap fit was solved against a leaving straight that had jumped out from under it — the appended fragment could end up ~1.4 km from the frog. Move the branch as one rigid body instead: the leaving straight fixes the rotation+translation onto the frog, and every appended straight rides along under the same motion, so the shapes and inter-fragment fits are preserved and the branch still meets the switch tangentially by construction. Add a test that draws an L-shaped branch far from the frog and checks the whole centreline lands next to the frog after pinning. --- maj0sted/src/editor/editor.cpp | 61 +++++++++++++++++++++++++--------- maj0sted/tests/test_editor.cpp | 42 +++++++++++++++++++++++ 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/maj0sted/src/editor/editor.cpp b/maj0sted/src/editor/editor.cpp index 5338a486..19a4af35 100644 --- a/maj0sted/src/editor/editor.cpp +++ b/maj0sted/src/editor/editor.cpp @@ -342,6 +342,47 @@ JunctionGeom solve_junction(const Junction& junction, return geom; } +// Rigidly repositions a whole branch niweleta so its leaving straight starts at +// the frog and runs along the frog heading. The branch becomes tangent to the +// switch curve however it was drawn, and — crucially — every straight appended +// behind the leaving straight rides along under the SAME rigid motion, so the +// shape of those fragments (and the fits between them) is preserved. Rewriting +// only the first straight would snap it to the frog while the appended fragments +// stayed where they were drawn, so their gap fits would be solved against a +// straight that had jumped out from under them. +void pin_branch_to_frog(NiweletaSpec& branch, const JunctionGeom& frog) { + if (branch.straights.empty()) return; + + // The leaving straight fixes the rigid motion: its start maps to the frog and + // its drawn direction rotates onto the frog heading. A leaving straight too + // short to have a direction contributes no rotation. + const StraightSpec& leaving = branch.straights.front(); + const double anchor_x = leaving.x1; + const double anchor_y = leaving.y1; + const double dx = leaving.x2 - leaving.x1; + const double dy = leaving.y2 - leaving.y1; + const double drawn_length = std::hypot(dx, dy); + double cos_r = 1.0; + double sin_r = 0.0; + if (drawn_length > 1e-9) { + const double hx = dx / drawn_length; + const double hy = dy / drawn_length; + cos_r = hx * frog.fhx + hy * frog.fhy; // drawn direction · frog heading + sin_r = hx * frog.fhy - hy * frog.fhx; // drawn direction × frog heading + } + + const auto move = [&](double& x, double& y) { + const double vx = x - anchor_x; + const double vy = y - anchor_y; + x = frog.fx + cos_r * vx - sin_r * vy; + y = frog.fy + sin_r * vx + cos_r * vy; + }; + for (auto& straight : branch.straights) { + move(straight.x1, straight.y1); + move(straight.x2, straight.y2); + } +} + } // namespace std::vector solve_project(const std::vector& niwelety) { @@ -356,12 +397,10 @@ LayoutSolution solve_layout(std::vector niwelety, // Pass 1: solve unpinned, to read the through centrelines the frogs sit on. auto solved = solve_project(niwelety); - // Lock each branch's leaving straight to the frog: it starts at the frog and - // runs along the frog heading, so the branch is tangent to the switch curve - // no matter what the user draws. Only its length is theirs — the projection of - // wherever they dragged its far end onto the frog heading. Because the first - // straight is pinned tangent, any fit in the branch's first gap (a plain arc - // or a łuk koszowy) meets the switch tangentially by construction. + // Pin each branch to its frog. The whole branch is moved rigidly so its + // leaving straight starts at the frog and runs along the frog heading (tangent + // to the switch curve), carrying every fragment appended behind it along + // unchanged — so any fit in the branch still meets the switch tangentially. for (const auto& junction : junctions) { const auto geom = solve_junction(junction, solved); if (!geom.valid) continue; @@ -369,15 +408,7 @@ LayoutSolution solve_layout(std::vector niwelety, static_cast(junction.branch) >= niwelety.size()) { continue; } - auto& branch = niwelety[junction.branch]; - if (branch.straights.empty()) continue; - auto& first = branch.straights.front(); - double length = (first.x2 - geom.fx) * geom.fhx + (first.y2 - geom.fy) * geom.fhy; - if (!(length > 1.0)) length = 1.0; // keep a usable minimum, also catches NaN - first.x1 = geom.fx; - first.y1 = geom.fy; - first.x2 = geom.fx + geom.fhx * length; - first.y2 = geom.fy + geom.fhy * length; + pin_branch_to_frog(niwelety[junction.branch], geom); } // Pass 2: solve with the branches pinned, then read the switches off the diff --git a/maj0sted/tests/test_editor.cpp b/maj0sted/tests/test_editor.cpp index 69b88367..afc3eb72 100644 --- a/maj0sted/tests/test_editor.cpp +++ b/maj0sted/tests/test_editor.cpp @@ -212,6 +212,47 @@ void hidden_tangent_guide_fits_curve_without_rendering_track() { CHECK(arc_rails == 2); } +void branch_rides_rigidly_when_pinned_to_frog() { + // Through track along +x; a switch diverges left at station 100. + NiweletaSpec through; + through.straights = {StraightSpec{0.0, 0.0, 200.0, 0.0}}; + + // Branch drawn far away as an L: a leaving straight plus one appended straight + // joined by an arc. Pinning must move the WHOLE branch rigidly onto the frog, + // so the appended fragment rides along instead of being left ~1.4 km away + // (the pre-fix behaviour, which only relocated the leaving straight). + NiweletaSpec branch; + branch.straights = {StraightSpec{1000.0, 1000.0, 1090.0, 1000.0}, + StraightSpec{1100.0, 1000.0, 1100.0, 1090.0}}; + branch.fits = {GapFit{.gap = 0, .mode = 1, .radius = 20.0}}; + + Junction j{}; + j.through = 0; + j.station = 100.0; + j.side = 0; // diverge left + j.facing = true; + j.crossing_n = 9.0; + j.curve.mode = 1; + j.curve.radius = 190.0; + j.branch = 1; + + const auto solution = solve_layout({through, branch}, {j}); + CHECK(solution.junctions.size() == 1); + CHECK(solution.junctions[0].valid); + CHECK(solution.niwelety.size() == 2); + + const auto& g = solution.junctions[0]; + const auto& branch_solved = solution.niwelety[1]; + CHECK(!branch_solved.centreline.empty()); + // The branch starts exactly at the frog... + CHECK(std::hypot(branch_solved.centreline.front().x - g.fx, + branch_solved.centreline.front().y - g.fy) < 1e-6); + // ...and the appended fragment rode along: nothing sits far from the frog. + for (const auto& p : branch_solved.centreline) { + CHECK(std::hypot(p.x - g.fx, p.y - g.fy) < 300.0); + } +} + } // namespace int main() { @@ -225,5 +266,6 @@ int main() { RUN(editor_leaves_parallel_offset_unfitted); RUN(editor_honours_requested_radius_via_single_arc); RUN(hidden_tangent_guide_fits_curve_without_rendering_track); + RUN(branch_rides_rigidly_when_pinned_to_frog); return REPORT(); } From 8cd42980e6ba1a931fb29af4fbfce9f352307ffa Mon Sep 17 00:00:00 2001 From: maj00r Date: Thu, 27 Aug 2026 19:36:52 +0200 Subject: [PATCH 14/17] Switches: lay the pre-blade lead and the blade, not a tangent arc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turnout's diverging track does not leave the through track tangentially. It runs straight along the through track to the blade tip (odcinek przediglicowy), breaks away there by the blade angle (kąt nagięcia iglicy, beta), runs straight over the blade to its heel, and only then curves - through alfa - beta, not the whole crossing angle - to the frog. Give TurnoutSegment a turn_in: a signed heading break applied before the segment is laid, which is the only way to express a track leaving by an angle rather than a tangent. lay_turnout then places the lead, the break and the blade ahead of the curve and hands the curve the angle that is actually left to spend; a zero blade length keeps the break and starts the curve at the tip. Reject a blade angle that is negative or already reaches the crossing angle. All-zero blade figures lay exactly the old tangent arc from PR, so existing layouts are untouched. Junction and turnout_preset carry the three new figures through to the panel; the presets themselves still hold zeros until the Id-1 catalogue numbers are in. Add test_turnout: the lead sits on the through track, the blade breaks by beta, and the frog still lands on the full crossing angle and the catalogue length, mirrored for a right-hand turnout. --- application/planpanel.cpp | 7 + application/planpanel.h | 3 + editor/turnouts.cpp | 19 +-- editor/turnouts.h | 1 + .../maj0sted/domain/geometry/turnout.hpp | 23 +++- maj0sted/include/maj0sted/editor/editor.hpp | 3 + maj0sted/src/domain/turnout.cpp | 46 +++++-- maj0sted/src/editor/editor.cpp | 11 +- maj0sted/tests/CMakeLists.txt | 5 + maj0sted/tests/test_turnout.cpp | 130 ++++++++++++++++++ 10 files changed, 223 insertions(+), 25 deletions(-) create mode 100644 maj0sted/tests/test_turnout.cpp diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 09bd0b0c..6a91492e 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -2337,6 +2337,9 @@ void plan_panel::render_switches() m_sw_crossing = preset.crossing_n; m_sw_radius = preset.radius; m_sw_length = preset.length; + m_sw_pre_blade = preset.pre_blade; + m_sw_blade_angle = preset.blade_angle; + m_sw_blade_length = preset.blade_length; } } ImGui::EndCombo(); @@ -2559,6 +2562,10 @@ void plan_panel::add_switch_on(std::size_t const Through, double const Wx, doubl junction.facing = true; junction.crossing_n = m_sw_crossing; junction.length = m_sw_length; // catalogue length from the template, 0 for a custom switch + // the blade figures come from the template too; all zero lays a tangent arc from PR + junction.pre_blade = m_sw_pre_blade; + junction.blade_angle = m_sw_blade_angle; + junction.blade_length = m_sw_blade_length; junction.curve.mode = 1; // plain arc; the internal curve can be made compound later junction.curve.radius = m_sw_radius; junction.branch = static_cast(m_document.niwelety.size()); diff --git a/application/planpanel.h b/application/planpanel.h index 1046df1b..1ebbf6cb 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -104,6 +104,9 @@ class plan_panel : public ui_panel double m_sw_crossing{9.0}; // skos 1:n double m_sw_radius{190.0}; // internal arc radius (metres) double m_sw_length{0.0}; // catalogue length from the chosen template (0 = just the curve) + double m_sw_pre_blade{0.0}; // odcinek przediglicowy from the template, metres + double m_sw_blade_angle{0.0}; // kąt nagięcia iglicy (beta) from the template, radians + double m_sw_blade_length{0.0};// the straight blade from the template, metres int m_drag_junction{-1}; // index of the switch being slid along its through track, or -1 int m_sel_straight{-1}; diff --git a/editor/turnouts.cpp b/editor/turnouts.cpp index a9726e3f..9cadad2b 100644 --- a/editor/turnouts.cpp +++ b/editor/turnouts.cpp @@ -20,16 +20,17 @@ std::vector const &turnout_presets() // the type tables (metres). the diverging leaves not tangent but at the blade angle (beta) after a // straight pre-blade lead, then arcs by (alfa - beta) to the frog. // - // blade_angle and pre_blade are NOT yet from Id-1 - left at 0 (a tangent arc from PR) until the - // catalogue figures are in. Fill them per type when we have them; the geometry already supports it. + // blade_angle, pre_blade and blade_length are NOT yet from Id-1 - left at 0, which lays a tangent + // arc straight from PR. Fill them per type when the catalogue figures are in; the geometry + // (domain::lay_turnout) already lays the pre-blade lead, the blade break and the shortened arc. static std::vector const presets{ - {"Rz 1:7,5 R190", 7.5, 190.0, 25.222, 0.0, 0.0}, - {"Rz 1:9 R190", 9.0, 190.0, 27.138, 0.0, 0.0}, - {"Rz 1:9 R300", 9.0, 300.0, 33.230, 0.0, 0.0}, - {"Rz 1:12 R500", 12.0, 500.0, 41.594, 0.0, 0.0}, - {"Rz 1:14 R500", 14.0, 500.0, 42.371, 0.0, 0.0}, - {"Rz 1:14 R760", 14.0, 760.0, 54.216, 0.0, 0.0}, - {"Rz 1:18,5 R1200", 18.5, 1200.0, 64.818, 0.0, 0.0}}; + {"Rz 1:7,5 R190", 7.5, 190.0, 25.222, 0.0, 0.0, 0.0}, + {"Rz 1:9 R190", 9.0, 190.0, 27.138, 0.0, 0.0, 0.0}, + {"Rz 1:9 R300", 9.0, 300.0, 33.230, 0.0, 0.0, 0.0}, + {"Rz 1:12 R500", 12.0, 500.0, 41.594, 0.0, 0.0, 0.0}, + {"Rz 1:14 R500", 14.0, 500.0, 42.371, 0.0, 0.0, 0.0}, + {"Rz 1:14 R760", 14.0, 760.0, 54.216, 0.0, 0.0, 0.0}, + {"Rz 1:18,5 R1200", 18.5, 1200.0, 64.818, 0.0, 0.0, 0.0}}; return presets; } diff --git a/editor/turnouts.h b/editor/turnouts.h index b4a3e442..357430e3 100644 --- a/editor/turnouts.h +++ b/editor/turnouts.h @@ -27,6 +27,7 @@ struct turnout_preset double length; // catalogue length PR->KR along the through track, metres double blade_angle; // kąt nagięcia iglicy (beta), radians; the diverging leaves the straight here double pre_blade; // odcinek przediglicowy: straight lead PR -> blade tip, metres + double blade_length; // the straight blade itself, tip -> heel where the arc starts, metres }; // typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard; diff --git a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp index 7fc1326b..104a6974 100644 --- a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp +++ b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp @@ -52,12 +52,24 @@ struct Turnout { DivergeSide side{DivergeSide::Left}; DivergingCurve curve; double length{0.0}; ///< catalogue length along the through track; 0 = a tangent-length rail + /// Odcinek przediglicowy: the straight lead from the switch start to the blade + /// tip, still along the through track. Zero for a curve tangent from the start. + double pre_blade{0.0}; + /// Kąt nagięcia iglicy (beta), radians: the heading break at the blade tip. The + /// diverging track leaves the through track here, not tangentially, and the + /// curve then turns through only the remaining `alfa - beta`. Zero for a + /// tangential (curved-blade) turnout. + double blade_angle{0.0}; + /// The straight blade itself, from its tip to its heel where the curve begins. + /// Zero starts the curve at the tip. + double blade_length{0.0}; }; /// One laid segment of the diverging path, as the constant/linear curvature the /// renderer and the fitter already share (see segment_layout): a straight when /// both curvatures are zero, an arc when they are equal, a clothoid otherwise. struct TurnoutSegment { + double turn_in{0.0}; ///< signed heading break applied before laying this segment (the blade) double k0{0.0}; double k1{0.0}; double length{0.0}; @@ -73,12 +85,13 @@ struct TurnoutGeometry { double diverging_length{0.0}; ///< total length of the diverging path (curve + frog rail) }; -/// Lays a turnout forward from @p start — the switch start, tangent to the through -/// track — turning toward its side until the heading has swung by the crossing -/// angle, then running straight to the catalogue length. Never throws; returns +/// Lays a turnout forward from @p start — the switch start (początek rozjazdu), +/// tangent to the through track: the pre-blade straight, the blade breaking away +/// by `blade_angle`, the curve turning through what is left of the crossing angle, +/// then the frog rail out to the catalogue length. Never throws; returns /// `valid == false` when the parameters admit no geometry: a non-positive radius, -/// or a curve whose fixed parts already overshoot the crossing angle before the -/// closing arc. +/// a blade angle that is negative or already reaches the crossing angle, or a +/// curve whose fixed parts overshoot the remaining angle before the closing arc. [[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, const Turnout& turnout); diff --git a/maj0sted/include/maj0sted/editor/editor.hpp b/maj0sted/include/maj0sted/editor/editor.hpp index 15ff6a9b..c57cee45 100644 --- a/maj0sted/include/maj0sted/editor/editor.hpp +++ b/maj0sted/include/maj0sted/editor/editor.hpp @@ -99,6 +99,9 @@ struct Junction { bool facing{true}; ///< true opens toward increasing station, false back double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n) double length{0.0}; ///< catalogue length (PR→KR along the through track); 0 = just the curve + double pre_blade{0.0}; ///< odcinek przediglicowy: straight lead PR → blade tip, metres + double blade_angle{0.0}; ///< kąt nagięcia iglicy (beta), radians; 0 = curve tangent from PR + double blade_length{0.0};///< the straight blade, tip → heel where the curve starts, metres GapFit curve; ///< the internal diverging curve: arc / compound / basket int branch{-1}; ///< index of the branch niweleta, pinned to the frog }; diff --git a/maj0sted/src/domain/turnout.cpp b/maj0sted/src/domain/turnout.cpp index d87e6c4f..4e9bb569 100644 --- a/maj0sted/src/domain/turnout.cpp +++ b/maj0sted/src/domain/turnout.cpp @@ -33,7 +33,7 @@ bool build_curve(const DivergingCurve& curve, double total_angle, double sign, double consumed = 0.0; // deflection of everything before the closing arc if (curve.entry_transition > 0.0) { - out.push_back({0.0, sign / first_radius, curve.entry_transition}); + out.push_back({0.0, 0.0, sign / first_radius, curve.entry_transition}); consumed += std::abs(deflection(out.back())); } @@ -41,12 +41,12 @@ bool build_curve(const DivergingCurve& curve, double total_angle, double sign, for (std::size_t i = 0; i + 1 < curve.arcs.size(); ++i) { const double radius = curve.arcs[i].radius; if (radius <= 0.0) return false; - out.push_back({sign / radius, sign / radius, curve.arcs[i].length}); + out.push_back({0.0, sign / radius, sign / radius, curve.arcs[i].length}); consumed += std::abs(deflection(out.back())); const double next_radius = curve.arcs[i + 1].radius; if (curve.arcs[i].transition_to_next > 0.0 && next_radius > 0.0) { - out.push_back({sign / radius, sign / next_radius, + out.push_back({0.0, sign / radius, sign / next_radius, curve.arcs[i].transition_to_next}); consumed += std::abs(deflection(out.back())); } @@ -56,17 +56,26 @@ bool build_curve(const DivergingCurve& curve, double total_angle, double sign, const double closing = total_angle - consumed - exit_deflection; if (closing <= 0.0) return false; // the fixed parts already reach the crossing angle - out.push_back({sign / last_radius, sign / last_radius, closing * last_radius}); + out.push_back({0.0, sign / last_radius, sign / last_radius, closing * last_radius}); if (curve.exit_transition > 0.0) { - out.push_back({sign / last_radius, 0.0, curve.exit_transition}); + out.push_back({0.0, sign / last_radius, 0.0, curve.exit_transition}); } return true; } -// Advances @p pose along @p segment (without sampling points; that is the -// renderer's job) and returns the end pose. +// Turns @p pose in place by @p angle (a heading break, no travel). +Pose turn(const Pose& pose, double angle) { + if (angle == 0.0) return pose; + const double c = std::cos(angle); + const double s = std::sin(angle); + return Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, pose.hx * s + pose.hy * c}; +} + +// Advances @p pose over @p segment (without sampling points; that is the +// renderer's job): the heading break first, then the travel. Returns the end pose. Pose advance(const TurnoutSegment& segment, const Pose& pose) { - return layout_segment(segment.k0, segment.k1, segment.length, pose, nullptr); + return layout_segment(segment.k0, segment.k1, segment.length, + turn(pose, segment.turn_in), nullptr); } // Where the through tangent (through @p a along @p a heading) meets the diverging @@ -91,10 +100,27 @@ TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout) { if (angle <= 0.0) return geometry; const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0; - if (!build_curve(turnout.curve, angle, sign, geometry.path)) { + const double blade = turnout.blade_angle; + if (blade < 0.0 || blade >= angle) return geometry; // the blade may not reach the frog on its own + + // odcinek przediglicowy: still along the through track, the blade tip sits at its end + if (turnout.pre_blade > 0.0) { + geometry.path.push_back(TurnoutSegment{0.0, 0.0, 0.0, turnout.pre_blade}); + } + // the blade breaks away by beta and runs straight to its heel; with no blade + // length the break still stands and the curve starts at the tip + if (turnout.blade_length > 0.0) { + geometry.path.push_back(TurnoutSegment{sign * blade, 0.0, 0.0, turnout.blade_length}); + } + + // the curve only has `alfa - beta` left to turn through + std::vector curve_path; + if (!build_curve(turnout.curve, angle - blade, sign, curve_path)) { geometry.path.clear(); return geometry; } + if (turnout.blade_length <= 0.0) curve_path.front().turn_in += sign * blade; + geometry.path.insert(geometry.path.end(), curve_path.begin(), curve_path.end()); // walk the curve to the frog direction (the crossing angle) Pose pose = start; @@ -112,7 +138,7 @@ TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout) { ? (turnout.length - through_projection) / cos_angle : turnout.curve.arcs.back().radius * std::tan(angle * 0.5); if (rail > 0.0) { - const TurnoutSegment frog_rail{0.0, 0.0, rail}; + const TurnoutSegment frog_rail{0.0, 0.0, 0.0, rail}; geometry.path.push_back(frog_rail); pose = advance(frog_rail, pose); } diff --git a/maj0sted/src/editor/editor.cpp b/maj0sted/src/editor/editor.cpp index 19a4af35..78ad0ba7 100644 --- a/maj0sted/src/editor/editor.cpp +++ b/maj0sted/src/editor/editor.cpp @@ -281,7 +281,9 @@ domain::Turnout to_turnout(const Junction& junction) { return domain::Turnout{ domain::CrossingMark{junction.crossing_n}, junction.side == 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right, - std::move(curve), junction.length}; + std::move(curve), junction.length, + junction.pre_blade, junction.blade_angle, + junction.blade_length}; } // The drawable geometry of one switch. @p station is the switch's start on the @@ -319,6 +321,13 @@ JunctionGeom solve_junction(const Junction& junction, std::abs(segment.k1 - segment.k0) >= 1e-12 ? render::ElementKind::Transition : std::abs(segment.k0) >= 1e-12 ? render::ElementKind::Arc : render::ElementKind::Straight; + // the blade's heading break happens in place, before the segment is laid + if (segment.turn_in != 0.0) { + const double c = std::cos(segment.turn_in); + const double s = std::sin(segment.turn_in); + pose = Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, + pose.hx * s + pose.hy * c}; + } pose = layout_segment(segment.k0, segment.k1, segment.length, pose, &pts); element.points.reserve(pts.size()); for (const auto& p : pts) element.points.push_back(render::Point{p.x, p.y}); diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt index 3d187671..94e2b978 100644 --- a/maj0sted/tests/CMakeLists.txt +++ b/maj0sted/tests/CMakeLists.txt @@ -47,3 +47,8 @@ add_executable(maj0sted_test_document test_document.cpp) target_link_libraries(maj0sted_test_document PRIVATE maj0sted::maj0sted) target_include_directories(maj0sted_test_document PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME document COMMAND maj0sted_test_document) + +add_executable(maj0sted_test_turnout test_turnout.cpp) +target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME turnout COMMAND maj0sted_test_turnout) diff --git a/maj0sted/tests/test_turnout.cpp b/maj0sted/tests/test_turnout.cpp new file mode 100644 index 00000000..d1ae8ae0 --- /dev/null +++ b/maj0sted/tests/test_turnout.cpp @@ -0,0 +1,130 @@ +#include +#include + +#include "check.hpp" +#include "maj0sted/domain/geometry/turnout.hpp" + +using namespace maj0sted::domain; +using maj0sted::domain::geometry::Pose; + +namespace { + +Turnout rz_1_9_r190() { + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, DivergingCurve{}, 27.138}; + turnout.curve.arcs.push_back(TurnoutArc{190.0, 0.0, 0.0}); + return turnout; +} + +// Walks the laid path and returns the pose at the end of segment @p index. +Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) { + Pose pose{0.0, 0.0, 1.0, 0.0}; + for (std::size_t i = 0; i <= index; ++i) { + const auto& segment = geometry.path[i]; + if (segment.turn_in != 0.0) { + const double c = std::cos(segment.turn_in); + const double s = std::sin(segment.turn_in); + pose = Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, + pose.hx * s + pose.hy * c}; + } + pose = geometry::layout_segment(segment.k0, segment.k1, segment.length, pose, + nullptr); + } + return pose; +} + +void pre_blade_runs_along_the_through_track() { + auto turnout = rz_1_9_r190(); + turnout.pre_blade = 3.5; + turnout.blade_angle = 0.02; + turnout.blade_length = 6.0; + + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + CHECK(geometry.valid); + + // the lead is straight, on the through track, and the blade tip sits at its end + const Pose tip = pose_after(geometry, 0); + CHECK(std::abs(tip.x - 3.5) < 1e-9); + CHECK(std::abs(tip.y) < 1e-12); + CHECK(std::abs(tip.hy) < 1e-12); + + // the blade breaks away by beta exactly, and stays straight over its length + const Pose heel = pose_after(geometry, 1); + CHECK(std::abs(std::atan2(heel.hy, heel.hx) - 0.02) < 1e-9); + CHECK(std::abs(std::hypot(heel.x - tip.x, heel.y - tip.y) - 6.0) < 1e-9); +} + +void the_frog_still_lands_on_the_crossing_angle() { + const double alfa = std::atan(1.0 / 9.0); + + auto tangential = rz_1_9_r190(); + auto bladed = rz_1_9_r190(); + bladed.pre_blade = 3.5; + bladed.blade_angle = 0.02; + bladed.blade_length = 6.0; + + for (const auto& turnout : {tangential, bladed}) { + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + CHECK(geometry.valid); + // the curve spends only alfa - beta, so the frog heading is alfa either way + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - alfa) < 1e-9); + // and the catalogue length is still measured along the through track + CHECK(std::abs(geometry.frog.x - 27.138) < 1e-6); + } +} + +void a_blade_reaching_the_crossing_angle_is_rejected() { + auto turnout = rz_1_9_r190(); + turnout.blade_angle = std::atan(1.0 / 9.0); // nothing left for the curve to turn + CHECK(!lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout).valid); + + turnout.blade_angle = -0.01; + CHECK(!lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout).valid); +} + +void a_bladeless_curve_still_starts_tangent() { + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, rz_1_9_r190()); + CHECK(geometry.valid); + for (const auto& segment : geometry.path) CHECK(segment.turn_in == 0.0); +} + +// With no blade length the break still stands: the curve simply starts at the tip. +void a_zero_length_blade_keeps_its_break() { + auto turnout = rz_1_9_r190(); + turnout.pre_blade = 3.5; + turnout.blade_angle = 0.02; + + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + CHECK(geometry.valid); + CHECK(geometry.path.size() >= 2); + CHECK(std::abs(geometry.path[1].turn_in - 0.02) < 1e-12); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - + std::atan(1.0 / 9.0)) < 1e-9); +} + +// A right-hand turnout is the mirror image: the blade breaks the other way. +void a_right_hand_blade_breaks_the_other_way() { + auto turnout = rz_1_9_r190(); + turnout.side = DivergeSide::Right; + turnout.pre_blade = 3.5; + turnout.blade_angle = 0.02; + turnout.blade_length = 6.0; + + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + CHECK(geometry.valid); + CHECK(std::abs(std::atan2(pose_after(geometry, 1).hy, pose_after(geometry, 1).hx) + + 0.02) < 1e-9); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + + std::atan(1.0 / 9.0)) < 1e-9); +} + +} // namespace + +int main() { + RUN(pre_blade_runs_along_the_through_track); + RUN(the_frog_still_lands_on_the_crossing_angle); + RUN(a_blade_reaching_the_crossing_angle_is_rejected); + RUN(a_bladeless_curve_still_starts_tangent); + RUN(a_zero_length_blade_keeps_its_break); + RUN(a_right_hand_blade_breaks_the_other_way); + return REPORT(); +} From 2376e4867b6341f3dbcf59ac81f4c49f045d0972 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sat, 29 Aug 2026 19:32:46 +0200 Subject: [PATCH 15/17] Editor: rebuild the plan library around a solved document, and bend turnouts onto curves The library was a heap of half-built services - fitting, parallelism, niwelety, an SVG writer - that never met each other. In their place is one authored Document and one solver that lays it whole: tracks as element chains, turnouts placed on them, and a Solution that is rebuilt from scratch every time and never fed back into the document. What cannot be laid comes back as a diagnostic with the number in it rather than as silence or a fitted-away error. Turnouts are dimensioned the way the drawings dimension them. The blade follows Koc's rys. 5.9 - the curve is theoretically tangent to the opornica at PR, the blade starts at a with a planed nose of d, and A is where the rail stands u thick - and a + d derived that way lands on his tablica 5.1. Bending follows 5.3.6: it rotates the triangle OBC about the centre of the turnout, so the tangent t and the crossing angle stand and both radii follow from them. That is Koc's (5.7) and (5.8) without coding either, and it puts a symmetrically bent Rz 49E1-190-1:9 on the catalogue's Rlus 49E1-380,292-1:9. A rozjazd standing on a curve is bent onto it; one on a track drawn straight can carry a bend of its own. The szyny laczace take up the difference, because the switch and the frog are only bent, not re-cut. The plan panel draws all of it: the catalogue drawing of a type with its construction points, the theoretical arc dashed where no rail runs, and the blade nose as the straight it is cut as. Dragging a straight now stops where it would pull the track out from under a turnout. File format goes to m0s 6. Versions 3 to 5 still open. --- CMakeLists.txt | 7 + NIWELETA_REDESIGN.md | 223 + TURNOUT_REDESIGN.md | 147 + TURNOUT_SPEC.md | 77 +- application/planpanel.cpp | 5934 ++++++++--------- application/planpanel.h | 240 +- editor/orthophoto.cpp | 61 +- editor/turnouts.cpp | 77 +- editor/turnouts.h | 34 +- maj0sted/CMakeLists.txt | 37 +- maj0sted/examples/CMakeLists.txt | 5 - maj0sted/examples/basic_editor.cpp | 20 - maj0sted/examples/plan_to_svg.cpp | 58 - .../include/maj0sted/app/basket_draft.hpp | 58 - .../include/maj0sted/app/editor_document.hpp | 34 - .../maj0sted/app/straight_constraints.hpp | 12 - .../domain/fitting/fit_parameters.hpp | 55 - .../maj0sted/domain/fitting/fit_result.hpp | 25 - .../domain/fitting/fitting_service.hpp | 69 - .../maj0sted/domain/fitting/gap_fitter.hpp | 74 - .../domain/geometry/horizontal_alignment.hpp | 60 - .../maj0sted/domain/geometry/plan_element.hpp | 127 - .../domain/geometry/profile_element.hpp | 53 - .../maj0sted/domain/geometry/turnout.hpp | 175 +- .../domain/geometry/vertical_alignment.hpp | 31 - .../include/maj0sted/domain/map_project.hpp | 32 - maj0sted/include/maj0sted/domain/niweleta.hpp | 70 - .../parallelism/parallelism_service.hpp | 39 - .../domain/parallelism/track_parallelism.hpp | 38 - .../maj0sted/domain/value_objects/azimuth.hpp | 67 - .../value_objects/cartesian_position.hpp | 27 - .../maj0sted/domain/value_objects/crs.hpp | 29 - .../maj0sted/domain/value_objects/length.hpp | 37 - .../domain/value_objects/niweleta_id.hpp | 36 - .../maj0sted/domain/value_objects/radius.hpp | 30 - .../domain/value_objects/straight_id.hpp | 36 - .../domain/value_objects/track_offset.hpp | 31 - maj0sted/include/maj0sted/editor/chain.hpp | 31 + maj0sted/include/maj0sted/editor/editor.hpp | 139 - maj0sted/include/maj0sted/editor/layout.hpp | 37 + maj0sted/include/maj0sted/editor/model.hpp | 217 + maj0sted/include/maj0sted/editor/render.hpp | 35 + maj0sted/include/maj0sted/editor/ribbon.hpp | 32 - maj0sted/include/maj0sted/editor/sketch.hpp | 118 + maj0sted/include/maj0sted/editor/solution.hpp | 169 + maj0sted/include/maj0sted/editor/solve.hpp | 55 - maj0sted/include/maj0sted/io/document_io.hpp | 31 + .../maj0sted/io/project_serializer.hpp | 19 - maj0sted/include/maj0sted/io/scn_export.hpp | 21 +- maj0sted/include/maj0sted/io/svg_writer.hpp | 20 - maj0sted/include/maj0sted/maj0sted.hpp | 50 +- .../include/maj0sted/render/plan_sampler.hpp | 18 - .../include/maj0sted/render/rail_renderer.hpp | 7 +- maj0sted/include/maj0sted/render/scene.hpp | 19 - .../maj0sted/render/track_renderer.hpp | 13 +- maj0sted/src/app/basket_draft.cpp | 389 -- maj0sted/src/app/editor_document.cpp | 520 -- maj0sted/src/app/straight_constraints.cpp | 112 - maj0sted/src/domain/fitting_service.cpp | 582 -- maj0sted/src/domain/gap_fitter.cpp | 215 - maj0sted/src/domain/horizontal_alignment.cpp | 95 - maj0sted/src/domain/map_project.cpp | 13 - maj0sted/src/domain/niweleta.cpp | 90 - maj0sted/src/domain/parallelism_service.cpp | 83 - maj0sted/src/domain/turnout.cpp | 333 +- maj0sted/src/domain/vertical_alignment.cpp | 13 - maj0sted/src/editor/chain.cpp | 141 + maj0sted/src/editor/editor.cpp | 434 -- maj0sted/src/editor/layout.cpp | 706 ++ maj0sted/src/editor/model.cpp | 74 + maj0sted/src/editor/render.cpp | 122 + maj0sted/src/editor/ribbon.cpp | 55 - maj0sted/src/editor/sketch.cpp | 481 ++ maj0sted/src/editor/solution.cpp | 118 + maj0sted/src/editor/solve.cpp | 98 - maj0sted/src/io/document_io.cpp | 544 ++ maj0sted/src/io/project_serializer.cpp | 291 - maj0sted/src/io/scn_export.cpp | 107 +- maj0sted/src/io/svg_writer.cpp | 63 - maj0sted/src/render/plan_sampler.cpp | 116 - maj0sted/src/render/rail_renderer.cpp | 6 +- maj0sted/tests/CMakeLists.txt | 67 +- maj0sted/tests/test_chain.cpp | 230 + maj0sted/tests/test_document.cpp | 144 - maj0sted/tests/test_editor.cpp | 271 - maj0sted/tests/test_fitting.cpp | 346 - maj0sted/tests/test_format.cpp | 279 + maj0sted/tests/test_graph.cpp | 225 + maj0sted/tests/test_hold.cpp | 310 + maj0sted/tests/test_lines.cpp | 549 ++ maj0sted/tests/test_map_project.cpp | 25 - maj0sted/tests/test_niweleta.cpp | 110 - maj0sted/tests/test_parallelism.cpp | 85 - maj0sted/tests/test_plan_alignment.cpp | 132 - maj0sted/tests/test_render.cpp | 78 - maj0sted/tests/test_serialization.cpp | 92 - maj0sted/tests/test_turnout.cpp | 460 +- 97 files changed, 8608 insertions(+), 9792 deletions(-) create mode 100644 NIWELETA_REDESIGN.md create mode 100644 TURNOUT_REDESIGN.md delete mode 100644 maj0sted/examples/CMakeLists.txt delete mode 100644 maj0sted/examples/basic_editor.cpp delete mode 100644 maj0sted/examples/plan_to_svg.cpp delete mode 100644 maj0sted/include/maj0sted/app/basket_draft.hpp delete mode 100644 maj0sted/include/maj0sted/app/editor_document.hpp delete mode 100644 maj0sted/include/maj0sted/app/straight_constraints.hpp delete mode 100644 maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp delete mode 100644 maj0sted/include/maj0sted/domain/fitting/fit_result.hpp delete mode 100644 maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp delete mode 100644 maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp delete mode 100644 maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp delete mode 100644 maj0sted/include/maj0sted/domain/geometry/plan_element.hpp delete mode 100644 maj0sted/include/maj0sted/domain/geometry/profile_element.hpp delete mode 100644 maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp delete mode 100644 maj0sted/include/maj0sted/domain/map_project.hpp delete mode 100644 maj0sted/include/maj0sted/domain/niweleta.hpp delete mode 100644 maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp delete mode 100644 maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/crs.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/length.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/radius.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp delete mode 100644 maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp create mode 100644 maj0sted/include/maj0sted/editor/chain.hpp delete mode 100644 maj0sted/include/maj0sted/editor/editor.hpp create mode 100644 maj0sted/include/maj0sted/editor/layout.hpp create mode 100644 maj0sted/include/maj0sted/editor/model.hpp create mode 100644 maj0sted/include/maj0sted/editor/render.hpp delete mode 100644 maj0sted/include/maj0sted/editor/ribbon.hpp create mode 100644 maj0sted/include/maj0sted/editor/sketch.hpp create mode 100644 maj0sted/include/maj0sted/editor/solution.hpp delete mode 100644 maj0sted/include/maj0sted/editor/solve.hpp create mode 100644 maj0sted/include/maj0sted/io/document_io.hpp delete mode 100644 maj0sted/include/maj0sted/io/project_serializer.hpp delete mode 100644 maj0sted/include/maj0sted/io/svg_writer.hpp delete mode 100644 maj0sted/include/maj0sted/render/plan_sampler.hpp delete mode 100644 maj0sted/src/app/basket_draft.cpp delete mode 100644 maj0sted/src/app/editor_document.cpp delete mode 100644 maj0sted/src/app/straight_constraints.cpp delete mode 100644 maj0sted/src/domain/fitting_service.cpp delete mode 100644 maj0sted/src/domain/gap_fitter.cpp delete mode 100644 maj0sted/src/domain/horizontal_alignment.cpp delete mode 100644 maj0sted/src/domain/map_project.cpp delete mode 100644 maj0sted/src/domain/niweleta.cpp delete mode 100644 maj0sted/src/domain/parallelism_service.cpp delete mode 100644 maj0sted/src/domain/vertical_alignment.cpp create mode 100644 maj0sted/src/editor/chain.cpp delete mode 100644 maj0sted/src/editor/editor.cpp create mode 100644 maj0sted/src/editor/layout.cpp create mode 100644 maj0sted/src/editor/model.cpp create mode 100644 maj0sted/src/editor/render.cpp delete mode 100644 maj0sted/src/editor/ribbon.cpp create mode 100644 maj0sted/src/editor/sketch.cpp create mode 100644 maj0sted/src/editor/solution.cpp delete mode 100644 maj0sted/src/editor/solve.cpp create mode 100644 maj0sted/src/io/document_io.cpp delete mode 100644 maj0sted/src/io/project_serializer.cpp delete mode 100644 maj0sted/src/io/svg_writer.cpp delete mode 100644 maj0sted/src/render/plan_sampler.cpp create mode 100644 maj0sted/tests/test_chain.cpp delete mode 100644 maj0sted/tests/test_document.cpp delete mode 100644 maj0sted/tests/test_editor.cpp delete mode 100644 maj0sted/tests/test_fitting.cpp create mode 100644 maj0sted/tests/test_format.cpp create mode 100644 maj0sted/tests/test_graph.cpp create mode 100644 maj0sted/tests/test_hold.cpp create mode 100644 maj0sted/tests/test_lines.cpp delete mode 100644 maj0sted/tests/test_map_project.cpp delete mode 100644 maj0sted/tests/test_niweleta.cpp delete mode 100644 maj0sted/tests/test_parallelism.cpp delete mode 100644 maj0sted/tests/test_plan_alignment.cpp delete mode 100644 maj0sted/tests/test_render.cpp delete mode 100644 maj0sted/tests/test_serialization.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e7ab124..12ee6ab0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -513,6 +513,13 @@ endif() find_package(Threads REQUIRED) target_link_libraries(${PROJECT_NAME} Threads::Threads) +# the editor's map imagery talks to a WMS over https. windows has WinHttp in the platform sdk; +# everywhere else libcurl is the transport (see editor/orthophoto.cpp) +if (NOT WIN32) + find_package(CURL REQUIRED) + target_link_libraries(${PROJECT_NAME} CURL::libcurl) +endif() + find_package(GLM REQUIRED) target_include_directories(${PROJECT_NAME} PRIVATE ${GLM_INCLUDE_DIR}) diff --git a/NIWELETA_REDESIGN.md b/NIWELETA_REDESIGN.md new file mode 100644 index 00000000..47cfd1b3 --- /dev/null +++ b/NIWELETA_REDESIGN.md @@ -0,0 +1,223 @@ +# Odcinki niwelety — jak to powinno być zrobione + +Dokument projektowy, nie opis stanu. Siostrzany do `TURNOUT_REDESIGN.md`, który +w §2.2 i §3.2 nazwał ten problem i odłożył go. To jest ten odłożony krok. + +Teza: model nie umie powiedzieć rzeczy najprostszej — **„tu zaczyna się łuk”**. +Wszystko inne jest skutkiem. + +## 1. Co jest dziś + +Fakty, nie opinie: + +- **Krzywa nie jest bytem.** Gap *i* jest zawsze między prostą *i* a *i+1*. + Twardo założone w `align_gap_fits` (przepisuje fity na gęstą tablicę + `straights-1`), w `editor.cpp` (`fits(n-1)`, prosta przycinana wyłącznie przez + dwa sąsiadujące fity), w `basket_draft.cpp` i w `planpanel.cpp`. +- Stąd **wyjątki**: `hidden` prosta-prowadnica, tryb 5, i `BasketDraft` — + czwarta reprezentacja ciągu (obok `NiweletaSpec`, `FitResult` i `RibbonRequest`), + która przy commicie musi **dorobić sztuczną 20-metrową prostą wyjściową**, żeby + kosz miał na czym stanąć. +- **Referencje pozycyjne.** `GapFit::gap`, `rel_niw`, `rel_str`, `frog_of`, + `Junction::through/branch` to indeksy w wektorze; wstawienie prostej wymaga + ręcznego przenumerowania w dwóch pętlach. Mocne `StraightId`/`NiweletaId` + istnieją w `domain/value_objects/` i **nie są używane**. +- **Dwa źródła prawdy o współrzędnych.** Dokument trzyma to, co narysowane; + solver zwraca `rendered_straights` i `pinned`, a host musi wpisać `pinned` z + powrotem do dokumentu — inaczej „dokument i rysunek się rozjeżdżają”. +- **Solver zjada intencję.** Nieudany fit wraca jako `nullopt` (każdy `throw` + z `FittingService` jest połykany przez `catch (...)`) i znika z `applied_fits`. + Bez powodu, bez liczby. `basket_draft` musi robić próbne `solve_project` + i sprawdzać, czy jego fit „przeżył”. +- **Brak porządku rozwiązywania.** Stała pętla 3 przebiegów relaksacji + i sztywne dwa przejścia w `solve_layout`. Rozjazd na odnodze rozjazdu nie ma + prawa się zbiec. +- **Druga, martwa warstwa.** `domain::Niweleta` + `HorizontalAlignment` + + `MapProject` + cały profil: host nie zna ani jednego z tych typów, + `to_map_project` fabrykuje placeholderowe łuki R=300 tylko po to, żeby przejść + walidację złącz, a reader i tak przewija ten blok bez czytania. +- `Junction::pre_blade/blade_angle/blade_length` nie są zapisywane ani czytane — + po `load` zerują się. + +## 2. Jak to robią inni + +- **IFC 4.3** — `IfcAlignmentHorizontal/Vertical/Cant`, każdy jako ciąg + `IfcAlignmentSegment`: `StartDistAlong`, `SegmentLength`, promień na początku + i na końcu, typ `LINE|CIRCULARARC|CLOTHOID`. Styczność jest **regułą walidacji + między segmentami**, nie własnością reprezentacji. +- **LandXML** — `CoordGeom` = `Line|Curve|Spiral`; profil osobno (`ProfAlign`: + `PVI|CircCurve|ParaCurve`). Poziom i pion to dwa niezależne ciągi nad wspólną + stacją. +- **Bentley OpenRoads** — dwa tryby autorstwa nad **jednym** modelem elementów: + *by PI* (wierzchołki i promienie — nasze „proste i luki”) oraz *by Element* + (dokładanie odcinków — nasz `BasketDraft`). Element niesie *rules*: styczny do + X, przez punkt P, offset od Y. To jest brakujące ogniwo. +- **Civil 3D Rail, „Diverted Profiles”** — geometria odnogi jest **wyprowadzana** + z toru zasadniczego w rozjeździe, nie autorowana niezależnie i doklejana. +- **railML** — wszystko parametryzowane pozycją wzdłuż toru, nigdy indeksem + elementu. + +Wniosek wspólny: **odcinek (k0, k1, L) jest jednostką, stacja jest parametrem, +styczność jest własnością reprezentacji, a „jak ten odcinek jest wyznaczony” jest +osobnym, jawnym polem.** Kernel do tego już mamy: `geometry::layout_segment`. + +## 3. Model docelowy + +Płasko, w `maj0sted::editor`, Id jako mocne typy. + +### 3.1. Odcinek jest bytem + +```cpp +enum class Kind { Line, Arc, Clothoid }; + +using Determination = std::variant< + FixedLength, // długość podana wprost + ToLine, // biegnij aż dojdziesz do tej prostej kierunkowej + ToPoint, // przez ten punkt + Remainder // pochłoń resztę odchylenia +>; + +struct Element { ElementId id; Kind kind; double radius; int hand; Determination det; }; +``` + +Odcinek zaczyna się tam, gdzie kończy się poprzedni — **pozą**, nie +współrzędnymi. Styczność przestaje być regułą do sprawdzania i staje się +własnością zapisu. Znikają `JointContinuity`, `validate_joint`, +`pin_branch_to_frog`, `rendered_straights` i cały dryf. + +### 3.2. Prosta kierunkowa oddzielona od toru + +```cpp +struct ConstructionLine { LineId id; double x1,y1,x2,y2; Binding binding; }; +``` + +Dziś `StraightSpec` jest jednocześnie prostą kierunkową i kawałkiem toru — to +jest źródło dwóch prawd o współrzędnych. Prosta kierunkowa nigdy nie jest torem; +torem jest `Element{Line, ToLine{id}}`. `hidden` przestaje być potrzebne: +prowadnica to linia, na której nie leży żaden element. + +### 3.3. Kotwica i porty — graf zamiast przypinania + +```cpp +struct Track { TrackId id; std::string name; Anchor anchor; std::vector elements; }; +using Anchor = std::variant; +``` + +`frog_of` → `AtPort`. Znikają `place_frog_straight`, `LayoutSolution::pinned` +i zapis solvera do dokumentu. + +### 3.4. Rozjazd: typ + umiejscowienie + +`TURNOUT_REDESIGN.md` §3.3, teraz odblokowane — krzyżownica jest portem: + +```cpp +struct TurnoutType { name, crossing_n, radius, length, pre_blade, blade_angle, blade_length }; +struct TurnoutPlacement { id, type, on, station, hand, facing }; +``` + +Naprawia przy okazji niezapisywane liczby iglicy: idą do katalogu, egzemplarz +trzyma nazwę typu. + +### 3.5. Solve — czysta funkcja, po Id, z diagnostyką + +```cpp +struct Solution { map; map; vector; }; +Solution solve(const Document&); +``` + +1. **Nic nie wraca do dokumentu.** Host rysuje `Solution` i w nią trafia. +2. **Nieudany fit to diagnostyka, nie cisza.** Geometria do tego odcinka zostaje + położona, łańcuch urywa się i jest rysowany jako kikut, a użytkownik dostaje + powód i liczbę. +3. **Kolejność z DAG-u.** Zależności (kotwice, `ToLine`, bindingi, rozjazdy) dają + graf; sortowanie topologiczne zastępuje 3 przebiegi relaksacji i sztywne dwa + przejścia. Cykl → diagnostyka, nie śmieci. + +### 3.6. Dwa tryby autorstwa nad jednym ciągiem + +To jest właściwy zysk. Dokładanie odcinek po odcinku i fitowanie przestają być +dwoma światami (`BasketDraft` kontra `GapFit`) i stają się dwoma sposobami +wypełnienia pola `Determination`. + +**Odcinek po odcinku** (`FixedLength`, `ToPoint`) — start z kotwicy, każdy +kolejny odcinek styczny do pozy końcowej poprzednika. Trzy rzeczy dziś +niewyrażalne wychodzą same: + +- **łuk wprost z krzyżownicy** — bez prostej KR, bez prowadnicy; +- **łuk po łuku** bez prostej między nimi; +- **ciąg, który się nie domyka** — bez sztucznej prostej wyjściowej. + +Każdy dołożony odcinek idzie od razu do dokumentu, nie do bufora szkicu. Cofanie +to usunięcie ostatniego elementu. Podgląd przestaje kłamać: dziś `draft_to_ribbon` +liczy kąt każdego łuku jako `L/R`, a `fit_compound` daje ostatniemu łukowi resztę +odchylenia — to są **różne krzywe**. + +**Fitowanie** (`ToLine`, `Remainder`) — między dwoma odcinkami `ToLine` łańcuch +z dokładnie jednym `Remainder`: + +| chcesz | łańcuch | +|---|---| +| sam łuk (tryb 1) | `Arc{R, Remainder}` | +| łuk z klotoidami (tryb 2) | `Clot{R,L}` → `Arc{R, Remainder}` → `Clot{∞,L}` | +| łuk koszowy (tryb 3) | `Arc{R₁,L₁}` → `Clot` → … → `Arc{Rₙ, Remainder}` | +| nawrót 180° (tryb 4) | ten sam łańcuch — nawrót wynika z geometrii | +| S-krzywa | `Arc{R,+1,L}` → `Arc{R,-1, Remainder}` | + +Tryby `GapFit` 0–4 znikają: to były nazwy dla pięciu kształtów łańcucha. + +**Mieszanie w jednym ciągu** — dziś nie ma tego w ogóle, bo wszystko między +dwiema prostymi musi być jednym `GapFit`: + +``` +anchor port turnout=3 KR +elem 11 arc 190 -1 len 40 ← odcinek po odcinku, prosto z krzyżownicy +elem 12 clot 600 -1 len 30 +elem 13 arc 600 -1 rem ← domknięcie fitem… +elem 14 arc 400 1 rem ← …dwoma łukami, bo start jest sztywny… +elem 15 line 0 0 toline 7 ← …na prostą kierunkową 7 +``` + +**Stopnie swobody zamiast wyjątków.** Domknięcie to dwa równania: wylądować na +prostej i być do niej stycznym. Łańcuch wchodzący *wzdłuż prostej* może się po +niej przesuwać — to jeden stopień swobody — więc potrzebuje dokładnie **jednego** +`Remainder`; to jest każdy dzisiejszy fit. Łańcuch wychodzący ze **sztywnej pozy** +(kotwica, zwykle krzyżownica) przesuwać się nie może, więc potrzebuje **dwóch**. +Łańcuch, który nie domyka się na nic — **zera**. + +Solver to liczy i mówi wprost: za mało → „przewiązany, brakuje N stopni swobody”; +za dużo → „niedookreślony, podaj długość jednego z łuków”; `Remainder ≤ 0` → ile +odchylenia było do rozdania i ile zużyły odcinki o zadanej długości. + +Uwaga na marginesie: odchylenie bierzemy z krótkiej gałęzi `atan2`, więc łuk +domykający nigdy nie skręci dalej, niż narożnik naprawdę się załamuje. Jazda +dookoła — nawrót powyżej 180°, pętla — jest czymś, co się **kładzie** zadanymi +długościami, nie czymś, co fit ma po cichu za nas wybrać. + +### 3.7. Format `.m0s` v2 + +Jawny numer wersji zamiast przemianowywania tagów, wszystko po Id, bez doklejania +tokenów na koniec linii. Bez zgodności wstecz. + +## 4. Co zostaje bez zmian + +Warto powiedzieć wprost, bo to nie jest przepisywanie wszystkiego: + +- `geometry::layout_segment` — jedyny ewaluator odcinka, zostaje. +- `domain::lay_turnout`, `Turnout`, `TurnoutGeometry` — zostają. +- Matematyka fitowania (`solve_chain`, `fit_compound`) — zmienia się interfejs + (powód zamiast `throw`/`nullopt`), nie liczby. +- `ParallelismService::project_onto_offset_line` — jako `Binding::Parallel`. +- `RailRenderer`, `TrackRenderer`, kafle, georeferencja, eksport `.scn` — bez + związku z modelem. + +Do wyrzucenia jest **klej**: przypinanie, prowadnice, tryby jako wyjątki, +`BasketDraft` i martwa warstwa agregatowa. + +## 5. Poza zakresem + +**Niweleta w profilu (pion).** `VerticalAlignment`, `ProfileElement`, `Grade`, +`VerticalCurve` istnieją, ale nic ich nie liczy, `.m0s` ich nie niesie, a +`scn_export` i tak wpisuje `y=0.2` i `roll=0` dla każdego węzła — mimo że format +`.scn` niesie `z` na obu końcach segmentu i `roll1`/`roll2` interpolowane +liniowo. Lecą jako martwy kod i wracają jako osobny temat: ten sam kształt +ciągu, o wymiar niżej, nad tą samą stacją. diff --git a/TURNOUT_REDESIGN.md b/TURNOUT_REDESIGN.md new file mode 100644 index 00000000..c0e2fc7f --- /dev/null +++ b/TURNOUT_REDESIGN.md @@ -0,0 +1,147 @@ +# Rozjazdy — jak to powinno być zrobione + +Dokument projektowy, nie opis stanu. `TURNOUT_SPEC.md` mówi, co edytor oddaje +runtime'owi; ten mówi, **jak edytor powinien trzymać rozjazd u siebie**, bo obecny +model się nie broni i każda kolejna funkcja jest w nim przypadkiem szczególnym. + +## 1. Co jest dziś + +Fakty, nie opinie: + +- `NiweletaSpec` = lista **prostych zakotwiczonych w XY** + `fits` w lukach między + kolejnymi prostymi. Krzywa nie jest bytem — jest wynikiem wpasowania w lukę. +- `Junction` (rozjazd) to **relacja** na niwelecie zasadniczej: stacja + liczby + katalogowe. Kładzie własną ścieżkę odgałęzienia (`lay_turnout`), ale ta ścieżka + **nie należy do żadnej niwelety** — jest rysowana osobno z `JunctionGeom`. +- Odnoga to osobna niweleta, **przypinana sztywno** do krzyżownicy przy każdym + rozwiązaniu (`pin_branch_to_frog`). +- Równolegle istnieje `BasketDraft` (395 linii) — drugi, własny mechanizm + budowania krzywych, nie przechodzący przez model luk. +- `Junction` niesie **jednocześnie** liczby katalogowe (skos, R, długość, iglica) + i swobodną krzywą wewnętrzną (`GapFit curve` z dowolnym koszem). +- Rozjazdy są serializowane **pozycyjnie i ręcznie**. `pre_blade`, `blade_angle` + i `blade_length` nie są zapisywane w ogóle — doszły do modelu i wypadły z pliku. + +## 2. Cztery przyczyny źródłowe + +**2.1. Geometria rozjazdu leży poza grafem toru.** +Tor zasadniczy nie jest dzielony (słusznie), ale odgałęzienie rozjazdu nie należy +do niczego, a odnoga jest osobnym bytem. Ciągłość między krzyżownicą a odnogą nie +wynika z konstrukcji, tylko z **doklejania jej z powrotem przy każdym solve**. +Stąd błąd „odnoga uciekła 1,4 km", stąd konieczność oddawania `pinned` do +dokumentu, stąd szkic startujący ze starego miejsca. + +**2.2. Krzywa jest obywatelem drugiej kategorii.** +Model brzmi „proste i luki między nimi". Wszystko, co nie jest łukiem między +dwiema prostymi, wymaga wyjątku: ukryta prosta-prowadnica, tryb 5, `BasketDraft`. +Dlatego „dodaj łuk wprost z krzyżownicy" było trudne — w danych **nie ma jak +powiedzieć „element: łuk, zaczyna się tutaj"**. + +**2.3. Dwa źródła prawdy o współrzędnych.** +Dokument trzyma to, co narysowane; solver trzyma przypięte i przycięte +(`rendered_straights`). GUI czyta raz jedno, raz drugie. Stąd łuk startujący z +azymutem 0 i cała rodzina błędów „to nie jest tam, gdzie jest narysowane". + +**2.4. Typ katalogowy zmieszany z egzemplarzem.** +„Rz 1:9 R190" ma geometrię **określoną przez typ**. Pozwalanie, by egzemplarz +miał dowolny łuk koszowy w środku, jest bez sensu — a jednocześnie to, co +naprawdę jest swobodne (dokąd biegnie tor za krzyżownicą), było najtrudniejsze do +edycji. Skutek uboczny: każda liczba katalogowa to kolejne pole doklejane do +formatu pliku. + +## 3. Model docelowy + +### 3.1. KR jest prostą *(zrobione)* + +Najprostsze możliwe ujęcie, i to ono wygrało: **koniec rozjazdu to zwyczajna +prosta**, tylko inaczej związana niż narysowana ręcznie. + +``` +StraightSpec { + ... + int frog_of; // indeks rozjazdu, którego KR opuszcza ta prosta; -1 = zwykła +} +``` + +Początek i kierunek pisze solver z krzyżownicy, długość zostaje jej własna. +I to wszystko — **łuk za rozjazdem przestaje istnieć jako pojęcie**. Jest fitem w +luce między dwiema prostymi, jak każdy inny: to samo combo, ten sam promień, ten +sam kosz, ten sam komunikat „does not fit". + +Styczność w krzyżownicy nie jest pilnowana, tylko **wynika**: prosta z KR leży na +stycznej, a fit między prostymi jest styczny z definicji. + +Skasowało to: przypinanie całej odnogi, tryb 5, ukrytą kotwicę, `LeadArc`, +`StartPose` i cały `pose_fitter`. Odnoga przestała być bytem specjalnym — jest +niweletą, której pierwsza prosta ma jedną więź więcej. + +Efekt uboczny, ważniejszy niż czystość: **to, co narysowane, zostaje tam, gdzie +narysowane**. Wcześniej cała odnoga jechała sztywno za krzyżownicą. + +### 3.2. Element *(do zrobienia, ale już nie dla rozjazdów)* + +„Proste i luki" nadal wymaga wyjątku wszędzie tam, gdzie krzywa ma istnieć sama +z siebie — stąd `BasketDraft`. Docelowo prosta, łuk i klotoida są równorzędnymi +elementami ciągu, a to, co dziś robi szkic kosza, jest zwykłym dokładaniem +elementów. Rozjazdów to już nie dotyczy: 3.1 załatwiło je bez tego. + +### 3.3. Rozjazd jako urządzenie typu *(do zrobienia)* + +Rozdzielić dwie rzeczy, dziś zlepione w `Junction`: + +``` +TurnoutType { // katalog, dane z Id-1, niezmienne + name, crossing_n, radius, length, + pre_blade, blade_angle, blade_length, rail_profile +} + +TurnoutPlacement { // to, co edytuje użytkownik + type : TurnoutTypeRef // po nazwie, nie przez kopiowanie liczb + on : AlignmentRef, station, hand : L|P, facing +} +``` + +**Strefa rozjazdu jest nienaruszalna, bo jest egzemplarzem typu.** Nie da się jej +edytować, bo nie ma czego — liczby należą do typu. Rozjazd nietypowy to własny +typ, nie dowolność w egzemplarzu. + +Serializacja przestaje puchnąć: egzemplarz zapisuje `type` po nazwie plus +umiejscowienie. Nowa liczba katalogowa (jak dzisiejsze `blade_angle`, które wciąż +nie trafia do pliku) idzie do katalogu i **nie dotyka formatu**. + +## 4. Co to naprawia — konkretnie + +| dzisiejszy problem | dlaczego znika | +|---|---| +| odnoga ucieka / dryfuje od krzyżownicy | start odnogi jest referencją do portu | +| szkic z azymutem 0 | nie ma drugiego źródła współrzędnych | +| „robi się sztywna prosta" za rozjazdem | pierwszy element odnogi może być łukiem | +| łuk wymaga dwóch prostych | element powstaje z pozy i punktu | +| kosz w środku „Rz 1:9" | geometria wewnętrzna należy do typu | +| liczby iglicy nie zapisują się | egzemplarz odwołuje się do typu po nazwie | +| `BasketDraft` obok modelu | kosz to ciąg elementów-łuków, nic osobnego | + +## 5. Etapy + +1. ~~Odnoga przestaje być przypinana~~ — **zrobione** przez 3.1: `frog_of`. + Zniknęły `pin_branch_to_frog`, tryb 5, ukryta kotwica, `LeadArc` i + `pose_fitter`. +2. **Typ oddzielony od umiejscowienia** (3.3). Katalog Id-1 jako dane; egzemplarz + trzyma nazwę typu. Naprawia przy okazji serializację liczb iglicy. +3. **Elementy pierwszej kategorii** (3.2) i wycofanie `BasketDraft` — 395 linii + równoległego świata. + +## 6. Co zostaje bez zmian + +Warto powiedzieć wprost, bo to nie jest przepisywanie wszystkiego: + +- `domain::lay_turnout` i cała geometria rozjazdu — dobre, zostaje. +- `TurnoutPart` i punkty konstrukcyjne (`JunctionMark`) — zostają. +- `fit_arc_from_pose`, `fit_arc_from_pose_through` — to **są** właściwe prymitywy + nowego modelu, przechodzą nietknięte. +- `GapFitter` i fitowanie między prostymi — zostaje, jako jeden ze sposobów + wyznaczania elementu swobodnego. +- Rysowanie szyn, kafle, georeferencja — bez związku. + +Do wyrzucenia jest przede wszystkim **klej**: przypinanie, ukryte prowadnice, +tryb 5 jako wyjątek i `BasketDraft`. diff --git a/TURNOUT_SPEC.md b/TURNOUT_SPEC.md index adbafbab..939ccf8d 100644 --- a/TURNOUT_SPEC.md +++ b/TURNOUT_SPEC.md @@ -34,15 +34,24 @@ Turnout { PR : Pose // początek rozjazdu: styczny do toru zasadniczego KR : Pose // koniec (krzyżownica): kierunek = tor zasadniczy obrócony o alfa + length : L = a + b // katalogowa: styczna przednia + tylna + // (Rz 190-1:9: 13,834 + 13,304 = 27,138) + // koniec rozjazdu na torze zasadniczym to + // rzut KR na prostą, krótszy od L crossing_angle : alfa = atan(1/n) // radiany - blade_angle : beta // kąt nagięcia iglicy (kąt zwrotnicowy) - pre_blade_length : d // odcinek przediglicowy: prosty, PR -> ostrze iglicy + pieces : [Piece...] // geometria toru zwrotnego, PR -> KR, wprost z katalogu + blade : { u, w } // grubosc scietego ostrza, szerokosc glowki szyny through_axis : [Bezier...] // tor zasadniczy przez rozjazd (zwykle prosty) - diverging_axis: [Bezier...] // przediglicowy -> iglica(prosta @ beta) -> luk -> prosta -> KR + diverging_axis: [Bezier...] // te same odcinki, przybliżone Bézierami } +Piece { part, length, radius_start, radius_end, turn_in } + // part: 0 przediglicowy | 1 iglica | 2 luk | 3 prosta krzyzownicowa + // radius 0 = prosta; rowne = luk; rozne = krzywa przejsciowa + // turn_in = kat nagiecia iglicy (beta); 0 = iglica styczna do opornicy + Pose { x, y, hx, hy } // pozycja + jednostkowa styczna Bezier { p0, p1, p2, p3 } // kubiczna, punkty kontrolne w XY (metry) ``` @@ -51,27 +60,46 @@ Tor zasadniczy nie jest dzielony — `through_axis` jest podany dla kompletnośc (runtime często ma już tor główny). Rozjazd wnosi przede wszystkim `diverging_axis` + osprzęt + podrozjazdnice spinające oba tory. -## Początek odnogi: odcinek przediglicowy + iglica +## Początek odnogi: lista odcinków, nie jeden kształt -Odnoga **nie zaczyna się łukiem**. To zwrotnica sieczna z iglicą prostą: +Odnoga to **lista odcinków z katalogu**, kładziona po kolei od `PR`. Nic w niej +nie jest dopasowywane: skos i długość katalogowa są tym, do czego wynik się +**sprawdza** (`angle_residual`, `length_residual`), a nie tym, co domyka ostatni +łuk. Typowe zestawy: -1. **odcinek przediglicowy** — prosta w kierunku toru zasadniczego (kąt 0), od - `PR` do ostrza iglicy. Tu rozjazd jest jeszcze równoległy do toru głównego; - tu siedzi napęd. -2. **iglica** — **prosta odcięta pod kątem `beta`** („prosta dogięta do - prostego"). Odnoga odchodzi od toru zasadniczego dopiero tutaj, o kąt nagięcia - iglicy `beta`. -3. **łuk** — promień `R`, skręca od `beta` do kąta krzyżownicy `alfa`, czyli o - **`alfa − beta`** (nie o całe `alfa`). -4. **prosta krzyżownicowa** — pod kątem `alfa`, do `KR`. +- **zwrotnica sieczna z iglicą prostą** — `[przediglicowy] [iglica prosta @ beta] + [łuk o alfa − beta] [prosta krzyżownicowa]`, +- **zwrotnica z iglicą łukową styczną do opornicy** — `[iglica łuk R od PR] + [łuk R] [prosta krzyżownicowa]`, `beta = 0` wszędzie: odnoga odchodzi stycznie, + bez załamania kierunku. -Stąd `diverging_axis` = `[przediglicowy] [iglica @ beta] [łuk] [prosta] `. Iglice -łukowe (styczne, `beta ≈ 0`, ostrze wtopione w łuk) to inny wariant — na razie -modelujemy iglicę prostą. +**Ścięte ostrze** (Koc, rys. 5.9). Iglicy nie da się zaostrzyć do zera. Łuk +iglicy jest **teoretycznie styczny do opornicy w PR**, ale sama iglica zaczyna +się dopiero tam, gdzie jest z czego — w *ostrzu*, w odległości `a` od PR — a jej +zestrugany dziób o długości `d` dochodzi do punktu **A**, gdzie iglica ma już +grubość `u`. To, co leży między PR a ostrzem, to odcinek przediglicowy: krzywa +już tam jest, pojazd jeszcze jedzie po torze zasadniczym. -**Model:** zespół iglic kotwiony w ostrzu (`PR` + `pre_blade_length` wzdłuż toru -głównego), zorientowany wzdłuż toru zasadniczego, nadaje `beta`; długość iglicy -jest po stronie modelu. +Wszystko wychodzi z jednej drogi — do zadanego luzu między iglicą a opornicą: + +``` +s(y) = R * (acos(cos beta - y/R1) - beta) // iglica łukowa, R1 = R + s/2 +s(y) = y / sin beta // iglica prosta +a + d = s(u) struganie = s(w) - a +``` + +Luz otwiera się na **łuku samej iglicy** (`R1 = R + s/2`, Koc 5.2), ale `s(y)` +jest stacją **po osi** — promień szyny zamienia luz na kąt, nie mierzy długości. +Rysunek wymiaruje iglicę po szynie, więc katalogowe `a + d` to `s(u)` przeskalowane +o `R1/R`; na tablicy 5.1 zgadza się do dziesiątej części milimetra przy +1200–1:18,5 i do sześciu przy 190–1:9. Dlatego typ niesie tylko `u`, `d` i `w`, +a `a` się **wyprowadza**. + +(Templotowe `pl = w / tan beta` to to samo struganie rzutowane na opornicę.) + +**Model:** zespół iglic kotwiony w ostrzu, zorientowany wzdłuż osi iglicy w tym +punkcie; edytor podaje `PR`, ostrze, koniec dzioba `A` i koniec strugania jako +znaczniki (`kind` 0, 1, 7, 8). ## Bézier — definicja i próbkowanie @@ -167,10 +195,11 @@ animacji przełożenia — do ustalenia z modelarzem. Pozycja bazowa: `PR`. długość i osie w postaci polilinii. Do kontraktu trzeba dołożyć: - **`crossing_mark` i `rail_profile`** w danych eksportu, -- **`blade_angle` (beta) i `pre_blade_length`** — dziś `lay_turnout` kładzie łuk - styczny od `PR` (beta = 0, brak przediglicowego). Trzeba: odcinek przediglicowy - (prosty), iglicę prostą pod `beta`, łuk o `alfa − beta`, prostą do `KR`, -- te dwa parametry **w szablonach** (`turnout_preset`) per typ, +- ~~`blade_angle` i `pre_blade_length`~~ — zrobione inaczej: `lay_turnout` kładzie + **listę odcinków** z typu, więc przediglicowy, iglica (prosta pod `beta` albo + łukowa styczna), łuk i prosta krzyżownicowa to po prostu jej pozycje, +- **wymiary Id-1 w szablonach** (`turnout_preset`): długości iglic i odcinków + przediglicowych, `u` per typ — dziś liczone z odsunięcia pięty, - osie jako **Béziery** (dziś polilinie) — konwersja łuk/klotoida → kubiczne wg powyższego, - jawne oznaczenie „to rozjazd" (nie zwykły łuk), z `PR`/`KR`/`alfa`. diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 6a91492e..de68271f 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -16,19 +16,26 @@ http://mozilla.org/MPL/2.0/. #include "rendering/renderer.h" #include "utilities/Globals.h" -#include "maj0sted/editor/ribbon.hpp" +#include "maj0sted/editor/sketch.hpp" +#include "maj0sted/io/document_io.hpp" #include #include #include -#include +#include #include -#include -#include namespace { +using maj0sted::editor::Element; +using maj0sted::editor::ElementId; +using maj0sted::editor::Free; +using maj0sted::editor::Kind; +using maj0sted::editor::Parallel; +using maj0sted::editor::TrackId; +using maj0sted::editor::TurnoutId; + // the plan works in EPSG:2180, the scenery in the engine's world space around its own zero. the // georeference says which point of the projection that zero stands for; a fictional scenery leaves // it at the origin, so the two frames differ only in the naming of the axes - easting runs along @@ -38,6 +45,170 @@ glm::dvec3 plan_to_world(double const X, double const Y) return {X - Global.scenery_origin.x, 0.0, -(Y - Global.scenery_origin.y)}; } +// the construction points of a turnout, by TurnoutMark::kind. the map and the template drawing name +// them from this one list +char const *const mark_names[]{"PR", "ostrze", "pieta iglicy", "styk", "koniec luku", + "KR", "punkt teoretyczny", "dziob u", "koniec strugania", "koniec rozjazdu"}; + +// an angle the way a turnout drawing gives it. the kat przylegania is a couple of dozen minutes, so +// degrees alone say nothing +std::string angle_text(double const Radians) +{ + auto const total{std::abs(Radians) * 180.0 / std::numbers::pi}; + auto const degrees{static_cast(total)}; + auto const minutes{(total - degrees) * 60.0}; + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%s%d st %02d' %05.2f\"", Radians < 0.0 ? "-" : "", degrees, static_cast(minutes), + (minutes - static_cast(minutes)) * 60.0); + return buffer; +} + +// What a bend works out to, said the way a catalogue says it: both radii and the beta the tor +// zasadniczy turns through. Jednostronny is both tracks bent the same way, dwustronny the opposite. +std::string bend_text(double const Bend_, double const Diverging_, double const Angle_) +{ + if (Bend_ == 0.0) + { + return "rozjazd zwyczajny: tor zasadniczy prosty"; + } + auto const way{[](double const Curvature_) { return Curvature_ > 0.0 ? "w lewo" : "w prawo"; }}; + char text[256]; + if (Diverging_ == 0.0) + { + std::snprintf(text, sizeof(text), "rozjazd lukowy dwustronny: zasadniczy R %.3f m %s, zwrotny prosty, beta %s", std::abs(1.0 / Bend_), way(Bend_), + angle_text(Angle_).c_str()); + } + else + { + std::snprintf(text, sizeof(text), "rozjazd lukowy %s: zasadniczy R %.3f m %s, zwrotny R %.3f m %s, beta %s", + Diverging_ * Bend_ > 0.0 ? "jednostronny" : "dwustronny", std::abs(1.0 / Bend_), way(Bend_), std::abs(1.0 / Diverging_), + way(Diverging_), angle_text(Angle_).c_str()); + } + return text; +} + +// The two limits Koc puts on bending an ordinary turnout. Reported, never enforced: the drawing is +// the user's, and a warning that says the number is worth more than a control that refuses it +char const *bend_limit(double const Diverging_) +{ + if (Diverging_ == 0.0) + { + return nullptr; + } + auto const radius{std::abs(1.0 / Diverging_)}; + if (radius < 190.0) + { + return "tor zwrotny ponizej 190 m - najmniejszego promienia rozjazdu"; + } + if (radius < 214.0) + { + return "tor zwrotny ponizej 214 m - potrzebne poszerzenie toru zwrotnego"; + } + return nullptr; +} + +// a dashed polyline in screen space, for the lines a drawing means as theoretical. the dash carries +// across the joints between segments so a curve built of several pieces reads as one line +void dashed_polyline(ImDrawList *Drawlist_, std::vector const &Points_, ImU32 const Colour_, float const Thickness_, float const Dash_ = 7.0f, + float const Gap_ = 5.0f) +{ + // the view magnifies to millimetres, so a line can run for millions of pixels: what falls outside + // the clip rect is stepped over rather than drawn, or the draw list runs out of 16-bit indices + auto const low{Drawlist_->GetClipRectMin()}; + auto const high{Drawlist_->GetClipRectMax()}; + auto const period{Dash_ + Gap_}; + auto carried{0.0f}; + auto budget{400}; + for (std::size_t i = 1; i < Points_.size(); ++i) + { + auto const from{Points_[i - 1]}; + auto const dx{Points_[i].x - from.x}; + auto const dy{Points_[i].y - from.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len < 1e-4f) + { + continue; + } + + // the stretch of this piece that the clip rect actually shows, as a distance along it + auto first{0.0f}; + auto last{len}; + auto visible{true}; + float const edge[]{-dx, dx, -dy, dy}; + float const room[]{from.x - low.x + 8.0f, high.x - from.x + 8.0f, from.y - low.y + 8.0f, high.y - from.y + 8.0f}; + for (int side = 0; side < 4 && visible; ++side) + { + if (std::abs(edge[side]) < 1e-6f) + { + visible = room[side] >= 0.0f; + } + else if (auto const cut{room[side] / edge[side] * len}; edge[side] < 0.0f) + { + first = std::max(first, cut); + } + else + { + last = std::min(last, cut); + } + } + if (false == visible || first >= last) + { + carried = std::fmod(carried + len, period); + continue; + } + + auto at{std::max(0.0f, first - std::fmod(carried + first, period))}; + while (at < last && budget > 0) + { + --budget; + auto const phase{std::fmod(carried + at, period)}; + if (phase < Dash_) + { + auto const to{std::min(at + Dash_ - phase, len)}; + Drawlist_->AddLine(ImVec2(from.x + dx * at / len, from.y + dy * at / len), ImVec2(from.x + dx * to / len, from.y + dy * to / len), Colour_, + Thickness_); + at = to; + } + else + { + at += period - phase; + } + } + carried = std::fmod(carried + len, period); + } +} + +editor::turnout_preset const *find_preset(std::string const &Name_) +{ + for (auto const &preset : editor::turnout_presets()) + { + if (preset.name == Name_) + { + return &preset; + } + } + return nullptr; +} + +// the catalogue figure written onto a document's own type. a document keeps its own copy of the +// catalogue - that is what lets an instance name its type and nothing more - so this is the one +// place the preset's numbers cross over, and it has to carry all of them +void adopt_preset(editor::turnout_preset const &Preset_, maj0sted::editor::TurnoutType &Type_) +{ + Type_.name = Preset_.name; + Type_.crossing_n = Preset_.crossing_n; + Type_.length = Preset_.length; + Type_.blade.tip_thickness = Preset_.tip_thickness; + Type_.blade.nose = Preset_.nose; + Type_.blade.railtop_width = Preset_.railtop_width; + Type_.pieces.clear(); + for (auto const &piece : Preset_.pieces) + { + Type_.pieces.push_back( + maj0sted::editor::TurnoutPieceSpec{piece.part, piece.length, piece.radius_start, piece.radius_end, piece.turn_in}); + } +} + void world_to_plan(glm::dvec3 const &World, double &X, double &Y) { X = World.x + Global.scenery_origin.x; @@ -64,8 +235,7 @@ bool world_to_screen(glm::dvec3 const &World, ImVec2 &Screen) } // ground under the cursor in the top-down plan view. depth picking is useless here: empty scenery -// has nothing to hit, so the last (or zero) offset would pin every click to the same spot and the -// solver would drop the resulting zero-length straight +// has nothing to hit, so the last (or zero) offset would pin every click to the same spot glm::dvec3 ortho_cursor_world() { auto const width = std::max(1, Global.window_size.x); @@ -78,418 +248,59 @@ glm::dvec3 ortho_cursor_world() return {Global.pCamera.Pos.x + ndcx * halfwidth, 0.0, Global.pCamera.Pos.z - ndcy * halfheight}; } -ImU32 element_colour(int const Kind, bool const Active) +ImU32 element_colour(Kind const Kind_, bool const Active) { - auto const alpha{Active ? 255 : 210}; - switch (Kind) + auto const alpha{Active ? 255 : 200}; + switch (Kind_) { - case 1: // arc + case Kind::Arc: return IM_COL32(255, 190, 90, alpha); - case 2: // transition curve + case Kind::Clothoid: return IM_COL32(140, 245, 150, alpha); - default: // straight + default: return IM_COL32(125, 200, 255, alpha); } } -// one centreline element recovered from a left/right rail pair -// dual rails from a centreline, same 1.5 m gauge as maj0sted::render::RailRenderer -std::vector rails_from_centreline(std::vector const &Centre, double const Gauge = 1.5) +char const *kind_name(Kind const Kind_) { - std::vector out; - auto const half{Gauge * 0.5}; - for (auto const &poly : Centre) + switch (Kind_) { - if (poly.points.size() < 2) - { - continue; - } - for (double const side : {half, -half}) - { - maj0sted::editor::PlanPolyline rail; - rail.kind = poly.kind; - rail.length = poly.length; - rail.radius_start = poly.radius_start; - rail.radius_end = poly.radius_end; - rail.points.resize(poly.points.size()); - auto const m{poly.points.size()}; - for (std::size_t i = 0; i < m; ++i) - { - double dx{1.0}; - double dy{0.0}; - if (i == 0) - { - dx = poly.points[1].x - poly.points[0].x; - dy = poly.points[1].y - poly.points[0].y; - } - else if (i + 1 == m) - { - dx = poly.points[i].x - poly.points[i - 1].x; - dy = poly.points[i].y - poly.points[i - 1].y; - } - else - { - dx = poly.points[i + 1].x - poly.points[i - 1].x; - dy = poly.points[i + 1].y - poly.points[i - 1].y; - } - auto const len{std::hypot(dx, dy)}; - auto const nx{len > 0.0 ? -dy / len : 0.0}; - auto const ny{len > 0.0 ? dx / len : 0.0}; - rail.points[i] = {poly.points[i].x + nx * side, poly.points[i].y + ny * side}; - } - out.push_back(std::move(rail)); - } - } - return out; -} - -void draw_label(ImDrawList *Drawlist, ImVec2 const &Anchor, char const *Text, ImU32 const Colour) -{ - auto const size{ImGui::CalcTextSize(Text)}; - ImVec2 const pad{4.0f, 2.0f}; - ImVec2 const origin{Anchor.x + 6.0f, Anchor.y - size.y * 0.5f}; - Drawlist->AddRectFilled({origin.x - pad.x, origin.y - pad.y}, {origin.x + size.x + pad.x, origin.y + size.y + pad.y}, IM_COL32(15, 20, 32, 210), 3.0f); - Drawlist->AddRect({origin.x - pad.x, origin.y - pad.y}, {origin.x + size.x + pad.x, origin.y + size.y + pad.y}, Colour, 3.0f); - Drawlist->AddText(origin, Colour, Text); -} - -// leader from the curve midpoint outward, then a boxed label (R= / KP L=) -void draw_curve_callout(ImDrawList *Drawlist, std::vector const &Screen, char const *Text, ImU32 const Colour) -{ - if (Screen.size() < 2) - { - return; - } - auto const mid{Screen[Screen.size() / 2]}; - auto const i{static_cast(Screen.size() / 2)}; - auto const a{Screen[std::max(0, i - 1)]}; - auto const c{Screen[std::min(static_cast(Screen.size()) - 1, i + 1)]}; - auto ox{-(c.y - a.y)}; - auto oy{c.x - a.x}; - auto const len{std::hypot(ox, oy)}; - if (len > 1e-3f) - { - ox /= len; - oy /= len; - } - else - { - ox = 0.0f; - oy = -1.0f; - } - constexpr float kLead = 22.0f; - ImVec2 const tip{mid.x + ox * kLead, mid.y + oy * kLead}; - Drawlist->AddLine(mid, tip, Colour, 1.2f); - draw_label(Drawlist, tip, Text, Colour); -} - -// short perpendicular tick across the track — marks an arc end / radius break -void draw_station_tick(ImDrawList *Drawlist, ImVec2 const &AlongA, ImVec2 const &AlongB, ImU32 const Colour, float const Half = 8.0f) -{ - auto tx{AlongB.x - AlongA.x}; - auto ty{AlongB.y - AlongA.y}; - auto const tl{std::hypot(tx, ty)}; - if (tl < 1e-3f) - { - return; - } - tx /= tl; - ty /= tl; - ImVec2 const mid{(AlongA.x + AlongB.x) * 0.5f, (AlongA.y + AlongB.y) * 0.5f}; - ImVec2 const px{-ty * Half, tx * Half}; - Drawlist->AddLine({mid.x - px.x, mid.y - px.y}, {mid.x + px.x, mid.y + px.y}, Colour, 2.0f); -} - -void draw_arc_end_tick(ImDrawList *Drawlist, std::vector const &Screen, bool const AtStart, ImU32 const Colour) -{ - if (Screen.size() < 2) - { - return; - } - if (AtStart) - { - draw_station_tick(Drawlist, Screen[0], Screen[1], Colour); - } - else - { - draw_station_tick(Drawlist, Screen[Screen.size() - 2], Screen[Screen.size() - 1], Colour); + case Kind::Arc: + return "Luk"; + case Kind::Clothoid: + return "Krzywa przejsciowa"; + default: + return "Prosta"; } } -double point_segment_distance(double const Px, double const Py, double const Ax, double const Ay, double const Bx, double const By) +double distance_point_segment(ImVec2 const &Point, ImVec2 const &A, ImVec2 const &B) { - auto const dx{Bx - Ax}; - auto const dy{By - Ay}; + auto const dx{B.x - A.x}; + auto const dy{B.y - A.y}; auto const len2{dx * dx + dy * dy}; - if (len2 < 1e-18) + if (len2 <= 0.0f) { - return std::hypot(Px - Ax, Py - Ay); + return std::hypot(Point.x - A.x, Point.y - A.y); } - auto const t{std::clamp(((Px - Ax) * dx + (Py - Ay) * dy) / len2, 0.0, 1.0)}; - return std::hypot(Px - (Ax + t * dx), Py - (Ay + t * dy)); -} - -// distance from (X,Y) to the gap track: curve elements plus the two bounding straights -double distance_to_gap_track(maj0sted::editor::NiweletaPolys const &Solved, int const Gap, double const X, double const Y) -{ - auto best{std::numeric_limits::infinity()}; - for (auto const &poly : Solved.polylines) - { - auto const on_curve{poly.gap == Gap}; - auto const on_straight{poly.straight_index == Gap || poly.straight_index == Gap + 1}; - if ((false == on_curve && false == on_straight) || poly.points.size() < 2) - { - continue; - } - for (std::size_t i = 1; i < poly.points.size(); ++i) - { - best = std::min(best, point_segment_distance(X, Y, poly.points[i - 1].x, poly.points[i - 1].y, poly.points[i].x, poly.points[i].y)); - } - } - return best; -} - -struct GapSample -{ - double x{0.0}; - double y{0.0}; - double along{0.0}; -}; - -// centreline of a fitted gap, sampled in element order -std::vector sample_gap_centreline(maj0sted::editor::NiweletaPolys const &Solved, int const Gap) -{ - std::map by_el; - for (auto const &poly : Solved.polylines) - { - if (poly.gap != Gap || poly.element_index < 0 || poly.points.size() < 2) - { - continue; - } - if (by_el.find(poly.element_index) == by_el.end()) - { - by_el.emplace(poly.element_index, &poly); - } - } - std::vector out; - double along{0.0}; - for (auto const &[idx, poly] : by_el) - { - (void)idx; - for (std::size_t i = 0; i < poly->points.size(); ++i) - { - auto const &p{poly->points[i]}; - if (false == out.empty()) - { - auto const &prev{out.back()}; - if (std::hypot(p.x - prev.x, p.y - prev.y) < 1e-9) - { - continue; - } - along += std::hypot(p.x - prev.x, p.y - prev.y); - } - out.push_back({p.x, p.y, along}); - } - } - return out; -} - -bool project_on_gap(std::vector const &Samples, double const X, double const Y, double &OutStation, double &OutX, double &OutY) -{ - if (Samples.size() < 2) - { - return false; - } - auto best_d{std::numeric_limits::infinity()}; - double best_s{0.0}; - double best_x{Samples.front().x}; - double best_y{Samples.front().y}; - for (std::size_t i = 1; i < Samples.size(); ++i) - { - auto const &a{Samples[i - 1]}; - auto const &b{Samples[i]}; - auto const dx{b.x - a.x}; - auto const dy{b.y - a.y}; - auto const len2{dx * dx + dy * dy}; - auto t{0.0}; - if (len2 > 1e-18) - { - t = std::clamp(((X - a.x) * dx + (Y - a.y) * dy) / len2, 0.0, 1.0); - } - auto const px{a.x + t * dx}; - auto const py{a.y + t * dy}; - auto const d{std::hypot(X - px, Y - py)}; - if (d < best_d) - { - best_d = d; - best_s = a.along + t * (b.along - a.along); - best_x = px; - best_y = py; - } - } - OutStation = best_s; - OutX = best_x; - OutY = best_y; - return std::isfinite(best_d); -} - -bool point_at_station(std::vector const &Samples, double Station, double &OutX, double &OutY) -{ - if (Samples.empty()) - { - return false; - } - if (Station <= Samples.front().along) - { - OutX = Samples.front().x; - OutY = Samples.front().y; - return true; - } - if (Station >= Samples.back().along) - { - OutX = Samples.back().x; - OutY = Samples.back().y; - return true; - } - for (std::size_t i = 1; i < Samples.size(); ++i) - { - auto const &a{Samples[i - 1]}; - auto const &b{Samples[i]}; - if (Station > b.along) - { - continue; - } - auto const span{std::max(1e-12, b.along - a.along)}; - auto const t{(Station - a.along) / span}; - OutX = a.x + (b.x - a.x) * t; - OutY = a.y + (b.y - a.y) * t; - return true; - } - return false; -} - -// kind: 0 entry KP end, 1 free-arc end, 2 between-KP end, 3 exit KP start -struct CmpStationHandle -{ - int gap{0}; - int kind{0}; - int arc{0}; - double x{0.0}; - double y{0.0}; -}; - -std::vector compound_station_handles(maj0sted::editor::GapFit const &Fit, maj0sted::editor::NiweletaPolys const &Solved, int const Gap) -{ - std::vector handles; - if (Fit.mode != 3 || Fit.arcs.empty()) - { - return handles; - } - auto const samples{sample_gap_centreline(Solved, Gap)}; - if (samples.size() < 2) - { - return handles; - } - auto const total{samples.back().along}; - - auto place = [&](int const Kind, int const Arc, double const Station) { - double x{0.0}; - double y{0.0}; - if (point_at_station(samples, std::clamp(Station, 0.0, total), x, y)) - { - handles.push_back({Gap, Kind, Arc, x, y}); - } - }; - - auto s{0.0}; - place(0, 0, std::max(0.0, Fit.entry_t)); // entry KP end (at 0 when no entry KP yet) - s = std::max(0.0, Fit.entry_t); - for (std::size_t i = 0; i + 1 < Fit.arcs.size(); ++i) - { - s += std::max(0.0, Fit.arcs[i].length); - place(1, static_cast(i), s); // free circular-arc end - auto const kp{std::max(0.0, Fit.arcs[i].transition_to_next)}; - if (kp > 1e-6) - { - s += kp; - place(2, static_cast(i), s); // between-KP end - } - } - // exit KP start: measured from the end of the solved curve - place(3, 0, std::max(0.0, total - std::max(0.0, Fit.exit_t))); - return handles; -} - -std::vector sample_draft_centreline(std::vector const &Polys) -{ - std::vector out; - double along{0.0}; - for (auto const &poly : Polys) - { - for (auto const &p : poly.points) - { - if (false == out.empty()) - { - auto const &prev{out.back()}; - if (std::hypot(p.x - prev.x, p.y - prev.y) < 1e-9) - { - continue; - } - along += std::hypot(p.x - prev.x, p.y - prev.y); - } - out.push_back({p.x, p.y, along}); - } - } - return out; -} - -// draft stations: every arc has L (including the last), unlike GapFit -std::vector draft_station_handles(maj0sted::app::BasketDraft const &Draft, std::vector const &Polys) -{ - std::vector handles; - if (false == Draft.active || Draft.arcs.empty()) - { - return handles; - } - auto const samples{sample_draft_centreline(Polys)}; - if (samples.size() < 2) - { - return handles; - } - auto const total{samples.back().along}; - auto place = [&](int const Kind, int const Arc, double const Station) { - double x{0.0}; - double y{0.0}; - if (point_at_station(samples, std::clamp(Station, 0.0, total), x, y)) - { - handles.push_back({-1, Kind, Arc, x, y}); - } - }; - auto s{std::max(0.0, Draft.entry_t)}; - place(0, 0, s); - for (std::size_t i = 0; i < Draft.arcs.size(); ++i) - { - s += std::max(0.0, Draft.arcs[i].length); - place(1, static_cast(i), s); - auto const kp{std::max(0.0, Draft.arcs[i].transition_to_next)}; - if (i + 1 < Draft.arcs.size() && kp > 1e-6) - { - s += kp; - place(2, static_cast(i), s); - } - } - place(3, 0, std::max(0.0, total - std::max(0.0, Draft.exit_t))); - return handles; + auto t{((Point.x - A.x) * dx + (Point.y - A.y) * dy) / len2}; + t = std::clamp(t, 0.0f, 1.0f); + return std::hypot(Point.x - (A.x + dx * t), Point.y - (A.y + dy * t)); } } // namespace +// --------------------------------------------------------------------------- +// lifecycle +// --------------------------------------------------------------------------- + plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) { - size_min = {420, 240}; + size_min = {440, 260}; size_max = {900, 900}; - m_document.niwelety.push_back({"niweleta 1", {}, {}}); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety.front().name.c_str()); + seed_catalogue(); + new_track(); solve(); } @@ -506,14 +317,2404 @@ void plan_panel::render_contents() draw_on_scene(); render_toolbar(); - render_draft(); - render_switches(); - render_gaps(); + render_elements(); + render_turnouts(); + render_diagnostics(); render_storage(); render_newmap_dialog(); render_location_dialog(); + render_template_window(); } +void plan_panel::seed_catalogue() +{ + // the catalogue is data: an instance names its type, so a new figure goes in here and never + // touches the file format for placements. + // + // a plan saved before the iglica became a piece of its own keeps a type of that very name with + // no blade in it at all - two odcinki, luk i krzyzownicowa. that is not a figure anyone + // authored, it is what the old writer could say, so it is brought up to the current catalogue + // rather than left to lay a switch that has no ostrze. a type someone really did edit keeps its + // iglica and is left alone + auto refreshed{0}; + for (auto const &preset : editor::turnout_presets()) + { + auto existing{std::find_if(m_document.turnout_types.begin(), m_document.turnout_types.end(), + [&](auto const &Type_) { return Type_.name == preset.name; })}; + if (existing == m_document.turnout_types.end()) + { + maj0sted::editor::TurnoutType type; + adopt_preset(preset, type); + m_document.turnout_types.push_back(std::move(type)); + continue; + } + auto const has_blade{std::any_of(existing->pieces.begin(), existing->pieces.end(), [](auto const &Piece_) { return Piece_.part == 1; })}; + if (false == has_blade) + { + adopt_preset(preset, *existing); + ++refreshed; + } + } + if (refreshed > 0) + { + m_status = std::to_string(refreshed) + " typ(ow) rozjazdu bylo bez iglicy - wziete z katalogu na nowo"; + } +} + +// --------------------------------------------------------------------------- +// document +// --------------------------------------------------------------------------- + +void plan_panel::solve() +{ + m_solution = maj0sted::editor::solve(m_document); + m_rails = maj0sted::editor::render_rails(m_solution); +} + +plan_panel::Track *plan_panel::current_track() +{ + return maj0sted::editor::find_track(m_document, m_track); +} + +plan_panel::Track const *plan_panel::current_track() const +{ + return maj0sted::editor::find_track(m_document, m_track); +} + +void plan_panel::new_track() +{ + Track track; + track.id = maj0sted::editor::mint_track(m_document); + track.name = "tor " + std::to_string(m_document.tracks.size() + 1); + m_document.tracks.push_back(track); + m_track = track.id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", track.name.c_str()); + m_pending = false; + clear_selection(); +} + +void plan_panel::delete_current_track() +{ + auto const it{std::find_if(m_document.tracks.begin(), m_document.tracks.end(), [&](Track const &Track_) { return Track_.id == m_track; })}; + if (it == m_document.tracks.end()) + { + return; + } + // a turnout standing on it, and anything anchored to that turnout, would be left dangling + auto const gone{it->id}; + m_document.tracks.erase(it); + for (auto turnout{m_document.turnouts.begin()}; turnout != m_document.turnouts.end();) + { + turnout = (turnout->on == gone) ? m_document.turnouts.erase(turnout) : turnout + 1; + } + if (m_document.tracks.empty()) + { + new_track(); + } + else + { + m_track = m_document.tracks.front().id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.tracks.front().name.c_str()); + } + m_pending = false; + clear_selection(); + solve(); +} + +void plan_panel::clear_selection() +{ + m_sel_element = ElementId::none; + m_sel_turnout = TurnoutId::none; + m_dragging_turnout = false; +} + +void plan_panel::append_point_to(double const X, double const Y) +{ + auto *track{current_track()}; + if (track == nullptr) + { + return; + } + + // an empty track has no direction yet, so the first click only says where it starts + if (track->elements.empty()) + { + if (false == m_pending) + { + m_pending = true; + m_startx = X; + m_starty = Y; + m_status = "kliknij drugi raz, zeby polozyc pierwsza prosta"; + return; + } + auto const dx{X - m_startx}; + auto const dy{Y - m_starty}; + auto const length{std::hypot(dx, dy)}; + if (length < 1.0) + { + m_status = "za krotka prosta"; + return; + } + track->anchor = maj0sted::editor::AtPose{m_startx, m_starty, std::atan2(dx, dy)}; + track->elements.push_back(maj0sted::editor::make_line(m_document, length)); + m_pending = false; + solve(); + m_status.clear(); + return; + } + + // the track ends on the last click, so that end is the corner this one turns at + auto const *solved{maj0sted::editor::find_track(m_solution, track->id)}; + if (solved == nullptr || solved->elements.empty()) + { + m_status = "tor nie jest polozony - popraw najpierw to, co nie wyszlo"; + return; + } + auto const corner_pose{solved->end}; + + auto const dx{X - corner_pose.x}; + auto const dy{Y - corner_pose.y}; + auto const reach{std::hypot(dx, dy)}; + if (reach < 1.0) + { + m_status = "za krotki odcinek"; + return; + } + + auto const corner{maj0sted::editor::fit_corner(corner_pose.hx, corner_pose.hy, dx / reach, dy / reach, m_corner_radius)}; + if (false == corner.ok) + { + // straight on: nothing to round. two straights end to end are one straight, so the one + // already there simply gets longer + track->elements.back().length += reach; + solve(); + m_status = "prosto dalej: przedluzona ostatnia prosta do " + to_string(track->elements.back().length, 1) + " m"; + return; + } + + // the arc eats R*tan(kat/2) off the leg behind the corner and off the one in front. neither may + // go negative, which is the whole of "naroznik za ostry na tym promieniu" + auto &previous{track->elements.back()}; + if (previous.kind != Kind::Line || previous.length <= corner.tangent + 0.5) + { + m_status = "naroznik za ostry na R=" + to_string(m_corner_radius, 0) + " m: potrzeba " + to_string(corner.tangent, 1) + " m stycznej, a przed nim jest " + + to_string(previous.kind == Kind::Line ? previous.length : 0.0, 1) + " m prostej"; + return; + } + if (reach <= corner.tangent + 0.5) + { + m_status = "klikniete za blisko naroznika: potrzeba " + to_string(corner.tangent, 1) + " m stycznej, a jest " + to_string(reach, 1) + " m"; + return; + } + + previous.length -= corner.tangent; + track->elements.push_back(maj0sted::editor::make_arc(m_document, m_corner_radius, corner.hand, corner.arc_length)); + track->elements.push_back(maj0sted::editor::make_line(m_document, reach - corner.tangent)); + solve(); + m_status.clear(); +} + +void plan_panel::drop_last_vertex() +{ + auto *track{current_track()}; + if (track == nullptr || track->elements.empty()) + { + m_pending = false; + return; + } + if (track->elements.back().kind != Kind::Line) + { + m_status = "ostatni odcinek nie jest prosta - zdejmij go w liscie"; + return; + } + + track->elements.pop_back(); + // and the arc that rounded the corner in front of it, whose tangent goes back onto the straight + // it was taken from. what is left ends on the click before this one + if (false == track->elements.empty() && track->elements.back().kind == Kind::Arc) + { + auto const &arc{track->elements.back()}; + auto const tangent{arc.radius > 0.0 ? arc.radius * std::tan(0.5 * arc.length / arc.radius) : 0.0}; + track->elements.pop_back(); + if (false == track->elements.empty() && track->elements.back().kind == Kind::Line) + { + track->elements.back().length += tangent; + } + } + + clear_selection(); + solve(); +} + +void plan_panel::append_element(Kind const Kind_) +{ + auto *track{current_track()}; + if (track == nullptr) + { + return; + } + + // two straights end to end are one straight - there would be no corner between them and nothing + // to grab hold of, so the one already there just gets longer + if (Kind_ == Kind::Line && false == track->elements.empty() && track->elements.back().kind == Kind::Line) + { + track->elements.back().length += m_new_length; + m_sel_element = track->elements.back().id; + solve(); + m_status = "prosta doklejona do poprzedniej: " + to_string(track->elements.back().length, 1) + " m"; + return; + } + + Element element; + element.id = maj0sted::editor::mint_element(m_document); + element.kind = Kind_; + element.radius = Kind_ == Kind::Line ? 0.0 : m_new_radius; + element.hand = Kind_ == Kind::Line ? 0 : m_new_hand; + element.length = m_new_length; + + // what the chain is already curving at decides the joint, not what happens to be typed in the + // boxes: an arc after a transition curve is the arc that curve eases into, so it takes its + // radius and its hand. a transition after an arc turns the same way the arc does + std::string inherited; + if (false == track->elements.empty()) + { + auto const &previous{track->elements.back()}; + auto const curving{previous.kind != Kind::Line && previous.radius > 0.0}; + if (Kind_ == Kind::Arc && previous.kind == Kind::Clothoid && curving) + { + element.radius = previous.radius; + element.hand = previous.hand; + inherited = " (R i skret z krzywej przejsciowej)"; + } + else if (Kind_ == Kind::Arc && previous.kind == Kind::Clothoid) + { + // the transition was running out into a straight; now it runs into this arc, so that + // is what it eases to + track->elements.back().radius = element.radius; + track->elements.back().hand = element.hand; + inherited = " (krzywa przejsciowa dostala jego R i skret)"; + } + else if (Kind_ == Kind::Clothoid && previous.kind == Kind::Arc) + { + // a transition ends on whatever follows it, and for now nothing does: it runs out + // into a straight. dokladajac za nia luk, przejmie jego promien + element.radius = 0.0; + element.hand = previous.hand; + inherited = " (wybiega w prosta)"; + } + } + + track->elements.push_back(element); + m_sel_element = element.id; + solve(); + m_status = std::string{"dolozono: "} + kind_name(Kind_) + inherited; +} + +void plan_panel::drop_last_element() +{ + auto *track{current_track()}; + if (track == nullptr || track->elements.empty()) + { + return; + } + track->elements.pop_back(); + clear_selection(); + solve(); +} + +void plan_panel::delete_selected_element() +{ + if (m_sel_element == ElementId::none) + { + return; + } + for (auto &track : m_document.tracks) + { + auto const it{std::find_if(track.elements.begin(), track.elements.end(), [&](Element const &Element_) { return Element_.id == m_sel_element; })}; + if (it != track.elements.end()) + { + track.elements.erase(it); + break; + } + } + m_sel_element = ElementId::none; + solve(); +} + +bool plan_panel::refit_current_track(std::string &Why) +{ + auto *track{current_track()}; + // the solution still holds the geometry from before the change, which is exactly what the + // straights are supposed to go on standing on + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + if (track == nullptr || solved == nullptr) + { + Why = "tor nie jest polozony"; + return false; + } + + maj0sted::editor::Skeleton skeleton; + if (false == maj0sted::editor::skeleton_of(*track, *solved, skeleton, Why)) + { + return false; + } + return maj0sted::editor::lay_along_skeleton(*track, skeleton, Why); +} + +void plan_panel::drag_straight_to(double const X, double const Y) +{ + auto *track{current_track()}; + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + if (track == nullptr || solved == nullptr || m_drag_index >= track->elements.size()) + { + return; + } + + maj0sted::editor::Skeleton skeleton; + std::string why; + if (false == maj0sted::editor::skeleton_of(*track, *solved, skeleton, why)) + { + m_status = why; + return; + } + + // which of the skeleton's lines the grabbed straight is: the straights in order, which is not + // every second element once corners hold baskets or transition curves + std::size_t line_index{0}; + for (std::size_t i = 0; i < m_drag_index; ++i) + { + if (track->elements[i].kind == Kind::Line) + { + ++line_index; + } + } + if (line_index >= skeleton.lines.size()) + { + return; + } + + // the grabbed straight swings onto the line through its far end and the cursor. every other + // line stays exactly as it was, which is what keeps their azimuths to the millimetre + auto const dx{X - m_pivot_x}; + auto const dy{Y - m_pivot_y}; + auto const reach{std::hypot(dx, dy)}; + if (reach < 1.0) + { + return; + } + // grabbing the end it runs out to swings it forward from the far end; grabbing the end it + // starts on swings it backward, so the direction of travel is the other way round + auto const sign{m_drag_end == 1 ? 1.0 : -1.0}; + auto &line{skeleton.lines[line_index]}; + line.px = m_pivot_x; + line.py = m_pivot_y; + line.dx = sign * dx / reach; + line.dy = sign * dy / reach; + + // the free ends of the skeleton stay where they are, except the one being dragged + if (line_index == 0 && m_drag_end == 0) + { + skeleton.start_x = X; + skeleton.start_y = Y; + } + if (line_index + 1 == skeleton.lines.size() && m_drag_end == 1) + { + skeleton.end_x = X; + skeleton.end_y = Y; + } + + // nothing is written until the whole chain works out, so a bad drag leaves the track alone + Track candidate{*track}; + if (false == maj0sted::editor::lay_along_skeleton(candidate, skeleton, why)) + { + m_status = why; + return; + } + // the chain laying out is not enough: shortening a straight can pull it out from under a rozjazd + // standing on it, and a rozjazd half on an arc is not a rozjazd. the drag stops at that instead + auto const off_straight{[](maj0sted::editor::Solution const &Solution_) { + std::size_t count{0}; + for (auto const &diagnostic : Solution_.diagnostics) + { + count += diagnostic.code == maj0sted::editor::Code::StationOffTrack ? 1 : 0; + } + return count; + }}; + + auto const was{off_straight(m_solution)}; + Track const before{*track}; + *track = std::move(candidate); + solve(); + if (off_straight(m_solution) > was) + { + for (auto const &diagnostic : m_solution.diagnostics) + { + if (diagnostic.code == maj0sted::editor::Code::StationOffTrack) + { + m_status = "dalej nie: " + diagnostic.text; + break; + } + } + *track = before; + solve(); + return; + } + m_status.clear(); +} + +void plan_panel::place_turnout_at(TrackId const On, double const Wx, double const Wy) +{ + double station{0.0}; + if (false == station_on(On, Wx, Wy, station)) + { + m_status = "nie ma na czym postawic rozjazdu"; + return; + } + if (m_document.turnout_types.empty()) + { + m_status = "katalog rozjazdow jest pusty"; + return; + } + auto const type{static_cast(std::clamp(m_turnout_type, 0, static_cast(m_document.turnout_types.size()) - 1))}; + + maj0sted::editor::TurnoutPlacement placement; + placement.id = maj0sted::editor::mint_turnout(m_document); + placement.type = m_document.turnout_types[type].name; + placement.on = On; + placement.station = station; + placement.hand = m_new_hand; + m_document.turnouts.push_back(placement); + + // a switch is worth nothing without somewhere for the branch to go, so one starts at its frog + Track branch; + branch.id = maj0sted::editor::mint_track(m_document); + branch.name = "odnoga " + std::to_string(m_document.tracks.size() + 1); + branch.anchor = maj0sted::editor::AtPort{placement.id, maj0sted::editor::Port::Frog}; + m_document.tracks.push_back(branch); + + m_track = branch.id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", branch.name.c_str()); + m_sel_turnout = placement.id; + m_pending = false; + solve(); + m_status = "rozjazd " + placement.type + " na " + to_string(station, 1) + " m; odnoga zaczyna sie na krzyzownicy"; +} + +// --------------------------------------------------------------------------- +// hit tests +// --------------------------------------------------------------------------- + +bool plan_panel::hit_straight_end(ImVec2 const &Mouse, std::size_t &OutIndex, int &OutEnd) const +{ + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + auto const *track{current_track()}; + if (solved == nullptr || track == nullptr) + { + return false; + } + + constexpr float tolerance{10.0f}; + auto best{tolerance}; + auto found{false}; + for (std::size_t i = 0; i < solved->elements.size() && i < track->elements.size(); ++i) + { + if (track->elements[i].kind != Kind::Line) + { + continue; + } + // a branch starts on its turnout's frog and cannot be pulled off it + auto const pinned{i == 0 && false == std::holds_alternative(track->anchor)}; + for (int end = 0; end < 2; ++end) + { + if (end == 0 && pinned) + { + continue; + } + auto const &pose{end == 0 ? solved->elements[i].start : solved->elements[i].end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const distance{static_cast(std::hypot(Mouse.x - screen.x, Mouse.y - screen.y))}; + if (distance < best) + { + best = distance; + OutIndex = i; + OutEnd = end; + found = true; + } + } + } + return found; +} + +bool plan_panel::hit_element(ImVec2 const &Mouse, ElementId &OutElement, TrackId &OutTrack) const +{ + constexpr float tolerance{8.0f}; + auto best{tolerance}; + auto found{false}; + for (auto const &rail : m_rails) + { + if (rail.element == ElementId::none || rail.points.size() < 2) + { + continue; + } + for (std::size_t i = 1; i < rail.points.size(); ++i) + { + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(rail.points[i - 1].x, rail.points[i - 1].y), a) || + false == world_to_screen(plan_to_world(rail.points[i].x, rail.points[i].y), b)) + { + continue; + } + auto const distance{static_cast(distance_point_segment(Mouse, a, b))}; + if (distance < best) + { + best = distance; + OutElement = rail.element; + OutTrack = rail.track; + found = true; + } + } + } + return found; +} + +bool plan_panel::hit_turnout(ImVec2 const &Mouse, TurnoutId &OutTurnout) const +{ + constexpr float tolerance{10.0f}; + auto best{tolerance}; + auto found{false}; + for (auto const &turnout : m_solution.turnouts) + { + if (false == turnout.valid) + { + continue; + } + for (auto const &pose : {turnout.start, turnout.frog}) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const distance{std::hypot(Mouse.x - screen.x, Mouse.y - screen.y)}; + if (distance < best) + { + best = distance; + OutTurnout = turnout.id; + found = true; + } + } + } + return found; +} + +bool plan_panel::station_on(TrackId const Track_, double const Wx, double const Wy, double &OutStation) const +{ + auto const *track{maj0sted::editor::find_track(m_solution, Track_)}; + if (track == nullptr || track->centreline.size() < 2) + { + return false; + } + + auto best{std::numeric_limits::max()}; + double walked{0.0}; + for (std::size_t i = 1; i < track->centreline.size(); ++i) + { + auto const &a{track->centreline[i - 1]}; + auto const &b{track->centreline[i]}; + auto const dx{b.x - a.x}; + auto const dy{b.y - a.y}; + auto const len2{dx * dx + dy * dy}; + auto const step{std::sqrt(len2)}; + if (step <= 0.0) + { + continue; + } + auto t{((Wx - a.x) * dx + (Wy - a.y) * dy) / len2}; + t = std::clamp(t, 0.0, 1.0); + auto const distance{std::hypot(Wx - (a.x + dx * t), Wy - (a.y + dy * t))}; + if (distance < best) + { + best = distance; + OutStation = walked + step * t; + } + walked += step; + } + return best < std::numeric_limits::max(); +} + +plan_panel::TrackId plan_panel::nearest_track_axis(ImVec2 const &Mouse, float const Tolerance) const +{ + auto best{Tolerance}; + auto found{TrackId::none}; + for (auto const &track : m_solution.tracks) + { + for (std::size_t i = 1; i < track.centreline.size(); ++i) + { + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(track.centreline[i - 1].x, track.centreline[i - 1].y), a) || + false == world_to_screen(plan_to_world(track.centreline[i].x, track.centreline[i].y), b)) + { + continue; + } + auto const distance{static_cast(distance_point_segment(Mouse, a, b))}; + if (distance < best) + { + best = distance; + found = track.id; + } + } + } + return found; +} + +// --------------------------------------------------------------------------- +// the scene +// --------------------------------------------------------------------------- + +void plan_panel::handle_scene() +{ + auto const &io{ImGui::GetIO()}; + if (io.WantCaptureMouse && false == m_dragging_turnout) + { + return; + } + + auto const mouse{io.MousePos}; + auto const world{ortho_cursor_world()}; + double wx{0.0}; + double wy{0.0}; + world_to_plan(world, wx, wy); + + // Esc drops every transient errand back to plain selection + // this ImGui indexes IsKeyPressed by the backend's own key codes; ImGuiKey_ names are slots in + // io.KeyMap, so the mapping has to be gone through rather than passed straight in + auto const &keymap{ImGui::GetIO().KeyMap}; + if (keymap[ImGuiKey_Escape] >= 0 && ImGui::IsKeyPressed(keymap[ImGuiKey_Escape])) + { + m_pick_turnout = 0; + m_pick_parallel = 0; + m_pending = false; + clear_selection(); + m_status.clear(); + } + + // --- transient errands ------------------------------------------------ + if (m_pick_turnout == 1 && ImGui::IsMouseClicked(0)) + { + auto const on{nearest_track_axis(mouse, 14.0f)}; + if (on != TrackId::none) + { + place_turnout_at(on, wx, wy); + m_pick_turnout = 0; + } + else + { + m_status = "kliknij tor, na ktorym ma stanac rozjazd"; + } + return; + } + if (m_pick_parallel != 0 && ImGui::IsMouseClicked(0)) + { + ElementId reference{ElementId::none}; + TrackId owner{TrackId::none}; + auto *track{current_track()}; + if (track != nullptr && hit_element(mouse, reference, owner) && owner != m_track) + { + auto const it{std::find_if(track->elements.begin(), track->elements.end(), [&](Element const &Element_) { return Element_.id == m_sel_element; })}; + if (it != track->elements.end()) + { + it->hold = Parallel{reference, m_new_offset}; + m_status = "trzyma rownoleglosc, odstep " + to_string(m_new_offset, 2) + " m"; + solve(); + } + } + else + { + m_status = "wskaz odcinek innego toru"; + } + m_pick_parallel = 0; + return; + } + + // --- ctrl: grabbing a straight by one of its ends --------------------- + // this is the one thing a modifier is needed for: the ends of a straight sit on the track, so + // a plain click there has to stay a plain click + if (io.KeyCtrl && ImGui::IsMouseClicked(0)) + { + std::size_t index{0}; + int end{0}; + if (hit_straight_end(mouse, index, end)) + { + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + auto const *track{current_track()}; + if (solved != nullptr && index < solved->elements.size()) + { + // the far end is captured now: re-fitting the arc behind it will move it + auto const &pivot{end == 1 ? solved->elements[index].start : solved->elements[index].end}; + m_pivot_x = pivot.x; + m_pivot_y = pivot.y; + m_drag_index = index; + m_drag_end = end; + m_dragging_straight = true; + m_sel_element = track->elements[index].id; + if (track != nullptr && std::holds_alternative(track->elements[index].hold)) + { + m_status = "ten odcinek trzyma rownoleglosc - po puszczeniu tor i tak zostanie przystawiony"; + } + } + return; + } + } + if (m_dragging_straight) + { + if (ImGui::IsMouseDown(0)) + { + drag_straight_to(wx, wy); + } + else + { + m_dragging_straight = false; + } + return; + } + + // --- one click, whatever is under it ---------------------------------- + // clicking something that is already drawn picks it up; clicking bare ground carries the + // current track on to there. there is nothing to switch between: what you clicked says what + // you meant + if (ImGui::IsMouseClicked(0)) + { + TurnoutId turnout{TurnoutId::none}; + ElementId element{ElementId::none}; + TrackId track{TrackId::none}; + + if (hit_turnout(mouse, turnout)) + { + m_sel_turnout = turnout; + m_dragging_turnout = true; + } + else if (hit_element(mouse, element, track)) + { + m_sel_element = element; + m_track = track; + auto const *chosen{maj0sted::editor::find_track(m_document, track)}; + if (chosen != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", chosen->name.c_str()); + } + } + else + { + append_point_to(wx, wy); + } + } + + // right-click takes back the last click, which is the only thing it does + if (ImGui::IsMouseClicked(1)) + { + drop_last_vertex(); + } + + if (ImGui::IsMouseDown(0) && m_dragging_turnout && m_sel_turnout != TurnoutId::none) + { + auto *placement{maj0sted::editor::find_turnout(m_document, m_sel_turnout)}; + if (placement != nullptr) + { + double station{0.0}; + if (station_on(placement->on, wx, wy, station)) + { + placement->station = station; + solve(); + } + } + } + + if (ImGui::IsMouseReleased(0)) + { + m_dragging_turnout = false; + } + + // double-clicking anywhere on a track makes it the one being edited + if (ImGui::IsMouseDoubleClicked(0)) + { + auto const track{nearest_track_axis(mouse, 16.0f)}; + if (track != TrackId::none) + { + m_track = track; + auto const *chosen{maj0sted::editor::find_track(m_document, track)}; + if (chosen != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", chosen->name.c_str()); + m_status = "edytowany: " + chosen->name; + } + } + } +} + +void plan_panel::draw_on_scene() +{ + // the background list paints over the rendered scenery but under every window, so the controls + // stay readable on top of the drawing + auto *drawlist{ImGui::GetBackgroundDrawList()}; + + // orthophoto under everything else. only a georeferenced map knows where on the ground it sits, + // so a fictional one gets no imagery + if (m_showortho && Global.scenery_georeferenced) + { + double planx{0.0}; + double plany{0.0}; + world_to_plan(Global.pCamera.Pos, planx, plany); + + // yaw is pinned north-up, so the screen is an axis-aligned box in plan space + auto const display{ImGui::GetIO().DisplaySize}; + auto const halfheight{static_cast(Global.editor_ortho_extent)}; + auto const halfwidth{halfheight * std::max(1.0f, display.x) / std::max(1.0f, display.y)}; + maj0sted::editor::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; + + auto const draw_box = [&](maj0sted::editor::TileBBox const &Box, unsigned int const Texture) { + ImVec2 corners[4]; + auto const ok = world_to_screen(plan_to_world(Box.min_x, Box.max_y), corners[0]) && world_to_screen(plan_to_world(Box.max_x, Box.max_y), corners[1]) && + world_to_screen(plan_to_world(Box.max_x, Box.min_y), corners[2]) && world_to_screen(plan_to_world(Box.min_x, Box.min_y), corners[3]); + if (false == ok) + { + return; + } + // north-west corner first, and the texture's first row (v=0) is its northern edge + drawlist->AddImageQuad(reinterpret_cast(static_cast(Texture)), corners[0], corners[1], corners[2], corners[3], ImVec2(0.0f, 0.0f), ImVec2(1.0f, 0.0f), + ImVec2(1.0f, 1.0f), ImVec2(0.0f, 1.0f)); + }; + + // once the view is wider than a kilometre the 100 m orto cells are the wrong scale: + // switch to a 1 km topo grid that can cover the whole screen + constexpr double kTopoPreviewMetres = 1000.0; + auto const span{std::max(halfwidth, halfheight) * 2.0}; + if (span > kTopoPreviewMetres) + { + for (auto const &tile : m_topo.collect(view, 80)) + { + draw_box(tile.box, tile.texture); + } + } + else + { + for (auto const &tile : m_ortho.collect(view)) + { + draw_box(tile.box, tile.texture); + } + } + } + + // the track itself + std::vector points; + for (auto const &rail : m_rails) + { + if (rail.points.size() < 2) + { + continue; + } + auto const active{rail.track == m_track || rail.turnout != TurnoutId::none}; + points.clear(); + points.reserve(rail.points.size()); + for (auto const &point : rail.points) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) + { + continue; + } + points.push_back(screen); + } + if (points.size() < 2) + { + continue; + } + auto colour{element_colour(rail.kind, active)}; + auto width{2.5f}; + if (rail.turnout != TurnoutId::none) + { + // every element of a rozjazd is drawn as itself: the pre-blade lead runs along + // the through track, the blade is the piece that leaves the opornica, then the + // curve and the straight frog rail + switch (rail.part) + { + case 0: + // the pre-blade lead lies exactly on the through track, so it has to be + // drawn wide and in a colour of its own or it simply is not there + colour = IM_COL32(140, 240, 150, 255); // przediglicowy + width = 4.5f; + break; + case 1: + colour = IM_COL32(120, 235, 255, 245); // iglica + break; + case 3: + colour = IM_COL32(255, 200, 110, 235); // prosta krzyzownicowa + break; + default: + colour = IM_COL32(255, 140, 200, 230); // luk + break; + } + } + else if (rail.element == m_sel_element && m_sel_element != ElementId::none) + { + colour = IM_COL32(255, 255, 255, 245); + } + drawlist->AddPolyline(points.data(), static_cast(points.size()), colour, false, width); + } + + // turnouts: PR, the frog, and the construction points a drawing is dimensioned from + for (auto const &turnout : m_solution.turnouts) + { + if (false == turnout.valid) + { + continue; + } + auto const selected{turnout.id == m_sel_turnout}; + + // the points that sit on the straight - PR, the blade tip and the koniec rozjazdu - + // are drawn the way a catalogue drawing marks them: a tick across the track, not a + // dot lost among the rails the turnout runs along + auto const tick{[&](maj0sted::editor::TurnoutMark const &Mark_, ImU32 const Colour_, float const Half_) { + ImVec2 centre; + if (false == world_to_screen(plan_to_world(Mark_.x, Mark_.y), centre)) + { + return; + } + ImVec2 along; + if (false == world_to_screen(plan_to_world(Mark_.x + turnout.start.hx, Mark_.y + turnout.start.hy), along)) + { + return; + } + auto dx{along.x - centre.x}; + auto dy{along.y - centre.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len < 1e-3f) + { + return; + } + dx /= len; + dy /= len; + drawlist->AddLine(ImVec2(centre.x + dy * Half_, centre.y - dx * Half_), ImVec2(centre.x - dy * Half_, centre.y + dx * Half_), Colour_, 2.0f); + }}; + + for (auto const &mark : turnout.marks) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(mark.x, mark.y), screen)) + { + continue; + } + + // PR and KR carry the switch - both on the diverging path and, for the + // koniec rozjazdu, on the straight it stands on. the ostrze is the other + // point worth finding on the drawing, so it gets a colour of its own + auto const primary{mark.kind == 0 || mark.kind == 5 || mark.kind == 9}; + auto const blade{mark.kind == 1 || mark.kind == 7 || mark.kind == 8}; + drawlist->AddCircleFilled(screen, primary ? (selected ? 6.0f : 5.0f) : (blade ? 4.0f : 3.0f), + primary ? IM_COL32(255, 210, 90, 240) : (blade ? IM_COL32(120, 235, 255, 230) : IM_COL32(255, 150, 210, 190))); + } + for (auto const &mark : turnout.marks) + { + switch (mark.kind) + { + case 0: // PR + case 9: // koniec rozjazdu na torze zasadniczym + tick(mark, IM_COL32(255, 210, 90, 245), selected ? 14.0f : 11.0f); + break; + case 1: // ostrze iglicy: koniec odcinka przediglicowego + tick(mark, IM_COL32(120, 235, 255, 245), 9.0f); + break; + case 7: // A: koniec zestruganego dzioba, iglica ma tu grubosc u + tick(mark, IM_COL32(140, 240, 150, 245), 7.0f); + break; + default: + break; + } + } + + // the blade's own geometry lives in the first few metres of the switch, so at any + // zoom that shows the whole rozjazd its points land on top of one another. named + // on leader lines fanned out to the side they can at least be told apart + if (selected) + { + auto ordered{turnout.marks}; + std::sort(ordered.begin(), ordered.end(), + [](auto const &Left_, auto const &Right_) { return Left_.station < Right_.station; }); + + auto level{0}; + for (auto const &mark : ordered) + { + if (mark.kind < 0 || mark.kind >= static_cast(sizeof(mark_names) / sizeof(mark_names[0]))) + { + continue; + } + ImVec2 centre; + ImVec2 along; + if (false == world_to_screen(plan_to_world(mark.x, mark.y), centre) || + false == world_to_screen(plan_to_world(mark.x + turnout.start.hx, mark.y + turnout.start.hy), along)) + { + continue; + } + auto dx{along.x - centre.x}; + auto dy{along.y - centre.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len < 1e-3f) + { + continue; + } + dx /= len; + dy /= len; + + // alternate sides and step further out on each pass, so the leaders fan + // instead of stacking on one line + auto const side{(level % 2) == 0 ? 1.0f : -1.0f}; + auto const reach{(30.0f + 17.0f * static_cast(level / 2)) * side}; + ImVec2 const anchor(centre.x + dy * reach, centre.y - dx * reach); + + char label[96]; + if (mark.station > 0.0) + { + std::snprintf(label, sizeof(label), "%s %.3f", mark_names[mark.kind], mark.station); + } + else + { + std::snprintf(label, sizeof(label), "%s", mark_names[mark.kind]); + } + auto const size{ImGui::CalcTextSize(label)}; + ImVec2 const corner(anchor.x - size.x * 0.5f, anchor.y - size.y * 0.5f); + + drawlist->AddLine(centre, anchor, IM_COL32(200, 200, 200, 130), 1.0f); + drawlist->AddRectFilled(ImVec2(corner.x - 3.0f, corner.y - 2.0f), ImVec2(corner.x + size.x + 3.0f, corner.y + size.y + 2.0f), + IM_COL32(15, 20, 30, 205), 3.0f); + drawlist->AddText(corner, IM_COL32(235, 240, 250, 245), label); + ++level; + } + } + } + + // where a chain was cut short, say so on the spot rather than only in the list + for (auto const &track : m_solution.tracks) + { + if (track.complete || track.elements.empty()) + { + continue; + } + ImVec2 screen; + if (world_to_screen(plan_to_world(track.end.x, track.end.y), screen)) + { + drawlist->AddCircle(screen, 9.0f, IM_COL32(255, 110, 110, 230), 0, 2.0f); + drawlist->AddLine(ImVec2(screen.x - 6.0f, screen.y - 6.0f), ImVec2(screen.x + 6.0f, screen.y + 6.0f), IM_COL32(255, 110, 110, 230), 2.0f); + drawlist->AddLine(ImVec2(screen.x - 6.0f, screen.y + 6.0f), ImVec2(screen.x + 6.0f, screen.y - 6.0f), IM_COL32(255, 110, 110, 230), 2.0f); + } + } + + // the ends of the edited track's straights, while ctrl says they are what you are after + if (ImGui::GetIO().KeyCtrl) + { + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + auto const *track{current_track()}; + if (solved != nullptr && track != nullptr) + { + for (std::size_t i = 0; i < solved->elements.size() && i < track->elements.size(); ++i) + { + if (track->elements[i].kind != Kind::Line) + { + continue; + } + auto const pinned{i == 0 && false == std::holds_alternative(track->anchor)}; + for (int end = 0; end < 2; ++end) + { + if (end == 0 && pinned) + { + continue; + } + auto const &pose{end == 0 ? solved->elements[i].start : solved->elements[i].end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const grabbed{m_dragging_straight && i == m_drag_index && end == m_drag_end}; + auto const size{grabbed ? 5.0f : 4.0f}; + drawlist->AddRectFilled(ImVec2(screen.x - size, screen.y - size), ImVec2(screen.x + size, screen.y + size), + grabbed ? IM_COL32(255, 255, 255, 245) : IM_COL32(255, 240, 140, 200)); + } + } + } + } + + // where the run of clicks starts, before there is any track to show for it + if (m_pending) + { + ImVec2 screen; + if (world_to_screen(plan_to_world(m_startx, m_starty), screen)) + { + drawlist->AddCircle(screen, 6.0f, IM_COL32(255, 240, 140, 230), 0, 2.0f); + } + } +} + +void plan_panel::go_to_plan() +{ + auto minx{std::numeric_limits::max()}; + auto miny{std::numeric_limits::max()}; + auto maxx{std::numeric_limits::lowest()}; + auto maxy{std::numeric_limits::lowest()}; + + for (auto const &track : m_solution.tracks) + { + for (auto const &point : track.centreline) + { + minx = std::min(minx, point.x); + maxx = std::max(maxx, point.x); + miny = std::min(miny, point.y); + maxy = std::max(maxy, point.y); + } + } + if (minx > maxx) + { + return; // nothing drawn yet, so there is nowhere to go + } + + auto &camera{editor_mode::get_camera()}; + auto const centre{plan_to_world((minx + maxx) * 0.5, (miny + maxy) * 0.5)}; + camera.Pos.x = centre.x; + camera.Pos.z = centre.z; + + // show the whole drawing with a little room around it + auto const span{std::max({maxx - minx, maxy - miny, 50.0})}; + Global.editor_ortho_extent = std::clamp(static_cast(span * 0.6), 5.0f, 20000.0f); +} + +// --------------------------------------------------------------------------- +// panels +// --------------------------------------------------------------------------- + +void plan_panel::render_toolbar() +{ + ImGui::Text("widok z gory, %.0f m w poprzek", Global.editor_ortho_extent * 2.0f); + ImGui::TextDisabled("klik w teren prowadzi tor, klik w odcinek go wybiera, dwuklik zmienia edytowany tor"); + ImGui::TextDisabled("Ctrl + LPM na krancu prostej: przeciagniecie; luki obok dopasuja sie same"); + if (Global.scenery_georeferenced) + { + ImGui::Text("zero mapy: %.0f, %.0f (EPSG:2180)", Global.scenery_origin.x, Global.scenery_origin.y); + } + else + { + ImGui::TextDisabled("mapa fikcyjna, bez georeferencji"); + } + + if (ImGui::Button("Nowa mapa")) + { + // opened from the same stack level the modal is drawn on, which is render_contents + ImGui::OpenPopup("New map"); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("wybor miejsca: mapa fikcyjna albo punkt w Polsce, na ktorym staje zero scenerii"); + } + if (Global.scenery_georeferenced) + { + ImGui::SameLine(); + ImGui::Checkbox("Ortofoto", &m_showortho); + if (m_showortho) + { + auto const pending{static_cast(m_ortho.pending() + m_topo.pending())}; + if (pending > 0) + { + ImGui::SameLine(); + ImGui::TextDisabled("(%d kafli w drodze)", pending); + } + } + } + ImGui::Separator(); + + // how a corner gets rounded when the track is carried on by clicking. a kink is not track, so + // the turn from where the track heads now to where the click is goes round an arc of this + // radius; it eats R*tan(kat/2) off the leg behind it and off the one in front + ImGui::SetNextItemWidth(120.0f); + ImGui::InputDouble("R w narozniku [m]", &m_corner_radius, 10.0, 100.0, "%.0f"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("klikniecie w pusty teren prowadzi tor dalej; zwrot miedzy dotychczasowym kierunkiem\n" + "a klikietym punktem zostaje zaokraglony lukiem o tym promieniu.\n" + "im wiekszy R, tym dluzsza styczna R*tan(kat/2) sciagana z obu prostych\n" + "- dlatego ostry naroznik tuz przy koncu toru sie nie miesci.\n\n" + "prawy klik cofa ostatnie klikniecie"); + } + ImGui::SameLine(); + ImGui::TextDisabled("prawy klik cofa klikniecie"); + + // which track is being edited + auto *track{current_track()}; + if (ImGui::BeginCombo("Edytowany", track != nullptr ? track->name.c_str() : "-")) + { + for (auto const &candidate : m_document.tracks) + { + if (ImGui::Selectable(candidate.name.c_str(), candidate.id == m_track)) + { + m_track = candidate.id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", candidate.name.c_str()); + clear_selection(); + m_pending = false; + } + } + ImGui::EndCombo(); + } + if (track != nullptr) + { + ImGui::SetNextItemWidth(240.0f); + if (ImGui::InputText("Nazwa", m_namebuf, sizeof(m_namebuf))) + { + track->name = m_namebuf; + } + } + + if (ImGui::Button("Nowy tor")) + { + new_track(); + solve(); + } + ImGui::SameLine(); + if (m_document.tracks.size() > 1 && ImGui::Button("Usun tor")) + { + delete_current_track(); + } + ImGui::SameLine(); + if (ImGui::Button("Pokaz calosc")) + { + go_to_plan(); + } + + ImGui::Separator(); +} + +void plan_panel::render_elements() +{ + auto *track{current_track()}; + if (track == nullptr) + { + return; + } + + if (false == ImGui::CollapsingHeader("Odcinki", ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + // what a new element is made of. pick the numbers, then say which of the three it is: there is + // nothing else to an element, and nothing here is a mode of its own + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("dl. [m]", &m_new_length, 5.0, 50.0, "%.1f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("R [m]", &m_new_radius, 10.0, 100.0, "%.1f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + { + int hand{m_new_hand >= 0 ? 0 : 1}; + if (ImGui::Combo("skret", &hand, "w lewo\0w prawo\0")) + { + m_new_hand = hand == 0 ? 1 : -1; + } + } + + if (ImGui::Button("+ prosta")) + { + append_element(Kind::Line); + } + ImGui::SameLine(); + if (ImGui::Button("+ luk")) + { + append_element(Kind::Arc); + } + ImGui::SameLine(); + if (ImGui::Button("+ krzywa przejsciowa")) + { + append_element(Kind::Clothoid); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("R jest promieniem na jej koncu; na poczatku bierze sie z poprzedniego odcinka.\n0 = wybiega w prosta"); + } + ImGui::SameLine(); + if (ImGui::Button("Cofnij") && false == track->elements.empty()) + { + drop_last_element(); + } + + ImGui::Separator(); + + if (track->elements.empty()) + { + ImGui::TextDisabled("pusty tor. dolóz odcinek ponizej, albo klikaj po mapie: pierwsze dwa klikniecia kladzie prosta."); + return; + } + + auto dirty{false}; + // a change of shape - a radius, a hand, the length of a transition - belongs to its own corner + // and to nothing else, so the track is re-laid along the lines its straights already stood on. + // a change of length is what it says it is: the element gets longer, and what follows moves + auto refit{false}; + auto const *solved{maj0sted::editor::find_track(m_solution, track->id)}; + + for (std::size_t i = 0; i < track->elements.size(); ++i) + { + auto &element{track->elements[i]}; + ImGui::PushID(static_cast(i)); + + auto const selected{element.id == m_sel_element}; + auto const *solved_element{solved != nullptr && i < solved->elements.size() ? &solved->elements[i] : nullptr}; + auto const held{std::holds_alternative(element.hold)}; + + // the row says the whole element: what it is, how long, and how it turns. a held one says + // so too, because its shape is not its own + char label[200]; + switch (element.kind) + { + case Kind::Arc: + std::snprintf(label, sizeof(label), "%zu. Luk R=%.0f %s %.1f m (%.2f st.)%s%s", i + 1, element.radius, element.hand >= 0 ? "w lewo" : "w prawo", element.length, + element.radius > 0.0 ? element.length / element.radius * 180.0 / std::numbers::pi : 0.0, held ? " [rownolegly]" : "", + solved_element != nullptr ? "" : " (nie polozony)"); + break; + case Kind::Clothoid: + std::snprintf(label, sizeof(label), "%zu. Krzywa przejsciowa %.1f m (do R=%.0f %s)%s", i + 1, element.length, element.radius, element.hand >= 0 ? "w lewo" : "w prawo", + solved_element != nullptr ? "" : " (nie polozony)"); + break; + default: + std::snprintf(label, sizeof(label), "%zu. Prosta %.1f m%s%s", i + 1, element.length, held ? " [rownolegla]" : "", + solved_element != nullptr ? "" : " (nie polozony)"); + break; + } + if (ImGui::Selectable(label, selected)) + { + m_sel_element = element.id; + } + + if (selected) + { + ImGui::Indent(); + + int kind{element.kind == Kind::Line ? 0 : (element.kind == Kind::Arc ? 1 : 2)}; + ImGui::SetNextItemWidth(180.0f); + if (ImGui::Combo("rodzaj", &kind, "prosta\0luk\0krzywa przejsciowa\0")) + { + element.kind = kind == 0 ? Kind::Line : (kind == 1 ? Kind::Arc : Kind::Clothoid); + dirty = true; + } + + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("dlugosc [m]", &element.length, 5.0, 50.0, "%.2f")) + { + dirty = true; + // a transition's length is authored, and what it takes out of the corner is the + // corner's business - so this one is absorbed rather than pushed down the track + refit = element.kind == Kind::Clothoid; + } + + if (element.kind != Kind::Line) + { + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble(element.kind == Kind::Clothoid ? "R na koncu [m]" : "promien [m]", &element.radius, 10.0, 100.0, "%.2f")) + { + maj0sted::editor::sync_joint(*track, i); + dirty = true; + refit = true; + } + if (element.kind == Kind::Clothoid && ImGui::IsItemHovered()) + { + ImGui::SetTooltip("0 = krzywa wybiegajaca w prosta; promien na poczatku bierze sie z poprzedniego odcinka"); + } + ImGui::SetNextItemWidth(120.0f); + int hand{element.hand >= 0 ? 0 : 1}; + if (ImGui::Combo("skret", &hand, "w lewo\0w prawo\0")) + { + element.hand = hand == 0 ? 1 : -1; + maj0sted::editor::sync_joint(*track, i); + dirty = true; + refit = true; + } + if (element.kind == Kind::Arc && element.radius > 0.0) + { + auto turn{element.length / element.radius * 180.0 / std::numbers::pi}; + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("kat zwrotu [st.]", &turn, 1.0, 10.0, "%.3f")) + { + element.length = std::max(0.0, turn) * std::numbers::pi / 180.0 * element.radius; + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("ten sam luk, powiedziany katem zamiast dlugoscia"); + } + } + } + + // TODO: kontrolki rownoleglosci (trzymanie miedzytorza) chwilowo schowane z gui - + // samo Parallel/hold i solver zostaja, wraca tu przycisk i pole odstepu + + if (solved_element != nullptr) + { + ImGui::TextDisabled("polozony: %.2f m, R %.1f -> %.1f", solved_element->length, std::abs(solved_element->k0) > 1e-9 ? 1.0 / std::abs(solved_element->k0) : 0.0, + std::abs(solved_element->k1) > 1e-9 ? 1.0 / std::abs(solved_element->k1) : 0.0); + } + + if (ImGui::SmallButton("usun odcinek")) + { + ImGui::Unindent(); + ImGui::PopID(); + delete_selected_element(); + return; + } + + ImGui::Unindent(); + } + + ImGui::PopID(); + } + + if (refit) + { + // the change stands either way - it is what was asked for. what a failed fit costs is that + // the rest of the track follows along instead of staying put, and that is worth saying + std::string why; + if (refit_current_track(why)) + { + m_status = "dopasowano naroznik; reszta toru bez zmian"; + } + else + { + m_status = why + " - dalsza czesc toru poszla za zmiana"; + } + } + if (dirty) + { + solve(); + } +} + +// the geometry of a turnout is the type's, and the type is a list: przediglicowy, iglica, luk, +// prosta krzyzownicowa, in the order they are laid from PR. every length is the catalogue's - +// nothing here is fitted, so the skos and the catalogue length are checked against what the list +// works out to and the difference is said out loud +void plan_panel::render_turnout_type(maj0sted::editor::TurnoutType &Type_) +{ + auto dirty{false}; + + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("skos 1:n", &Type_.crossing_n, 0.5, 1.0, "%.3f")) + { + dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("dl. kat. a+b [m]", &Type_.length, 0.1, 1.0, "%.3f")) + { + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("dlugosc katalogowa to suma stycznych a + b, tak jak na rysunku typu"); + } + + static char const *const part_names[]{"przediglicowy", "iglica", "luk", "krzyzownicowa"}; + for (std::size_t i = 0; i < Type_.pieces.size(); ++i) + { + auto &piece{Type_.pieces[i]}; + ImGui::PushID(static_cast(2000 + i)); + piece.part = std::clamp(piece.part, 0, 3); + + char label[200]; + if (piece.radius_start <= 0.0 && piece.radius_end <= 0.0) + { + std::snprintf(label, sizeof(label), "%zu. %s prosta %.3f m", i + 1, part_names[piece.part], piece.length); + } + else if (std::abs(piece.radius_start - piece.radius_end) < 1e-9) + { + std::snprintf(label, sizeof(label), "%zu. %s luk R %.0f %.3f m", i + 1, part_names[piece.part], piece.radius_start, piece.length); + } + else + { + std::snprintf(label, sizeof(label), "%zu. %s R %.0f -> %.0f %.3f m", i + 1, part_names[piece.part], piece.radius_start, piece.radius_end, + piece.length); + } + if (ImGui::Selectable(label, static_cast(i) == m_sel_piece)) + { + m_sel_piece = static_cast(i); + } + if (static_cast(i) == m_sel_piece) + { + ImGui::Indent(); + ImGui::SetNextItemWidth(150.0f); + if (ImGui::Combo("rola", &piece.part, "przediglicowy\0iglica\0luk\0krzyzownicowa\0")) + { + dirty = true; + } + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("dlugosc [m]", &piece.length, 0.1, 1.0, "%.3f")) + { + dirty = true; + } + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("R poczatek [m]", &piece.radius_start, 10.0, 100.0, "%.2f")) + { + dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("R koniec [m]", &piece.radius_end, 10.0, 100.0, "%.2f")) + { + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("0 = prosta; rowne promienie = luk, rozne = krzywa przejsciowa"); + } + auto turn_in{piece.turn_in * 180.0 / std::numbers::pi}; + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("nagiecie [st.]", &turn_in, 0.05, 0.5, "%.4f")) + { + piece.turn_in = std::max(0.0, turn_in) * std::numbers::pi / 180.0; + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("kat nagiecia iglicy (beta): 0 = iglica styczna do opornicy"); + } + if (ImGui::SmallButton("usun odcinek")) + { + Type_.pieces.erase(Type_.pieces.begin() + static_cast(i)); + m_sel_piece = -1; + ImGui::Unindent(); + ImGui::PopID(); + solve(); + return; + } + ImGui::Unindent(); + } + ImGui::PopID(); + } + if (ImGui::SmallButton("dodaj odcinek")) + { + Type_.pieces.push_back(maj0sted::editor::TurnoutPieceSpec{2, 1.0, 0.0, 0.0, 0.0}); + m_sel_piece = static_cast(Type_.pieces.size()) - 1; + dirty = true; + } + + // the blade rail itself: a tip cannot be ground to nothing, so it is cut back to a thickness + // and the point where the blade leaves the opornica is only a theoretical one + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("ostrze u [m]", &Type_.blade.tip_thickness, 0.001, 0.005, "%.4f")) + { + dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("dziob d [m]", &Type_.blade.nose, 0.001, 0.005, "%.4f")) + { + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("zestrugana dlugosc od ostrza do punktu, w ktorym iglica ma grubosc u (Koc, tablica 5.1)"); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("glowka w [m]", &Type_.blade.railtop_width, 0.001, 0.005, "%.4f")) + { + dirty = true; + } + + auto const laid{maj0sted::domain::lay_turnout(maj0sted::domain::geometry::Pose{0.0, 0.0, 1.0, 0.0}, maj0sted::editor::to_domain(Type_, 1))}; + if (false == laid.valid) + { + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.55f, 1.0f), "lista nie da sie polozyc: dlugosci > 0, promienie >= 0"); + } + else + { + if (laid.has_blade) + { + ImGui::TextDisabled("iglica: ostrze a %.4f m, dziob do %.4f m (o %+.4f), struganie %.3f m, przyleganie %.4f st.", laid.blade_station, laid.nose_end, + laid.blade_residual, laid.planing_length, laid.blade_angle * 180.0 / std::numbers::pi); + } + auto const closed{std::abs(laid.angle_residual) <= 1e-6 && (Type_.length <= 0.0 || std::abs(laid.length_residual) <= 1e-3)}; + ImGui::TextColored(closed ? ImVec4(0.6f, 0.9f, 0.6f, 1.0f) : ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "domkniecie: kat %+.5f st., dlugosc %+.4f m", + laid.angle_residual * 180.0 / std::numbers::pi, laid.length_residual); + } + + if (dirty) + { + solve(); + } + ImGui::Separator(); +} + +// the catalogue drawing of the chosen type: the diverging path piece by piece in the colours of its +// roles, both tracks' rails, and the construction points - the same list the solver hands the map, +// so the two cannot drift apart. a rozjazd is thirty metres long and a metre and a half wide, so as +// on every real drawing the view across the track is stretched, and by how much is said on screen. +// the blade's own dimensions are millimetres; they come out under the wheel, which magnifies about +// the cursor +void plan_panel::render_template_window() +{ + if (false == m_show_template) + { + return; + } + ImGui::SetNextWindowSize(ImVec2(1020.0f, 580.0f), ImGuiCond_FirstUseEver); + if (false == ImGui::Begin("Rysunek szablonu rozjazdu", &m_show_template)) + { + ImGui::End(); + return; + } + if (m_document.turnout_types.empty()) + { + ImGui::TextDisabled("katalog jest pusty"); + ImGui::End(); + return; + } + + m_turnout_type = std::clamp(m_turnout_type, 0, static_cast(m_document.turnout_types.size()) - 1); + auto const &type{m_document.turnout_types[static_cast(m_turnout_type)]}; + auto const start{maj0sted::domain::geometry::Pose{0.0, 0.0, 1.0, 0.0}}; + auto const bend{m_tpl_bend_radius > 1.0 ? (m_tpl_bend_hand == 0 ? 1.0 : -1.0) / m_tpl_bend_radius : 0.0}; + auto const laid{maj0sted::domain::lay_turnout(start, maj0sted::editor::to_domain(type, 1), bend)}; + if (false == laid.valid) + { + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.55f, 1.0f), bend != 0.0 ? "typu \"%s\" nie da sie wygiac na taki luk" : "typ \"%s\" nie da sie polozyc", + type.name.c_str()); + ImGui::SetNextItemWidth(150.0f); + ImGui::InputDouble("R toru zasadniczego", &m_tpl_bend_radius, 10.0, 100.0, "%.3f"); + ImGui::End(); + return; + } + + // what the drawing says in words, above what it says in lines + ImGui::Text("%s skos 1:%.4g alfa %s dlugosc katalogowa %.3f m", type.name.c_str(), type.crossing_n, + angle_text(std::atan(1.0 / std::max(type.crossing_n, 1e-9))).c_str(), type.length); + ImGui::TextDisabled("styczne a %.3f + b %.3f m na torze zasadniczym %.3f m odgalezienie %.3f m", laid.tangent_front, laid.tangent_back, + laid.through_length, laid.diverging_length); + if (laid.has_blade) + { + ImGui::TextDisabled("iglica: u %.4f d %.4f w %.3f m ostrze a %.4f m dziob u %.4f m (o %+.4f) struganie %.3f m przyleganie %s", + type.blade.tip_thickness, type.blade.nose, type.blade.railtop_width, laid.blade_station, laid.nose_end, laid.blade_residual, + laid.planing_length, angle_text(laid.blade_angle).c_str()); + } + else + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "ten typ nie ma iglicy - zaden odcinek nie ma roli \"iglica\""); + } + auto const closed{std::abs(laid.angle_residual) <= 1e-6 && (type.length <= 0.0 || std::abs(laid.length_residual) <= 1e-3)}; + ImGui::TextColored(closed ? ImVec4(0.6f, 0.9f, 0.6f, 1.0f) : ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "domkniecie: kat %+.5f st., dlugosc %+.4f m", + laid.angle_residual * 180.0 / std::numbers::pi, laid.length_residual); + + // lukowanie: the drawing of a bent turnout is the drawing of the same turnout, rotated about its + // centre - the tangents and the crossing angle do not move, only the radii + ImGui::SetNextItemWidth(150.0f); + if (ImGui::InputDouble("R zasadniczego (0 = prosty)", &m_tpl_bend_radius, 10.0, 100.0, "%.3f")) + { + m_tpl_bend_radius = std::max(0.0, m_tpl_bend_radius); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + ImGui::Combo("wygiety", &m_tpl_bend_hand, "w lewo\0w prawo\0"); + ImGui::TextDisabled("%s", bend_text(laid.bend, laid.diverging_curvature, laid.bend_angle).c_str()); + if (auto const *limit{bend_limit(laid.diverging_curvature)}; limit != nullptr) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "%s", limit); + } + + ImGui::SetNextItemWidth(180.0f); + ImGui::SliderFloat("przewyzszenie", &m_tpl_exagg, 1.0f, 60.0f, "x%.0f"); + ImGui::SameLine(); + ImGui::Checkbox("szyny", &m_tpl_rails); + ImGui::SameLine(); + ImGui::Checkbox("punkty", &m_tpl_marks); + ImGui::SameLine(); + if (ImGui::SmallButton("dopasuj")) + { + m_tpl_zoom = 1.0f; + m_tpl_pan = 0.0f; + } + ImGui::SameLine(); + ImGui::TextDisabled("kolko powieksza, przeciaganie przesuwa"); + + auto const avail{ImGui::GetContentRegionAvail()}; + auto const w{std::max(avail.x, 120.0f)}; + auto const h{std::max(avail.y, 120.0f)}; + auto const origin{ImGui::GetCursorScreenPos()}; + ImGui::InvisibleButton("rysunek", ImVec2(w, h)); + + auto const span{std::max(laid.through_length, laid.tangent_front + laid.tangent_back) + 2.0}; + auto const scale_of{[&](float const Zoom) { return static_cast(w) * 0.94 / span * static_cast(Zoom); }}; + auto sx{scale_of(m_tpl_zoom)}; + auto centre{span * 0.5 + static_cast(m_tpl_pan)}; + + // the wheel magnifies about the cursor, so the blade can be run down to millimetres + if (ImGui::IsItemHovered() && ImGui::GetIO().MouseWheel != 0.0f) + { + auto const offset{static_cast(ImGui::GetIO().MousePos.x - origin.x - w * 0.5f)}; + auto const under{centre + offset / sx}; + m_tpl_zoom = std::clamp(m_tpl_zoom * std::pow(1.25f, ImGui::GetIO().MouseWheel), 1.0f, 5000.0f); + sx = scale_of(m_tpl_zoom); + centre = under - offset / sx; + m_tpl_pan = static_cast(centre - span * 0.5); + } + if (ImGui::IsItemActive() && ImGui::IsMouseDown(0)) + { + m_tpl_pan -= static_cast(static_cast(ImGui::GetIO().MouseDelta.x) / sx); + centre = span * 0.5 + static_cast(m_tpl_pan); + } + + auto const sy{sx * static_cast(m_tpl_exagg)}; + auto const to_screen{[&](double const X, double const Y) { + return ImVec2(origin.x + w * 0.5f + static_cast((X - centre) * sx), origin.y + h * 0.58f - static_cast(Y * sy)); + }}; + + auto *drawlist{ImGui::GetWindowDrawList()}; + drawlist->PushClipRect(origin, ImVec2(origin.x + w, origin.y + h), true); + drawlist->AddRectFilled(origin, ImVec2(origin.x + w, origin.y + h), IM_COL32(16, 20, 26, 255)); + + auto const half{maj0sted::domain::kHalfGauge}; + + // the tor zasadniczy, and the opornica the blade lies against. bent, it is an arc like any other, + // so it is walked rather than ruled + auto const through_at{[&](double const Station_) { return maj0sted::domain::geometry::layout_segment(bend, bend, Station_, start, nullptr); }}; + auto const through_line{[&](double const From_, double const To_, double const Offset_) { + auto const steps{bend == 0.0 ? 1 : 96}; + std::vector line; + line.reserve(static_cast(steps) + 1); + for (int i = 0; i <= steps; ++i) + { + auto const pose{through_at(From_ + (To_ - From_) * static_cast(i) / static_cast(steps))}; + line.push_back(to_screen(pose.x - pose.hy * Offset_, pose.y + pose.hx * Offset_)); + } + return line; + }}; + + { + auto const axis{through_line(-1.0, span, 0.0)}; + drawlist->AddPolyline(axis.data(), static_cast(axis.size()), IM_COL32(105, 115, 130, 220), false, 1.0f); + if (m_tpl_rails) + { + for (int side = -1; side <= 1; side += 2) + { + auto const rail{through_line(-1.0, span, half * side)}; + drawlist->AddPolyline(rail.data(), static_cast(rail.size()), IM_COL32(150, 160, 175, 210), false, 1.5f); + } + } + } + + // the diverging path, in the colours of the roles: przediglicowy, iglica, luk, krzyzownicowa. + // everything up to the punkt A is dashed, because none of it is rail the wheel can run on: before + // the ostrze there is no blade at all, and from the ostrze to A the blade is the straight planed + // dziob, not the arc. from A on the blade lies on the arc and the line goes solid + static ImU32 const part_colour[]{IM_COL32(150, 175, 210, 255), IM_COL32(120, 235, 255, 255), IM_COL32(240, 240, 240, 255), IM_COL32(255, 200, 110, 255)}; + auto const hand{laid.frog.y >= 0.0 ? 1 : -1}; + auto const rail_line{[&](std::vector const &Points_, int const Side_) { + std::vector rail; + rail.reserve(Points_.size()); + for (std::size_t i = 0; i < Points_.size(); ++i) + { + auto const &back{Points_[i == 0 ? 0 : i - 1]}; + auto const &ahead{Points_[i == 0 ? 1 : i]}; + auto const dx{ahead.x - back.x}; + auto const dy{ahead.y - back.y}; + auto const len{std::hypot(dx, dy)}; + if (len < 1e-12) + { + continue; + } + rail.push_back(to_screen(Points_[i].x - dy / len * half * Side_, Points_[i].y + dx / len * half * Side_)); + } + return rail; + }}; + + // the sampling step is metres and the dziob is centimetres, so the two stretches have to part on + // the exact distance rather than on whichever sample happens to fall nearest it + auto const cut_at{[](std::vector const &Points_, double const Distance_, + std::vector &Head_, std::vector &Tail_) { + auto travelled{0.0}; + for (std::size_t i = 0; i < Points_.size(); ++i) + { + auto const step{i == 0 ? 0.0 : std::hypot(Points_[i].x - Points_[i - 1].x, Points_[i].y - Points_[i - 1].y)}; + travelled += step; + if (travelled <= Distance_) + { + Head_.push_back(Points_[i]); + continue; + } + if (Tail_.empty() && false == Head_.empty() && step > 1e-12) + { + auto const &back{Points_[i - 1]}; + auto const share{(Distance_ - (travelled - step)) / step}; + maj0sted::domain::geometry::XY const meeting{back.x + (Points_[i].x - back.x) * share, back.y + (Points_[i].y - back.y) * share}; + Head_.push_back(meeting); + Tail_.push_back(meeting); + } + Tail_.push_back(Points_[i]); + } + }}; + + auto const draw_run{[&](std::vector const &Points_, ImU32 const Colour_, bool const Theoretical_) { + if (Points_.size() < 2) + { + return; + } + std::vector line; + line.reserve(Points_.size()); + for (auto const &point : Points_) + { + line.push_back(to_screen(point.x, point.y)); + } + if (Theoretical_) + { + dashed_polyline(drawlist, line, (Colour_ & 0x00ffffffu) | 0xb0000000u, 1.6f); + } + else + { + drawlist->AddPolyline(line.data(), static_cast(line.size()), Colour_, false, 2.5f); + } + if (false == m_tpl_rails) + { + return; + } + for (int side = -1; side <= 1; side += 2) + { + auto const rail{rail_line(Points_, side)}; + if (rail.size() < 2) + { + continue; + } + auto const shade{(Colour_ & 0x00ffffffu) | 0x88000000u}; + if (Theoretical_) + { + dashed_polyline(drawlist, rail, shade, 1.0f, 5.0f, 4.0f); + } + else + { + drawlist->AddPolyline(rail.data(), static_cast(rail.size()), shade, false, 1.2f); + } + } + }}; + + auto walker{start}; + auto station{0.0}; + auto heel{0.0}; + for (auto const &segment : laid.path) + { + if (segment.turn_in != 0.0) + { + auto const c{std::cos(segment.turn_in)}; + auto const s{std::sin(segment.turn_in)}; + walker = {walker.x, walker.y, walker.hx * c - walker.hy * s, walker.hx * s + walker.hy * c}; + } + std::vector points; + walker = maj0sted::domain::geometry::layout_segment(segment.k0, segment.k1, segment.length, walker, &points); + auto const from{station}; + station += segment.length; + if (segment.part == maj0sted::domain::TurnoutPart::Blade) + { + heel = station; + } + if (points.size() < 2) + { + continue; + } + auto const colour{part_colour[std::clamp(static_cast(segment.part), 0, 3)]}; + + // nothing is real before A; a straight blade is its own true shape, so it stays whole + auto const bent{segment.k0 != 0.0 || segment.k1 != 0.0}; + auto const upto{laid.has_blade && bent ? std::clamp(laid.nose_end - from, 0.0, segment.length) : 0.0}; + if (upto <= 0.0) + { + draw_run(points, colour, false); + continue; + } + std::vector ahead; + std::vector behind; + cut_at(points, upto, behind, ahead); + draw_run(behind, colour, true); + draw_run(ahead, colour, false); + } + + // the dziob as it is really cut: one straight planed rail lying on the opornica at the ostrze - the + // gap there is nothing at all - and opening to u at the punkt A, where it meets the arc. the blade + // is the rail on the far side from the way the turnout leads, so it peels off the stock rail the + // diverging track leaves behind + if (laid.has_blade && laid.nose_end > laid.blade_station) + { + auto const blade_side{-hand}; + auto const blade_colour{part_colour[1]}; + auto const foot_pose{through_at(laid.blade_station)}; + auto const tip{to_screen(foot_pose.x - foot_pose.hy * half * blade_side, foot_pose.y + foot_pose.hx * half * blade_side)}; + auto const nose{to_screen(laid.blade_nose_end.x - laid.blade_nose_end.hy * half * blade_side, laid.blade_nose_end.y + laid.blade_nose_end.hx * half * blade_side)}; + if (m_tpl_rails) + { + drawlist->AddLine(tip, nose, (blade_colour & 0x00ffffffu) | 0xee000000u, 2.4f); + } + + // the same straight said on the axis: half a gauge in from the face, so it leaves the through + // axis at nothing and meets the arc where the face meets the opornica at u + drawlist->AddLine(to_screen(foot_pose.x, foot_pose.y), to_screen(laid.blade_nose_end.x, laid.blade_nose_end.y), blade_colour, 2.5f); + if (std::hypot(nose.x - tip.x, nose.y - tip.y) >= 26.0f) + { + char label[64]; + std::snprintf(label, sizeof(label), "dziob prosty d %.4f m, od stycznosci do u", laid.nose_end - laid.blade_station); + drawlist->AddText(ImVec2((tip.x + nose.x) * 0.5f - ImGui::CalcTextSize(label).x * 0.5f, std::min(tip.y, nose.y) - 20.0f), blade_colour, label); + } + + // the other blade of the pair: the straight one, which carries the through route. its running + // edge is the tor zasadniczy's own rail, so it is drawn over it; what is planed is its back, + // from the ostrze lying on the opornica lukowa down to the rail at A + auto const straight{through_line(laid.blade_station, heel, half * hand)}; + drawlist->AddPolyline(straight.data(), static_cast(straight.size()), (blade_colour & 0x00ffffffu) | 0xdd000000u, false, 2.4f); + auto const foot{straight.front()}; + auto const shoulder{straight.back()}; + auto const nose_pose{through_at(laid.nose_end)}; + drawlist->AddLine(to_screen(laid.blade_tip.x - laid.blade_tip.hy * half * hand, laid.blade_tip.y + laid.blade_tip.hx * half * hand), + to_screen(nose_pose.x - nose_pose.hy * half * hand, nose_pose.y + nose_pose.hx * half * hand), + (blade_colour & 0x00ffffffu) | 0xdd000000u, 2.0f); + if (shoulder.x - foot.x >= 60.0f) + { + char label[64]; + std::snprintf(label, sizeof(label), "iglica prosta - jazda na wprost"); + drawlist->AddText(ImVec2((foot.x + shoulder.x) * 0.5f - ImGui::CalcTextSize(label).x * 0.5f, foot.y - (hand > 0 ? 20.0f : -8.0f)), blade_colour, label); + } + } + + // the construction points, from the very list the solver reports for a placement + if (m_tpl_marks) + { + auto marks{maj0sted::editor::turnout_marks(laid, start)}; + std::sort(marks.begin(), marks.end(), [](auto const &Left_, auto const &Right_) { return Left_.station < Right_.station; }); + + auto level{0}; + for (auto const &mark : marks) + { + if (mark.kind < 0 || mark.kind >= static_cast(sizeof(mark_names) / sizeof(mark_names[0]))) + { + continue; + } + auto const at{to_screen(mark.x, mark.y)}; + auto const blade{mark.kind == 1 || mark.kind == 7 || mark.kind == 8}; + auto const primary{mark.kind == 0 || mark.kind == 5 || mark.kind == 9}; + auto const colour{primary ? IM_COL32(255, 210, 90, 245) : (blade ? IM_COL32(120, 235, 255, 245) : IM_COL32(255, 150, 210, 220))}; + + // a tick across the track, then the name on a leader fanned out above it + drawlist->AddLine(ImVec2(at.x, at.y - 11.0f), ImVec2(at.x, at.y + 11.0f), colour, 1.5f); + + auto const reach{34.0f + 16.0f * static_cast(level % 5)}; + ImVec2 const anchor(at.x, origin.y + h * 0.58f - reach - 40.0f); + char label[96]; + if (mark.station > 0.0) + { + std::snprintf(label, sizeof(label), "%s %.4f", mark_names[mark.kind], mark.station); + } + else + { + std::snprintf(label, sizeof(label), "%s", mark_names[mark.kind]); + } + auto const size{ImGui::CalcTextSize(label)}; + ImVec2 const corner(anchor.x - size.x * 0.5f, anchor.y - size.y * 0.5f); + drawlist->AddLine(ImVec2(at.x, at.y - 11.0f), ImVec2(anchor.x, corner.y + size.y + 2.0f), IM_COL32(190, 190, 190, 110), 1.0f); + drawlist->AddRectFilled(ImVec2(corner.x - 3.0f, corner.y - 2.0f), ImVec2(corner.x + size.x + 3.0f, corner.y + size.y + 2.0f), + IM_COL32(12, 16, 24, 215), 3.0f); + drawlist->AddText(corner, colour, label); + ++level; + } + } + + // what the two line styles mean, since the difference between them is the whole point of the drawing + { + auto const left{origin.x + 14.0f}; + auto top{origin.y + 12.0f}; + auto const step{ImGui::GetTextLineHeight() + 3.0f}; + std::vector const sample{ImVec2(left, top + step * 0.5f), ImVec2(left + 34.0f, top + step * 0.5f)}; + dashed_polyline(drawlist, sample, IM_COL32(200, 210, 230, 200), 1.6f); + drawlist->AddText(ImVec2(left + 42.0f, top), IM_COL32(200, 210, 230, 220), "teoretyczny luk od PR do punktu A: nie ma tam szyny"); + top += step; + drawlist->AddLine(ImVec2(left, top + step * 0.5f), ImVec2(left + 34.0f, top + step * 0.5f), IM_COL32(120, 235, 255, 255), 2.5f); + drawlist->AddText(ImVec2(left + 42.0f, top), IM_COL32(120, 235, 255, 235), "dziob iglicy: prosty, od stycznosci z opornica do grubosci u"); + } + + // a metre bar, because with the view stretched across the track no shape can be trusted by eye + { + auto const decades{std::pow(10.0, std::floor(std::log10(std::max(span * 0.2, 1e-6))))}; + auto bar{decades}; + while (bar * sx > w * 0.3) + { + bar *= 0.5; + } + auto const left{origin.x + 14.0f}; + auto const base{origin.y + h - 16.0f}; + auto const right{left + static_cast(bar * sx)}; + drawlist->AddLine(ImVec2(left, base), ImVec2(right, base), IM_COL32(220, 220, 220, 220), 1.5f); + drawlist->AddLine(ImVec2(left, base - 5.0f), ImVec2(left, base + 5.0f), IM_COL32(220, 220, 220, 220), 1.5f); + drawlist->AddLine(ImVec2(right, base - 5.0f), ImVec2(right, base + 5.0f), IM_COL32(220, 220, 220, 220), 1.5f); + char scale_label[128]; + std::snprintf(scale_label, sizeof(scale_label), "%g m wzdluz poprzecznie x%.0f", bar, m_tpl_exagg); + drawlist->AddText(ImVec2(right + 8.0f, base - ImGui::GetTextLineHeight() * 0.5f), IM_COL32(220, 220, 220, 220), scale_label); + } + + drawlist->PopClipRect(); + ImGui::End(); +} + +void plan_panel::render_turnouts() +{ + if (false == ImGui::CollapsingHeader("Rozjazdy")) + { + return; + } + + if (m_document.turnout_types.empty()) + { + ImGui::TextDisabled("katalog jest pusty"); + return; + } + + m_turnout_type = std::clamp(m_turnout_type, 0, static_cast(m_document.turnout_types.size()) - 1); + auto &chosen{m_document.turnout_types[static_cast(m_turnout_type)]}; + ImGui::SetNextItemWidth(260.0f); + if (ImGui::BeginCombo("Typ", chosen.name.c_str())) + { + for (std::size_t i = 0; i < m_document.turnout_types.size(); ++i) + { + if (ImGui::Selectable(m_document.turnout_types[i].name.c_str(), static_cast(i) == m_turnout_type)) + { + m_turnout_type = static_cast(i); + } + } + ImGui::EndCombo(); + } + + // a document carries its own copy of the catalogue, so a plan saved before the figure + // changed keeps the numbers it was saved with - and a type laid before the iglica was a + // piece of its own has no blade in it at all. this puts the current figure back + if (auto const *preset{find_preset(chosen.name)}; preset != nullptr) + { + if (ImGui::SmallButton("przywroc z katalogu")) + { + adopt_preset(*preset, chosen); + m_sel_piece = -1; + m_status = "typ \"" + chosen.name + "\" wziety z katalogu na nowo"; + solve(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("wpisuje na nowo liczby z wbudowanego katalogu: odcinek przediglicowy, iglice, luk, prosta krzyzownicowa oraz u/d/w iglicy"); + } + ImGui::SameLine(); + } + if (ImGui::SmallButton(m_show_template ? "zamknij rysunek" : "rysunek szablonu")) + { + m_show_template = !m_show_template; + } + + render_turnout_type(chosen); + + if (ImGui::Button(m_pick_turnout == 1 ? "kliknij tor..." : "Postaw rozjazd")) + { + m_pick_turnout = m_pick_turnout == 1 ? 0 : 1; + m_status = m_pick_turnout == 1 ? "kliknij tor, na ktorym ma stanac rozjazd" : ""; + } + + ImGui::Separator(); + for (std::size_t i = 0; i < m_document.turnouts.size(); ++i) + { + auto &placement{m_document.turnouts[i]}; + ImGui::PushID(static_cast(1000 + i)); + + auto const *solved{maj0sted::editor::find_turnout(m_solution, placement.id)}; + auto const *on{maj0sted::editor::find_track(m_document, placement.on)}; + char label[200]; + std::snprintf(label, sizeof(label), "%s na %s, km %.1f%s", placement.type.c_str(), on != nullptr ? on->name.c_str() : "?", placement.station, + (solved != nullptr && solved->valid) ? "" : " (nie da sie zlozyc)"); + if (ImGui::Selectable(label, placement.id == m_sel_turnout)) + { + m_sel_turnout = placement.id; + } + + if (placement.id == m_sel_turnout) + { + ImGui::Indent(); + auto dirty{false}; + ImGui::SetNextItemWidth(130.0f); + if (ImGui::InputDouble("km", &placement.station, 1.0, 10.0, "%.2f")) + { + dirty = true; + } + int hand{placement.hand >= 0 ? 0 : 1}; + ImGui::SetNextItemWidth(130.0f); + if (ImGui::Combo("odgalezia", &hand, "w lewo\0w prawo\0")) + { + placement.hand = hand == 0 ? 1 : -1; + dirty = true; + } + if (ImGui::Checkbox("zwrotny w kierunku km", &placement.facing)) + { + dirty = true; + } + // lukowanie: standing on a curve is the ordinary way to get a rozjazd lukowy, and the + // solver takes the radius from under the PR. asking for one by hand is for a rozjazd + // bent on a track drawn straight + if (ImGui::Checkbox("wygiecie z toru pod rozjazdem", &placement.bend_from_track)) + { + dirty = true; + } + if (false == placement.bend_from_track) + { + auto radius{placement.bend != 0.0 ? std::abs(1.0 / placement.bend) : 0.0}; + int way{placement.bend >= 0.0 ? 0 : 1}; + ImGui::SetNextItemWidth(130.0f); + auto touched{ImGui::InputDouble("R zasadniczego", &radius, 10.0, 100.0, "%.3f")}; + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + touched = ImGui::Combo("wygiety", &way, "w lewo\0w prawo\0") || touched; + if (touched) + { + placement.bend = radius > 1.0 ? (way == 0 ? 1.0 : -1.0) / radius : 0.0; + dirty = true; + } + } + if (solved != nullptr && solved->valid) + { + ImGui::TextDisabled("styczne a %.3f / b %.3f m (razem %.3f), na torze zasadniczym %.3f m", solved->tangent_front, solved->tangent_back, + solved->tangent_front + solved->tangent_back, solved->through_length); + ImGui::TextDisabled("odgalezienie %.3f m", solved->diverging_length); + ImGui::TextDisabled("%s", bend_text(solved->bend, solved->diverging_curvature, solved->bend_angle).c_str()); + if (auto const *limit{bend_limit(solved->diverging_curvature)}; limit != nullptr) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "%s", limit); + } + if (solved->has_blade) + { + ImGui::TextDisabled("iglica: ostrze a %.4f m, dziob do %.4f m, struganie %.3f m", solved->blade_station, solved->nose_end, solved->planing_length); + } + } + // a rozjazd left standing across a joint, or off the end, needs a way back that does not + // mean guessing kilometres by hand: the nearest element long enough to hold it, whole + if (solved != nullptr && solved->valid) + { + auto const *through{maj0sted::editor::find_track(m_solution, placement.on)}; + double curvature{0.0}; + double behind{0.0}; + double ahead{0.0}; + auto const room{through != nullptr && maj0sted::editor::steady_room(*through, placement.station, curvature, behind, ahead) + ? (placement.facing ? ahead : behind) + : -1.0}; + if (through != nullptr && room < solved->through_length - 1e-6) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "rozjazd nie miesci sie na odcinku toru, na ktorym stoi"); + ImGui::SameLine(); + if (ImGui::SmallButton("wstaw na odcinek")) + { + double station{0.0}; + auto best{std::numeric_limits::max()}; + auto walked{0.0}; + for (auto const &element : through->elements) + { + if (std::abs(element.k1 - element.k0) < 1e-12 && element.length >= solved->through_length) + { + // anywhere on this straight with the whole rozjazd still on it + auto const low{placement.facing ? walked : walked + solved->through_length}; + auto const high{placement.facing ? walked + element.length - solved->through_length : walked + element.length}; + auto const put{std::clamp(placement.station, low, high)}; + if (std::abs(put - placement.station) < best) + { + best = std::abs(put - placement.station); + station = put; + } + } + walked += element.length; + } + if (best < std::numeric_limits::max()) + { + placement.station = station; + dirty = true; + } + else + { + m_status = "zaden odcinek tego toru nie jest dosc dlugi na ten rozjazd"; + } + } + } + } + if (ImGui::SmallButton("usun rozjazd")) + { + m_document.turnouts.erase(m_document.turnouts.begin() + static_cast(i)); + m_sel_turnout = TurnoutId::none; + ImGui::Unindent(); + ImGui::PopID(); + solve(); + return; + } + ImGui::Unindent(); + if (dirty) + { + solve(); + } + } + ImGui::PopID(); + } +} + +void plan_panel::render_diagnostics() +{ + if (m_solution.diagnostics.empty()) + { + return; + } + + // a fit that does not work says why, with the figure that matters in it. it used to come back as + // nullopt and simply disappear from the drawing + char header[64]; + std::snprintf(header, sizeof(header), "Nie wyszlo (%zu)###diag", m_solution.diagnostics.size()); + if (false == ImGui::CollapsingHeader(header, ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + for (std::size_t i = 0; i < m_solution.diagnostics.size(); ++i) + { + auto const &diagnostic{m_solution.diagnostics[i]}; + ImGui::PushID(static_cast(2000 + i)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.60f, 0.55f, 1.0f)); + auto const *track{maj0sted::editor::find_track(m_document, diagnostic.track)}; + if (ImGui::Selectable(diagnostic.text.c_str())) + { + if (diagnostic.track != TrackId::none) + { + m_track = diagnostic.track; + } + if (diagnostic.element != ElementId::none) + { + m_sel_element = diagnostic.element; + } + if (diagnostic.turnout != TurnoutId::none) + { + m_sel_turnout = diagnostic.turnout; + } + } + ImGui::PopStyleColor(); + if (track != nullptr) + { + ImGui::SameLine(); + ImGui::TextDisabled("(%s)", track->name.c_str()); + } + ImGui::PopID(); + } +} + +void plan_panel::render_storage() +{ + ImGui::Separator(); + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##path", m_path, sizeof(m_path)); + ImGui::SameLine(); + if (ImGui::Button("Save")) + { + double planx{0.0}; + double plany{0.0}; + world_to_plan(editor_mode::get_camera().Pos, planx, plany); + m_document.view_x = planx; + m_document.view_y = plany; + m_document.view_extent = static_cast(Global.editor_ortho_extent); + m_document.origin_set = true; + m_document.georeferenced = Global.scenery_georeferenced; + m_document.origin_x = Global.scenery_origin.x; + m_document.origin_y = Global.scenery_origin.y; + m_status = maj0sted::io::save(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path); + } + ImGui::SameLine(); + if (ImGui::Button("Load")) + { + auto const loaded{maj0sted::io::load(m_path)}; + if (loaded) + { + m_document = *loaded; + seed_catalogue(); + if (m_document.tracks.empty()) + { + new_track(); + } + else + { + m_track = m_document.tracks.front().id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.tracks.front().name.c_str()); + } + if (m_document.origin_set) + { + Global.scenery_georeferenced = m_document.georeferenced; + Global.scenery_origin = {m_document.origin_x, m_document.origin_y}; + } + m_document.origin_set = true; + m_document.georeferenced = Global.scenery_georeferenced; + m_document.origin_x = Global.scenery_origin.x; + m_document.origin_y = Global.scenery_origin.y; + m_pending = false; + clear_selection(); + solve(); + if (m_document.view_extent > 0.0) + { + auto &camera{editor_mode::get_camera()}; + auto const centre{plan_to_world(m_document.view_x, m_document.view_y)}; + camera.Pos.x = centre.x; + camera.Pos.z = centre.z; + Global.editor_ortho_extent = std::clamp(static_cast(m_document.view_extent), 5.0f, 20000.0f); + } + else + { + go_to_plan(); + } + m_status = "loaded " + std::string(m_path); + } + else + { + // version 1 files are not read: they described a different model + m_status = "could not read " + std::string(m_path) + " (potrzebny format m0s 2)"; + } + } + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##scnpath", m_scn_path, sizeof(m_scn_path)); + ImGui::SameLine(); + if (ImGui::Button("Export SCN")) + { + export_scn(); + } + if (false == m_status.empty()) + { + ImGui::TextDisabled("%s", m_status.c_str()); + } +} + +void plan_panel::export_scn() +{ + solve(); + maj0sted::io::ScnExportOptions opt; + opt.origin_east = Global.scenery_origin.x; + opt.origin_north = Global.scenery_origin.y; + std::ofstream out(m_scn_path, std::ios::binary | std::ios::trunc); + if (!out) + { + m_status = "could not write " + std::string(m_scn_path); + return; + } + auto const result{maj0sted::io::export_scn(m_solution, opt, out)}; + out.flush(); + m_status = out.good() ? ("exported " + std::to_string(result.tracks) + " tracks") : ("could not write " + std::string(m_scn_path)); +} + +// --------------------------------------------------------------------------- +// map dialogs +// --------------------------------------------------------------------------- + void plan_panel::render_newmap_dialog() { if (false == ImGui::BeginPopupModal("New map", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) @@ -629,12 +2830,9 @@ void plan_panel::render_location_dialog() { // the picture on screen is the one that was fetched, which may lag the current view by a // request; drawing it against its own box keeps it registered while the new one arrives - // the image arrives with its first row at the north edge; the vertical texture coordinates are - // swapped to put that row against the box's northern edge. the orthophoto is handed the same - // swap - both are placed north-west corner first, so they cannot want different conventions auto const &covered{m_topomap.covered()}; - drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y), ImVec2(0.0f, 1.0f), - ImVec2(1.0f, 0.0f)); + drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y), ImVec2(0.0f, 0.0f), + ImVec2(1.0f, 1.0f)); } auto const picked{to_screen(m_pickx, m_picky)}; @@ -671,21 +2869,18 @@ void plan_panel::start_map(bool const Georeferenced, double const Originx, doubl { Global.scenery_georeferenced = Georeferenced; Global.scenery_origin = {Originx, Originy}; - m_document.origin_set = true; - m_document.georeferenced = Georeferenced; - m_document.origin_x = Originx; - m_document.origin_y = Originy; - m_document.niwelety.clear(); - m_document.niwelety.push_back({"niweleta 1", {}, {}}); - m_niweleta = 0; - m_pending = false; - clear_selection(); - m_pick_rel = 0; - m_ask_rel_param = false; - m_sel_gap = -1; - m_pick_compound_point = false; - m_fit_guides.clear(); + Document fresh; + fresh.origin_set = true; + fresh.georeferenced = Georeferenced; + fresh.origin_x = Originx; + fresh.origin_y = Originy; + m_document = std::move(fresh); + seed_catalogue(); + new_track(); + + m_pick_turnout = 0; + m_pick_parallel = 0; solve(); // the scenery's zero is where the work starts, whichever frame it stands for @@ -698,2910 +2893,3 @@ void plan_panel::start_map(bool const Georeferenced, double const Originx, doubl m_status = Georeferenced ? "map pinned at " + to_string(Originx, 0) + ", " + to_string(Originy, 0) + " (EPSG:2180)" : "fictional map"; } - -void plan_panel::handle_scene() -{ - // clicks that land on a panel belong to the panel, not to the ground under it - if (ImGui::GetIO().WantCaptureMouse) - { - m_dragging = false; - m_dragging_cmp = false; - m_dragging_draft = false; - m_drag_junction = -1; - return; - } - - double cursorx{0.0}; - double cursory{0.0}; - // the plan view must not lean on the depth buffer: on empty ground it has nothing to read - world_to_plan(Global.editor_ortho ? ortho_cursor_world() : Global.pCamera.Pos + GfxRenderer->Mouse_Position(), cursorx, cursory); - - auto const mouse{ImGui::GetIO().MousePos}; - - if (ImGui::IsMouseDoubleClicked(0) && m_draft_place == 0 && m_anchor_pick == 0 && m_pick_rel == 0 && false == m_pick_compound_point) - { - int hit_niw{-1}; - int hit_str{-1}; - // straights catch only their trimmed remainders; fall back to the whole solved axis (curves - // included) so a double-click anywhere on the visible track still selects its niweleta - bool hit{hit_straight(mouse, hit_niw, hit_str, true)}; - if (false == hit) - { - hit_niw = nearest_niweleta_axis(mouse, 18.0f); - hit_str = -1; - hit = hit_niw >= 0; - } - if (hit && hit_niw >= 0 && static_cast(hit_niw) < m_document.niwelety.size()) - { - m_dragging = false; - m_dragging_cmp = false; - m_dragging_draft = false; - m_niweleta = static_cast(hit_niw); - m_sel_straight = hit_str; - m_sel_end = -1; - m_pending = false; - m_draft.active = false; - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - m_status = m_document.niwelety[m_niweleta].name; - return; - } - } - - if (ImGui::IsMouseClicked(0)) - { - m_dragging = false; - - if (m_draft_place == 1) - { - int hit_niw{-1}; - int hit_str{-1}; - int hit_end{-1}; - if (hit_endpoint(mouse, hit_niw, hit_str, hit_end)) - { - m_niweleta = static_cast(hit_niw); - start_draft_from_end(static_cast(hit_str), hit_end); - } - else - { - m_status = "kliknij koniec prostej"; - } - return; - } - - if (m_anchor_pick != 0) - { - int hit_niw{-1}; - int hit_str{-1}; - if (hit_straight(mouse, hit_niw, hit_str, false)) - { - auto *niweleta{current_niweleta()}; - if (niweleta != nullptr && niweleta->straights.size() >= 2) - { - auto gap{static_cast(hit_str)}; - if (gap + 1 >= niweleta->straights.size()) - { - gap = niweleta->straights.size() - 2; - } - m_anchor_pick = 0; - anchor_draft_to_gap(gap); - return; - } - } - m_status = "kliknij prostą przy luce"; - return; - } - - if (m_pick_compound_point) - { - m_fit_guides.push_back({cursorx, cursory}); - m_status = std::to_string(m_fit_guides.size()) + " pts"; - return; - } - - // dropping a switch on a through niweleta - if (m_pick_switch != 0) - { - int hit_niw{-1}; - int hit_str{-1}; - if (hit_straight(mouse, hit_niw, hit_str, true) && hit_niw >= 0 && static_cast(hit_niw) < m_solved.size()) - { - add_switch_on(static_cast(hit_niw), cursorx, cursory); - m_pick_switch = 0; - } - else - { - m_status = "kliknij niweletę na wprost"; - } - return; - } - - // picking a reference straight for a parallel/skew constraint - if (m_pick_rel != 0) - { - int hit_niw{-1}; - int hit_str{-1}; - if (hit_straight(mouse, hit_niw, hit_str, true) && m_sel_straight >= 0 && m_niweleta < m_document.niwelety.size() && - static_cast(m_sel_straight) < m_document.niwelety[m_niweleta].straights.size()) - { - auto &dep{m_document.niwelety[m_niweleta].straights[static_cast(m_sel_straight)]}; - if (!(hit_niw == static_cast(m_niweleta) && hit_str == m_sel_straight)) - { - dep.rel_kind = m_pick_rel; - dep.rel_niw = hit_niw; - dep.rel_str = hit_str; - if (m_pick_rel == 1) - { - dep.rel_offset = m_ask_rel_value; - m_status = "parallel → n" + std::to_string(hit_niw + 1) + " s" + std::to_string(hit_str); - } - else - { - auto const dx{dep.x2 - dep.x1}; - auto const dy{dep.y2 - dep.y1}; - dep.rel_length = std::max(1.0, std::hypot(dx, dy)); - dep.rel_cot = std::abs(m_ask_rel_value); - dep.rel_side = m_ask_rel_value < 0.0 ? -1 : 1; - m_status = "skew 1:" + to_string(dep.rel_cot, 0) + " → n" + std::to_string(hit_niw + 1) + " s" + std::to_string(hit_str); - } - solve(); - } - } - m_pick_rel = 0; - return; - } - - int draft_kind{-1}; - int draft_arc{-1}; - if (m_draft.active && hit_draft_station(mouse, draft_kind, draft_arc)) - { - clear_selection(); - m_dragging_draft = true; - m_draft_kind = draft_kind; - m_draft_arc = draft_arc; - m_drawing = false; - m_status = draft_kind == 0 ? "KP wejście" - : draft_kind == 1 ? "L łuku" - : draft_kind == 2 ? "KP między" - : "KP wyjście"; - return; - } - - int cmp_gap{-1}; - int cmp_kind{-1}; - int cmp_arc{-1}; - if (hit_compound_station(mouse, cmp_gap, cmp_kind, cmp_arc)) - { - clear_selection(); - m_dragging_cmp = true; - m_cmp_gap = cmp_gap; - m_cmp_kind = cmp_kind; - m_cmp_arc = cmp_arc; - m_sel_gap = cmp_gap; - m_drawing = false; - m_status = cmp_kind == 0 ? "KP wejście" - : cmp_kind == 1 ? "L łuku" - : cmp_kind == 2 ? "KP między" - : "KP wyjście"; - return; - } - - // grabbing a switch's start to slide it along the through track - for (std::size_t j = 0; j < m_junctions.size(); ++j) - { - if (false == m_junctions[j].valid) - { - continue; - } - ImVec2 sw; - if (world_to_screen(plan_to_world(m_junctions[j].px, m_junctions[j].py), sw) && std::hypot(sw.x - mouse.x, sw.y - mouse.y) <= 9.0f) - { - clear_selection(); - m_dragging_cmp = false; - m_dragging_draft = false; - m_drag_junction = static_cast(j); - m_drawing = false; - m_status = "przesuwanie rozjazdu"; - return; - } - } - - int hit_niw{-1}; - int hit_str{-1}; - int hit_end{-1}; - if (hit_endpoint(mouse, hit_niw, hit_str, hit_end)) - { - m_dragging_cmp = false; - m_dragging_draft = false; - m_sel_straight = hit_str; - m_sel_end = hit_end; - m_dragging = true; - auto *niweleta{current_niweleta()}; - if (niweleta != nullptr && static_cast(hit_str) < niweleta->straights.size()) - { - auto const &raw{niweleta->straights[static_cast(hit_str)]}; - m_drag_fixed_x = hit_end == 0 ? raw.x2 : raw.x1; - m_drag_fixed_y = hit_end == 0 ? raw.y2 : raw.y1; - auto const gx{hit_end == 0 ? raw.x1 : raw.x2}; - auto const gy{hit_end == 0 ? raw.y1 : raw.y2}; - auto const dl{std::max(1e-9, std::hypot(gx - m_drag_fixed_x, gy - m_drag_fixed_y))}; - m_drag_ux = (gx - m_drag_fixed_x) / dl; - m_drag_uy = (gy - m_drag_fixed_y) / dl; - } - } - else if (hit_straight(mouse, hit_niw, hit_str, false)) - { - m_dragging_cmp = false; - m_dragging_draft = false; - m_sel_straight = hit_str; - m_sel_end = -1; - } - else if (m_drawing && false == m_draft.active && m_draft_place == 0 && m_anchor_pick == 0) - { - clear_selection(); - append_vertex(cursorx, cursory); - } - else - { - clear_selection(); - } - } - - if (m_drag_junction >= 0) - { - if (ImGui::IsMouseDown(0) && static_cast(m_drag_junction) < m_document.junctions.size()) - { - auto &junction{m_document.junctions[static_cast(m_drag_junction)]}; - double station{junction.station}; - // slide along the through track: the cursor's nearest station on its centreline - if (junction.through >= 0 && station_on_through(static_cast(junction.through), cursorx, cursory, station)) - { - junction.station = station; - solve(); - } - } - else - { - m_drag_junction = -1; - } - } - else if (m_dragging_draft) - { - if (ImGui::IsMouseDown(0)) - { - drag_draft_station(cursorx, cursory); - } - else - { - m_dragging_draft = false; - } - } - else if (m_dragging_cmp) - { - if (ImGui::IsMouseDown(0)) - { - drag_compound_station(cursorx, cursory); - } - else - { - m_dragging_cmp = false; - } - } - else if (m_dragging && m_sel_straight >= 0 && m_sel_end >= 0) - { - if (ImGui::IsMouseDown(0)) - { - auto *niweleta{current_niweleta()}; - if (niweleta != nullptr && static_cast(m_sel_straight) < niweleta->straights.size()) - { - auto &st{niweleta->straights[static_cast(m_sel_straight)]}; - double x{cursorx}; - double y{cursory}; - // Shift: slide along the straight's axis — length changes, angle stays - if (ImGui::GetIO().KeyShift) - { - auto const t{std::max(1.0, (x - m_drag_fixed_x) * m_drag_ux + (y - m_drag_fixed_y) * m_drag_uy)}; - x = m_drag_fixed_x + m_drag_ux * t; - y = m_drag_fixed_y + m_drag_uy * t; - } - if (m_sel_end == 0) - { - st.x1 = x; - st.y1 = y; - } - else - { - st.x2 = x; - st.y2 = y; - } - if (st.rel_kind == 2) - { - st.rel_length = std::max(1.0, std::hypot(st.x2 - st.x1, st.y2 - st.y1)); - } - solve(); - } - } - else - { - m_dragging = false; - } - } -} - -void plan_panel::draw_on_scene() -{ - // the background list paints over the rendered scenery but under every window, so the controls - // stay readable on top of the drawing - auto *drawlist{ImGui::GetBackgroundDrawList()}; - - // orthophoto under everything else. only a georeferenced map knows where on the ground it sits, - // so a fictional one gets no imagery - if (m_showortho && Global.scenery_georeferenced) - { - double planx{0.0}; - double plany{0.0}; - world_to_plan(Global.pCamera.Pos, planx, plany); - - // yaw is pinned north-up, so the screen is an axis-aligned box in plan space - auto const display{ImGui::GetIO().DisplaySize}; - auto const halfheight{static_cast(Global.editor_ortho_extent)}; - auto const halfwidth{halfheight * std::max(1.0f, display.x) / std::max(1.0f, display.y)}; - maj0sted::editor::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; - - auto const draw_box = [&](maj0sted::editor::TileBBox const &Box, unsigned int const Texture) { - ImVec2 corners[4]; - auto const ok = world_to_screen(plan_to_world(Box.min_x, Box.max_y), corners[0]) && world_to_screen(plan_to_world(Box.max_x, Box.max_y), corners[1]) && - world_to_screen(plan_to_world(Box.max_x, Box.min_y), corners[2]) && world_to_screen(plan_to_world(Box.min_x, Box.min_y), corners[3]); - if (false == ok) - { - return; - } - // north-west corner first; the texture's first row is its northern edge - drawlist->AddImageQuad(reinterpret_cast(static_cast(Texture)), corners[0], corners[1], corners[2], corners[3], ImVec2(0.0f, 1.0f), ImVec2(1.0f, 1.0f), - ImVec2(1.0f, 0.0f), ImVec2(0.0f, 0.0f)); - }; - - // once the view is wider than a kilometre the 100 m orto cells are the wrong scale: - // switch to a 1 km topo grid that can cover the whole screen - constexpr double kTopoPreviewMetres = 1000.0; - auto const span{std::max(halfwidth, halfheight) * 2.0}; - if (span > kTopoPreviewMetres) - { - for (auto const &tile : m_topo.collect(view, 80)) - { - draw_box(tile.box, tile.texture); - } - } - else - { - for (auto const &tile : m_ortho.collect(view)) - { - draw_box(tile.box, tile.texture); - } - } - } - - std::vector points; - for (std::size_t n = 0; n < m_solved.size(); ++n) - { - auto const active{n == m_niweleta}; - for (auto const &polyline : m_solved[n].polylines) - { - if (polyline.points.size() < 2) - { - continue; - } - points.clear(); - points.reserve(polyline.points.size()); - for (auto const &point : polyline.points) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) - { - continue; - } - points.push_back(screen); - } - if (points.size() < 2) - { - continue; - } - drawlist->AddPolyline(points.data(), static_cast(points.size()), element_colour(polyline.kind, active), false, 2.5f); - } - } - - // switches: the diverging curve in its own colour, with a marker at the switch point and the frog - for (auto const &junction : m_junctions) - { - if (false == junction.valid) - { - continue; - } - for (auto const &polyline : junction.polylines) - { - if (polyline.points.size() < 2) - { - continue; - } - points.clear(); - for (auto const &point : polyline.points) - { - ImVec2 screen; - if (world_to_screen(plan_to_world(point.x, point.y), screen)) - { - points.push_back(screen); - } - } - if (points.size() >= 2) - { - drawlist->AddPolyline(points.data(), static_cast(points.size()), IM_COL32(255, 150, 40, 255), false, 2.5f); - } - } - ImVec2 marker; - if (world_to_screen(plan_to_world(junction.px, junction.py), marker)) - { - drawlist->AddCircleFilled(marker, 4.0f, IM_COL32(255, 210, 90, 255)); - } - if (world_to_screen(plan_to_world(junction.fx, junction.fy), marker)) - { - drawlist->AddCircleFilled(marker, 4.0f, IM_COL32(255, 150, 40, 255)); - } - } - - // arc / KP annotations: R= and KP labels, plus station ticks at each circular-arc end - // (and a stronger tick where consecutive arcs of a compound meet with different R) - if (m_niweleta < m_solved.size()) - { - auto const &solved{m_solved[m_niweleta]}; - std::set gaps; - for (auto const &poly : solved.polylines) - { - if (poly.gap >= 0) - { - gaps.insert(poly.gap); - } - } - for (auto const gap : gaps) - { - std::map by_el; - for (auto const &poly : solved.polylines) - { - if (poly.gap != gap || poly.points.size() < 2 || poly.element_index < 0) - { - continue; - } - if (by_el.find(poly.element_index) == by_el.end()) - { - by_el.emplace(poly.element_index, &poly); - } - } - - std::vector arcs; - for (auto const &[idx, poly] : by_el) - { - (void)idx; - std::vector screen; - screen.reserve(poly->points.size()); - for (auto const &point : poly->points) - { - ImVec2 s; - if (world_to_screen(plan_to_world(point.x, point.y), s)) - { - screen.push_back(s); - } - } - if (poly->kind == 1) - { - arcs.push_back(poly); - auto const r{poly->radius_start > 0.0 ? poly->radius_start : poly->radius_end}; - if (r > 0.0) - { - char buf[48]; - std::snprintf(buf, sizeof(buf), "R = %.1f m", r); - draw_curve_callout(drawlist, screen, buf, IM_COL32(255, 209, 102, 255)); - } - // ends of this circular arc - draw_arc_end_tick(drawlist, screen, true, IM_COL32(255, 100, 100, 230)); - draw_arc_end_tick(drawlist, screen, false, IM_COL32(255, 100, 100, 230)); - } - else if (poly->kind == 2 && poly->length > 0.0) - { - char buf[48]; - std::snprintf(buf, sizeof(buf), "KP L = %.1f m", poly->length); - draw_curve_callout(drawlist, screen, buf, IM_COL32(140, 245, 150, 255)); - } - } - - // compound: thicker tick where two consecutive arcs meet (radius break) - for (std::size_t i = 0; i + 1 < arcs.size(); ++i) - { - auto const r1{arcs[i]->radius_start > 0.0 ? arcs[i]->radius_start : arcs[i]->radius_end}; - auto const r2{arcs[i + 1]->radius_start > 0.0 ? arcs[i + 1]->radius_start : arcs[i + 1]->radius_end}; - if (std::abs(r1 - r2) < 1e-6) - { - continue; - } - auto const &a{arcs[i]->points.back()}; - auto const &b{arcs[i + 1]->points.front()}; - ImVec2 sa; - ImVec2 sb; - if (world_to_screen(plan_to_world(a.x, a.y), sa) && world_to_screen(plan_to_world(b.x, b.y), sb)) - { - draw_station_tick(drawlist, sa, sb, IM_COL32(255, 80, 80, 255), 10.0f); - } - } - } - } - - // independent endpoints of the active niweleta (prefer solved/trimmed positions) - if (auto const *niweleta{current_niweleta()}; niweleta != nullptr) - { - for (std::size_t i = 0; i < niweleta->straights.size(); ++i) - { - auto const *st{handle_straight(m_niweleta, i)}; - if (st == nullptr || st->hidden) - { - continue; - } - auto const selected{static_cast(i) == m_sel_straight}; - for (int end = 0; end < 2; ++end) - { - auto const x{end == 0 ? st->x1 : st->x2}; - auto const y{end == 0 ? st->y1 : st->y2}; - ImVec2 screen; - if (false == world_to_screen(plan_to_world(x, y), screen)) - { - continue; - } - auto const hot{selected && end == m_sel_end}; - drawlist->AddCircleFilled(screen, hot ? 6.0f : 5.0f, hot ? IM_COL32(255, 240, 120, 255) : selected ? IM_COL32(255, 220, 140, 220) : IM_COL32(240, 240, 240, 255)); - } - if (selected) - { - ImVec2 a; - ImVec2 b; - if (world_to_screen(plan_to_world(st->x1, st->y1), a) && world_to_screen(plan_to_world(st->x2, st->y2), b)) - { - drawlist->AddLine(a, b, IM_COL32(255, 240, 120, 200), 3.0f); - } - } - } - } - - // the corner still waiting for the one that closes its straight, and the rubber band to the cursor - auto const mouse{ImGui::GetIO().MousePos}; - auto const overscenery{false == ImGui::GetIO().WantCaptureMouse}; - if (m_draft_place == 1 && overscenery) - { - drawlist->AddCircleFilled(mouse, 5.0f, IM_COL32(255, 180, 90, 255)); - } - else if (m_pending) - { - ImVec2 screen; - if (world_to_screen(plan_to_world(m_pendingx, m_pendingy), screen)) - { - drawlist->AddCircleFilled(screen, 5.0f, IM_COL32(255, 240, 120, 255)); - if (overscenery) - { - drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); - } - } - } - else if (overscenery && m_drawing && false == m_dragging && m_draft_place == 0 && m_anchor_pick == 0) - { - if (auto const *niweleta{current_niweleta()}; niweleta != nullptr && false == niweleta->straights.empty()) - { - auto const &last{niweleta->straights.back()}; - ImVec2 screen; - if (world_to_screen(plan_to_world(last.x2, last.y2), screen)) - { - drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); - } - } - } - - // basket draft: dual rails + same station handles as compound - if (m_draft.active) - { - std::vector pts; - for (auto const &polyline : rails_from_centreline(m_draft_polys)) - { - if (polyline.points.size() < 2) - { - continue; - } - pts.clear(); - pts.reserve(polyline.points.size()); - for (auto const &point : polyline.points) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) - { - continue; - } - pts.push_back(screen); - } - if (pts.size() < 2) - { - continue; - } - drawlist->AddPolyline(pts.data(), static_cast(pts.size()), element_colour(polyline.kind, true), false, 2.5f); - } - for (auto const &h : draft_station_handles(m_draft, m_draft_polys)) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) - { - continue; - } - auto const hot{m_dragging_draft && h.kind == m_draft_kind && h.arc == m_draft_arc}; - auto const kp{h.kind == 0 || h.kind == 2 || h.kind == 3}; - auto const fill{kp ? (hot ? IM_COL32(120, 255, 160, 255) : IM_COL32(90, 230, 140, 230)) - : (hot ? IM_COL32(255, 200, 90, 255) : IM_COL32(255, 160, 70, 230))}; - drawlist->AddCircleFilled(screen, hot ? 6.5f : 5.0f, fill); - drawlist->AddCircle(screen, hot ? 6.5f : 5.0f, IM_COL32(245, 245, 245, 255)); - } - } - - // draggable compound stations (KP / arc ends) on the active niweleta - if (auto const *niweleta{current_niweleta()}; niweleta != nullptr && m_niweleta < m_solved.size()) - { - for (auto const &fit : niweleta->fits) - { - if (fit.mode != 3 || fit.arcs.empty()) - { - continue; - } - for (auto const &h : compound_station_handles(fit, m_solved[m_niweleta], fit.gap)) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) - { - continue; - } - auto const hot{m_dragging_cmp && h.gap == m_cmp_gap && h.kind == m_cmp_kind && h.arc == m_cmp_arc}; - auto const kp{h.kind == 0 || h.kind == 2 || h.kind == 3}; - auto const fill{kp ? (hot ? IM_COL32(120, 255, 160, 255) : IM_COL32(90, 230, 140, 230)) - : (hot ? IM_COL32(255, 200, 90, 255) : IM_COL32(255, 160, 70, 230))}; - drawlist->AddCircleFilled(screen, hot ? 6.5f : 5.0f, fill); - drawlist->AddCircle(screen, hot ? 6.5f : 5.0f, IM_COL32(245, 245, 245, 255)); - } - } - } - - // guide points for multi-point compound fit - for (std::size_t i = 0; i < m_fit_guides.size(); ++i) - { - auto const &g{m_fit_guides[i]}; - ImVec2 screen; - if (false == world_to_screen(plan_to_world(g.x, g.y), screen)) - { - continue; - } - drawlist->AddCircleFilled(screen, 4.5f, IM_COL32(80, 220, 255, 240)); - drawlist->AddCircle(screen, 4.5f, IM_COL32(230, 245, 255, 255)); - char buf[16]; - std::snprintf(buf, sizeof(buf), "%zu", i + 1); - drawlist->AddText({screen.x + 6.0f, screen.y - 8.0f}, IM_COL32(160, 230, 255, 255), buf); - } - if (m_pick_compound_point && overscenery && false == m_fit_guides.empty()) - { - ImVec2 prev; - if (world_to_screen(plan_to_world(m_fit_guides.back().x, m_fit_guides.back().y), prev)) - { - drawlist->AddLine(prev, mouse, IM_COL32(80, 220, 255, 140), 1.2f); - } - } -} - -void plan_panel::render_draft() -{ - ImGui::Separator(); - if (m_draft_place == 1) - { - ImGui::Text("Kosz: kliknij koniec prostej"); - ImGui::SameLine(); - if (ImGui::SmallButton("Anuluj##draftplace")) - { - m_draft_place = 0; - m_status.clear(); - } - return; - } - - if (false == m_draft.active) - { - if (ImGui::Button("Kosz")) - { - begin_draft(); - } - return; - } - - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Kosz"); - ImGui::SameLine(); - bool const left{m_draft.dir >= 0}; - if (ImGui::RadioButton("Left##ddir", left)) - { - m_draft.dir = 1; - rebuild_draft_preview(); - } - ImGui::SameLine(); - if (ImGui::RadioButton("Right##ddir", false == left)) - { - m_draft.dir = -1; - rebuild_draft_preview(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Anuluj##draft")) - { - discard_draft(); - m_status.clear(); - } - - ImGui::Indent(); - ImGui::SetNextItemWidth(100.0f); - if (ImGui::InputDouble("wejscie##dent", &m_draft.entry_t, 5.0, 20.0, "%.1f")) - { - rebuild_draft_preview(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(100.0f); - if (ImGui::InputDouble("wyjscie##dex", &m_draft.exit_t, 5.0, 20.0, "%.1f")) - { - rebuild_draft_preview(); - } - - ImGui::Spacing(); - double station{m_draft.entry_t}; - for (std::size_t i = 0; i < m_draft.arcs.size(); ++i) - { - auto &arc{m_draft.arcs[i]}; - ImGui::PushID(static_cast(i) + 9000); - ImGui::AlignTextToFramePadding(); - ImGui::Text("Luk %d", static_cast(i + 1)); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("R##dar", &arc.radius, 10.0, 100.0, "%.1f")) - { - rebuild_draft_preview(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("L##dal", &arc.length, 5.0, 20.0, "%.1f")) - { - rebuild_draft_preview(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("KP##dat", &arc.transition_to_next, 5.0, 20.0, "%.1f")) - { - rebuild_draft_preview(); - } - station += std::max(0.0, arc.length) + std::max(0.0, arc.transition_to_next); - ImGui::TextDisabled("~%.0f m", station); - ImGui::PopID(); - } - - if (ImGui::SmallButton("Add arc##draft")) - { - auto const radius{m_draft.arcs.empty() ? 300.0 : std::max(30.0, m_draft.arcs.back().radius * 0.75)}; - if (false == m_draft.arcs.empty() && m_draft.arcs.back().transition_to_next < 1e-6) - { - m_draft.arcs.back().transition_to_next = 40.0; - } - m_draft.arcs.push_back({radius, 60.0, 0.0}); - m_draft_r = radius; - m_draft.preview_next_r = radius; - rebuild_draft_preview(); - } - if (m_draft.arcs.size() > 1) - { - ImGui::SameLine(); - if (ImGui::SmallButton("Remove arc##draft")) - { - m_draft.arcs.pop_back(); - if (false == m_draft.arcs.empty()) - { - m_draft.arcs.back().transition_to_next = 0.0; - } - rebuild_draft_preview(); - } - } - - ImGui::Spacing(); - if (m_draft.reedit_gap >= 0) - { - if (ImGui::Button("Zatwierdź##dfin") && false == m_draft.arcs.empty()) - { - finish_draft(); - } - } - else - { - ImGui::SetNextItemWidth(80.0f); - ImGui::InputDouble("L prostej##dfin", &m_draft_finish_straight, 10.0, 50.0, "%.0f"); - if (m_draft_finish_straight < 20.0) - { - m_draft_finish_straight = 20.0; - } - ImGui::SameLine(); - if (ImGui::Button("Zatwierdź##dfin") && false == m_draft.arcs.empty()) - { - finish_draft(); - } - } - ImGui::Unindent(); -} - -void plan_panel::render_toolbar() -{ - auto *niweleta{current_niweleta()}; - - ImGui::Text("top-down view, %.0f m across", Global.editor_ortho_extent * 2.0f); - ImGui::TextDisabled("Shift = długość, double-click = inna niweleta"); - if (Global.scenery_georeferenced) - { - ImGui::Text("zero at %.0f, %.0f (EPSG:2180)", Global.scenery_origin.x, Global.scenery_origin.y); - } - else - { - ImGui::TextDisabled("fictional map, no georeference"); - } - ImGui::Separator(); - - if (ImGui::Button("New map")) - { - ImGui::OpenPopup("New map"); - } - ImGui::SameLine(); - if (ImGui::Button("New niweleta")) - { - m_document.niwelety.push_back({"niweleta " + std::to_string(m_document.niwelety.size() + 1), {}, {}}); - m_niweleta = m_document.niwelety.size() - 1; - m_pending = false; - clear_selection(); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - solve(); - } - ImGui::SameLine(); - if (ImGui::Button("Undo point")) - { - drop_last_vertex(); - } - ImGui::SameLine(); - if (ImGui::Button("Go to plan")) - { - go_to_plan(); - } - ImGui::SameLine(); - ImGui::Checkbox("Draw", &m_drawing); - if (Global.scenery_georeferenced) - { - ImGui::SameLine(); - ImGui::Checkbox("Orthophoto", &m_showortho); - if (m_showortho) - { - auto const pending{static_cast(m_ortho.pending() + m_topo.pending())}; - if (pending > 0) - { - ImGui::SameLine(); - ImGui::TextDisabled("(%d tiles on the way)", pending); - } - } - } - - if (m_document.niwelety.size() > 1) - { - ImGui::SetNextItemWidth(220.0f); - if (ImGui::BeginCombo("Edited", m_document.niwelety[m_niweleta].name.c_str())) - { - for (std::size_t i = 0; i < m_document.niwelety.size(); ++i) - { - if (ImGui::Selectable(m_document.niwelety[i].name.c_str(), i == m_niweleta)) - { - m_niweleta = i; - m_pending = false; - clear_selection(); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - } - } - ImGui::EndCombo(); - } - } - - if (niweleta != nullptr) - { - ImGui::SetNextItemWidth(220.0f); - if (ImGui::InputText("Name", m_namebuf, sizeof(m_namebuf))) - { - niweleta->name = m_namebuf; - } - else if (false == ImGui::IsItemActive() && niweleta->name != m_namebuf) - { - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", niweleta->name.c_str()); - } - ImGui::SameLine(); - if (m_document.niwelety.size() > 1 && ImGui::Button("Delete niweleta")) - { - delete_current_niweleta(); - niweleta = current_niweleta(); - } - } - - { - auto const sel_label{m_sel_straight >= 0 ? " | selected #" + std::to_string(m_sel_straight) : std::string{}}; - ImGui::Text("straights: %d%s", niweleta != nullptr ? static_cast(niweleta->straights.size()) : 0, sel_label.c_str()); - } - - auto const has_sel{niweleta != nullptr && m_sel_straight >= 0 && static_cast(m_sel_straight) < niweleta->straights.size()}; - if (has_sel && ImGui::Button("Delete straight")) - { - delete_selected_straight(); - niweleta = current_niweleta(); - } - if (has_sel) - { - auto &st{niweleta->straights[static_cast(m_sel_straight)]}; - ImGui::SameLine(); - if (ImGui::Button(st.rel_kind == 1 ? "Re-pick parallel..." : "Parallel...")) - { - m_ask_rel_value = st.rel_kind == 1 ? st.rel_offset : 4.5; - m_pick_rel = 1; - m_status = "referencja: parallel"; - } - ImGui::SameLine(); - if (ImGui::Button(st.rel_kind == 2 ? "Re-pick skew..." : "Skew 1:n...")) - { - m_ask_rel_value = st.rel_kind == 2 ? st.rel_cot * (st.rel_side < 0 ? -1.0 : 1.0) : 9.0; - m_pick_rel = 2; - m_status = "referencja: skew"; - } - if (st.rel_kind == 1) - { - ImGui::Text("parallel → n%d s%d", st.rel_niw + 1, st.rel_str); - ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("offset m##parlive", &st.rel_offset, 0.1, 1.0, "%.2f")) - { - solve(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Clear##parlive")) - { - st.rel_kind = 0; - m_status = "cleared parallel"; - solve(); - } - } - else if (st.rel_kind == 2) - { - auto skew_n{st.rel_cot * (st.rel_side < 0 ? -1.0 : 1.0)}; - ImGui::Text("skew → n%d s%d", st.rel_niw + 1, st.rel_str); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("1:n##skewlive", &skew_n, 1.0, 1.0, "%.2f")) - { - if (std::abs(skew_n) < 1e-9) - { - st.rel_kind = 0; - m_status = "cleared skew"; - } - else - { - st.rel_cot = std::abs(skew_n); - st.rel_side = skew_n < 0.0 ? -1 : 1; - } - solve(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("L##skewlive", &st.rel_length, 5.0, 20.0, "%.1f")) - { - if (st.rel_length < 1.0) - { - st.rel_length = 1.0; - } - solve(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Clear##skewlive")) - { - st.rel_kind = 0; - m_status = "cleared skew"; - solve(); - } - } - } - - if (m_pick_rel != 0) - { - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.4f, 1.0f), "%s", m_pick_rel == 1 ? "referencja: parallel" : "referencja: skew"); - ImGui::SameLine(); - if (ImGui::SmallButton("Cancel pick")) - { - m_pick_rel = 0; - } - } - if (m_pick_compound_point) - { - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.4f, 1.0f), "punkty: %zu", m_fit_guides.size()); - ImGui::SameLine(); - if (ImGui::SmallButton("Fit##cmpick") && false == m_fit_guides.empty()) - { - fit_compound_to_guides(); - m_pick_compound_point = false; - } - ImGui::SameLine(); - if (ImGui::SmallButton("Clear##cmpick")) - { - clear_fit_guides(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Cancel##cmpick")) - { - m_pick_compound_point = false; - clear_fit_guides(); - } - } -} - -void plan_panel::render_gaps() -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || niweleta->straights.size() < 2) - { - return; - } - - ImGui::Separator(); - if (false == ImGui::CollapsingHeader("Fits", ImGuiTreeNodeFlags_DefaultOpen)) - { - return; - } - - ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 280.0f), false); - bool dirty{false}; - for (std::size_t gap = 0; gap < niweleta->fits.size(); ++gap) - { - auto &fit{niweleta->fits[gap]}; - ImGui::PushID(static_cast(gap)); - - auto const selected_gap{m_sel_gap == static_cast(gap)}; - if (ImGui::Selectable(selected_gap ? "gap##sel" : "gap##unsel", selected_gap, ImGuiSelectableFlags_AllowItemOverlap, ImVec2(36.0f, 0.0f))) - { - m_sel_gap = static_cast(gap); - } - ImGui::SameLine(); - ImGui::Text("%d", static_cast(gap)); - ImGui::SameLine(70.0f); - ImGui::SetNextItemWidth(170.0f); - // the library numbers these; 3 is the unified compound, any number of arcs eased by clothoids - if (ImGui::Combo("##mode", &fit.mode, "none\0arc\0arc + clothoids\0compound\0")) - { - m_sel_gap = static_cast(gap); - if (fit.mode != 0 && fit.radius <= 0.0) - { - fit.radius = 300.0; // something drawable to start from, rather than a rejected fit - } - if (fit.mode == 3 && fit.arcs.empty()) - { - // a basket needs at least two arcs to be one; the last absorbs the leftover deflection - fit.arcs.push_back({600.0, 60.0, 0.0}); - fit.arcs.push_back({300.0, 0.0, 0.0}); - } - dirty = true; - } - if (fit.mode == 1 || fit.mode == 2) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(120.0f); - if (ImGui::InputDouble("R", &fit.radius, 10.0, 100.0, "%.1f")) - { - dirty = true; - } - } - if (fit.mode == 2) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(120.0f); - if (ImGui::InputDouble("L", &fit.transition, 5.0, 20.0, "%.1f")) - { - dirty = true; - } - } - if (fit.mode == 3) - { - render_compound(fit, gap, dirty); - } - // a fit the library could not produce is simply absent from its answer; say so rather than - // leaving the parameters looking as though they took effect - if (fit.mode != 0 && false == fit_applied(gap)) - { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "does not fit"); - } - else if (fit.mode != 0 && m_niweleta < m_solved.size()) - { - auto const &solved{m_solved[m_niweleta]}; - double total{0.0}; - double kp_len{0.0}; - int kp_count{0}; - std::vector radii; - for (auto const &poly : solved.polylines) - { - if (poly.gap != static_cast(gap) || poly.element_index < 0) - { - continue; - } - // count each element once (rails may duplicate polylines) - bool seen{false}; - for (auto const &other : solved.polylines) - { - if (&other == &poly) - { - break; - } - if (other.gap == poly.gap && other.element_index == poly.element_index) - { - seen = true; - break; - } - } - if (seen) - { - continue; - } - total += poly.length; - if (poly.kind == 2) - { - kp_len += poly.length; - ++kp_count; - } - if (poly.kind == 1) - { - auto const r{poly.radius_start > 0.0 ? poly.radius_start : poly.radius_end}; - if (r > 0.0) - { - radii.push_back(r); - } - } - } - if (false == radii.empty()) - { - std::string rtxt{"R lukow: "}; - for (std::size_t i = 0; i < radii.size(); ++i) - { - if (i > 0) - { - rtxt += " / "; - } - char piece[32]; - std::snprintf(piece, sizeof(piece), "%.1f", radii[i]); - rtxt += piece; - } - rtxt += " m"; - ImGui::TextDisabled("%s", rtxt.c_str()); - } - if (kp_count > 0) - { - ImGui::TextDisabled("KP: %d L = %.1f m (krzywa lacznie %.1f m)", kp_count, kp_len, total); - } - else if (total > 0.0) - { - ImGui::TextDisabled("krzywa L = %.1f m", total); - } - } - - ImGui::PopID(); - } - ImGui::EndChild(); - - if (dirty) - { - solve(); - } -} - -void plan_panel::render_compound(maj0sted::editor::GapFit &Fit, std::size_t const Gap, bool &Dirty) -{ - ImGui::Indent(); - - if (ImGui::SmallButton("Kosz##reopen") && false == m_draft.active) - { - if (reopen_draft_from_gap(Gap)) - { - Dirty = false; - ImGui::Unindent(); - return; - } - } - - ImGui::SetNextItemWidth(100.0f); - if (ImGui::InputDouble("wejscie##ent", &Fit.entry_t, 5.0, 20.0, "%.1f")) - { - Dirty = true; - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(100.0f); - if (ImGui::InputDouble("wyjscie##ex", &Fit.exit_t, 5.0, 20.0, "%.1f")) - { - Dirty = true; - } - - ImGui::Spacing(); - double station{Fit.entry_t}; - for (std::size_t i = 0; i < Fit.arcs.size(); ++i) - { - auto &arc{Fit.arcs[i]}; - auto const last{i + 1 == Fit.arcs.size()}; - ImGui::PushID(static_cast(i)); - - ImGui::AlignTextToFramePadding(); - ImGui::Text("Luk %d", static_cast(i + 1)); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("R##ar", &arc.radius, 10.0, 100.0, "%.1f")) - { - Dirty = true; - } - - if (false == last) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("L##al", &arc.length, 5.0, 20.0, "%.1f")) - { - Dirty = true; - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("KP##at", &arc.transition_to_next, 5.0, 20.0, "%.1f")) - { - Dirty = true; - } - station += std::max(0.0, arc.length) + std::max(0.0, arc.transition_to_next); - ImGui::TextDisabled("~%.0f m", station); - } - else - { - ImGui::SameLine(); - ImGui::TextDisabled("L auto"); - } - - ImGui::PopID(); - } - - if (ImGui::SmallButton("Add arc")) - { - auto const radius{Fit.arcs.empty() ? 300.0 : Fit.arcs.back().radius}; - if (false == Fit.arcs.empty() && !(Fit.arcs.back().length > 0.0)) - { - Fit.arcs.back().length = 60.0; - } - Fit.arcs.insert(Fit.arcs.end() - (Fit.arcs.empty() ? 0 : 1), {radius, 60.0, 0.0}); - Dirty = true; - } - if (Fit.arcs.size() > 2) - { - ImGui::SameLine(); - if (ImGui::SmallButton("Remove arc")) - { - Fit.arcs.erase(Fit.arcs.end() - 2); - Dirty = true; - } - } - - ImGui::Spacing(); - if (ImGui::Button("Fit through points")) - { - m_sel_gap = static_cast(Gap); - m_pick_compound_point = true; - m_pick_rel = 0; - m_drawing = false; - m_fit_guides.clear(); - m_status = "click points, then Fit"; - } - - ImGui::Unindent(); -} - -void plan_panel::clear_fit_guides() -{ - m_fit_guides.clear(); - m_status.clear(); -} - -void plan_panel::fit_compound_to_guides() -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || m_sel_gap < 0 || static_cast(m_sel_gap) >= niweleta->fits.size()) - { - return; - } - if (m_fit_guides.empty()) - { - return; - } - auto const gap{static_cast(m_sel_gap)}; - if (gap + 1 >= niweleta->straights.size()) - { - return; - } - auto &fit{niweleta->fits[gap]}; - if (fit.mode != 3) - { - return; - } - if (fit.arcs.empty()) - { - fit.arcs.push_back({fit.radius > 30.0 ? fit.radius : 300.0, 0.0, 0.0}); - } - for (auto &arc : fit.arcs) - { - if (!(arc.radius >= 30.0)) - { - arc.radius = 300.0; - } - } - for (std::size_t i = 1; i < fit.arcs.size(); ++i) - { - if (std::abs(fit.arcs[i].radius - fit.arcs[i - 1].radius) < 1.0) - { - fit.arcs[i].radius = std::max(30.0, fit.arcs[i - 1].radius * 0.75); - } - } - for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) - { - if (!(fit.arcs[i].length > 0.0)) - { - fit.arcs[i].length = 60.0; - } - } - - auto &entry{niweleta->straights[gap]}; - auto &exit{niweleta->straights[gap + 1]}; - - // search window for straight ends: big enough to reach the guide cloud - double gx{0.0}; - double gy{0.0}; - for (auto const &g : m_fit_guides) - { - gx += g.x; - gy += g.y; - } - gx /= static_cast(m_fit_guides.size()); - gy /= static_cast(m_fit_guides.size()); - auto guide_span{80.0}; - for (auto const &g : m_fit_guides) - { - guide_span = std::max(guide_span, std::hypot(g.x - gx, g.y - gy) * 1.6); - } - auto const near_span{std::max(guide_span, 120.0)}; - auto const far_span{std::max(guide_span * 0.5, 60.0)}; - - enum class KnobKind - { - EntryT, - ExitT, - Radius, - Length, - Between, - EntryNearX, - EntryNearY, - ExitNearX, - ExitNearY, - EntryFarX, - EntryFarY, - ExitFarX, - ExitFarY - }; - struct Knob - { - KnobKind kind{}; - std::size_t arc{0}; - double lo{0.0}; - double hi{0.0}; - int priority{0}; - }; - - std::vector knobs; - knobs.push_back({KnobKind::EntryT, 0, 0.0, std::max(fit.entry_t * 4.0, 180.0), 2}); - knobs.push_back({KnobKind::ExitT, 0, 0.0, std::max(fit.exit_t * 4.0, 180.0), 2}); - for (std::size_t i = 0; i < fit.arcs.size(); ++i) - { - auto const &arc{fit.arcs[i]}; - knobs.push_back({KnobKind::Radius, i, 30.0, std::max(arc.radius * 5.0, 1200.0), 3}); - if (i + 1 < fit.arcs.size()) - { - knobs.push_back({KnobKind::Length, i, 5.0, std::max(arc.length * 6.0, 400.0), 3}); - knobs.push_back({KnobKind::Between, i, 0.0, std::max(arc.transition_to_next * 4.0, 160.0), 2}); - } - } - knobs.push_back({KnobKind::EntryNearX, 0, entry.x2 - near_span, entry.x2 + near_span, 4}); - knobs.push_back({KnobKind::EntryNearY, 0, entry.y2 - near_span, entry.y2 + near_span, 4}); - knobs.push_back({KnobKind::ExitNearX, 0, exit.x1 - near_span, exit.x1 + near_span, 4}); - knobs.push_back({KnobKind::ExitNearY, 0, exit.y1 - near_span, exit.y1 + near_span, 4}); - knobs.push_back({KnobKind::EntryFarX, 0, entry.x1 - far_span, entry.x1 + far_span, 1}); - knobs.push_back({KnobKind::EntryFarY, 0, entry.y1 - far_span, entry.y1 + far_span, 1}); - knobs.push_back({KnobKind::ExitFarX, 0, exit.x2 - far_span, exit.x2 + far_span, 1}); - knobs.push_back({KnobKind::ExitFarY, 0, exit.y2 - far_span, exit.y2 + far_span, 1}); - std::stable_sort(knobs.begin(), knobs.end(), [](Knob const &A, Knob const &B) { return A.priority > B.priority; }); - - auto trial{m_document}; - auto &trial_niw{trial.niwelety[m_niweleta]}; - auto &trial_fit{trial_niw.fits[gap]}; - auto &trial_entry{trial_niw.straights[gap]}; - auto &trial_exit{trial_niw.straights[gap + 1]}; - - auto get_value = [&](Knob const &K) -> double { - switch (K.kind) - { - case KnobKind::EntryT: - return trial_fit.entry_t; - case KnobKind::ExitT: - return trial_fit.exit_t; - case KnobKind::Radius: - return trial_fit.arcs[K.arc].radius; - case KnobKind::Length: - return trial_fit.arcs[K.arc].length; - case KnobKind::Between: - return trial_fit.arcs[K.arc].transition_to_next; - case KnobKind::EntryNearX: - return trial_entry.x2; - case KnobKind::EntryNearY: - return trial_entry.y2; - case KnobKind::ExitNearX: - return trial_exit.x1; - case KnobKind::ExitNearY: - return trial_exit.y1; - case KnobKind::EntryFarX: - return trial_entry.x1; - case KnobKind::EntryFarY: - return trial_entry.y1; - case KnobKind::ExitFarX: - return trial_exit.x2; - case KnobKind::ExitFarY: - return trial_exit.y2; - } - return 0.0; - }; - auto set_value = [&](Knob const &K, double const V) { - switch (K.kind) - { - case KnobKind::EntryT: - trial_fit.entry_t = V; - break; - case KnobKind::ExitT: - trial_fit.exit_t = V; - break; - case KnobKind::Radius: - trial_fit.arcs[K.arc].radius = V; - break; - case KnobKind::Length: - trial_fit.arcs[K.arc].length = V; - break; - case KnobKind::Between: - trial_fit.arcs[K.arc].transition_to_next = V; - break; - case KnobKind::EntryNearX: - trial_entry.x2 = V; - break; - case KnobKind::EntryNearY: - trial_entry.y2 = V; - break; - case KnobKind::ExitNearX: - trial_exit.x1 = V; - break; - case KnobKind::ExitNearY: - trial_exit.y1 = V; - break; - case KnobKind::EntryFarX: - trial_entry.x1 = V; - break; - case KnobKind::EntryFarY: - trial_entry.y1 = V; - break; - case KnobKind::ExitFarX: - trial_exit.x2 = V; - break; - case KnobKind::ExitFarY: - trial_exit.y2 = V; - break; - } - }; - auto radii_legal = [&]() { - for (std::size_t i = 0; i < trial_fit.arcs.size(); ++i) - { - if (!(trial_fit.arcs[i].radius >= 30.0)) - { - return false; - } - if (i > 0 && std::abs(trial_fit.arcs[i].radius - trial_fit.arcs[i - 1].radius) < 1.0) - { - return false; - } - } - return true; - }; - - auto evaluate = [&](double &OutMaxMiss) { - OutMaxMiss = std::numeric_limits::infinity(); - if (false == radii_legal()) - { - return std::numeric_limits::infinity(); - } - // tiny straights break the fitter - if (std::hypot(trial_entry.x2 - trial_entry.x1, trial_entry.y2 - trial_entry.y1) < 1.0 || - std::hypot(trial_exit.x2 - trial_exit.x1, trial_exit.y2 - trial_exit.y1) < 1.0) - { - return std::numeric_limits::infinity(); - } - auto const solved{maj0sted::editor::solve_project(trial.niwelety)}; - if (m_niweleta >= solved.size()) - { - return std::numeric_limits::infinity(); - } - double sum2{0.0}; - double max_miss{0.0}; - for (auto const &g : m_fit_guides) - { - auto const d{distance_to_gap_track(solved[m_niweleta], m_sel_gap, g.x, g.y)}; - if (false == std::isfinite(d)) - { - return std::numeric_limits::infinity(); - } - sum2 += d * d; - max_miss = std::max(max_miss, d); - } - OutMaxMiss = max_miss; - return sum2; - }; - - double best_max{0.0}; - auto best_cost{evaluate(best_max)}; - auto best_niw{trial_niw}; - - auto scan_knob = [&](Knob const &K, int const Samples) { - for (int s = 0; s < Samples; ++s) - { - auto const v{K.lo + (K.hi - K.lo) * static_cast(s) / static_cast(std::max(1, Samples - 1))}; - set_value(K, v); - double max_miss{0.0}; - auto const cost{evaluate(max_miss)}; - if (cost < best_cost) - { - best_cost = cost; - best_max = max_miss; - best_niw = trial_niw; - } - } - trial_niw = best_niw; - }; - - for (int round = 0; round < 3; ++round) - { - for (auto const &knob : knobs) - { - auto const samples{knob.priority >= 4 ? 22 : (knob.priority >= 3 ? 20 : (knob.priority >= 2 ? 14 : 10))}; - scan_knob(knob, samples); - } - } - - for (auto const &knob : knobs) - { - trial_niw = best_niw; - auto const centre{get_value(knob)}; - auto const span{(knob.hi - knob.lo) / 16.0}; - auto const lo{std::max(knob.lo, centre - span)}; - auto const hi{std::min(knob.hi, centre + span)}; - for (int s = 0; s < 14; ++s) - { - set_value(knob, lo + (hi - lo) * static_cast(s) / 13.0); - double max_miss{0.0}; - auto const cost{evaluate(max_miss)}; - if (cost < best_cost) - { - best_cost = cost; - best_max = max_miss; - best_niw = trial_niw; - } - } - } - - *niweleta = best_niw; - // drop constraints that would yank the ends back after we moved them on purpose - niweleta->straights[gap].rel_kind = 0; - niweleta->straights[gap + 1].rel_kind = 0; - solve(); - auto const rms{std::sqrt(best_cost / static_cast(m_fit_guides.size()))}; - m_status = "fit " + std::to_string(m_fit_guides.size()) + " pts rms " + to_string(rms, 2) + " max " + to_string(best_max, 2) + " m"; -} - -void plan_panel::render_switches() -{ - if (false == ImGui::CollapsingHeader("Rozjazdy")) - { - return; - } - - // typical Polish turnout templates: pick one to set the crossing mark and radius at once - auto const &presets{editor::turnout_presets()}; - ImGui::SetNextItemWidth(160.0f); - if (ImGui::BeginCombo("typ", "wybierz szablon")) - { - for (auto const &preset : presets) - { - if (ImGui::Selectable(preset.name.c_str())) - { - m_sw_crossing = preset.crossing_n; - m_sw_radius = preset.radius; - m_sw_length = preset.length; - m_sw_pre_blade = preset.pre_blade; - m_sw_blade_angle = preset.blade_angle; - m_sw_blade_length = preset.blade_length; - } - } - ImGui::EndCombo(); - } - // the template's catalogue length (PR->KR); custom switches with no template just span their curve - ImGui::SameLine(); - if (m_sw_length > 0.0) - { - ImGui::TextDisabled("dl. %.3f m (Id-1)", m_sw_length); - } - else - { - ImGui::TextDisabled("dl. ~%.1f m (sam luk)", m_sw_radius * std::atan(1.0 / std::max(1.0, m_sw_crossing))); - } - - ImGui::SetNextItemWidth(90.0f); - ImGui::Combo("strona", &m_sw_side, "w lewo\0w prawo\0"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - ImGui::InputDouble("skos 1:", &m_sw_crossing, 1.0, 1.0, "%.1f"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - ImGui::InputDouble("R", &m_sw_radius, 10.0, 50.0, "%.0f"); - if (m_sw_crossing < 4.0) - { - m_sw_crossing = 4.0; - } - if (m_sw_radius < 50.0) - { - m_sw_radius = 50.0; - } - - if (m_pick_switch != 0) - { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "kliknij niweletę na wprost..."); - ImGui::SameLine(); - if (ImGui::Button("anuluj")) - { - m_pick_switch = 0; - } - } - else if (ImGui::Button("Dodaj rozjazd")) - { - m_pick_switch = 1; - m_status = "kliknij niweletę na wprost"; - } - - ImGui::TextDisabled("odnoga wychodzi wymuszenie stycznie; luk koszowy zaraz za rozjazdem = luka 0 odnogi"); - - // existing switches: the theoretical tangent lengths the solver worked out, plus the internal - // curve (a plain arc, or a łuk koszowy right inside the switch) - for (std::size_t i = 0; i < m_document.junctions.size(); ++i) - { - auto &junction{m_document.junctions[i]}; - ImGui::PushID(static_cast(i)); - char const *through_name{junction.through >= 0 && static_cast(junction.through) < m_document.niwelety.size() - ? m_document.niwelety[static_cast(junction.through)].name.c_str() - : "?"}; - bool const valid{i < m_junctions.size() && m_junctions[i].valid}; - - ImGui::AlignTextToFramePadding(); - ImGui::Text("%zu. na %s, 1:%.1f %s", i + 1, through_name, junction.crossing_n, junction.side == 0 ? "L" : "P"); - if (valid) - { - ImGui::SameLine(); - ImGui::TextDisabled("dl=%.1f m, styczne t=%.1f / %.1f m", m_junctions[i].length, m_junctions[i].tangent_front, m_junctions[i].tangent_back); - } - else - { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "nie wpasowany"); - } - ImGui::SameLine(); - if (ImGui::SmallButton(junction.side == 0 ? "L->P" : "P->L")) - { - junction.side = junction.side == 0 ? 1 : 0; - solve(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("usuń")) - { - m_document.junctions.erase(m_document.junctions.begin() + static_cast(i)); - solve(); - ImGui::PopID(); - break; - } - - // internal diverging curve of the switch: a single arc, or a basket of arcs - ImGui::Indent(); - int curve_mode{junction.curve.mode == 3 ? 1 : 0}; - ImGui::SetNextItemWidth(110.0f); - if (ImGui::Combo("krzywa", &curve_mode, "luk\0koszowy\0")) - { - if (curve_mode == 1) - { - junction.curve.mode = 3; - if (junction.curve.arcs.empty()) - { - // a basket needs at least two arcs; a gentler entry radius eases into the nominal - // one, whose closing arc absorbs the rest of the turn. a short fixed length keeps - // the whole curve inside the turnout rather than far past its end - auto const nominal{junction.curve.radius > 0.0 ? junction.curve.radius : 300.0}; - junction.curve.arcs.push_back({nominal * 2.0, 6.0, 0.0}); - junction.curve.arcs.push_back({nominal, 0.0, 0.0}); - } - } - else - { - junction.curve.mode = 1; - } - solve(); - } - - if (junction.curve.mode == 3) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("wej##je", &junction.curve.entry_t, 5.0, 20.0, "%.1f")) - { - solve(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("wyj##jx", &junction.curve.exit_t, 5.0, 20.0, "%.1f")) - { - solve(); - } - for (std::size_t a = 0; a < junction.curve.arcs.size(); ++a) - { - auto &arc{junction.curve.arcs[a]}; - bool const last{a + 1 == junction.curve.arcs.size()}; - ImGui::PushID(static_cast(a)); - ImGui::AlignTextToFramePadding(); - ImGui::Text("Luk %d", static_cast(a + 1)); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("R##jar", &arc.radius, 10.0, 100.0, "%.1f")) - { - solve(); - } - if (false == last) - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("L##jal", &arc.length, 5.0, 20.0, "%.1f")) - { - solve(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.0f); - if (ImGui::InputDouble("KP##jat", &arc.transition_to_next, 5.0, 20.0, "%.1f")) - { - solve(); - } - } - else - { - ImGui::SameLine(); - ImGui::TextDisabled("domyka"); - } - ImGui::PopID(); - } - if (ImGui::SmallButton("+ luk")) - { - junction.curve.arcs.insert(junction.curve.arcs.end() - 1, {junction.curve.arcs.back().radius, 40.0, 0.0}); - solve(); - } - if (junction.curve.arcs.size() > 2) - { - ImGui::SameLine(); - if (ImGui::SmallButton("- luk")) - { - junction.curve.arcs.erase(junction.curve.arcs.end() - 2); - solve(); - } - } - } - else - { - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.0f); - if (ImGui::InputDouble("R##jr", &junction.curve.radius, 10.0, 50.0, "%.0f")) - { - solve(); - } - } - - if (junction.branch >= 0 && static_cast(junction.branch) < m_document.niwelety.size()) - { - ImGui::SameLine(); - if (ImGui::SmallButton("edytuj odnoge")) - { - m_niweleta = static_cast(junction.branch); - clear_selection(); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - } - } - ImGui::Unindent(); - ImGui::PopID(); - } -} - -void plan_panel::add_switch_on(std::size_t const Through, double const Wx, double const Wy) -{ - if (Through >= m_solved.size()) - { - return; - } - double best_station{0.0}; - if (false == station_on_through(Through, Wx, Wy, best_station)) - { - m_status = "niweleta bez osi"; - return; - } - - maj0sted::editor::Junction junction; - junction.through = static_cast(Through); - junction.station = best_station; - junction.side = m_sw_side; - junction.facing = true; - junction.crossing_n = m_sw_crossing; - junction.length = m_sw_length; // catalogue length from the template, 0 for a custom switch - // the blade figures come from the template too; all zero lays a tangent arc from PR - junction.pre_blade = m_sw_pre_blade; - junction.blade_angle = m_sw_blade_angle; - junction.blade_length = m_sw_blade_length; - junction.curve.mode = 1; // plain arc; the internal curve can be made compound later - junction.curve.radius = m_sw_radius; - junction.branch = static_cast(m_document.niwelety.size()); - - // a branch niweleta to draw the diverging track into, pinned to the frog by the solver - maj0sted::editor::NiweletaSpec branch; - branch.name = "odnoga " + std::to_string(m_document.niwelety.size() + 1); - branch.straights.push_back({Wx, Wy, Wx + 10.0, Wy, false}); // placeholder, repositioned below - m_document.niwelety.push_back(std::move(branch)); - m_document.junctions.push_back(junction); - - // solve once to learn where the frog lands, then seed the branch to leave it tangent - solve(); - if (false == m_junctions.empty() && m_junctions.back().valid) - { - auto const &geom{m_junctions.back()}; - auto &seed{m_document.niwelety[static_cast(junction.branch)].straights.front()}; - seed = {geom.fx, geom.fy, geom.fx + geom.fhx * 50.0, geom.fy + geom.fhy * 50.0, false}; - } - - m_niweleta = static_cast(junction.branch); - clear_selection(); - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); - solve(); - m_status = "rozjazd 1:" + to_string(m_sw_crossing, 1) + " dodany"; -} - -void plan_panel::render_storage() -{ - ImGui::Separator(); - ImGui::SetNextItemWidth(240.0f); - ImGui::InputText("##path", m_path, sizeof(m_path)); - ImGui::SameLine(); - if (ImGui::Button("Save")) - { - double planx{0.0}; - double plany{0.0}; - world_to_plan(editor_mode::get_camera().Pos, planx, plany); - m_document.view_x = planx; - m_document.view_y = plany; - m_document.view_extent = static_cast(Global.editor_ortho_extent); - m_document.origin_set = true; - m_document.georeferenced = Global.scenery_georeferenced; - m_document.origin_x = Global.scenery_origin.x; - m_document.origin_y = Global.scenery_origin.y; - m_status = maj0sted::app::save_project(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path); - } - ImGui::SameLine(); - if (ImGui::Button("Load")) - { - auto const loaded{maj0sted::app::load_project(m_path)}; - if (loaded) - { - m_document = *loaded; - if (m_document.niwelety.empty()) - { - m_document.niwelety.push_back({"niweleta 1", {}, {}}); - } - auto looks_puwg = [](maj0sted::app::EditorDocument const &Doc) { - if (std::abs(Doc.view_x) > 10000.0 || std::abs(Doc.view_y) > 10000.0) - { - return true; - } - for (auto const &n : Doc.niwelety) - { - for (auto const &s : n.straights) - { - if (std::abs(s.x1) > 10000.0 || std::abs(s.y1) > 10000.0 || std::abs(s.x2) > 10000.0 || std::abs(s.y2) > 10000.0) - { - return true; - } - } - } - return false; - }; - if (m_document.origin_set) - { - Global.scenery_georeferenced = m_document.georeferenced; - Global.scenery_origin = {m_document.origin_x, m_document.origin_y}; - } - if (false == Global.scenery_georeferenced && looks_puwg(m_document)) - { - Global.scenery_georeferenced = true; - } - m_document.origin_set = true; - m_document.georeferenced = Global.scenery_georeferenced; - m_document.origin_x = Global.scenery_origin.x; - m_document.origin_y = Global.scenery_origin.y; - m_niweleta = 0; - m_pending = false; - clear_selection(); - m_pick_rel = 0; - m_ask_rel_param = false; - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[0].name.c_str()); - solve(); - if (m_document.view_extent > 0.0) - { - auto &camera{editor_mode::get_camera()}; - auto const centre{plan_to_world(m_document.view_x, m_document.view_y)}; - camera.Pos.x = centre.x; - camera.Pos.z = centre.z; - Global.editor_ortho_extent = std::clamp(static_cast(m_document.view_extent), 5.0f, 20000.0f); - } - else - { - go_to_plan(); - } - m_status = "loaded " + std::string(m_path); - } - else - { - m_status = "could not read " + std::string(m_path); - } - } - ImGui::SetNextItemWidth(240.0f); - ImGui::InputText("##scnpath", m_scn_path, sizeof(m_scn_path)); - ImGui::SameLine(); - if (ImGui::Button("Export SCN")) - { - export_scn(); - } - if (false == m_status.empty()) - { - ImGui::TextDisabled("%s", m_status.c_str()); - } -} - -void plan_panel::export_scn() -{ - solve(); - maj0sted::io::ScnExportOptions opt; - opt.origin_east = Global.scenery_origin.x; - opt.origin_north = Global.scenery_origin.y; - std::ofstream out(m_scn_path, std::ios::binary | std::ios::trunc); - if (!out) - { - m_status = "could not write " + std::string(m_scn_path); - return; - } - auto const result{maj0sted::io::export_scn(m_solved, opt, out)}; - out.flush(); - m_status = out.good() ? ("exported " + std::to_string(result.tracks) + " tracks") - : ("could not write " + std::string(m_scn_path)); -} - - -void plan_panel::solve() -{ - for (auto &niweleta : m_document.niwelety) - { - maj0sted::app::align_gap_fits(niweleta); - } - maj0sted::app::apply_straight_constraints(m_document.niwelety); - // switches pin their branches, so the whole layout is solved together - auto layout{maj0sted::editor::solve_layout(m_document.niwelety, m_document.junctions)}; - m_solved = std::move(layout.niwelety); - m_junctions = std::move(layout.junctions); -} - - -void plan_panel::apply_constraints() -{ - maj0sted::app::apply_straight_constraints(m_document.niwelety); -} - - -maj0sted::editor::NiweletaSpec *plan_panel::current_niweleta() -{ - return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; -} - -maj0sted::editor::NiweletaSpec const *plan_panel::current_niweleta() const -{ - return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; -} - -void plan_panel::align_fits() -{ - for (auto &niweleta : m_document.niwelety) - { - maj0sted::app::align_gap_fits(niweleta); - } -} - - -bool plan_panel::fit_applied(std::size_t const Gap) const -{ - if (m_niweleta >= m_solved.size()) - { - return false; - } - for (auto const &fit : m_solved[m_niweleta].applied_fits) - { - if (fit.gap >= 0 && static_cast(fit.gap) == Gap) - { - return true; - } - } - return false; -} - -maj0sted::editor::StraightSpec const *plan_panel::handle_straight(std::size_t const Niw, std::size_t const Index) const -{ - if (Niw >= m_document.niwelety.size()) - { - return nullptr; - } - auto const &raw{m_document.niwelety[Niw].straights}; - if (Index >= raw.size()) - { - return nullptr; - } - if (Niw < m_solved.size() && Index < m_solved[Niw].rendered_straights.size()) - { - return &m_solved[Niw].rendered_straights[Index]; - } - return &raw[Index]; -} - -void plan_panel::append_vertex(double const X, double const Y) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - return; - } - - if (niweleta->straights.empty()) - { - if (false == m_pending) - { - // nothing to attach to yet, so the first corner waits for the one that closes the straight - m_pending = true; - m_pendingx = X; - m_pendingy = Y; - return; - } - m_pending = false; - niweleta->straights.push_back({m_pendingx, m_pendingy, X, Y, false}); - } - else - { - // seed from the previous end, but the joint is not locked afterwards - auto const &last{niweleta->straights.back()}; - niweleta->straights.push_back({last.x2, last.y2, X, Y, false}); - } - - solve(); -} - -void plan_panel::drop_last_vertex() -{ - if (m_pending) - { - m_pending = false; - return; - } - - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || niweleta->straights.empty()) - { - return; - } - - m_sel_straight = static_cast(niweleta->straights.size() - 1); - delete_selected_straight(); -} - -void plan_panel::clear_selection() -{ - m_sel_straight = -1; - m_sel_end = -1; - m_dragging = false; - m_dragging_cmp = false; - m_cmp_gap = -1; - m_cmp_kind = -1; - m_cmp_arc = -1; - m_dragging_draft = false; - m_draft_kind = -1; - m_draft_arc = -1; -} - -void plan_panel::delete_selected_straight() -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || m_sel_straight < 0 || static_cast(m_sel_straight) >= niweleta->straights.size()) - { - return; - } - - auto const sel{m_sel_straight}; - niweleta->straights.erase(niweleta->straights.begin() + sel); - - // gaps touching the removed straight disappear; later gaps shift down - std::vector kept; - kept.reserve(niweleta->fits.size()); - for (auto const &fit : niweleta->fits) - { - if (fit.gap < sel - 1) - { - kept.push_back(fit); - } - else if (fit.gap >= sel + 1) - { - auto copy{fit}; - copy.gap = fit.gap - 1; - kept.push_back(copy); - } - } - niweleta->fits = std::move(kept); - - // remap constraint references across every niweleta - for (std::size_t n = 0; n < m_document.niwelety.size(); ++n) - { - for (auto &st : m_document.niwelety[n].straights) - { - if (st.rel_kind == 0) - { - continue; - } - if (st.rel_niw == static_cast(m_niweleta)) - { - if (st.rel_str == sel) - { - st.rel_kind = 0; - } - else if (st.rel_str > sel) - { - --st.rel_str; - } - } - } - } - - clear_selection(); - solve(); -} - -void plan_panel::delete_current_niweleta() -{ - if (m_document.niwelety.size() <= 1 || m_niweleta >= m_document.niwelety.size()) - { - return; - } - - auto const removed{static_cast(m_niweleta)}; - m_document.niwelety.erase(m_document.niwelety.begin() + static_cast(m_niweleta)); - - for (auto &niweleta : m_document.niwelety) - { - for (auto &st : niweleta.straights) - { - if (st.rel_kind == 0) - { - continue; - } - if (st.rel_niw == removed) - { - st.rel_kind = 0; - } - else if (st.rel_niw > removed) - { - --st.rel_niw; - } - } - } - - if (m_niweleta >= m_document.niwelety.size()) - { - m_niweleta = m_document.niwelety.size() - 1; - } - m_pending = false; - clear_selection(); - m_pick_rel = 0; - m_ask_rel_param = false; - if (auto *niweleta{current_niweleta()}; niweleta != nullptr) - { - std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", niweleta->name.c_str()); - } - solve(); -} - -bool plan_panel::hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const -{ - auto const *niweleta{current_niweleta()}; - if (niweleta == nullptr || m_niweleta >= m_solved.size()) - { - return false; - } - auto best{14.0f}; - bool found{false}; - for (auto const &fit : niweleta->fits) - { - if (fit.mode != 3 || fit.arcs.empty()) - { - continue; - } - for (auto const &h : compound_station_handles(fit, m_solved[m_niweleta], fit.gap)) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) - { - continue; - } - auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; - if (d <= best) - { - best = d; - OutGap = h.gap; - OutKind = h.kind; - OutArc = h.arc; - found = true; - } - } - } - return found; -} - -void plan_panel::begin_draft() -{ - discard_draft(); - m_draft.reedit_gap = -1; - m_draft.reedit_backup = {}; - m_draft_place = 1; - m_anchor_pick = 0; - m_drawing = false; - m_pick_compound_point = false; - m_pick_rel = 0; - m_dragging_draft = false; - m_draft_append_kind = 0; - m_fit_guides.clear(); - m_status = "kliknij koniec prostej"; -} - -void plan_panel::discard_draft() -{ - auto *niweleta{current_niweleta()}; - maj0sted::app::discard_basket_draft(m_draft, niweleta); - if (niweleta != nullptr) - { - solve(); - } - m_draft_polys.clear(); - m_draft_place = 0; - m_anchor_pick = 0; - m_dragging_draft = false; - m_draft_kind = -1; - m_draft_arc = -1; - m_draft_append_kind = 0; - m_status.clear(); -} - - -bool plan_panel::reopen_draft_from_gap(std::size_t const Gap) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - return false; - } - auto const *solved{m_niweleta < m_solved.size() ? &m_solved[m_niweleta] : nullptr}; - if (false == maj0sted::app::reopen_basket_draft(m_draft, *niweleta, Gap, solved)) - { - m_status = "nie compound"; - return false; - } - m_draft_place = 0; - m_anchor_pick = 0; - m_dragging_draft = false; - m_drawing = false; - m_sel_gap = static_cast(Gap); - m_sel_straight = static_cast(Gap); - m_sel_end = 1; - solve(); - rebuild_draft_preview(); - m_status = "luka " + std::to_string(Gap); - return true; -} - - -void plan_panel::start_draft_from_end(std::size_t const Str, int const End) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || Str >= niweleta->straights.size() || (End != 0 && End != 1)) - { - return; - } - maj0sted::app::start_basket_draft_from_end(m_draft, niweleta->straights[Str], static_cast(Str), End, m_draft_r); - m_draft_place = 0; - m_draft_append_kind = 0; - m_drawing = false; - m_sel_straight = static_cast(Str); - m_sel_end = End; - rebuild_draft_preview(); - m_status.clear(); -} - - -void plan_panel::append_draft_at(double const X, double const Y) -{ - if (false == m_draft.active) - { - return; - } - double tipx{0.0}; - double tipy{0.0}; - if (false == draft_tip(tipx, tipy)) - { - return; - } - auto const len{std::max(5.0, std::hypot(X - tipx, Y - tipy))}; - m_draft.preview_next_r = std::max(30.0, m_draft_r); - - if (m_draft_append_kind == 0) - { - m_draft.arcs.push_back({std::max(30.0, m_draft_r), len, 0.0}); - m_status = "luk L=" + to_string(len, 1) + " R=" + to_string(m_draft_r, 0); - } - else if (m_draft_append_kind == 1) - { - if (m_draft.arcs.empty()) - { - m_draft.entry_t = len; - m_status = "KP wejście " + to_string(len, 1) + " m"; - } - else - { - m_draft.arcs.back().transition_to_next = len; - m_draft.exit_t = 0.0; - m_status = "KP między " + to_string(len, 1) + " m"; - } - } - else - { - if (m_draft.arcs.empty()) - { - m_status = "najpierw łuk"; - return; - } - m_draft.exit_t = len; - rebuild_draft_preview(); - finish_draft(); - return; - } - rebuild_draft_preview(); -} - -void plan_panel::undo_draft_fragment() -{ - if (false == m_draft.active) - { - return; - } - if (m_draft.exit_t > 1e-6) - { - m_draft.exit_t = 0.0; - } - else if (false == m_draft.arcs.empty() && m_draft.arcs.back().transition_to_next > 1e-6) - { - m_draft.arcs.back().transition_to_next = 0.0; - } - else if (false == m_draft.arcs.empty()) - { - m_draft.arcs.pop_back(); - } - else if (m_draft.entry_t > 1e-6) - { - m_draft.entry_t = 0.0; - } - else - { - m_status = "nic do cofniecia"; - return; - } - rebuild_draft_preview(); - m_status = "cofnieto fragment"; -} - -bool plan_panel::draft_tip(double &OutX, double &OutY) const -{ - double az{0.0}; - return draft_end_pose(OutX, OutY, az); -} - -bool plan_panel::draft_end_pose(double &OutX, double &OutY, double &OutAz) const -{ - if (false == m_draft.active) - { - return false; - } - OutAz = m_draft.az0; - if (false == m_draft_polys.empty() && false == m_draft_polys.back().points.empty()) - { - auto const &pts{m_draft_polys.back().points}; - OutX = pts.back().x; - OutY = pts.back().y; - if (pts.size() >= 2) - { - OutAz = std::atan2(pts.back().x - pts[pts.size() - 2].x, pts.back().y - pts[pts.size() - 2].y); - } - return true; - } - OutX = m_draft.x0; - OutY = m_draft.y0; - return true; -} - -bool plan_panel::finish_draft() -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - m_status = "brak niwelety"; - return false; - } - auto const result{maj0sted::app::finish_basket_draft(m_draft, *niweleta, m_document.niwelety, static_cast(m_niweleta), m_draft_finish_straight)}; - m_status = result.status; - if (result.ok) - { - m_draft_polys.clear(); - m_draft_place = 0; - m_anchor_pick = 0; - m_dragging_draft = false; - solve(); - } - else - { - rebuild_draft_preview(); - solve(); - } - return result.ok; -} - - -void plan_panel::rebuild_draft_preview() -{ - m_draft_polys = maj0sted::app::preview_basket_draft(m_draft, m_draft_r); -} - - -bool plan_panel::hit_draft_station(ImVec2 const &Mouse, int &OutKind, int &OutArc) const -{ - if (false == m_draft.active) - { - return false; - } - auto best{14.0f}; - bool found{false}; - for (auto const &h : draft_station_handles(m_draft, m_draft_polys)) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) - { - continue; - } - auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; - if (d <= best) - { - best = d; - OutKind = h.kind; - OutArc = h.arc; - found = true; - } - } - return found; -} - -void plan_panel::drag_draft_station(double const X, double const Y) -{ - if (false == m_draft.active || m_draft.arcs.empty()) - { - return; - } - auto const samples{sample_draft_centreline(m_draft_polys)}; - double station{0.0}; - double px{0.0}; - double py{0.0}; - if (false == project_on_gap(samples, X, Y, station, px, py)) - { - return; - } - auto const total{samples.empty() ? 0.0 : samples.back().along}; - - auto station_before_arc = [&](int const Arc) { - auto s{std::max(0.0, m_draft.entry_t)}; - for (int i = 0; i < Arc && i < static_cast(m_draft.arcs.size()); ++i) - { - s += std::max(0.0, m_draft.arcs[static_cast(i)].length); - if (i + 1 < static_cast(m_draft.arcs.size())) - { - s += std::max(0.0, m_draft.arcs[static_cast(i)].transition_to_next); - } - } - return s; - }; - - constexpr double kMinL = 5.0; - constexpr double kMinKp = 0.0; - - if (m_draft_kind == 0) - { - m_draft.entry_t = std::clamp(station, 0.0, std::max(0.0, total - 20.0)); - } - else if (m_draft_kind == 1 && m_draft_arc >= 0 && m_draft_arc < static_cast(m_draft.arcs.size())) - { - auto const s0{station_before_arc(m_draft_arc)}; - auto &arc{m_draft.arcs[static_cast(m_draft_arc)]}; - if (ImGui::GetIO().KeyShift && m_draft_arc + 1 < static_cast(m_draft.arcs.size())) - { - auto const arc_end{s0 + std::max(kMinL, arc.length)}; - arc.transition_to_next = std::max(kMinKp, station - arc_end); - } - else - { - arc.length = std::max(kMinL, station - s0); - } - } - else if (m_draft_kind == 2 && m_draft_arc >= 0 && m_draft_arc + 1 < static_cast(m_draft.arcs.size())) - { - auto const s0{station_before_arc(m_draft_arc) + std::max(0.0, m_draft.arcs[static_cast(m_draft_arc)].length)}; - m_draft.arcs[static_cast(m_draft_arc)].transition_to_next = std::max(kMinKp, station - s0); - } - else if (m_draft_kind == 3) - { - m_draft.exit_t = std::clamp(total - station, 0.0, std::max(0.0, total - 20.0)); - } - - rebuild_draft_preview(); -} - -bool plan_panel::anchor_draft_to_gap(std::size_t const Gap) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - return false; - } - if (false == maj0sted::app::anchor_basket_draft(m_draft, *niweleta, Gap)) - { - m_status = "nie da się (kierunek / L+KP)"; - rebuild_draft_preview(); - solve(); - return false; - } - m_draft_polys.clear(); - m_draft_place = 0; - m_anchor_pick = 0; - m_dragging_draft = false; - m_sel_gap = static_cast(Gap); - solve(); - m_status = "luka " + std::to_string(Gap); - return true; -} - - -void plan_panel::drag_compound_station(double const X, double const Y) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || m_cmp_gap < 0 || static_cast(m_cmp_gap) >= niweleta->fits.size() || m_niweleta >= m_solved.size()) - { - return; - } - auto &fit{niweleta->fits[static_cast(m_cmp_gap)]}; - if (fit.mode != 3 || fit.arcs.empty()) - { - return; - } - - auto const samples{sample_gap_centreline(m_solved[m_niweleta], m_cmp_gap)}; - double station{0.0}; - double px{0.0}; - double py{0.0}; - if (false == project_on_gap(samples, X, Y, station, px, py)) - { - return; - } - auto const total{samples.empty() ? 0.0 : samples.back().along}; - - if (fit.arcs.empty()) - { - fit.arcs.push_back({300.0, 0.0, 0.0}); - } - for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) - { - if (!(fit.arcs[i].length > 0.0)) - { - fit.arcs[i].length = 40.0; - } - } - - auto station_before_arc = [&](int const Arc) { - auto s{std::max(0.0, fit.entry_t)}; - for (int i = 0; i < Arc && i + 1 < static_cast(fit.arcs.size()); ++i) - { - s += std::max(0.0, fit.arcs[static_cast(i)].length); - s += std::max(0.0, fit.arcs[static_cast(i)].transition_to_next); - } - return s; - }; - - constexpr double kMinL = 5.0; - constexpr double kMinKp = 0.0; - - if (m_cmp_kind == 0) - { - // entry KP end: station from the start of the curve - fit.entry_t = std::clamp(station, 0.0, std::max(0.0, total - 20.0)); - } - else if (m_cmp_kind == 1 && m_cmp_arc >= 0 && m_cmp_arc + 1 < static_cast(fit.arcs.size())) - { - auto const s0{station_before_arc(m_cmp_arc)}; - auto &arc{fit.arcs[static_cast(m_cmp_arc)]}; - if (ImGui::GetIO().KeyShift) - { - // Shift: pull a between-KP out past the arc end (L stays) - auto const arc_end{s0 + std::max(kMinL, arc.length)}; - arc.transition_to_next = std::max(kMinKp, station - arc_end); - } - else - { - arc.length = std::max(kMinL, station - s0); - } - } - else if (m_cmp_kind == 2 && m_cmp_arc >= 0 && m_cmp_arc + 1 < static_cast(fit.arcs.size())) - { - auto const s0{station_before_arc(m_cmp_arc) + std::max(0.0, fit.arcs[static_cast(m_cmp_arc)].length)}; - fit.arcs[static_cast(m_cmp_arc)].transition_to_next = std::max(kMinKp, station - s0); - } - else if (m_cmp_kind == 3) - { - fit.exit_t = std::clamp(total - station, 0.0, std::max(0.0, total - 20.0)); - } - - solve(); -} - -bool plan_panel::hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const -{ - auto best{12.0f}; - bool found{false}; - auto const niw{m_niweleta}; - auto const *niweleta{current_niweleta()}; - if (niweleta == nullptr) - { - return false; - } - for (std::size_t i = 0; i < niweleta->straights.size(); ++i) - { - auto const *st{handle_straight(niw, i)}; - if (st == nullptr || st->hidden) - { - continue; - } - for (int end = 0; end < 2; ++end) - { - ImVec2 screen; - if (false == world_to_screen(plan_to_world(end == 0 ? st->x1 : st->x2, end == 0 ? st->y1 : st->y2), screen)) - { - continue; - } - auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; - if (d <= best) - { - best = d; - OutNiw = static_cast(niw); - OutStr = static_cast(i); - OutEnd = end; - found = true; - } - } - } - return found; -} - -bool plan_panel::station_on_through(std::size_t const Through, double const Wx, double const Wy, double &OutStation) const -{ - if (Through >= m_solved.size()) - { - return false; - } - auto const ¢re{m_solved[Through].centreline}; - if (centre.size() < 2) - { - return false; - } - // walk the axis, project the point onto each segment, keep the nearest station (arc length) - double best_station{0.0}; - double best_dist{std::numeric_limits::max()}; - double travelled{0.0}; - for (std::size_t i = 0; i + 1 < centre.size(); ++i) - { - double const ax{centre[i].x}; - double const ay{centre[i].y}; - double const dx{centre[i + 1].x - ax}; - double const dy{centre[i + 1].y - ay}; - double const seg{std::hypot(dx, dy)}; - if (seg < 1e-9) - { - continue; - } - double const t{std::clamp(((Wx - ax) * dx + (Wy - ay) * dy) / (seg * seg), 0.0, 1.0)}; - double const d{std::hypot(Wx - (ax + dx * t), Wy - (ay + dy * t))}; - if (d < best_dist) - { - best_dist = d; - best_station = travelled + seg * t; - } - travelled += seg; - } - OutStation = best_station; - return true; -} - -int plan_panel::nearest_niweleta_axis(ImVec2 const &Mouse, float const Tolerance) const -{ - auto best{Tolerance}; - int found{-1}; - for (std::size_t n = 0; n < m_solved.size(); ++n) - { - for (auto const &polyline : m_solved[n].polylines) - { - for (std::size_t i = 0; i + 1 < polyline.points.size(); ++i) - { - ImVec2 a; - ImVec2 b; - if (false == world_to_screen(plan_to_world(polyline.points[i].x, polyline.points[i].y), a) || - false == world_to_screen(plan_to_world(polyline.points[i + 1].x, polyline.points[i + 1].y), b)) - { - continue; - } - auto const dx{b.x - a.x}; - auto const dy{b.y - a.y}; - auto const len2{dx * dx + dy * dy}; - if (len2 <= 0.0f) - { - continue; - } - auto const t{std::clamp(((Mouse.x - a.x) * dx + (Mouse.y - a.y) * dy) / len2, 0.0f, 1.0f)}; - auto const d{std::hypot(Mouse.x - (a.x + t * dx), Mouse.y - (a.y + t * dy))}; - if (d <= best) - { - best = d; - found = static_cast(n); - } - } - } - } - return found; -} - -bool plan_panel::hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const -{ - auto best{10.0f}; - bool found{false}; - auto const consider = [&](std::size_t const Niw) { - if (Niw >= m_document.niwelety.size()) - { - return; - } - auto const &straights{m_document.niwelety[Niw].straights}; - for (std::size_t i = 0; i < straights.size(); ++i) - { - auto const *st{handle_straight(Niw, i)}; - if (st == nullptr || st->hidden) - { - continue; - } - ImVec2 a; - ImVec2 b; - if (false == world_to_screen(plan_to_world(st->x1, st->y1), a) || false == world_to_screen(plan_to_world(st->x2, st->y2), b)) - { - continue; - } - auto const dx{b.x - a.x}; - auto const dy{b.y - a.y}; - auto const len2{dx * dx + dy * dy}; - if (len2 <= 0.0f) - { - continue; - } - auto const t{std::clamp(((Mouse.x - a.x) * dx + (Mouse.y - a.y) * dy) / len2, 0.0f, 1.0f)}; - auto const px{a.x + t * dx}; - auto const py{a.y + t * dy}; - auto const d{std::hypot(Mouse.x - px, Mouse.y - py)}; - if (d <= best) - { - best = d; - OutNiw = static_cast(Niw); - OutStr = static_cast(i); - found = true; - } - } - }; - if (AllNiwelety) - { - for (std::size_t n = 0; n < m_document.niwelety.size(); ++n) - { - consider(n); - } - } - else - { - consider(m_niweleta); - } - return found; -} - -void plan_panel::go_to_plan() -{ - auto minx{std::numeric_limits::max()}; - auto miny{std::numeric_limits::max()}; - auto maxx{std::numeric_limits::lowest()}; - auto maxy{std::numeric_limits::lowest()}; - - for (auto const &niweleta : m_document.niwelety) - { - for (auto const &straight : niweleta.straights) - { - minx = std::min({minx, straight.x1, straight.x2}); - maxx = std::max({maxx, straight.x1, straight.x2}); - miny = std::min({miny, straight.y1, straight.y2}); - maxy = std::max({maxy, straight.y1, straight.y2}); - } - } - - if (minx > maxx) - { - return; // nothing drawn yet, so there is nowhere to go - } - - auto &camera{editor_mode::get_camera()}; - auto const centre{plan_to_world((minx + maxx) * 0.5, (miny + maxy) * 0.5)}; - camera.Pos.x = centre.x; - camera.Pos.z = centre.z; - - // show the whole drawing with a little room around it - auto const span{std::max({maxx - minx, maxy - miny, 50.0})}; - Global.editor_ortho_extent = std::clamp(static_cast(span * 0.6), 5.0f, 20000.0f); -} diff --git a/application/planpanel.h b/application/planpanel.h index 1ebbf6cb..786fbab5 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -12,19 +12,27 @@ http://mozilla.org/MPL/2.0/. #include "application/uilayer.h" #include "editor/orthophoto.h" -#include "maj0sted/app/basket_draft.hpp" -#include "maj0sted/app/editor_document.hpp" -#include "maj0sted/app/straight_constraints.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/render.hpp" +#include "maj0sted/editor/solution.hpp" #include "maj0sted/io/scn_export.hpp" -#include "maj0sted/editor/editor.hpp" -#include "maj0sted/editor/ribbon.hpp" +#include #include -// track layout tool: straights laid down by hand on the scenery itself and the curves fitted into -// the gaps between them. the panel holds only the controls - the drawing happens in the viewport, -// which the tool puts into a top-down orthographic plan view. none of the geometry is computed -// here: maj0sted solves the whole document and the panel paints what comes back. +// track layout tool. the drawing happens in the viewport, which the tool puts into a top-down +// orthographic plan view; the panel holds only the controls. none of the geometry is computed here: +// maj0sted solves the whole document and the panel paints what comes back. +// +// a track is a chain of elements, and every one of them says outright what it is: prosta of so many +// metres, luk of radius R turning so far, krzywa przejsciowa easing to R over so many metres. there +// is no second language of "run on until you reach that line" - laying by clicking along the ground +// works out the corner at the click and writes the numbers down. +// +// what one track keeps against another is said as a relation, not redrawn by hand: an element may be +// held parallel to another track's element at a given miedzytorze, and the solver moves the whole +// track until it really is. class plan_panel : public ui_panel { @@ -35,131 +43,133 @@ class plan_panel : public ui_panel void render_contents() override; private: - // methods + using Document = maj0sted::editor::Document; + using Element = maj0sted::editor::Element; + using ElementId = maj0sted::editor::ElementId; + using Solution = maj0sted::editor::Solution; + using Track = maj0sted::editor::Track; + using TrackId = maj0sted::editor::TrackId; + using TurnoutId = maj0sted::editor::TurnoutId; + + // panels void render_toolbar(); - void render_draft(); - void render_gaps(); - void render_compound(maj0sted::editor::GapFit &Fit, std::size_t const Gap, bool &Dirty); - void fit_compound_to_guides(); - void clear_fit_guides(); + void render_elements(); + void render_turnouts(); + void render_turnout_type(maj0sted::editor::TurnoutType &Type_); + // the catalogue drawing of the chosen type, in its own window: the diverging path piece by + // piece, both tracks' rails, and the points a drawing is dimensioned from + void render_template_window(); + void render_diagnostics(); void render_storage(); void export_scn(); + void seed_catalogue(); void render_newmap_dialog(); void render_location_dialog(); - void start_map(bool const Georeferenced, double const Originx, double const Originy); + + // scene void handle_scene(); void draw_on_scene(); - void solve(); - void apply_constraints(); - maj0sted::editor::NiweletaSpec *current_niweleta(); - maj0sted::editor::NiweletaSpec const *current_niweleta() const; - void align_fits(); - bool fit_applied(std::size_t const Gap) const; - // independent anchored straights: each endpoint is its own handle - maj0sted::editor::StraightSpec const *handle_straight(std::size_t const Niw, std::size_t const Index) const; - void append_vertex(double const X, double const Y); - void drop_last_vertex(); - void delete_selected_straight(); - void delete_current_niweleta(); - void clear_selection(); - // hit tests (screen pixels). niw=-1 means "current niweleta only" - bool hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const; - bool hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const; - // nearest niweleta by its solved axis (curves included), for a forgiving double-click select - int nearest_niweleta_axis(ImVec2 const &Mouse, float const Tolerance) const; - // nearest station (arc length) on a through niweleta's axis to a plan point; for placing/moving switches - bool station_on_through(std::size_t const Through, double const Wx, double const Wy, double &OutStation) const; - // compound basket: draggable ends of entry/exit KP, free arcs and between-KP - bool hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const; - void drag_compound_station(double const X, double const Y); - // arc-first draft: continue from a straight end, click appends luk/KP by length - void begin_draft(); - void discard_draft(); - void start_draft_from_end(std::size_t const Str, int const End); - void append_draft_at(double const X, double const Y); - void undo_draft_fragment(); - bool finish_draft(); - bool reopen_draft_from_gap(std::size_t const Gap); - void rebuild_draft_preview(); - bool draft_tip(double &OutX, double &OutY) const; - bool draft_end_pose(double &OutX, double &OutY, double &OutAz) const; - bool hit_draft_station(ImVec2 const &Mouse, int &OutKind, int &OutArc) const; - void drag_draft_station(double const X, double const Y); - bool anchor_draft_to_gap(std::size_t const Gap); - // switches (rozjazdy): a branch leaves a through niweleta without splitting it. clicking the - // through track drops a switch there and starts a branch niweleta pinned to its frog - void add_switch_on(std::size_t const Through, double const Wx, double const Wy); - void render_switches(); void go_to_plan(); + // document + void solve(); + void start_map(bool const Georeferenced, double const Originx, double const Originy); + Track *current_track(); + Track const *current_track() const; + void new_track(); + void delete_current_track(); + void clear_selection(); + // carries the track on to (X,Y). the track ends where it was last clicked, so that end is the + // corner: it gets rounded with an arc of m_corner_radius, the straight behind it gives up the + // tangent, and the new straight runs out to (X,Y) exactly. all of it lands in the document as + // ordinary elements with ordinary lengths + void append_point_to(double const X, double const Y); + // takes back one click: the trailing straight, its corner arc, and the tangent the arc had + // taken off the straight before it - which leaves the track ending on the click before + void drop_last_vertex(); + // appends one element to the end of the current track, exactly as asked for + void append_element(maj0sted::editor::Kind const Kind); + void drop_last_element(); + void delete_selected_element(); + void place_turnout_at(TrackId const On, double const Wx, double const Wy); + // re-lays the edited track along the lines its straights sat on before the change, so a new + // radius (or a new transition length) is absorbed by the corner it belongs to and nothing + // further down the track moves. false, with a reason, when it cannot be laid that way + bool refit_current_track(std::string &Why); + // swings the grabbed straight onto the line through its far end and (X,Y), then re-fits the + // arcs on either side of it. leaves the track untouched when that cannot be laid + void drag_straight_to(double const X, double const Y); + + // hit tests, in screen pixels + // the end of a straight of the edited track: OutIndex is its element index, OutEnd 0 for the + // end it starts on and 1 for the end it runs out to + bool hit_straight_end(ImVec2 const &Mouse, std::size_t &OutIndex, int &OutEnd) const; + bool hit_element(ImVec2 const &Mouse, ElementId &OutElement, TrackId &OutTrack) const; + bool hit_turnout(ImVec2 const &Mouse, TurnoutId &OutTurnout) const; + // nearest station along a solved track's axis, for placing and sliding turnouts + bool station_on(TrackId const Track, double const Wx, double const Wy, double &OutStation) const; + TrackId nearest_track_axis(ImVec2 const &Mouse, float const Tolerance) const; + // members - maj0sted::app::EditorDocument m_document; - std::vector m_solved; - std::vector m_junctions; - std::size_t m_niweleta{0}; + Document m_document; + Solution m_solution; + std::vector m_rails; - // switch placement: 0 idle, 1 waiting for a click on the through track - int m_pick_switch{0}; - int m_sw_side{0}; // 0 left, 1 right - double m_sw_crossing{9.0}; // skos 1:n - double m_sw_radius{190.0}; // internal arc radius (metres) - double m_sw_length{0.0}; // catalogue length from the chosen template (0 = just the curve) - double m_sw_pre_blade{0.0}; // odcinek przediglicowy from the template, metres - double m_sw_blade_angle{0.0}; // kąt nagięcia iglicy (beta) from the template, radians - double m_sw_blade_length{0.0};// the straight blade from the template, metres - int m_drag_junction{-1}; // index of the switch being slid along its through track, or -1 + TrackId m_track{}; + ElementId m_sel_element{}; + TurnoutId m_sel_turnout{}; - int m_sel_straight{-1}; - int m_sel_end{-1}; - bool m_dragging{false}; - // captured at drag start so Shift keeps the angle while the length changes - double m_drag_fixed_x{0.0}; - double m_drag_fixed_y{0.0}; - double m_drag_ux{1.0}; - double m_drag_uy{0.0}; + // a press on the ground does whatever is under it: picks up what is drawn there, or carries the + // current track on to there. every transient errand (placing a turnout, picking the element to + // run parallel to) is started from its own button, and Esc drops back out of it + bool m_dragging_turnout{false}; - // compound station drag: kind 0=entry KP end, 1=free-arc end, 2=between-KP end, 3=exit KP start - bool m_dragging_cmp{false}; - int m_cmp_gap{-1}; - int m_cmp_kind{-1}; - int m_cmp_arc{-1}; + // grabbing a straight by one of its ends: which one, and the far end it swings about. the far + // end is captured once, at the grab, because re-fitting the arc behind it moves it + bool m_dragging_straight{false}; + std::size_t m_drag_index{0}; + int m_drag_end{0}; + double m_pivot_x{0.0}; + double m_pivot_y{0.0}; - bool m_drawing{true}; + // an empty track needs two clicks before there is anything to carry on: the first only marks + // where it starts. after that the track always ends on the last click, which is the corner the + // next one is rounded at bool m_pending{false}; - double m_pendingx{0.0}; - double m_pendingy{0.0}; + double m_startx{0.0}; + double m_starty{0.0}; - // 0 = idle, 1 = picking parallel reference, 2 = picking skew reference - int m_pick_rel{0}; - // after a reference is chosen, ask for the numeric parameter - bool m_ask_rel_param{false}; - double m_ask_rel_value{4.5}; - // compound: gap being edited; pick mode accumulates guide points, then Fit runs the solver - int m_sel_gap{-1}; - bool m_pick_compound_point{false}; - struct FitGuide - { - double x{0.0}; - double y{0.0}; - }; - std::vector m_fit_guides; + // 0 idle, 1 waiting for a click on the through track + int m_pick_turnout{0}; + // 0 idle, 1 waiting for the element the selected one is to be held parallel to + int m_pick_parallel{0}; - maj0sted::app::BasketDraft m_draft; - std::vector m_draft_polys; - // 0 idle, 1 pick straight end to continue from - int m_draft_place{0}; - // next click appends: 0 = luk, 1 = KP (wej/między), 2 = KP wyjście (kończy: prosta + kotwica) - int m_draft_append_kind{0}; - double m_draft_r{300.0}; - double m_draft_finish_straight{100.0}; - // 0 idle, 1 click a straight to choose the gap to anchor into - int m_anchor_pick{0}; - bool m_dragging_draft{false}; - int m_draft_kind{-1}; - int m_draft_arc{-1}; + // what the next appended element is made of + double m_new_radius{300.0}; + double m_new_length{100.0}; + int m_new_hand{1}; + // the radius a corner is rounded with when laying by clicking + double m_corner_radius{300.0}; + // miedzytorze a new hold is given, metres + double m_new_offset{4.75}; + + int m_turnout_type{0}; + // which piece of the chosen type's geometry list is open for editing; -1 = none + int m_sel_piece{-1}; + + // the template drawing. a switch is thirty metres long and a metre and a half wide, so like + // every catalogue drawing this one is stretched across the track - the ratio is said on screen + bool m_show_template{false}; + float m_tpl_zoom{1.0f}; + float m_tpl_pan{0.0f}; // metres off centre, along the through track + float m_tpl_exagg{6.0f}; // przewyzszenie poprzeczne + bool m_tpl_rails{true}; + bool m_tpl_marks{true}; + // lukowanie in the drawing: the radius the tor zasadniczy is bent to, and which way + double m_tpl_bend_radius{0.0}; + int m_tpl_bend_hand{0}; char m_namebuf[128]{}; - std::string m_status; char m_path[256]{"editor/plan.m0s"}; char m_scn_path[256]{"scenery/plan_export.scn"}; diff --git a/editor/orthophoto.cpp b/editor/orthophoto.cpp index 7fc09ef1..01a95d62 100644 --- a/editor/orthophoto.cpp +++ b/editor/orthophoto.cpp @@ -17,6 +17,8 @@ http://mozilla.org/MPL/2.0/. #include #include #pragma comment(lib, "winhttp.lib") +#else +#include #endif namespace editor @@ -127,9 +129,64 @@ bool http_get(std::string const &Url, std::vector &Bytes, std::str #else -bool http_get(std::string const &, std::vector &, std::string &) +// libcurl's write callback: appends one chunk of the body to the caller's buffer +std::size_t append_body(char const *Data, std::size_t const Size, std::size_t const Count, void *Userdata) { - return false; // no native transport on this platform yet + auto &bytes = *static_cast *>(Userdata); + auto const length = Size * Count; + bytes.insert(bytes.end(), Data, Data + length); + return length; +} + +// plain HTTPS GET, the same shape as the WinHttp one above: one request, one answer, nothing kept +// between calls. libcurl wants its global init done once before any handle exists, and this is the +// only place in the program that uses it, so a function-local static does the job thread-safely +bool http_get(std::string const &Url, std::vector &Bytes, std::string &Contenttype) +{ + static bool const initialised = (curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK); + if (false == initialised) + { + return false; + } + + CURL *handle = curl_easy_init(); + if (handle == nullptr) + { + return false; + } + + Bytes.clear(); + curl_easy_setopt(handle, CURLOPT_URL, Url.c_str()); + curl_easy_setopt(handle, CURLOPT_USERAGENT, kUserAgent); + curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, append_body); + curl_easy_setopt(handle, CURLOPT_WRITEDATA, &Bytes); + // a stuck transfer must not hold a worker forever; the caller retries + curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT, 10L); + curl_easy_setopt(handle, CURLOPT_TIMEOUT, 30L); + // workers run on their own threads: keep libcurl off signals, which are process-wide + curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L); + + bool ok = (curl_easy_perform(handle) == CURLE_OK); + if (true == ok) + { + long status = 0; + curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status); + char const *type = nullptr; + if ((curl_easy_getinfo(handle, CURLINFO_CONTENT_TYPE, &type) == CURLE_OK) && (type != nullptr)) + { + Contenttype.assign(type); + } + ok = (status == 200) && (false == Bytes.empty()); + } + if (false == ok) + { + Bytes.clear(); + } + + curl_easy_cleanup(handle); + + return ok; } #endif diff --git a/editor/turnouts.cpp b/editor/turnouts.cpp index 9cadad2b..eb442d8f 100644 --- a/editor/turnouts.cpp +++ b/editor/turnouts.cpp @@ -10,27 +10,74 @@ http://mozilla.org/MPL/2.0/. #include "stdafx.h" #include "editor/turnouts.h" +#include "maj0sted/domain/geometry/turnout.hpp" + +#include + namespace editor { +namespace +{ + +// the blade ends where the gap to the stock rail takes the przekladka in its heel. +// this is what fixes the length of a curved blade, so it is said once here. +// TODO: dlugosci iglic z tablic Id-1, gdy beda pod reka +double const heel_offset{0.136}; // metres +// 49E1 railhead. the blade is planed from its tip until it is this wide +double const railtop_width{0.070}; + +// a turnout with an iglica lukowa styczna do opornicy o scietym ostrzu (Koc, rys. 5.9). +// the curve is theoretically tangent to the opornica at PR, but the blade cannot begin +// there - it begins where there is metal enough, at the ostrze, and its planed nose (d) +// runs on to the point where the rail is u thick. what lies between PR and the ostrze is +// the odcinek przediglicowy: the curve is already there, the vehicle is still on the +// through track. Koc's tablica 5.1 gives a, d and u per type, and they obey +// a + d = the run to a gap of u, taken on the blade's curve R + s/2 +// so a is derived from u and d here rather than copied. The drawing dimensions it on the +// rail and a piece length is on the axis, so what goes in is the run scaled by R/(R+s/2); +// against the table itself that is a tenth of a millimetre at 1200-1:18,5, six at 190-1:9. +// +// b is the rear tangent from the drawing: a rozjazd podstawowy (krzyzownica lukowa) has +// b = R*tg(alfa/2) and no straight at all, while a straight crossing carries the arc out +// on a prosta krzyzownicowa of b - R*tg(alfa/2). Passing 0 asks for the podstawowy. +turnout_preset curved_tangent(std::string name, double crossing_n, double radius, double tip_thickness, double nose, double back_tangent = 0.0) +{ + auto const alfa{std::atan(1.0 / crossing_n)}; + auto const tangent{radius * std::tan(0.5 * alfa)}; + auto const b{back_tangent > 0.0 ? back_tangent : tangent}; + auto const lead{maj0sted::domain::blade_run_to_offset(radius, 0.0, tip_thickness) - nose}; + auto const blade{maj0sted::domain::blade_run_to_offset(radius, 0.0, heel_offset) - lead}; + + turnout_preset preset{std::move(name), crossing_n, tangent + b, tip_thickness, nose, railtop_width, {}}; + preset.pieces.push_back({0, lead, radius, radius, 0.0}); + preset.pieces.push_back({1, blade, radius, radius, 0.0}); + preset.pieces.push_back({2, radius * alfa - lead - blade, radius, radius, 0.0}); + if (b - tangent > 1e-6) + { + preset.pieces.push_back({3, b - tangent, 0.0, 0.0, 0.0}); + } + return preset; +} + +} // namespace + std::vector const &turnout_presets() { - // the standard PKP rozjazdy zwyczajne (49E1 / 60E1). crossing mark, radius and catalogue length - // (PR→KR along the through track). the crossing angles check out as atan(1/n); the lengths follow - // the type tables (metres). the diverging leaves not tangent but at the blade angle (beta) after a - // straight pre-blade lead, then arcs by (alfa - beta) to the frog. - // - // blade_angle, pre_blade and blade_length are NOT yet from Id-1 - left at 0, which lays a tangent - // arc straight from PR. Fill them per type when the catalogue figures are in; the geometry - // (domain::lay_turnout) already lays the pre-blade lead, the blade break and the shortened arc. + // the standard PKP rozjazdy zwyczajne. u and d come from Koc, tablica 5.1 (iglice + // styczne do opornicy o scietym ostrzu); the rear tangents from the catalogue drawings: + // rys. 5.13 gives Rz 49E1-190-1:9 as 27138 = 10523 + 16615 (krzyzownica prosta) and + // rys. 5.15 gives Rz 49E1-300-1:9 as 33230 = 16615 + 16615 (lukowa, rozjazd podstawowy). + // krzyzownice proste sa w 49E1-190-1:9 i 49E1-500-1:14, lukowe w 300-1:9, 500-1:12 i + // 1200-1:18,5. TODO: 1:7,5 R190 i 1:14 R500 czekaja na wlasne u/d i styczne z Id-1 static std::vector const presets{ - {"Rz 1:7,5 R190", 7.5, 190.0, 25.222, 0.0, 0.0, 0.0}, - {"Rz 1:9 R190", 9.0, 190.0, 27.138, 0.0, 0.0, 0.0}, - {"Rz 1:9 R300", 9.0, 300.0, 33.230, 0.0, 0.0, 0.0}, - {"Rz 1:12 R500", 12.0, 500.0, 41.594, 0.0, 0.0, 0.0}, - {"Rz 1:14 R500", 14.0, 500.0, 42.371, 0.0, 0.0, 0.0}, - {"Rz 1:14 R760", 14.0, 760.0, 54.216, 0.0, 0.0, 0.0}, - {"Rz 1:18,5 R1200", 18.5, 1200.0, 64.818, 0.0, 0.0, 0.0}}; + curved_tangent("Rz 1:7,5 R190", 7.5, 190.0, 0.0049, 0.1250), + curved_tangent("Rz 1:9 R190", 9.0, 190.0, 0.0049, 0.1250, 16.615), + curved_tangent("Rz 1:9 R300", 9.0, 300.0, 0.0044, 0.1250), + curved_tangent("Rz 1:12 R500", 12.0, 500.0, 0.0053, 0.1250), + curved_tangent("Rz 1:14 R500", 14.0, 500.0, 0.0053, 0.1250, 24.544), + curved_tangent("Rz 1:14 R760", 14.0, 760.0, 0.0050, 0.1250), + curved_tangent("Rz 1:18,5 R1200", 18.5, 1200.0, 0.0050, 0.1304)}; return presets; } diff --git a/editor/turnouts.h b/editor/turnouts.h index 357430e3..91b042f8 100644 --- a/editor/turnouts.h +++ b/editor/turnouts.h @@ -15,23 +15,33 @@ http://mozilla.org/MPL/2.0/. namespace editor { -// a standard turnout template: its designation and the defining pair a catalogue entry is built on - -// the crossing mark 1:n (skos) and the radius of the diverging arc. the actual length the switch -// works out to is computed geometrically from these (arc = R * atan(1/n)), shown next to it; the -// exact catalogue length can be pinned later if the Id-1 tables call for a different figure +// one piece of a preset's diverging path, in catalogue order from PR. mirrors +// maj0sted::editor::TurnoutPieceSpec so this header stays free of the library +struct turnout_preset_piece +{ + int part; // 0 przediglicowy, 1 iglica, 2 luk, 3 krzyzownicowa + double length; // metres, always explicit - nothing here is fitted + double radius_start; // metres; 0 = straight + double radius_end; // metres; different from radius_start = clothoid + double turn_in; // kat nagiecia iglicy (beta), radians; 0 = tangent to the stock rail +}; + +// a standard turnout template: its designation, the catalogue numbers the laid path is +// checked against (skos 1:n and the length PR->KR along the through track), the pieces it +// is laid from, and the two blade dimensions that place its real, cut tip struct turnout_preset { std::string name; - double crossing_n; // skos 1:n - double radius; // metres - double length; // catalogue length PR->KR along the through track, metres - double blade_angle; // kąt nagięcia iglicy (beta), radians; the diverging leaves the straight here - double pre_blade; // odcinek przediglicowy: straight lead PR -> blade tip, metres - double blade_length; // the straight blade itself, tip -> heel where the arc starts, metres + double crossing_n; // skos 1:n + double length; // catalogue length: a + b, the two tangent legs of the drawing, metres + double tip_thickness; // grubosc iglicy na koncu zestruganego dzioba (u), metres + double nose; // dziob iglicy: zestrugana dlugosc od ostrza (d), metres + double railtop_width; // szerokosc glowki szyny (w), metres + std::vector pieces; }; -// typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard; -// confirm against Id-1 before treating the derived length as the catalogue length +// typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised +// standard; confirm the piece lengths and the blade against Id-1 std::vector const &turnout_presets(); } // namespace editor diff --git a/maj0sted/CMakeLists.txt b/maj0sted/CMakeLists.txt index 8feb4546..accc4a5b 100644 --- a/maj0sted/CMakeLists.txt +++ b/maj0sted/CMakeLists.txt @@ -2,14 +2,13 @@ cmake_minimum_required(VERSION 3.20) project(maj0sted VERSION 0.1.0 - DESCRIPTION "Domain-Driven Design library for a railway track-layout editor" + DESCRIPTION "Model and solver for a railway track-layout editor" LANGUAGES CXX) # ---------------------------------------------------------------------------- # Options # ---------------------------------------------------------------------------- option(MAJ0STED_BUILD_TESTS "Build unit tests" ON) -option(MAJ0STED_BUILD_EXAMPLES "Build usage examples" ON) option(MAJ0STED_WARNINGS_AS_ERRORS "Treat warnings as errors" OFF) # Only drive tests/examples/install defaults when built on its own, not when @@ -24,26 +23,17 @@ endif() # The static library # ---------------------------------------------------------------------------- add_library(maj0sted STATIC - src/domain/map_project.cpp - src/domain/horizontal_alignment.cpp - src/domain/vertical_alignment.cpp - src/domain/niweleta.cpp - src/domain/parallelism_service.cpp - src/domain/fitting_service.cpp - src/domain/gap_fitter.cpp src/domain/turnout.cpp - src/io/project_serializer.cpp - src/io/svg_writer.cpp - src/io/scn_export.cpp - src/render/plan_sampler.cpp - src/render/rail_renderer.cpp - src/editor/solve.cpp - src/editor/editor.cpp - src/editor/ribbon.cpp + src/editor/model.cpp + src/editor/solution.cpp + src/editor/chain.cpp + src/editor/sketch.cpp + src/editor/layout.cpp + src/editor/render.cpp src/editor/tile_cache.cpp - src/app/editor_document.cpp - src/app/straight_constraints.cpp - src/app/basket_draft.cpp + src/render/rail_renderer.cpp + src/io/document_io.cpp + src/io/scn_export.cpp ) add_library(maj0sted::maj0sted ALIAS maj0sted) @@ -77,13 +67,6 @@ if(MAJ0STED_BUILD_TESTS AND MAJ0STED_STANDALONE) add_subdirectory(tests) endif() -# ---------------------------------------------------------------------------- -# Examples -# ---------------------------------------------------------------------------- -if(MAJ0STED_BUILD_EXAMPLES AND MAJ0STED_STANDALONE) - add_subdirectory(examples) -endif() - # ---------------------------------------------------------------------------- # Install / package # ---------------------------------------------------------------------------- diff --git a/maj0sted/examples/CMakeLists.txt b/maj0sted/examples/CMakeLists.txt deleted file mode 100644 index 227f9e31..00000000 --- a/maj0sted/examples/CMakeLists.txt +++ /dev/null @@ -1,5 +0,0 @@ -add_executable(maj0sted_example basic_editor.cpp) -target_link_libraries(maj0sted_example PRIVATE maj0sted::maj0sted) - -add_executable(maj0sted_plan_to_svg plan_to_svg.cpp) -target_link_libraries(maj0sted_plan_to_svg PRIVATE maj0sted::maj0sted) diff --git a/maj0sted/examples/basic_editor.cpp b/maj0sted/examples/basic_editor.cpp deleted file mode 100644 index 00e4dd84..00000000 --- a/maj0sted/examples/basic_editor.cpp +++ /dev/null @@ -1,20 +0,0 @@ -// Minimal walkthrough: create a map project and inspect its CRS. - -#include - -#include "maj0sted/maj0sted.hpp" - -using namespace maj0sted::domain; - -int main() { - std::printf("maj0sted %s\n\n", MAJ0STED_VERSION_STRING); - - const MapProject project; // defaults to EPSG:2180 - std::printf("Map project CRS: EPSG:%d\n", project.crs().epsg()); - - const CartesianPosition position{500000.0, 300000.0}; - std::printf("Sample position: x=%.1f m, y=%.1f m\n", position.x(), - position.y()); - - return 0; -} diff --git a/maj0sted/examples/plan_to_svg.cpp b/maj0sted/examples/plan_to_svg.cpp deleted file mode 100644 index 063ce687..00000000 --- a/maj0sted/examples/plan_to_svg.cpp +++ /dev/null @@ -1,58 +0,0 @@ -// A simple visual check tool: build a niweleta, fit a compound curve with -// transition curves between the two straights, apply it, and write the plan to -// an SVG file you can open in a browser. -// -// Usage: plan_to_svg [output.svg] (default: plan.svg) -// -// The reusable pieces are render::sample() (geometry -> polylines) and -// io::to_svg(). A different GUI would call render::sample() and paint itself. - -#include -#include -#include - -#include "maj0sted/io/svg_writer.hpp" -#include "maj0sted/maj0sted.hpp" -#include "maj0sted/render/plan_sampler.hpp" - -using namespace maj0sted::domain; - -int main(int argc, char** argv) { - MapProject project; - - // Two straights forming a 90-degree corner. - Niweleta niweleta{NiweletaId{1}, "Demo"}; - const StraightId entry = - niweleta.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{300.0, 0.0}); - const StraightId exit = niweleta.add_straight( - CartesianPosition{300.0, 0.0}, CartesianPosition{300.0, 300.0}, - JointContinuity::AzimuthBreak); - - // A compound curve R120 -> R80 with clothoids at the ends and between arcs. - const CompoundFitParameters params{ - .entry = entry, - .exit = exit, - .entry_transition = Length::from_metres(30.0), - .exit_transition = Length::from_metres(30.0), - .arcs = {CompoundArc{Radius::from_metres(120.0), Length::from_metres(60.0), - Length::from_metres(25.0)}, - CompoundArc{Radius::from_metres(80.0)}}}; - - const FitResult fit = FittingService::fit_compound(niweleta, params); - niweleta.apply_fit(fit); - project.add_niweleta(std::move(niweleta)); - - const maj0sted::render::Scene scene = maj0sted::render::sample(project); - const std::string svg = maj0sted::io::to_svg(scene); - - const std::string path = (argc > 1) ? argv[1] : "plan.svg"; - std::ofstream out(path); - if (!out) { - std::fprintf(stderr, "cannot write %s\n", path.c_str()); - return 1; - } - out << svg; - std::printf("wrote %s (%zu polylines) — open it in a browser\n", path.c_str(), - scene.polylines.size()); - return 0; -} diff --git a/maj0sted/include/maj0sted/app/basket_draft.hpp b/maj0sted/include/maj0sted/app/basket_draft.hpp deleted file mode 100644 index 6214bfbc..00000000 --- a/maj0sted/include/maj0sted/app/basket_draft.hpp +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include - -#include "maj0sted/editor/editor.hpp" -#include "maj0sted/editor/ribbon.hpp" -#include "maj0sted/editor/solve.hpp" - -namespace maj0sted::app { - -struct BasketDraft { - bool active{false}; - double x0{0.0}; - double y0{0.0}; - double az0{0.0}; - int dir{1}; - double entry_t{0.0}; - double exit_t{0.0}; - std::vector arcs; - double preview_next_r{300.0}; - int attach_str{-1}; - int attach_end{-1}; - int reedit_gap{-1}; - maj0sted::editor::GapFit reedit_backup{}; -}; - -[[nodiscard]] maj0sted::editor::RibbonRequest draft_to_ribbon(const BasketDraft& draft); - -[[nodiscard]] std::vector preview_basket_draft( - BasketDraft& draft, double fallback_next_r = 300.0); - -void start_basket_draft_from_end(BasketDraft& draft, - const maj0sted::editor::StraightSpec& straight, - int straight_index, int end, double default_r = 300.0); - -[[nodiscard]] bool reopen_basket_draft(BasketDraft& draft, - maj0sted::editor::NiweletaSpec& niweleta, - std::size_t gap, - const maj0sted::editor::NiweletaPolys* solved); - -void discard_basket_draft(BasketDraft& draft, maj0sted::editor::NiweletaSpec* niweleta); - -[[nodiscard]] bool anchor_basket_draft(BasketDraft& draft, - maj0sted::editor::NiweletaSpec& niweleta, - std::size_t gap); - -struct FinishBasketDraftResult { - bool ok{false}; - std::string status; -}; - -[[nodiscard]] FinishBasketDraftResult finish_basket_draft( - BasketDraft& draft, maj0sted::editor::NiweletaSpec& niweleta, - std::vector& all_niwelety, int niweleta_index, - double finish_straight_m); - -} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/app/editor_document.hpp b/maj0sted/include/maj0sted/app/editor_document.hpp deleted file mode 100644 index d24aad3a..00000000 --- a/maj0sted/include/maj0sted/app/editor_document.hpp +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "maj0sted/domain/map_project.hpp" -#include "maj0sted/editor/editor.hpp" - -namespace maj0sted::app { - -struct EditorDocument { - std::vector niwelety; - std::vector junctions; - double view_x{0.0}; - double view_y{0.0}; - double view_extent{0.0}; - bool origin_set{false}; - bool georeferenced{false}; - double origin_x{0.0}; - double origin_y{0.0}; -}; - -[[nodiscard]] maj0sted::domain::MapProject to_map_project(const EditorDocument& document); -[[nodiscard]] EditorDocument from_map_project(const maj0sted::domain::MapProject& project); -[[nodiscard]] std::string serialize_document(const EditorDocument& document); -[[nodiscard]] EditorDocument deserialize_document(const std::string& text); -[[nodiscard]] std::string default_project_path(); -bool save_project(const EditorDocument& document, - const std::string& path = default_project_path()); -[[nodiscard]] std::optional load_project( - const std::string& path = default_project_path()); - -} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/app/straight_constraints.hpp b/maj0sted/include/maj0sted/app/straight_constraints.hpp deleted file mode 100644 index ddf2659f..00000000 --- a/maj0sted/include/maj0sted/app/straight_constraints.hpp +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/editor/editor.hpp" - -namespace maj0sted::app { - -void apply_straight_constraints(std::vector& niwelety); -void align_gap_fits(maj0sted::editor::NiweletaSpec& niweleta); - -} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp b/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp deleted file mode 100644 index 3d0e1b87..00000000 --- a/maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include -#include - -#include "maj0sted/domain/value_objects/length.hpp" -#include "maj0sted/domain/value_objects/radius.hpp" -#include "maj0sted/domain/value_objects/straight_id.hpp" - -namespace maj0sted::domain { - -/// Input parameters for fitting a curve between two straights of one niweleta. -/// -/// These map directly onto a GUI form: the user picks the entry and exit -/// straights, types a radius, and optionally a (symmetric) transition-curve -/// length. The turn direction is NOT an input — it follows from the geometry of -/// the two straights (their deflection angle). -/// -/// * transition_length absent -> a plain circular arc is fitted; -/// * transition_length present -> a symmetric klotoida-arc-klotoida is fitted, -/// with that length on both ends. -struct FitParameters { - StraightId entry{}; ///< pierwsza (wejściowa) prosta - StraightId exit{}; ///< druga (wyjściowa) prosta - Radius radius; ///< promień łuku kołowego - std::optional transition_length{}; ///< symetryczna klotoida; brak = sam łuk -}; - -/// One arc of a compound curve. -struct CompoundArc { - Radius radius; ///< promień łuku - std::optional length{}; ///< długość łuku; wymagana OPRÓCZ ostatniego - std::optional transition_to_next{}; ///< klotoida do następnego łuku (ignorowana dla ostatniego) -}; - -/// Input parameters for fitting a compound curve — a sequence of one or more -/// circular arcs (of different neighbouring radii, same direction) with optional -/// transition curves (klotoidy) between the arcs and at the ends — between two -/// straights. This single shape covers both a "łuk + klotoidy" (one arc eased by -/// symmetric clothoids) and a "łuk koszowy" (two or more arcs). -/// -/// Both straight lines stay put (position and azimuth). Each arc except the last -/// carries its length, which fixes its central angle (`δ = L / R`); the last (or -/// only) arc takes whatever deflection remains after the other arcs and every -/// transition curve. The two straights are re-trimmed in length so the whole -/// chain closes. -struct CompoundFitParameters { - StraightId entry{}; ///< pierwsza (wejściowa) prosta - StraightId exit{}; ///< druga (wyjściowa) prosta - std::optional entry_transition{}; ///< klotoida prosta -> pierwszy łuk - std::optional exit_transition{}; ///< klotoida ostatni łuk -> prosta - std::vector arcs{}; ///< >= 1 łuk; sąsiednie promienie różne -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp b/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp deleted file mode 100644 index a4d541a5..00000000 --- a/maj0sted/include/maj0sted/domain/fitting/fit_result.hpp +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/domain/geometry/plan_element.hpp" -#include "maj0sted/domain/value_objects/cartesian_position.hpp" - -namespace maj0sted::domain { - -/// Result of fitting a curve between two straights. -/// -/// The two straights keep their identity but are trimmed back to the tangent -/// points; @c curve is the klotoida?/łuk/klotoida? to splice between them. The -/// caller (or an aggregate command) replaces the original entry/exit straights -/// with @c entry, then @c curve, then @c exit. -struct FitResult { - Straight entry; ///< trimmed entry straight (ends at tangent_in) - std::vector curve; ///< klotoida? / łuk / klotoida? - Straight exit; ///< trimmed exit straight (starts at tangent_out) - CartesianPosition tangent_in; ///< entry -> curve tangent point - CartesianPosition tangent_out; ///< curve -> exit tangent point - TurnDirection direction; ///< derived turn direction -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp b/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp deleted file mode 100644 index 8d1f97fe..00000000 --- a/maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/domain/fitting/fit_parameters.hpp" -#include "maj0sted/domain/fitting/fit_result.hpp" -#include "maj0sted/domain/niweleta.hpp" -#include "maj0sted/domain/value_objects/length.hpp" -#include "maj0sted/domain/value_objects/radius.hpp" - -namespace maj0sted::domain { - -/// Domain Service: fits a curve between two straights of a single niweleta. -class FittingService { -public: - // --- Overloads that fit two standalone straights (no niweleta) --- - - /// Rounds the corner between two straight lines with an arc (and optional - /// symmetric transition curves). Works for any deflection strictly between - /// 0° and 180°. For a 180° reversal use fit_uturn instead. - static FitResult fit_between_straights( - const Straight& entry, const Straight& exit, Radius radius, - std::optional transition = std::nullopt); - - /// Fits a compound curve between two standalone straight lines. - static FitResult fit_compound(const Straight& entry, const Straight& exit, - const CompoundFitParameters& params); - - /// Connects two (anti-)parallel straights with a 180° reversal ("nawrót"). - /// The radius is derived from the perpendicular offset between the two lines - /// (a plain arc uses R = offset / 2). Optional symmetric transition curves - /// are solved for numerically. - /// @throws std::invalid_argument if the straights are not anti-parallel or - /// their offset is zero. - static FitResult fit_uturn(const Straight& entry, const Straight& exit, - std::optional transition = std::nullopt); - - /// Fits an S-curve made of two tangent circular arcs with the same radius - /// and opposite turn directions. Supports non-parallel straights and - /// same-direction parallel straights with a non-zero lateral offset. - /// Tangent points are required to lie on both finite straight segments. - /// @throws std::invalid_argument if no equal-radius reverse curve fits. - static FitResult fit_reverse(const Straight& entry, const Straight& exit, - Radius radius); - - /// Computes the curve (and the trimmed straights) that connects the two - /// straights named in @p params, both belonging to @p niweleta. - /// - /// @throws std::invalid_argument if a straight is missing, the two are the - /// same, the straights are parallel/collinear, or the transition - /// curves are too long for the chosen radius and deflection. - static FitResult fit_between_straights(const Niweleta& niweleta, - const FitParameters& params); - - /// Computes a compound curve ("łuk koszowy") — a chain of N arcs of - /// different neighbouring radii, same direction — between the two straights - /// named in @p params. Both straight lines stay fixed and are re-trimmed in - /// length; each arc's length sets its central angle, and the last arc takes - /// the remaining deflection. - /// - /// @throws std::invalid_argument if a straight is missing, the two are the - /// same, there are no arcs, neighbouring radii are equal, a non-last - /// arc has no length, the straights are parallel/collinear, or the - /// split lengths exceed the total deflection. - static FitResult fit_compound(const Niweleta& niweleta, - const CompoundFitParameters& params); -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp b/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp deleted file mode 100644 index eb76e5cc..00000000 --- a/maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp +++ /dev/null @@ -1,74 +0,0 @@ -#pragma once - -#include -#include - -#include "maj0sted/domain/fitting/fit_result.hpp" -#include "maj0sted/domain/geometry/plan_element.hpp" // Straight -#include "maj0sted/domain/value_objects/length.hpp" -#include "maj0sted/domain/value_objects/radius.hpp" - -namespace maj0sted::domain { - -/// How the gap between two independent, finite straights should be connected. -/// A pure domain concept: the editor's numeric fit modes map onto it, but this -/// enum carries no UI knowledge. A plain circular arc (Arc) and a compound curve -/// (Compound) are the only kinds — a "łuk + klotoidy" is just a Compound with a -/// single arc eased by symmetric clothoids. -enum class GapConnectionMode { Arc, Compound }; - -/// One arc of a compound connection. -struct CompoundGapArc { - double radius{0.0}; ///< arc radius (metres) - double length{0.0}; ///< fixed arc length (metres); ignored for the last arc - double transition_to_next{0.0}; ///< clothoid to the next arc (metres, 0 = none) -}; - -/// Extra parameters for a compound connection: entry/exit easing clothoids plus a -/// sequence of one or more arcs. A single arc yields "łuk + klotoidy"; two or -/// more yield a "łuk koszowy". The last arc absorbs the remaining deflection, so -/// its @c length / @c transition_to_next are ignored. -struct CompoundGapParameters { - double entry_transition{0.0}; - double exit_transition{0.0}; - std::vector arcs{}; -}; - -/// A request to connect two finite straights with a curve. -struct GapConnectionRequest { - GapConnectionMode mode{GapConnectionMode::Arc}; - double radius{0.0}; ///< plain-arc radius (metres) - CompoundGapParameters compound{}; ///< compound only -}; - -/// The connection actually produced. -struct GapConnection { - FitResult fit; ///< trimmed straights + spliced curve - double applied_radius{0.0}; ///< radius truly used (may be reduced to fit) -}; - -/// Domain policy connecting two independent, finite straights for the editor. -/// -/// This is the single source of truth for the fitting business rules: -/// * a 180° reversal is detected from the geometry and handled as a nawrót, -/// never surfaced as a user-chosen mode; -/// * a plain arc uses the requested radius verbatim whenever it fits; the -/// radius is NEVER enlarged. A tighter-than-needed arc fits by extending the -/// straights toward the corner; only a radius too large to land on the -/// straights is reduced, to the largest one that fits. No other curve type is -/// ever substituted; -/// * a compound curve (one arc eased by clothoids, or a multi-arc basket) is -/// built as specified; -/// * a tangent point may extend a straight toward the corner, but must not cross -/// behind its outer, anchored end. -/// -/// Returns nullopt when nothing admissible fits (the caller then leaves the gap -/// unconnected). Never throws. -class GapFitter { -public: - [[nodiscard]] static std::optional connect( - const Straight& entry, const Straight& exit, - const GapConnectionRequest& request); -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp b/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp deleted file mode 100644 index fe1e08c8..00000000 --- a/maj0sted/include/maj0sted/domain/geometry/horizontal_alignment.hpp +++ /dev/null @@ -1,60 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "maj0sted/domain/geometry/plan_element.hpp" -#include "maj0sted/domain/value_objects/length.hpp" - -namespace maj0sted::domain { - -/// How two consecutive plan elements meet. -enum class JointContinuity { - /// Uniform azimuth ("styczność") — the rule for every joint. - Tangent, - /// An explicitly marked azimuth discontinuity ("załamanie"). Allowed only - /// between two straights that touch at different azimuths. - AzimuthBreak, -}; - -/// "Niweleta w planie": a directional (ordered) list of plan elements. -/// -/// Straights are anchored in XY; the curves float and are positioned by later -/// fitting. The list enforces the connection rules between consecutive elements: -/// -/// * as a rule consecutive elements meet under a uniform azimuth (Tangent); -/// * two straights may meet at different azimuths only if the joint is -/// explicitly marked as an AzimuthBreak; -/// * two circular arcs with the same radius and the same direction may not -/// be adjacent (it would be a single arc). -/// -/// Joints that involve a floating curve cannot be fully checked yet and are -/// accepted pending the fitting step. -class HorizontalAlignment { -public: - /// Appends an element. The first element ignores @p joint; every later - /// element is validated against the current last element. - /// @throws std::logic_error if the joint violates a connection rule. - void append(PlanElement element, JointContinuity joint = JointContinuity::Tangent); - - [[nodiscard]] const std::vector& elements() const noexcept { - return elements_; - } - /// Joint between element @p index and @p index + 1 (index < size() - 1). - [[nodiscard]] JointContinuity joint(std::size_t index) const { - return joints_.at(index); - } - - [[nodiscard]] bool empty() const noexcept { return elements_.empty(); } - [[nodiscard]] std::size_t size() const noexcept { return elements_.size(); } - - /// Sum of the known element lengths; arcs without a length are skipped. - [[nodiscard]] Length total_length() const; - -private: - std::vector elements_; - std::vector joints_; // size() == elements_.size() - 1 -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp b/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp deleted file mode 100644 index b4281287..00000000 --- a/maj0sted/include/maj0sted/domain/geometry/plan_element.hpp +++ /dev/null @@ -1,127 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "maj0sted/domain/value_objects/azimuth.hpp" -#include "maj0sted/domain/value_objects/cartesian_position.hpp" -#include "maj0sted/domain/value_objects/length.hpp" -#include "maj0sted/domain/value_objects/radius.hpp" -#include "maj0sted/domain/value_objects/straight_id.hpp" - -namespace maj0sted::domain { - -/// Direction of a horizontal turn: left ("w lewo") or right ("w prawo"). -enum class TurnDirection { Left, Right }; - -/// Krzywa w planie: odcinek prosty. -/// -/// A straight is *anchored* by both of its XY endpoints; its azimuth and length -/// are computed from them. During fitting a straight keeps its supporting line -/// (position + azimuth) fixed — only its length may change as an endpoint slides -/// along that line. The curves between straights are positioned as a result of -/// that fitting, not the other way round. -/// -/// A straight also carries an optional identity (StraightId). References between -/// straight segments — e.g. parallelism — point at that id directly. Straights -/// used purely as geometry may stay anonymous (null id). -class Straight { -public: - /// Anonymous straight (null id). - /// @throws std::invalid_argument if @p start and @p end coincide (a - /// zero-length straight has no defined azimuth). - Straight(CartesianPosition start, CartesianPosition end) - : Straight{StraightId{}, start, end} {} - - /// Identified straight. - /// @throws std::invalid_argument if @p start and @p end coincide. - Straight(StraightId id, CartesianPosition start, CartesianPosition end) - : id_{id}, - start_{start}, - end_{end}, - azimuth_{Azimuth::of_direction(start, end)}, - length_{Length::from_metres(distance(start, end))} {} - - [[nodiscard]] StraightId id() const noexcept { return id_; } - [[nodiscard]] CartesianPosition start() const noexcept { return start_; } - [[nodiscard]] CartesianPosition end() const noexcept { return end_; } - [[nodiscard]] Azimuth azimuth() const noexcept { return azimuth_; } - [[nodiscard]] Length length() const noexcept { return length_; } - -private: - static double distance(CartesianPosition a, CartesianPosition b) noexcept { - const double dx = b.x() - a.x(); - const double dy = b.y() - a.y(); - return std::sqrt(dx * dx + dy * dy); - } - - StraightId id_; - CartesianPosition start_; - CartesianPosition end_; - Azimuth azimuth_; - Length length_; -}; - -/// Krzywa w planie: łuk kołowy o stałym promieniu. -/// -/// A floating element: it is defined by its radius and turn direction. Its -/// length is not essential and is therefore optional — typically it becomes -/// known only as a resultant of fitting between the anchored straights. -class CircularArc { -public: - CircularArc(Radius radius, TurnDirection direction, - std::optional length = std::nullopt) noexcept - : radius_{radius}, direction_{direction}, length_{length} {} - - [[nodiscard]] Radius radius() const noexcept { return radius_; } - [[nodiscard]] TurnDirection direction() const noexcept { return direction_; } - [[nodiscard]] std::optional length() const noexcept { return length_; } - -private: - Radius radius_; - TurnDirection direction_; - std::optional length_; -}; - -/// Krzywa w planie: krzywa przejściowa (klotoida). -/// -/// A floating element defined by its length, the radii at each end and its turn -/// direction. A missing radius (nullopt) denotes an infinite radius — a tangent -/// to a straight — so one type covers straight->arc, arc->arc and arc->straight. -class TransitionCurve { -public: - TransitionCurve(Length length, std::optional start_radius, - std::optional end_radius, TurnDirection direction) noexcept - : length_{length}, - start_radius_{start_radius}, - end_radius_{end_radius}, - direction_{direction} {} - - [[nodiscard]] Length length() const noexcept { return length_; } - [[nodiscard]] std::optional start_radius() const noexcept { return start_radius_; } - [[nodiscard]] std::optional end_radius() const noexcept { return end_radius_; } - [[nodiscard]] TurnDirection direction() const noexcept { return direction_; } - -private: - Length length_; - std::optional start_radius_; - std::optional end_radius_; - TurnDirection direction_; -}; - -/// Any single element of the horizontal alignment. -using PlanElement = std::variant; - -/// Length of a plan element, if known. A circular arc may not carry one. -[[nodiscard]] inline std::optional length_of(const PlanElement& element) { - if (const auto* straight = std::get_if(&element)) { - return straight->length(); - } - if (const auto* transition = std::get_if(&element)) { - return transition->length(); - } - return std::get(element).length(); -} - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp b/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp deleted file mode 100644 index 9866cb1e..00000000 --- a/maj0sted/include/maj0sted/domain/geometry/profile_element.hpp +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/domain/value_objects/length.hpp" -#include "maj0sted/domain/value_objects/radius.hpp" - -namespace maj0sted::domain { - -/// Sense of a vertical curve: a crest ("wypukły" — hilltop) or a sag -/// ("wklęsły" — valley). -enum class VerticalCurvature { Crest, Sag }; - -/// Krzywa w profilu: pochylenie o stałym nachyleniu, wyrażonym w promilach (‰). -/// Wartość dodatnia oznacza wznoszenie, ujemna — opadanie. -class Grade { -public: - Grade(Length length, double slope_permille) noexcept - : length_{length}, slope_permille_{slope_permille} {} - - [[nodiscard]] Length length() const noexcept { return length_; } - [[nodiscard]] double slope_permille() const noexcept { return slope_permille_; } - -private: - Length length_; - double slope_permille_; -}; - -/// Krzywa w profilu: łuk pionowy o stałym promieniu, łączący dwa pochylenia. -class VerticalCurve { -public: - VerticalCurve(Length length, Radius radius, VerticalCurvature curvature) noexcept - : length_{length}, radius_{radius}, curvature_{curvature} {} - - [[nodiscard]] Length length() const noexcept { return length_; } - [[nodiscard]] Radius radius() const noexcept { return radius_; } - [[nodiscard]] VerticalCurvature curvature() const noexcept { return curvature_; } - -private: - Length length_; - Radius radius_; - VerticalCurvature curvature_; -}; - -/// Any single element of the vertical alignment. -using ProfileElement = std::variant; - -/// Length of a profile element, whatever its concrete kind. -[[nodiscard]] inline Length length_of(const ProfileElement& element) { - return std::visit([](const auto& e) { return e.length(); }, element); -} - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp index 104a6974..d356b232 100644 --- a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp +++ b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp @@ -9,6 +9,7 @@ namespace maj0sted::domain { /// The crossing mark of a turnout — the skos 1:n. It is the tangent of the angle /// between the through and diverging tracks, so it fixes the total angle the /// diverging curve must turn through: a turnout is 1:9, 1:12, 1:18,5 and so on. +/// Nothing is fitted to it any more: it is what the laid path is checked against. class CrossingMark { public: explicit CrossingMark(double denominator) noexcept : denominator_{denominator} {} @@ -24,75 +25,155 @@ private: /// Which way the branch leaves the through track. enum class DivergeSide { Left, Right }; -/// One arc of a turnout's diverging curve: its radius and — unless it is the -/// closing arc — its fixed length and the clothoid easing it into the next arc. -struct TurnoutArc { - double radius{0.0}; - double length{0.0}; ///< fixed length; ignored for the closing arc - double transition_to_next{0.0}; ///< clothoid to the next arc (0 = none) +/// Which part of the turnout a piece of the diverging path is. The role names the +/// piece in switch terms; it does not constrain its shape — a blade may be straight +/// (iglica prosta sieczna) or an arc (iglica łukowa styczna). +enum class TurnoutPart { + PreBlade, ///< odcinek przediglicowy: PR to the blade tip, along the through track + Blade, ///< iglica, from its (theoretical) tip to its heel + Curve, ///< the diverging curve between the blade heel and the frog rail + FrogRail, ///< the straight frog rail out to KR }; -/// The shape of a turnout's diverging curve. One arc is the everyday turnout; two -/// or more make a łuk koszowy. Whatever the shape, the curve turns through exactly -/// the crossing angle — the closing (last) arc spends whatever deflection the -/// earlier arcs and clothoids leave. -struct DivergingCurve { - std::vector arcs; ///< at least one; the last one closes the turn - double entry_transition{0.0}; ///< clothoid from the through track into the first arc - double exit_transition{0.0}; ///< clothoid from the last arc back toward tangent +/// One authored piece of the diverging path, in order from PR. Every length is +/// explicit — the catalogue says it, and nothing here is solved for. A piece is a +/// straight when both radii are zero, an arc when they are equal, a clothoid +/// otherwise; radii are unsigned, the hand comes from DivergeSide. +struct TurnoutPiece { + TurnoutPart part{TurnoutPart::Curve}; + double length{0.0}; + double radius_start{0.0}; ///< 0 = straight + double radius_end{0.0}; ///< != radius_start = clothoid + /// Kąt nagięcia iglicy (beta): the heading break applied before this piece, + /// radians, unsigned. Zero everywhere for a blade tangent to the stock rail. + double turn_in{0.0}; }; -/// A turnout (rozjazd), described the way a catalogue does: a crossing mark, the -/// side it opens to, the free diverging curve inside it, and the length it spans -/// from its start (początek rozjazdu) to its end (koniec rozjazdu) measured along -/// the through track. The curve turns to the crossing angle and a straight frog -/// rail carries the track on to that length. +/// Iglica — not the shape of the axis (that is a TurnoutPiece), but the two +/// dimensions of the blade rail itself that place its real tip. A blade cannot be +/// ground to nothing, so its tip is cut back to a thickness and the point where +/// the axis leaves the stock rail is only a theoretical one. +struct Blade { + double tip_thickness{0.0}; ///< u: the blade's width where its planed nose ends + double nose{0.0}; ///< d: the planed nose itself, from the tip to that point + double railtop_width{0.0}; ///< w: railhead width, metres (49E1 ≈ 0,070) +}; + +/// Half the standard gauge. The gap the blade opens is taken between two rails, +/// and the blade's own curve is `R + s/2` where R is the diverging track's radius +/// (Koc, 5.2), so this is what turns a gap into an angle. Stations stay on the +/// axis: the rail radius converts, it does not measure. +inline constexpr double kHalfGauge = 0.7175; + +/// A turnout (rozjazd), described the way a catalogue drawing is: the crossing +/// mark and length it is meant to work out to, and the list of pieces the +/// diverging path is actually laid from. struct Turnout { CrossingMark mark; DivergeSide side{DivergeSide::Left}; - DivergingCurve curve; - double length{0.0}; ///< catalogue length along the through track; 0 = a tangent-length rail - /// Odcinek przediglicowy: the straight lead from the switch start to the blade - /// tip, still along the through track. Zero for a curve tangent from the start. - double pre_blade{0.0}; - /// Kąt nagięcia iglicy (beta), radians: the heading break at the blade tip. The - /// diverging track leaves the through track here, not tangentially, and the - /// curve then turns through only the remaining `alfa - beta`. Zero for a - /// tangential (curved-blade) turnout. - double blade_angle{0.0}; - /// The straight blade itself, from its tip to its heel where the curve begins. - /// Zero starts the curve at the tip. - double blade_length{0.0}; + std::vector path; ///< PR -> KR, in order; nothing implicit + /// Catalogue length: a + b, the two tangent legs of the drawing — PR to the + /// theoretical crossing point along the through track, and that point to KR + /// along the diverging one. This is the L the type tables give (Rz 190-1:9: + /// a 13,834 + b 13,304 = 27,138), not the projection on the through track. + double length{0.0}; + Blade blade{}; }; -/// One laid segment of the diverging path, as the constant/linear curvature the -/// renderer and the fitter already share (see segment_layout): a straight when -/// both curvatures are zero, an arc when they are equal, a clothoid otherwise. +/// One laid segment of the diverging path: a piece with its curvature signed for +/// the hand it diverges to, ready for layout_segment. struct TurnoutSegment { - double turn_in{0.0}; ///< signed heading break applied before laying this segment (the blade) + double turn_in{0.0}; ///< signed heading break applied before laying this segment double k0{0.0}; double k1{0.0}; double length{0.0}; + TurnoutPart part{TurnoutPart::Curve}; }; /// What a turnout works out to when laid from its start pose. struct TurnoutGeometry { bool valid{false}; std::vector path; ///< diverging segments from the switch start to the frog - geometry::Pose frog{}; ///< the end (koniec rozjazdu): position + heading at the crossing angle + geometry::Pose frog{}; ///< the end (koniec rozjazdu): position + heading double tangent_front{0.0}; ///< theoretical tangent length, switch start to the crossing point double tangent_back{0.0}; ///< theoretical tangent length, crossing point to the frog - double diverging_length{0.0}; ///< total length of the diverging path (curve + frog rail) + double diverging_length{0.0}; ///< total length of the diverging path + /// Where the turnout ends as measured on the through track: the frog projected + /// back onto it. This is the point that closes the switch on the straight — or + /// on the arc, when the turnout is bent. + geometry::Pose through_end{}; + double through_length{0.0}; + + // --- łukowanie ---------------------------------------------------------- + /// Signed curvature of the through track the turnout was laid against, and + /// what the diverging path came out at. Both zero for a rozjazd zwyczajny; + /// `diverging_curvature` is zero too when a two-sided bend straightens the + /// branch out. Positive turns left. + double bend{0.0}; + double diverging_curvature{0.0}; + /// Beta: how far the through track turns over the turnout. The bend is this + /// angle as much as it is a radius, and it is what the crossing angle is + /// measured from once the tor zasadniczy no longer runs straight. + double bend_angle{0.0}; + + // --- the blade, as dimensioned rather than as laid ---------------------- + // The curve is theoretically tangent to the stock rail at the switch start, but + // the blade only begins where there is metal enough to begin it. What runs from + // PR to that tip is the odcinek przediglicowy: the curve is already there, the + // vehicle is still on the through track. + bool has_blade{false}; + geometry::Pose blade_tip{}; ///< ostrze iglicy, where the blade piece starts + geometry::Pose blade_nose_end{}; ///< A: where the planed nose ends and the blade is u thick + geometry::Pose blade_planed{}; ///< where the blade reaches full railhead width + double blade_station{0.0}; ///< a: PR -> the tip, the odcinek przediglicowy + double nose_end{0.0}; ///< station of A, theoretically a + d + double planing_length{0.0}; ///< struganie: the tip to full railhead width + double planing_end{0.0}; ///< station where the planing ends + double blade_angle{0.0}; ///< delta: kąt przylegania, the tangent angle at A + /// (a + d) minus where the rail really stands `u` off the stock rail. Zero means + /// the type's own a, d and u agree with the curve it is laid on. + double blade_residual{0.0}; + + // --- what the catalogue numbers say about all that ---------------------- + /// Total deflection of the path minus the crossing angle. Zero = the path + /// closes on the mark; non-zero is reported, never fitted away. + double angle_residual{0.0}; + /// The laid tangent legs (a + b) minus the catalogue length. + double length_residual{0.0}; }; -/// Lays a turnout forward from @p start — the switch start (początek rozjazdu), -/// tangent to the through track: the pre-blade straight, the blade breaking away -/// by `blade_angle`, the curve turning through what is left of the crossing angle, -/// then the frog rail out to the catalogue length. Never throws; returns -/// `valid == false` when the parameters admit no geometry: a non-positive radius, -/// a blade angle that is negative or already reaches the crossing angle, or a -/// curve whose fixed parts overshoot the remaining angle before the closing arc. -[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, - const Turnout& turnout); +/// How far along an axis of radius @p radius, leaving the stock rail at angle +/// @p angle, the blade stands @p offset away from it: `R·(acos(cos β − offset/R1) − β)` +/// for a curved blade, `offset / sin β` for a straight one. The gap opens on the +/// blade's own curve `R1 = R + s/2`, but the answer is a station on the axis, which +/// is what every length here is measured in. +/// +/// This is the relation the catalogue's own numbers obey. A drawing dimensions the +/// blade on the rail rather than on the axis, so what Koc's Tablica 5.1 gives as +/// `a + d` is this run scaled by `R1/R` — at 1200-1:18,5 the two agree to a tenth +/// of a millimetre. Zero when the curve neither turns nor breaks away, or when the +/// offset is out of its reach. +[[nodiscard]] double blade_run_to_offset(double radius, double angle, double offset); + +/// Lays @p turnout from @p start. @p bend is the signed curvature (1/m, positive +/// turns left) of the through track under it: zero for a rozjazd zwyczajny, and +/// anything else for a rozjazd łukowy. +/// +/// Bending is not adding curvature. Koc, 5.3.6: it rotates the triangle OBC about +/// the centre of the turnout, so the crossing angle alfa and the tangent +/// `t = R·tg(alfa/2)` come through untouched and every radius follows from them — +/// the through track turns by `beta = 2·arctg(t·bend)`, the diverging path by +/// `beta + alfa`, and each gets the radius `t/tg(its own angle/2)`. That is where +/// Koc's (5.7) and (5.8) come from, and it is why a Rz 49E1-190-1:9 bent +/// symmetrically is the catalogue's Rłs 49E1-380,292-1:9. +/// +/// The switch and the frog are not re-cut, only bent: a straight krzyżownica stays +/// straight and the blade keeps its own dimensions, measured — as on the ground — +/// against the opornica that curves with it. +/// +/// Invalid when the path has no arc to bend or its arcs disagree on a radius: +/// there is then no single triangle to rotate, and guessing one would be a lie. +[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, const Turnout& turnout, + double bend = 0.0); } // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp b/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp deleted file mode 100644 index 41c23fb3..00000000 --- a/maj0sted/include/maj0sted/domain/geometry/vertical_alignment.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "maj0sted/domain/geometry/profile_element.hpp" -#include "maj0sted/domain/value_objects/length.hpp" - -namespace maj0sted::domain { - -/// Geometria w profilu (rzut pionowy toru): uporządkowany ciąg pochyleń i łuków -/// pionowych, gdzie każdy element zaczyna się tam, gdzie kończy się poprzedni. -class VerticalAlignment { -public: - void append(ProfileElement element) { elements_.push_back(std::move(element)); } - - [[nodiscard]] const std::vector& elements() const noexcept { - return elements_; - } - [[nodiscard]] bool empty() const noexcept { return elements_.empty(); } - [[nodiscard]] std::size_t size() const noexcept { return elements_.size(); } - - /// Sumaryczna długość toru w profilu. - [[nodiscard]] Length total_length() const; - -private: - std::vector elements_; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/map_project.hpp b/maj0sted/include/maj0sted/domain/map_project.hpp deleted file mode 100644 index a8879605..00000000 --- a/maj0sted/include/maj0sted/domain/map_project.hpp +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/domain/niweleta.hpp" -#include "maj0sted/domain/value_objects/crs.hpp" - -namespace maj0sted::domain { - -/// The map project ("projekt mapy"). -/// -/// It defines the Cartesian coordinate reference system in which every position -/// in the project is expressed (by default EPSG:2180, metres) and owns the -/// niwelety that make up the layout. -class MapProject { -public: - /// Creates a project using @p crs, or EPSG:2180 by default. - explicit MapProject(Crs crs = Crs::default_crs()); - - [[nodiscard]] Crs crs() const noexcept { return crs_; } - - void add_niweleta(Niweleta niweleta); - [[nodiscard]] const std::vector& niwelety() const noexcept { - return niwelety_; - } - -private: - Crs crs_; - std::vector niwelety_; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/niweleta.hpp b/maj0sted/include/maj0sted/domain/niweleta.hpp deleted file mode 100644 index c6cafae2..00000000 --- a/maj0sted/include/maj0sted/domain/niweleta.hpp +++ /dev/null @@ -1,70 +0,0 @@ -#pragma once - -#include -#include - -#include "maj0sted/domain/fitting/fit_result.hpp" -#include "maj0sted/domain/geometry/horizontal_alignment.hpp" -#include "maj0sted/domain/geometry/vertical_alignment.hpp" -#include "maj0sted/domain/value_objects/cartesian_position.hpp" -#include "maj0sted/domain/value_objects/length.hpp" -#include "maj0sted/domain/value_objects/niweleta_id.hpp" -#include "maj0sted/domain/value_objects/straight_id.hpp" - -namespace maj0sted::domain { - -/// Niweleta — jednolity odcinek toru złożony z wielu krzywych w planie i w -/// profilu. -/// -/// Aggregate Root: to granica spójności całego odcinka. Geometria w planie i w -/// profilu opisują ten sam fizyczny tor, więc ich sumaryczne długości muszą być -/// zgodne — pilnuje tego lengths_consistent(). Krzywe dokłada się wyłącznie -/// przez metody agregatu, który utrzymuje ich kolejność. -class Niweleta { -public: - explicit Niweleta(NiweletaId id, std::string name = {}); - - [[nodiscard]] NiweletaId id() const noexcept { return id_; } - [[nodiscard]] const std::string& name() const noexcept { return name_; } - - /// Dodaje prostą na końcu planu, nadając jej automatycznie StraightId, - /// który zwraca (do późniejszych referencji: równoległość, fitowanie). - StraightId add_straight(CartesianPosition start, CartesianPosition end, - JointContinuity joint = JointContinuity::Tangent); - - /// Dokłada krzywą na końcu geometrii w planie, łącząc ją z poprzednim - /// elementem zadanym stykiem (domyślnie stycznym). Jeśli elementem jest - /// prosta bez tożsamości, niweleta nadaje jej StraightId. - void add_plan_element(PlanElement element, - JointContinuity joint = JointContinuity::Tangent); - - /// Wpina wynik fitowania w plan: podmienia dwie sąsiednie proste (entry, - /// exit z @p result) na entry(przyciętą) → curve... → exit(przyciętą), - /// stykami stycznymi (Tangent). Proste zachowują swoje StraightId. - /// @throws std::invalid_argument jeśli proste nie zostaną znalezione lub nie - /// są sąsiednie (entry bezpośrednio przed exit). - void apply_fit(const FitResult& result); - - /// Dokłada krzywą na końcu geometrii w profilu. - void add_profile_element(ProfileElement element); - - [[nodiscard]] const HorizontalAlignment& plan() const noexcept { return plan_; } - [[nodiscard]] const VerticalAlignment& profile() const noexcept { return profile_; } - - [[nodiscard]] Length plan_length() const { return plan_.total_length(); } - [[nodiscard]] Length profile_length() const { return profile_.total_length(); } - - /// Czy plan i profil opisują tor tej samej długości (z zadaną tolerancją)? - [[nodiscard]] bool lengths_consistent(double tolerance_m = 1e-6) const; - -private: - StraightId next_straight_id() noexcept; - - NiweletaId id_; - std::string name_; - HorizontalAlignment plan_; - VerticalAlignment profile_; - std::uint64_t straight_seq_{0}; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp b/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp deleted file mode 100644 index 77ee03b1..00000000 --- a/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include "maj0sted/domain/geometry/plan_element.hpp" -#include "maj0sted/domain/parallelism/track_parallelism.hpp" -#include "maj0sted/domain/value_objects/track_offset.hpp" - -namespace maj0sted::domain { - -/// Domain Service: creates and reasons about parallelism between straight -/// segments. -class ParallelismService { -public: - /// Marks @p origin as the origin of a parallel relation with @p parallel, - /// separated by @p offset. Both straights must be identified. - /// @throws std::invalid_argument if a straight has no id or the two are the - /// same straight. - static TrackParallelism mark_parallel(const Straight& origin, - const Straight& parallel, - TrackOffset offset); - - /// Are two straights parallel as lines (same or opposite azimuth)? - static bool are_parallel(const Straight& a, const Straight& b, - double tolerance_rad = 1e-9); - - /// The line the parallel straight lies on: @p origin shifted sideways by - /// @p offset, perpendicular to its direction of travel. Same length and - /// azimuth as the origin; the returned straight is anonymous. - static Straight derive_parallel_line(const Straight& origin, TrackOffset offset); - - /// Perpendicular distance (>= 0) from @p other's start point to the - /// (infinite) line carrying @p origin. - static double perpendicular_distance(const Straight& origin, const Straight& other); - - [[nodiscard]] static Straight project_onto_offset_line(const Straight& origin, - double offset_m, - const Straight& segment); -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp b/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp deleted file mode 100644 index 9a62c0e0..00000000 --- a/maj0sted/include/maj0sted/domain/parallelism/track_parallelism.hpp +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/domain/value_objects/straight_id.hpp" -#include "maj0sted/domain/value_objects/track_offset.hpp" - -namespace maj0sted::domain { - -/// A reference-based relation between two straight segments, identified by their -/// StraightId. @c origin is the authoritative reference track; @c parallel runs -/// alongside it at @c offset (a perpendicular distance on a given side). -/// -/// Invariants: both straights must be identified (non-null id), and a straight -/// cannot be parallel to itself. -class TrackParallelism { -public: - TrackParallelism(StraightId origin, StraightId parallel, TrackOffset offset) - : origin_{origin}, parallel_{parallel}, offset_{offset} { - if (origin.is_null() || parallel.is_null()) { - throw std::invalid_argument{"Parallelism requires identified straights"}; - } - if (origin == parallel) { - throw std::invalid_argument{"A straight cannot be parallel to itself"}; - } - } - - [[nodiscard]] StraightId origin() const noexcept { return origin_; } - [[nodiscard]] StraightId parallel() const noexcept { return parallel_; } - [[nodiscard]] TrackOffset offset() const noexcept { return offset_; } - -private: - StraightId origin_; - StraightId parallel_; - TrackOffset offset_; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp b/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp deleted file mode 100644 index 8c7c73a8..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "maj0sted/domain/value_objects/cartesian_position.hpp" - -namespace maj0sted::domain { - -/// Value Object: a horizontal azimuth — the direction of travel in the plan, -/// measured clockwise from north (the +y / northing axis) and normalised to -/// [0, 2*pi) radians. -class Azimuth { -public: - static Azimuth from_radians(double radians) noexcept { - return Azimuth{normalise(radians)}; - } - - static Azimuth from_degrees(double degrees) noexcept { - return from_radians(degrees * std::numbers::pi / 180.0); - } - - /// Azimuth of the direction from @p from to @p to. - /// @throws std::invalid_argument if the two points coincide. - static Azimuth of_direction(CartesianPosition from, CartesianPosition to) { - const double d_east = to.x() - from.x(); - const double d_north = to.y() - from.y(); - if (d_east == 0.0 && d_north == 0.0) { - throw std::invalid_argument{"Azimuth is undefined for coincident points"}; - } - return from_radians(std::atan2(d_east, d_north)); - } - - [[nodiscard]] double radians() const noexcept { return radians_; } - [[nodiscard]] double degrees() const noexcept { - return radians_ * 180.0 / std::numbers::pi; - } - - /// Smallest absolute angular difference to @p other, in [0, pi]. - [[nodiscard]] double angular_distance(Azimuth other) const noexcept { - double difference = std::fabs(radians_ - other.radians_); - if (difference > std::numbers::pi) { - difference = 2.0 * std::numbers::pi - difference; - } - return difference; - } - - friend auto operator<=>(const Azimuth&, const Azimuth&) noexcept = default; - -private: - explicit Azimuth(double radians) noexcept : radians_{radians} {} - - static double normalise(double radians) noexcept { - const double two_pi = 2.0 * std::numbers::pi; - double result = std::fmod(radians, two_pi); - if (result < 0.0) { - result += two_pi; - } - return result; - } - - double radians_; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp b/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp deleted file mode 100644 index 60dbe82d..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/cartesian_position.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include - -namespace maj0sted::domain { - -/// Value Object: a position in a Cartesian coordinate reference system. -/// -/// Coordinates are expressed in the units of the project's CRS. For the default -/// EPSG:2180 these are metres, with @c x as easting and @c y as northing. -class CartesianPosition { -public: - constexpr CartesianPosition() noexcept = default; - constexpr CartesianPosition(double x, double y) noexcept : x_{x}, y_{y} {} - - [[nodiscard]] constexpr double x() const noexcept { return x_; } - [[nodiscard]] constexpr double y() const noexcept { return y_; } - - friend constexpr bool operator==(const CartesianPosition&, - const CartesianPosition&) noexcept = default; - -private: - double x_{0.0}; - double y_{0.0}; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/crs.hpp b/maj0sted/include/maj0sted/domain/value_objects/crs.hpp deleted file mode 100644 index 6ab6775a..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/crs.hpp +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -namespace maj0sted::domain { - -/// Value Object: a Coordinate Reference System, identified by its EPSG code. -/// -/// The default for a map project is EPSG:2180 (PUWG 1992 / Poland CS92), a -/// Cartesian, metre-based projected system. -class Crs { -public: - // EPSG code of the default map-project CRS. - static constexpr int kDefaultEpsg = 2180; - - explicit constexpr Crs(int epsg) noexcept : epsg_{epsg} {} - - /// The default CRS: EPSG:2180. - static constexpr Crs default_crs() noexcept { return Crs{kDefaultEpsg}; } - - [[nodiscard]] constexpr int epsg() const noexcept { return epsg_; } - - friend constexpr auto operator<=>(const Crs&, const Crs&) noexcept = default; - -private: - int epsg_; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/length.hpp b/maj0sted/include/maj0sted/domain/value_objects/length.hpp deleted file mode 100644 index a86c10b5..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/length.hpp +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include -#include - -namespace maj0sted::domain { - -/// Value Object: a non-negative length, in metres (the unit of the default -/// EPSG:2180 CRS). Lengths add and compare by value. -class Length { -public: - constexpr Length() noexcept = default; - - static constexpr Length from_metres(double metres) { - if (metres < 0.0) { - throw std::invalid_argument{"Length cannot be negative"}; - } - return Length{metres}; - } - - static constexpr Length zero() noexcept { return Length{}; } - - [[nodiscard]] constexpr double metres() const noexcept { return metres_; } - - [[nodiscard]] constexpr Length operator+(Length other) const noexcept { - return Length{metres_ + other.metres_}; - } - - friend constexpr auto operator<=>(const Length&, const Length&) noexcept = default; - -private: - explicit constexpr Length(double metres) noexcept : metres_{metres} {} - - double metres_{0.0}; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp b/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp deleted file mode 100644 index 067367df..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/niweleta_id.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace maj0sted::domain { - -/// Value Object: identity of a Niweleta aggregate. Cross-aggregate references -/// (e.g. parallelism) point at niwelety by this id, never by pointer. Value 0 -/// denotes "no identity". -class NiweletaId { -public: - using value_type = std::uint64_t; - - constexpr NiweletaId() noexcept = default; - explicit constexpr NiweletaId(value_type value) noexcept : value_{value} {} - - [[nodiscard]] constexpr value_type value() const noexcept { return value_; } - [[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; } - - friend constexpr auto operator<=>(const NiweletaId&, const NiweletaId&) noexcept = default; - -private: - value_type value_{0}; -}; - -} // namespace maj0sted::domain - -template <> -struct std::hash { - std::size_t operator()(const maj0sted::domain::NiweletaId& id) const noexcept { - return std::hash{}(id.value()); - } -}; diff --git a/maj0sted/include/maj0sted/domain/value_objects/radius.hpp b/maj0sted/include/maj0sted/domain/value_objects/radius.hpp deleted file mode 100644 index e00119fe..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/radius.hpp +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include - -namespace maj0sted::domain { - -/// Value Object: a radius of curvature, in metres. Always strictly positive — -/// the turn direction (plan) or curve sense (profile) is carried separately by -/// the geometric element, so the radius itself is just a magnitude. -class Radius { -public: - static constexpr Radius from_metres(double metres) { - if (!(metres > 0.0)) { - throw std::invalid_argument{"Radius must be positive"}; - } - return Radius{metres}; - } - - [[nodiscard]] constexpr double metres() const noexcept { return metres_; } - - friend constexpr auto operator<=>(const Radius&, const Radius&) noexcept = default; - -private: - explicit constexpr Radius(double metres) noexcept : metres_{metres} {} - - double metres_; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp b/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp deleted file mode 100644 index d61a6724..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/straight_id.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace maj0sted::domain { - -/// Value Object: identity of a straight segment. A straight that takes part in -/// references (e.g. parallelism) carries a non-null id; anonymous straights used -/// purely as geometry may leave it null. Value 0 denotes "no identity". -class StraightId { -public: - using value_type = std::uint64_t; - - constexpr StraightId() noexcept = default; - explicit constexpr StraightId(value_type value) noexcept : value_{value} {} - - [[nodiscard]] constexpr value_type value() const noexcept { return value_; } - [[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; } - - friend constexpr auto operator<=>(const StraightId&, const StraightId&) noexcept = default; - -private: - value_type value_{0}; -}; - -} // namespace maj0sted::domain - -template <> -struct std::hash { - std::size_t operator()(const maj0sted::domain::StraightId& id) const noexcept { - return std::hash{}(id.value()); - } -}; diff --git a/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp b/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp deleted file mode 100644 index c4e5d69c..00000000 --- a/maj0sted/include/maj0sted/domain/value_objects/track_offset.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/domain/value_objects/length.hpp" - -namespace maj0sted::domain { - -/// Which side a parallel track runs on, relative to the origin straight's -/// direction of travel: left ("po lewej") or right ("po prawej"). -enum class Side { Left, Right }; - -/// Value Object: the sideways offset of a parallel track from its origin — a -/// strictly positive perpendicular distance plus the side it lies on. -class TrackOffset { -public: - TrackOffset(Length distance, Side side) : distance_{distance}, side_{side} { - if (!(distance.metres() > 0.0)) { - throw std::invalid_argument{"Track offset distance must be positive"}; - } - } - - [[nodiscard]] Length distance() const noexcept { return distance_; } - [[nodiscard]] Side side() const noexcept { return side_; } - -private: - Length distance_; - Side side_; -}; - -} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/editor/chain.hpp b/maj0sted/include/maj0sted/editor/chain.hpp new file mode 100644 index 00000000..c75ee9fc --- /dev/null +++ b/maj0sted/include/maj0sted/editor/chain.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// What laying one track produced, and everything that went wrong doing it. +struct TrackOutcome { + SolvedTrack track; + std::vector diagnostics; +}; + +/// Lays @p track from @p start. +/// +/// Every element states its own length, so laying is a walk: each element starts +/// on the pose the one before it ended on, and nothing is solved for. A clothoid +/// takes its near-end curvature from where the chain has got to, which is the +/// only way one element depends on another. +/// +/// Never throws. An element that cannot be laid — a non-positive length, an arc +/// without a radius or a hand, a clothoid that changes nothing — is reported and +/// ends the chain there; everything before it is still returned and still +/// drawable. +[[nodiscard]] TrackOutcome lay_track(const Document& document, const Track& track, + const domain::geometry::Pose& start); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/editor.hpp b/maj0sted/include/maj0sted/editor/editor.hpp deleted file mode 100644 index c57cee45..00000000 --- a/maj0sted/include/maj0sted/editor/editor.hpp +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include -#include - -#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline - -namespace maj0sted::editor { - -/// An anchored straight segment (two XY endpoints). -struct StraightSpec { - double x1{0.0}, y1{0.0}, x2{0.0}, y2{0.0}; - /// Internal tangent guide used to solve a terminal curve. It participates - /// in fitting but is not rendered as track or exposed as an editor handle. - bool hidden{false}; - - /// Live constraint the host reapplies before each solve (same model as the - /// web editor's `par` / `skew`). 0 = none, 1 = parallel offset from another - /// straight, 2 = skew 1:n from another straight. - int rel_kind{0}; - int rel_niw{-1}; ///< index of the reference niweleta - int rel_str{-1}; ///< index of the reference straight - double rel_offset{0.0}; ///< parallel: signed metres (+ = left of travel) - double rel_cot{0.0}; ///< skew: n of a 1:n turnout - int rel_side{1}; ///< skew: +1 left / -1 right of the reference - double rel_length{0.0}; ///< skew: length of the dependent arm -}; - -/// One arc of a compound fit: its radius, its fixed length (ignored for the last -/// arc, which absorbs the remaining deflection) and an optional clothoid to the -/// next arc. -struct CompoundArcSpec { - double radius{0.0}; - double length{0.0}; - double transition_to_next{0.0}; -}; - -/// A fit in the gap between straight @c gap and straight @c gap + 1. -struct GapFit { - int gap{0}; - int mode{0}; ///< 0 none, 1 arc, 2 legacy arc+clothoids, 3 compound, 4 U-turn - - double radius{0.0}; ///< plain arc (mode 1) - double transition{0.0}; ///< legacy mode-2 symmetric clothoid - - // Unified compound (mode 3): end clothoids plus one or more arcs. - double entry_t{0.0}, exit_t{0.0}; - std::vector arcs{}; - - // Legacy fixed two-arc fields, kept so old saved projects still deserialize; - // superseded by @c arcs. Used only when @c arcs is empty. - double r1{0.0}, arc1_len{0.0}, between{0.0}, r2{0.0}; -}; - -/// A niweleta as edited in the GUI: independent anchored straights plus fits -/// connecting consecutive straights. Because the straights are independent, a -/// gap can be a normal corner (straights meet at an intersection) OR a 180° -/// reversal (parallel, offset straights) — no 180° limitation. -struct NiweletaSpec { - std::string name; - std::vector straights; - std::vector fits; -}; - -struct NiweletaPolys { - std::vector polylines; - /// Centreline segments after adjacent fits trim the input straights. Kept - /// index-aligned with NiweletaSpec::straights for editor hit handles. - std::vector rendered_straights; - /// The authoritative fit set after solving: exactly the requested fits that - /// actually succeeded. A requested fit that could not be produced (bad - /// parameters or geometry) is simply absent here — the library, not the host, - /// decides that a failed fit becomes "no fit". The host mirrors this list. - std::vector applied_fits; - /// The solved axis as one polyline, in order, with no duplicate join points. - /// Lets a station along the niweleta be resolved to a pose (see pose_at). - std::vector centreline; -}; - -/// Builds each niweleta, fits every requested gap, and returns the sampled plan -/// per niweleta. Never throws: gaps whose parameters don't admit a fit are left -/// unconnected (the two straights simply stay as drawn). -[[nodiscard]] std::vector solve_project( - const std::vector& niwelety); - -/// A switch (rozjazd): the point where a branch leaves a through niweleta. It is -/// a relation, not a niweleta — the through track is not split. The switch is -/// described the surveyor's way: a crossing mark 1:n (skos) that fixes the angle -/// the branch leaves at, and an interior curve that is free to be anything — a -/// single arc, or a compound basket (łuk koszowy). @c station marks the switch's -/// theoretical point (środek rozjazdu, where the through and diverging tangent -/// lines cross); the curve is fitted around it and the branch niweleta is pinned -/// to the frog it ends on. The theoretical tangent lengths follow from the curve -/// (see JunctionGeom) — they are read off, not set. -struct Junction { - int through{-1}; ///< index of the through niweleta (not modified) - double station{0.0}; ///< theoretical point: arc length along the through centreline - int side{0}; ///< 0 = diverges left, 1 = diverges right - bool facing{true}; ///< true opens toward increasing station, false back - double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n) - double length{0.0}; ///< catalogue length (PR→KR along the through track); 0 = just the curve - double pre_blade{0.0}; ///< odcinek przediglicowy: straight lead PR → blade tip, metres - double blade_angle{0.0}; ///< kąt nagięcia iglicy (beta), radians; 0 = curve tangent from PR - double blade_length{0.0};///< the straight blade, tip → heel where the curve starts, metres - GapFit curve; ///< the internal diverging curve: arc / compound / basket - int branch{-1}; ///< index of the branch niweleta, pinned to the frog -}; - -/// The solved geometry of one switch: the switch point where the branch leaves -/// the through track, the frog it ends on, the diverging curve between them and -/// the theoretical tangent lengths that curve works out to. -struct JunctionGeom { - bool valid{false}; - double px{0.0}, py{0.0}; ///< switch point (diverging leaves the through here) - double fx{0.0}, fy{0.0}; ///< frog: where the branch niweleta is pinned - double fhx{0.0}, fhy{0.0}; ///< unit heading the branch leaves the frog on - double tangent_front{0.0}; ///< theoretical tangent length before the curve - double tangent_back{0.0}; ///< theoretical tangent length after the curve - double length{0.0}; ///< diverging curve length (switch point to frog), metres - /// The diverging curve, as rails ready to draw. - std::vector polylines; -}; - -/// A whole layout solved at once: the niwelety plus every switch. Returned by -/// solve_layout, which additionally pins each branch's start to its frog. -struct LayoutSolution { - std::vector niwelety; - std::vector junctions; ///< index-aligned with the input switches -}; - -/// Solves @p niwelety together with @p junctions: each branch niweleta's start is -/// pinned to its switch's frog before solving, and every switch's diverging arc -/// is computed from the through niweleta's centreline. Never throws; a switch -/// whose through/branch is out of range or whose geometry is degenerate comes -/// back with valid == false. Takes @p niwelety by value because it repins them. -[[nodiscard]] LayoutSolution solve_layout(std::vector niwelety, - const std::vector& junctions); - -} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/layout.hpp b/maj0sted/include/maj0sted/editor/layout.hpp new file mode 100644 index 00000000..945f3416 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/layout.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "maj0sted/domain/geometry/turnout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// Solves a whole document: lays every turnout on its through track, lays every +/// track from its anchor, and moves the tracks that are held parallel to another +/// track's element until they really lie at the offset they were given. +/// +/// The order is not fixed in advance — it is worked out from what refers to +/// what. A branch anchored to a turnout's frog is laid after that turnout, which +/// is laid after its through track, however deep that goes: a turnout on the +/// branch of a turnout is nothing special. A reference that runs in a circle is +/// reported and its members are left unlaid, rather than being fed through a +/// fixed number of relaxation passes and whatever came out. +/// +/// Nothing here writes to @p document. Never throws. +[[nodiscard]] Solution solve(const Document& document); + +/// The catalogue type as the geometry layer wants it: the piece list with its +/// curvatures signed for @p hand (+1 diverges left). Public because the editor +/// dimensions a *type* with it — laying one from an identity pose is how the +/// catalogue numbers are checked before any instance stands on a track. +[[nodiscard]] domain::Turnout to_domain(const TurnoutType& type, int hand); + +/// The construction points of a turnout laid from @p start: where its parts meet, +/// where its blade is dimensioned, and the points the drawing takes its lengths +/// between. Public for the same reason as to_domain — so a bare type is drawn from +/// the very same list the solver reports for a placement, rather than from a second +/// idea of what a construction point is. +[[nodiscard]] std::vector turnout_marks(const domain::TurnoutGeometry& geometry, + const domain::geometry::Pose& start); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/model.hpp b/maj0sted/include/maj0sted/editor/model.hpp new file mode 100644 index 00000000..9c60df1f --- /dev/null +++ b/maj0sted/include/maj0sted/editor/model.hpp @@ -0,0 +1,217 @@ +#pragma once + +#include +#include +#include +#include + +namespace maj0sted::editor { + +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +/// Stable handles. Every reference in a document points at one of these, never +/// at a position in a vector: inserting or deleting anything must not silently +/// re-target a reference somewhere else. Ids are minted from one counter per +/// document (Document::next_id), so they are unique across all four spaces — +/// which makes a stray reference a lookup miss rather than a wrong hit. +enum class ElementId : std::uint32_t { none = 0 }; +enum class TrackId : std::uint32_t { none = 0 }; +enum class TurnoutId : std::uint32_t { none = 0 }; + +// --------------------------------------------------------------------------- +// The element +// --------------------------------------------------------------------------- + +/// What an element is, geometrically. These are the three shapes +/// `geometry::layout_segment` lays: constant zero curvature, constant non-zero +/// curvature, linearly varying curvature. +enum class Kind { + Line, + Arc, + Clothoid, +}; + +// --------------------------------------------------------------------------- +// What holds an element +// --------------------------------------------------------------------------- + +/// Nothing holds it: its shape and its place are its own. +struct Free {}; + +/// Held parallel to another track's element at a signed offset (+ = left of +/// *that* element's travel, whichever way this one runs). +/// +/// This is międzytorze, said once: a straight beside a straight, an arc +/// concentric with an arc at R∓d. It fixes two things — the shape (kind, radius, +/// hand, taken from the reference) and where the track runs across the +/// reference. It fixes nothing along it, so the track stays where it was put and +/// slides nowhere when the reference is edited. +/// +/// A track that can move — one anchored at a pose of its own — is moved as one +/// rigid piece until the held element really lies at the offset. A track pinned +/// to a turnout's port cannot move: there the reference still fixes the shape, +/// and the offset that actually came out is reported rather than forced. +struct Parallel { + ElementId ref{ElementId::none}; + double offset{0.0}; +}; + +using Hold = std::variant; + +/// One element of a track. It starts where the previous element ends — by pose, +/// not by coordinates — so tangency is a property of the representation and not +/// a rule anyone has to check or restore. +/// +/// Everything about it is stated outright: what shape it is, how long it is, and +/// how tightly it turns. Nothing here is left for a solver to decide — there is +/// no "run on until you reach that line". Laying track by clicking along the +/// ground still works, but the corner is worked out at the click and written +/// down as numbers, so what the document says is what stands on the ground. +struct Element { + ElementId id{ElementId::none}; + Kind kind{Kind::Line}; + /// Arc: the radius, metres, > 0. Clothoid: the radius at its far end, with + /// 0 meaning infinite (a clothoid running out to a straight); its near-end + /// radius is whatever the previous element ends on. Line: ignored. + double radius{0.0}; + /// +1 turns left, -1 turns right, 0 for a straight. Signed curvature is + /// exactly `hand / radius`, and positive curvature turns left in + /// `geometry::layout_segment` — so no sign is flipped anywhere downstream. + int hand{0}; + /// Along the axis, metres, > 0. + double length{0.0}; + /// What holds it to something else. Never changes its length. + Hold hold{Free{}}; +}; + +// --------------------------------------------------------------------------- +// Anchors +// --------------------------------------------------------------------------- + +/// Start at an explicit pose. @c az is an azimuth: radians clockwise from north, +/// i.e. atan2(dx, dy), matching the rest of the editor. +struct AtPose { + double x{0.0}, y{0.0}, az{0.0}; +}; +/// Which end of a turnout a track leaves from. +enum class Port { + Frog, ///< KR — koniec rozjazdu, the diverging track's end + Start, ///< PR — początek rozjazdu +}; +/// Start at a turnout's port. This is what used to be `frog_of` plus the pinning +/// pass: the branch no longer gets moved back into place after the fact, it was +/// never anywhere else. +struct AtPort { + TurnoutId turnout{TurnoutId::none}; + Port port{Port::Frog}; +}; + +using Anchor = std::variant; + +// --------------------------------------------------------------------------- +// Track +// --------------------------------------------------------------------------- + +/// A niweleta: an anchor plus an ordered chain of elements. Straights, arcs and +/// clothoids are equal citizens here — a chain may start with an arc, put two +/// arcs back to back, or simply stop without closing onto anything. +struct Track { + TrackId id{TrackId::none}; + std::string name; + Anchor anchor{AtPose{}}; + std::vector elements; +}; + +// --------------------------------------------------------------------------- +// Turnouts: catalogue type + placement +// --------------------------------------------------------------------------- + +/// One piece of a catalogue turnout's diverging path. Mirrors +/// domain::TurnoutPiece; kept here so the document does not depend on the +/// geometry header. +struct TurnoutPieceSpec { + int part{2}; ///< 0 przediglicowy, 1 iglica, 2 łuk, 3 krzyżownicowa + double length{0.0}; ///< always explicit; nothing is fitted + double radius_start{0.0}; ///< 0 = straight + double radius_end{0.0}; ///< != radius_start = clothoid + double turn_in{0.0}; ///< kąt nagięcia iglicy, radians, unsigned +}; + +/// The blade's own dimensions, which place its cut tip. Mirrors domain::Blade. +struct BladeSpec { + double tip_thickness{0.0}; ///< u: width where the planed nose ends + double nose{0.0}; ///< d: the planed nose, from the tip to that point + double railtop_width{0.0}; ///< w: railhead width +}; + +/// A catalogue turnout: the list of pieces its diverging path is laid from, plus +/// the crossing mark and length the catalogue says it should work out to — which +/// the solver checks the list against and never fits it to. Every number that +/// belongs to the *type* lives here, so adding one is a catalogue change and +/// never touches the file format for placements. +struct TurnoutType { + std::string name; + double crossing_n{9.0}; ///< skos 1:n, checked against the laid deflection + double length{0.0}; ///< catalogue length PR->KR along the through track, checked + std::vector pieces; + BladeSpec blade{}; +}; + +/// An instance of a type, laid on a through track. There is nothing here to +/// edit but where it sits — the geometry belongs to the type. +struct TurnoutPlacement { + TurnoutId id{TurnoutId::none}; + std::string type; ///< catalogue name + TrackId on{TrackId::none}; ///< the through track, which is not split + double station{0.0}; ///< PR, as arc length along the through centreline + int hand{1}; ///< +1 diverges left, -1 diverges right + bool facing{true}; ///< opens toward increasing station + + /// Łukowanie. A turnout standing on a curve is bent onto it, and normally + /// that is all there is to say: the tor zasadniczy is whatever the track does + /// under the PR. Clearing this asks for a bend of its own instead — a rozjazd + /// łukowy on a track drawn straight — and then `bend` is the signed curvature + /// (1/m, positive turns left) the through track is taken to have. What the + /// bend works out to is the solver's business and is never written back here. + bool bend_from_track{true}; + double bend{0.0}; +}; + +// --------------------------------------------------------------------------- +// Document +// --------------------------------------------------------------------------- + +/// Everything that is authored. Nothing in here is ever written by the solver. +struct Document { + std::vector turnout_types; + std::vector turnouts; + std::vector tracks; + + double view_x{0.0}, view_y{0.0}, view_extent{0.0}; + bool origin_set{false}; + bool georeferenced{false}; + double origin_x{0.0}, origin_y{0.0}; + + /// Next handle to hand out. One counter for every id space. + std::uint32_t next_id{1}; +}; + +/// Mints the next handle. The four overloads exist so a caller states which kind +/// of thing it is naming. +[[nodiscard]] ElementId mint_element(Document& document); +[[nodiscard]] TrackId mint_track(Document& document); +[[nodiscard]] TurnoutId mint_turnout(Document& document); + +[[nodiscard]] const Track* find_track(const Document& document, TrackId id); +[[nodiscard]] Track* find_track(Document& document, TrackId id); +[[nodiscard]] const TurnoutPlacement* find_turnout(const Document& document, TurnoutId id); +[[nodiscard]] TurnoutPlacement* find_turnout(Document& document, TurnoutId id); +[[nodiscard]] const TurnoutType* find_type(const Document& document, const std::string& name); +/// The element itself, wherever it is; and the track it belongs to. Null when +/// unknown. +[[nodiscard]] const Element* find_element(const Document& document, ElementId id); +[[nodiscard]] const Track* find_element_track(const Document& document, ElementId id); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/render.hpp b/maj0sted/include/maj0sted/editor/render.hpp new file mode 100644 index 00000000..539ccb24 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/render.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// One drawable rail, tagged with what it came from so the host can colour it +/// and hit-test it. Exactly one of @c element / @c turnout is set. +struct PlanPolyline { + Kind kind{Kind::Line}; + ElementId element{ElementId::none}; + TrackId track{TrackId::none}; + TurnoutId turnout{TurnoutId::none}; + /// For a turnout rail: which piece of the diverging path it is, and what part + /// that piece plays (0 przediglicowy, 1 iglica, 2 łuk, 3 krzyżownicowa), so a + /// drawing can say every element apart. -1 on a plain track rail. + int part{-1}; + std::size_t piece{0}; + double length{0.0}; + double radius_start{0.0}; ///< 0 == infinite + double radius_end{0.0}; + std::vector points; +}; + +/// Draws a solved layout as rails: two per element, a gauge apart. Turnout +/// diverging paths are drawn too, tagged with their turnout. +/// +/// This is presentation only. It decides nothing about geometry, and the +/// exporter no longer reads it back — the axis it needs is in the Solution. +[[nodiscard]] std::vector render_rails(const Solution& solution, + double gauge_metres = 1.5); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/ribbon.hpp b/maj0sted/include/maj0sted/editor/ribbon.hpp deleted file mode 100644 index 2f39a0ad..00000000 --- a/maj0sted/include/maj0sted/editor/ribbon.hpp +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline - -namespace maj0sted::editor { - -/// One element of an alignment "ribbon" (element chain), defined intrinsically. -struct RibbonElement { - int kind{0}; ///< 0 = line, 1 = arc, 2 = spiral (clothoid) - double length{0.0}; ///< line / spiral length (arc length is derived) - double radius{0.0}; ///< arc radius; spiral end radius (0 = straight / infinite) - double angle{0.0}; ///< arc central angle (radians) - double radius_start{0.0}; ///< spiral start radius (0 = infinite) - int dir{1}; ///< +1 left, -1 right (arc / spiral) -}; - -/// A whole alignment defined as a start pose plus an ordered element chain, -/// laid out head-to-tail. Handles any arc angle (including 180°+, loops) because -/// nothing depends on tangent intersection. -struct RibbonRequest { - double x0{0.0}; - double y0{0.0}; - double az0{0.0}; ///< start azimuth (radians, clockwise from north) - std::vector elements; -}; - -/// Lays the ribbon out and returns one polyline per element, ready to draw. -[[nodiscard]] std::vector solve_ribbon(const RibbonRequest& request); - -} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/sketch.hpp b/maj0sted/include/maj0sted/editor/sketch.hpp new file mode 100644 index 00000000..d7ce0294 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/sketch.hpp @@ -0,0 +1,118 @@ +#pragma once + +#include +#include + +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// Laying track by clicking along the ground. +/// +/// The corner is worked out here, at the click, and written into the document as +/// ordinary elements with ordinary lengths — a straight shortened by the +/// tangent, an arc of the radius asked for, a straight running out to the point +/// clicked. Nothing is left pointing at the drawing, so the numbers in the +/// document are the numbers on the ground. + +/// What rounding one corner costs. +struct Corner { + bool ok{false}; + /// How far back from the corner the arc starts, and how far past it the arc + /// ends: R·tan(|Δ|/2). Both straights lose this much. + double tangent{0.0}; + double arc_length{0.0}; ///< R·|Δ| + int hand{0}; ///< +1 left, -1 right + double deflection{0.0}; ///< Δ, radians, signed, + = left +}; + +/// Rounds the corner between a heading in and a heading out with an arc of +/// @p radius. Both headings are unit vectors in the plan's frame. +/// +/// Not ok for a corner that does not bend, one that doubles back on itself, or a +/// non-positive radius: none of those is an arc, and guessing one is how a fit +/// used to quietly lay geometry nobody asked for. +[[nodiscard]] Corner fit_corner(double in_hx, double in_hy, double out_hx, double out_hy, + double radius); + +/// The three elements a straight-arc-straight corner is made of, ready to append +/// after @p incoming has been shortened by `corner.tangent`. +[[nodiscard]] Element make_arc(Document& document, double radius, int hand, double length); +[[nodiscard]] Element make_line(Document& document, double length); +[[nodiscard]] Element make_clothoid(Document& document, double end_radius, int hand, + double length); + +/// Carries a change of radius or hand across the joint it belongs to. +/// +/// A transition curve's radius is the radius it ends on, so it belongs to +/// whatever comes after it: the arc it eases into, or nothing at all when a +/// straight follows and it runs out flat at R=0. That is one number, not two, so +/// editing either side of the joint carries the other with it — and the joint +/// only ever looks forward. The transition behind an arc is the one that eases +/// into it; the one in front of it eases into whatever is beyond, which is none +/// of that arc's business. +void sync_joint(Track& track, std::size_t at); + +// --------------------------------------------------------------------------- +// Straights as lines, and the arcs between them +// --------------------------------------------------------------------------- + +/// The line a straight lies on: a point on it and the direction of travel. +/// +/// This is what a straight really is once it is drawn — where it lies and which +/// way it points. Its length is not part of it: that falls out of where the arcs +/// at either end come off it. +struct Support { + double px{0.0}, py{0.0}; + double dx{1.0}, dy{0.0}; +}; + +/// What a track hangs on: the line under every one of its straights, and where +/// the first and last of them begin and end. +/// +/// The corners between two straights are not in here, because they do not have +/// to be: given the lines, each of them is worked out. A corner hanging off +/// either end of the chain — a track that starts or finishes mid-curve — is not +/// in here either, for the opposite reason: it has nothing on its far side to be +/// fitted against, so it is carried along exactly as it was authored. +struct Skeleton { + std::vector lines; + double start_x{0.0}, start_y{0.0}; ///< where the first straight begins + double end_x{0.0}, end_y{0.0}; ///< where the last straight ends +}; + +/// Pulls @p track's skeleton out of @p solved. +/// +/// A chain must read as straights with corners between them — a corner being any +/// run of arcs and transition curves that turns one way, one arc or a basket of +/// them. Two straights may never touch: two straights end to end are one +/// straight. A track with no straight at all has no skeleton, and says so. +[[nodiscard]] bool skeleton_of(const Track& track, const SolvedTrack& solved, + Skeleton& out, std::string& why); + +/// Rewrites @p track to run along @p skeleton, keeping every arc's radius and +/// hand, and every transition curve's length. +/// +/// Each corner with a straight on both sides is fitted: the transitions turn what +/// they turn, the arcs share out what is left of the corner in the proportions +/// they were drawn with — so a basket curve stays that basket, tighter or wider — +/// and the whole corner is then slid along the line it leaves until it lands on +/// the line it joins. The turn is taken the way the arcs go, so a corner may run +/// past a half circle, up to a full one, and nothing shortens it to the way round +/// that happens to be shorter. +/// +/// A corner hanging off either end of the chain keeps exactly what it was given: +/// there is no second line to fit it against, so a track ending mid-curve simply +/// ends where that curve takes it. +/// +/// The skeleton's ends stay put and the anchor follows them — unless the track is +/// pinned to a turnout's port, which is not an anchor anyone may move: a change +/// that would drag the branch off its frog is refused instead. +/// +/// False, with a reason, when the skeleton cannot carry the chain. Nothing is +/// written to @p track unless all of it works out. +[[nodiscard]] bool lay_along_skeleton(Track& track, const Skeleton& skeleton, + std::string& why); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/solution.hpp b/maj0sted/include/maj0sted/editor/solution.hpp new file mode 100644 index 00000000..1112baff --- /dev/null +++ b/maj0sted/include/maj0sted/editor/solution.hpp @@ -0,0 +1,169 @@ +#pragma once + +#include +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" // Pose +#include "maj0sted/domain/geometry/turnout.hpp" // TurnoutPart +#include "maj0sted/editor/model.hpp" + +namespace maj0sted::editor { + +/// A point in the project's CRS (metres for EPSG:2180). +struct PlanPoint { + double x{0.0}; + double y{0.0}; +}; + +// --------------------------------------------------------------------------- +// Solved geometry +// --------------------------------------------------------------------------- + +/// What one authored element works out to on the ground. Curvatures rather than +/// radii, because that is what `geometry::layout_segment` takes and what makes a +/// straight (0, 0), an arc (k, k) and a clothoid (k0, k1) one shape. +struct SolvedElement { + ElementId id{ElementId::none}; + Kind kind{Kind::Line}; + domain::geometry::Pose start{}; ///< pose the element is laid from + domain::geometry::Pose end{}; ///< pose it ends on = the next element's start + double k0{0.0}; ///< signed curvature at the start (1/m) + double k1{0.0}; ///< signed curvature at the end + double length{0.0}; + std::vector points; ///< sampled axis, first point == start +}; + +/// A solved track: its elements in order, plus the axis flattened into one +/// polyline with no duplicated join points, which is what a station along the +/// track is resolved against. +struct SolvedTrack { + TrackId id{TrackId::none}; + std::vector elements; + std::vector centreline; + domain::geometry::Pose start{}; + domain::geometry::Pose end{}; + double length{0.0}; + /// True when the chain was laid whole. False means it was cut short by a + /// diagnostic — the elements up to the cut are still here and still drawable. + bool complete{true}; +}; + +/// One laid segment of a turnout's diverging path. +struct SolvedTurnoutSegment { + domain::geometry::Pose start{}; + double k0{0.0}, k1{0.0}, length{0.0}; + domain::TurnoutPart part{domain::TurnoutPart::Curve}; + std::vector points; +}; + +/// A construction point of a turnout: where two of its parts meet, or a point +/// the catalogue drawing is dimensioned from. Reported rather than left to be +/// eyeballed off the drawn curve. +struct TurnoutMark { + double x{0.0}, y{0.0}; + /// 0 PR, 1 ostrze iglicy (where the blade begins, the odcinek przediglicowy + /// behind it), 2 pięta iglicy, 3 joint inside the curve, 4 end of curve / + /// start of the frog rail, 5 KR, 6 punkt teoretyczny, 7 koniec dzioba iglicy + /// (A, where the blade is u thick), 8 koniec strugania, 9 koniec rozjazdu on + /// the through track (the frog projected back onto the straight). + int kind{0}; + double station{0.0}; ///< along the diverging path from PR; 0 for the theoretical point +}; + +struct SolvedTurnout { + TurnoutId id{TurnoutId::none}; + bool valid{false}; + domain::geometry::Pose start{}; ///< PR, on the through track + domain::geometry::Pose frog{}; ///< KR, where a branch anchors + double tangent_front{0.0}, tangent_back{0.0}; + double diverging_length{0.0}; + double through_length{0.0}; ///< PR to the frog's projection, along the through track + std::vector path; + std::vector marks; + + /// The blade as dimensioned: where its tip stands (the odcinek przediglicowy + /// is everything before it), how long the planed nose and the planing run, + /// the kąt przylegania, and whether the type's own a/d/u agree with the curve. + /// Łukowanie, as laid: the signed curvature of the through track under it, the + /// one the branch came out at, and how far the through track turns over the + /// turnout. All zero for a rozjazd zwyczajny. + double bend{0.0}; + double diverging_curvature{0.0}; + double bend_angle{0.0}; + + bool has_blade{false}; + double blade_station{0.0}; + double nose_end{0.0}; + double planing_length{0.0}; + double blade_angle{0.0}; + double blade_residual{0.0}; + /// What the laid path works out to, against what the catalogue promised: + /// deflection minus the crossing angle, and through-length minus the + /// catalogue length. Reported, never fitted away. + double angle_residual{0.0}; + double length_residual{0.0}; +}; + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +/// Why something could not be laid. Reported, never silently dropped: the user +/// gets the reason along with whatever part of the drawing did stand up. +enum class Code { + UnknownReference, ///< an anchor points at an id that is not there + CyclicDependency, ///< tracks/turnouts depend on each other in a circle + DegenerateElement, ///< non-positive radius or length, a clothoid that eases nothing + TurnoutInvalid, ///< catalogue numbers admit no geometry + TurnoutNotClosed, ///< the laid path misses the crossing angle or the catalogue length + StationOffTrack, ///< a turnout sits past the end of its through track + TouchingStraights, ///< two straights end to end, which is one straight + HoldConflict, ///< two elements of one movable track both hold it parallel + OffsetNotHeld, ///< a pinned track cannot move, so its offset is what it is +}; + +[[nodiscard]] const char* code_name(Code code); + +struct Diagnostic { + Code code{Code::DegenerateElement}; + TrackId track{TrackId::none}; + ElementId element{ElementId::none}; + TurnoutId turnout{TurnoutId::none}; + /// Human-readable, with the number that matters in it ("R=300 m nie mieści + /// się, maksimum 214,4 m"). The host shows this; it does not compose it. + std::string text; +}; + +// --------------------------------------------------------------------------- +// The whole solution +// --------------------------------------------------------------------------- + +/// Everything derived. Rebuilt whole on each solve, never fed back into the +/// document. Vectors rather than maps so iteration order is the document's and +/// two solves of the same document compare equal. +struct Solution { + std::vector tracks; + std::vector turnouts; + std::vector diagnostics; +}; + +[[nodiscard]] const SolvedTrack* find_track(const Solution& solution, TrackId id); +[[nodiscard]] const SolvedTurnout* find_turnout(const Solution& solution, TurnoutId id); +[[nodiscard]] const SolvedElement* find_element(const Solution& solution, ElementId id); + +/// Pose at @p station metres along a solved track's centreline. Clamped to the +/// ends. Returns false for an empty track. +[[nodiscard]] bool pose_at(const SolvedTrack& track, double station, + domain::geometry::Pose& out); + +/// How much track of one steady curvature there is around @p station: @p behind +/// back to where the element begins, @p ahead on to where it ends, and +/// @p curvature what it runs at (signed, positive turns left; zero on a +/// straight). A turnout is laid as one piece of geometry, bent to the one radius +/// under it, so it belongs on a single element and has to fit on it whole. +/// False on a clothoid, where there is no one radius to bend to, and past the +/// end of the track. Nothing is written then. +[[nodiscard]] bool steady_room(const SolvedTrack& track, double station, double& curvature, + double& behind, double& ahead); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/solve.hpp b/maj0sted/include/maj0sted/editor/solve.hpp deleted file mode 100644 index 35a73647..00000000 --- a/maj0sted/include/maj0sted/editor/solve.hpp +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include - -namespace maj0sted::editor { - -/// A request from the GUI: three draggable vertices (entry straight A->B, exit -/// straight B->C) plus the fit parameters. Plain doubles, a flat DTO across the -/// boundary between the editor UI and this solver. -struct SolveRequest { - double ax{0.0}, ay{0.0}; ///< vertex A (entry start) - double bx{0.0}, by{0.0}; ///< vertex B (corner: entry end = exit start) - double cx{0.0}, cy{0.0}; ///< vertex C (exit end) - - int mode{0}; ///< 0 = arc, 1 = arc + symmetric transitions, 2 = compound (łuk koszowy) - - double radius{0.0}; ///< modes 0/1: arc radius - double transition{0.0}; ///< mode 1: symmetric transition-curve length - - // Mode 2 (compound): - double r1{0.0}; ///< first arc radius - double arc1_len{0.0}; ///< first arc length (split place) - double between{0.0}; ///< transition length between the two arcs - double r2{0.0}; ///< second arc radius - double entry_t{0.0}; ///< entry transition length - double exit_t{0.0}; ///< exit transition length -}; - -/// A point in the CRS. -struct PlanPoint { - double x{0.0}; - double y{0.0}; -}; - -/// One polyline; @c kind is render::ElementKind (0 straight, 1 arc, 2 transition). -struct PlanPolyline { - int kind{0}; - /// Logical owner used by map hit-testing. Exactly one is non-negative. - int straight_index{-1}; - int gap{-1}; - /// Position inside a fitted curve chain (arc/clothoid/arc...). - int element_index{-1}; - double length{0.0}; - /// Zero denotes an infinite radius (straight end of a transition). - double radius_start{0.0}; - double radius_end{0.0}; - std::vector points; -}; - -/// Builds the niweleta, runs the requested fit (falling back to the bare -/// straights if the parameters do not admit a fit) and returns the sampled plan -/// as polylines ready to draw. -[[nodiscard]] std::vector solve_scene(const SolveRequest& request); - -} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/io/document_io.hpp b/maj0sted/include/maj0sted/io/document_io.hpp new file mode 100644 index 00000000..c311cdae --- /dev/null +++ b/maj0sted/include/maj0sted/io/document_io.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "maj0sted/editor/model.hpp" + +namespace maj0sted::io { + +/// The `.m0s` format, version 3. +/// +/// Line-oriented text, one concept per token, every reference by id, and an +/// explicit version on the first line. Earlier versions are not read: they +/// described different models — anchored straights with mode-tagged fits between +/// them (1), then elements whose lengths were solved against construction lines +/// (2) — and translating either would have meant keeping those concepts alive in +/// the reader after they were removed from everything else. +/// +/// Doubles go through std::to_chars/from_chars, so a round trip is exact and +/// does not depend on the locale. +[[nodiscard]] std::string serialize(const editor::Document& document); + +/// Returns nothing when @p text is not a version 3 document or is malformed. +[[nodiscard]] std::optional deserialize(const std::string& text); + +[[nodiscard]] std::string default_project_path(); +bool save(const editor::Document& document, const std::string& path = default_project_path()); +[[nodiscard]] std::optional load( + const std::string& path = default_project_path()); + +} // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/io/project_serializer.hpp b/maj0sted/include/maj0sted/io/project_serializer.hpp deleted file mode 100644 index e43c8873..00000000 --- a/maj0sted/include/maj0sted/io/project_serializer.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/domain/map_project.hpp" - -namespace maj0sted::io { - -/// Serializes a whole map project (CRS + niwelety, each with its plan and -/// profile) to a self-contained textual format. The format is line-oriented and -/// dependency-free; @c deserialize round-trips it exactly. -[[nodiscard]] std::string serialize(const maj0sted::domain::MapProject& project); - -/// Rebuilds a map project from text produced by @c serialize. -/// @throws std::runtime_error on malformed input; std::invalid_argument if the -/// reconstructed geometry violates a domain invariant. -[[nodiscard]] maj0sted::domain::MapProject deserialize(const std::string& text); - -} // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/io/scn_export.hpp b/maj0sted/include/maj0sted/io/scn_export.hpp index 42b4edd2..4f64fc92 100644 --- a/maj0sted/include/maj0sted/io/scn_export.hpp +++ b/maj0sted/include/maj0sted/io/scn_export.hpp @@ -2,9 +2,8 @@ #include #include -#include -#include "maj0sted/editor/editor.hpp" +#include "maj0sted/editor/solution.hpp" namespace maj0sted::io { @@ -22,12 +21,18 @@ struct ScnExportResult { double origin_north{0.0}; }; -[[nodiscard]] ScnExportResult export_scn( - const std::vector& solved, - const ScnExportOptions& options, std::ostream& out); +/// Writes the solved layout as MaSzyna scenery. The axis comes straight out of +/// the solution: the exporter used to average the two drawn rails back together +/// to recover it, which put the whole geometry through the renderer and back. +/// +/// Height is still a constant and both roll slots are still zero — the vertical +/// alignment is a separate piece of work, and nothing in the editor produces one +/// yet. +[[nodiscard]] ScnExportResult export_scn(const editor::Solution& solution, + const ScnExportOptions& options, + std::ostream& out); -[[nodiscard]] ScnExportOptions resolve_scn_origin( - const std::vector& solved, - ScnExportOptions options); +[[nodiscard]] ScnExportOptions resolve_scn_origin(const editor::Solution& solution, + ScnExportOptions options); } // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/io/svg_writer.hpp b/maj0sted/include/maj0sted/io/svg_writer.hpp deleted file mode 100644 index fd9e1168..00000000 --- a/maj0sted/include/maj0sted/io/svg_writer.hpp +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include - -#include "maj0sted/render/scene.hpp" - -namespace maj0sted::io { - -struct SvgOptions { - double width_px = 900.0; ///< target drawing width - double margin_px = 24.0; ///< padding around the drawing - double stroke_px = 2.0; ///< line width -}; - -/// Renders a scene to a standalone SVG document (north up). Straights, arcs and -/// transition curves are drawn in distinct colours. Dependency-free — just a -/// simple viewer; a real GUI would consume render::Scene directly. -[[nodiscard]] std::string to_svg(const render::Scene& scene, const SvgOptions& options = {}); - -} // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/maj0sted.hpp b/maj0sted/include/maj0sted/maj0sted.hpp index aeaab42c..167c15a5 100644 --- a/maj0sted/include/maj0sted/maj0sted.hpp +++ b/maj0sted/include/maj0sted/maj0sted.hpp @@ -1,39 +1,27 @@ #pragma once -// maj0sted — a Domain-Driven Design library for a railway track-layout editor -// ("edytor torowiska"). Include this single header to pull in the whole public -// domain model. +// maj0sted — the model behind a railway track-layout editor ("edytor +// torowiska"). One header pulls in the whole public surface. +// +// The shape of it: a Document is authored (construction lines, catalogue +// turnout types and their placements, tracks made of elements), `solve` turns it +// into a Solution, and nothing ever travels back the other way. #include "maj0sted/version.hpp" -// Value Objects -#include "maj0sted/domain/value_objects/azimuth.hpp" -#include "maj0sted/domain/value_objects/cartesian_position.hpp" -#include "maj0sted/domain/value_objects/crs.hpp" -#include "maj0sted/domain/value_objects/length.hpp" -#include "maj0sted/domain/value_objects/niweleta_id.hpp" -#include "maj0sted/domain/value_objects/radius.hpp" -#include "maj0sted/domain/value_objects/straight_id.hpp" -#include "maj0sted/domain/value_objects/track_offset.hpp" +// The authored model, and what solving it produces. +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" -// Geometry — krzywe w planie i w profilu -#include "maj0sted/domain/geometry/horizontal_alignment.hpp" -#include "maj0sted/domain/geometry/plan_element.hpp" -#include "maj0sted/domain/geometry/profile_element.hpp" -#include "maj0sted/domain/geometry/vertical_alignment.hpp" +// Solving: one track's chain, and a whole document in dependency order. +#include "maj0sted/editor/chain.hpp" +#include "maj0sted/editor/layout.hpp" -// Aggregates -#include "maj0sted/domain/map_project.hpp" -#include "maj0sted/domain/niweleta.hpp" +// Geometry primitives shared by the solver and the renderer. +#include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/domain/geometry/turnout.hpp" -// Parallelism — równoległość torów -#include "maj0sted/domain/parallelism/parallelism_service.hpp" -#include "maj0sted/domain/parallelism/track_parallelism.hpp" - -// Fitting — dopasowanie krzywej między prostymi -#include "maj0sted/domain/fitting/fit_parameters.hpp" -#include "maj0sted/domain/fitting/fit_result.hpp" -#include "maj0sted/domain/fitting/fitting_service.hpp" - -// IO — serializacja projektu -#include "maj0sted/io/project_serializer.hpp" +// Presentation and IO. +#include "maj0sted/editor/render.hpp" +#include "maj0sted/io/document_io.hpp" +#include "maj0sted/io/scn_export.hpp" diff --git a/maj0sted/include/maj0sted/render/plan_sampler.hpp b/maj0sted/include/maj0sted/render/plan_sampler.hpp deleted file mode 100644 index 7899806c..00000000 --- a/maj0sted/include/maj0sted/render/plan_sampler.hpp +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include "maj0sted/domain/geometry/horizontal_alignment.hpp" -#include "maj0sted/domain/map_project.hpp" -#include "maj0sted/render/scene.hpp" - -namespace maj0sted::render { - -/// Samples a plan into absolute polylines in the CRS. Straights are -/// authoritative for position; curves (arcs, clothoids) are laid out continuing -/// from the previous element's end and heading. This is the reusable bridge -/// between the domain model and any renderer. -[[nodiscard]] Scene sample(const maj0sted::domain::HorizontalAlignment& plan); - -/// Samples the plans of every niweleta in the project into one scene. -[[nodiscard]] Scene sample(const maj0sted::domain::MapProject& project); - -} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/render/rail_renderer.hpp b/maj0sted/include/maj0sted/render/rail_renderer.hpp index e43f6d30..93125718 100644 --- a/maj0sted/include/maj0sted/render/rail_renderer.hpp +++ b/maj0sted/include/maj0sted/render/rail_renderer.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include "maj0sted/render/track_renderer.hpp" @@ -12,9 +13,9 @@ namespace maj0sted::render { struct RailPolyline { ElementKind kind{ElementKind::Straight}; std::vector points; - int straight_index{-1}; - int gap{-1}; - int element_index{-1}; + std::uint32_t element_id{0}; + std::uint32_t owner_id{0}; + bool from_turnout{false}; double length{0.0}; double radius_start{0.0}; double radius_end{0.0}; diff --git a/maj0sted/include/maj0sted/render/scene.hpp b/maj0sted/include/maj0sted/render/scene.hpp index 843e07a8..41812153 100644 --- a/maj0sted/include/maj0sted/render/scene.hpp +++ b/maj0sted/include/maj0sted/render/scene.hpp @@ -1,7 +1,5 @@ #pragma once -#include - namespace maj0sted::render { /// A point in the project's CRS (metres for EPSG:2180). @@ -13,21 +11,4 @@ struct Point { /// Which kind of plan element a polyline came from (lets a GUI colour it). enum class ElementKind { Straight, Arc, Transition }; -/// A polyline approximating one plan element, in CRS coordinates. -struct Polyline { - ElementKind kind; - std::vector points; -}; - -/// A rendering-agnostic view of one or more plans: polylines plus a bounding box. -/// Any GUI can draw this — the library computes the geometry, the GUI only paints. -struct Scene { - std::vector polylines; - double min_x{0.0}; - double min_y{0.0}; - double max_x{0.0}; - double max_y{0.0}; - bool empty{true}; -}; - } // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/render/track_renderer.hpp b/maj0sted/include/maj0sted/render/track_renderer.hpp index 14210973..bd5b9201 100644 --- a/maj0sted/include/maj0sted/render/track_renderer.hpp +++ b/maj0sted/include/maj0sted/render/track_renderer.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include "maj0sted/render/scene.hpp" // Point, ElementKind @@ -12,12 +13,12 @@ namespace maj0sted::render { /// hands to a renderer. struct CentrelineElement { ElementKind kind{ElementKind::Straight}; - std::vector points; ///< sampled axis in CRS metres - int straight_index{-1}; ///< owning straight, or -1 for a curve element - int gap{-1}; ///< owning gap, or -1 for a straight - int element_index{-1}; ///< position within a gap's curve chain - double length{0.0}; ///< element length (metres) - double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end) + std::vector points; ///< sampled axis in CRS metres + std::uint32_t element_id{0}; ///< the authored element this came from + std::uint32_t owner_id{0}; ///< the track or turnout it belongs to + bool from_turnout{false}; ///< true when owner_id names a turnout + double length{0.0}; ///< element length (metres) + double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end) double radius_end{0.0}; }; diff --git a/maj0sted/src/app/basket_draft.cpp b/maj0sted/src/app/basket_draft.cpp deleted file mode 100644 index 0bbef566..00000000 --- a/maj0sted/src/app/basket_draft.cpp +++ /dev/null @@ -1,389 +0,0 @@ -#include "maj0sted/app/basket_draft.hpp" - -#include -#include - -#include "maj0sted/app/straight_constraints.hpp" - -namespace maj0sted::app { -namespace { - -using maj0sted::editor::CompoundArcSpec; -using maj0sted::editor::GapFit; -using maj0sted::editor::NiweletaPolys; -using maj0sted::editor::NiweletaSpec; -using maj0sted::editor::RibbonElement; -using maj0sted::editor::RibbonRequest; -using maj0sted::editor::StraightSpec; -using maj0sted::editor::PlanPolyline; - -void sanitize_draft_arcs(BasketDraft& draft) { - for (auto& arc : draft.arcs) { - if (!(arc.radius >= 30.0)) { - arc.radius = 30.0; - } - if (!(arc.length > 0.0)) { - arc.length = 5.0; - } - if (arc.transition_to_next < 0.0) { - arc.transition_to_next = 0.0; - } - } - if (draft.entry_t < 0.0) { - draft.entry_t = 0.0; - } - if (draft.exit_t < 0.0) { - draft.exit_t = 0.0; - } -} - -bool draft_end_pose(const BasketDraft& draft, - const std::vector& polys, double& out_x, - double& out_y, double& out_az) { - if (!draft.active) { - return false; - } - out_az = draft.az0; - if (!polys.empty() && !polys.back().points.empty()) { - const auto& pts = polys.back().points; - out_x = pts.back().x; - out_y = pts.back().y; - if (pts.size() >= 2) { - out_az = std::atan2(pts.back().x - pts[pts.size() - 2].x, - pts.back().y - pts[pts.size() - 2].y); - } - return true; - } - out_x = draft.x0; - out_y = draft.y0; - return true; -} - -} // namespace - -RibbonRequest draft_to_ribbon(const BasketDraft& draft) { - RibbonRequest req; - req.x0 = draft.x0; - req.y0 = draft.y0; - req.az0 = draft.az0; - if (draft.arcs.empty()) { - return req; - } - const int dir = draft.dir >= 0 ? 1 : -1; - if (draft.entry_t > 1e-6) { - RibbonElement el; - el.kind = 2; - el.length = draft.entry_t; - el.radius_start = 0.0; - el.radius = draft.arcs.front().radius; - el.dir = dir; - req.elements.push_back(el); - } - for (std::size_t i = 0; i < draft.arcs.size(); ++i) { - const auto& arc = draft.arcs[i]; - const double r = std::max(30.0, arc.radius); - const double len = std::max(1e-3, arc.length); - RibbonElement a; - a.kind = 1; - a.radius = r; - a.angle = len / r; - a.dir = dir; - req.elements.push_back(a); - if (arc.transition_to_next > 1e-6) { - RibbonElement kp; - kp.kind = 2; - kp.length = arc.transition_to_next; - kp.radius_start = r; - if (i + 1 < draft.arcs.size()) { - kp.radius = std::max(30.0, draft.arcs[i + 1].radius); - } else { - kp.radius = std::max(30.0, draft.preview_next_r); - } - kp.dir = dir; - req.elements.push_back(kp); - } - } - if (draft.exit_t > 1e-6) { - RibbonElement el; - el.kind = 2; - el.length = draft.exit_t; - el.radius_start = draft.arcs.back().radius; - el.radius = 0.0; - el.dir = dir; - req.elements.push_back(el); - } - return req; -} - -std::vector preview_basket_draft(BasketDraft& draft, - double fallback_next_r) { - if (!draft.active || draft.arcs.empty()) { - return {}; - } - sanitize_draft_arcs(draft); - draft.preview_next_r = std::max(30.0, fallback_next_r); - return maj0sted::editor::solve_ribbon(draft_to_ribbon(draft)); -} - -void start_basket_draft_from_end(BasketDraft& draft, const StraightSpec& straight, - int straight_index, int end, double default_r) { - draft = {}; - draft.active = true; - draft.attach_str = straight_index; - draft.attach_end = end; - draft.dir = 1; - draft.preview_next_r = default_r; - draft.reedit_gap = -1; - draft.reedit_backup = {}; - if (end == 1) { - draft.x0 = straight.x2; - draft.y0 = straight.y2; - draft.az0 = std::atan2(straight.x2 - straight.x1, straight.y2 - straight.y1); - } else { - draft.x0 = straight.x1; - draft.y0 = straight.y1; - draft.az0 = std::atan2(straight.x1 - straight.x2, straight.y1 - straight.y2); - } - draft.arcs.push_back({600.0, 60.0, 40.0}); - draft.arcs.push_back({std::max(30.0, default_r), 80.0, 0.0}); -} - -bool reopen_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta, - std::size_t gap, const NiweletaPolys* solved) { - if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) { - return false; - } - const auto& fit = niweleta.fits[gap]; - if (fit.mode != 3 || fit.arcs.empty()) { - return false; - } - - std::vector solved_arc_lengths; - if (solved != nullptr) { - for (const auto& poly : solved->polylines) { - if (poly.gap != static_cast(gap) || poly.kind != 1 || - poly.element_index < 0) { - continue; - } - bool seen = false; - for (const auto& other : solved->polylines) { - if (&other == &poly) { - break; - } - if (other.gap == poly.gap && - other.element_index == poly.element_index) { - seen = true; - break; - } - } - if (!seen) { - solved_arc_lengths.push_back(std::max(5.0, poly.length)); - } - } - } - - const auto& entry = niweleta.straights[gap]; - const auto& exit = niweleta.straights[gap + 1]; - const double edx = entry.x2 - entry.x1; - const double edy = entry.y2 - entry.y1; - const double xdx = exit.x2 - exit.x1; - const double xdy = exit.y2 - exit.y1; - const double turn = edx * xdy - edy * xdx; - - draft = {}; - draft.active = true; - draft.reedit_backup = fit; - draft.reedit_gap = static_cast(gap); - draft.attach_str = static_cast(gap); - draft.attach_end = 1; - draft.x0 = entry.x2; - draft.y0 = entry.y2; - draft.az0 = std::atan2(edx, edy); - draft.dir = turn >= 0.0 ? 1 : -1; - draft.entry_t = std::max(0.0, fit.entry_t); - draft.exit_t = std::max(0.0, fit.exit_t); - draft.arcs = fit.arcs; - for (std::size_t i = 0; i < draft.arcs.size(); ++i) { - if (!(draft.arcs[i].radius >= 30.0)) { - draft.arcs[i].radius = 30.0; - } - if (draft.arcs[i].length <= 1e-6) { - draft.arcs[i].length = - i < solved_arc_lengths.size() ? solved_arc_lengths[i] : 60.0; - } - if (draft.arcs[i].transition_to_next < 0.0) { - draft.arcs[i].transition_to_next = 0.0; - } - } - if (!draft.arcs.empty()) { - draft.arcs.back().transition_to_next = 0.0; - draft.preview_next_r = draft.arcs.back().radius; - } - - niweleta.fits[gap] = {}; - niweleta.fits[gap].gap = static_cast(gap); - niweleta.fits[gap].mode = 0; - return true; -} - -void discard_basket_draft(BasketDraft& draft, NiweletaSpec* niweleta) { - if (draft.reedit_gap >= 0 && niweleta != nullptr && - static_cast(draft.reedit_gap) < niweleta->fits.size()) { - niweleta->fits[static_cast(draft.reedit_gap)] = - draft.reedit_backup; - } - draft = {}; -} - -bool anchor_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta, - std::size_t gap) { - if (!draft.active || draft.arcs.empty() || niweleta.straights.size() < 2) { - return false; - } - if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) { - return false; - } - - const GapFit previous = niweleta.fits[gap]; - const BasketDraft draft_backup = draft; - - GapFit fit{}; - fit.gap = static_cast(gap); - fit.mode = 3; - fit.entry_t = std::max(0.0, draft.entry_t); - fit.exit_t = std::max(0.0, draft.exit_t); - fit.arcs = draft.arcs; - for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) { - if (!(fit.arcs[i].radius >= 30.0)) { - fit.arcs[i].radius = 30.0; - } - if (!(fit.arcs[i].length > 0.0)) { - fit.arcs[i].length = 40.0; - } - } - fit.arcs.back().length = 0.0; - fit.arcs.back().transition_to_next = 0.0; - if (!(fit.arcs.back().radius >= 30.0)) { - fit.arcs.back().radius = 30.0; - } - for (std::size_t i = 1; i < fit.arcs.size(); ++i) { - if (std::abs(fit.arcs[i].radius - fit.arcs[i - 1].radius) < 1.0) { - fit.arcs[i].radius = std::max(30.0, fit.arcs[i - 1].radius * 0.75); - } - } - - niweleta.fits[gap] = fit; - niweleta.straights[gap].rel_kind = 0; - niweleta.straights[gap + 1].rel_kind = 0; - - const auto probe = maj0sted::editor::solve_project( - std::vector{niweleta}); - bool applied = false; - if (!probe.empty()) { - for (const auto& af : probe.front().applied_fits) { - if (af.gap == static_cast(gap) && af.mode == 3) { - applied = true; - break; - } - } - } - if (!applied) { - niweleta.fits[gap] = previous; - draft = draft_backup; - return false; - } - - draft = {}; - return true; -} - -FinishBasketDraftResult finish_basket_draft( - BasketDraft& draft, NiweletaSpec& niweleta, - std::vector& all_niwelety, int niweleta_index, - double finish_straight_m) { - FinishBasketDraftResult result; - if (!draft.active || draft.arcs.empty() || draft.attach_str < 0 || - static_cast(draft.attach_str) >= niweleta.straights.size()) { - result.status = "brak łuku"; - return result; - } - - if (draft.reedit_gap >= 0) { - const auto gap = static_cast(draft.reedit_gap); - const GapFit backup = draft.reedit_backup; - niweleta.fits[gap] = backup; - if (!anchor_basket_draft(draft, niweleta, gap)) { - draft.reedit_gap = static_cast(gap); - draft.reedit_backup = backup; - result.status = "nie da się (kierunek / L+KP)"; - return result; - } - result.ok = true; - result.status = "luka " + std::to_string(gap); - return result; - } - - auto polys = preview_basket_draft(draft); - double tipx = 0.0, tipy = 0.0, tipaz = 0.0; - if (!draft_end_pose(draft, polys, tipx, tipy, tipaz)) { - result.status = "brak końca kosza"; - return result; - } - - const NiweletaSpec backup = niweleta; - const BasketDraft draft_backup = draft; - const int attach = draft.attach_str; - - auto& entry = niweleta.straights[static_cast(attach)]; - if (draft.attach_end == 0) { - std::swap(entry.x1, entry.x2); - std::swap(entry.y1, entry.y2); - draft.attach_end = 1; - } - entry.x2 = draft.x0; - entry.y2 = draft.y0; - entry.rel_kind = 0; - - const double sl = std::max(20.0, finish_straight_m); - StraightSpec exit_st{}; - exit_st.x1 = tipx; - exit_st.y1 = tipy; - exit_st.x2 = tipx + std::sin(tipaz) * sl; - exit_st.y2 = tipy + std::cos(tipaz) * sl; - - const auto insert_at = static_cast(attach + 1); - for (auto& fit : niweleta.fits) { - if (fit.gap >= attach) { - ++fit.gap; - } - } - auto bump_rel = [&](int delta) { - for (std::size_t n = 0; n < all_niwelety.size(); ++n) { - for (auto& st : all_niwelety[n].straights) { - if (st.rel_kind != 0 && st.rel_niw == niweleta_index && - st.rel_str >= static_cast(insert_at)) { - st.rel_str += delta; - } - } - } - }; - bump_rel(+1); - niweleta.straights.insert( - niweleta.straights.begin() + static_cast(insert_at), - exit_st); - align_gap_fits(niweleta); - - if (!anchor_basket_draft(draft, niweleta, static_cast(attach))) { - bump_rel(-1); - niweleta = backup; - draft = draft_backup; - result.status = "nie da się (kierunek / L+KP)"; - return result; - } - - result.ok = true; - result.status = "kosz + " + std::to_string(static_cast(sl)) + " m"; - return result; -} - -} // namespace maj0sted::app diff --git a/maj0sted/src/app/editor_document.cpp b/maj0sted/src/app/editor_document.cpp deleted file mode 100644 index 759bcc01..00000000 --- a/maj0sted/src/app/editor_document.cpp +++ /dev/null @@ -1,520 +0,0 @@ -#include "maj0sted/app/editor_document.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "maj0sted/io/project_serializer.hpp" -#include "maj0sted/maj0sted.hpp" - -namespace maj0sted::app { - -using namespace maj0sted::domain; -using maj0sted::editor::GapFit; -using maj0sted::editor::NiweletaSpec; -using maj0sted::editor::StraightSpec; - -namespace { - -// Shortest round-trippable, locale-independent double — matches maj0sted::io. -std::string num(double value) { - char buffer[64]; - const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value); - return std::string(buffer, result.ptr); -} - -std::vector tokenize(const std::string& line) { - std::vector tokens; - std::size_t i = 0; - const std::size_t n = line.size(); - while (i < n) { - while (i < n && std::isspace(static_cast(line[i]))) ++i; - std::size_t j = i; - while (j < n && !std::isspace(static_cast(line[j]))) ++j; - if (j > i) tokens.push_back(line.substr(i, j - i)); - i = j; - } - return tokens; -} - -double to_double(const std::string& text) { - double value{}; - const auto result = std::from_chars(text.data(), text.data() + text.size(), value); - if (result.ec != std::errc{}) { - throw std::runtime_error{"maj0sted: bad number '" + text + "'"}; - } - return value; -} - -long to_long(const std::string& text) { - long value{}; - const auto result = std::from_chars(text.data(), text.data() + text.size(), value); - if (result.ec != std::errc{}) { - throw std::runtime_error{"maj0sted: bad integer '" + text + "'"}; - } - return value; -} - -// Placeholder turn direction for the resolved-domain view: derived from the two -// straights' headings (sign of the cross product). -TurnDirection gap_direction(const StraightSpec& a, const StraightSpec& b) noexcept { - const double ax = a.x2 - a.x1, ay = a.y2 - a.y1; - const double bx = b.x2 - b.x1, by = b.y2 - b.y1; - return (ax * by - ay * bx) >= 0.0 ? TurnDirection::Left : TurnDirection::Right; -} - -double gap_radius(const GapFit* fit) noexcept { - if (fit != nullptr) { - if (fit->radius > 0.0) return fit->radius; - if (!fit->arcs.empty() && fit->arcs.front().radius > 0.0) { - return fit->arcs.front().radius; - } - if (fit->r1 > 0.0) return fit->r1; - } - return 300.0; // benign placeholder; arcs carry no length in this view -} - -} // namespace - -MapProject to_map_project(const EditorDocument& document) { - MapProject project{Crs::default_crs()}; - - for (std::size_t index = 0; index < document.niwelety.size(); ++index) { - const NiweletaSpec& spec = document.niwelety[index]; - try { - Niweleta niweleta{NiweletaId{static_cast(index + 1)}, - spec.name}; - for (std::size_t i = 0; i < spec.straights.size(); ++i) { - const StraightSpec& s = spec.straights[i]; - if (i > 0) { - // Keep two anchored straights from being adjacent (which the - // alignment forbids) by threading a floating placeholder arc. - const GapFit* fit = nullptr; - for (const auto& f : spec.fits) { - if (static_cast(f.gap) == i - 1 && f.mode > 0) { - fit = &f; - break; - } - } - niweleta.add_plan_element( - CircularArc{Radius::from_metres(gap_radius(fit)), - gap_direction(spec.straights[i - 1], s)}); - } - niweleta.add_plan_element( - Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}}); - } - project.add_niweleta(std::move(niweleta)); - } catch (...) { - // Degenerate/inconsistent niweleta: keep the (named) shell so the - // project still lists it; the editor section restores the details. - project.add_niweleta(Niweleta{ - NiweletaId{static_cast(index + 1)}, spec.name}); - } - } - return project; -} - -EditorDocument from_map_project(const MapProject& project) { - EditorDocument document; - for (const auto& niweleta : project.niwelety()) { - NiweletaSpec spec; - spec.name = niweleta.name(); - for (const auto& element : niweleta.plan().elements()) { - if (const auto* straight = std::get_if(&element)) { - spec.straights.push_back(StraightSpec{ - straight->start().x(), straight->start().y(), - straight->end().x(), straight->end().y()}); - } - } - document.niwelety.push_back(std::move(spec)); - } - return document; -} - -std::string serialize_document(const EditorDocument& document) { - // Reuse the domain serializer for a real, inspectable maj0sted project... - std::string out = maj0sted::io::serialize(to_map_project(document)); - - // ...then the lossless editor section (straights + fit parameters). - out += "editor " + std::to_string(document.niwelety.size()) + "\n"; - if (document.view_extent > 0.0) { - out += "eview " + num(document.view_x) + " " + num(document.view_y) + " " + - num(document.view_extent) + "\n"; - } - if (document.origin_set) { - out += "eorigin " + (document.georeferenced ? std::string{"1"} : "0") + " " + - num(document.origin_x) + " " + num(document.origin_y) + "\n"; - } - for (const auto& spec : document.niwelety) { - out += "eniw " + std::to_string(spec.straights.size()) + " " + - std::to_string(spec.fits.size()) + "\n"; - out += "ename " + spec.name + "\n"; - for (const auto& s : spec.straights) { - out += "estr " + num(s.x1) + " " + num(s.y1) + " " + num(s.x2) + " " + - num(s.y2) + " " + (s.hidden ? "1" : "0") + "\n"; - } - for (std::size_t i = 0; i < spec.straights.size(); ++i) { - const auto& s = spec.straights[i]; - if (s.rel_kind == 1) { - out += "epar " + std::to_string(i) + " " + - std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) + - " " + num(s.rel_offset) + "\n"; - } else if (s.rel_kind == 2) { - out += "eskew " + std::to_string(i) + " " + - std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) + - " " + num(s.rel_cot) + " " + std::to_string(s.rel_side) + " " + - num(s.rel_length) + "\n"; - } - } - for (const auto& f : spec.fits) { - // efit2: gap mode radius transition entry_t exit_t [r len trans]* - // The variable arc list supersedes the legacy fixed two-arc 'efit'. - out += "efit2 " + std::to_string(f.gap) + " " + - std::to_string(f.mode) + " " + num(f.radius) + " " + - num(f.transition) + " " + num(f.entry_t) + " " + num(f.exit_t) + - " " + std::to_string(f.arcs.size()); - for (const auto& a : f.arcs) { - out += " " + num(a.radius) + " " + num(a.length) + " " + - num(a.transition_to_next); - } - out += "\n"; - } - } - // switches: through/station/side/facing/skew/branch, then the internal curve - // inline in the same shape as an efit2 tail (mode radius trans entry exit …) - out += "ejuncs " + std::to_string(document.junctions.size()) + "\n"; - for (const auto& j : document.junctions) { - out += "ejunc " + std::to_string(j.through) + " " + num(j.station) + " " + - std::to_string(j.side) + " " + (j.facing ? std::string{"1"} : "0") + - " " + num(j.crossing_n) + " " + std::to_string(j.branch) + " " + - std::to_string(j.curve.mode) + " " + num(j.curve.radius) + " " + - num(j.curve.transition) + " " + num(j.curve.entry_t) + " " + - num(j.curve.exit_t) + " " + std::to_string(j.curve.arcs.size()); - for (const auto& a : j.curve.arcs) { - out += " " + num(a.radius) + " " + num(a.length) + " " + - num(a.transition_to_next); - } - // catalogue length trails the arcs, so files written before it still parse (length stays 0) - out += " " + num(j.length); - out += "\n"; - } - return out; -} - -EditorDocument deserialize_document(const std::string& text) { - std::vector lines; - { - std::istringstream in(text); - std::string line; - while (std::getline(in, line)) { - if (!line.empty() && line.back() == '\r') line.pop_back(); - lines.push_back(line); - } - } - - // Locate the editor section (the domain block before it is interop-only). - std::size_t cursor = lines.size(); - for (std::size_t i = 0; i < lines.size(); ++i) { - const auto tokens = tokenize(lines[i]); - if (!tokens.empty() && tokens[0] == "editor") { - cursor = i; - break; - } - } - if (cursor >= lines.size()) { - throw std::runtime_error{"maj0sted: missing 'editor' section"}; - } - - const auto next = [&]() -> const std::string& { - while (cursor < lines.size()) { - const std::string& l = lines[cursor++]; - bool only_ws = true; - for (const char c : l) { - if (!std::isspace(static_cast(c))) { - only_ws = false; - break; - } - } - if (!only_ws) return l; - } - throw std::runtime_error{"maj0sted: unexpected end of editor section"}; - }; - - EditorDocument document; - const auto header = tokenize(next()); - if (header.size() < 2 || header[0] != "editor") { - throw std::runtime_error{"maj0sted: expected 'editor '"}; - } - const long niweleta_count = to_long(header[1]); - // Optional camera/zoom line (absent in older files). - { - std::size_t peek = cursor; - while (peek < lines.size()) { - bool only_ws = true; - for (const char c : lines[peek]) { - if (!std::isspace(static_cast(c))) { - only_ws = false; - break; - } - } - if (!only_ws) { - break; - } - ++peek; - } - if (peek < lines.size()) { - const auto view_tok = tokenize(lines[peek]); - if (view_tok.size() >= 4 && view_tok[0] == "eview") { - next(); - document.view_x = to_double(view_tok[1]); - document.view_y = to_double(view_tok[2]); - document.view_extent = to_double(view_tok[3]); - } - } - // Optional local-zero line (absent in older files). May follow eview. - peek = cursor; - while (peek < lines.size()) { - bool only_ws = true; - for (const char c : lines[peek]) { - if (!std::isspace(static_cast(c))) { - only_ws = false; - break; - } - } - if (!only_ws) { - break; - } - ++peek; - } - if (peek < lines.size()) { - const auto origin_tok = tokenize(lines[peek]); - if (origin_tok.size() >= 4 && origin_tok[0] == "eorigin") { - next(); - document.origin_set = true; - document.georeferenced = to_long(origin_tok[1]) != 0; - document.origin_x = to_double(origin_tok[2]); - document.origin_y = to_double(origin_tok[3]); - } - } - } - for (long n = 0; n < niweleta_count; ++n) { - const auto eniw = tokenize(next()); - if (eniw.size() < 3 || eniw[0] != "eniw") { - throw std::runtime_error{"maj0sted: expected 'eniw '"}; - } - const long straight_count = to_long(eniw[1]); - const long fit_count = to_long(eniw[2]); - - NiweletaSpec spec; - const std::string& name_line = next(); - if (name_line.rfind("ename", 0) != 0) { - throw std::runtime_error{"maj0sted: expected 'ename'"}; - } - spec.name = name_line.size() > 6 ? name_line.substr(6) : std::string{}; - - for (long i = 0; i < straight_count; ++i) { - const auto e = tokenize(next()); - if (e.size() < 5 || e[0] != "estr") { - throw std::runtime_error{"maj0sted: bad 'estr' line"}; - } - spec.straights.push_back( - StraightSpec{to_double(e[1]), to_double(e[2]), to_double(e[3]), - to_double(e[4]), e.size() >= 6 && to_long(e[5]) != 0}); - } - // Optional parallel/skew lines sit between straights and fits; older files omit them. - auto apply_relation = [&](const std::vector& e) -> bool { - if (e.empty()) return false; - if (e[0] == "epar" && e.size() >= 5) { - const long idx = to_long(e[1]); - if (idx < 0 || static_cast(idx) >= spec.straights.size()) { - throw std::runtime_error{"maj0sted: bad 'epar' index"}; - } - auto& s = spec.straights[static_cast(idx)]; - s.rel_kind = 1; - s.rel_niw = static_cast(to_long(e[2])); - s.rel_str = static_cast(to_long(e[3])); - s.rel_offset = to_double(e[4]); - return true; - } - if (e[0] == "eskew" && e.size() >= 7) { - const long idx = to_long(e[1]); - if (idx < 0 || static_cast(idx) >= spec.straights.size()) { - throw std::runtime_error{"maj0sted: bad 'eskew' index"}; - } - auto& s = spec.straights[static_cast(idx)]; - s.rel_kind = 2; - s.rel_niw = static_cast(to_long(e[2])); - s.rel_str = static_cast(to_long(e[3])); - s.rel_cot = to_double(e[4]); - s.rel_side = static_cast(to_long(e[5])); - s.rel_length = to_double(e[6]); - return true; - } - return false; - }; - auto peek_tokens = [&]() -> std::vector { - std::size_t i = cursor; - while (i < lines.size()) { - bool only_ws = true; - for (const char c : lines[i]) { - if (!std::isspace(static_cast(c))) { - only_ws = false; - break; - } - } - if (!only_ws) { - return tokenize(lines[i]); - } - ++i; - } - return {}; - }; - while (apply_relation(peek_tokens())) { - next(); // consume the relation line - } - for (long i = 0; i < fit_count; ++i) { - const auto e = tokenize(next()); - GapFit f; - if (!e.empty() && e[0] == "efit2") { - // New variable-arc format. - if (e.size() < 8) { - throw std::runtime_error{"maj0sted: bad 'efit2' line"}; - } - f.gap = static_cast(to_long(e[1])); - f.mode = static_cast(to_long(e[2])); - f.radius = to_double(e[3]); - f.transition = to_double(e[4]); - f.entry_t = to_double(e[5]); - f.exit_t = to_double(e[6]); - const long arc_count = to_long(e[7]); - for (long a = 0; a < arc_count; ++a) { - const std::size_t base = 8 + static_cast(a) * 3; - if (base + 2 >= e.size()) { - throw std::runtime_error{"maj0sted: truncated 'efit2' arcs"}; - } - f.arcs.push_back(maj0sted::editor::CompoundArcSpec{ - to_double(e[base]), to_double(e[base + 1]), - to_double(e[base + 2])}); - } - } else if (!e.empty() && e[0] == "efit" && e.size() >= 11) { - // Legacy fixed two-arc format; arcs stay empty and to_request - // falls back to the r1/r2 scalar fields. - f.gap = static_cast(to_long(e[1])); - f.mode = static_cast(to_long(e[2])); - f.radius = to_double(e[3]); - f.transition = to_double(e[4]); - f.r1 = to_double(e[5]); - f.arc1_len = to_double(e[6]); - f.between = to_double(e[7]); - f.r2 = to_double(e[8]); - f.entry_t = to_double(e[9]); - f.exit_t = to_double(e[10]); - } else { - throw std::runtime_error{"maj0sted: bad 'efit'/'efit2' line"}; - } - spec.fits.push_back(f); - } - document.niwelety.push_back(std::move(spec)); - } - // Optional switches section (absent in older files); mirrors the eview/eorigin peek. - { - std::size_t peek = cursor; - while (peek < lines.size()) { - bool only_ws = true; - for (const char c : lines[peek]) { - if (!std::isspace(static_cast(c))) { - only_ws = false; - break; - } - } - if (!only_ws) break; - ++peek; - } - if (peek < lines.size()) { - const auto head = tokenize(lines[peek]); - if (head.size() >= 2 && head[0] == "ejuncs") { - next(); // consume the 'ejuncs ' header - const long junction_count = to_long(head[1]); - for (long j = 0; j < junction_count; ++j) { - const auto e = tokenize(next()); - if (e.size() < 12 || e[0] != "ejunc") { - throw std::runtime_error{"maj0sted: bad 'ejunc' line"}; - } - maj0sted::editor::Junction junction; - junction.through = static_cast(to_long(e[1])); - junction.station = to_double(e[2]); - junction.side = static_cast(to_long(e[3])); - junction.facing = to_long(e[4]) != 0; - junction.crossing_n = to_double(e[5]); - junction.branch = static_cast(to_long(e[6])); - junction.curve.mode = static_cast(to_long(e[7])); - junction.curve.radius = to_double(e[8]); - junction.curve.transition = to_double(e[9]); - junction.curve.entry_t = to_double(e[10]); - junction.curve.exit_t = to_double(e[11]); - const long arc_count = e.size() >= 13 ? to_long(e[12]) : 0; - for (long a = 0; a < arc_count; ++a) { - const std::size_t base = 13 + static_cast(a) * 3; - if (base + 2 >= e.size()) { - throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"}; - } - junction.curve.arcs.push_back(maj0sted::editor::CompoundArcSpec{ - to_double(e[base]), to_double(e[base + 1]), - to_double(e[base + 2])}); - } - // optional catalogue length after the arcs (absent in the earliest files) - const std::size_t after_arcs = 13 + static_cast(arc_count) * 3; - if (e.size() > after_arcs) { - junction.length = to_double(e[after_arcs]); - } - document.junctions.push_back(std::move(junction)); - } - } - } - } - return document; -} - -std::string default_project_path() { - // Fallback only; the editor passes its own path (see plan_panel). Relative, so - // it lands under the working directory rather than at a filesystem root. - return "editor/project.m0s"; -} - -bool save_project(const EditorDocument& document, const std::string& path) { - try { - const std::filesystem::path file{path}; - if (file.has_parent_path()) { - std::filesystem::create_directories(file.parent_path()); - } - std::ofstream out(file, std::ios::binary | std::ios::trunc); - if (!out) return false; - out << serialize_document(document); - return static_cast(out); - } catch (...) { - return false; - } -} - -std::optional load_project(const std::string& path) { - try { - const std::filesystem::path file{path}; - if (!std::filesystem::exists(file)) return std::nullopt; - std::ifstream in(file, std::ios::binary); - if (!in) return std::nullopt; - std::ostringstream buffer; - buffer << in.rdbuf(); - return deserialize_document(buffer.str()); - } catch (...) { - return std::nullopt; - } -} - -} // namespace maj0sted::app diff --git a/maj0sted/src/app/straight_constraints.cpp b/maj0sted/src/app/straight_constraints.cpp deleted file mode 100644 index 65ce03c7..00000000 --- a/maj0sted/src/app/straight_constraints.cpp +++ /dev/null @@ -1,112 +0,0 @@ -#include "maj0sted/app/straight_constraints.hpp" - -#include -#include - -#include "maj0sted/domain/geometry/plan_element.hpp" -#include "maj0sted/domain/parallelism/parallelism_service.hpp" -#include "maj0sted/domain/value_objects/cartesian_position.hpp" - -namespace maj0sted::app { -namespace { - -using maj0sted::domain::CartesianPosition; -using maj0sted::domain::ParallelismService; -using maj0sted::domain::Straight; -using maj0sted::editor::NiweletaSpec; -using maj0sted::editor::StraightSpec; - -Straight to_straight(const StraightSpec& s) { - return Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}}; -} - -} // namespace - -void align_gap_fits(NiweletaSpec& niweleta) { - const auto gaps = niweleta.straights.empty() ? std::size_t{0} - : niweleta.straights.size() - 1; - std::vector slots(gaps); - for (std::size_t gap = 0; gap < gaps; ++gap) { - slots[gap].gap = static_cast(gap); - } - for (const auto& fit : niweleta.fits) { - if (fit.gap >= 0 && static_cast(fit.gap) < gaps) { - slots[static_cast(fit.gap)] = fit; - } - } - niweleta.fits = std::move(slots); -} - -void apply_straight_constraints(std::vector& niwelety) { - for (int pass = 0; pass < 3; ++pass) { - bool changed = false; - for (auto& niweleta : niwelety) { - for (auto& st : niweleta.straights) { - if (st.rel_kind == 0 || st.rel_niw < 0 || st.rel_str < 0) { - continue; - } - if (static_cast(st.rel_niw) >= niwelety.size()) { - continue; - } - const auto& ref_niw = niwelety[static_cast(st.rel_niw)]; - if (static_cast(st.rel_str) >= ref_niw.straights.size()) { - continue; - } - const auto& ref = ref_niw.straights[static_cast(st.rel_str)]; - if (&ref == &st) { - continue; - } - - const double dx = ref.x2 - ref.x1; - const double dy = ref.y2 - ref.y1; - const double len = std::hypot(dx, dy); - if (len < 1e-6) { - continue; - } - - double x1 = st.x1, y1 = st.y1, x2 = st.x2, y2 = st.y2; - if (st.rel_kind == 1) { - const Straight projected = ParallelismService::project_onto_offset_line( - to_straight(ref), st.rel_offset, to_straight(st)); - x1 = projected.start().x(); - y1 = projected.start().y(); - x2 = projected.end().x(); - y2 = projected.end().y(); - } else if (st.rel_kind == 2) { - const double a = - std::atan2(1.0, std::max(1e-9, st.rel_cot)) * - (st.rel_side < 0 ? -1.0 : 1.0); - const double ux = dx / len; - const double uy = dy / len; - const double ca = std::cos(a); - const double sa = std::sin(a); - const double rx = ux * ca - uy * sa; - const double ry = ux * sa + uy * ca; - double L = std::hypot(st.x2 - st.x1, st.y2 - st.y1); - if (L < 1.0) { - L = st.rel_length > 0.0 ? st.rel_length : len; - } - st.rel_length = L; - x1 = st.x1; - y1 = st.y1; - x2 = st.x1 + rx * L; - y2 = st.y1 + ry * L; - } - - if (std::abs(st.x1 - x1) > 1e-9 || std::abs(st.y1 - y1) > 1e-9 || - std::abs(st.x2 - x2) > 1e-9 || std::abs(st.y2 - y2) > 1e-9) { - st.x1 = x1; - st.y1 = y1; - st.x2 = x2; - st.y2 = y2; - changed = true; - } - } - } - if (!changed) { - break; - } - } -} - -} // namespace maj0sted::app diff --git a/maj0sted/src/domain/fitting_service.cpp b/maj0sted/src/domain/fitting_service.cpp deleted file mode 100644 index d7285bb5..00000000 --- a/maj0sted/src/domain/fitting_service.cpp +++ /dev/null @@ -1,582 +0,0 @@ -#include "maj0sted/domain/fitting/fitting_service.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "maj0sted/domain/geometry/segment_layout.hpp" - -namespace maj0sted::domain { -namespace { - -constexpr double kEpsilon = 1e-9; - -struct Vec { - double east; - double north; -}; - -// Unit direction of travel for an azimuth (east=x, north=y). -Vec direction_of(Azimuth azimuth) noexcept { - return Vec{std::sin(azimuth.radians()), std::cos(azimuth.radians())}; -} - -const Straight& find_straight(const Niweleta& niweleta, StraightId id) { - for (const auto& element : niweleta.plan().elements()) { - if (const auto* straight = std::get_if(&element)) { - if (!straight->id().is_null() && straight->id() == id) { - return *straight; - } - } - } - throw std::invalid_argument{"Straight with the given id not found in the niweleta"}; -} - -// A segment of the chain: signed curvature k0 -> k1 over `length`. -struct Seg { - double k0; - double k1; - double length; -}; - -// Result of closing a chain onto the exit straight: the two tangent points that -// the entry/exit straights are trimmed to. -struct ChainClose { - CartesianPosition tangent_in; - CartesianPosition tangent_out; -}; - -double straight_length(const Straight& straight) noexcept { - return straight.length().metres(); -} - -double parameter_on(const Straight& straight, CartesianPosition point) noexcept { - const Vec direction = direction_of(straight.azimuth()); - return (point.x() - straight.start().x()) * direction.east + - (point.y() - straight.start().y()) * direction.north; -} - -bool valid_tangent_points(const Straight& entry, const Straight& exit, - const ChainClose& close) noexcept { - constexpr double tolerance = 1e-6; - const double entry_parameter = parameter_on(entry, close.tangent_in); - const double exit_parameter = parameter_on(exit, close.tangent_out); - // A zero-length trimmed straight cannot be represented by Straight. - return entry_parameter > tolerance && - entry_parameter <= straight_length(entry) + tolerance && - exit_parameter >= -tolerance && - exit_parameter < straight_length(exit) - tolerance; -} - -// Lays out `segments` head-to-tail from entry.start() heading along the entry -// azimuth, then slides the whole rigid chain along the entry line until its end -// lands on the exit line. Uses the SAME layout as the renderer, so the fitted -// tangent points coincide exactly with the drawn curve. -ChainClose solve_chain(const Straight& entry, const Straight& exit, - const std::vector& segments) { - const Vec d1 = direction_of(entry.azimuth()); - const Vec d2 = direction_of(exit.azimuth()); - - geometry::Pose pose{entry.start().x(), entry.start().y(), d1.east, d1.north}; - for (const auto& s : segments) { - pose = geometry::layout_segment(s.k0, s.k1, s.length, pose, nullptr); - } - - const Vec exit_normal{-d2.north, d2.east}; - const double denominator = d1.east * exit_normal.east + d1.north * exit_normal.north; - if (std::abs(denominator) < kEpsilon) { - throw std::invalid_argument{"Straights are parallel; nothing to fit"}; - } - const double offset = (pose.x - exit.start().x()) * exit_normal.east + - (pose.y - exit.start().y()) * exit_normal.north; - const double shift = -offset / denominator; - - return ChainClose{ - CartesianPosition{entry.start().x() + shift * d1.east, - entry.start().y() + shift * d1.north}, - CartesianPosition{pose.x + shift * d1.east, pose.y + shift * d1.north}}; -} - -} // namespace - -FitResult FittingService::fit_between_straights(const Niweleta& niweleta, - const FitParameters& params) { - if (params.entry.is_null() || params.exit.is_null()) { - throw std::invalid_argument{"Both straights must be identified"}; - } - if (params.entry == params.exit) { - throw std::invalid_argument{"Fitting needs two different straights"}; - } - return fit_between_straights(find_straight(niweleta, params.entry), - find_straight(niweleta, params.exit), params.radius, - params.transition_length); -} - -// --------------------------------------------------------------------------- -// Arc fit (tryb łuku) -// -// Rounds the corner between two supporting lines with a circular arc, optionally -// eased by a symmetric pair of clothoids (spiral–arc–spiral). The straights are -// treated as infinite lines: this routine finds the tangent points on those -// lines and returns the two straights trimmed to them. Whether those tangent -// points fall on the drawn finite segments is a separate, higher-level policy -// (see domain::GapFitter) — not this service's concern. -// -// The geometry is produced by laying the arc chain out with the SAME sampler the -// renderer uses (geometry::layout_segment via solve_chain) and sliding it onto -// the exit line. That guarantees the returned tangent points coincide with the -// drawn curve to the last bit, even for tight radii, so no lateral gap appears. -// --------------------------------------------------------------------------- -FitResult FittingService::fit_between_straights(const Straight& entry, - const Straight& exit, - Radius radius_value, - std::optional transition_value) { - const double radius = radius_value.metres(); // Radius guarantees radius > 0 - double transition = 0.0; - if (transition_value) { - transition = transition_value->metres(); - if (!(transition > 0.0)) { - throw std::invalid_argument{"Transition length must be positive"}; - } - } - - // Signed turn from the entry heading to the exit heading, in (-pi, pi]. Its - // sign is the turn direction; its magnitude is the total deflection the curve - // must absorb. - const Vec d1 = direction_of(entry.azimuth()); - const Vec d2 = direction_of(exit.azimuth()); - const double deflection = std::atan2(d1.east * d2.north - d1.north * d2.east, - d1.east * d2.east + d1.north * d2.north); - const double total_turn = std::abs(deflection); - if (total_turn < kEpsilon || std::abs(total_turn - std::numbers::pi) < kEpsilon) { - throw std::invalid_argument{ - "Straights are parallel, collinear or anti-parallel; no arc fits"}; - } - const TurnDirection direction = - deflection > 0.0 ? TurnDirection::Left : TurnDirection::Right; - const double curvature = (deflection > 0.0 ? 1.0 : -1.0) / radius; - - // A clothoid of length L run into a radius R bends the heading by L / (2R); - // the two symmetric spirals therefore consume L / R of the turn, and the - // circular arc between them takes whatever remains. - const double arc_turn = total_turn - transition / radius; - if (arc_turn < -kEpsilon) { - throw std::invalid_argument{ - "Transition curves are too long for this radius and deflection"}; - } - const double arc_length = radius * std::max(arc_turn, 0.0); - - // Chain the elements head-to-tail — clothoid in / arc / clothoid out — using - // one definition for both the closure solve and the returned plan elements. - std::vector chain; - std::vector curve; - chain.reserve(3); - curve.reserve(3); - if (transition > 0.0) { - chain.push_back({0.0, curvature, transition}); - curve.push_back(TransitionCurve{Length::from_metres(transition), - std::nullopt, radius_value, direction}); - } - chain.push_back({curvature, curvature, arc_length}); - curve.push_back( - CircularArc{radius_value, direction, Length::from_metres(arc_length)}); - if (transition > 0.0) { - chain.push_back({curvature, 0.0, transition}); - curve.push_back(TransitionCurve{Length::from_metres(transition), - radius_value, std::nullopt, direction}); - } - - const ChainClose close = solve_chain(entry, exit, chain); - return FitResult{ - Straight{entry.id(), entry.start(), close.tangent_in}, - std::move(curve), - Straight{exit.id(), close.tangent_out, exit.end()}, - close.tangent_in, - close.tangent_out, - direction, - }; -} - -FitResult FittingService::fit_uturn(const Straight& entry, const Straight& exit, - std::optional transition) { - const Vec d1 = direction_of(entry.azimuth()); - const Vec d2 = direction_of(exit.azimuth()); - const double dot = d1.east * d2.east + d1.north * d2.north; - if (dot > -1.0 + 1e-6) { - throw std::invalid_argument{"A U-turn needs two anti-parallel straights"}; - } - if (transition && !(transition->metres() > 0.0)) { - throw std::invalid_argument{"Transition length must be positive"}; - } - - // Signed perpendicular offset from the entry line to the exit line. - const Vec left{-d1.north, d1.east}; - const double offset = (exit.start().x() - entry.start().x()) * left.east + - (exit.start().y() - entry.start().y()) * left.north; - if (std::abs(offset) < kEpsilon) { - throw std::invalid_argument{"U-turn straights are collinear (zero offset)"}; - } - const double sign = (offset > 0.0) ? 1.0 : -1.0; // turn toward the exit line - const TurnDirection direction = - (offset > 0.0) ? TurnDirection::Left : TurnDirection::Right; - - // Plain 180° arc spans 2R across the offset, so R = |offset| / 2. (Transition - // curves would change the span; solved numerically — not yet supported here.) - const double radius = std::abs(offset) / 2.0; - const Radius radius_vo = Radius::from_metres(radius); - const double arc_length = radius * std::numbers::pi; - - // Start the reversal at the entry straight's end; the 180° arc lands on the - // exit line at the diametrically opposite point. - const CartesianPosition tangent_in = entry.end(); - const CartesianPosition tangent_out{tangent_in.x() + std::abs(offset) * sign * left.east, - tangent_in.y() + std::abs(offset) * sign * left.north}; - - std::vector curve; - curve.push_back(CircularArc{radius_vo, direction, Length::from_metres(arc_length)}); - (void)transition; // clothoid U-turn: follow-up - - return FitResult{ - Straight{entry.id(), entry.start(), tangent_in}, - std::move(curve), - Straight{exit.id(), tangent_out, exit.end()}, - tangent_in, - tangent_out, - direction, - }; -} - -FitResult FittingService::fit_reverse(const Straight& entry, const Straight& exit, - Radius radius_vo) { - constexpr double parallel_tolerance = 1e-6; - constexpr double angle_epsilon = 1e-5; - const double radius = radius_vo.metres(); - const Vec d1 = direction_of(entry.azimuth()); - const Vec d2 = direction_of(exit.azimuth()); - const double cross = d1.east * d2.north - d1.north * d2.east; - const double dot = d1.east * d2.east + d1.north * d2.north; - - const auto direction = [](double sign) { - return sign > 0.0 ? TurnDirection::Left : TurnDirection::Right; - }; - const auto opposite = [](TurnDirection value) { - return value == TurnDirection::Left ? TurnDirection::Right - : TurnDirection::Left; - }; - const auto result_from = [&](const ChainClose& close, double first_sign, - double first_angle, double second_angle) { - const TurnDirection first_direction = direction(first_sign); - std::vector curve; - curve.push_back(CircularArc{radius_vo, first_direction, - Length::from_metres(radius * first_angle)}); - curve.push_back(CircularArc{radius_vo, opposite(first_direction), - Length::from_metres(radius * second_angle)}); - return FitResult{ - Straight{entry.id(), entry.start(), close.tangent_in}, - std::move(curve), - Straight{exit.id(), close.tangent_out, exit.end()}, - close.tangent_in, - close.tangent_out, - first_direction, - }; - }; - - // Same-direction parallel lines are the canonical S-curve case. Their - // lateral displacement fixes the common arc angle for a requested radius. - if (std::abs(cross) <= parallel_tolerance) { - if (dot < 0.0) { - throw std::invalid_argument{ - "Anti-parallel straights require a U-turn, not a reverse curve"}; - } - const Vec left{-d1.north, d1.east}; - const double offset = - (exit.start().x() - entry.start().x()) * left.east + - (exit.start().y() - entry.start().y()) * left.north; - if (std::abs(offset) <= kEpsilon) { - throw std::invalid_argument{ - "Collinear straights do not need a reverse curve"}; - } - - const double cosine = 1.0 - std::abs(offset) / (2.0 * radius); - if (cosine < -1.0 || cosine >= 1.0) { - throw std::invalid_argument{ - "Requested radius cannot span the parallel-line offset"}; - } - const double angle = std::acos(std::clamp(cosine, -1.0, 1.0)); - if (angle <= angle_epsilon) { - throw std::invalid_argument{"Reverse-curve angle is too small"}; - } - const double first_sign = offset > 0.0 ? 1.0 : -1.0; - const std::vector segments{ - {first_sign / radius, first_sign / radius, radius * angle}, - {-first_sign / radius, -first_sign / radius, radius * angle}, - }; - - geometry::Pose pose{entry.start().x(), entry.start().y(), d1.east, d1.north}; - for (const auto& segment : segments) { - pose = geometry::layout_segment(segment.k0, segment.k1, segment.length, - pose, nullptr); - } - const double exit_at_zero = - (pose.x - exit.start().x()) * d2.east + - (pose.y - exit.start().y()) * d2.north; - constexpr double length_epsilon = 1e-6; - const double lower = std::max(length_epsilon, -exit_at_zero); - const double upper = - std::min(straight_length(entry), - straight_length(exit) - length_epsilon - exit_at_zero); - if (lower > upper) { - throw std::invalid_argument{ - "Reverse curve does not fit on the finite parallel straights"}; - } - - // Prefer tangent points near entry.end() and exit.start(). - const double unconstrained = - 0.5 * (straight_length(entry) - exit_at_zero); - const double shift = std::clamp(unconstrained, lower, upper); - const ChainClose close{ - CartesianPosition{entry.start().x() + shift * d1.east, - entry.start().y() + shift * d1.north}, - CartesianPosition{pose.x + shift * d1.east, - pose.y + shift * d1.north}, - }; - if (!valid_tangent_points(entry, exit, close)) { - throw std::invalid_argument{ - "Reverse-curve tangent points lie outside the parallel straights"}; - } - return result_from(close, first_sign, angle, angle); - } - - const double deflection = std::atan2(cross, dot); - const double delta = std::abs(deflection); - if (std::abs(delta - std::numbers::pi) <= parallel_tolerance) { - throw std::invalid_argument{ - "Anti-parallel straights require a U-turn, not a reverse curve"}; - } - const double extra_max = std::numbers::pi - delta - angle_epsilon; - if (extra_max <= angle_epsilon) { - throw std::invalid_argument{"Not enough angle for a reverse curve"}; - } - - std::optional best; - double best_length = 0.0; - constexpr int samples = 720; - for (const double first_sign : {1.0, -1.0}) { - const double signed_delta = deflection / first_sign; - const auto attempt = [&](double extra) -> std::optional { - const double first_angle = - signed_delta >= 0.0 ? extra + signed_delta : extra; - const double second_angle = - signed_delta >= 0.0 ? extra : extra - signed_delta; - if (first_angle <= angle_epsilon || second_angle <= angle_epsilon || - first_angle >= std::numbers::pi || - second_angle >= std::numbers::pi) { - return std::nullopt; - } - const std::vector segments{ - {first_sign / radius, first_sign / radius, radius * first_angle}, - {-first_sign / radius, -first_sign / radius, radius * second_angle}, - }; - try { - const ChainClose close = solve_chain(entry, exit, segments); - if (!valid_tangent_points(entry, exit, close)) return std::nullopt; - return result_from(close, first_sign, first_angle, second_angle); - } catch (...) { - return std::nullopt; - } - }; - - double previous = angle_epsilon; - for (int sample = 1; sample <= samples; ++sample) { - const double extra = - angle_epsilon + - (extra_max - angle_epsilon) * static_cast(sample) / - static_cast(samples); - auto candidate = attempt(extra); - if (!candidate) { - previous = extra; - continue; - } - - // Refine the first feasible angle boundary to avoid grid-shaped - // jumps while dragging a straight in the editor. - double low = previous; - double high = extra; - std::optional refined = std::move(candidate); - for (int iteration = 0; iteration < 48; ++iteration) { - const double middle = std::midpoint(low, high); - if (auto middle_result = attempt(middle)) { - high = middle; - refined = std::move(middle_result); - } else { - low = middle; - } - } - const double total_length = radius * (delta + 2.0 * high); - if (!best || total_length < best_length) { - best = std::move(refined); - best_length = total_length; - } - break; - } - } - - if (!best) { - throw std::invalid_argument{ - "No equal-radius reverse curve fits the finite straights"}; - } - return *best; -} - -FitResult FittingService::fit_compound(const Niweleta& niweleta, - const CompoundFitParameters& params) { - if (params.entry.is_null() || params.exit.is_null()) { - throw std::invalid_argument{"Both straights must be identified"}; - } - if (params.entry == params.exit) { - throw std::invalid_argument{"Fitting needs two different straights"}; - } - return fit_compound(find_straight(niweleta, params.entry), - find_straight(niweleta, params.exit), params); -} - -FitResult FittingService::fit_compound(const Straight& entry, const Straight& exit, - const CompoundFitParameters& params) { - if (params.arcs.empty()) { - throw std::invalid_argument{"A compound curve needs at least one arc"}; - } - for (std::size_t k = 1; k < params.arcs.size(); ++k) { - if (params.arcs[k].radius == params.arcs[k - 1].radius) { - throw std::invalid_argument{ - "Adjacent compound arcs must have different radii"}; - } - } - - const Vec d1 = direction_of(entry.azimuth()); - const Vec d2 = direction_of(exit.azimuth()); - const double cross = d1.east * d2.north - d1.north * d2.east; - const double dot = d1.east * d2.east + d1.north * d2.north; - const double deflection = std::atan2(cross, dot); - const double delta = std::abs(deflection); - if (delta < kEpsilon || std::abs(delta - std::numbers::pi) < kEpsilon) { - throw std::invalid_argument{ - "Straights are parallel or collinear; nothing to fit"}; - } - const double sign = (deflection >= 0.0) ? 1.0 : -1.0; - const TurnDirection direction = - (deflection > 0.0) ? TurnDirection::Left : TurnDirection::Right; - - const std::size_t arc_count = params.arcs.size(); - const auto curvature = [&](std::size_t k) { - return sign / params.arcs[k].radius.metres(); - }; - const auto require_positive = [](const std::optional& length, - const char* message) -> double { - const double value = length->metres(); - if (value <= kEpsilon) throw std::invalid_argument{message}; - return value; - }; - - // Heading (deflection) consumed by everything except the last arc's angle: - // the fixed-length arcs plus every transition curve. The last arc absorbs - // whatever remains. - double consumed = 0.0; - std::vector arc_angle(arc_count, 0.0); - for (std::size_t k = 0; k + 1 < arc_count; ++k) { - if (!params.arcs[k].length) { - throw std::invalid_argument{"Every arc except the last needs a length"}; - } - const double angle = - require_positive(params.arcs[k].length, "Arc length must be positive") / - params.arcs[k].radius.metres(); - arc_angle[k] = angle; - consumed += angle; - } - - // Transition heading = mean curvature * length = (1/Ra + 1/Rb)/2 * L. - const double entry_len = - params.entry_transition - ? require_positive(params.entry_transition, "Transition length must be positive") - : 0.0; - if (entry_len > 0.0) { - consumed += 0.5 * (1.0 / params.arcs.front().radius.metres()) * entry_len; - } - const double exit_len = - params.exit_transition - ? require_positive(params.exit_transition, "Transition length must be positive") - : 0.0; - if (exit_len > 0.0) { - consumed += 0.5 * (1.0 / params.arcs.back().radius.metres()) * exit_len; - } - std::vector between(arc_count, 0.0); - for (std::size_t k = 0; k + 1 < arc_count; ++k) { - if (params.arcs[k].transition_to_next) { - const double length = require_positive(params.arcs[k].transition_to_next, - "Transition length must be positive"); - between[k] = length; - consumed += 0.5 * - (1.0 / params.arcs[k].radius.metres() + - 1.0 / params.arcs[k + 1].radius.metres()) * - length; - } - } - - arc_angle[arc_count - 1] = delta - consumed; - if (arc_angle[arc_count - 1] <= kEpsilon) { - throw std::invalid_argument{ - "Arc lengths and transitions exceed the total deflection"}; - } - - // Build the chain of segments: entry KP?, arc, (KP?, arc)..., exit KP?. - std::vector segments; - if (entry_len > 0.0) segments.push_back({0.0, curvature(0), entry_len}); - for (std::size_t k = 0; k < arc_count; ++k) { - segments.push_back({curvature(k), curvature(k), - params.arcs[k].radius.metres() * arc_angle[k]}); - if (k + 1 < arc_count && between[k] > 0.0) { - segments.push_back({curvature(k), curvature(k + 1), between[k]}); - } - } - if (exit_len > 0.0) segments.push_back({curvature(arc_count - 1), 0.0, exit_len}); - - const ChainClose close = solve_chain(entry, exit, segments); - const CartesianPosition tangent_in = close.tangent_in; - const CartesianPosition tangent_out = close.tangent_out; - - // Build the plan elements to splice in, mirroring the segment chain. - std::vector curve; - if (entry_len > 0.0) { - curve.push_back(TransitionCurve{Length::from_metres(entry_len), std::nullopt, - params.arcs.front().radius, direction}); - } - for (std::size_t k = 0; k < arc_count; ++k) { - curve.push_back( - CircularArc{params.arcs[k].radius, direction, - Length::from_metres(params.arcs[k].radius.metres() * arc_angle[k])}); - if (k + 1 < arc_count && between[k] > 0.0) { - curve.push_back(TransitionCurve{Length::from_metres(between[k]), - params.arcs[k].radius, - params.arcs[k + 1].radius, direction}); - } - } - if (exit_len > 0.0) { - curve.push_back(TransitionCurve{Length::from_metres(exit_len), - params.arcs.back().radius, std::nullopt, direction}); - } - - return FitResult{ - Straight{entry.id(), entry.start(), tangent_in}, - std::move(curve), - Straight{exit.id(), tangent_out, exit.end()}, - tangent_in, - tangent_out, - direction, - }; -} - -} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/gap_fitter.cpp b/maj0sted/src/domain/gap_fitter.cpp deleted file mode 100644 index 8d4dd5d3..00000000 --- a/maj0sted/src/domain/gap_fitter.cpp +++ /dev/null @@ -1,215 +0,0 @@ -#include "maj0sted/domain/fitting/gap_fitter.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "maj0sted/domain/fitting/fit_parameters.hpp" -#include "maj0sted/domain/fitting/fitting_service.hpp" - -namespace maj0sted::domain { - -namespace { - -std::optional optional_length(double metres) { - if (metres > 0.0) return Length::from_metres(metres); - return std::nullopt; -} - -// The two straights point in (nearly) opposite directions: the connection is a -// 180° reversal ("nawrót"), not an ordinary corner. Matches fit_uturn's own -// anti-parallel threshold. -bool is_reversal(const Straight& entry, const Straight& exit) { - const double cos_between = - std::cos(entry.azimuth().radians() - exit.azimuth().radians()); - return cos_between <= -1.0 + 1e-6; -} - -// A tangent point may extend the drawn straight *toward the corner* as far as the -// geometry needs (filling the gap between the finite straight and the corner), but -// must never cross behind the outer, anchored end — that would flip the straight -// around and fling it off-screen. So we only bound the outer side: the point must -// lie on the ray from @p outer through @p corner (t >= 0), with no upper limit. -bool tangent_ok(CartesianPosition p, CartesianPosition outer, - CartesianPosition corner) { - const double dx = corner.x() - outer.x(); - const double dy = corner.y() - outer.y(); - const double len2 = dx * dx + dy * dy; - if (len2 <= 0.0) return false; - const double t = ((p.x() - outer.x()) * dx + (p.y() - outer.y()) * dy) / len2; - return t >= -1e-3; -} - -double deflection(const Straight& entry, const Straight& exit) { - const double a1 = entry.azimuth().radians(); - const double a2 = exit.azimuth().radians(); - const double d1x = std::sin(a1), d1y = std::cos(a1); - const double d2x = std::sin(a2), d2y = std::cos(a2); - return std::abs(std::atan2(d1x * d2y - d1y * d2x, d1x * d2x + d1y * d2y)); -} - -// The single place the "lands on both finite straights" invariant is checked. -std::optional attempt_arc(const Straight& entry, const Straight& exit, - double radius, - std::optional transition) { - if (!(radius > 0.0)) return std::nullopt; - try { - FitResult fit = FittingService::fit_between_straights( - entry, exit, Radius::from_metres(radius), transition); - // The corner is at entry.end() and exit.start() (that is where the two - // straights meet through the fit); the anchored outer ends are the - // opposite endpoints. - if (tangent_ok(fit.tangent_in, entry.start(), entry.end()) && - tangent_ok(fit.tangent_out, exit.end(), exit.start())) { - return fit; - } - } catch (...) { - } - return std::nullopt; -} - -// Smallest radius the geometry admits: two symmetric clothoids consume the whole -// deflection at R = transition / delta, below which the arc angle turns -// negative; a plain arc has only a tiny numerical floor. -double smallest_radius(const Straight& entry, const Straight& exit, - std::optional transition) { - constexpr double kTiny = 1e-3; - if (!transition) return kTiny; - const double delta = deflection(entry, exit); - if (delta < 1e-3) return kTiny; - return std::max(kTiny, transition->metres() / delta) * (1.0 + 1e-10); -} - -// Bisects a fitting radius toward its non-fitting boundary and returns the fit -// at the fitting end. @p fitting fits, @p missing does not. -std::optional refine(const Straight& entry, const Straight& exit, - std::optional transition, double fitting, - double missing, double& used_radius) { - for (int step = 0; step < 60; ++step) { - const double mid = std::midpoint(fitting, missing); - if (attempt_arc(entry, exit, mid, transition)) { - fitting = mid; - } else { - missing = mid; - } - } - used_radius = fitting; - return attempt_arc(entry, exit, fitting, transition); -} - -// The largest radius <= @p requested_radius whose arc fits both straights, or -// nullopt when none does. A fit NEVER enlarges the user's radius: if the request -// is too large to land on the (extendable) straights, we only ever reduce it to -// the largest radius that does. A radius that is smaller than the request already -// fits (a tighter arc just extends the straights toward the corner), so it is -// used verbatim upstream and this reduction path is not even reached. -std::optional fit_nearest_arc(const Straight& entry, - const Straight& exit, - double requested_radius, - std::optional transition, - double& used_radius) { - const double floor = smallest_radius(entry, exit, transition); - constexpr int kSamples = 4096; - - // Scan DOWN from the request for the largest radius that fits, then bisect. - if (requested_radius > floor) { - const double span = requested_radius - floor; - double missing = requested_radius; - for (int i = 1; i <= kSamples; ++i) { - const double r = requested_radius - span * i / kSamples; - if (attempt_arc(entry, exit, r, transition)) { - return refine(entry, exit, transition, r, missing, used_radius); - } - missing = r; - } - } - return std::nullopt; -} - -std::optional connect_uturn(const Straight& entry, - const Straight& exit, - std::optional transition, - double requested_radius) { - try { - FitResult fit = FittingService::fit_uturn(entry, exit, transition); - double applied = requested_radius; - for (const auto& element : fit.curve) { - if (const auto* arc = std::get_if(&element)) { - applied = arc->radius().metres(); - break; - } - } - return GapConnection{std::move(fit), applied}; - } catch (...) { - return std::nullopt; - } -} - -std::optional connect_compound(const Straight& entry, - const Straight& exit, - const CompoundGapParameters& c) { - if (c.arcs.empty()) return std::nullopt; - try { - // Every arc except the last carries its fixed length (which fixes its - // angle) and an optional clothoid to the next; the last arc absorbs the - // remaining deflection. One arc => "łuk + klotoidy", two or more => a - // "łuk koszowy". - std::vector arcs; - arcs.reserve(c.arcs.size()); - for (std::size_t i = 0; i < c.arcs.size(); ++i) { - const auto& a = c.arcs[i]; - if (i + 1 == c.arcs.size()) { - arcs.push_back(CompoundArc{Radius::from_metres(a.radius)}); - } else { - arcs.push_back(CompoundArc{Radius::from_metres(a.radius), - Length::from_metres(a.length), - optional_length(a.transition_to_next)}); - } - } - FitResult fit = FittingService::fit_compound( - entry, exit, - CompoundFitParameters{ - .entry_transition = optional_length(c.entry_transition), - .exit_transition = optional_length(c.exit_transition), - .arcs = std::move(arcs)}); - return GapConnection{std::move(fit), c.arcs.front().radius}; - } catch (...) { - return std::nullopt; - } -} - -} // namespace - -std::optional GapFitter::connect( - const Straight& entry, const Straight& exit, - const GapConnectionRequest& request) { - // A compound curve is taken as requested (its arcs/clothoids are explicit). - if (request.mode == GapConnectionMode::Compound) { - return connect_compound(entry, exit, request.compound); - } - - // Plain arc. A 180° reversal is geometry-driven and handled transparently. - if (is_reversal(entry, exit)) { - return connect_uturn(entry, exit, std::nullopt, request.radius); - } - - // Use the requested radius verbatim when it fits (a tighter arc fits by - // extending the straights toward the corner). Only when the request is too - // large to land on them do we reduce it to the largest radius that fits — the - // radius is never enlarged. - if (auto fit = attempt_arc(entry, exit, request.radius, std::nullopt)) { - return GapConnection{std::move(*fit), request.radius}; - } - double applied = request.radius; - if (auto fit = fit_nearest_arc(entry, exit, request.radius, std::nullopt, applied)) { - return GapConnection{std::move(*fit), applied}; - } - return std::nullopt; -} - -} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/horizontal_alignment.cpp b/maj0sted/src/domain/horizontal_alignment.cpp deleted file mode 100644 index a554a651..00000000 --- a/maj0sted/src/domain/horizontal_alignment.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#include "maj0sted/domain/geometry/horizontal_alignment.hpp" - -#include -#include -#include - -namespace maj0sted::domain { -namespace { - -// Two anchored endpoints closer than this are treated as touching. -constexpr double kPositionToleranceM = 1e-6; -// Azimuths differing by less than this are treated as equal. -constexpr double kAzimuthToleranceRad = 1e-9; - -double distance(CartesianPosition a, CartesianPosition b) noexcept { - const double dx = b.x() - a.x(); - const double dy = b.y() - a.y(); - return std::sqrt(dx * dx + dy * dy); -} - -bool same_arc(const CircularArc& a, const CircularArc& b) noexcept { - return a.direction() == b.direction() && a.radius() == b.radius(); -} - -void validate_joint(const PlanElement& previous, const PlanElement& current, - JointContinuity joint) { - // Rule: two circular arcs of the same radius and direction must not meet. - if (const auto* a = std::get_if(&previous)) { - if (const auto* b = std::get_if(¤t)) { - if (same_arc(*a, *b)) { - throw std::logic_error{ - "Adjacent circular arcs with the same radius and direction " - "are not allowed"}; - } - } - } - - const auto* previous_straight = std::get_if(&previous); - const auto* current_straight = std::get_if(¤t); - - if (joint == JointContinuity::AzimuthBreak) { - if (previous_straight == nullptr || current_straight == nullptr) { - throw std::logic_error{ - "An azimuth break is only allowed between two straights"}; - } - if (distance(previous_straight->end(), current_straight->start()) > - kPositionToleranceM) { - throw std::logic_error{"Straights at an azimuth break must touch"}; - } - if (previous_straight->azimuth().angular_distance( - current_straight->azimuth()) <= kAzimuthToleranceRad) { - throw std::logic_error{ - "An azimuth break requires the two straights to have different " - "azimuths"}; - } - return; - } - - // Tangent joint. Only straight-to-straight can be verified now; joints that - // involve a floating curve are checked after fitting. - if (previous_straight != nullptr && current_straight != nullptr) { - if (distance(previous_straight->end(), current_straight->start()) > - kPositionToleranceM) { - throw std::logic_error{"Tangent straights must touch"}; - } - if (previous_straight->azimuth().angular_distance( - current_straight->azimuth()) > kAzimuthToleranceRad) { - throw std::logic_error{ - "Adjacent straights with different azimuths must be marked as an " - "azimuth break"}; - } - } -} - -} // namespace - -void HorizontalAlignment::append(PlanElement element, JointContinuity joint) { - if (!elements_.empty()) { - validate_joint(elements_.back(), element, joint); - joints_.push_back(joint); - } - elements_.push_back(std::move(element)); -} - -Length HorizontalAlignment::total_length() const { - Length total = Length::zero(); - for (const auto& element : elements_) { - if (const auto element_length = length_of(element)) { - total = total + *element_length; - } - } - return total; -} - -} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/map_project.cpp b/maj0sted/src/domain/map_project.cpp deleted file mode 100644 index aede9de8..00000000 --- a/maj0sted/src/domain/map_project.cpp +++ /dev/null @@ -1,13 +0,0 @@ -#include "maj0sted/domain/map_project.hpp" - -#include - -namespace maj0sted::domain { - -MapProject::MapProject(Crs crs) : crs_{crs} {} - -void MapProject::add_niweleta(Niweleta niweleta) { - niwelety_.push_back(std::move(niweleta)); -} - -} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/niweleta.cpp b/maj0sted/src/domain/niweleta.cpp deleted file mode 100644 index 3006c4f7..00000000 --- a/maj0sted/src/domain/niweleta.cpp +++ /dev/null @@ -1,90 +0,0 @@ -#include "maj0sted/domain/niweleta.hpp" - -#include -#include -#include -#include -#include - -namespace maj0sted::domain { - -Niweleta::Niweleta(NiweletaId id, std::string name) - : id_{id}, name_{std::move(name)} {} - -StraightId Niweleta::next_straight_id() noexcept { return StraightId{++straight_seq_}; } - -StraightId Niweleta::add_straight(CartesianPosition start, CartesianPosition end, - JointContinuity joint) { - const StraightId id = next_straight_id(); - plan_.append(Straight{id, start, end}, joint); - return id; -} - -void Niweleta::add_plan_element(PlanElement element, JointContinuity joint) { - if (auto* straight = std::get_if(&element); straight != nullptr) { - if (straight->id().is_null()) { - // A straight added without an identity gets one from the niweleta. - element = Straight{next_straight_id(), straight->start(), straight->end()}; - } else if (straight->id().value() > straight_seq_) { - // Keep the counter ahead of any explicitly supplied id (e.g. on load). - straight_seq_ = straight->id().value(); - } - } - plan_.append(std::move(element), joint); -} - -void Niweleta::apply_fit(const FitResult& result) { - const StraightId entry_id = result.entry.id(); - const StraightId exit_id = result.exit.id(); - if (entry_id.is_null() || exit_id.is_null()) { - throw std::invalid_argument{"apply_fit: trimmed straights must be identified"}; - } - - const auto& elements = plan_.elements(); - const auto index_of = [&](StraightId id) -> std::size_t { - for (std::size_t k = 0; k < elements.size(); ++k) { - const auto* straight = std::get_if(&elements[k]); - if (straight != nullptr && !straight->id().is_null() && - straight->id() == id) { - return k; - } - } - throw std::invalid_argument{"apply_fit: straight not found in plan"}; - }; - const std::size_t i = index_of(entry_id); - const std::size_t j = index_of(exit_id); - if (j != i + 1) { - throw std::invalid_argument{ - "apply_fit: the two straights must be adjacent (entry then exit)"}; - } - - // Rebuild the plan, re-validating every joint via append(). - HorizontalAlignment rebuilt; - const auto add = [&](PlanElement element, JointContinuity joint) { - rebuilt.append(std::move(element), joint); - }; - - for (std::size_t k = 0; k < i; ++k) { - add(elements[k], k == 0 ? JointContinuity::Tangent : plan_.joint(k - 1)); - } - add(result.entry, i == 0 ? JointContinuity::Tangent : plan_.joint(i - 1)); - for (const auto& curve_element : result.curve) { - add(curve_element, JointContinuity::Tangent); - } - add(result.exit, JointContinuity::Tangent); - for (std::size_t k = j + 1; k < elements.size(); ++k) { - add(elements[k], plan_.joint(k - 1)); - } - - plan_ = std::move(rebuilt); -} - -void Niweleta::add_profile_element(ProfileElement element) { - profile_.append(std::move(element)); -} - -bool Niweleta::lengths_consistent(double tolerance_m) const { - return std::abs(plan_length().metres() - profile_length().metres()) <= tolerance_m; -} - -} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/parallelism_service.cpp b/maj0sted/src/domain/parallelism_service.cpp deleted file mode 100644 index 34f93e2c..00000000 --- a/maj0sted/src/domain/parallelism_service.cpp +++ /dev/null @@ -1,83 +0,0 @@ -#include "maj0sted/domain/parallelism/parallelism_service.hpp" - -#include -#include - -namespace maj0sted::domain { -namespace { - -// Unit left-normal of a straight's direction of travel, in (east=x, north=y). -// Direction d = (sin az, cos az); rotating it +90 deg (counter-clockwise) gives -// the left normal (-cos az, sin az). -struct Vec { - double east; - double north; -}; - -Vec left_normal(Azimuth azimuth) noexcept { - return Vec{-std::cos(azimuth.radians()), std::sin(azimuth.radians())}; -} - -} // namespace - -TrackParallelism ParallelismService::mark_parallel(const Straight& origin, - const Straight& parallel, - TrackOffset offset) { - // The invariants (both identified, distinct) are enforced by TrackParallelism. - return TrackParallelism{origin.id(), parallel.id(), offset}; -} - -bool ParallelismService::are_parallel(const Straight& a, const Straight& b, - double tolerance_rad) { - const double difference = a.azimuth().angular_distance(b.azimuth()); // [0, pi] - return difference <= tolerance_rad || - std::abs(difference - std::numbers::pi) <= tolerance_rad; -} - -Straight ParallelismService::derive_parallel_line(const Straight& origin, - TrackOffset offset) { - const Vec normal = left_normal(origin.azimuth()); - const double sign = (offset.side() == Side::Left) ? 1.0 : -1.0; - const double shift = offset.distance().metres() * sign; - - const CartesianPosition start{origin.start().x() + normal.east * shift, - origin.start().y() + normal.north * shift}; - const CartesianPosition end{origin.end().x() + normal.east * shift, - origin.end().y() + normal.north * shift}; - return Straight{start, end}; -} - -double ParallelismService::perpendicular_distance(const Straight& origin, - const Straight& other) { - const Vec normal = left_normal(origin.azimuth()); - const double d_east = other.start().x() - origin.start().x(); - const double d_north = other.start().y() - origin.start().y(); - return std::abs(d_east * normal.east + d_north * normal.north); -} - -Straight ParallelismService::project_onto_offset_line(const Straight& origin, - double offset_m, - const Straight& segment) { - const double az = origin.azimuth().radians(); - const double ux = std::sin(az); - const double uy = std::cos(az); - const Vec normal = left_normal(origin.azimuth()); - const double ox = origin.start().x() + normal.east * offset_m; - const double oy = origin.start().y() + normal.north * offset_m; - - auto station = [&](double x, double y) { - return (x - ox) * ux + (y - oy) * uy; - }; - double t1 = station(segment.start().x(), segment.start().y()); - double t2 = station(segment.end().x(), segment.end().y()); - if (std::abs(t2 - t1) < 1.0) { - const double tm = 0.5 * (t1 + t2); - const double half = (t2 >= t1) ? 0.5 : -0.5; - t1 = tm - half; - t2 = tm + half; - } - return Straight{CartesianPosition{ox + ux * t1, oy + uy * t1}, - CartesianPosition{ox + ux * t2, oy + uy * t2}}; -} - -} // namespace maj0sted::domain diff --git a/maj0sted/src/domain/turnout.cpp b/maj0sted/src/domain/turnout.cpp index 4e9bb569..b88561b0 100644 --- a/maj0sted/src/domain/turnout.cpp +++ b/maj0sted/src/domain/turnout.cpp @@ -1,6 +1,9 @@ #include "maj0sted/domain/geometry/turnout.hpp" +#include #include +#include +#include namespace maj0sted::domain { @@ -19,50 +22,6 @@ double deflection(const TurnoutSegment& segment) { return 0.5 * (segment.k0 + segment.k1) * segment.length; } -// Builds the diverging curve as signed-curvature segments that together turn -// through exactly @p total_angle. Every part but the closing arc is fixed; the -// closing arc's length is chosen to spend whatever deflection is left. Returns -// false when a radius is non-positive or the fixed parts overshoot the angle. -bool build_curve(const DivergingCurve& curve, double total_angle, double sign, - std::vector& out) { - if (curve.arcs.empty()) return false; - const double first_radius = curve.arcs.front().radius; - const double last_radius = curve.arcs.back().radius; - if (first_radius <= 0.0 || last_radius <= 0.0) return false; - - double consumed = 0.0; // deflection of everything before the closing arc - - if (curve.entry_transition > 0.0) { - out.push_back({0.0, 0.0, sign / first_radius, curve.entry_transition}); - consumed += std::abs(deflection(out.back())); - } - - // every arc but the last contributes its fixed length, eased to the next - for (std::size_t i = 0; i + 1 < curve.arcs.size(); ++i) { - const double radius = curve.arcs[i].radius; - if (radius <= 0.0) return false; - out.push_back({0.0, sign / radius, sign / radius, curve.arcs[i].length}); - consumed += std::abs(deflection(out.back())); - - const double next_radius = curve.arcs[i + 1].radius; - if (curve.arcs[i].transition_to_next > 0.0 && next_radius > 0.0) { - out.push_back({0.0, sign / radius, sign / next_radius, - curve.arcs[i].transition_to_next}); - consumed += std::abs(deflection(out.back())); - } - } - - const double exit_deflection = 0.5 * curve.exit_transition / last_radius; - const double closing = total_angle - consumed - exit_deflection; - if (closing <= 0.0) return false; // the fixed parts already reach the crossing angle - - out.push_back({0.0, sign / last_radius, sign / last_radius, closing * last_radius}); - if (curve.exit_transition > 0.0) { - out.push_back({0.0, sign / last_radius, 0.0, curve.exit_transition}); - } - return true; -} - // Turns @p pose in place by @p angle (a heading break, no travel). Pose turn(const Pose& pose, double angle) { if (angle == 0.0) return pose; @@ -91,62 +50,264 @@ void tangent_lengths(const Pose& a, const Pose& b, double& front, double& back) back = std::hypot(cx - b.x, cy - b.y); } -} // namespace +// The heading of @p pose off the through track that runs through @p start, signed +// so that it grows as the path leaves it. +double heading_off(const Pose& start, const Pose& pose, double sign) { + return sign * std::atan2(pose.hy * start.hx - pose.hx * start.hy, + pose.hx * start.hx + pose.hy * start.hy); +} -TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout) { - TurnoutGeometry geometry; - - const double angle = turnout.mark.angle(); - if (angle <= 0.0) return geometry; - const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0; - - const double blade = turnout.blade_angle; - if (blade < 0.0 || blade >= angle) return geometry; // the blade may not reach the frog on its own - - // odcinek przediglicowy: still along the through track, the blade tip sits at its end - if (turnout.pre_blade > 0.0) { - geometry.path.push_back(TurnoutSegment{0.0, 0.0, 0.0, turnout.pre_blade}); +// The pose @p station metres along @p path. Stations are the same whichever frame +// the path is measured in, because bending moves curvature and never length. +Pose pose_at_station(const std::vector& path, const Pose& start, + double station) { + Pose pose = start; + double walked = 0.0; + for (const auto& segment : path) { + const Pose from = turn(pose, segment.turn_in); + if (station <= walked + segment.length) { + return layout_segment(segment.k0, segment.k1, station - walked, from, nullptr); + } + walked += segment.length; + pose = layout_segment(segment.k0, segment.k1, segment.length, from, nullptr); } - // the blade breaks away by beta and runs straight to its heel; with no blade - // length the break still stands and the curve starts at the tip - if (turnout.blade_length > 0.0) { - geometry.path.push_back(TurnoutSegment{sign * blade, 0.0, 0.0, turnout.blade_length}); + return pose; +} + +// Bends the laid path onto a through track of curvature @p bend, the way a rozjazd +// łukowy is made: the triangle the turnout is dimensioned by is rotated about its +// centre, so `t` and the crossing angle stand and every arc takes the radius that +// follows from them. +// +// What is re-cut and what is not comes straight from Koc: the zwrotnica and the +// krzyżownica keep their dimensions and are only bent, so the blade, the odcinek +// przediglicowy and the frog rail keep their lengths to the millimetre. The szyny +// łączące — the plain curve between them — take up the whole difference, which is +// small: the arc between two tangent points barely changes length when it is bent. +// +// False when there is no single arc radius to rotate about, nothing flexible to +// take up the change, or the bend is sharp enough to eat the connecting rails whole. +bool bend_path(std::vector& path, double alpha, double sign, double bend, + double& tangent_out, double& curvature_out, double& beta_out) { + double radius = 0.0; + double cut = 0.0; // what bending does not re-cut: switch and frog + double flexible = 0.0; // the connecting rails, which it does + for (const auto& segment : path) { + if (segment.k0 == 0.0 && segment.k1 == 0.0) continue; + if (segment.k0 != segment.k1) return false; // a clothoid has no one radius + const double own = std::abs(1.0 / segment.k0); + if (radius != 0.0 && std::abs(own - radius) > 1e-6 * radius) return false; + radius = own; + (segment.part == TurnoutPart::Curve ? flexible : cut) += segment.length; + } + if (radius <= 0.0 || flexible <= 0.0 || alpha <= 0.0) return false; + + const double tangent = radius * std::tan(0.5 * alpha); + const double beta = 2.0 * std::atan(tangent * bend); + const double turn_to = beta + sign * alpha; + if (std::abs(turn_to) >= std::numbers::pi - 1e-9) return false; + + // t/tg(theta/2) is the radius of every leg of the triangle, the through track's + // as much as the branch's. At theta = 0 the branch comes out straight, and its + // two tangents are then all the length there is between PR and the crossing + const double curvature = + std::abs(turn_to) > 1e-12 ? std::tan(0.5 * turn_to) / tangent : 0.0; + const double run = curvature != 0.0 ? turn_to / curvature : 2.0 * tangent; + const double spare = run - cut; + if (spare <= 0.0) return false; + + for (auto& segment : path) { + if (segment.k0 == 0.0 && segment.k1 == 0.0) continue; + segment.k0 = curvature; + segment.k1 = curvature; + if (segment.part == TurnoutPart::Curve) { + segment.length *= spare / flexible; + } } - // the curve only has `alfa - beta` left to turn through - std::vector curve_path; - if (!build_curve(turnout.curve, angle - blade, sign, curve_path)) { - geometry.path.clear(); - return geometry; - } - if (turnout.blade_length <= 0.0) curve_path.front().turn_in += sign * blade; - geometry.path.insert(geometry.path.end(), curve_path.begin(), curve_path.end()); + tangent_out = tangent; + curvature_out = curvature; + beta_out = beta; + return true; +} - // walk the curve to the frog direction (the crossing angle) +// Where along the laid path the blade first stands @p target away from the stock +// rail — the dimension a catalogue drawing gives its blade in (the tip is where the +// rail is u thick, the planing ends where it is a railhead wide). The gap is not the +// offset of the axis: it opens on the blade's own curve, half a gauge wider than the +// axis it is offset from (Koc, 5.2), so it is carried segment by segment and the one +// that crosses it is solved in place. The station is on the axis. Returns false when +// the path never gets that far. +bool station_at_offset(const std::vector& path, const Pose& start, + double target, double sign, double& station_out, Pose& pose_out) { + if (target <= 0.0) return false; + + Pose pose = start; + double station = 0.0; + double gap = 0.0; + for (const auto& segment : path) { + const Pose from = turn(pose, segment.turn_in); + const Pose to = advance(segment, pose); + const double heading = heading_off(start, from, sign); + const double radius = segment.k0 != 0.0 ? std::abs(1.0 / segment.k0) : 0.0; + const double opened = + radius > 0.0 + ? (radius + kHalfGauge) * + (std::cos(heading) - std::cos(heading_off(start, to, sign))) + : segment.length * std::sin(heading); + + if (gap <= target && gap + opened >= target) { + const double run = std::clamp( + blade_run_to_offset(radius, heading, target - gap), 0.0, segment.length); + station_out = station + run; + pose_out = layout_segment(segment.k0, segment.k1, run, from, nullptr); + return true; + } + gap += opened; + station += segment.length; + pose = to; + } + return false; +} + +// Fills the blade dimensions: where its tip stands, how much of the curve runs ahead +// of it as the odcinek przediglicowy, where its planed nose ends and where it reaches +// full railhead width. +// @p relative is the path with the through track's own curvature taken out of it: +// the blade opens against the opornica, so what dimensions it is how the two part +// company, not how either of them runs across the ground. For a rozjazd zwyczajny +// the two paths are the same thing. +void dimension_blade(const Turnout& turnout, const Pose& start, + const std::vector& relative, TurnoutGeometry& geometry, + double sign) { + double station = 0.0; Pose pose = start; for (const auto& segment : geometry.path) { - pose = advance(segment, pose); + if (segment.part != TurnoutPart::Blade) { + station += segment.length; + pose = advance(segment, pose); + continue; + } + geometry.has_blade = true; + // the blade begins where its piece does; everything ahead of it is the lead + geometry.blade_station = station; + geometry.blade_tip = turn(pose, segment.turn_in); + + double at{0.0}; + Pose at_pose{}; + if (station_at_offset(relative, start, turnout.blade.tip_thickness, sign, at, at_pose)) { + geometry.nose_end = at; + geometry.blade_nose_end = pose_at_station(geometry.path, start, at); + geometry.blade_angle = heading_off(start, at_pose, sign); + geometry.blade_residual = station + turnout.blade.nose - at; + } + if (station_at_offset(relative, start, turnout.blade.railtop_width, sign, at, at_pose)) { + geometry.planing_end = at; + geometry.blade_planed = pose_at_station(geometry.path, start, at); + geometry.planing_length = at - geometry.blade_station; + } + return; + } +} + +} // namespace + +double blade_run_to_offset(double radius, double angle, double offset) { + if (offset <= 0.0) return 0.0; + if (radius > 0.0) { + // the blade is an arc: the gap opens as `R1(cos beta - cos(beta + theta))` + // on the rail's own curve, and the axis spends `R theta` getting there + const double rail = radius + kHalfGauge; + const double target = std::cos(angle) - offset / rail; + if (target < -1.0) return 0.0; // the offset is more than the arc ever reaches + return radius * (std::acos(std::clamp(target, -1.0, 1.0)) - angle); + } + // a straight blade: the offset grows with the sine of the break + const double s = std::sin(angle); + return s > 1e-12 ? offset / s : 0.0; +} + +TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout, double bend) { + TurnoutGeometry geometry; + + if (turnout.path.empty()) return geometry; + const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0; + + for (const auto& piece : turnout.path) { + if (piece.length <= 0.0) return geometry; + if (piece.radius_start < 0.0 || piece.radius_end < 0.0) return geometry; + if (piece.turn_in < 0.0) return geometry; + geometry.path.push_back(TurnoutSegment{ + sign * piece.turn_in, piece.radius_start > 0.0 ? sign / piece.radius_start : 0.0, + piece.radius_end > 0.0 ? sign / piece.radius_end : 0.0, piece.length, piece.part}); } - // a turnout always runs on past the curve as a straight frog rail. its length - // takes the switch to its catalogue length, measured along the through track; - // without a catalogue length a tangent-length rail keeps a frog rail present. - const double through_projection = - (pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy; - const double cos_angle = pose.hx * start.hx + pose.hy * start.hy; - double rail = turnout.length > 0.0 && cos_angle > 1e-6 - ? (turnout.length - through_projection) / cos_angle - : turnout.curve.arcs.back().radius * std::tan(angle * 0.5); - if (rail > 0.0) { - const TurnoutSegment frog_rail{0.0, 0.0, 0.0, rail}; - geometry.path.push_back(frog_rail); - pose = advance(frog_rail, pose); + double tangent = 0.0; + double beta = 0.0; + if (bend != 0.0) { + double curvature = 0.0; + if (!bend_path(geometry.path, turnout.mark.angle(), sign, bend, tangent, curvature, + beta)) { + return geometry; + } + geometry.bend = bend; + geometry.bend_angle = beta; + geometry.diverging_curvature = curvature; + } + + // the path is laid exactly as authored: what it works out to is then checked + // against the catalogue numbers rather than fitted to them + Pose pose = start; + double deflected = 0.0; + for (const auto& segment : geometry.path) { + deflected += segment.turn_in + deflection(segment); + pose = advance(segment, pose); + geometry.diverging_length += segment.length; } geometry.valid = true; geometry.frog = pose; - for (const auto& segment : geometry.path) geometry.diverging_length += segment.length; tangent_lengths(start, pose, geometry.tangent_front, geometry.tangent_back); + + // the blade is dimensioned against the opornica, which bends with the turnout + std::vector relative = geometry.path; + for (auto& segment : relative) { + segment.k0 -= bend; + segment.k1 -= bend; + } + dimension_blade(turnout, start, relative, geometry, sign); + + // deflected is signed by the hand, and it is the through track the crossing + // angle is measured from — which turns by beta when the turnout is bent + geometry.angle_residual = sign * (deflected - beta) - turnout.mark.angle(); + + // where the switch ends on the tor zasadniczy: the frog, projected back onto it. + // this is the second point a drawing marks on the through track, and on a bent + // turnout the projection runs along the radius rather than square to a straight + if (bend == 0.0) { + geometry.through_length = + (pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy; + geometry.through_end = Pose{start.x + start.hx * geometry.through_length, + start.y + start.hy * geometry.through_length, start.hx, + start.hy}; + } else { + const double centre_x = start.x - start.hy / bend; + const double centre_y = start.y + start.hx / bend; + const double from = std::atan2(start.y - centre_y, start.x - centre_x); + const double to = std::atan2(pose.y - centre_y, pose.x - centre_x); + double swept = to - from; + while (swept > std::numbers::pi) swept -= 2.0 * std::numbers::pi; + while (swept < -std::numbers::pi) swept += 2.0 * std::numbers::pi; + geometry.through_length = swept / bend; + geometry.through_end = + layout_segment(bend, bend, geometry.through_length, start, nullptr); + } + + // the catalogue length is a + b, the two tangent legs of the drawing + if (turnout.length > 0.0) { + geometry.length_residual = + geometry.tangent_front + geometry.tangent_back - turnout.length; + } return geometry; } diff --git a/maj0sted/src/domain/vertical_alignment.cpp b/maj0sted/src/domain/vertical_alignment.cpp deleted file mode 100644 index fc576e10..00000000 --- a/maj0sted/src/domain/vertical_alignment.cpp +++ /dev/null @@ -1,13 +0,0 @@ -#include "maj0sted/domain/geometry/vertical_alignment.hpp" - -namespace maj0sted::domain { - -Length VerticalAlignment::total_length() const { - Length total = Length::zero(); - for (const auto& element : elements_) { - total = total + length_of(element); - } - return total; -} - -} // namespace maj0sted::domain diff --git a/maj0sted/src/editor/chain.cpp b/maj0sted/src/editor/chain.cpp new file mode 100644 index 00000000..f6f1da36 --- /dev/null +++ b/maj0sted/src/editor/chain.cpp @@ -0,0 +1,141 @@ +#include "maj0sted/editor/chain.hpp" + +#include +#include +#include +#include + +namespace maj0sted::editor { +namespace { + +constexpr double kEps = 1e-9; + +using domain::geometry::Pose; + +Diagnostic make(Code code, TrackId track, ElementId element, std::string text) { + Diagnostic diagnostic; + diagnostic.code = code; + diagnostic.track = track; + diagnostic.element = element; + diagnostic.text = std::move(text); + return diagnostic; +} + +Diagnostic degenerate(TrackId track, ElementId element, std::string text) { + return make(Code::DegenerateElement, track, element, std::move(text)); +} + +/// Signed curvature an element ends on. A straight, and a clothoid running out +/// to an infinite radius, both end flat. +double end_curvature(const Element& element) noexcept { + if (element.kind == Kind::Line || element.radius <= kEps) { + return 0.0; + } + return static_cast(element.hand) / element.radius; +} + +} // namespace + +// --------------------------------------------------------------------------- + +TrackOutcome lay_track(const Document& /*document*/, const Track& track, const Pose& start) { + TrackOutcome out; + out.track.id = track.id; + out.track.start = start; + out.track.end = start; + + // Two straights end to end are one straight: there is no corner between them, + // so there is nothing to grab hold of and nothing an arc could re-fit against. + // The chain is still laid — this is a rule about how track is written down, + // not about whether the geometry closes. + for (std::size_t i = 1; i < track.elements.size(); ++i) { + if (track.elements[i].kind == Kind::Line && + track.elements[i - 1].kind == Kind::Line) { + out.diagnostics.push_back(make( + Code::TouchingStraights, track.id, track.elements[i].id, + "dwie proste stykają się ze sobą — to jedna prosta o długości " + + std::to_string(static_cast(track.elements[i - 1].length + + track.elements[i].length)) + + " m; złącz je albo wstaw między nie łuk")); + } + } + + Pose walker = start; + // curvature the chain is running at where the previous element ended, which + // is what a clothoid eases away from. It carries across the whole chain: two + // clothoids back to back are as ordinary as two arcs. + double k_prev = 0.0; + + out.track.elements.reserve(track.elements.size()); + for (const Element& element : track.elements) { + double k0 = 0.0; + double k1 = 0.0; + + if (element.kind == Kind::Arc) { + if (element.radius <= kEps) { + out.diagnostics.push_back( + degenerate(track.id, element.id, "łuk musi mieć dodatni promień")); + out.track.complete = false; + break; + } + if (element.hand == 0) { + out.diagnostics.push_back(degenerate( + track.id, element.id, "łuk musi skręcać w lewo albo w prawo")); + out.track.complete = false; + break; + } + k0 = k1 = static_cast(element.hand) / element.radius; + } else if (element.kind == Kind::Clothoid) { + k0 = k_prev; + k1 = end_curvature(element); + if (std::abs(k1 - k0) < kEps) { + out.diagnostics.push_back(degenerate( + track.id, element.id, + "krzywa przejściowa musi zmieniać krzywiznę — na jej końcu " + "jest ten sam promień, co na początku")); + out.track.complete = false; + break; + } + } + + if (element.length <= kEps) { + out.diagnostics.push_back( + degenerate(track.id, element.id, "długość musi być dodatnia")); + out.track.complete = false; + break; + } + + SolvedElement solved; + solved.id = element.id; + solved.kind = element.kind; + solved.start = walker; + solved.k0 = k0; + solved.k1 = k1; + solved.length = element.length; + + std::vector points; + walker = domain::geometry::layout_segment(k0, k1, element.length, walker, &points); + solved.end = walker; + solved.points.reserve(points.size()); + for (const auto& point : points) { + solved.points.push_back(PlanPoint{point.x, point.y}); + } + + out.track.length += element.length; + out.track.elements.push_back(std::move(solved)); + k_prev = k1; + } + + out.track.end = walker; + + for (const auto& element : out.track.elements) { + for (std::size_t i = out.track.centreline.empty() ? 0 : 1; i < element.points.size(); + ++i) { + out.track.centreline.push_back(element.points[i]); + } + } + + return out; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/editor.cpp b/maj0sted/src/editor/editor.cpp deleted file mode 100644 index 78ad0ba7..00000000 --- a/maj0sted/src/editor/editor.cpp +++ /dev/null @@ -1,434 +0,0 @@ -#include "maj0sted/editor/editor.hpp" - -#include -#include -#include -#include -#include -#include - -#include "maj0sted/domain/fitting/gap_fitter.hpp" -#include "maj0sted/domain/geometry/segment_layout.hpp" -#include "maj0sted/domain/geometry/turnout.hpp" -#include "maj0sted/maj0sted.hpp" -#include "maj0sted/render/rail_renderer.hpp" -#include "maj0sted/render/track_renderer.hpp" - -// This file is orchestration only: it turns the editor's request DTOs into -// domain calls, samples the solved geometry into rendering-neutral centreline -// elements, and drives a render::TrackRenderer to produce the drawable output. -// All fitting business logic lives in maj0sted::domain::GapFitter; all rail -// geometry lives in maj0sted::render::RailRenderer. Nothing here decides how a -// curve is fitted or how it is drawn. -namespace maj0sted::editor { - -using namespace maj0sted::domain; -using maj0sted::domain::geometry::layout_segment; -using maj0sted::domain::geometry::Pose; -using maj0sted::domain::geometry::XY; - -namespace { - -std::optional make_straight(const StraightSpec& s, std::uint64_t id) { - try { - return Straight{StraightId{id}, CartesianPosition{s.x1, s.y1}, - CartesianPosition{s.x2, s.y2}}; - } catch (...) { - return std::nullopt; // degenerate (zero length) - } -} - -// Maps an editor gap-fit DTO onto a domain connection request. Returns nullopt -// for "no fit" (mode 0) or an unknown mode. -std::optional to_request(const GapFit& fit) { - GapConnectionRequest request; - switch (fit.mode) { - case 1: // plain arc - // Legacy explicit nawrót (mode 4): treated as a plain-arc request; a - // genuine 180° reversal is then detected from the geometry in GapFitter. - case 4: - request.mode = GapConnectionMode::Arc; - request.radius = fit.radius; - return request; - case 2: // legacy "łuk + klotoidy" -> compound with a single eased arc - request.mode = GapConnectionMode::Compound; - request.compound.entry_transition = fit.transition; - request.compound.exit_transition = fit.transition; - request.compound.arcs.push_back(CompoundGapArc{fit.radius, 0.0, 0.0}); - return request; - case 3: // unified compound: any number of arcs eased by clothoids - request.mode = GapConnectionMode::Compound; - request.compound.entry_transition = fit.entry_t; - request.compound.exit_transition = fit.exit_t; - if (!fit.arcs.empty()) { - for (const auto& a : fit.arcs) { - request.compound.arcs.push_back( - CompoundGapArc{a.radius, a.length, a.transition_to_next}); - } - } else if (fit.r2 > 0.0) { // legacy two-arc basket - request.compound.arcs.push_back( - CompoundGapArc{fit.r1, fit.arc1_len, fit.between}); - request.compound.arcs.push_back(CompoundGapArc{fit.r2, 0.0, 0.0}); - } else { // legacy single arc - request.compound.arcs.push_back(CompoundGapArc{fit.r1, 0.0, 0.0}); - } - return request; - default: - return std::nullopt; - } -} - -double radius_of(double curvature) { - return std::abs(curvature) > 1e-12 ? 1.0 / std::abs(curvature) : 0.0; -} - -// Describes one domain plan element as sampling parameters for layout_segment, -// returning its render kind. A circular arc has constant curvature; a clothoid -// varies linearly; a straight has zero curvature. -render::ElementKind sample_params(const PlanElement& element, double& k0, double& k1, - double& length) { - if (const auto* arc = std::get_if(&element)) { - const double sign = arc->direction() == TurnDirection::Left ? 1.0 : -1.0; - k0 = k1 = sign / arc->radius().metres(); - length = arc->length() ? arc->length()->metres() : 0.0; - return render::ElementKind::Arc; - } - const auto& transition = std::get(element); - const double sign = transition.direction() == TurnDirection::Left ? 1.0 : -1.0; - k0 = transition.start_radius() ? sign / transition.start_radius()->metres() : 0.0; - k1 = transition.end_radius() ? sign / transition.end_radius()->metres() : 0.0; - length = transition.length().metres(); - return render::ElementKind::Transition; -} - -// Samples the solved niweleta (trimmed straights + fitted curves) into -// rendering-neutral centreline elements, and records the trimmed straights. -std::vector sample_centrelines( - const NiweletaSpec& spec, const std::vector>& straights, - const std::vector>& fits, - std::vector& rendered_straights) { - std::vector axis; - const std::size_t n = straights.size(); - for (std::size_t i = 0; i < n; ++i) { - if (!straights[i]) continue; - - // Trim the straight to the tangent points of its adjacent fits. - CartesianPosition a = straights[i]->start(); - CartesianPosition b = straights[i]->end(); - if (i > 0 && fits[i - 1]) a = fits[i - 1]->tangent_out; - if (i < fits.size() && fits[i]) b = fits[i]->tangent_in; - rendered_straights[i] = - StraightSpec{a.x(), a.y(), b.x(), b.y(), spec.straights[i].hidden}; - - if (!spec.straights[i].hidden) { - render::CentrelineElement straight_element; - straight_element.kind = render::ElementKind::Straight; - straight_element.straight_index = static_cast(i); - straight_element.length = std::hypot(b.x() - a.x(), b.y() - a.y()); - straight_element.points = {render::Point{a.x(), a.y()}, - render::Point{b.x(), b.y()}}; - axis.push_back(std::move(straight_element)); - } - - if (i < fits.size() && fits[i]) { - Pose pose{fits[i]->tangent_in.x(), fits[i]->tangent_in.y(), - std::sin(straights[i]->azimuth().radians()), - std::cos(straights[i]->azimuth().radians())}; - int element_index = 0; - for (const auto& element : fits[i]->curve) { - double k0 = 0.0, k1 = 0.0, length = 0.0; - render::CentrelineElement curve_element; - curve_element.kind = sample_params(element, k0, k1, length); - std::vector pts; - pose = layout_segment(k0, k1, length, pose, &pts); - curve_element.points.reserve(pts.size()); - for (const auto& p : pts) { - curve_element.points.push_back(render::Point{p.x, p.y}); - } - curve_element.gap = static_cast(i); - curve_element.element_index = element_index++; - curve_element.length = length; - curve_element.radius_start = radius_of(k0); - curve_element.radius_end = radius_of(k1); - axis.push_back(std::move(curve_element)); - } - } - } - return axis; -} - -// Renders the centreline elements to PlanPolylines through the renderer -// interface. A different render::TrackRenderer could be dropped in here without -// touching anything above. -std::vector render_rails( - const std::vector& axis) { - render::RailRenderer rail_renderer; - render::TrackRenderer& renderer = rail_renderer; - for (const auto& element : axis) renderer.add(element); - - std::vector polylines; - polylines.reserve(rail_renderer.rails().size()); - for (const auto& rail : rail_renderer.rails()) { - PlanPolyline poly; - poly.kind = static_cast(rail.kind); - poly.straight_index = rail.straight_index; - poly.gap = rail.gap; - poly.element_index = rail.element_index; - poly.length = rail.length; - poly.radius_start = rail.radius_start; - poly.radius_end = rail.radius_end; - poly.points.reserve(rail.points.size()); - for (const auto& p : rail.points) poly.points.push_back(PlanPoint{p.x, p.y}); - polylines.push_back(std::move(poly)); - } - return polylines; -} - -NiweletaPolys build(const NiweletaSpec& spec) { - const std::size_t n = spec.straights.size(); - std::vector> straights(n); - for (std::size_t i = 0; i < n; ++i) { - straights[i] = make_straight(spec.straights[i], i + 1); - } - - NiweletaPolys result; - result.rendered_straights = spec.straights; - - // 1. Business: connect each requested gap. The domain decides the curve type - // and the radius actually used; a gap that admits no fit is simply left - // out (the library, not the host, turns a failed fit into "no fit"). Only - // fits that succeeded are echoed in applied_fits for the host to mirror. - std::vector> fits(n == 0 ? 0 : n - 1); - for (const auto& gap : spec.fits) { - const int g = gap.gap; - if (g < 0 || static_cast(g) + 1 >= n) continue; - if (!straights[g] || !straights[g + 1]) continue; - const auto request = to_request(gap); - if (!request) continue; - const auto connection = - GapFitter::connect(*straights[g], *straights[g + 1], *request); - if (!connection) continue; - fits[g] = connection->fit; - GapFit applied = gap; - applied.radius = connection->applied_radius; - result.applied_fits.push_back(applied); - } - - // 2. Geometry: sample trimmed straights and fitted curves into a neutral, - // renderer-agnostic centreline. - const auto axis = - sample_centrelines(spec, straights, fits, result.rendered_straights); - - // Flatten the axis into one polyline for station lookups, dropping the join - // points each element repeats at its start. - for (const auto& element : axis) { - for (const auto& p : element.points) { - if (!result.centreline.empty()) { - const auto& last = result.centreline.back(); - if (std::hypot(p.x - last.x, p.y - last.y) < 1e-9) continue; - } - result.centreline.push_back(PlanPoint{p.x, p.y}); - } - } - - // 3. Presentation: hand the centreline to a renderer. - result.polylines = render_rails(axis); - return result; -} - -// The pose (position + unit heading) at arc length @p station along a centreline -// polyline. Clamped to the ends; heading comes from the segment the station falls -// on. Returns false only for a centreline too short to have a direction. -bool pose_at(const std::vector& centreline, double station, Pose& out) { - if (centreline.size() < 2) return false; - - double travelled = 0.0; - for (std::size_t i = 0; i + 1 < centreline.size(); ++i) { - const auto& a = centreline[i]; - const auto& b = centreline[i + 1]; - const double dx = b.x - a.x; - const double dy = b.y - a.y; - const double seg = std::hypot(dx, dy); - if (seg < 1e-12) continue; - - // the last segment also catches any station beyond the centreline's end - if (station <= travelled + seg || i + 2 == centreline.size()) { - const double t = std::clamp((station - travelled) / seg, 0.0, 1.0); - out = Pose{a.x + dx * t, a.y + dy * t, dx / seg, dy / seg}; - return true; - } - travelled += seg; - } - return false; -} - -// Translates the editor's switch DTO into a domain Turnout: a plain-arc curve -// (mode 1) or a basket of arcs eased by clothoids (mode 3). All the turnout -// business — turning to the crossing angle, closing the last arc, the frog rail — -// lives in the domain; nothing here decides geometry. -domain::Turnout to_turnout(const Junction& junction) { - domain::DivergingCurve curve; - if (junction.curve.mode == 3 && junction.curve.arcs.size() >= 2) { - for (const auto& arc : junction.curve.arcs) { - curve.arcs.push_back( - domain::TurnoutArc{arc.radius, arc.length, arc.transition_to_next}); - } - curve.entry_transition = junction.curve.entry_t; - curve.exit_transition = junction.curve.exit_t; - } else { - curve.arcs.push_back(domain::TurnoutArc{junction.curve.radius, 0.0, 0.0}); - } - return domain::Turnout{ - domain::CrossingMark{junction.crossing_n}, - junction.side == 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right, - std::move(curve), junction.length, - junction.pre_blade, junction.blade_angle, - junction.blade_length}; -} - -// The drawable geometry of one switch. @p station is the switch's start on the -// through track (początek rozjazdu) — the point the user clicks. The diverging -// path and frog come from the domain; here we only place them on the through -// track and sample them into rails. -JunctionGeom solve_junction(const Junction& junction, - const std::vector& solved) { - JunctionGeom geom; - if (junction.through < 0 || - static_cast(junction.through) >= solved.size()) { - return geom; - } - - Pose start{}; - if (!pose_at(solved[junction.through].centreline, junction.station, start)) { - return geom; - } - // a trailing switch opens against the running direction: flip the heading - if (!junction.facing) { - start.hx = -start.hx; - start.hy = -start.hy; - } - - const auto turnout = domain::lay_turnout(start, to_turnout(junction)); - if (!turnout.valid) return geom; - - // sample the domain segments into drawable rails - std::vector axis; - Pose pose = start; - for (const auto& segment : turnout.path) { - std::vector pts; - render::CentrelineElement element; - element.kind = - std::abs(segment.k1 - segment.k0) >= 1e-12 ? render::ElementKind::Transition - : std::abs(segment.k0) >= 1e-12 ? render::ElementKind::Arc - : render::ElementKind::Straight; - // the blade's heading break happens in place, before the segment is laid - if (segment.turn_in != 0.0) { - const double c = std::cos(segment.turn_in); - const double s = std::sin(segment.turn_in); - pose = Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, - pose.hx * s + pose.hy * c}; - } - pose = layout_segment(segment.k0, segment.k1, segment.length, pose, &pts); - element.points.reserve(pts.size()); - for (const auto& p : pts) element.points.push_back(render::Point{p.x, p.y}); - element.length = segment.length; - element.radius_start = radius_of(segment.k0); - element.radius_end = radius_of(segment.k1); - axis.push_back(std::move(element)); - } - - geom.valid = true; - geom.px = start.x; - geom.py = start.y; - geom.fx = turnout.frog.x; - geom.fy = turnout.frog.y; - geom.fhx = turnout.frog.hx; - geom.fhy = turnout.frog.hy; - geom.tangent_front = turnout.tangent_front; - geom.tangent_back = turnout.tangent_back; - geom.length = turnout.diverging_length; - geom.polylines = render_rails(axis); - return geom; -} - -// Rigidly repositions a whole branch niweleta so its leaving straight starts at -// the frog and runs along the frog heading. The branch becomes tangent to the -// switch curve however it was drawn, and — crucially — every straight appended -// behind the leaving straight rides along under the SAME rigid motion, so the -// shape of those fragments (and the fits between them) is preserved. Rewriting -// only the first straight would snap it to the frog while the appended fragments -// stayed where they were drawn, so their gap fits would be solved against a -// straight that had jumped out from under them. -void pin_branch_to_frog(NiweletaSpec& branch, const JunctionGeom& frog) { - if (branch.straights.empty()) return; - - // The leaving straight fixes the rigid motion: its start maps to the frog and - // its drawn direction rotates onto the frog heading. A leaving straight too - // short to have a direction contributes no rotation. - const StraightSpec& leaving = branch.straights.front(); - const double anchor_x = leaving.x1; - const double anchor_y = leaving.y1; - const double dx = leaving.x2 - leaving.x1; - const double dy = leaving.y2 - leaving.y1; - const double drawn_length = std::hypot(dx, dy); - double cos_r = 1.0; - double sin_r = 0.0; - if (drawn_length > 1e-9) { - const double hx = dx / drawn_length; - const double hy = dy / drawn_length; - cos_r = hx * frog.fhx + hy * frog.fhy; // drawn direction · frog heading - sin_r = hx * frog.fhy - hy * frog.fhx; // drawn direction × frog heading - } - - const auto move = [&](double& x, double& y) { - const double vx = x - anchor_x; - const double vy = y - anchor_y; - x = frog.fx + cos_r * vx - sin_r * vy; - y = frog.fy + sin_r * vx + cos_r * vy; - }; - for (auto& straight : branch.straights) { - move(straight.x1, straight.y1); - move(straight.x2, straight.y2); - } -} - -} // namespace - -std::vector solve_project(const std::vector& niwelety) { - std::vector result; - result.reserve(niwelety.size()); - for (const auto& spec : niwelety) result.push_back(build(spec)); - return result; -} - -LayoutSolution solve_layout(std::vector niwelety, - const std::vector& junctions) { - // Pass 1: solve unpinned, to read the through centrelines the frogs sit on. - auto solved = solve_project(niwelety); - - // Pin each branch to its frog. The whole branch is moved rigidly so its - // leaving straight starts at the frog and runs along the frog heading (tangent - // to the switch curve), carrying every fragment appended behind it along - // unchanged — so any fit in the branch still meets the switch tangentially. - for (const auto& junction : junctions) { - const auto geom = solve_junction(junction, solved); - if (!geom.valid) continue; - if (junction.branch < 0 || - static_cast(junction.branch) >= niwelety.size()) { - continue; - } - pin_branch_to_frog(niwelety[junction.branch], geom); - } - - // Pass 2: solve with the branches pinned, then read the switches off the - // final centrelines (the through track may itself be a pinned branch). - LayoutSolution out; - out.niwelety = solve_project(niwelety); - out.junctions.reserve(junctions.size()); - for (const auto& junction : junctions) { - out.junctions.push_back(solve_junction(junction, out.niwelety)); - } - return out; -} - -} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/layout.cpp b/maj0sted/src/editor/layout.cpp new file mode 100644 index 00000000..34249647 --- /dev/null +++ b/maj0sted/src/editor/layout.cpp @@ -0,0 +1,706 @@ +#include "maj0sted/editor/layout.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "maj0sted/domain/geometry/turnout.hpp" +#include "maj0sted/editor/chain.hpp" +#include "maj0sted/editor/sketch.hpp" + +namespace maj0sted::editor { +namespace { + +constexpr double kEps = 1e-9; + +using domain::geometry::Pose; + +std::string num(double value, int decimals = 1) { + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%.*f", decimals, value); + return buffer; +} + +// --------------------------------------------------------------------------- +// The dependency graph +// --------------------------------------------------------------------------- + +enum class NodeKind { Turnout, Track }; + +struct Node { + NodeKind kind{NodeKind::Turnout}; + std::size_t index{0}; + std::vector depends_on; +}; + +/// Everything a track needs laid before it can be laid itself: the turnout a +/// branch hangs off, and every track it holds an element parallel to — however +/// deep either goes. A hold that runs in a circle is caught as a cycle, the same +/// as anything else. +void track_dependencies(const Document& document, const Track& track, + const std::vector& turnout_node, + const std::vector& track_node, Node& node) { + if (const auto* at_port = std::get_if(&track.anchor)) { + for (std::size_t i = 0; i < document.turnouts.size(); ++i) { + if (document.turnouts[i].id == at_port->turnout) { + node.depends_on.push_back(turnout_node[i]); + break; + } + } + } + for (const auto& element : track.elements) { + const auto* parallel = std::get_if(&element.hold); + if (parallel == nullptr) { + continue; + } + for (std::size_t i = 0; i < document.tracks.size(); ++i) { + if (document.tracks[i].id == track.id) { + continue; + } + for (const auto& candidate : document.tracks[i].elements) { + if (candidate.id == parallel->ref) { + node.depends_on.push_back(track_node[i]); + break; + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Holds: parallelism between elements +// --------------------------------------------------------------------------- + +/// Where a held element has to be, and what shape it has to be, to lie at the +/// offset asked for. Everything is worked out from the reference as solved, so +/// this is geometry and not a guess. +struct Held { + bool ok{false}; + Kind kind{Kind::Line}; + double radius{0.0}; + int hand{0}; + Pose target{}; ///< the pose the held element must start on + double actual{0.0}; ///< the offset that came out, for a track that cannot move + std::string why; ///< why not, when !ok +}; + +/// The offset curve of @p reference at @p offset, taken where @p current is now. +/// +/// Across the reference the answer is forced; along it, nothing is: the point +/// nearest to where the element already sits is the one taken, so holding a +/// track parallel never slides it sideways along the reference. +Held hold_against(const SolvedElement& reference, double offset, const Pose& current) { + Held held; + + if (std::abs(reference.k1 - reference.k0) > kEps) { + held.why = + "równoległości nie da się trzymać do krzywej przejściowej — odsunięcie " + "krzywej przejściowej nie jest krzywą przejściową"; + return held; + } + + const Pose& base = reference.start; + // Which way this element runs along the reference. The offset is always + // measured to the left of the *reference's* travel, whichever way this one + // goes, so a track laid against the run of another still sits where it looks. + const double sense = (current.hx * base.hx + current.hy * base.hy) >= 0.0 ? 1.0 : -1.0; + const double nx = -base.hy; + const double ny = base.hx; + const double k = reference.k0; + + if (std::abs(k) < kEps) { + const double px = base.x + nx * offset; + const double py = base.y + ny * offset; + const double hx = sense * base.hx; + const double hy = sense * base.hy; + const double along = (current.x - px) * hx + (current.y - py) * hy; + held.ok = true; + held.kind = Kind::Line; + held.target = Pose{px + hx * along, py + hy * along, hx, hy}; + held.actual = (current.x - base.x) * nx + (current.y - base.y) * ny; + return held; + } + + // Concentric with the reference: same centre, radius R∓d. + const double radius = 1.0 / std::abs(k); + const double cx = base.x + nx / k; + const double cy = base.y + ny / k; + const double curvature = k / (1.0 - offset * k); + if (!std::isfinite(curvature) || std::abs(1.0 - offset * k) < kEps) { + held.why = "odstęp " + num(offset, 2) + " m trafia w środek łuku R=" + + num(radius) + " m"; + return held; + } + + double ux = current.x - cx; + double uy = current.y - cy; + const double reach = std::hypot(ux, uy); + if (reach < kEps) { + held.why = "odcinek zaczyna się w środku łuku, do którego ma trzymać " + "równoległość"; + return held; + } + ux /= reach; + uy /= reach; + + const double held_radius = 1.0 / std::abs(curvature); + const double turn = curvature > 0.0 ? 1.0 : -1.0; + // The centre lies to the left of travel for a left-hand curve, so the + // heading follows from where the point sits on the circle. + const double hx = sense * turn * uy; + const double hy = sense * turn * -ux; + + held.ok = true; + held.kind = Kind::Arc; + held.radius = held_radius; + held.hand = (sense * turn) > 0.0 ? 1 : -1; + held.target = Pose{cx + ux * held_radius, cy + uy * held_radius, hx, hy}; + // What offset this element actually keeps, as the drawing stands. + held.actual = (k > 0.0 ? 1.0 : -1.0) * (radius - reach); + return held; +} + +/// Moves a whole chain, as one rigid piece, so that the pose @p from becomes the +/// pose @p to. Returns the anchor pose to lay it from again. +Pose rigid_move(const Pose& anchor, const Pose& from, const Pose& to) { + const double cos_t = from.hx * to.hx + from.hy * to.hy; + const double sin_t = from.hx * to.hy - from.hy * to.hx; + const double dx = anchor.x - from.x; + const double dy = anchor.y - from.y; + return Pose{to.x + dx * cos_t - dy * sin_t, to.y + dx * sin_t + dy * cos_t, + anchor.hx * cos_t - anchor.hy * sin_t, + anchor.hx * sin_t + anchor.hy * cos_t}; +} + +// --------------------------------------------------------------------------- +// Turnouts +// --------------------------------------------------------------------------- + +domain::TurnoutPart part_of(int part) { + switch (part) { + case 0: return domain::TurnoutPart::PreBlade; + case 1: return domain::TurnoutPart::Blade; + case 3: return domain::TurnoutPart::FrogRail; + default: return domain::TurnoutPart::Curve; + } +} + +int mark_kind_for(domain::TurnoutPart part) { + switch (part) { + case domain::TurnoutPart::PreBlade: return 0; // PR + case domain::TurnoutPart::Blade: return 1; // ostrze iglicy + case domain::TurnoutPart::Curve: return 2; // pięta iglicy + case domain::TurnoutPart::FrogRail: return 4; // koniec krzywej + } + return 3; +} + +} // namespace + +// --------------------------------------------------------------------------- + +domain::Turnout to_domain(const TurnoutType& type, int hand) { + domain::Turnout turnout{ + domain::CrossingMark{type.crossing_n}, + hand >= 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right, + {}, + type.length, + domain::Blade{type.blade.tip_thickness, type.blade.nose, type.blade.railtop_width}, + }; + turnout.path.reserve(type.pieces.size()); + for (const auto& piece : type.pieces) { + turnout.path.push_back(domain::TurnoutPiece{part_of(piece.part), piece.length, + piece.radius_start, piece.radius_end, + piece.turn_in}); + } + return turnout; +} + +std::vector turnout_marks(const domain::TurnoutGeometry& geometry, + const domain::geometry::Pose& start) { + std::vector marks; + if (!geometry.valid) { + return marks; + } + + // PR stands whatever the path starts with, so it is said once here + marks.push_back(TurnoutMark{start.x, start.y, 0, 0.0}); + + domain::geometry::Pose walker = start; + double station = 0.0; + domain::TurnoutPart previous = domain::TurnoutPart::PreBlade; + bool first = true; + for (const auto& segment : geometry.path) { + if (std::abs(segment.turn_in) > kEps) { + const double cos_t = std::cos(segment.turn_in); + const double sin_t = std::sin(segment.turn_in); + walker = domain::geometry::Pose{walker.x, walker.y, + walker.hx * cos_t - walker.hy * sin_t, + walker.hx * sin_t + walker.hy * cos_t}; + } + if ((first || segment.part != previous) && + !(first && segment.part == domain::TurnoutPart::PreBlade)) { + marks.push_back( + TurnoutMark{walker.x, walker.y, mark_kind_for(segment.part), station}); + } + previous = segment.part; + first = false; + + walker = domain::geometry::layout_segment(segment.k0, segment.k1, segment.length, + walker, nullptr); + station += segment.length; + } + + marks.push_back(TurnoutMark{geometry.frog.x, geometry.frog.y, 5, station}); + marks.push_back(TurnoutMark{start.x + start.hx * geometry.tangent_front, + start.y + start.hy * geometry.tangent_front, 6, 0.0}); + // PR is on the through track already; this is where the switch ends on it, so the + // straight carries both ends of the rozjazd + marks.push_back(TurnoutMark{geometry.through_end.x, geometry.through_end.y, 9, + geometry.through_length}); + // the ostrze itself is marked with the blade piece; what the drawing dimensions on + // top of it is where the planed nose ends (A, the rail u thick) and where the + // planing does + if (geometry.has_blade && geometry.nose_end > geometry.blade_station) { + marks.push_back(TurnoutMark{geometry.blade_nose_end.x, geometry.blade_nose_end.y, + 7, geometry.nose_end}); + } + if (geometry.has_blade && geometry.planing_length > 0.0) { + marks.push_back(TurnoutMark{geometry.blade_planed.x, geometry.blade_planed.y, 8, + geometry.planing_end}); + } + return marks; +} + +// --------------------------------------------------------------------------- + +Solution solve(const Document& document) { + Solution solution; + + const std::size_t turnout_count = document.turnouts.size(); + const std::size_t track_count = document.tracks.size(); + + std::vector nodes; + nodes.reserve(turnout_count + track_count); + std::vector turnout_node(turnout_count, 0); + std::vector track_node(track_count, 0); + + for (std::size_t i = 0; i < turnout_count; ++i) { + turnout_node[i] = nodes.size(); + nodes.push_back(Node{NodeKind::Turnout, i, {}}); + } + for (std::size_t i = 0; i < track_count; ++i) { + track_node[i] = nodes.size(); + nodes.push_back(Node{NodeKind::Track, i, {}}); + } + + // Edges. + for (std::size_t i = 0; i < turnout_count; ++i) { + for (std::size_t k = 0; k < track_count; ++k) { + if (document.tracks[k].id == document.turnouts[i].on) { + nodes[turnout_node[i]].depends_on.push_back(track_node[k]); + break; + } + } + } + for (std::size_t i = 0; i < track_count; ++i) { + track_dependencies(document, document.tracks[i], turnout_node, track_node, + nodes[track_node[i]]); + } + + // Topological order, Kahn's algorithm. + std::vector remaining(nodes.size(), 0); + std::vector> dependents(nodes.size()); + for (std::size_t i = 0; i < nodes.size(); ++i) { + remaining[i] = nodes[i].depends_on.size(); + for (const std::size_t dependency : nodes[i].depends_on) { + dependents[dependency].push_back(i); + } + } + std::vector order; + order.reserve(nodes.size()); + for (std::size_t i = 0; i < nodes.size(); ++i) { + if (remaining[i] == 0) { + order.push_back(i); + } + } + for (std::size_t head = 0; head < order.size(); ++head) { + for (const std::size_t next : dependents[order[head]]) { + if (--remaining[next] == 0) { + order.push_back(next); + } + } + } + + // Whatever never came free is in a cycle. + if (order.size() != nodes.size()) { + for (std::size_t i = 0; i < nodes.size(); ++i) { + if (remaining[i] == 0) { + continue; + } + Diagnostic diagnostic; + diagnostic.code = Code::CyclicDependency; + switch (nodes[i].kind) { + case NodeKind::Track: + diagnostic.track = document.tracks[nodes[i].index].id; + diagnostic.text = "tor „" + document.tracks[nodes[i].index].name + + "” zależy sam od siebie w kółko"; + break; + case NodeKind::Turnout: + diagnostic.turnout = document.turnouts[nodes[i].index].id; + diagnostic.text = "rozjazd zależy sam od siebie w kółko"; + break; + } + solution.diagnostics.push_back(std::move(diagnostic)); + } + } + + // ---- walk the order --------------------------------------------------- + for (const std::size_t index : order) { + const Node& node = nodes[index]; + switch (node.kind) { + case NodeKind::Turnout: { + const TurnoutPlacement& placement = document.turnouts[node.index]; + SolvedTurnout solved; + solved.id = placement.id; + + const TurnoutType* type = find_type(document, placement.type); + if (type == nullptr) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.turnout = placement.id; + diagnostic.text = "rozjazd odwołuje się do typu „" + placement.type + + "”, którego nie ma w katalogu"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + + const SolvedTrack* through = find_track(solution, placement.on); + Pose at{}; + if (through == nullptr || !pose_at(*through, placement.station, at)) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = "rozjazd stoi na torze, którego nie da się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + // łukowanie: the turnout is bent onto whatever the track does under + // it, unless it was asked to carry a bend of its own. Read the room + // here too — both answers come off the same element + double curvature = 0.0; + double behind = 0.0; + double ahead = 0.0; + const bool steady = steady_room(*through, placement.station, curvature, behind, + ahead); + double bend = placement.bend_from_track ? (steady ? curvature : 0.0) + : placement.bend; + + if (!placement.facing) { + at = Pose{at.x, at.y, -at.hx, -at.hy}; + // laid against the way the track runs, a left curve is a right one + bend = -bend; + } + + const domain::TurnoutGeometry geometry = + domain::lay_turnout(at, to_domain(*type, placement.hand), bend); + if (!geometry.valid) { + Diagnostic diagnostic; + diagnostic.code = Code::TurnoutInvalid; + diagnostic.turnout = placement.id; + diagnostic.text = + bend != 0.0 + ? "typu „" + type->name + "” nie da się wygiąć na łuk R " + + num(std::abs(1.0 / bend)) + + " m: łukowanie wymaga jednego promienia i szyn łączących, " + "które je przyjmą" + : "typ „" + type->name + + "” nie daje się złożyć: sprawdź skos, promienie i iglicę"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + + // a turnout is one piece of geometry bent to the one radius under it, so it has to + // stand on a single element and fit on it whole, or the drawing says something the + // ground would not. facing turnouts eat the room ahead, trailing ones behind + if (!steady) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = + through->length > 0.0 && placement.station > through->length + 1e-6 + ? "rozjazd stoi " + num(placement.station - through->length) + + " m za końcem toru zasadniczego" + : "rozjazd stoi na krzywej przejściowej: nie ma jednego promienia, " + "na który dałoby się go wygiąć"; + solution.diagnostics.push_back(std::move(diagnostic)); + } else if (const double room = placement.facing ? ahead : behind; + geometry.through_length > room + 1e-6) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = "rozjazd wychodzi " + num(geometry.through_length - room) + + " m poza odcinek toru, na którym stoi: potrzebuje " + + num(geometry.through_length) + " m, a zostało " + num(room); + solution.diagnostics.push_back(std::move(diagnostic)); + } + + solved.valid = true; + solved.bend = geometry.bend; + solved.diverging_curvature = geometry.diverging_curvature; + solved.bend_angle = geometry.bend_angle; + solved.start = at; + solved.frog = geometry.frog; + solved.tangent_front = geometry.tangent_front; + solved.tangent_back = geometry.tangent_back; + solved.diverging_length = geometry.diverging_length; + solved.through_length = geometry.through_length; + solved.has_blade = geometry.has_blade; + solved.blade_station = geometry.blade_station; + solved.nose_end = geometry.nose_end; + solved.planing_length = geometry.planing_length; + solved.blade_angle = geometry.blade_angle; + solved.blade_residual = geometry.blade_residual; + solved.angle_residual = geometry.angle_residual; + solved.length_residual = geometry.length_residual; + + solved.marks = turnout_marks(geometry, at); + + Pose walker = at; + for (const auto& segment : geometry.path) { + if (std::abs(segment.turn_in) > kEps) { + const double cos_t = std::cos(segment.turn_in); + const double sin_t = std::sin(segment.turn_in); + walker = Pose{walker.x, walker.y, + walker.hx * cos_t - walker.hy * sin_t, + walker.hx * sin_t + walker.hy * cos_t}; + } + + SolvedTurnoutSegment out_segment; + out_segment.start = walker; + out_segment.k0 = segment.k0; + out_segment.k1 = segment.k1; + out_segment.length = segment.length; + std::vector points; + walker = domain::geometry::layout_segment(segment.k0, segment.k1, + segment.length, walker, + &points); + out_segment.part = segment.part; + out_segment.points.reserve(points.size()); + for (const auto& point : points) { + out_segment.points.push_back(PlanPoint{point.x, point.y}); + } + solved.path.push_back(std::move(out_segment)); + } + + // the list is laid as authored; whether it keeps the catalogue's + // promises is said out loud rather than fitted away + if (std::abs(geometry.angle_residual) > 1e-6 || + (type->length > 0.0 && std::abs(geometry.length_residual) > 1e-3)) { + Diagnostic diagnostic; + diagnostic.code = Code::TurnoutNotClosed; + diagnostic.turnout = placement.id; + diagnostic.text = + "typ „" + type->name + "” nie domyka się: kąt o " + + num(geometry.angle_residual * 180.0 / std::numbers::pi) + + " st., długość o " + num(geometry.length_residual) + " m"; + solution.diagnostics.push_back(std::move(diagnostic)); + } + + solution.turnouts.push_back(std::move(solved)); + break; + } + + case NodeKind::Track: { + const Track& track = document.tracks[node.index]; + Pose start{0.0, 0.0, 0.0, 1.0}; + bool anchored = true; + + if (const auto* pose = std::get_if(&track.anchor)) { + start = Pose{pose->x, pose->y, std::sin(pose->az), std::cos(pose->az)}; + } else if (const auto* at_port = std::get_if(&track.anchor)) { + const SolvedTurnout* turnout = find_turnout(solution, at_port->turnout); + if (turnout == nullptr || !turnout->valid) { + anchored = false; + } else { + start = at_port->port == Port::Frog ? turnout->frog : turnout->start; + } + } + + if (!anchored) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.track = track.id; + diagnostic.text = "tor „" + track.name + + "” jest zakotwiczony w czymś, czego nie ma " + "albo czego nie dało się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + SolvedTrack empty; + empty.id = track.id; + empty.complete = false; + solution.tracks.push_back(std::move(empty)); + break; + } + + // Laying is a walk, so a hold is resolved around it rather than + // inside it: lay once to see which way the held element runs, + // take its shape from the reference, lay again, and finally move + // the whole chain onto the offset it is held at. + const bool movable = std::holds_alternative(track.anchor); + Track resolved = track; + TrackOutcome outcome = lay_track(document, resolved, start); + + std::size_t held_index = resolved.elements.size(); + for (std::size_t e = 0; e < resolved.elements.size(); ++e) { + const auto* parallel = std::get_if(&resolved.elements[e].hold); + if (parallel == nullptr) { + continue; + } + const SolvedElement* reference = + find_element(solution, parallel->ref); + if (reference == nullptr || e >= outcome.track.elements.size()) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[e].id; + diagnostic.text = + "odcinek ma trzymać równoległość do czegoś, czego nie ma " + "albo czego nie dało się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + continue; + } + const Held held = hold_against(*reference, parallel->offset, + outcome.track.elements[e].start); + if (!held.ok) { + Diagnostic diagnostic; + diagnostic.code = Code::DegenerateElement; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[e].id; + diagnostic.text = held.why; + solution.diagnostics.push_back(std::move(diagnostic)); + continue; + } + resolved.elements[e].kind = held.kind; + resolved.elements[e].radius = held.radius; + resolved.elements[e].hand = held.hand; + if (held_index == resolved.elements.size()) { + held_index = e; + } else if (movable) { + Diagnostic diagnostic; + diagnostic.code = Code::HoldConflict; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[e].id; + diagnostic.text = + "tor „" + track.name + + "” trzyma równoległość dwoma odcinkami naraz; przystawiony " + "jest do pierwszego z nich"; + solution.diagnostics.push_back(std::move(diagnostic)); + } + } + + if (held_index < resolved.elements.size()) { + outcome = lay_track(document, resolved, start); + if (held_index < outcome.track.elements.size()) { + const auto* parallel = + std::get_if(&resolved.elements[held_index].hold); + const SolvedElement* reference = + find_element(solution, parallel->ref); + const Held held = + hold_against(*reference, parallel->offset, + outcome.track.elements[held_index].start); + if (held.ok && movable) { + const Pose moved = + rigid_move(start, outcome.track.elements[held_index].start, + held.target); + outcome = lay_track(document, resolved, moved); + } else if (held.ok) { + // A track pinned to a turnout's port cannot be moved + // onto the offset, so the corner between the port and + // the held element has to take up the difference — + // the same fit as pulling a straight about by hand. + std::string why; + Skeleton skeleton; + Track fitted = resolved; + bool done = false; + if (resolved.elements[held_index].kind == Kind::Line && + skeleton_of(resolved, outcome.track, skeleton, why)) { + std::size_t line_index = 0; + for (std::size_t e = 0; e < held_index; ++e) { + if (resolved.elements[e].kind == Kind::Line) { + ++line_index; + } + } + if (line_index == 0) { + why = "przed odcinkiem trzymającym równoległość nie ma " + "łuku, który mógłby na nią wyprowadzić"; + } else { + skeleton.lines[line_index] = + Support{held.target.x, held.target.y, held.target.hx, + held.target.hy}; + if (line_index + 1 == skeleton.lines.size()) { + skeleton.end_x = held.target.x; + skeleton.end_y = held.target.y; + } + done = lay_along_skeleton(fitted, skeleton, why); + } + } + if (done) { + resolved = std::move(fitted); + outcome = lay_track(document, resolved, start); + } else if (std::abs(held.actual - parallel->offset) > 1e-3) { + Diagnostic diagnostic; + diagnostic.code = Code::OffsetNotHeld; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[held_index].id; + diagnostic.text = + "tor „" + track.name + + "” jest przypięty do rozjazdu i nie da się go dopasować (" + + why + "): odstęp wychodzi " + num(held.actual, 2) + + " m zamiast " + num(parallel->offset, 2) + " m"; + solution.diagnostics.push_back(std::move(diagnostic)); + } + } + } + } + + for (auto& diagnostic : outcome.diagnostics) { + solution.diagnostics.push_back(std::move(diagnostic)); + } + solution.tracks.push_back(std::move(outcome.track)); + break; + } + } + } + + // Anything left out by a cycle still gets an entry, so the host can index by + // id without checking for holes. + for (const auto& track : document.tracks) { + if (find_track(solution, track.id) == nullptr) { + SolvedTrack empty; + empty.id = track.id; + empty.complete = false; + solution.tracks.push_back(std::move(empty)); + } + } + for (const auto& turnout : document.turnouts) { + if (find_turnout(solution, turnout.id) == nullptr) { + SolvedTurnout empty; + empty.id = turnout.id; + solution.turnouts.push_back(std::move(empty)); + } + } + + return solution; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/model.cpp b/maj0sted/src/editor/model.cpp new file mode 100644 index 00000000..8550237e --- /dev/null +++ b/maj0sted/src/editor/model.cpp @@ -0,0 +1,74 @@ +#include "maj0sted/editor/model.hpp" + +namespace maj0sted::editor { +namespace { + +template +auto* lookup(Container& items, Id id) { + if (id == Id::none) { + return decltype(&items[0]){nullptr}; + } + for (auto& item : items) { + if (item.id == id) { + return &item; + } + } + return decltype(&items[0]){nullptr}; +} + +} // namespace + +ElementId mint_element(Document& document) { return static_cast(document.next_id++); } +TrackId mint_track(Document& document) { return static_cast(document.next_id++); } +TurnoutId mint_turnout(Document& document) { return static_cast(document.next_id++); } + +const Track* find_track(const Document& document, TrackId id) { + return lookup(document.tracks, id); +} +Track* find_track(Document& document, TrackId id) { return lookup(document.tracks, id); } + +const TurnoutPlacement* find_turnout(const Document& document, TurnoutId id) { + return lookup(document.turnouts, id); +} +TurnoutPlacement* find_turnout(Document& document, TurnoutId id) { + return lookup(document.turnouts, id); +} + +const TurnoutType* find_type(const Document& document, const std::string& name) { + for (const auto& type : document.turnout_types) { + if (type.name == name) { + return &type; + } + } + return nullptr; +} + +const Element* find_element(const Document& document, ElementId id) { + if (id == ElementId::none) { + return nullptr; + } + for (const auto& track : document.tracks) { + for (const auto& element : track.elements) { + if (element.id == id) { + return &element; + } + } + } + return nullptr; +} + +const Track* find_element_track(const Document& document, ElementId id) { + if (id == ElementId::none) { + return nullptr; + } + for (const auto& track : document.tracks) { + for (const auto& element : track.elements) { + if (element.id == id) { + return &track; + } + } + } + return nullptr; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/render.cpp b/maj0sted/src/editor/render.cpp new file mode 100644 index 00000000..58bf6c27 --- /dev/null +++ b/maj0sted/src/editor/render.cpp @@ -0,0 +1,122 @@ +#include "maj0sted/editor/render.hpp" + +#include +#include + +#include "maj0sted/render/rail_renderer.hpp" + +namespace maj0sted::editor { +namespace { + +render::ElementKind kind_of(Kind kind) { + switch (kind) { + case Kind::Line: return render::ElementKind::Straight; + case Kind::Arc: return render::ElementKind::Arc; + case Kind::Clothoid: return render::ElementKind::Transition; + } + return render::ElementKind::Straight; +} + +Kind kind_from(render::ElementKind kind) { + switch (kind) { + case render::ElementKind::Straight: return Kind::Line; + case render::ElementKind::Arc: return Kind::Arc; + case render::ElementKind::Transition: return Kind::Clothoid; + } + return Kind::Line; +} + +double radius_of(double curvature) { + return std::abs(curvature) > 1e-9 ? 1.0 / std::abs(curvature) : 0.0; +} + +render::CentrelineElement centreline_of(Kind kind, double k0, double k1, double length, + const std::vector& points, + std::uint32_t element_id, std::uint32_t owner_id, + bool from_turnout) { + render::CentrelineElement element; + element.kind = kind_of(kind); + element.element_id = element_id; + element.owner_id = owner_id; + element.from_turnout = from_turnout; + element.length = length; + element.radius_start = radius_of(k0); + element.radius_end = radius_of(k1); + element.points.reserve(points.size()); + for (const auto& point : points) { + element.points.push_back(render::Point{point.x, point.y}); + } + return element; +} + +Kind kind_of_segment(double k0, double k1) { + if (std::abs(k0) < 1e-12 && std::abs(k1) < 1e-12) { + return Kind::Line; + } + return std::abs(k1 - k0) < 1e-12 ? Kind::Arc : Kind::Clothoid; +} + +} // namespace + +std::vector render_rails(const Solution& solution, double gauge_metres) { + render::RailRenderer rail_renderer{gauge_metres}; + render::TrackRenderer& renderer = rail_renderer; + + // Owner ids are recorded alongside so a rail can be traced back without + // widening the render layer's vocabulary. + for (const auto& track : solution.tracks) { + for (const auto& element : track.elements) { + if (element.points.size() < 2) { + continue; + } + renderer.add(centreline_of(element.kind, element.k0, element.k1, element.length, + element.points, + static_cast(element.id), + static_cast(track.id), false)); + } + } + for (const auto& turnout : solution.turnouts) { + if (!turnout.valid) { + continue; + } + std::uint32_t index = 0; + for (const auto& segment : turnout.path) { + if (segment.points.size() < 2) { + ++index; + continue; + } + renderer.add(centreline_of(kind_of_segment(segment.k0, segment.k1), segment.k0, + segment.k1, segment.length, segment.points, index++, + static_cast(turnout.id), true)); + } + } + + std::vector out; + out.reserve(rail_renderer.rails().size()); + for (const auto& rail : rail_renderer.rails()) { + PlanPolyline polyline; + polyline.kind = kind_from(rail.kind); + polyline.length = rail.length; + polyline.radius_start = rail.radius_start; + polyline.radius_end = rail.radius_end; + if (rail.from_turnout) { + polyline.turnout = static_cast(rail.owner_id); + polyline.piece = rail.element_id; + const SolvedTurnout* owner = find_turnout(solution, polyline.turnout); + if (owner != nullptr && polyline.piece < owner->path.size()) { + polyline.part = static_cast(owner->path[polyline.piece].part); + } + } else { + polyline.element = static_cast(rail.element_id); + polyline.track = static_cast(rail.owner_id); + } + polyline.points.reserve(rail.points.size()); + for (const auto& point : rail.points) { + polyline.points.push_back(PlanPoint{point.x, point.y}); + } + out.push_back(std::move(polyline)); + } + return out; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/ribbon.cpp b/maj0sted/src/editor/ribbon.cpp deleted file mode 100644 index e6db4c1a..00000000 --- a/maj0sted/src/editor/ribbon.cpp +++ /dev/null @@ -1,55 +0,0 @@ -#include "maj0sted/editor/ribbon.hpp" - -#include - -#include "maj0sted/domain/geometry/segment_layout.hpp" - -namespace maj0sted::editor { - -using maj0sted::domain::geometry::layout_segment; -using maj0sted::domain::geometry::Pose; -using maj0sted::domain::geometry::XY; - -std::vector solve_ribbon(const RibbonRequest& request) { - std::vector result; - result.reserve(request.elements.size()); - - Pose pose{request.x0, request.y0, std::sin(request.az0), std::cos(request.az0)}; - - for (const auto& element : request.elements) { - double k0 = 0.0; - double k1 = 0.0; - double length = 0.0; - int kind = element.kind; - - if (element.kind == 1) { // arc - const double sign = element.dir >= 0 ? 1.0 : -1.0; - const double k = element.radius > 0.0 ? sign / element.radius : 0.0; - k0 = k; - k1 = k; - length = element.radius * element.angle; - } else if (element.kind == 2) { // spiral (clothoid) - const double sign = element.dir >= 0 ? 1.0 : -1.0; - k0 = element.radius_start > 0.0 ? sign / element.radius_start : 0.0; - k1 = element.radius > 0.0 ? sign / element.radius : 0.0; - length = element.length; - } else { // line - kind = 0; - length = element.length; - } - - std::vector points; - pose = layout_segment(k0, k1, length, pose, &points); - - PlanPolyline polyline; - polyline.kind = kind; - polyline.points.reserve(points.size()); - for (const auto& p : points) { - polyline.points.push_back(PlanPoint{p.x, p.y}); - } - result.push_back(std::move(polyline)); - } - return result; -} - -} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/sketch.cpp b/maj0sted/src/editor/sketch.cpp new file mode 100644 index 00000000..9e567891 --- /dev/null +++ b/maj0sted/src/editor/sketch.cpp @@ -0,0 +1,481 @@ +#include "maj0sted/editor/sketch.hpp" + +#include +#include +#include +#include +#include + +namespace maj0sted::editor { +namespace { +constexpr double kEps = 1e-9; +} // namespace + +using domain::geometry::Pose; + + +Corner fit_corner(double in_hx, double in_hy, double out_hx, double out_hy, double radius) { + Corner corner; + if (radius <= kEps) { + return corner; + } + const double in_length = std::hypot(in_hx, in_hy); + const double out_length = std::hypot(out_hx, out_hy); + if (in_length < kEps || out_length < kEps) { + return corner; + } + in_hx /= in_length; + in_hy /= in_length; + out_hx /= out_length; + out_hy /= out_length; + + // On the short branch: a corner never turns further than it actually bends. + const double deflection = std::atan2(in_hx * out_hy - in_hy * out_hx, + in_hx * out_hx + in_hy * out_hy); + const double turn = std::abs(deflection); + if (turn < 1e-6 || turn > std::numbers::pi - 1e-6) { + return corner; + } + + corner.ok = true; + corner.deflection = deflection; + corner.hand = deflection > 0.0 ? 1 : -1; + corner.tangent = radius * std::tan(0.5 * turn); + corner.arc_length = radius * turn; + return corner; +} + +Element make_arc(Document& document, double radius, int hand, double length) { + Element element; + element.id = mint_element(document); + element.kind = Kind::Arc; + element.radius = radius; + element.hand = hand >= 0 ? 1 : -1; + element.length = length; + return element; +} + +Element make_line(Document& document, double length) { + Element element; + element.id = mint_element(document); + element.kind = Kind::Line; + element.length = length; + return element; +} + +Element make_clothoid(Document& document, double end_radius, int hand, double length) { + Element element; + element.id = mint_element(document); + element.kind = Kind::Clothoid; + element.radius = end_radius; + element.hand = hand >= 0 ? 1 : -1; + element.length = length; + return element; +} + + +namespace { + +std::string metres(double value) { + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%.1f", value); + return buffer; +} + +/// Signed distance from a point to a line, positive to the left of travel. +double left_of(const Support& line, double x, double y) noexcept { + return -(x - line.px) * line.dy + (y - line.py) * line.dx; +} + +/// How far along a line a point sits. +double along(const Support& line, double x, double y) noexcept { + return (x - line.px) * line.dx + (y - line.py) * line.dy; +} + +} // namespace + +namespace { + +/// What sits between two straights: one or more arcs turning the same way, with +/// transition curves easing into them, between them and out of them. One arc is +/// the everyday corner; several of them are a basket curve, and nothing here +/// treats that as a special case. +struct Group { + std::vector elements; ///< in order, between the two straights + std::vector arcs; ///< the arcs among them + int hand{0}; +}; + +/// Signed curvature an element ends on, the way the chain reads it. +double end_curvature(const Element& element) noexcept { + if (element.kind == Kind::Line || element.radius <= kEps) { + return 0.0; + } + return static_cast(element.hand) / element.radius; +} + +/// Cuts a chain into straights and the corners between them, or says why it is +/// not one. A corner may hang off either end, where there is no second straight +/// to fit it against: that is a track that starts or finishes mid-curve, which is +/// ordinary and is simply carried along as authored. +bool parse_chain(const Track& track, std::vector& straights, + std::vector& groups, bool& has_head, std::string& why) { + straights.clear(); + groups.clear(); + has_head = false; + const std::size_t count = track.elements.size(); + if (count == 0) { + why = "tor jest pusty"; + return false; + } + + std::size_t i = 0; + while (i < count) { + if (track.elements[i].kind == Kind::Line) { + if (!straights.empty() && straights.back() + 1 == i) { + why = "dwie proste stykają się ze sobą — to jedna prosta"; + return false; + } + straights.push_back(i++); + continue; + } + + // A corner: everything up to the next straight, turning one way. + Group group; + const std::size_t first = i; + while (i < count && track.elements[i].kind != Kind::Line) { + if (track.elements[i].kind == Kind::Arc) { + if (track.elements[i].hand == 0) { + why = "łuk nie mówi, w którą stronę skręca"; + return false; + } + if (group.hand != 0 && track.elements[i].hand != group.hand) { + why = "łuki w jednym narożniku skręcają w różne strony — to nie jest " + "łuk koszowy, tylko esica, i nie ma jej co dopasowywać do dwóch " + "prostych"; + return false; + } + group.hand = track.elements[i].hand; + group.arcs.push_back(i); + } + group.elements.push_back(i++); + } + if (group.arcs.empty()) { + why = "krzywa przejściowa nie wchodzi w żaden łuk — nie ma tu narożnika"; + return false; + } + + // Every transition must ease to whatever it runs into: to the radius of + // the arc after it, or out into the straight after it. Anything else is + // a kink dressed up as an easement. + for (const std::size_t at : group.elements) { + if (track.elements[at].kind != Kind::Clothoid || at + 1 >= count) { + continue; + } + const Element& next = track.elements[at + 1]; + if (next.kind == Kind::Line) { + if (track.elements[at].radius > kEps) { + why = "krzywa przejściowa przed prostą musi wybiegać w prostą (R=0)"; + return false; + } + } else if (std::abs(track.elements[at].radius - next.radius) > 1e-6 || + track.elements[at].hand != next.hand) { + why = "krzywa przejściowa nie schodzi się z łukiem: kończy się na R=" + + metres(track.elements[at].radius) + " m, a łuk ma R=" + + metres(next.radius) + " m"; + return false; + } + } + + if (first == 0) { + // A corner before the first straight: it hangs off the anchor and is + // carried along, not fitted. + has_head = true; + } else if (i >= count) { + // And one after the last straight, which simply runs out. + break; + } + groups.push_back(std::move(group)); + } + + if (straights.empty()) { + why = "tor nie ma ani jednej prostej — nie ma na czym oprzeć dopasowania"; + return false; + } + return true; +} + +/// Lays a corner on its own, from the origin heading along +x, and reports where +/// it ends up. The arc lengths are given; everything else is as authored. +Pose lay_group(const Track& track, const Group& group, const std::vector& arc_length) { + Pose pose{0.0, 0.0, 1.0, 0.0}; + double k_prev = 0.0; + std::size_t arc_at = 0; + for (const std::size_t at : group.elements) { + const Element& element = track.elements[at]; + const double k1 = end_curvature(element); + const double k0 = element.kind == Kind::Clothoid ? k_prev : k1; + const double length = + element.kind == Kind::Arc ? arc_length[arc_at++] : element.length; + pose = domain::geometry::layout_segment(k0, k1, length, pose, nullptr); + k_prev = k1; + } + return pose; +} + +/// The arc lengths a corner comes out with, and where it puts the chain down. +struct Fitted { + std::vector arc_length; + double leaves{0.0}; ///< along the line in, where the straight gives way + double arrives{0.0}; ///< along the line out, where the straight picks up +}; + +/// Fits one corner between two lines. @p keep_at is where it sits when the lines +/// are parallel and nothing else decides. +bool fit_group(const Track& track, const Group& group, const Support& in, + const Support& out_line, double keep_at, std::size_t ordinal, Fitted& fitted, + std::string& why) { + const double side = group.hand >= 0 ? 1.0 : -1.0; + + // What the corner was drawn as: the transitions turn what they turn, the arcs + // turn the rest. Both are read in the hand's convention, so both come out + // positive for a corner going the way its arcs go. + double spent = 0.0; + double authored = 0.0; + double k_prev = 0.0; + for (const std::size_t at : group.elements) { + const Element& element = track.elements[at]; + const double k1 = end_curvature(element); + if (element.kind == Kind::Clothoid) { + spent += side * 0.5 * (k_prev + k1) * element.length; + } else { + if (element.radius <= kEps) { + why = "łuk w narożniku " + std::to_string(ordinal) + " nie ma promienia"; + return false; + } + authored += element.length / element.radius; + } + k_prev = k1; + } + const double as_drawn = spent + authored; + + // How far it has to turn now. The angle between two lines says that only to + // within a full circle, so the turn taken is the one nearest to what was + // drawn: a corner drawn at 240° stays about 240°, and one drawn at 10° stays + // about 10° instead of looping the long way round to 350°. + const double raw = std::atan2(in.dx * out_line.dy - in.dy * out_line.dx, + in.dx * out_line.dx + in.dy * out_line.dy); + double turn = side * raw; + turn += 2.0 * std::numbers::pi * + std::round((as_drawn - turn) / (2.0 * std::numbers::pi)); + if (turn <= kEps) { + why = "narożnik " + std::to_string(ordinal) + + " musiałby skręcać w drugą stronę niż jego łuki"; + return false; + } + + // The transitions are authored, so what they turn is theirs; the arcs share + // out what is left, keeping the proportions they were drawn with. A basket + // curve therefore stays a basket curve — the same curve, tighter or wider, + // not one arc quietly swallowing the others. + const double left_for_arcs = turn - spent; + if (left_for_arcs <= kEps) { + why = "krzywe przejściowe w narożniku " + std::to_string(ordinal) + + " zużywają cały zwrot — na łuki nie zostaje nic"; + return false; + } + + fitted.arc_length.clear(); + fitted.arc_length.reserve(group.arcs.size()); + for (const std::size_t at : group.arcs) { + const Element& arc = track.elements[at]; + const double share = authored > kEps ? (arc.length / arc.radius) / authored + : 1.0 / static_cast(group.arcs.size()); + fitted.arc_length.push_back(arc.radius * left_for_arcs * share); + } + + // The corner is rigid now, so all that is left is sliding it along the line it + // leaves until it lands on the line it joins. + const Pose local = lay_group(track, group, fitted.arc_length); + const double reach_x = local.x * in.dx - local.y * in.dy; + const double reach_y = local.x * in.dy + local.y * in.dx; + const double determinant = in.dx * out_line.dy - in.dy * out_line.dx; + + double at_line = 0.0; + if (std::abs(determinant) < 1e-12) { + // Parallel lines: the half circle, and the full one. Their spacing fixes + // the geometry outright and says nothing about where along them the + // corner sits, so it stays where it is. + at_line = keep_at; + const double miss = left_of(out_line, in.px + in.dx * at_line + reach_x, + in.py + in.dy * at_line + reach_y); + if (std::abs(miss) > 1e-6) { + why = "proste przy narożniku " + std::to_string(ordinal) + + " są równoległe, więc ich rozstaw wyznacza geometrię: tak dopasowany " + "narożnik mija prostą o " + metres(std::abs(miss)) + " m"; + return false; + } + } else { + at_line = left_of(out_line, in.px + reach_x, in.py + reach_y) / determinant; + } + + fitted.leaves = at_line; + fitted.arrives = along(out_line, in.px + in.dx * at_line + reach_x, + in.py + in.dy * at_line + reach_y); + return true; +} + +} // namespace + +void sync_joint(Track& track, std::size_t at) { + if (at >= track.elements.size()) { + return; + } + const Element& edited = track.elements[at]; + + if (edited.kind == Kind::Arc) { + // The transition that eases into this arc is the one before it: its end + // radius is this arc's radius. What comes after the arc eases into + // something else, and is none of this arc's business. + if (at == 0 || track.elements[at - 1].kind != Kind::Clothoid) { + return; + } + track.elements[at - 1].radius = edited.radius; + track.elements[at - 1].hand = edited.hand; + return; + } + + if (edited.kind == Kind::Clothoid && edited.radius > kEps) { + // A transition that ends curved ends on an arc, and that arc is it. + if (at + 1 >= track.elements.size() || + track.elements[at + 1].kind != Kind::Arc) { + return; + } + track.elements[at + 1].radius = edited.radius; + track.elements[at + 1].hand = edited.hand; + } +} + +bool skeleton_of(const Track& track, const SolvedTrack& solved, Skeleton& out, + std::string& why) { + out.lines.clear(); + if (solved.elements.size() != track.elements.size()) { + why = "tor nie jest położony"; + return false; + } + std::vector straights; + std::vector groups; + bool has_head = false; + if (!parse_chain(track, straights, groups, has_head, why)) { + return false; + } + for (const std::size_t i : straights) { + const Pose& start = solved.elements[i].start; + out.lines.push_back(Support{start.x, start.y, start.hx, start.hy}); + } + out.start_x = solved.elements[straights.front()].start.x; + out.start_y = solved.elements[straights.front()].start.y; + out.end_x = solved.elements[straights.back()].end.x; + out.end_y = solved.elements[straights.back()].end.y; + return true; +} + +bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why) { + std::vector straights; + std::vector groups; + bool has_head = false; + if (!parse_chain(track, straights, groups, has_head, why)) { + return false; + } + if (skeleton.lines.size() != straights.size()) { + why = "proste nie pasują do łańcucha"; + return false; + } + + const std::vector& lines = skeleton.lines; + // Where each corner leaves the line before it and lands on the line after it, + // as distances along those lines. Nothing is written until every one of them + // works out. + std::vector leaves(straights.size(), 0.0); + std::vector arrives(straights.size(), 0.0); + std::vector fitted(straights.size() > 0 ? straights.size() - 1 : 0); + + arrives[0] = along(lines[0], skeleton.start_x, skeleton.start_y); + leaves[straights.size() - 1] = + along(lines[straights.size() - 1], skeleton.end_x, skeleton.end_y); + + // The corners with a straight on either side are the ones that get fitted. A + // head or a tail corner has nothing on its far side, so it keeps what it was + // given and the chain simply walks through it. + const std::size_t first_interior = has_head ? 1u : 0u; + for (std::size_t i = 0; i + 1 < straights.size(); ++i) { + const Group& group = groups[first_interior + i]; + const double keep_at = arrives[i] + track.elements[straights[i]].length; + if (!fit_group(track, group, lines[i], lines[i + 1], keep_at, i + 1, fitted[i], why)) { + return false; + } + leaves[i] = fitted[i].leaves; + arrives[i + 1] = fitted[i].arrives; + } + + std::vector straight_length(straights.size(), 0.0); + for (std::size_t i = 0; i < straights.size(); ++i) { + const double left = leaves[i] - arrives[i]; + if (left <= kEps) { + why = "narożniki zachodzą na siebie na prostej " + std::to_string(i + 1) + + ": brakuje " + metres(-left) + " m"; + return false; + } + straight_length[i] = left; + } + + // Where the chain has to start for all of that to stand where it stands. With + // a corner hanging off the front, that is worked out by laying it and putting + // its far end on the first straight. + const Pose head_end{lines[0].px + lines[0].dx * arrives[0], + lines[0].py + lines[0].dy * arrives[0], lines[0].dx, lines[0].dy}; + Pose begins = head_end; + if (has_head) { + std::vector as_authored; + for (const std::size_t at : groups.front().arcs) { + as_authored.push_back(track.elements[at].length); + } + const Pose local = lay_group(track, groups.front(), as_authored); + // Undo the corner: rotate its local end onto the pose it has to reach, + // and carry its local start along. + const double cos_t = local.hx * head_end.hx + local.hy * head_end.hy; + const double sin_t = local.hx * head_end.hy - local.hy * head_end.hx; + // the corner's local start heads along +x, so turning it by the same angle + // gives the heading the chain has to set off on + begins = Pose{head_end.x - (local.x * cos_t - local.y * sin_t), + head_end.y - (local.x * sin_t + local.y * cos_t), + cos_t, sin_t}; + } + + if (std::holds_alternative(track.anchor)) { + track.anchor = AtPose{begins.x, begins.y, std::atan2(begins.hx, begins.hy)}; + } else if (has_head) { + // A branch pinned to a turnout's port and starting mid-curve: its first + // straight is wherever the frog and that curve put it, so there is nothing + // here that may move. + why = "tor jest przypięty do rozjazdu i zaczyna się łukiem — nie ma czego dopasować " + "bez odrywania go od krzyżownicy"; + return false; + } else if (std::hypot(begins.x - skeleton.start_x, begins.y - skeleton.start_y) > 1e-6) { + why = "tor jest przypięty do rozjazdu — jego początku nie da się przesunąć"; + return false; + } + + for (std::size_t i = 0; i < straights.size(); ++i) { + track.elements[straights[i]].length = straight_length[i]; + } + for (std::size_t i = 0; i + 1 < straights.size(); ++i) { + const Group& group = groups[first_interior + i]; + for (std::size_t k = 0; k < group.arcs.size(); ++k) { + track.elements[group.arcs[k]].length = fitted[i].arc_length[k]; + } + } + return true; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/solution.cpp b/maj0sted/src/editor/solution.cpp new file mode 100644 index 00000000..583b4185 --- /dev/null +++ b/maj0sted/src/editor/solution.cpp @@ -0,0 +1,118 @@ +#include "maj0sted/editor/solution.hpp" + +#include +#include + +namespace maj0sted::editor { + +const char* code_name(Code code) { + switch (code) { + case Code::UnknownReference: return "UnknownReference"; + case Code::CyclicDependency: return "CyclicDependency"; + case Code::DegenerateElement: return "DegenerateElement"; + case Code::TurnoutInvalid: return "TurnoutInvalid"; + case Code::TurnoutNotClosed: return "TurnoutNotClosed"; + case Code::StationOffTrack: return "StationOffTrack"; + case Code::TouchingStraights: return "TouchingStraights"; + case Code::HoldConflict: return "HoldConflict"; + case Code::OffsetNotHeld: return "OffsetNotHeld"; + } + return "Unknown"; +} + +const SolvedTrack* find_track(const Solution& solution, TrackId id) { + if (id == TrackId::none) { + return nullptr; + } + for (const auto& track : solution.tracks) { + if (track.id == id) { + return &track; + } + } + return nullptr; +} + +const SolvedTurnout* find_turnout(const Solution& solution, TurnoutId id) { + if (id == TurnoutId::none) { + return nullptr; + } + for (const auto& turnout : solution.turnouts) { + if (turnout.id == id) { + return &turnout; + } + } + return nullptr; +} + +const SolvedElement* find_element(const Solution& solution, ElementId id) { + if (id == ElementId::none) { + return nullptr; + } + for (const auto& track : solution.tracks) { + for (const auto& element : track.elements) { + if (element.id == id) { + return &element; + } + } + } + return nullptr; +} + +bool pose_at(const SolvedTrack& track, double station, domain::geometry::Pose& out) { + const auto& line = track.centreline; + if (line.empty()) { + return false; + } + if (line.size() == 1) { + out = domain::geometry::Pose{line[0].x, line[0].y, track.start.hx, track.start.hy}; + return true; + } + + double walked = 0.0; + for (std::size_t i = 1; i < line.size(); ++i) { + const double dx = line[i].x - line[i - 1].x; + const double dy = line[i].y - line[i - 1].y; + const double step = std::hypot(dx, dy); + if (step <= 0.0) { + continue; + } + if (station <= walked + step) { + const double t = std::clamp((station - walked) / step, 0.0, 1.0); + out = domain::geometry::Pose{line[i - 1].x + dx * t, line[i - 1].y + dy * t, + dx / step, dy / step}; + return true; + } + walked += step; + } + + out = track.end; + return true; +} + +bool steady_room(const SolvedTrack& track, double station, double& curvature, double& behind, + double& ahead) { + constexpr double kEps = 1e-6; + if (station < -kEps) { + return false; + } + double walked = 0.0; + for (std::size_t i = 0; i < track.elements.size(); ++i) { + const SolvedElement& element = track.elements[i]; + const double ends = walked + element.length; + // a station sitting exactly on a joint belongs to the element ahead of it + const bool last = i + 1 == track.elements.size(); + if (station < ends - kEps || (last && station <= ends + kEps)) { + if (std::abs(element.k1 - element.k0) > 1e-12) { + return false; // a clothoid: no one radius to bend a turnout to + } + curvature = element.k0; + behind = station - walked; + ahead = ends - station; + return true; + } + walked = ends; + } + return false; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/solve.cpp b/maj0sted/src/editor/solve.cpp deleted file mode 100644 index dfc05aeb..00000000 --- a/maj0sted/src/editor/solve.cpp +++ /dev/null @@ -1,98 +0,0 @@ -#include "maj0sted/editor/solve.hpp" - -#include - -#include "maj0sted/maj0sted.hpp" -#include "maj0sted/render/plan_sampler.hpp" - -namespace maj0sted::editor { - -using namespace maj0sted::domain; - -namespace { - -std::optional optional_length(double metres) { - if (metres > 0.0) return Length::from_metres(metres); - return std::nullopt; -} - -FitResult make_fit(const Niweleta& niweleta, StraightId entry, StraightId exit, - const SolveRequest& request) { - if (request.mode == 2) { - return FittingService::fit_compound( - niweleta, - CompoundFitParameters{ - .entry = entry, - .exit = exit, - .entry_transition = optional_length(request.entry_t), - .exit_transition = optional_length(request.exit_t), - .arcs = {CompoundArc{Radius::from_metres(request.r1), - Length::from_metres(request.arc1_len), - optional_length(request.between)}, - CompoundArc{Radius::from_metres(request.r2)}}}); - } - return FittingService::fit_between_straights( - niweleta, - FitParameters{.entry = entry, - .exit = exit, - .radius = Radius::from_metres(request.radius), - .transition_length = request.mode == 1 - ? optional_length(request.transition) - : std::nullopt}); -} - -std::vector to_polylines(const render::Scene& scene) { - std::vector result; - result.reserve(scene.polylines.size()); - for (const auto& polyline : scene.polylines) { - PlanPolyline out; - out.kind = static_cast(polyline.kind); - out.points.reserve(polyline.points.size()); - for (const auto& p : polyline.points) { - out.points.push_back(PlanPoint{p.x, p.y}); - } - result.push_back(std::move(out)); - } - return result; -} - -} // namespace - -std::vector solve_scene(const SolveRequest& request) { - Niweleta niweleta{NiweletaId{1}}; - const CartesianPosition a{request.ax, request.ay}; - const CartesianPosition b{request.bx, request.by}; - const CartesianPosition c{request.cx, request.cy}; - - StraightId entry; - try { - entry = niweleta.add_straight(a, b); - } catch (...) { - return {}; // degenerate entry straight - } - - bool has_exit = false; - StraightId exit; - try { - exit = niweleta.add_straight(b, c, JointContinuity::AzimuthBreak); - has_exit = true; - } catch (...) { - try { - exit = niweleta.add_straight(b, c, JointContinuity::Tangent); - has_exit = true; - } catch (...) { - } - } - - if (has_exit) { - try { - niweleta.apply_fit(make_fit(niweleta, entry, exit, request)); - } catch (...) { - // Parameters don't admit a fit — show the bare straights instead. - } - } - - return to_polylines(render::sample(niweleta.plan())); -} - -} // namespace maj0sted::editor diff --git a/maj0sted/src/io/document_io.cpp b/maj0sted/src/io/document_io.cpp new file mode 100644 index 00000000..fa41c9aa --- /dev/null +++ b/maj0sted/src/io/document_io.cpp @@ -0,0 +1,544 @@ +#include "maj0sted/io/document_io.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace maj0sted::io { +namespace { + +using namespace maj0sted::editor; + +constexpr int kVersion = 6; +constexpr int kStraightOnly = 5; // before a placement could be bent onto a łuk +constexpr int kNoseless = 4; // the piece list, but before the blade carried its nose +constexpr int kLegacyTurnoutVersion = 3; // arcs + blade fields, before the piece list + +std::string num(double value) { + char buffer[64]; + const auto [end, error] = std::to_chars(buffer, buffer + sizeof(buffer), value); + if (error != std::errc{}) { + return "0"; + } + return std::string{buffer, end}; +} + +std::string num(std::uint32_t value) { return std::to_string(value); } + +/// One line of the file, split into tokens, with the tail kept whole so a name +/// may contain spaces. +struct Line { + std::vector tokens; + std::string_view tail; ///< everything after the first token, verbatim + + [[nodiscard]] std::string_view tag() const { + return tokens.empty() ? std::string_view{} : tokens.front(); + } + [[nodiscard]] std::size_t size() const { return tokens.size(); } +}; + +Line split(std::string_view text) { + Line line; + std::size_t i = 0; + bool first = true; + while (i < text.size()) { + while (i < text.size() && (text[i] == ' ' || text[i] == '\t')) { + ++i; + } + if (i >= text.size()) { + break; + } + if (!first && line.tail.empty()) { + line.tail = text.substr(i); + } + const std::size_t start = i; + while (i < text.size() && text[i] != ' ' && text[i] != '\t') { + ++i; + } + line.tokens.push_back(text.substr(start, i - start)); + first = false; + } + return line; +} + +bool to_double(std::string_view token, double& out) { + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out); + return error == std::errc{} && end == token.data() + token.size(); +} + +bool to_uint(std::string_view token, std::uint32_t& out) { + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out); + return error == std::errc{} && end == token.data() + token.size(); +} + +bool to_int(std::string_view token, int& out) { + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out); + return error == std::errc{} && end == token.data() + token.size(); +} + +/// A cursor over the file's lines, skipping blanks. +class Reader { +public: + explicit Reader(const std::string& text) { + std::istringstream stream{text}; + std::string raw; + while (std::getline(stream, raw)) { + if (!raw.empty() && raw.back() == '\r') { + raw.pop_back(); + } + storage_.push_back(std::move(raw)); + } + } + + /// The next non-blank line, or nothing at the end of the file. + std::optional next() { + while (at_ < storage_.size()) { + Line line = split(storage_[at_++]); + if (!line.tokens.empty()) { + return line; + } + } + return std::nullopt; + } + + /// The tag of the next non-blank line, without consuming it. Optional + /// records — an element's hold — are recognised this way. + std::string_view peek_tag() { + while (at_ < storage_.size()) { + const Line line = split(storage_[at_]); + if (!line.tokens.empty()) { + return line.tokens.front(); + } + ++at_; + } + return {}; + } + + /// The next line, required to carry @p tag and at least @p tokens tokens. + std::optional next(std::string_view tag, std::size_t tokens) { + const std::optional line = next(); + if (!line || line->tag() != tag || line->size() < tokens) { + return std::nullopt; + } + return line; + } + +private: + std::vector storage_; + std::size_t at_{0}; +}; + +/// Reads a version-3 turnout type — arcs plus the old fixed blade fields — and +/// converts it to a piece list. The old geometry closed itself: the last arc spent +/// whatever deflection was left to the crossing angle, and the frog rail ran on to +/// the catalogue length. Both are worked out here once, so an old plan opens as the +/// same shape and is saved in the new form. +bool read_legacy_type(Reader& reader, TurnoutType& type) { + const std::optional line = reader.next("type", 9); + double entry_transition = 0.0; + double exit_transition = 0.0; + double pre_blade = 0.0; + double blade_angle = 0.0; + double blade_length = 0.0; + std::uint32_t arc_count = 0; + if (!line || !to_double(line->tokens[1], type.crossing_n) || + !to_double(line->tokens[2], entry_transition) || + !to_double(line->tokens[3], exit_transition) || + !to_double(line->tokens[4], type.length) || + !to_double(line->tokens[5], pre_blade) || + !to_double(line->tokens[6], blade_angle) || + !to_double(line->tokens[7], blade_length) || !to_uint(line->tokens[8], arc_count)) { + return false; + } + + struct LegacyArc { + double radius{0.0}; + double length{0.0}; + double transition_to_next{0.0}; + }; + std::vector arcs; + for (std::uint32_t k = 0; k < arc_count; ++k) { + const std::optional arc_line = reader.next("tarc", 4); + LegacyArc arc; + if (!arc_line || !to_double(arc_line->tokens[1], arc.radius) || + !to_double(arc_line->tokens[2], arc.length) || + !to_double(arc_line->tokens[3], arc.transition_to_next)) { + return false; + } + arcs.push_back(arc); + } + if (arcs.empty() || arcs.front().radius <= 0.0 || arcs.back().radius <= 0.0) { + return false; + } + + const double alfa = type.crossing_n > 0.0 ? std::atan(1.0 / type.crossing_n) : 0.0; + // heading and through-projection are accumulated as the pieces are appended, so + // the closing arc and the frog rail can be given their old lengths + double heading = 0.0; + double projection = 0.0; // along the through track + double offset = 0.0; // away from it + const auto append = [&](int part, double length, double r0, double r1, double turn_in) { + if (length <= 0.0) { + return; + } + type.pieces.push_back(TurnoutPieceSpec{part, length, r0, r1, turn_in}); + heading += turn_in; + const double k0 = r0 > 0.0 ? 1.0 / r0 : 0.0; + const double k1 = r1 > 0.0 ? 1.0 / r1 : 0.0; + const double turned = 0.5 * (k0 + k1) * length; + // a straight projects whole; a turning piece projects its chord, which is + // shorter than its length by sin(theta/2)/(theta/2) and points at half the turn + const double half = 0.5 * turned; + const double chord = std::abs(half) < 1e-12 ? length : length * std::sin(half) / half; + projection += chord * std::cos(heading + half); + offset += chord * std::sin(heading + half); + heading += turned; + }; + + append(0, pre_blade, 0.0, 0.0, 0.0); + append(1, blade_length, 0.0, 0.0, blade_angle); + double pending_turn = blade_length > 0.0 ? 0.0 : blade_angle; + if (entry_transition > 0.0) { + append(2, entry_transition, 0.0, arcs.front().radius, pending_turn); + pending_turn = 0.0; + } + for (std::size_t k = 0; k + 1 < arcs.size(); ++k) { + append(2, arcs[k].length, arcs[k].radius, arcs[k].radius, pending_turn); + pending_turn = 0.0; + append(2, arcs[k].transition_to_next, arcs[k].radius, arcs[k + 1].radius, 0.0); + } + const double closing = alfa - heading - 0.5 * exit_transition / arcs.back().radius; + append(2, closing * arcs.back().radius, arcs.back().radius, arcs.back().radius, + pending_turn); + append(2, exit_transition, arcs.back().radius, 0.0, 0.0); + + const double rail = type.length > 0.0 && std::cos(alfa) > 1e-6 + ? (type.length - projection) / std::cos(alfa) + : arcs.back().radius * std::tan(alfa * 0.5); + append(3, rail, 0.0, 0.0, 0.0); + + // version 3 called the length the projection on the through track; version 4 uses + // the catalogue's own a + b, so it is restated here from where the frog ended up + if (std::abs(std::sin(alfa)) > 1e-9) { + const double back = offset / std::sin(alfa); + type.length = projection - back * std::cos(alfa) + back; + } + return !type.pieces.empty(); +} + +} // namespace + +// --------------------------------------------------------------------------- + +std::string serialize(const Document& document) { + std::string out; + out += "m0s " + std::to_string(kVersion) + "\n"; + out += "next " + num(document.next_id) + "\n"; + out += "view " + num(document.view_x) + " " + num(document.view_y) + " " + + num(document.view_extent) + "\n"; + out += "origin " + std::string(document.origin_set ? "1" : "0") + " " + + std::string(document.georeferenced ? "1" : "0") + " " + num(document.origin_x) + + " " + num(document.origin_y) + "\n"; + + out += "types " + std::to_string(document.turnout_types.size()) + "\n"; + for (const auto& type : document.turnout_types) { + out += "type " + num(type.crossing_n) + " " + num(type.length) + " " + + num(type.blade.tip_thickness) + " " + num(type.blade.nose) + " " + + num(type.blade.railtop_width) + " " + std::to_string(type.pieces.size()) + + "\n"; + for (const auto& piece : type.pieces) { + out += "tseg " + std::to_string(piece.part) + " " + num(piece.length) + " " + + num(piece.radius_start) + " " + num(piece.radius_end) + " " + + num(piece.turn_in) + "\n"; + } + out += "tname " + type.name + "\n"; + } + + out += "turnouts " + std::to_string(document.turnouts.size()) + "\n"; + for (const auto& turnout : document.turnouts) { + out += "turnout " + num(static_cast(turnout.id)) + " " + + num(static_cast(turnout.on)) + " " + num(turnout.station) + + " " + std::to_string(turnout.hand) + " " + + std::string(turnout.facing ? "1" : "0") + " " + + std::string(turnout.bend_from_track ? "1" : "0") + " " + num(turnout.bend) + + "\n"; + out += "tutype " + turnout.type + "\n"; + } + + out += "tracks " + std::to_string(document.tracks.size()) + "\n"; + for (const auto& track : document.tracks) { + out += "track " + num(static_cast(track.id)) + "\n"; + out += "tkname " + track.name + "\n"; + if (const auto* pose = std::get_if(&track.anchor)) { + out += "anchor pose " + num(pose->x) + " " + num(pose->y) + " " + num(pose->az) + + "\n"; + } else { + const auto& port = std::get(track.anchor); + out += "anchor port " + num(static_cast(port.turnout)) + " " + + std::string(port.port == Port::Frog ? "frog" : "start") + "\n"; + } + out += "elems " + std::to_string(track.elements.size()) + "\n"; + for (const auto& element : track.elements) { + const char* kind = element.kind == Kind::Line ? "line" + : element.kind == Kind::Arc ? "arc" + : "clot"; + out += "elem " + num(static_cast(element.id)) + " " + kind + " " + + num(element.radius) + " " + std::to_string(element.hand) + " " + + num(element.length) + "\n"; + if (const auto* parallel = std::get_if(&element.hold)) { + out += "epar " + num(static_cast(parallel->ref)) + " " + + num(parallel->offset) + "\n"; + } + } + } + return out; +} + +// --------------------------------------------------------------------------- + +std::optional deserialize(const std::string& text) { + Reader reader{text}; + Document document; + + int version = 0; + { + const std::optional header = reader.next("m0s", 2); + if (!header || !to_int(header->tokens[1], version) || + (version != kVersion && version != kStraightOnly && version != kNoseless && + version != kLegacyTurnoutVersion)) { + return std::nullopt; + } + } + { + const std::optional line = reader.next("next", 2); + if (!line || !to_uint(line->tokens[1], document.next_id)) { + return std::nullopt; + } + } + { + const std::optional line = reader.next("view", 4); + if (!line || !to_double(line->tokens[1], document.view_x) || + !to_double(line->tokens[2], document.view_y) || + !to_double(line->tokens[3], document.view_extent)) { + return std::nullopt; + } + } + { + const std::optional line = reader.next("origin", 5); + if (!line || !to_double(line->tokens[3], document.origin_x) || + !to_double(line->tokens[4], document.origin_y)) { + return std::nullopt; + } + document.origin_set = line->tokens[1] == "1"; + document.georeferenced = line->tokens[2] == "1"; + } + + const auto count_of = [&](std::string_view tag, std::size_t& out) { + const std::optional line = reader.next(tag, 2); + std::uint32_t value = 0; + if (!line || !to_uint(line->tokens[1], value)) { + return false; + } + out = value; + return true; + }; + + std::size_t count = 0; + + // ---- turnout types ---------------------------------------------------- + if (!count_of("types", count)) { + return std::nullopt; + } + for (std::size_t i = 0; i < count; ++i) { + TurnoutType type; + if (version == kLegacyTurnoutVersion) { + if (!read_legacy_type(reader, type)) { + return std::nullopt; + } + } else { + // the nose (d) came late: before it, a type line went straight from the tip + // thickness to the railhead width + const bool has_nose = version > kNoseless; + const std::optional line = reader.next("type", has_nose ? 7 : 6); + std::uint32_t piece_count = 0; + if (!line || !to_double(line->tokens[1], type.crossing_n) || + !to_double(line->tokens[2], type.length) || + !to_double(line->tokens[3], type.blade.tip_thickness) || + (has_nose && !to_double(line->tokens[4], type.blade.nose)) || + !to_double(line->tokens[has_nose ? 5 : 4], type.blade.railtop_width) || + !to_uint(line->tokens[has_nose ? 6 : 5], piece_count)) { + return std::nullopt; + } + for (std::uint32_t k = 0; k < piece_count; ++k) { + const std::optional piece_line = reader.next("tseg", 6); + TurnoutPieceSpec piece; + if (!piece_line || !to_int(piece_line->tokens[1], piece.part) || + !to_double(piece_line->tokens[2], piece.length) || + !to_double(piece_line->tokens[3], piece.radius_start) || + !to_double(piece_line->tokens[4], piece.radius_end) || + !to_double(piece_line->tokens[5], piece.turn_in)) { + return std::nullopt; + } + type.pieces.push_back(piece); + } + } + const std::optional name = reader.next("tname", 1); + if (!name) { + return std::nullopt; + } + type.name = std::string{name->tail}; + document.turnout_types.push_back(std::move(type)); + } + + // ---- turnouts --------------------------------------------------------- + if (!count_of("turnouts", count)) { + return std::nullopt; + } + for (std::size_t i = 0; i < count; ++i) { + // łukowanie came late: before it every placement stood on the track as it + // found it, which is what taking the bend from the track means anyway + const bool has_bend = version > kStraightOnly; + const std::optional line = reader.next("turnout", has_bend ? 8 : 6); + TurnoutPlacement placement; + std::uint32_t id = 0; + std::uint32_t on = 0; + if (!line || !to_uint(line->tokens[1], id) || !to_uint(line->tokens[2], on) || + !to_double(line->tokens[3], placement.station) || + !to_int(line->tokens[4], placement.hand)) { + return std::nullopt; + } + placement.id = static_cast(id); + placement.on = static_cast(on); + placement.facing = line->tokens[5] == "1"; + if (has_bend) { + placement.bend_from_track = line->tokens[6] == "1"; + if (!to_double(line->tokens[7], placement.bend)) { + return std::nullopt; + } + } + const std::optional type = reader.next("tutype", 1); + if (!type) { + return std::nullopt; + } + placement.type = std::string{type->tail}; + document.turnouts.push_back(std::move(placement)); + } + + // ---- tracks ----------------------------------------------------------- + if (!count_of("tracks", count)) { + return std::nullopt; + } + for (std::size_t i = 0; i < count; ++i) { + const std::optional head = reader.next("track", 2); + Track track; + std::uint32_t id = 0; + if (!head || !to_uint(head->tokens[1], id)) { + return std::nullopt; + } + track.id = static_cast(id); + + const std::optional name = reader.next("tkname", 1); + if (!name) { + return std::nullopt; + } + track.name = std::string{name->tail}; + + const std::optional anchor = reader.next("anchor", 2); + if (!anchor) { + return std::nullopt; + } + if (anchor->tokens[1] == "pose" && anchor->size() >= 5) { + AtPose pose; + if (!to_double(anchor->tokens[2], pose.x) || + !to_double(anchor->tokens[3], pose.y) || + !to_double(anchor->tokens[4], pose.az)) { + return std::nullopt; + } + track.anchor = pose; + } else if (anchor->tokens[1] == "port" && anchor->size() >= 4) { + std::uint32_t turnout_id = 0; + if (!to_uint(anchor->tokens[2], turnout_id)) { + return std::nullopt; + } + track.anchor = AtPort{static_cast(turnout_id), + anchor->tokens[3] == "start" ? Port::Start : Port::Frog}; + } else { + return std::nullopt; + } + + std::size_t element_count = 0; + if (!count_of("elems", element_count)) { + return std::nullopt; + } + for (std::size_t k = 0; k < element_count; ++k) { + const std::optional line = reader.next("elem", 6); + Element element; + std::uint32_t element_id = 0; + if (!line || !to_uint(line->tokens[1], element_id) || + !to_double(line->tokens[3], element.radius) || + !to_int(line->tokens[4], element.hand) || + !to_double(line->tokens[5], element.length)) { + return std::nullopt; + } + element.id = static_cast(element_id); + if (line->tokens[2] == "line") { + element.kind = Kind::Line; + } else if (line->tokens[2] == "arc") { + element.kind = Kind::Arc; + } else if (line->tokens[2] == "clot") { + element.kind = Kind::Clothoid; + } else { + return std::nullopt; + } + + // A hold, when there is one, follows its element immediately. + if (reader.peek_tag() == "epar") { + const std::optional hold = reader.next("epar", 3); + std::uint32_t ref = 0; + Parallel parallel; + if (!hold || !to_uint(hold->tokens[1], ref) || + !to_double(hold->tokens[2], parallel.offset)) { + return std::nullopt; + } + parallel.ref = static_cast(ref); + element.hold = parallel; + } + + track.elements.push_back(element); + } + document.tracks.push_back(std::move(track)); + } + + return document; +} + +// --------------------------------------------------------------------------- + +std::string default_project_path() { return "editor/plan.m0s"; } + +bool save(const Document& document, const std::string& path) { + std::ofstream file{path, std::ios::binary | std::ios::trunc}; + if (!file) { + return false; + } + const std::string text = serialize(document); + file.write(text.data(), static_cast(text.size())); + return static_cast(file); +} + +std::optional load(const std::string& path) { + std::ifstream file{path, std::ios::binary}; + if (!file) { + return std::nullopt; + } + std::ostringstream buffer; + buffer << file.rdbuf(); + return deserialize(buffer.str()); +} + +} // namespace maj0sted::io diff --git a/maj0sted/src/io/project_serializer.cpp b/maj0sted/src/io/project_serializer.cpp deleted file mode 100644 index f52ab1fe..00000000 --- a/maj0sted/src/io/project_serializer.cpp +++ /dev/null @@ -1,291 +0,0 @@ -#include "maj0sted/io/project_serializer.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "maj0sted/maj0sted.hpp" - -namespace maj0sted::io { - -using namespace maj0sted::domain; - -namespace { - -template -struct overloaded : Ts... { - using Ts::operator()...; -}; -template -overloaded(Ts...) -> overloaded; - -// --- writing ------------------------------------------------------------- - -// Shortest round-trippable, locale-independent representation of a double. -std::string num(double value) { - char buffer[64]; - const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value); - return std::string(buffer, result.ptr); -} - -char dir_char(TurnDirection direction) { - return direction == TurnDirection::Left ? 'L' : 'R'; -} -char joint_char(JointContinuity joint) { - return joint == JointContinuity::AzimuthBreak ? 'B' : 'T'; -} -char curvature_char(VerticalCurvature curvature) { - return curvature == VerticalCurvature::Crest ? 'C' : 'S'; -} - -std::string optional_metres(const std::optional& length) { - return length ? num(length->metres()) : std::string{"-"}; -} -std::string optional_metres(const std::optional& radius) { - return radius ? num(radius->metres()) : std::string{"-"}; -} - -// --- reading ------------------------------------------------------------- - -std::vector tokenize(const std::string& line) { - std::vector tokens; - std::size_t i = 0; - const std::size_t n = line.size(); - while (i < n) { - while (i < n && std::isspace(static_cast(line[i]))) ++i; - std::size_t j = i; - while (j < n && !std::isspace(static_cast(line[j]))) ++j; - if (j > i) tokens.push_back(line.substr(i, j - i)); - i = j; - } - return tokens; -} - -double to_double(const std::string& text) { - double value{}; - const auto result = std::from_chars(text.data(), text.data() + text.size(), value); - if (result.ec != std::errc{}) { - throw std::runtime_error{"maj0sted: bad number '" + text + "'"}; - } - return value; -} - -std::uint64_t to_u64(const std::string& text) { - std::uint64_t value{}; - const auto result = std::from_chars(text.data(), text.data() + text.size(), value); - if (result.ec != std::errc{}) { - throw std::runtime_error{"maj0sted: bad integer '" + text + "'"}; - } - return value; -} - -TurnDirection to_direction(const std::string& text) { - if (text == "L") return TurnDirection::Left; - if (text == "R") return TurnDirection::Right; - throw std::runtime_error{"maj0sted: bad direction '" + text + "'"}; -} -JointContinuity to_joint(const std::string& text) { - if (text == "T") return JointContinuity::Tangent; - if (text == "B") return JointContinuity::AzimuthBreak; - throw std::runtime_error{"maj0sted: bad joint '" + text + "'"}; -} -VerticalCurvature to_curvature(const std::string& text) { - if (text == "C") return VerticalCurvature::Crest; - if (text == "S") return VerticalCurvature::Sag; - throw std::runtime_error{"maj0sted: bad curvature '" + text + "'"}; -} - -std::optional to_optional_length(const std::string& text) { - if (text == "-") return std::nullopt; - return Length::from_metres(to_double(text)); -} -std::optional to_optional_radius(const std::string& text) { - if (text == "-") return std::nullopt; - return Radius::from_metres(to_double(text)); -} - -} // namespace - -std::string serialize(const MapProject& project) { - std::string out; - out += "maj0sted 1\n"; - out += "crs " + std::to_string(project.crs().epsg()) + "\n"; - out += "niwelety " + std::to_string(project.niwelety().size()) + "\n"; - - for (const auto& niweleta : project.niwelety()) { - out += "niweleta " + std::to_string(niweleta.id().value()) + "\n"; - out += "name " + niweleta.name() + "\n"; - - const auto& plan = niweleta.plan(); - out += "plan " + std::to_string(plan.size()) + "\n"; - for (std::size_t k = 0; k < plan.elements().size(); ++k) { - const char joint = (k == 0) ? 'T' : joint_char(plan.joint(k - 1)); - std::visit( - overloaded{ - [&](const Straight& s) { - out += "straight " + std::to_string(s.id().value()) + " " + - num(s.start().x()) + " " + num(s.start().y()) + " " + - num(s.end().x()) + " " + num(s.end().y()) + " " + joint + - "\n"; - }, - [&](const CircularArc& a) { - out += "arc " + num(a.radius().metres()) + " " + - dir_char(a.direction()) + " " + optional_metres(a.length()) + - " " + joint + "\n"; - }, - [&](const TransitionCurve& t) { - out += "transition " + num(t.length().metres()) + " " + - optional_metres(t.start_radius()) + " " + - optional_metres(t.end_radius()) + " " + - dir_char(t.direction()) + " " + joint + "\n"; - }, - }, - plan.elements()[k]); - } - - const auto& profile = niweleta.profile(); - out += "profile " + std::to_string(profile.size()) + "\n"; - for (const auto& element : profile.elements()) { - std::visit( - overloaded{ - [&](const Grade& g) { - out += "grade " + num(g.length().metres()) + " " + - num(g.slope_permille()) + "\n"; - }, - [&](const VerticalCurve& v) { - out += "vcurve " + num(v.length().metres()) + " " + - num(v.radius().metres()) + " " + - curvature_char(v.curvature()) + "\n"; - }, - }, - element); - } - } - return out; -} - -MapProject deserialize(const std::string& text) { - // Split into non-blank lines, stripping any trailing '\r'. - std::vector lines; - { - std::istringstream in(text); - std::string line; - while (std::getline(in, line)) { - if (!line.empty() && line.back() == '\r') line.pop_back(); - bool only_ws = true; - for (const char c : line) { - if (!std::isspace(static_cast(c))) { - only_ws = false; - break; - } - } - if (!only_ws) lines.push_back(line); - } - } - - std::size_t cursor = 0; - const auto next_line = [&]() -> const std::string& { - if (cursor >= lines.size()) { - throw std::runtime_error{"maj0sted: unexpected end of data"}; - } - return lines[cursor++]; - }; - const auto expect = [&](const std::vector& tokens, const char* tag, - std::size_t min_size) { - if (tokens.size() < min_size || tokens[0] != tag) { - throw std::runtime_error{std::string{"maj0sted: expected '"} + tag + "'"}; - } - }; - - { - const auto header = tokenize(next_line()); - expect(header, "maj0sted", 2); - } - const auto crs_line = tokenize(next_line()); - expect(crs_line, "crs", 2); - const Crs crs{static_cast(to_u64(crs_line[1]))}; - - const auto count_line = tokenize(next_line()); - expect(count_line, "niwelety", 2); - const std::size_t niweleta_count = static_cast(to_u64(count_line[1])); - - MapProject project{crs}; - for (std::size_t index = 0; index < niweleta_count; ++index) { - const auto header = tokenize(next_line()); - expect(header, "niweleta", 2); - const NiweletaId id{to_u64(header[1])}; - - const std::string& name_line = next_line(); - if (name_line.rfind("name", 0) != 0) { - throw std::runtime_error{"maj0sted: expected 'name'"}; - } - std::string name = name_line.size() > 5 ? name_line.substr(5) : std::string{}; - Niweleta niweleta{id, std::move(name)}; - - const auto plan_line = tokenize(next_line()); - expect(plan_line, "plan", 2); - const std::size_t plan_count = static_cast(to_u64(plan_line[1])); - for (std::size_t k = 0; k < plan_count; ++k) { - const auto e = tokenize(next_line()); - if (e.empty()) throw std::runtime_error{"maj0sted: empty plan element"}; - if (e[0] == "straight") { - expect(e, "straight", 7); - niweleta.add_plan_element( - Straight{StraightId{to_u64(e[1])}, - CartesianPosition{to_double(e[2]), to_double(e[3])}, - CartesianPosition{to_double(e[4]), to_double(e[5])}}, - to_joint(e[6])); - } else if (e[0] == "arc") { - expect(e, "arc", 5); - niweleta.add_plan_element( - CircularArc{Radius::from_metres(to_double(e[1])), to_direction(e[2]), - to_optional_length(e[3])}, - to_joint(e[4])); - } else if (e[0] == "transition") { - expect(e, "transition", 6); - niweleta.add_plan_element( - TransitionCurve{Length::from_metres(to_double(e[1])), - to_optional_radius(e[2]), to_optional_radius(e[3]), - to_direction(e[4])}, - to_joint(e[5])); - } else { - throw std::runtime_error{"maj0sted: unknown plan element '" + e[0] + "'"}; - } - } - - const auto profile_line = tokenize(next_line()); - expect(profile_line, "profile", 2); - const std::size_t profile_count = - static_cast(to_u64(profile_line[1])); - for (std::size_t k = 0; k < profile_count; ++k) { - const auto e = tokenize(next_line()); - if (e.empty()) throw std::runtime_error{"maj0sted: empty profile element"}; - if (e[0] == "grade") { - expect(e, "grade", 3); - niweleta.add_profile_element( - Grade{Length::from_metres(to_double(e[1])), to_double(e[2])}); - } else if (e[0] == "vcurve") { - expect(e, "vcurve", 4); - niweleta.add_profile_element( - VerticalCurve{Length::from_metres(to_double(e[1])), - Radius::from_metres(to_double(e[2])), - to_curvature(e[3])}); - } else { - throw std::runtime_error{"maj0sted: unknown profile element '" + e[0] + - "'"}; - } - } - - project.add_niweleta(std::move(niweleta)); - } - return project; -} - -} // namespace maj0sted::io diff --git a/maj0sted/src/io/scn_export.cpp b/maj0sted/src/io/scn_export.cpp index bd51bfb5..554b6e06 100644 --- a/maj0sted/src/io/scn_export.cpp +++ b/maj0sted/src/io/scn_export.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -9,15 +10,13 @@ namespace maj0sted::io { namespace { -using maj0sted::editor::NiweletaPolys; using maj0sted::editor::PlanPoint; -using maj0sted::editor::PlanPolyline; +using maj0sted::editor::SolvedElement; +using maj0sted::editor::SolvedTrack; struct AxisElement { int kind{0}; - int straight_index{-1}; - int gap{-1}; - int element_index{-1}; + std::uint32_t element_id{0}; double length{0.0}; double radius_start{0.0}; double radius_end{0.0}; @@ -30,49 +29,25 @@ struct WorldXYZ { double z{0.0}; }; -std::vector recover_axis(const NiweletaPolys& solved) { +/// The solved elements, in the shape the emitter below wants. Nothing is +/// recovered or averaged: the solution already holds the axis. +std::vector axis_of(const SolvedTrack& track) { + const auto radius_of = [](double k) { + return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0; + }; std::vector out; - const auto& polys = solved.polylines; - for (std::size_t i = 0; i < polys.size();) { - const auto& a = polys[i]; - if (a.points.size() < 2) { - ++i; - continue; - } - if (i + 1 < polys.size()) { - const auto& b = polys[i + 1]; - if (b.straight_index == a.straight_index && b.gap == a.gap && - b.element_index == a.element_index && - b.points.size() == a.points.size()) { - AxisElement el; - el.kind = a.kind; - el.straight_index = a.straight_index; - el.gap = a.gap; - el.element_index = a.element_index; - el.length = a.length; - el.radius_start = a.radius_start; - el.radius_end = a.radius_end; - el.points.resize(a.points.size()); - for (std::size_t k = 0; k < a.points.size(); ++k) { - el.points[k] = {(a.points[k].x + b.points[k].x) * 0.5, - (a.points[k].y + b.points[k].y) * 0.5}; - } - out.push_back(std::move(el)); - i += 2; - continue; - } - } - AxisElement el; - el.kind = a.kind; - el.straight_index = a.straight_index; - el.gap = a.gap; - el.element_index = a.element_index; - el.length = a.length; - el.radius_start = a.radius_start; - el.radius_end = a.radius_end; - el.points = a.points; - out.push_back(std::move(el)); - ++i; + out.reserve(track.elements.size()); + for (const SolvedElement& element : track.elements) { + AxisElement axis; + axis.kind = element.kind == maj0sted::editor::Kind::Line ? 0 + : element.kind == maj0sted::editor::Kind::Arc ? 1 + : 2; + axis.element_id = static_cast(element.id); + axis.length = element.length; + axis.radius_start = radius_of(element.k0); + axis.radius_end = radius_of(element.k1); + axis.points = element.points; + out.push_back(std::move(axis)); } return out; } @@ -215,29 +190,29 @@ void write_scn_track(std::ostream& out, const std::string& name, const WorldXYZ& } // namespace -ScnExportOptions resolve_scn_origin(const std::vector& solved, +ScnExportOptions resolve_scn_origin(const maj0sted::editor::Solution& solution, ScnExportOptions options) { if (std::abs(options.origin_east) >= 1.0 || std::abs(options.origin_north) >= 1.0) { return options; } - for (const auto& s : solved) { - const auto axis = recover_axis(s); - if (!axis.empty() && !axis.front().points.empty()) { - const auto& p = axis.front().points.front(); - if (std::abs(p.x) > 10000.0 || std::abs(p.y) > 10000.0) { - options.origin_east = p.x; - options.origin_north = p.y; - } - break; + for (const auto& track : solution.tracks) { + if (track.elements.empty() || track.elements.front().points.empty()) { + continue; } + const auto& point = track.elements.front().points.front(); + if (std::abs(point.x) > 10000.0 || std::abs(point.y) > 10000.0) { + options.origin_east = point.x; + options.origin_north = point.y; + } + break; } return options; } -ScnExportResult export_scn(const std::vector& solved, +ScnExportResult export_scn(const maj0sted::editor::Solution& solution, const ScnExportOptions& options, std::ostream& out) { - const auto opt = resolve_scn_origin(solved, options); + const auto opt = resolve_scn_origin(solution, options); ScnExportResult result; result.origin_east = opt.origin_east; result.origin_north = opt.origin_north; @@ -248,8 +223,9 @@ ScnExportResult export_scn(const std::vector& solved, const double kRailY = opt.rail_y; const double kMaxArcAngle = opt.max_arc_angle; - for (std::size_t n = 0; n < solved.size(); ++n) { - const auto axis = recover_axis(solved[n]); + for (const auto& track : solution.tracks) { + const auto track_id = static_cast(track.id); + const auto axis = axis_of(track); for (std::size_t ei = 0; ei < axis.size(); ++ei) { const auto& el = axis[ei]; if (el.points.size() < 2) { @@ -315,13 +291,8 @@ ScnExportResult export_scn(const std::vector& solved, } std::ostringstream name; - name << "plan_n" << n; - if (el.straight_index >= 0) { - name << "_s" << el.straight_index; - } else { - name << "_g" << el.gap << "_e" << el.element_index; - } - name << '_' << tag << '_' << result.tracks; + name << "plan_t" << track_id << "_e" << el.element_id << '_' << tag + << '_' << result.tracks; const auto track_name = name.str(); if (result.first_track_name.empty()) { result.first_track_name = track_name; diff --git a/maj0sted/src/io/svg_writer.cpp b/maj0sted/src/io/svg_writer.cpp deleted file mode 100644 index 5a32b9e4..00000000 --- a/maj0sted/src/io/svg_writer.cpp +++ /dev/null @@ -1,63 +0,0 @@ -#include "maj0sted/io/svg_writer.hpp" - -#include -#include - -namespace maj0sted::io { - -namespace { - -std::string num(double value) { - char buffer[64]; - const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value); - return std::string(buffer, result.ptr); -} - -const char* colour(render::ElementKind kind) { - switch (kind) { - case render::ElementKind::Straight: - return "#222222"; - case render::ElementKind::Arc: - return "#1f77b4"; - case render::ElementKind::Transition: - return "#ff7f0e"; - } - return "#000000"; -} - -} // namespace - -std::string to_svg(const render::Scene& scene, const SvgOptions& options) { - double world_w = scene.max_x - scene.min_x; - double world_h = scene.max_y - scene.min_y; - if (world_w <= 0.0) world_w = 1.0; - if (world_h <= 0.0) world_h = 1.0; - - const double scale = (options.width_px - 2.0 * options.margin_px) / world_w; - const double height_px = 2.0 * options.margin_px + world_h * scale; - - // World -> SVG. North is up, so the y axis is flipped. - const auto sx = [&](double x) { return options.margin_px + (x - scene.min_x) * scale; }; - const auto sy = [&](double y) { return options.margin_px + (scene.max_y - y) * scale; }; - - std::string out; - out += "\n"; - out += "\n"; - - for (const auto& polyline : scene.polylines) { - out += std::string("\n"; - } - - out += "\n"; - return out; -} - -} // namespace maj0sted::io diff --git a/maj0sted/src/render/plan_sampler.cpp b/maj0sted/src/render/plan_sampler.cpp deleted file mode 100644 index f9687909..00000000 --- a/maj0sted/src/render/plan_sampler.cpp +++ /dev/null @@ -1,116 +0,0 @@ -#include "maj0sted/render/plan_sampler.hpp" - -#include -#include -#include - -#include "maj0sted/domain/geometry/segment_layout.hpp" -#include "maj0sted/maj0sted.hpp" - -namespace maj0sted::render { - -using namespace maj0sted::domain; - -namespace { - -template -struct overloaded : Ts... { - using Ts::operator()...; -}; -template -overloaded(Ts...) -> overloaded; - -struct Vec { - double east; - double north; -}; - -Vec direction_of(Azimuth azimuth) noexcept { - return Vec{std::sin(azimuth.radians()), std::cos(azimuth.radians())}; -} - -// Samples a segment of linearly varying signed curvature (arc when k0 == k1, -// clothoid otherwise), starting at @p pos heading @p heading. Appends points to -// @p out and updates @p pos / @p heading to the segment's end. -// Samples a segment via the shared layout (identical to the fitting solver, so -// the drawn curve coincides exactly with the fitted tangent points). -void sample_segment(double k0, double k1, double length, Vec& pos, Vec& heading, - std::vector& out) { - geometry::Pose start{pos.east, pos.north, heading.east, heading.north}; - std::vector points; - const geometry::Pose end = geometry::layout_segment(k0, k1, length, start, &points); - for (const auto& p : points) out.push_back({p.x, p.y}); - pos = Vec{end.x, end.y}; - heading = Vec{end.hx, end.hy}; -} - -void expand(Scene& scene, const Point& p) { - if (scene.empty) { - scene.min_x = scene.max_x = p.x; - scene.min_y = scene.max_y = p.y; - scene.empty = false; - return; - } - scene.min_x = std::min(scene.min_x, p.x); - scene.min_y = std::min(scene.min_y, p.y); - scene.max_x = std::max(scene.max_x, p.x); - scene.max_y = std::max(scene.max_y, p.y); -} - -void sample_into(const HorizontalAlignment& plan, Scene& scene) { - Vec pos{0.0, 0.0}; - Vec heading{1.0, 0.0}; - - for (const auto& element : plan.elements()) { - Polyline polyline; - std::visit( - overloaded{ - [&](const Straight& s) { - polyline.kind = ElementKind::Straight; - polyline.points = {{s.start().x(), s.start().y()}, - {s.end().x(), s.end().y()}}; - pos = Vec{s.end().x(), s.end().y()}; - heading = direction_of(s.azimuth()); - }, - [&](const CircularArc& a) { - polyline.kind = ElementKind::Arc; - const double sign = a.direction() == TurnDirection::Left ? 1.0 : -1.0; - const double k = sign / a.radius().metres(); - const double length = a.length() ? a.length()->metres() : 0.0; - sample_segment(k, k, length, pos, heading, polyline.points); - }, - [&](const TransitionCurve& t) { - polyline.kind = ElementKind::Transition; - const double sign = t.direction() == TurnDirection::Left ? 1.0 : -1.0; - const double k0 = - t.start_radius() ? sign / t.start_radius()->metres() : 0.0; - const double k1 = - t.end_radius() ? sign / t.end_radius()->metres() : 0.0; - sample_segment(k0, k1, t.length().metres(), pos, heading, - polyline.points); - }, - }, - element); - - for (const auto& p : polyline.points) expand(scene, p); - scene.polylines.push_back(std::move(polyline)); - } -} - -} // namespace - -Scene sample(const HorizontalAlignment& plan) { - Scene scene; - sample_into(plan, scene); - return scene; -} - -Scene sample(const MapProject& project) { - Scene scene; - for (const auto& niweleta : project.niwelety()) { - sample_into(niweleta.plan(), scene); - } - return scene; -} - -} // namespace maj0sted::render diff --git a/maj0sted/src/render/rail_renderer.cpp b/maj0sted/src/render/rail_renderer.cpp index f1ece92b..7cf5e619 100644 --- a/maj0sted/src/render/rail_renderer.cpp +++ b/maj0sted/src/render/rail_renderer.cpp @@ -46,9 +46,9 @@ void RailRenderer::add(const CentrelineElement& element) { for (const double side : {half_gauge_, -half_gauge_}) { RailPolyline rail; rail.kind = element.kind; - rail.straight_index = element.straight_index; - rail.gap = element.gap; - rail.element_index = element.element_index; + rail.element_id = element.element_id; + rail.owner_id = element.owner_id; + rail.from_turnout = element.from_turnout; rail.length = element.length; rail.radius_start = element.radius_start; rail.radius_end = element.radius_end; diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt index 94e2b978..dabea4e8 100644 --- a/maj0sted/tests/CMakeLists.txt +++ b/maj0sted/tests/CMakeLists.txt @@ -1,54 +1,35 @@ -add_executable(maj0sted_test_map_project test_map_project.cpp) -target_link_libraries(maj0sted_test_map_project PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_map_project PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME map_project COMMAND maj0sted_test_map_project) +add_executable(maj0sted_test_chain test_chain.cpp) +target_link_libraries(maj0sted_test_chain PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_chain PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME chain COMMAND maj0sted_test_chain) -add_executable(maj0sted_test_niweleta test_niweleta.cpp) -target_link_libraries(maj0sted_test_niweleta PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_niweleta PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME niweleta COMMAND maj0sted_test_niweleta) +add_executable(maj0sted_test_hold test_hold.cpp) +target_link_libraries(maj0sted_test_hold PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_hold PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME hold COMMAND maj0sted_test_hold) -add_executable(maj0sted_test_plan_alignment test_plan_alignment.cpp) -target_link_libraries(maj0sted_test_plan_alignment PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_plan_alignment PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME plan_alignment COMMAND maj0sted_test_plan_alignment) +add_executable(maj0sted_test_lines test_lines.cpp) +target_link_libraries(maj0sted_test_lines PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_lines PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME lines COMMAND maj0sted_test_lines) -add_executable(maj0sted_test_parallelism test_parallelism.cpp) -target_link_libraries(maj0sted_test_parallelism PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_parallelism PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME parallelism COMMAND maj0sted_test_parallelism) +add_executable(maj0sted_test_graph test_graph.cpp) +target_link_libraries(maj0sted_test_graph PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_graph PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME graph COMMAND maj0sted_test_graph) -add_executable(maj0sted_test_fitting test_fitting.cpp) -target_link_libraries(maj0sted_test_fitting PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_fitting PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME fitting COMMAND maj0sted_test_fitting) +add_executable(maj0sted_test_format test_format.cpp) +target_link_libraries(maj0sted_test_format PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_format PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME format COMMAND maj0sted_test_format) -add_executable(maj0sted_test_serialization test_serialization.cpp) -target_link_libraries(maj0sted_test_serialization PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_serialization PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME serialization COMMAND maj0sted_test_serialization) - -add_executable(maj0sted_test_render test_render.cpp) -target_link_libraries(maj0sted_test_render PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_render PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME render COMMAND maj0sted_test_render) - -add_executable(maj0sted_test_editor test_editor.cpp) -target_link_libraries(maj0sted_test_editor PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_editor PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME editor COMMAND maj0sted_test_editor) +add_executable(maj0sted_test_turnout test_turnout.cpp) +target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME turnout COMMAND maj0sted_test_turnout) add_executable(maj0sted_test_tile_cache test_tile_cache.cpp) target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted) target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME tile_cache COMMAND maj0sted_test_tile_cache) -add_executable(maj0sted_test_document test_document.cpp) -target_link_libraries(maj0sted_test_document PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_document PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME document COMMAND maj0sted_test_document) - -add_executable(maj0sted_test_turnout test_turnout.cpp) -target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME turnout COMMAND maj0sted_test_turnout) diff --git a/maj0sted/tests/test_chain.cpp b/maj0sted/tests/test_chain.cpp new file mode 100644 index 00000000..80e3bcae --- /dev/null +++ b/maj0sted/tests/test_chain.cpp @@ -0,0 +1,230 @@ +// The element chain: a walk, one element at a time, with every length said +// outright. Nothing here is solved for — what the document says is what is laid. + +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/chain.hpp" + +using namespace maj0sted::editor; +using maj0sted::domain::geometry::Pose; + +namespace { + +constexpr double kTol = 1e-6; + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Pose pose_at(double x, double y, double azimuth) { + return Pose{x, y, std::sin(azimuth), std::cos(azimuth)}; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} +Element clothoid(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Clothoid, radius, hand, metres, Free{}}; +} + +bool has(const TrackOutcome& outcome, Code code) { + for (const auto& diagnostic : outcome.diagnostics) { + if (diagnostic.code == code) { + return true; + } + } + return false; +} + +// ------------------------------------------------------------------------- + +/// A straight, an arc and a straight laid head to tail: no construction lines, +/// no fitting, nothing to close onto. +void open_chain_lays_head_to_tail() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 100.0), arc(document, 300.0, 1, 150.0), + line(document, 50.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.complete); + CHECK(out.track.elements.size() == 3); + CHECK(near(out.track.length, 300.0)); + + // Heading turned by L/R = 150/300 = 0.5 rad, to the left (hand = +1). + const double turned = 0.5; + CHECK(near(out.track.end.hx, std::sin(-turned) * -1.0 + 0.0, 1e-9) || + near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9)); + // Start pose points north; after a left turn the azimuth decreases. + CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9)); + + // The first element starts exactly at the anchor. + CHECK(near(out.track.elements[0].start.x, 0.0)); + CHECK(near(out.track.elements[0].start.y, 0.0)); + // The straight really is 100 m due north. + CHECK(near(out.track.elements[0].end.x, 0.0, 1e-9)); + CHECK(near(out.track.elements[0].end.y, 100.0, 1e-9)); +} + +/// An arc as the very first element — the "łuk wprost z krzyżownicy" case. The +/// old model could not say this: a curve only existed between two straights. +void a_chain_may_start_with_an_arc() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 190.0, -1, 60.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(10.0, 20.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.elements.size() == 1); + CHECK(near(out.track.elements[0].start.x, 10.0)); + CHECK(near(out.track.elements[0].start.y, 20.0)); + CHECK(near(out.track.elements[0].k0, -1.0 / 190.0)); + CHECK(near(out.track.length, 60.0)); +} + +/// Two arcs back to back with no straight between them — also unsayable before. +void arcs_may_follow_each_other() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 400.0, 1, 100.0), arc(document, 250.0, -1, 80.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.elements.size() == 2); + // The second starts exactly where the first ends, tangentially. + CHECK(near(out.track.elements[1].start.x, out.track.elements[0].end.x, 1e-12)); + CHECK(near(out.track.elements[1].start.y, out.track.elements[0].end.y, 1e-12)); + CHECK(near(out.track.elements[1].start.hx, out.track.elements[0].end.hx, 1e-12)); + // Net turn: +100/400 - 80/250 = 0.25 - 0.32 = -0.07 rad. + CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), 0.07, 1e-9)); +} + +/// A chain that simply stops. Nothing is synthesised to close it onto. +void an_open_chain_need_not_close() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 300.0, 1, 500.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(out.diagnostics.empty()); + CHECK(out.track.complete); + CHECK(out.track.elements.size() == 1); +} + +/// What cannot be laid is said out loud, and the chain still gives up whatever +/// stood before it. +void a_bad_element_is_reported_and_stops_the_chain() { + { // A length of nothing. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {line(document, 100.0), arc(document, 300.0, 1, 0.0), + line(document, 50.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + CHECK(!out.track.complete); + CHECK(out.track.elements.size() == 1); // the straight before it still stands + CHECK(near(out.track.length, 100.0)); + } + { // An arc without a radius. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 0.0, 1, 50.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + CHECK(out.track.elements.empty()); + } + { // An arc that does not say which way it turns. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 300.0, 0, 50.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + } + { // A transition curve that eases nothing: flat to flat. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {line(document, 20.0), clothoid(document, 0.0, 1, 40.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + CHECK(out.track.elements.size() == 1); + } +} + +/// A transition curve takes its near-end curvature from wherever the chain has +/// got to, and hands its far-end curvature on. That is the only way one element +/// depends on another. +void a_transition_eases_between_what_it_finds() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 300.0, 1, 100.0), clothoid(document, 600.0, 1, 50.0), + arc(document, 600.0, 1, 80.0), clothoid(document, 0.0, 1, 40.0), + line(document, 30.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.elements.size() == 5); + CHECK(near(out.track.elements[1].k0, 1.0 / 300.0)); + CHECK(near(out.track.elements[1].k1, 1.0 / 600.0)); + CHECK(near(out.track.elements[3].k0, 1.0 / 600.0)); + CHECK(near(out.track.elements[3].k1, 0.0)); + // Every element kept the length it was given. + CHECK(near(out.track.length, 100.0 + 50.0 + 80.0 + 40.0 + 30.0)); +} + +/// Solving twice gives the same answer: nothing is written back anywhere. +void solving_is_idempotent() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {line(document, 120.0), clothoid(document, 500.0, -1, 40.0), + arc(document, 500.0, -1, 180.0), clothoid(document, 0.0, -1, 40.0), + line(document, 60.0)}; + + const TrackOutcome first = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + const TrackOutcome second = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(first.diagnostics.empty()); + CHECK(first.track.elements.size() == second.track.elements.size()); + for (std::size_t i = 0; i < first.track.elements.size(); ++i) { + CHECK(near(first.track.elements[i].length, second.track.elements[i].length, kTol)); + CHECK(near(first.track.elements[i].start.x, second.track.elements[i].start.x, kTol)); + } +} + +} // namespace + +int main() { + RUN(open_chain_lays_head_to_tail); + RUN(a_chain_may_start_with_an_arc); + RUN(arcs_may_follow_each_other); + RUN(an_open_chain_need_not_close); + RUN(a_bad_element_is_reported_and_stops_the_chain); + RUN(a_transition_eases_between_what_it_finds); + RUN(solving_is_idempotent); + return REPORT(); +} diff --git a/maj0sted/tests/test_document.cpp b/maj0sted/tests/test_document.cpp deleted file mode 100644 index cb59e5d2..00000000 --- a/maj0sted/tests/test_document.cpp +++ /dev/null @@ -1,144 +0,0 @@ -#include -#include - -#include "check.hpp" -#include "maj0sted/app/editor_document.hpp" - -using namespace maj0sted::app; -using maj0sted::editor::GapFit; -using maj0sted::editor::NiweletaSpec; -using maj0sted::editor::StraightSpec; - -namespace { - -EditorDocument sample_document() { - EditorDocument doc; - - NiweletaSpec a; - a.name = "Niweleta 1"; - a.straights = {StraightSpec{0.0, 0.0, 120.0, 0.0}, - StraightSpec{120.0, 0.0, 120.0, 120.0, true}}; - GapFit fit; - fit.gap = 0; - fit.mode = 2; // arc + transitions - fit.radius = 40.0; - fit.transition = 12.5; - a.fits = {fit}; - - NiweletaSpec b; - b.name = "Bocznica z spacją"; - b.straights = {StraightSpec{500000.25, 300000.75, 500200.0, 300000.0}}; - b.straights[0].rel_kind = 1; - b.straights[0].rel_niw = 0; - b.straights[0].rel_str = 0; - b.straights[0].rel_offset = -4.5; - - doc.niwelety = {a, b}; - doc.view_x = 500100.5; - doc.view_y = 300050.25; - doc.view_extent = 420.0; - doc.origin_set = true; - doc.georeferenced = true; - doc.origin_x = 500000.0; - doc.origin_y = 300000.0; - return doc; -} - -bool same(const StraightSpec& x, const StraightSpec& y) { - return x.x1 == y.x1 && x.y1 == y.y1 && x.x2 == y.x2 && x.y2 == y.y2 && - x.hidden == y.hidden && x.rel_kind == y.rel_kind && - x.rel_niw == y.rel_niw && x.rel_str == y.rel_str && - x.rel_offset == y.rel_offset && x.rel_cot == y.rel_cot && - x.rel_side == y.rel_side && x.rel_length == y.rel_length; -} - -bool same(const GapFit& x, const GapFit& y) { - return x.gap == y.gap && x.mode == y.mode && x.radius == y.radius && - x.transition == y.transition && x.r1 == y.r1 && x.arc1_len == y.arc1_len && - x.between == y.between && x.r2 == y.r2 && x.entry_t == y.entry_t && - x.exit_t == y.exit_t; -} - -void check_equal(const EditorDocument& x, const EditorDocument& y) { - CHECK(x.view_x == y.view_x); - CHECK(x.view_y == y.view_y); - CHECK(x.view_extent == y.view_extent); - CHECK(x.origin_set == y.origin_set); - CHECK(x.georeferenced == y.georeferenced); - CHECK(x.origin_x == y.origin_x); - CHECK(x.origin_y == y.origin_y); - CHECK(x.niwelety.size() == y.niwelety.size()); - if (x.niwelety.size() != y.niwelety.size()) return; - for (std::size_t n = 0; n < x.niwelety.size(); ++n) { - const auto& a = x.niwelety[n]; - const auto& b = y.niwelety[n]; - CHECK(a.name == b.name); - CHECK(a.straights.size() == b.straights.size()); - for (std::size_t i = 0; i < a.straights.size() && i < b.straights.size(); ++i) { - CHECK(same(a.straights[i], b.straights[i])); - } - CHECK(a.fits.size() == b.fits.size()); - for (std::size_t i = 0; i < a.fits.size() && i < b.fits.size(); ++i) { - CHECK(same(a.fits[i], b.fits[i])); - } - } -} - -void serialize_round_trips_exactly() { - const EditorDocument doc = sample_document(); - const std::string text = serialize_document(doc); - const EditorDocument back = deserialize_document(text); - check_equal(doc, back); - - // The file really embeds a maj0sted project (reuses io::serialize). - CHECK(text.rfind("maj0sted", 0) == 0); - CHECK(text.find("\neditor 2\n") != std::string::npos); - - // Serialization is stable (byte-for-byte) across a round trip. - CHECK(serialize_document(back) == text); -} - -void embedded_project_is_valid_and_readable() { - const EditorDocument doc = sample_document(); - // to_map_project keeps one niweleta per editor niweleta and the straights. - const auto project = to_map_project(doc); - CHECK(project.niwelety().size() == 2); - const EditorDocument imported = from_map_project(project); - CHECK(imported.niwelety.size() == 2); - CHECK(imported.niwelety[0].straights.size() == 2); - CHECK(imported.niwelety[0].name == "Niweleta 1"); -} - -void save_then_load_from_disk() { - const EditorDocument doc = sample_document(); - const std::filesystem::path dir = - std::filesystem::temp_directory_path() / "maj0sted_test"; - std::filesystem::remove_all(dir); - const std::string path = (dir / "sub" / "proj.m0s").string(); - - CHECK(save_project(doc, path)); // creates parent dirs - CHECK(std::filesystem::exists(path)); - - const auto loaded = load_project(path); - CHECK(loaded.has_value()); - if (loaded) check_equal(doc, *loaded); - - std::filesystem::remove_all(dir); -} - -void load_missing_file_is_nullopt() { - const std::string path = - (std::filesystem::temp_directory_path() / "maj0sted_nope_12345.m0s").string(); - std::filesystem::remove(path); - CHECK(!load_project(path).has_value()); -} - -} // namespace - -int main() { - RUN(serialize_round_trips_exactly); - RUN(embedded_project_is_valid_and_readable); - RUN(save_then_load_from_disk); - RUN(load_missing_file_is_nullopt); - return REPORT(); -} diff --git a/maj0sted/tests/test_editor.cpp b/maj0sted/tests/test_editor.cpp deleted file mode 100644 index afc3eb72..00000000 --- a/maj0sted/tests/test_editor.cpp +++ /dev/null @@ -1,271 +0,0 @@ -#include -#include - -#include "check.hpp" -#include "maj0sted/editor/editor.hpp" -#include "maj0sted/editor/solve.hpp" - -using namespace maj0sted::editor; - -namespace { - -bool has_kind(const std::vector& polylines, int kind) { - for (const auto& p : polylines) { - if (p.kind == kind) return true; - } - return false; -} - -void arc_with_transitions_produces_full_chain() { - SolveRequest r{}; - r.ax = 0.0; r.ay = 0.0; - r.bx = 300.0; r.by = 0.0; - r.cx = 300.0; r.cy = 300.0; - r.mode = 1; - r.radius = 80.0; - r.transition = 20.0; - - const auto polylines = solve_scene(r); - CHECK(!polylines.empty()); - CHECK(has_kind(polylines, 0)); // straight - CHECK(has_kind(polylines, 1)); // arc - CHECK(has_kind(polylines, 2)); // transition - // Each polyline has at least two points. - for (const auto& p : polylines) CHECK(p.points.size() >= 2); -} - -void compound_mode_produces_two_arcs() { - SolveRequest r{}; - r.ax = 0.0; r.ay = 0.0; - r.bx = 300.0; r.by = 0.0; - r.cx = 300.0; r.cy = 300.0; - r.mode = 2; - r.r1 = 120.0; r.arc1_len = 60.0; r.between = 25.0; r.r2 = 80.0; - r.entry_t = 30.0; r.exit_t = 30.0; - - const auto polylines = solve_scene(r); - int arcs = 0; - for (const auto& p : polylines) { - if (p.kind == 1) ++arcs; - } - CHECK(arcs == 2); - CHECK(has_kind(polylines, 2)); // transitions present -} - -void degenerate_input_falls_back_to_straights() { - // Collinear vertices -> no fit; still returns the straights, never throws. - SolveRequest r{}; - r.ax = 0.0; r.ay = 0.0; - r.bx = 100.0; r.by = 0.0; - r.cx = 200.0; r.cy = 0.0; - r.mode = 0; - r.radius = 50.0; - - const auto polylines = solve_scene(r); - CHECK(!polylines.empty()); - CHECK(has_kind(polylines, 0)); // straights - CHECK(!has_kind(polylines, 1)); // no arc -} - -NiweletaSpec short_corner(double radius, double transition = 0.0) { - NiweletaSpec spec; - spec.straights = { - StraightSpec{0.0, 0.0, 10.0, 0.0}, - StraightSpec{10.0, 0.0, 10.0, 10.0}, - }; - spec.fits = {GapFit{.gap = 0, - .mode = transition > 0.0 ? 2 : 1, - .radius = radius, - .transition = transition}}; - return spec; -} - -void editor_clamps_oversized_arc_to_fitting_radius() { - const auto solved = solve_project({short_corner(60.0)}); - CHECK(solved.size() == 1); - CHECK(solved[0].applied_fits.size() == 1); - const double applied = solved[0].applied_fits[0].radius; - CHECK(applied > 9.9); - CHECK(applied < 10.1); -} - -void editor_leaves_gap_unfitted_when_radius_below_floor() { - // A radius too small for the requested transitions is NEVER enlarged (the - // fitting rule only ever reduces). When nothing at or below the request fits, - // the gap is simply left unconnected. - NiweletaSpec spec; - spec.straights = { - StraightSpec{0.0, 0.0, 100.0, 0.0}, - StraightSpec{100.0, 0.0, 100.0, 100.0}, - }; - spec.fits = {GapFit{.gap = 0, .mode = 2, .radius = 1.0, .transition = 20.0}}; - - const auto solved = solve_project({spec}); - CHECK(solved.size() == 1); - CHECK(solved[0].applied_fits.empty()); - CHECK(!has_kind(solved[0].polylines, 1)); // no arc rendered -} - -void editor_emits_two_rails_at_standard_gauge() { - const auto solved = solve_project({short_corner(5.0)}); - CHECK(solved[0].polylines.size() == 6); - CHECK(solved[0].rendered_straights.size() == 2); - CHECK(std::abs(solved[0].rendered_straights[0].x2 - 5.0) < 1e-6); - CHECK(std::abs(solved[0].rendered_straights[1].y1 - 5.0) < 1e-6); - const auto& left = solved[0].polylines[0].points; - const auto& right = solved[0].polylines[1].points; - CHECK(left.size() == right.size()); - CHECK(std::abs(std::hypot(left[0].x - right[0].x, left[0].y - right[0].y) - - 1.5) < 1e-9); - CHECK(solved[0].polylines[0].straight_index == 0); - CHECK(solved[0].polylines[0].gap == -1); - CHECK(std::abs(solved[0].polylines[0].length - 5.0) < 1e-9); - const PlanPolyline* arc = nullptr; - for (const auto& polyline : solved[0].polylines) { - if (polyline.kind == 1) { - arc = &polyline; - break; - } - } - CHECK(arc != nullptr); - CHECK(arc->straight_index == -1); - CHECK(arc->gap == 0); - CHECK(arc->element_index == 0); - CHECK(std::abs(arc->radius_start - 5.0) < 1e-9); - CHECK(std::abs(arc->radius_end - 5.0) < 1e-9); - CHECK(std::abs(arc->length - 5.0 * std::numbers::pi / 2.0) < 1e-9); -} - -void editor_exposes_transition_radius_range() { - const auto solved = solve_project({short_corner(5.0, 2.0)}); - const PlanPolyline* transition = nullptr; - for (const auto& polyline : solved[0].polylines) { - if (polyline.kind == 2) { - transition = &polyline; - break; - } - } - CHECK(transition != nullptr); - CHECK(transition->gap == 0); - CHECK(std::abs(transition->length - 2.0) < 1e-9); - CHECK(transition->radius_start == 0.0); - CHECK(std::abs(transition->radius_end - 5.0) < 1e-9); -} - -void editor_leaves_parallel_offset_unfitted() { - // Two parallel, laterally offset straights admit no plain arc (a single arc - // cannot connect parallels) and reverse S-curves are never substituted, so the - // gap stays unconnected rather than inventing a curve. - NiweletaSpec spec; - spec.straights = { - StraightSpec{0.0, 0.0, 200.0, 0.0}, - StraightSpec{0.0, 20.0, 200.0, 20.0}, - }; - spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 50.0}}; - - const auto solved = solve_project({spec}); - CHECK(solved[0].applied_fits.empty()); - CHECK(!has_kind(solved[0].polylines, 1)); // no arc, no reverse pair -} - -void editor_honours_requested_radius_via_single_arc() { - // A requested radius that lands on the straights (extending them toward the - // corner if needed) is used verbatim as a single arc — never enlarged and - // never swapped for a reverse pair. - const double heading = std::numbers::pi / 6.0; - const double tangent_x = 86.9615242271; - const double tangent_y = 25.9807621135; - const double dx = std::cos(heading); - const double dy = std::sin(heading); - NiweletaSpec spec; - spec.straights = { - StraightSpec{0.0, 0.0, 100.0, 0.0}, - StraightSpec{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy, - tangent_x + 70.0 * dx, tangent_y + 70.0 * dy}, - }; - spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 30.0}}; - - const auto solved = solve_project({spec}); - CHECK(solved[0].applied_fits.size() == 1); - CHECK(std::abs(solved[0].applied_fits[0].radius - 30.0) < 1e-9); - int arc_rails = 0; - for (const auto& polyline : solved[0].polylines) { - if (polyline.kind == 1) ++arc_rails; - } - CHECK(arc_rails == 2); // a single arc (two rails), not a reverse pair -} - -void hidden_tangent_guide_fits_curve_without_rendering_track() { - NiweletaSpec spec = short_corner(5.0); - spec.straights[1].hidden = true; - - const auto solved = solve_project({spec}); - CHECK(solved[0].applied_fits.size() == 1); - CHECK(solved[0].rendered_straights[1].hidden); - int straight_rails = 0; - int arc_rails = 0; - for (const auto& polyline : solved[0].polylines) { - if (polyline.kind == 0) ++straight_rails; - if (polyline.kind == 1) ++arc_rails; - } - CHECK(straight_rails == 2); // only the visible entry straight - CHECK(arc_rails == 2); -} - -void branch_rides_rigidly_when_pinned_to_frog() { - // Through track along +x; a switch diverges left at station 100. - NiweletaSpec through; - through.straights = {StraightSpec{0.0, 0.0, 200.0, 0.0}}; - - // Branch drawn far away as an L: a leaving straight plus one appended straight - // joined by an arc. Pinning must move the WHOLE branch rigidly onto the frog, - // so the appended fragment rides along instead of being left ~1.4 km away - // (the pre-fix behaviour, which only relocated the leaving straight). - NiweletaSpec branch; - branch.straights = {StraightSpec{1000.0, 1000.0, 1090.0, 1000.0}, - StraightSpec{1100.0, 1000.0, 1100.0, 1090.0}}; - branch.fits = {GapFit{.gap = 0, .mode = 1, .radius = 20.0}}; - - Junction j{}; - j.through = 0; - j.station = 100.0; - j.side = 0; // diverge left - j.facing = true; - j.crossing_n = 9.0; - j.curve.mode = 1; - j.curve.radius = 190.0; - j.branch = 1; - - const auto solution = solve_layout({through, branch}, {j}); - CHECK(solution.junctions.size() == 1); - CHECK(solution.junctions[0].valid); - CHECK(solution.niwelety.size() == 2); - - const auto& g = solution.junctions[0]; - const auto& branch_solved = solution.niwelety[1]; - CHECK(!branch_solved.centreline.empty()); - // The branch starts exactly at the frog... - CHECK(std::hypot(branch_solved.centreline.front().x - g.fx, - branch_solved.centreline.front().y - g.fy) < 1e-6); - // ...and the appended fragment rode along: nothing sits far from the frog. - for (const auto& p : branch_solved.centreline) { - CHECK(std::hypot(p.x - g.fx, p.y - g.fy) < 300.0); - } -} - -} // namespace - -int main() { - RUN(arc_with_transitions_produces_full_chain); - RUN(compound_mode_produces_two_arcs); - RUN(degenerate_input_falls_back_to_straights); - RUN(editor_clamps_oversized_arc_to_fitting_radius); - RUN(editor_leaves_gap_unfitted_when_radius_below_floor); - RUN(editor_emits_two_rails_at_standard_gauge); - RUN(editor_exposes_transition_radius_range); - RUN(editor_leaves_parallel_offset_unfitted); - RUN(editor_honours_requested_radius_via_single_arc); - RUN(hidden_tangent_guide_fits_curve_without_rendering_track); - RUN(branch_rides_rigidly_when_pinned_to_frog); - return REPORT(); -} diff --git a/maj0sted/tests/test_fitting.cpp b/maj0sted/tests/test_fitting.cpp deleted file mode 100644 index 27b75048..00000000 --- a/maj0sted/tests/test_fitting.cpp +++ /dev/null @@ -1,346 +0,0 @@ -#include -#include -#include - -#include "check.hpp" -#include "maj0sted/maj0sted.hpp" - -using namespace maj0sted::domain; - -namespace { - -// A 90-degree left corner: entry heads east into (100,0), exit leaves north. -Niweleta make_corner() { - Niweleta n{NiweletaId{1}, "Corner"}; - n.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0}, - CartesianPosition{100.0, 0.0}}); - n.add_plan_element(Straight{StraightId{2}, CartesianPosition{100.0, 0.0}, - CartesianPosition{100.0, 100.0}}, - JointContinuity::AzimuthBreak); - return n; -} - -void fit_plain_arc() { - const Niweleta corner = make_corner(); - const FitParameters params{.entry = StraightId{1}, - .exit = StraightId{2}, - .radius = Radius::from_metres(50.0), - .transition_length = std::nullopt}; - const FitResult fit = FittingService::fit_between_straights(corner, params); - - CHECK(fit.direction == TurnDirection::Left); - - // Tangent length = R*tan(45) = 50 -> tangent points at (50,0) and (100,50). - CHECK(std::abs(fit.tangent_in.x() - 50.0) < 1e-6); - CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6); - CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6); - CHECK(std::abs(fit.tangent_out.y() - 50.0) < 1e-6); - - // The trimmed straights keep their ids and outer endpoints. - CHECK(fit.entry.id() == StraightId{1}); - CHECK(std::abs(fit.entry.end().x() - 50.0) < 1e-6); - CHECK(fit.exit.id() == StraightId{2}); - CHECK(std::abs(fit.exit.start().y() - 50.0) < 1e-6); - CHECK(std::abs(fit.exit.end().y() - 100.0) < 1e-6); - - // A single circular arc, length R*delta = 50*pi/2. - CHECK(fit.curve.size() == 1); - const auto* arc = std::get_if(&fit.curve.front()); - CHECK(arc != nullptr); - CHECK(arc->direction() == TurnDirection::Left); - CHECK(arc->length().has_value()); - CHECK(std::abs(arc->length().value().metres() - 50.0 * std::numbers::pi / 2.0) < 1e-6); -} - -void fit_with_symmetric_transitions() { - const Niweleta corner = make_corner(); - const FitParameters params{.entry = StraightId{1}, - .exit = StraightId{2}, - .radius = Radius::from_metres(50.0), - .transition_length = Length::from_metres(10.0)}; - const FitResult fit = FittingService::fit_between_straights(corner, params); - - // klotoida -> łuk -> klotoida - CHECK(fit.curve.size() == 3); - CHECK(std::holds_alternative(fit.curve[0])); - CHECK(std::holds_alternative(fit.curve[1])); - CHECK(std::holds_alternative(fit.curve[2])); - - // Entry transition runs from an infinite radius (straight) into R. - const auto& entry_kp = std::get(fit.curve[0]); - CHECK(!entry_kp.start_radius().has_value()); - CHECK(entry_kp.end_radius().has_value()); - CHECK(entry_kp.length().metres() == 10.0); - - // Arc central angle = delta - 2*tau, tau = Lk/(2R) = 0.1 -> arc len = 50*(pi/2 - 0.2). - const auto& arc = std::get(fit.curve[1]); - const double expected = 50.0 * (std::numbers::pi / 2.0 - 0.2); - CHECK(arc.length().has_value()); - CHECK(std::abs(arc.length().value().metres() - expected) < 1e-6); - - // Entry tangent point from the exact clothoid layout (not the L^2/24R - // approximation, which was ~1.8 mm off and caused a visible lateral gap). - CHECK(std::abs(fit.tangent_in.x() - 44.9185491305) < 1e-6); -} - -void invalid_inputs_are_rejected() { - const Niweleta corner = make_corner(); - const Radius r = Radius::from_metres(50.0); - - // Same straight. - CHECK_THROWS(FittingService::fit_between_straights( - corner, FitParameters{StraightId{1}, StraightId{1}, r, std::nullopt})); - // Unknown id. - CHECK_THROWS(FittingService::fit_between_straights( - corner, FitParameters{StraightId{1}, StraightId{99}, r, std::nullopt})); - - // Collinear straights -> nothing to fit. - Niweleta line{NiweletaId{2}}; - line.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0}, - CartesianPosition{50.0, 0.0}}); - line.add_plan_element(Straight{StraightId{2}, CartesianPosition{50.0, 0.0}, - CartesianPosition{100.0, 0.0}}); - CHECK_THROWS(FittingService::fit_between_straights( - line, FitParameters{StraightId{1}, StraightId{2}, r, std::nullopt})); - - // Transitions too long for this radius/deflection. - CHECK_THROWS(FittingService::fit_between_straights( - corner, FitParameters{StraightId{1}, StraightId{2}, r, - Length::from_metres(200.0)})); -} - -void fit_compound_curve_two_arcs() { - const Niweleta corner = make_corner(); // 90-degree left corner, IP at (100,0) - - // First arc delta1 = 45 deg -> length = R1 * pi/4 = 60 * pi/4; last arc computed. - const CompoundFitParameters params{ - .entry = StraightId{1}, - .exit = StraightId{2}, - .arcs = {CompoundArc{Radius::from_metres(60.0), - Length::from_metres(60.0 * std::numbers::pi / 4.0)}, - CompoundArc{Radius::from_metres(40.0), std::nullopt}}}; - const FitResult fit = FittingService::fit_compound(corner, params); - - CHECK(fit.direction == TurnDirection::Left); - CHECK(fit.curve.size() == 2); - const auto& arc1 = std::get(fit.curve[0]); - const auto& arc2 = std::get(fit.curve[1]); - CHECK(arc1.radius().metres() == 60.0); - CHECK(arc2.radius().metres() == 40.0); - - // Same hand-computed geometry as before (rigid-chain solver reproduces it). - CHECK(std::abs(arc1.length().value().metres() - 60.0 * std::numbers::pi / 4.0) < 1e-6); - CHECK(std::abs(arc2.length().value().metres() - 40.0 * std::numbers::pi / 4.0) < 1e-6); - CHECK(std::abs(fit.tangent_in.x() - 45.85786) < 1e-4); - CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-4); - CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-4); - CHECK(std::abs(fit.tangent_out.y() - 45.85786) < 1e-4); -} - -void fit_compound_curve_three_arcs() { - const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100 - - // 30 + 30 + (remaining 30) degrees, radii 80 / 50 / 30. - const CompoundFitParameters params{ - .entry = StraightId{1}, - .exit = StraightId{2}, - .arcs = {CompoundArc{Radius::from_metres(80.0), - Length::from_metres(80.0 * std::numbers::pi / 6.0)}, - CompoundArc{Radius::from_metres(50.0), - Length::from_metres(50.0 * std::numbers::pi / 6.0)}, - CompoundArc{Radius::from_metres(30.0), std::nullopt}}}; - const FitResult fit = FittingService::fit_compound(corner, params); - - CHECK(fit.curve.size() == 3); - CHECK(std::get(fit.curve[0]).radius().metres() == 80.0); - CHECK(std::get(fit.curve[1]).radius().metres() == 50.0); - CHECK(std::get(fit.curve[2]).radius().metres() == 30.0); - - // Last arc takes the remaining 30 deg -> length = 30 * pi/6. - CHECK(std::abs(std::get(fit.curve[2]).length().value().metres() - - 30.0 * std::numbers::pi / 6.0) < 1e-6); - - // The chain closes onto both fixed lines: tangent_in on y=0, tangent_out on x=100. - CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6); - CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6); - CHECK(fit.direction == TurnDirection::Left); -} - -void fit_compound_with_transitions() { - const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100 - - const CompoundFitParameters params{ - .entry = StraightId{1}, - .exit = StraightId{2}, - .entry_transition = Length::from_metres(10.0), - .exit_transition = Length::from_metres(10.0), - .arcs = {CompoundArc{Radius::from_metres(60.0), Length::from_metres(20.0), - Length::from_metres(8.0)}, // arc(60), KP 60->40 of 8 m - CompoundArc{Radius::from_metres(40.0), std::nullopt, std::nullopt}}}; - const FitResult fit = FittingService::fit_compound(corner, params); - - // Sequence: KP -> arc(60) -> KP(60->40) -> arc(40) -> KP. - CHECK(fit.curve.size() == 5); - CHECK(std::holds_alternative(fit.curve[0])); - CHECK(std::holds_alternative(fit.curve[1])); - CHECK(std::holds_alternative(fit.curve[2])); - CHECK(std::holds_alternative(fit.curve[3])); - CHECK(std::holds_alternative(fit.curve[4])); - - // Entry transition: straight (inf) -> R60. - const auto& kp_in = std::get(fit.curve[0]); - CHECK(!kp_in.start_radius().has_value()); - CHECK(kp_in.end_radius().value().metres() == 60.0); - CHECK(kp_in.length().metres() == 10.0); - - // Between transition: R60 -> R40. - const auto& kp_mid = std::get(fit.curve[2]); - CHECK(kp_mid.start_radius().value().metres() == 60.0); - CHECK(kp_mid.end_radius().value().metres() == 40.0); - CHECK(kp_mid.length().metres() == 8.0); - - // Exit transition: R40 -> straight (inf). - const auto& kp_out = std::get(fit.curve[4]); - CHECK(kp_out.start_radius().value().metres() == 40.0); - CHECK(!kp_out.end_radius().has_value()); - - // The chain still closes onto both fixed lines. - CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6); - CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6); - CHECK(fit.direction == TurnDirection::Left); -} - -void compound_invalid_inputs_are_rejected() { - const Niweleta corner = make_corner(); - const auto arc = [](double r, double len) { - return CompoundArc{Radius::from_metres(r), Length::from_metres(len)}; - }; - const auto last = [](double r) { return CompoundArc{Radius::from_metres(r)}; }; - - // Equal neighbouring radii. - CHECK_THROWS(FittingService::fit_compound( - corner, CompoundFitParameters{.entry = StraightId{1}, - .exit = StraightId{2}, - .arcs = {arc(50.0, 10.0), last(50.0)}})); - - // Split lengths exceed the total deflection. - CHECK_THROWS(FittingService::fit_compound( - corner, CompoundFitParameters{.entry = StraightId{1}, - .exit = StraightId{2}, - .arcs = {arc(60.0, 1000.0), last(40.0)}})); - - // A non-last arc without a length. - CHECK_THROWS(FittingService::fit_compound( - corner, CompoundFitParameters{.entry = StraightId{1}, - .exit = StraightId{2}, - .arcs = {last(60.0), last(40.0)}})); -} - -void fit_equal_radius_reverse_curve_between_parallel_straights() { - const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0}, - CartesianPosition{200.0, 0.0}}; - const Straight exit{StraightId{2}, CartesianPosition{0.0, 20.0}, - CartesianPosition{200.0, 20.0}}; - - const FitResult fit = - FittingService::fit_reverse(entry, exit, Radius::from_metres(50.0)); - CHECK(fit.curve.size() == 2); - const auto& first = std::get(fit.curve[0]); - const auto& second = std::get(fit.curve[1]); - CHECK(first.radius() == second.radius()); - CHECK(first.radius() == Radius::from_metres(50.0)); - CHECK(first.direction() != second.direction()); - CHECK(first.length().has_value()); - CHECK(second.length().has_value()); - CHECK(std::abs(first.length()->metres() - second.length()->metres()) < 1e-9); - CHECK(std::abs(fit.tangent_in.y()) < 1e-9); - CHECK(std::abs(fit.tangent_out.y() - 20.0) < 1e-6); - CHECK(fit.tangent_in.x() > 0.0 && fit.tangent_in.x() <= 200.0); - CHECK(fit.tangent_out.x() >= 0.0 && fit.tangent_out.x() < 200.0); -} - -void reverse_curve_rejects_collinear_and_antiparallel_straights() { - const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0}, - CartesianPosition{100.0, 0.0}}; - const Straight collinear{StraightId{2}, CartesianPosition{0.0, 0.0}, - CartesianPosition{100.0, 0.0}}; - const Straight antiparallel{StraightId{3}, CartesianPosition{100.0, 20.0}, - CartesianPosition{0.0, 20.0}}; - const Radius radius = Radius::from_metres(30.0); - - CHECK_THROWS(FittingService::fit_reverse(entry, collinear, radius)); - CHECK_THROWS(FittingService::fit_reverse(entry, antiparallel, radius)); -} - -void fit_reverse_curve_between_nonparallel_straights() { - const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0}, - CartesianPosition{100.0, 0.0}}; - // This exit line is constructed around the end pose of a R=30 m chain: - // left 60 degrees followed by right 30 degrees. - const double heading = std::numbers::pi / 6.0; - const double tangent_x = 86.9615242271; - const double tangent_y = 25.9807621135; - const double dx = std::cos(heading); - const double dy = std::sin(heading); - const Straight exit{ - StraightId{2}, - CartesianPosition{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy}, - CartesianPosition{tangent_x + 70.0 * dx, tangent_y + 70.0 * dy}}; - - const FitResult fit = - FittingService::fit_reverse(entry, exit, Radius::from_metres(30.0)); - CHECK(fit.curve.size() == 2); - const auto& first = std::get(fit.curve[0]); - const auto& second = std::get(fit.curve[1]); - CHECK(first.direction() != second.direction()); - CHECK(first.radius() == second.radius()); - CHECK(std::abs(fit.tangent_in.y()) < 1e-6); - const double exit_cross = - (fit.tangent_out.x() - exit.start().x()) * dy - - (fit.tangent_out.y() - exit.start().y()) * dx; - CHECK(std::abs(exit_cross) < 1e-5); -} - -void apply_fit_replaces_straights_in_plan() { - Niweleta corner = make_corner(); // straight(1) --break--> straight(2) - const FitParameters params{.entry = StraightId{1}, - .exit = StraightId{2}, - .radius = Radius::from_metres(50.0), - .transition_length = std::nullopt}; - const FitResult fit = FittingService::fit_between_straights(corner, params); - - corner.apply_fit(fit); - - // Plan is now: straight(1) -> arc -> straight(2), tangent joints, no break. - CHECK(corner.plan().size() == 3); - CHECK(std::holds_alternative(corner.plan().elements()[0])); - CHECK(std::holds_alternative(corner.plan().elements()[1])); - CHECK(std::holds_alternative(corner.plan().elements()[2])); - CHECK(corner.plan().joint(0) == JointContinuity::Tangent); - CHECK(corner.plan().joint(1) == JointContinuity::Tangent); - - // Straights keep their ids and are trimmed to the tangent points. - const auto& first = std::get(corner.plan().elements()[0]); - const auto& last = std::get(corner.plan().elements()[2]); - CHECK(first.id() == StraightId{1}); - CHECK(last.id() == StraightId{2}); - CHECK(std::abs(first.end().x() - 50.0) < 1e-6); - CHECK(std::abs(last.start().y() - 50.0) < 1e-6); -} - -} // namespace - -int main() { - RUN(fit_plain_arc); - RUN(fit_with_symmetric_transitions); - RUN(invalid_inputs_are_rejected); - RUN(fit_compound_curve_two_arcs); - RUN(fit_compound_curve_three_arcs); - RUN(fit_compound_with_transitions); - RUN(compound_invalid_inputs_are_rejected); - RUN(fit_equal_radius_reverse_curve_between_parallel_straights); - RUN(reverse_curve_rejects_collinear_and_antiparallel_straights); - RUN(fit_reverse_curve_between_nonparallel_straights); - RUN(apply_fit_replaces_straights_in_plan); - return REPORT(); -} diff --git a/maj0sted/tests/test_format.cpp b/maj0sted/tests/test_format.cpp new file mode 100644 index 00000000..6696fed7 --- /dev/null +++ b/maj0sted/tests/test_format.cpp @@ -0,0 +1,279 @@ +// The .m0s format, version 5: everything by id, an explicit version, and a +// round trip that is byte-identical. Older files still open: a version-3 one has +// its arcs and blade fields converted to the piece list on the way in, and a +// version-4 one simply had no nose on its blade yet. + +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/io/document_io.hpp" + +using namespace maj0sted::editor; + +namespace { + +Document sample() { + Document document; + + TurnoutType type; + type.name = "Rz 1:9 R190 z iglicą nagiętą"; // spaces and non-ASCII on purpose + type.crossing_n = 9.0; + type.length = 27.1; + type.pieces = { + TurnoutPieceSpec{0, 2.42, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 8.3, 190.0, 190.0, 0.0058}, + TurnoutPieceSpec{2, 12.75, 190.0, 250.0, 0.0}, + TurnoutPieceSpec{3, 4.125, 0.0, 0.0, 0.0}, + }; + type.blade.tip_thickness = 0.005; + type.blade.nose = 0.125; + type.blade.railtop_width = 0.07; + document.turnout_types.push_back(type); + + Track through; + through.id = mint_track(document); + through.name = "tor zasadniczy 1"; + through.anchor = AtPose{100.5, -2.25, 0.7853981633974483}; + through.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 412.5, Free{}}, + Element{mint_element(document), Kind::Clothoid, 600.0, -1, 40.0, Free{}}, + Element{mint_element(document), Kind::Arc, 600.0, -1, 235.125, Free{}}, + Element{mint_element(document), Kind::Line, 0.0, 0, 180.0, Free{}}, + }; + document.tracks.push_back(through); + + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = type.name; + placement.on = through.id; + placement.station = 312.75; + placement.hand = -1; + placement.facing = false; + placement.bend_from_track = false; + placement.bend = -1.0 / 380.292; // wygięty dwustronnie, na własny łuk + document.turnouts.push_back(placement); + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.anchor = AtPort{placement.id, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Arc, 190.0, -1, 45.5, Free{}}, + Element{mint_element(document), Kind::Line, 0.0, 0, 123.5, Free{}}, + }; + // Held parallel to the through track's last straight, at międzytorze. + branch.elements[1].hold = Parallel{through.elements[3].id, 4.75}; + document.tracks.push_back(branch); + + document.view_x = 500.0; + document.view_y = 250.0; + document.view_extent = 2000.0; + document.origin_set = true; + document.georeferenced = true; + document.origin_x = 6540000.25; + document.origin_y = 5760000.5; + return document; +} + +bool same(const Document& a, const Document& b) { + if (a.tracks.size() != b.tracks.size() || + a.turnouts.size() != b.turnouts.size() || + a.turnout_types.size() != b.turnout_types.size() || a.next_id != b.next_id) { + return false; + } + for (std::size_t i = 0; i < a.tracks.size(); ++i) { + if (a.tracks[i].id != b.tracks[i].id || a.tracks[i].name != b.tracks[i].name || + a.tracks[i].elements.size() != b.tracks[i].elements.size()) { + return false; + } + if (a.tracks[i].anchor.index() != b.tracks[i].anchor.index()) { + return false; + } + for (std::size_t k = 0; k < a.tracks[i].elements.size(); ++k) { + const Element& x = a.tracks[i].elements[k]; + const Element& y = b.tracks[i].elements[k]; + if (x.id != y.id || x.kind != y.kind || x.radius != y.radius || + x.hand != y.hand || x.length != y.length || + x.hold.index() != y.hold.index()) { + return false; + } + } + } + return true; +} + +void round_trip_is_byte_identical() { + const Document document = sample(); + const std::string text = maj0sted::io::serialize(document); + + const auto reloaded = maj0sted::io::deserialize(text); + CHECK(reloaded.has_value()); + if (!reloaded) { + return; + } + CHECK(same(document, *reloaded)); + CHECK(maj0sted::io::serialize(*reloaded) == text); +} + +void every_field_survives() { + const Document document = sample(); + const auto reloaded = maj0sted::io::deserialize(maj0sted::io::serialize(document)); + CHECK(reloaded.has_value()); + if (!reloaded) { + return; + } + + // A name with spaces and non-ASCII. + CHECK(reloaded->turnout_types[0].name == "Rz 1:9 R190 z iglicą nagiętą"); + // The geometry list, every piece of it, and the blade's own dimensions. + CHECK(reloaded->turnout_types[0].pieces.size() == 4); + CHECK(reloaded->turnout_types[0].pieces[0].part == 0); + CHECK(reloaded->turnout_types[0].pieces[0].length == 2.42); + CHECK(reloaded->turnout_types[0].pieces[1].part == 1); + CHECK(reloaded->turnout_types[0].pieces[1].radius_start == 190.0); + CHECK(reloaded->turnout_types[0].pieces[1].turn_in == 0.0058); + CHECK(reloaded->turnout_types[0].pieces[2].radius_end == 250.0); + CHECK(reloaded->turnout_types[0].pieces[3].part == 3); + CHECK(reloaded->turnout_types[0].blade.tip_thickness == 0.005); + CHECK(reloaded->turnout_types[0].blade.nose == 0.125); + CHECK(reloaded->turnout_types[0].blade.railtop_width == 0.07); + // Placement, including a backward-facing turnout. + CHECK(reloaded->turnouts[0].station == 312.75); + CHECK(reloaded->turnouts[0].hand == -1); + CHECK(reloaded->turnouts[0].facing == false); + CHECK(reloaded->turnouts[0].bend_from_track == false); + CHECK(std::abs(reloaded->turnouts[0].bend + 1.0 / 380.292) < 1e-12); + CHECK(reloaded->turnouts[0].type == "Rz 1:9 R190 z iglicą nagiętą"); + // Every element says its own shape and its own length. + CHECK(reloaded->tracks[0].elements[0].kind == Kind::Line); + CHECK(reloaded->tracks[0].elements[0].length == 412.5); + CHECK(reloaded->tracks[0].elements[1].kind == Kind::Clothoid); + CHECK(reloaded->tracks[0].elements[1].radius == 600.0); + CHECK(reloaded->tracks[0].elements[2].length == 235.125); + CHECK(reloaded->tracks[0].elements[2].hand == -1); + // A hold, and the one element that carries it. + CHECK(std::holds_alternative(reloaded->tracks[1].elements[0].hold)); + CHECK(std::holds_alternative(reloaded->tracks[1].elements[1].hold)); + CHECK(std::get(reloaded->tracks[1].elements[1].hold).offset == 4.75); + CHECK(std::get(reloaded->tracks[1].elements[1].hold).ref == + reloaded->tracks[0].elements[3].id); + // Anchors. + CHECK(std::holds_alternative(reloaded->tracks[0].anchor)); + CHECK(std::holds_alternative(reloaded->tracks[1].anchor)); + CHECK(std::get(reloaded->tracks[1].anchor).port == Port::Frog); + // Georeference and view. + CHECK(reloaded->origin_x == 6540000.25); + CHECK(reloaded->view_extent == 2000.0); + CHECK(reloaded->georeferenced); + // Ids keep going where they left off, so nothing minted later collides. + CHECK(reloaded->next_id == document.next_id); +} + +void an_older_file_is_refused() { + // Version 1 and version 2 described different models; both are refused + // outright rather than half-understood. Version 3 is not refused — it is + // converted, which an_old_file_upgrades_to_the_piece_list checks. + const std::string version_1 = + "maj0sted 1\ncrs 2180\nniwelety 0\neditor 1\neniw 2 1\nename niweleta 1\n"; + CHECK(!maj0sted::io::deserialize(version_1).has_value()); + const std::string version_2 = + "m0s 2\nnext 9\nview 0 0 0\norigin 0 0 0 0\nlines 0\ntypes 0\n" + "tracks 0\n"; + CHECK(!maj0sted::io::deserialize(version_2).has_value()); + CHECK(!maj0sted::io::deserialize("").has_value()); + CHECK(!maj0sted::io::deserialize("m0s 7\n").has_value()); +} + +void a_file_from_before_lukowanie_still_opens() { + // Version 5 placements had nothing to say about a bend, because a turnout stood + // on the track as it found it. That is what taking the bend from the track means, + // so they open as exactly that and nothing about them moves. + const std::string version_5 = + "m0s 5\nnext 5\nview 0 0 0\norigin 0 0 0 0\ntypes 0\n" + "turnouts 1\nturnout 2 1 100 1 1\ntutype Rz 1:9 R190\ntracks 0\n"; + const auto document = maj0sted::io::deserialize(version_5); + CHECK(document.has_value()); + if (!document) { + return; + } + CHECK(document->turnouts.size() == 1); + CHECK(document->turnouts[0].station == 100.0); + CHECK(document->turnouts[0].bend_from_track); + CHECK(document->turnouts[0].bend == 0.0); + CHECK(maj0sted::io::serialize(*document).find("m0s 6") != std::string::npos); +} + +void a_file_from_before_the_nose_still_opens() { + // Version 4 had the piece list but no nose on the blade: the type line went + // straight from the tip thickness to the railhead width. It opens with the nose + // at nothing, which is what the type then reports against the curve it lays. + const std::string version_4 = + "m0s 4\nnext 1\nview 0 0 0\norigin 0 0 0 0\n" + "types 1\ntype 9 27.138 0.0049 0.07 1\ntseg 2 21.02 190 190 0\n" + "tname Rz 1:9 R190\nturnouts 0\ntracks 0\n"; + const auto document = maj0sted::io::deserialize(version_4); + CHECK(document.has_value()); + if (!document) { + return; + } + CHECK(document->turnout_types.size() == 1); + CHECK(document->turnout_types[0].blade.tip_thickness == 0.0049); + CHECK(document->turnout_types[0].blade.nose == 0.0); + CHECK(document->turnout_types[0].blade.railtop_width == 0.07); + CHECK(document->turnout_types[0].pieces.size() == 1); + + // and it is written back in the new form, with a place for the nose + CHECK(maj0sted::io::serialize(*document).find("m0s 6") != std::string::npos); +} + +void an_old_file_upgrades_to_the_piece_list() { + // A version-3 catalogue: one arc, no blade, and a closing arc that was worked + // out on the fly. It has to come back as an explicit list that lays the very + // same shape — on the crossing angle and on the catalogue length. + const std::string version_3 = + "m0s 3\nnext 1\nview 0 0 0\norigin 0 0 0 0\n" + "types 1\ntype 9 0 0 27.138 0 0 0 1\ntarc 190 0 0\ntname Rz 1:9 R190\n" + "turnouts 0\ntracks 0\n"; + const auto document = maj0sted::io::deserialize(version_3); + CHECK(document.has_value()); + if (!document) { + return; + } + CHECK(document->turnout_types.size() == 1); + const TurnoutType& type = document->turnout_types[0]; + CHECK(type.name == "Rz 1:9 R190"); + CHECK(type.pieces.size() == 2); // the closing arc and the frog rail + CHECK(type.pieces[0].part == 2); + CHECK(std::abs(type.pieces[0].length - 190.0 * std::atan(1.0 / 9.0)) < 1e-9); + CHECK(type.pieces[1].part == 3); + + const auto laid = maj0sted::domain::lay_turnout( + maj0sted::domain::geometry::Pose{0.0, 0.0, 1.0, 0.0}, to_domain(type, 1)); + CHECK(laid.valid); + CHECK(std::abs(laid.angle_residual) < 1e-9); + CHECK(std::abs(laid.length_residual) < 1e-9); + + // and it is written back in the new form + CHECK(maj0sted::io::serialize(*document).find("tseg ") != std::string::npos); +} + +void a_truncated_file_is_refused() { + const std::string text = maj0sted::io::serialize(sample()); + // Cut off half way: the counts no longer match what follows. + CHECK(!maj0sted::io::deserialize(text.substr(0, text.size() / 2)).has_value()); +} + +} // namespace + +int main() { + RUN(round_trip_is_byte_identical); + RUN(every_field_survives); + RUN(an_older_file_is_refused); + RUN(an_old_file_upgrades_to_the_piece_list); + RUN(a_file_from_before_the_nose_still_opens); + RUN(a_file_from_before_lukowanie_still_opens); + RUN(a_truncated_file_is_refused); + return REPORT(); +} diff --git a/maj0sted/tests/test_graph.cpp b/maj0sted/tests/test_graph.cpp new file mode 100644 index 00000000..06791d81 --- /dev/null +++ b/maj0sted/tests/test_graph.cpp @@ -0,0 +1,225 @@ +// What refers to what decides the order things are laid in. A turnout on the +// branch of a turnout is not a special case, and a reference that runs in a +// circle is reported rather than relaxed at for a fixed number of passes. + +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" + +using namespace maj0sted::editor; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +bool has(const Solution& solution, Code code) { + for (const auto& diagnostic : solution.diagnostics) { + if (diagnostic.code == code) { + return true; + } + } + return false; +} + +/// A plain Rz 1:9 R190, the everyday Polish turnout. +void add_catalogue(Document& document) { + TurnoutType type; + type.name = "Rz 1:9 R190"; + type.crossing_n = 9.0; + type.length = 27.138; // the catalogue's a + b + type.blade = BladeSpec{0.005, 0.070}; + type.pieces = { + // przediglicowy, then blade and curve as one arc of the crossing angle, then + // the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232 + TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0}, + }; + document.turnout_types.push_back(type); +} + +TurnoutId add_turnout(Document& document, TrackId on, double station, int hand) { + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = "Rz 1:9 R190"; + placement.on = on; + placement.station = station; + placement.hand = hand; + document.turnouts.push_back(placement); + return placement.id; +} + +TrackId add_straight_track(Document& document, const char* name, double x, double y, + double azimuth, double length) { + Track track; + track.id = mint_track(document); + track.name = name; + track.anchor = AtPose{x, y, azimuth}; + track.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, length, Free{}}}; + document.tracks.push_back(track); + return track.id; +} + +// ------------------------------------------------------------------------- + +/// A branch anchored to a frog is laid after the turnout, which is laid after +/// its through track — whatever order they appear in the document. +void a_branch_follows_its_frog() { + Document document; + add_catalogue(document); + + // The branch is declared BEFORE the turnout and the through track it needs. + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 100.0, Free{}}}; + document.tracks.push_back(branch); + + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0); + const TurnoutId turnout = add_turnout(document, through, 300.0, 1); + document.tracks[0].anchor = AtPort{turnout, Port::Frog}; + + const Solution solution = solve(document); + + CHECK(solution.diagnostics.empty()); + const SolvedTurnout* solved_turnout = find_turnout(solution, turnout); + CHECK(solved_turnout != nullptr && solved_turnout->valid); + + const SolvedTrack* solved_branch = find_track(solution, branch.id); + CHECK(solved_branch != nullptr); + CHECK(solved_branch->elements.size() == 1); + // The branch starts exactly at the frog, on the frog's heading. It was never + // anywhere else, so there is nothing to pin back. + CHECK(near(solved_branch->start.x, solved_turnout->frog.x)); + CHECK(near(solved_branch->start.y, solved_turnout->frog.y)); + CHECK(near(solved_branch->start.hx, solved_turnout->frog.hx)); + CHECK(near(solved_branch->start.hy, solved_turnout->frog.hy)); + // It diverges: the frog is off the through track's straight line. + CHECK(std::abs(solved_turnout->frog.x) > 0.5); +} + +/// A turnout on the branch of a turnout. The old solver ran a fixed two passes, +/// so this depth had no way to settle. +void a_turnout_on_a_branch_of_a_turnout() { + Document document; + add_catalogue(document); + + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 2000.0); + const TurnoutId first = add_turnout(document, through, 300.0, 1); + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga 1"; + branch.anchor = AtPort{first, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 400.0, Free{}}}; + document.tracks.push_back(branch); + + const TurnoutId second = add_turnout(document, branch.id, 150.0, -1); + + Track branch2; + branch2.id = mint_track(document); + branch2.name = "odnoga 2"; + branch2.anchor = AtPort{second, Port::Frog}; + branch2.elements = { + Element{mint_element(document), Kind::Arc, 300.0, -1, 120.0, Free{}}}; + document.tracks.push_back(branch2); + + const Solution solution = solve(document); + + CHECK(solution.diagnostics.empty()); + CHECK(find_turnout(solution, second) != nullptr); + CHECK(find_turnout(solution, second)->valid); + + const SolvedTrack* deep = find_track(solution, branch2.id); + CHECK(deep != nullptr); + CHECK(deep->elements.size() == 1); + CHECK(near(deep->start.x, find_turnout(solution, second)->frog.x)); + CHECK(near(deep->start.y, find_turnout(solution, second)->frog.y)); + // Three levels deep, well away from the through track. + CHECK(std::abs(deep->start.x) > 1.0); +} + +/// References that run in a circle are named, not relaxed at. +void a_cycle_is_reported() { + // Two tracks, each held parallel to the other's element. + Document document; + const TrackId first = add_straight_track(document, "tor 1", 0.0, 0.0, 0.0, 500.0); + const TrackId second = add_straight_track(document, "tor 2", 20.0, 0.0, 0.0, 500.0); + document.tracks[0].elements[0].hold = + Parallel{document.tracks[1].elements[0].id, 4.75}; + document.tracks[1].elements[0].hold = + Parallel{document.tracks[0].elements[0].id, 4.75}; + (void)first; + (void)second; + + const Solution solution = solve(document); + CHECK(has(solution, Code::CyclicDependency)); +} + +/// A turnout whose catalogue type is missing says so, and the branch that hangs +/// off it says why it could not be laid — neither is silently dropped. +void a_missing_type_is_reported() { + Document document; + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 500.0); + const TurnoutId turnout = add_turnout(document, through, 100.0, 1); // no catalogue + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.anchor = AtPort{turnout, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 50.0, Free{}}}; + document.tracks.push_back(branch); + + const Solution solution = solve(document); + CHECK(has(solution, Code::UnknownReference)); + CHECK(solution.diagnostics.size() >= 2); + // Every track still has an entry, so a host can index by id without holes. + CHECK(find_track(solution, branch.id) != nullptr); + CHECK(find_track(solution, through) != nullptr); +} + +/// Solving twice gives the same answer, and solving does not touch the document. +void solving_leaves_the_document_alone() { + Document document; + add_catalogue(document); + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0); + const TurnoutId turnout = add_turnout(document, through, 300.0, 1); + Track branch; + branch.id = mint_track(document); + branch.anchor = AtPort{turnout, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Arc, 300.0, 1, 200.0, Free{}}}; + document.tracks.push_back(branch); + + const Document before = document; + const Solution first = solve(document); + const Solution second = solve(document); + + CHECK(document.tracks.size() == before.tracks.size()); + CHECK(document.next_id == before.next_id); + CHECK(first.tracks.size() == second.tracks.size()); + for (std::size_t i = 0; i < first.tracks.size(); ++i) { + CHECK(near(first.tracks[i].length, second.tracks[i].length)); + CHECK(near(first.tracks[i].end.x, second.tracks[i].end.x)); + CHECK(near(first.tracks[i].end.y, second.tracks[i].end.y)); + } +} + +} // namespace + +int main() { + RUN(a_branch_follows_its_frog); + RUN(a_turnout_on_a_branch_of_a_turnout); + RUN(a_cycle_is_reported); + RUN(a_missing_type_is_reported); + RUN(solving_leaves_the_document_alone); + return REPORT(); +} diff --git a/maj0sted/tests/test_hold.cpp b/maj0sted/tests/test_hold.cpp new file mode 100644 index 00000000..67875a35 --- /dev/null +++ b/maj0sted/tests/test_hold.cpp @@ -0,0 +1,310 @@ +// What one track keeps against another, and what clicking along the ground +// works out at the click. Both are the same idea from two ends: geometry is +// decided once, in numbers, and nothing is left pointing at a drawing. + +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/sketch.hpp" + +using namespace maj0sted::editor; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} + +/// A straight due north from the origin, 1000 m of it. +TrackId add_main(Document& document, ElementId& out_element) { + Track track; + track.id = mint_track(document); + track.name = "tor 1"; + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 1000.0)}; + out_element = track.elements.front().id; + document.tracks.push_back(track); + return track.id; +} + +bool has(const Solution& solution, Code code) { + for (const auto& diagnostic : solution.diagnostics) { + if (diagnostic.code == code) { + return true; + } + } + return false; +} + +// ------------------------------------------------------------------------- + +/// A corner rounded at the click: the tangent comes off both straights, and the +/// arc turns exactly as far as the corner bends. +void a_corner_is_rounded_with_a_tangent_arc() { + // Due north, then due east: a 90° corner to the right. + const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0); + CHECK(corner.ok); + CHECK(corner.hand == -1); + CHECK(near(corner.deflection, -std::numbers::pi / 2.0)); + CHECK(near(corner.tangent, 300.0)); // R·tan(45°) + CHECK(near(corner.arc_length, 300.0 * std::numbers::pi / 2.0)); + + // Straight on is not a corner, and neither is doubling back. + CHECK(!fit_corner(0.0, 1.0, 0.0, 1.0, 300.0).ok); + CHECK(!fit_corner(0.0, 1.0, 0.0, -1.0, 300.0).ok); + CHECK(!fit_corner(0.0, 1.0, 1.0, 0.0, 0.0).ok); +} + +/// The corner really does come out tangent: laying the three elements it +/// produces lands the track on the second leg, heading along it. +void a_rounded_corner_lands_on_the_next_leg() { + Document document; + const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0); + + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 1000.0 - corner.tangent), + arc(document, 300.0, corner.hand, corner.arc_length), + line(document, 800.0 - corner.tangent)}; + document.tracks.push_back(track); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + const SolvedTrack* laid = find_track(solution, track.id); + CHECK(laid != nullptr); + // The corner was at (0, 1000); the second leg runs east from there to (800, 1000). + CHECK(near(laid->end.x, 800.0, 1e-6)); + CHECK(near(laid->end.y, 1000.0, 1e-6)); + CHECK(near(laid->end.hx, 1.0, 1e-9)); + CHECK(near(laid->end.hy, 0.0, 1e-9)); +} + +/// Międzytorze, said once: the held track is moved bodily onto the offset, and +/// stays where it was put along it. +void a_track_held_parallel_is_moved_onto_the_offset() { + Document document; + ElementId reference{ElementId::none}; + add_main(document, reference); + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + // Put it well off the offset, and part way up the main track. + siding.anchor = AtPose{40.0, 300.0, 0.0}; + siding.elements = {line(document, 500.0)}; + siding.elements.front().hold = Parallel{reference, 4.75}; + document.tracks.push_back(siding); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + + const SolvedTrack* laid = find_track(solution, siding.id); + CHECK(laid != nullptr && laid->elements.size() == 1); + // The main track heads north, so left of its travel is -x. + CHECK(near(laid->elements[0].start.x, -4.75, 1e-9)); + // Nothing slid it along: it kept the y it was anchored at. + CHECK(near(laid->elements[0].start.y, 300.0, 1e-9)); + CHECK(near(laid->elements[0].start.hy, 1.0, 1e-9)); + CHECK(near(laid->length, 500.0)); + + // The offset is signed: the other side is the other side. + document.tracks.back().elements.front().hold = Parallel{reference, -4.75}; + const Solution mirrored = solve(document); + const SolvedTrack* other = find_track(mirrored, document.tracks.back().id); + CHECK(other != nullptr && near(other->elements[0].start.x, 4.75, 1e-9)); +} + +/// Parallel to an arc is concentric with it: same centre, R∓d. +void a_track_held_parallel_to_an_arc_is_concentric() { + Document document; + Track main; + main.id = mint_track(document); + main.name = "tor 1"; + main.anchor = AtPose{0.0, 0.0, 0.0}; + main.elements = {arc(document, 500.0, 1, 400.0)}; + const ElementId reference = main.elements.front().id; + document.tracks.push_back(main); + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{-60.0, 10.0, 0.0}; + // The radius and hand asked for here are beside the point: the reference + // fixes the shape. + siding.elements = {arc(document, 999.0, -1, 200.0)}; + siding.elements.front().hold = Parallel{reference, 50.0}; + document.tracks.push_back(siding); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + + const SolvedTrack* laid = find_track(solution, siding.id); + CHECK(laid != nullptr && laid->elements.size() == 1); + // Left of a left-hand curve is the inside: R - d. + CHECK(near(laid->elements[0].k0, 1.0 / 450.0, 1e-12)); + // The main track's centre of curvature is 500 m to the left of its start. + CHECK(near(std::hypot(laid->elements[0].start.x + 500.0, laid->elements[0].start.y), + 450.0, 1e-6)); + CHECK(near(laid->length, 200.0)); +} + +/// A hold is a reference like any other: it orders the solve, and a circle of +/// them is reported rather than relaxed at. +void holds_are_ordered_and_cycles_are_caught() { + Document document; + ElementId reference{ElementId::none}; + add_main(document, reference); + + // Held to a track that is itself held: laid third, and still exact. + Track second; + second.id = mint_track(document); + second.name = "tor 2"; + second.anchor = AtPose{40.0, 0.0, 0.0}; + second.elements = {line(document, 400.0)}; + second.elements.front().hold = Parallel{reference, 4.75}; + const ElementId middle = second.elements.front().id; + document.tracks.push_back(second); + + Track third; + third.id = mint_track(document); + third.name = "tor 3"; + third.anchor = AtPose{80.0, 0.0, 0.0}; + third.elements = {line(document, 400.0)}; + third.elements.front().hold = Parallel{middle, 4.75}; + document.tracks.push_back(third); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + const SolvedTrack* laid = find_track(solution, third.id); + CHECK(laid != nullptr && near(laid->elements[0].start.x, -9.5, 1e-9)); + + // Now point the first one back at the last: a circle. + document.tracks[0].elements[0].hold = + Parallel{document.tracks[2].elements[0].id, 4.75}; + CHECK(has(solve(document), Code::CyclicDependency)); +} + +/// Two elements of one movable track cannot both decide where it goes. +void two_holds_on_one_movable_track_conflict() { + Document document; + ElementId reference{ElementId::none}; + add_main(document, reference); + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{40.0, 0.0, 0.0}; + siding.elements = {line(document, 200.0), line(document, 200.0)}; + siding.elements[0].hold = Parallel{reference, 4.75}; + siding.elements[1].hold = Parallel{reference, 9.50}; + document.tracks.push_back(siding); + + CHECK(has(solve(document), Code::HoldConflict)); +} + +/// A branch pinned to a turnout's frog cannot be moved onto the offset, so the +/// corner between the frog and the held straight takes up the difference — the +/// same fit as pulling a straight about by hand, and no full turn anywhere. +void a_pinned_branch_fits_its_way_onto_the_offset() { + Document document; + + TurnoutType type; + type.name = "Rz 1:9 R190"; + type.crossing_n = 9.0; + type.length = 27.138; // the catalogue's a + b + type.blade = BladeSpec{0.005, 0.070}; + type.pieces = { + // przediglicowy, then blade and curve as one arc of the crossing angle, then + // the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232 + TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0}, + }; + document.turnout_types.push_back(type); + + ElementId reference{ElementId::none}; + const TrackId main = add_main(document, reference); + + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = type.name; + placement.on = main; + placement.station = 300.0; + placement.hand = -1; // diverging to the right of a northbound track + document.turnouts.push_back(placement); + + // Off the frog: a straight, a corner, and the straight that is to run beside + // the main track. Nothing about it is right to begin with. + Track branch; + branch.id = mint_track(document); + branch.name = "bocznica"; + branch.anchor = AtPort{placement.id, Port::Frog}; + branch.elements = {line(document, 40.0), arc(document, 300.0, 1, 30.0), + line(document, 400.0)}; + branch.elements[2].hold = Parallel{reference, -4.75}; + document.tracks.push_back(branch); + + const Solution solution = solve(document); + for (const auto& diagnostic : solution.diagnostics) { + std::printf(" diag: %s\n", diagnostic.text.c_str()); + } + CHECK(!has(solution, Code::OffsetNotHeld)); + + const SolvedTrack* laid = find_track(solution, branch.id); + CHECK(laid != nullptr && laid->elements.size() == 3); + const SolvedTurnout* turnout = find_turnout(solution, placement.id); + CHECK(turnout != nullptr && turnout->valid); + + // It still starts exactly on the frog: the fit moved lengths, not the branch. + CHECK(near(laid->start.x, turnout->frog.x, 1e-6)); + CHECK(near(laid->start.y, turnout->frog.y, 1e-6)); + // And the held straight really lies 4.75 m to the right of the main track, + // pointing the same way. + CHECK(near(laid->elements[2].start.x, 4.75, 1e-6)); + CHECK(near(laid->elements[2].start.hy, 1.0, 1e-9)); + // The corner took up the difference without looping the long way round. + CHECK(laid->elements[1].length < 300.0 * std::numbers::pi); +} + +/// A reference that is not there is said so, and the track is still laid. +void a_missing_reference_is_reported() { + Document document; + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{0.0, 0.0, 0.0}; + siding.elements = {line(document, 100.0)}; + siding.elements.front().hold = Parallel{static_cast(9999), 4.75}; + document.tracks.push_back(siding); + + const Solution solution = solve(document); + CHECK(has(solution, Code::UnknownReference)); + const SolvedTrack* laid = find_track(solution, siding.id); + CHECK(laid != nullptr && laid->elements.size() == 1); +} + +} // namespace + +int main() { + RUN(a_corner_is_rounded_with_a_tangent_arc); + RUN(a_rounded_corner_lands_on_the_next_leg); + RUN(a_track_held_parallel_is_moved_onto_the_offset); + RUN(a_track_held_parallel_to_an_arc_is_concentric); + RUN(holds_are_ordered_and_cycles_are_caught); + RUN(two_holds_on_one_movable_track_conflict); + RUN(a_pinned_branch_fits_its_way_onto_the_offset); + RUN(a_missing_reference_is_reported); + return REPORT(); +} diff --git a/maj0sted/tests/test_lines.cpp b/maj0sted/tests/test_lines.cpp new file mode 100644 index 00000000..27ca9066 --- /dev/null +++ b/maj0sted/tests/test_lines.cpp @@ -0,0 +1,549 @@ +// Pulling a straight about by one of its ends. The arcs beside it re-fit, every +// other straight keeps its bearing, and an arc that turns past a half circle +// stays as far round as it was drawn — that last one is the whole reason the +// construction goes through the arc's centre and not through where the tangents +// would have crossed. + +#include +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/sketch.hpp" + +using namespace maj0sted::editor; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} + +double azimuth(const SolvedElement& element) { + return std::atan2(element.start.hx, element.start.hy); +} + +/// The shape most of this is about: straight, corner, straight, corner, straight. +TrackId add_track(Document& document, std::vector elements) { + Track track; + track.id = mint_track(document); + track.name = "tor 1"; + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = std::move(elements); + document.tracks.push_back(track); + return track.id; +} + +// ------------------------------------------------------------------------- + +/// Taking a track apart into lines and putting it back together, without moving +/// anything, gives back exactly the track that went in. +void a_track_survives_being_taken_apart_and_put_back() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + CHECK(skeleton.lines.size() == 3); + + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) { + CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-9)); + CHECK(rebuilt.elements[i].radius == document.tracks[0].elements[i].radius); + CHECK(rebuilt.elements[i].hand == document.tracks[0].elements[i].hand); + } +} + +/// An arc of 240°, which has no tangent crossing to be found: the two straights +/// it joins meet at 120° the other way round. Nothing may quietly turn it into +/// the 120° arc that would fit between the same two lines. +void an_arc_past_a_half_circle_stays_where_it_was_drawn() { + Document document; + const double turn = 240.0 * std::numbers::pi / 180.0; + const TrackId id = add_track(document, {line(document, 200.0), + arc(document, 200.0, 1, 200.0 * turn), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + CHECK(near(rebuilt.elements[1].length, 200.0 * turn, 1e-9)); + CHECK(near(rebuilt.elements[0].length, 200.0, 1e-9)); + CHECK(near(rebuilt.elements[2].length, 200.0, 1e-9)); +} + +/// Exactly a half circle: the two straights are antiparallel and never cross at +/// all. Tangency fixes their spacing and nothing else, so the arc stays put. +void a_half_circle_is_laid_and_not_refused() { + Document document; + const double turn = std::numbers::pi; + const TrackId id = add_track(document, {line(document, 150.0), + arc(document, 250.0, -1, 250.0 * turn), + line(document, 400.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + Track rebuilt = document.tracks[0]; + const bool ok = lay_along_skeleton(rebuilt, skeleton, why); + CHECK(ok); + if (!ok) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(near(rebuilt.elements[1].length, 250.0 * turn, 1e-6)); + CHECK(near(rebuilt.elements[0].length, 150.0, 1e-6)); + CHECK(near(rebuilt.elements[2].length, 400.0, 1e-6)); +} + +/// Swinging the middle straight: the arcs beside it re-fit, keeping their radii, +/// and the straights that do not touch it keep their bearing to the last bit. +void swinging_one_straight_leaves_the_others_pointing_where_they_did() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr); + const double first_azimuth = azimuth(laid->elements[0]); + const double last_azimuth = azimuth(laid->elements[4]); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + // Swing the middle line about the point the straight starts on, by 4°. + const double angle = 4.0 * std::numbers::pi / 180.0; + Support& middle = skeleton.lines[1]; + middle.px = laid->elements[2].start.x; + middle.py = laid->elements[2].start.y; + const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle); + const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle); + middle.dx = dx; + middle.dy = dy; + + Track moved = document.tracks[0]; + CHECK(lay_along_skeleton(moved, skeleton, why)); + document.tracks[0] = moved; + + const Solution after = solve(document); + const SolvedTrack* again = find_track(after, id); + CHECK(again != nullptr && after.diagnostics.empty()); + + // The straights at either end never moved: same bearing, to the micro-radian. + CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12)); + CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12)); + // The arcs kept their radii and changed only how far they run. + CHECK(near(again->elements[1].k0, 1.0 / 400.0, 1e-12)); + CHECK(near(again->elements[3].k0, -1.0 / 300.0, 1e-12)); + CHECK(!near(again->elements[1].length, 210.0, 1e-6)); + // And the free ends are still exactly where they were. + CHECK(near(again->start.x, laid->start.x, 1e-9)); + CHECK(near(again->end.y, laid->end.y, 1e-9)); +} + +/// What cannot be laid says why, and leaves the caller's track alone. +void a_move_that_cannot_be_laid_is_refused() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + { // Swing the middle straight far enough that the two arcs overlap on it. + Skeleton bent = skeleton; + const double angle = 60.0 * std::numbers::pi / 180.0; + Support& middle = bent.lines[1]; + const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle); + const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle); + middle.dx = dx; + middle.dy = dy; + Track candidate = document.tracks[0]; + CHECK(!lay_along_skeleton(candidate, bent, why)); + CHECK(!why.empty()); + // Nothing was written: the caller's copy still reads as it did. + CHECK(candidate.elements[0].length == document.tracks[0].elements[0].length); + } + { // A transition that does not run out into the straight after it is a kink. + Document other; + Track track; + track.id = mint_track(other); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0), + Element{mint_element(other), Kind::Clothoid, 400.0, 1, 40.0, Free{}}, + line(other, 100.0)}; + other.tracks.push_back(track); + const Solution solution = solve(other); + Skeleton none; + CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why)); + } + { // Two arcs turning opposite ways inside one corner is an S, not a basket. + Document other; + Track track; + track.id = mint_track(other); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0), + arc(other, 400.0, -1, 100.0), line(other, 100.0)}; + other.tracks.push_back(track); + const Solution solution = solve(other); + Skeleton none; + CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why)); + } +} + +/// The everyday main-line corner: straight, transition, arc, transition, straight. +/// The transitions are authored, so they keep their lengths; the arc takes what +/// is left of the turn. +void a_corner_with_transitions_re_fits() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = { + line(document, 400.0), + Element{mint_element(document), Kind::Clothoid, 600.0, 1, 80.0, Free{}}, + arc(document, 600.0, 1, 300.0), + Element{mint_element(document), Kind::Clothoid, 0.0, 1, 80.0, Free{}}, + line(document, 400.0)}; + document.tracks.push_back(track); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, track.id); + CHECK(laid != nullptr && before.diagnostics.empty()); + const double first_azimuth = azimuth(laid->elements[0]); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.lines.size() == 2); + + // Put it back untouched: everything comes out as it went in. + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) { + CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-6)); + } + + // Now swing the second straight by 3°: the transitions keep their lengths, + // the arc takes up the difference, and the first straight does not budge. + const double angle = 3.0 * std::numbers::pi / 180.0; + Support& second = skeleton.lines[1]; + const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle); + const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle); + second.dx = dx; + second.dy = dy; + + Track moved = document.tracks[0]; + CHECK(lay_along_skeleton(moved, skeleton, why)); + CHECK(near(moved.elements[1].length, 80.0, 1e-12)); + CHECK(near(moved.elements[3].length, 80.0, 1e-12)); + CHECK(!near(moved.elements[2].length, 300.0, 1e-6)); + document.tracks[0] = moved; + + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + const SolvedTrack* again = find_track(after, track.id); + CHECK(again != nullptr); + CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12)); + // The transition still hands the arc exactly the curvature it eased to. + CHECK(near(again->elements[1].k1, 1.0 / 600.0, 1e-12)); + CHECK(near(again->elements[2].k0, 1.0 / 600.0, 1e-12)); +} + +/// A basket curve — two arcs of different radii turning the same way. Re-fitting +/// shares the corner's turn out between them in the proportions they were drawn +/// with, so it stays the same basket, tighter or wider. +void a_basket_curve_keeps_its_proportions() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 400.0), arc(document, 800.0, 1, 160.0), + arc(document, 400.0, 1, 120.0), line(document, 400.0)}; + document.tracks.push_back(track); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, track.id); + CHECK(laid != nullptr && before.diagnostics.empty()); + const double wide = 160.0 / 800.0; // what each arc turns, as drawn + const double tight = 120.0 / 400.0; + const double first_azimuth = azimuth(laid->elements[0]); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + CHECK(near(rebuilt.elements[1].length, 160.0, 1e-9)); + CHECK(near(rebuilt.elements[2].length, 120.0, 1e-9)); + + // Swing the far straight: both arcs give, in proportion, and neither radius + // changes. + const double angle = 5.0 * std::numbers::pi / 180.0; + Support& second = skeleton.lines[1]; + const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle); + const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle); + second.dx = dx; + second.dy = dy; + + Track moved = document.tracks[0]; + CHECK(lay_along_skeleton(moved, skeleton, why)); + CHECK(moved.elements[1].radius == 800.0); + CHECK(moved.elements[2].radius == 400.0); + const double now_wide = moved.elements[1].length / 800.0; + const double now_tight = moved.elements[2].length / 400.0; + CHECK(!near(now_wide, wide, 1e-9)); + CHECK(near(now_wide / now_tight, wide / tight, 1e-9)); + + document.tracks[0] = moved; + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + CHECK(near(azimuth(find_track(after, track.id)->elements[0]), first_azimuth, 1e-12)); +} + +/// Changing a radius is the corner's business and nobody else's: the straights +/// on either side give, and nothing past them moves a millimetre. +void a_new_radius_is_absorbed_by_its_own_corner() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr); + const double last_azimuth = azimuth(laid->elements[4]); + const double end_x = laid->end.x; + const double end_y = laid->end.y; + const double second_arc_length = document.tracks[0].elements[3].length; + + // The lines are taken off the track as it stands, then the radius changes. + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + document.tracks[0].elements[1].radius = 250.0; + + CHECK(lay_along_skeleton(document.tracks[0], skeleton, why)); + + const Solution after = solve(document); + const SolvedTrack* again = find_track(after, id); + CHECK(again != nullptr && after.diagnostics.empty()); + + // A tighter arc turns the same corner, so it is shorter and the straights on + // either side of it grow. The corner beyond is untouched, and so is the end. + CHECK(near(again->elements[1].k0, 1.0 / 250.0, 1e-12)); + CHECK(near(document.tracks[0].elements[3].length, second_arc_length, 1e-9)); + CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12)); + CHECK(near(again->end.x, end_x, 1e-9)); + CHECK(near(again->end.y, end_y, 1e-9)); + CHECK(near(again->start.x, laid->start.x, 1e-9)); +} + +/// A track that finishes mid-curve — laid by clicking and then given an arc, so +/// there is no straight after it. The corner at the end has nothing on its far +/// side to be fitted against, so it keeps what it was given and the track simply +/// ends where it takes it. Turning it the other way is an ordinary edit, not a +/// reason to refuse the whole chain. +void a_chain_ending_on_a_curve_still_re_fits() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + const double first_azimuth = azimuth(laid->elements[0]); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.lines.size() == 2); + + // Turn the trailing arc the other way. Everything before it stays put; the + // arc keeps the length it was given and takes the track off elsewhere. + document.tracks[0].elements[3].hand = 1; + CHECK(lay_along_skeleton(document.tracks[0], skeleton, why)); + CHECK(near(document.tracks[0].elements[3].length, 180.0, 1e-12)); + + const Solution after = solve(document); + const SolvedTrack* again = find_track(after, id); + CHECK(again != nullptr && after.diagnostics.empty()); + CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12)); + CHECK(near(again->elements[2].length, laid->elements[2].length, 1e-9)); + CHECK(near(again->elements[3].start.x, laid->elements[3].start.x, 1e-9)); + CHECK(near(again->elements[3].k0, 1.0 / 300.0, 1e-12)); +} + +/// And one that starts mid-curve: a branch leaving a frog on a curve. The corner +/// at the front rides along, and the anchor is worked back through it. +void a_chain_starting_on_a_curve_carries_its_head_along() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{10.0, 20.0, 0.3}; + track.elements = {arc(document, 250.0, -1, 90.0), line(document, 300.0), + arc(document, 400.0, 1, 210.0), line(document, 250.0)}; + document.tracks.push_back(track); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, track.id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.lines.size() == 2); + + // Untouched, it comes back exactly as it was — anchor included. + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + CHECK(near(rebuilt.elements[0].length, 90.0, 1e-12)); + CHECK(near(std::get(rebuilt.anchor).x, 10.0, 1e-9)); + CHECK(near(std::get(rebuilt.anchor).y, 20.0, 1e-9)); + CHECK(near(std::get(rebuilt.anchor).az, 0.3, 1e-9)); + + // Tighten the interior corner: the head keeps its length, and the straights + // beside the corner give. + document.tracks[0].elements[2].radius = 250.0; + CHECK(lay_along_skeleton(document.tracks[0], skeleton, why)); + CHECK(near(document.tracks[0].elements[0].length, 90.0, 1e-12)); + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + const SolvedTrack* again = find_track(after, track.id); + CHECK(near(again->elements[1].start.x, laid->elements[1].start.x, 1e-9)); + CHECK(near(again->elements[2].k0, 1.0 / 250.0, 1e-12)); +} + +/// A transition curve's radius is the one it ends on, so it belongs to what +/// comes after it. The transition running out of an arc into a straight ends +/// flat, and no amount of editing the arc may put the arc's radius on it — that +/// would be a transition easing from R to R, which eases nothing. +void a_joint_only_ever_looks_forward() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = { + line(document, 400.0), + Element{mint_element(document), Kind::Clothoid, 600.0, 1, 80.0, Free{}}, + arc(document, 600.0, 1, 300.0), + Element{mint_element(document), Kind::Clothoid, 0.0, 1, 80.0, Free{}}, + line(document, 400.0)}; + document.tracks.push_back(track); + + // Editing the arc carries its radius back onto the transition that eases into + // it, and leaves the one running out of it flat. + document.tracks[0].elements[2].radius = 450.0; + sync_joint(document.tracks[0], 2); + CHECK(document.tracks[0].elements[1].radius == 450.0); + CHECK(document.tracks[0].elements[3].radius == 0.0); + + // Turning it the other way carries the hand back the same way. + document.tracks[0].elements[2].hand = -1; + sync_joint(document.tracks[0], 2); + CHECK(document.tracks[0].elements[1].hand == -1); + + // And the chain still lays: nothing anywhere eases from a radius to itself. + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + + // Editing the entry transition carries forward onto its arc. + document.tracks[0].elements[1].radius = 700.0; + sync_joint(document.tracks[0], 1); + CHECK(document.tracks[0].elements[2].radius == 700.0); + + // A transition that ends flat has no joint to carry anywhere. + document.tracks[0].elements[3].hand = 1; + sync_joint(document.tracks[0], 3); + CHECK(document.tracks[0].elements[4].kind == Kind::Line); + CHECK(solve(document).diagnostics.empty()); +} + +/// Two straights end to end are one straight, and the solver says so. +void touching_straights_are_reported() { + Document document; + add_track(document, {line(document, 100.0), line(document, 150.0)}); + const Solution solution = solve(document); + bool found = false; + for (const auto& diagnostic : solution.diagnostics) { + found = found || diagnostic.code == Code::TouchingStraights; + } + CHECK(found); + // It is a rule about writing track down, not about the geometry: it still lays. + CHECK(find_track(solution, document.tracks[0].id)->elements.size() == 2); +} + +} // namespace + +int main() { + RUN(a_track_survives_being_taken_apart_and_put_back); + RUN(an_arc_past_a_half_circle_stays_where_it_was_drawn); + RUN(a_half_circle_is_laid_and_not_refused); + RUN(swinging_one_straight_leaves_the_others_pointing_where_they_did); + RUN(a_move_that_cannot_be_laid_is_refused); + RUN(a_corner_with_transitions_re_fits); + RUN(a_basket_curve_keeps_its_proportions); + RUN(a_new_radius_is_absorbed_by_its_own_corner); + RUN(a_chain_ending_on_a_curve_still_re_fits); + RUN(a_chain_starting_on_a_curve_carries_its_head_along); + RUN(a_joint_only_ever_looks_forward); + RUN(touching_straights_are_reported); + return REPORT(); +} diff --git a/maj0sted/tests/test_map_project.cpp b/maj0sted/tests/test_map_project.cpp deleted file mode 100644 index 5cd09d1b..00000000 --- a/maj0sted/tests/test_map_project.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "check.hpp" -#include "maj0sted/maj0sted.hpp" - -using namespace maj0sted::domain; - -int main() { - // A map project defaults to the Cartesian CRS EPSG:2180. - const MapProject project; - CHECK(project.crs() == Crs::default_crs()); - CHECK(project.crs().epsg() == 2180); - - // An explicit CRS is honoured. - const MapProject wgs84{Crs{4326}}; - CHECK(wgs84.crs().epsg() == 4326); - - // Cartesian position value object. - const CartesianPosition p{100.5, 200.25}; - CHECK(p.x() == 100.5); - CHECK(p.y() == 200.25); - CHECK(CartesianPosition{1.0, 2.0} == CartesianPosition{1.0, 2.0}); - CHECK(CartesianPosition{1.0, 2.0} != CartesianPosition{2.0, 1.0}); - CHECK(CartesianPosition{}.x() == 0.0); - - return REPORT(); -} diff --git a/maj0sted/tests/test_niweleta.cpp b/maj0sted/tests/test_niweleta.cpp deleted file mode 100644 index 7c99a978..00000000 --- a/maj0sted/tests/test_niweleta.cpp +++ /dev/null @@ -1,110 +0,0 @@ -#include - -#include "check.hpp" -#include "maj0sted/maj0sted.hpp" - -using namespace maj0sted::domain; - -namespace { - -void plan_and_profile_element_lengths() { - const PlanElement straight = - Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}; - const PlanElement arc = CircularArc{Radius::from_metres(300.0), - TurnDirection::Left, - Length::from_metres(50.0)}; - const PlanElement transition = TransitionCurve{ - Length::from_metres(30.0), std::nullopt, Radius::from_metres(300.0), - TurnDirection::Left}; - CHECK(length_of(straight).value().metres() == 100.0); - CHECK(length_of(arc).value().metres() == 50.0); - CHECK(length_of(transition).value().metres() == 30.0); - - const ProfileElement grade = Grade{Length::from_metres(120.0), 5.0}; - const ProfileElement vcurve = VerticalCurve{ - Length::from_metres(60.0), Radius::from_metres(4000.0), - VerticalCurvature::Crest}; - CHECK(length_of(grade).metres() == 120.0); - CHECK(length_of(vcurve).metres() == 60.0); -} - -void value_objects_guard_invariants() { - CHECK_THROWS(Length::from_metres(-1.0)); - CHECK_THROWS(Radius::from_metres(0.0)); - CHECK_THROWS(Radius::from_metres(-5.0)); -} - -void niweleta_aggregates_plan_and_profile() { - Niweleta n{NiweletaId{1}, "Odcinek 1"}; - CHECK(n.name() == "Odcinek 1"); - CHECK(n.plan().empty()); - CHECK(n.profile().empty()); - - // Plan: prosta -> klotoida -> łuk -> klotoida -> prosta (razem 180 m) - n.add_plan_element( - Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - n.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt, - Radius::from_metres(300.0), - TurnDirection::Right}); - n.add_plan_element(CircularArc{Radius::from_metres(300.0), - TurnDirection::Right, - Length::from_metres(20.0)}); - n.add_plan_element(TransitionCurve{Length::from_metres(30.0), - Radius::from_metres(300.0), std::nullopt, - TurnDirection::Right}); - n.add_plan_element( - Straight{CartesianPosition{100.0, 0.0}, CartesianPosition{150.0, 0.0}}); - - // Profil: pochylenie -> łuk pionowy -> pochylenie (razem 180 m) - n.add_profile_element(Grade{Length::from_metres(80.0), 3.0}); - n.add_profile_element(VerticalCurve{Length::from_metres(40.0), - Radius::from_metres(5000.0), - VerticalCurvature::Sag}); - n.add_profile_element(Grade{Length::from_metres(60.0), -2.0}); - - CHECK(n.plan().size() == 5); - CHECK(n.profile().size() == 3); - CHECK(n.plan_length().metres() == 180.0); - CHECK(n.profile_length().metres() == 180.0); - CHECK(n.lengths_consistent()); -} - -void inconsistent_lengths_are_detected() { - Niweleta n{NiweletaId{2}}; - n.add_plan_element( - Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}); - n.add_profile_element(Grade{Length::from_metres(90.0), 0.0}); - CHECK(!n.lengths_consistent()); - CHECK(n.plan_length().metres() == 100.0); - CHECK(n.profile_length().metres() == 90.0); -} - -void niweleta_assigns_straight_ids() { - Niweleta n{NiweletaId{3}}; - // add_straight mints and returns fresh ids. - const StraightId a = - n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}); - const StraightId b = - n.add_straight(CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}); - CHECK(!a.is_null()); - CHECK(!b.is_null()); - CHECK(a != b); - - // An anonymous straight added via add_plan_element is identified too. - Niweleta m{NiweletaId{4}}; - m.add_plan_element( - Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - const auto& element = m.plan().elements().front(); - CHECK(!std::get(element).id().is_null()); -} - -} // namespace - -int main() { - RUN(plan_and_profile_element_lengths); - RUN(value_objects_guard_invariants); - RUN(niweleta_aggregates_plan_and_profile); - RUN(inconsistent_lengths_are_detected); - RUN(niweleta_assigns_straight_ids); - return REPORT(); -} diff --git a/maj0sted/tests/test_parallelism.cpp b/maj0sted/tests/test_parallelism.cpp deleted file mode 100644 index a603f88c..00000000 --- a/maj0sted/tests/test_parallelism.cpp +++ /dev/null @@ -1,85 +0,0 @@ -#include - -#include "check.hpp" -#include "maj0sted/maj0sted.hpp" - -using namespace maj0sted::domain; - -namespace { - -void mark_parallel_references_two_straights() { - // Two identified straights (typically from different niwelety). - const Straight origin{StraightId{10}, CartesianPosition{0.0, 0.0}, - CartesianPosition{100.0, 0.0}}; - const Straight parallel{StraightId{20}, CartesianPosition{0.0, 5.0}, - CartesianPosition{100.0, 5.0}}; - - const TrackOffset offset{Length::from_metres(5.0), Side::Left}; - const auto relation = ParallelismService::mark_parallel(origin, parallel, offset); - - CHECK(relation.origin() == StraightId{10}); - CHECK(relation.parallel() == StraightId{20}); - CHECK(relation.offset().distance().metres() == 5.0); - CHECK(relation.offset().side() == Side::Left); -} - -void marking_requires_identified_distinct_straights() { - const Straight anonymous{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}; - const Straight a{StraightId{1}, CartesianPosition{0.0, 0.0}, - CartesianPosition{100.0, 0.0}}; - const Straight b{StraightId{2}, CartesianPosition{0.0, 5.0}, - CartesianPosition{100.0, 5.0}}; - const TrackOffset offset{Length::from_metres(5.0), Side::Left}; - - // A straight without an id cannot be referenced. - CHECK_THROWS(ParallelismService::mark_parallel(anonymous, b, offset)); - CHECK_THROWS(ParallelismService::mark_parallel(a, anonymous, offset)); - - // A straight cannot be parallel to itself (same id). - const Straight a_again{StraightId{1}, CartesianPosition{0.0, 10.0}, - CartesianPosition{100.0, 10.0}}; - CHECK_THROWS(ParallelismService::mark_parallel(a, a_again, offset)); -} - -void offset_must_be_positive() { - CHECK_THROWS(TrackOffset(Length::from_metres(0.0), Side::Left)); -} - -void parallelism_geometry() { - const Straight origin{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}; - const Straight same_direction{CartesianPosition{0.0, 5.0}, - CartesianPosition{100.0, 5.0}}; - const Straight opposite{CartesianPosition{100.0, 5.0}, CartesianPosition{0.0, 5.0}}; - const Straight perpendicular{CartesianPosition{0.0, 0.0}, - CartesianPosition{0.0, 50.0}}; - - CHECK(ParallelismService::are_parallel(origin, same_direction)); - CHECK(ParallelismService::are_parallel(origin, opposite)); // anti-parallel lines - CHECK(!ParallelismService::are_parallel(origin, perpendicular)); - - // Derive a line 5 m to the LEFT of an east-going origin -> to the north (y = +5). - const Straight left = ParallelismService::derive_parallel_line( - origin, TrackOffset{Length::from_metres(5.0), Side::Left}); - CHECK(std::abs(left.start().x() - 0.0) < 1e-9); - CHECK(std::abs(left.start().y() - 5.0) < 1e-9); - CHECK(std::abs(left.end().y() - 5.0) < 1e-9); - CHECK(ParallelismService::are_parallel(origin, left)); - - // To the RIGHT -> to the south (y = -5). - const Straight right = ParallelismService::derive_parallel_line( - origin, TrackOffset{Length::from_metres(5.0), Side::Right}); - CHECK(std::abs(right.start().y() + 5.0) < 1e-9); - - CHECK(std::abs(ParallelismService::perpendicular_distance(origin, same_direction) - - 5.0) < 1e-9); -} - -} // namespace - -int main() { - RUN(mark_parallel_references_two_straights); - RUN(marking_requires_identified_distinct_straights); - RUN(offset_must_be_positive); - RUN(parallelism_geometry); - return REPORT(); -} diff --git a/maj0sted/tests/test_plan_alignment.cpp b/maj0sted/tests/test_plan_alignment.cpp deleted file mode 100644 index 353a6bd0..00000000 --- a/maj0sted/tests/test_plan_alignment.cpp +++ /dev/null @@ -1,132 +0,0 @@ -#include - -#include "check.hpp" -#include "maj0sted/maj0sted.hpp" - -using namespace maj0sted::domain; - -namespace { - -void straight_computes_azimuth_and_length() { - // Going east (+x): azimuth = 90 deg (clockwise from north). - const Straight east{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}}; - CHECK(std::abs(east.length().metres() - 100.0) < 1e-9); - CHECK(std::abs(east.azimuth().degrees() - 90.0) < 1e-9); - - // Going north (+y): azimuth = 0 deg. - const Straight north{CartesianPosition{0.0, 0.0}, CartesianPosition{0.0, 50.0}}; - CHECK(std::abs(north.azimuth().degrees() - 0.0) < 1e-9); - CHECK(std::abs(north.length().metres() - 50.0) < 1e-9); - - // A zero-length straight has no azimuth and is rejected. - CHECK_THROWS(Straight(CartesianPosition{1.0, 1.0}, CartesianPosition{1.0, 1.0})); -} - -void arc_carries_radius_direction_and_optional_length() { - const CircularArc without_length{Radius::from_metres(300.0), TurnDirection::Left}; - CHECK(!without_length.length().has_value()); - - const CircularArc with_length{Radius::from_metres(300.0), TurnDirection::Left, - Length::from_metres(45.0)}; - CHECK(with_length.length().has_value()); - CHECK(with_length.length().value().metres() == 45.0); -} - -void adjacent_same_arcs_are_rejected() { - HorizontalAlignment same; - same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}); - // same radius + same direction -> forbidden - CHECK_THROWS( - same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left})); - - HorizontalAlignment opposite; - opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}); - // same radius, different direction -> allowed (S-curve) - opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Right}); - CHECK(opposite.size() == 2); - - HorizontalAlignment compound; - compound.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}); - // same direction, different radius -> allowed (compound curve) - compound.append(CircularArc{Radius::from_metres(500.0), TurnDirection::Left}); - CHECK(compound.size() == 2); -} - -void straight_to_straight_tangent_requires_equal_azimuth() { - // Collinear straights, same azimuth, touching -> tangent OK. - HorizontalAlignment collinear; - collinear.append( - Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - collinear.append( - Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}}); - CHECK(collinear.size() == 2); - CHECK(collinear.joint(0) == JointContinuity::Tangent); - - // Touching but different azimuth with a Tangent joint -> must be a break. - HorizontalAlignment unmarked; - unmarked.append( - Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - CHECK_THROWS(unmarked.append( - Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}})); - - // Non-touching tangent straights -> invalid. - HorizontalAlignment gap; - gap.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - CHECK_THROWS(gap.append( - Straight{CartesianPosition{60.0, 0.0}, CartesianPosition{100.0, 0.0}})); -} - -void azimuth_break_between_straights_must_be_marked() { - // Corner marked as AzimuthBreak -> OK. - HorizontalAlignment kink; - kink.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - kink.append(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}}, - JointContinuity::AzimuthBreak); - CHECK(kink.size() == 2); - CHECK(kink.joint(0) == JointContinuity::AzimuthBreak); - - // AzimuthBreak with equal azimuth is pointless -> invalid. - HorizontalAlignment straight_break; - straight_break.append( - Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - CHECK_THROWS(straight_break.append( - Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}}, - JointContinuity::AzimuthBreak)); - - // AzimuthBreak is only allowed between two straights. - HorizontalAlignment arc_break; - arc_break.append( - Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - CHECK_THROWS(arc_break.append( - CircularArc{Radius::from_metres(300.0), TurnDirection::Left}, - JointContinuity::AzimuthBreak)); -} - -void straight_curve_joints_are_accepted_pending_fit() { - // prosta -> klotoida -> łuk -> klotoida -> prosta - HorizontalAlignment plan; - plan.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - plan.append(TransitionCurve{Length::from_metres(30.0), std::nullopt, - Radius::from_metres(300.0), TurnDirection::Left}); - plan.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left, - Length::from_metres(40.0)}); - plan.append(TransitionCurve{Length::from_metres(30.0), - Radius::from_metres(300.0), std::nullopt, - TurnDirection::Left}); - plan.append(Straight{CartesianPosition{200.0, 0.0}, CartesianPosition{260.0, 0.0}}); - CHECK(plan.size() == 5); - // Known lengths: 50 + 30 + 40 + 30 + 60. - CHECK(plan.total_length().metres() == 210.0); -} - -} // namespace - -int main() { - RUN(straight_computes_azimuth_and_length); - RUN(arc_carries_radius_direction_and_optional_length); - RUN(adjacent_same_arcs_are_rejected); - RUN(straight_to_straight_tangent_requires_equal_azimuth); - RUN(azimuth_break_between_straights_must_be_marked); - RUN(straight_curve_joints_are_accepted_pending_fit); - return REPORT(); -} diff --git a/maj0sted/tests/test_render.cpp b/maj0sted/tests/test_render.cpp deleted file mode 100644 index 58261a2a..00000000 --- a/maj0sted/tests/test_render.cpp +++ /dev/null @@ -1,78 +0,0 @@ -#include - -#include "check.hpp" -#include "maj0sted/io/svg_writer.hpp" -#include "maj0sted/maj0sted.hpp" -#include "maj0sted/render/plan_sampler.hpp" - -using namespace maj0sted::domain; - -namespace { - -void samples_one_polyline_per_element() { - Niweleta n{NiweletaId{1}}; - const StraightId a = - n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}); - const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0}, - CartesianPosition{100.0, 100.0}, - JointContinuity::AzimuthBreak); - const FitResult fit = FittingService::fit_between_straights( - n, FitParameters{.entry = a, - .exit = b, - .radius = Radius::from_metres(40.0), - .transition_length = Length::from_metres(10.0)}); - n.apply_fit(fit); // straight -> KP -> arc -> KP -> straight (5 elements) - - const auto scene = maj0sted::render::sample(n.plan()); - CHECK(scene.polylines.size() == n.plan().size()); - CHECK(scene.polylines.size() == 5); - CHECK(!scene.empty); - - // First polyline is the (straight) entry, drawn from its anchored endpoints. - const auto& first = scene.polylines.front(); - CHECK(first.kind == maj0sted::render::ElementKind::Straight); - CHECK(first.points.size() == 2); - CHECK(std::abs(first.points.front().x - 0.0) < 1e-9); - - // Curves are sampled into many points. - CHECK(scene.polylines[2].kind == maj0sted::render::ElementKind::Arc); - CHECK(scene.polylines[2].points.size() > 2); -} - -void curve_geometry_is_continuous() { - // The sampled points should join up (each element starts where the previous - // ended) for a properly fitted, tangent-continuous plan. - Niweleta n{NiweletaId{2}}; - const StraightId a = - n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}); - const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0}, - CartesianPosition{100.0, 100.0}, - JointContinuity::AzimuthBreak); - n.apply_fit(FittingService::fit_between_straights( - n, FitParameters{.entry = a, .exit = b, .radius = Radius::from_metres(40.0)})); - - const auto scene = maj0sted::render::sample(n.plan()); - for (std::size_t i = 1; i < scene.polylines.size(); ++i) { - const auto& prev_end = scene.polylines[i - 1].points.back(); - const auto& next_start = scene.polylines[i].points.front(); - CHECK(std::abs(prev_end.x - next_start.x) < 1e-6); - CHECK(std::abs(prev_end.y - next_start.y) < 1e-6); - } -} - -void svg_is_produced() { - Niweleta n{NiweletaId{3}}; - n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}); - const auto svg = maj0sted::io::to_svg(maj0sted::render::sample(n.plan())); - CHECK(svg.rfind(" - -#include "check.hpp" -#include "maj0sted/maj0sted.hpp" - -using namespace maj0sted::domain; - -namespace { - -MapProject make_project() { - MapProject project{Crs{2180}}; - - Niweleta a{NiweletaId{5}, "Odcinek A"}; - const StraightId s1 = - a.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}); - (void)s1; - a.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt, - Radius::from_metres(300.0), TurnDirection::Right}); - a.add_plan_element(CircularArc{Radius::from_metres(300.0), TurnDirection::Right, - Length::from_metres(40.0)}); - a.add_profile_element(Grade{Length::from_metres(80.0), 3.0}); - a.add_profile_element(VerticalCurve{Length::from_metres(40.0), - Radius::from_metres(5000.0), - VerticalCurvature::Sag}); - project.add_niweleta(std::move(a)); - - // A second niweleta with an azimuth break, to exercise joints. - Niweleta b{NiweletaId{9}, "Odcinek B z załamaniem"}; - b.add_plan_element( - Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}}); - b.add_plan_element(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}}, - JointContinuity::AzimuthBreak); - project.add_niweleta(std::move(b)); - - return project; -} - -void round_trip_is_stable() { - const MapProject project = make_project(); - const std::string text = maj0sted::io::serialize(project); - const MapProject loaded = maj0sted::io::deserialize(text); - - // Re-serializing the loaded project yields byte-identical text. - CHECK(maj0sted::io::serialize(loaded) == text); - - CHECK(loaded.crs().epsg() == 2180); - CHECK(loaded.niwelety().size() == 2); -} - -void niweleta_and_geometry_survive() { - const MapProject loaded = maj0sted::io::deserialize(maj0sted::io::serialize(make_project())); - - const Niweleta& a = loaded.niwelety()[0]; - CHECK(a.id() == NiweletaId{5}); - CHECK(a.name() == "Odcinek A"); - CHECK(a.plan().size() == 3); - CHECK(a.profile().size() == 2); - - // Straight kept its id and geometry. - const auto& straight = std::get(a.plan().elements()[0]); - CHECK(straight.id() == StraightId{1}); - CHECK(straight.length().metres() == 100.0); - - // Arc kept radius, direction and length. - const auto& arc = std::get(a.plan().elements()[2]); - CHECK(arc.radius().metres() == 300.0); - CHECK(arc.direction() == TurnDirection::Right); - CHECK(arc.length().value().metres() == 40.0); - - // Profile survived. - const auto& grade = std::get(a.profile().elements()[0]); - CHECK(grade.slope_permille() == 3.0); - - // Name with spaces and the azimuth-break joint survived on the 2nd niweleta. - const Niweleta& b = loaded.niwelety()[1]; - CHECK(b.name() == "Odcinek B z załamaniem"); - CHECK(b.plan().joint(0) == JointContinuity::AzimuthBreak); -} - -void malformed_input_throws() { - CHECK_THROWS(maj0sted::io::deserialize("not a maj0sted file")); - CHECK_THROWS(maj0sted::io::deserialize("maj0sted 1\ncrs 2180\nniwelety 3\n")); -} - -} // namespace - -int main() { - RUN(round_trip_is_stable); - RUN(niweleta_and_geometry_survive); - RUN(malformed_input_throws); - return REPORT(); -} diff --git a/maj0sted/tests/test_turnout.cpp b/maj0sted/tests/test_turnout.cpp index d1ae8ae0..0c0b49d3 100644 --- a/maj0sted/tests/test_turnout.cpp +++ b/maj0sted/tests/test_turnout.cpp @@ -1,3 +1,11 @@ +// A turnout is laid from the list its catalogue entry gives, piece by piece, and +// nothing in it is fitted: whether the list keeps the promises of the crossing +// mark and the catalogue length is reported, not corrected. The blade is +// dimensioned on top of that, the way Koc's rys. 5.9 dimensions an iglica styczna +// do opornicy o ściętym ostrzu: the curve is theoretically tangent to the stock +// rail at PR, but the blade only starts at a, and its planed nose runs on to A +// where the rail is u thick. + #include #include @@ -9,12 +17,54 @@ using maj0sted::domain::geometry::Pose; namespace { +constexpr double kAlfa19 = 0.11065722117389563; // atan(1/9) +constexpr double kStart[]{0.0, 0.0, 1.0, 0.0}; + +// Rz 49E1-190-1:9 as the drawings have it: u and d from Koc's tablica 5.1, the +// rear tangent from rys. 5.13 (27138 = 10523 + 16615, krzyżownica prosta). The +// blade ends where the gap to the stock rail takes the przekładka in its heel. +constexpr double kRadius = 190.0; +constexpr double kTipThickness = 0.0049; // u +constexpr double kNose = 0.1250; // d +constexpr double kRailtop = 0.070; // w +constexpr double kHeelOffset = 0.136; +constexpr double kBackTangent = 16.615; // b + +// a: the odcinek przediglicowy, derived from u and d rather than copied — the gap +// opens on the blade's own curve, and the station it opens at is on the axis +const double kLead = blade_run_to_offset(kRadius, 0.0, kTipThickness) - kNose; +const double kBlade = blade_run_to_offset(kRadius, 0.0, kHeelOffset) - kLead; + +// The gap @p pose has opened between the blade and the stock rail: the axis offset +// plus what half a gauge of rail swings out of the way as the path turns off. +double gap_at(const Pose& start, const Pose& pose) { + const double offset = (pose.x - start.x) * -start.hy + (pose.y - start.y) * start.hx; + const double heading = std::atan2(pose.hy * start.hx - pose.hx * start.hy, + pose.hx * start.hx + pose.hy * start.hy); + return offset + kHalfGauge * (1.0 - std::cos(heading)); +} +const double kFrontTangent = kRadius * std::tan(0.5 * kAlfa19); +const double kFrogRail = kBackTangent - kFrontTangent; + Turnout rz_1_9_r190() { - Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, DivergingCurve{}, 27.138}; - turnout.curve.arcs.push_back(TurnoutArc{190.0, 0.0, 0.0}); + Turnout turnout{CrossingMark{9.0}, + DivergeSide::Left, + {}, + kFrontTangent + kBackTangent, + Blade{kTipThickness, kNose, kRailtop}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, kLead, kRadius, kRadius, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, kBlade, kRadius, kRadius, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, + kRadius * kAlfa19 - kLead - kBlade, kRadius, + kRadius, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::FrogRail, kFrogRail, 0.0, 0.0, 0.0}); return turnout; } +TurnoutGeometry laid(const Turnout& turnout) { + return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout); +} + // Walks the laid path and returns the pose at the end of segment @p index. Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) { Pose pose{0.0, 0.0, 1.0, 0.0}; @@ -32,99 +82,365 @@ Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) { return pose; } -void pre_blade_runs_along_the_through_track() { - auto turnout = rz_1_9_r190(); - turnout.pre_blade = 3.5; - turnout.blade_angle = 0.02; - turnout.blade_length = 6.0; - - const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); +void a_tangential_blade_never_breaks_away() { + const auto geometry = laid(rz_1_9_r190()); CHECK(geometry.valid); - - // the lead is straight, on the through track, and the blade tip sits at its end - const Pose tip = pose_after(geometry, 0); - CHECK(std::abs(tip.x - 3.5) < 1e-9); - CHECK(std::abs(tip.y) < 1e-12); - CHECK(std::abs(tip.hy) < 1e-12); - - // the blade breaks away by beta exactly, and stays straight over its length - const Pose heel = pose_after(geometry, 1); - CHECK(std::abs(std::atan2(heel.hy, heel.hx) - 0.02) < 1e-9); - CHECK(std::abs(std::hypot(heel.x - tip.x, heel.y - tip.y) - 6.0) < 1e-9); + for (const auto& segment : geometry.path) { + CHECK(segment.turn_in == 0.0); + } + // the curve is there from PR: the odcinek przediglicowy is already on it, the + // vehicle is simply still on the through track + CHECK(std::abs(geometry.path[0].k0 - 1.0 / kRadius) < 1e-12); + CHECK(std::abs(geometry.path[1].k0 - 1.0 / kRadius) < 1e-12); + // the blade begins where its piece does, a from PR + CHECK(geometry.has_blade); + CHECK(std::abs(geometry.blade_station - kLead) < 1e-12); + CHECK(std::abs(geometry.blade_tip.x - kRadius * std::sin(kLead / kRadius)) < 1e-9); + CHECK(std::abs(geometry.blade_tip.y - + kRadius * (1.0 - std::cos(kLead / kRadius))) < 1e-9); } -void the_frog_still_lands_on_the_crossing_angle() { - const double alfa = std::atan(1.0 / 9.0); +void a_planed_nose_reaches_its_thickness_where_the_table_says() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); - auto tangential = rz_1_9_r190(); - auto bladed = rz_1_9_r190(); - bladed.pre_blade = 3.5; - bladed.blade_angle = 0.02; - bladed.blade_length = 6.0; + const Pose start{kStart[0], kStart[1], kStart[2], kStart[3]}; + // A: where the blade stands u away from the stock rail, and where the tangent + // there is the kąt przylegania — a couple of dozen minutes + CHECK(std::abs(gap_at(start, geometry.blade_nose_end) - kTipThickness) < 1e-9); + CHECK(std::abs(geometry.blade_angle - geometry.nose_end / kRadius) < 1e-9); + CHECK(geometry.blade_angle * 180.0 / std::numbers::pi > 0.40); + CHECK(geometry.blade_angle * 180.0 / std::numbers::pi < 0.42); + // a was cut so that A lands on a + d, so a type carrying all three agrees with + // the curve it is laid on and the residual is nothing + CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-12); + CHECK(std::abs(geometry.blade_residual) < 1e-12); - for (const auto& turnout : {tangential, bladed}) { - const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); - CHECK(geometry.valid); - // the curve spends only alfa - beta, so the frog heading is alfa either way - CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - alfa) < 1e-9); - // and the catalogue length is still measured along the through track - CHECK(std::abs(geometry.frog.x - 27.138) < 1e-6); + // the planing runs from the ostrze to full railhead width + CHECK(std::abs(geometry.planing_end - blade_run_to_offset(kRadius, 0.0, kRailtop)) < + 1e-12); + CHECK(std::abs(gap_at(start, geometry.blade_planed) - kRailtop) < 1e-9); + CHECK(std::abs(geometry.planing_length - (geometry.planing_end - kLead)) < 1e-12); + CHECK(geometry.planing_end > geometry.nose_end); +} + +void a_lead_derived_from_u_and_d_lands_on_the_table() { + // Koc, tablica 5.1: a, d and u for the iglice styczne do opornicy o ściętym + // ostrzu. They obey a + d = the run to a gap of u, which is what lets a preset + // carry only u and d. The table dimensions it on the rail, as a drawing does, so + // the axis station is scaled back up by R1/R; the numbers then agree to a hair at + // 1200 m and to millimetres at 190 + struct Row { + double radius, a, d, u, tolerance; + }; + constexpr Row rows[]{ + {190.0, 1.2483, 0.1250, 0.0049, 7e-3}, {300.0, 1.5000, 0.1250, 0.0044, 2e-3}, + {500.0, 2.1650, 0.1250, 0.0053, 1.4e-2}, {760.0, 2.6330, 0.1250, 0.0050, 2e-4}, + {1200.0, 3.3347, 0.1304, 0.0050, 1e-4}, + }; + for (const auto& row : rows) { + const double rail = row.radius + kHalfGauge; + const double drawn = + blade_run_to_offset(row.radius, 0.0, row.u) * rail / row.radius; + CHECK(std::abs(drawn - (row.a + row.d)) < row.tolerance); } } -void a_blade_reaching_the_crossing_angle_is_rejected() { - auto turnout = rz_1_9_r190(); - turnout.blade_angle = std::atan(1.0 / 9.0); // nothing left for the curve to turn - CHECK(!lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout).valid); +void a_straight_blade_is_planed_by_its_break() { + // iglica prosta sieczna: the same two dimensions, taken off the break angle + const double beta = 0.02; + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, + Blade{0.005, 0.0, 0.070}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, 3.5, 0.0, 0.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, 6.0, 0.0, 0.0, beta}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, 190.0 * (kAlfa19 - beta), + 190.0, 190.0, 0.0}); - turnout.blade_angle = -0.01; - CHECK(!lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout).valid); -} - -void a_bladeless_curve_still_starts_tangent() { - const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, rz_1_9_r190()); + const auto geometry = laid(turnout); CHECK(geometry.valid); - for (const auto& segment : geometry.path) CHECK(segment.turn_in == 0.0); + // the lead runs along the through track and the blade tip sits at its end + CHECK(geometry.blade_station == 3.5); + CHECK(std::abs(geometry.blade_tip.x - 3.5) < 1e-9); + CHECK(geometry.blade_tip.y == 0.0); + CHECK(std::abs(geometry.nose_end - (3.5 + 0.005 / std::sin(beta))) < 1e-12); + CHECK(std::abs(geometry.planing_length - 0.070 / std::sin(beta)) < 1e-12); + // Templot says the same length along the stock rail rather than along the blade + CHECK(std::abs(geometry.planing_length * std::cos(beta) - 0.070 / std::tan(beta)) < + 1e-12); + // the break itself is the kąt przylegania here: the blade is straight + CHECK(std::abs(geometry.blade_angle - beta) < 1e-12); } -// With no blade length the break still stands: the curve simply starts at the tip. -void a_zero_length_blade_keeps_its_break() { +void a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start() { auto turnout = rz_1_9_r190(); - turnout.pre_blade = 3.5; - turnout.blade_angle = 0.02; - - const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + turnout.blade = Blade{0.0, 0.0, 0.0}; + const auto geometry = laid(turnout); CHECK(geometry.valid); - CHECK(geometry.path.size() >= 2); - CHECK(std::abs(geometry.path[1].turn_in - 0.02) < 1e-12); - CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - - std::atan(1.0 / 9.0)) < 1e-9); + CHECK(geometry.has_blade); + CHECK(std::abs(geometry.blade_station - kLead) < 1e-12); + CHECK(geometry.nose_end == 0.0); + CHECK(geometry.planing_length == 0.0); + CHECK(geometry.blade_residual == 0.0); } -// A right-hand turnout is the mirror image: the blade breaks the other way. -void a_right_hand_blade_breaks_the_other_way() { +void a_catalogue_list_closes_on_its_own_numbers() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + CHECK(std::abs(geometry.angle_residual) < 1e-9); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9); + // the two tangent legs are the drawing's a and b, and L is their sum + CHECK(std::abs(geometry.tangent_front - 10.523) < 1e-3); + CHECK(std::abs(geometry.tangent_back - 16.615) < 1e-3); + CHECK(std::abs(geometry.tangent_front + geometry.tangent_back - 27.138) < 1e-3); + CHECK(std::abs(geometry.length_residual) < 1e-9); +} + +void the_switch_ends_on_the_straight_too() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + // koniec rozjazdu as the straight sees it: the frog projected back onto it, which + // is shorter than a + b and is the second point marked on the tor zasadniczy + CHECK(std::abs(geometry.through_length - + (kRadius * std::sin(kAlfa19) + kFrogRail * std::cos(kAlfa19))) < 1e-9); + CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back); + CHECK(std::abs(geometry.through_end.x - geometry.through_length) < 1e-12); + CHECK(geometry.through_end.y == 0.0); + CHECK(geometry.through_end.hx == 1.0); +} + +void a_list_that_misses_the_mark_is_still_laid_and_said_out_loud() { + auto turnout = rz_1_9_r190(); + turnout.path[2].length -= 2.0; // two metres of curve short of the crossing angle + const auto geometry = laid(turnout); + CHECK(geometry.valid); // it lays: the numbers are the author's business + CHECK(std::abs(geometry.angle_residual + 2.0 / kRadius) < 1e-9); + CHECK(geometry.length_residual != 0.0); +} + +void a_degenerate_piece_is_refused() { + auto turnout = rz_1_9_r190(); + turnout.path[2].length = 0.0; + CHECK(!laid(turnout).valid); + + turnout = rz_1_9_r190(); + turnout.path[1].radius_start = -190.0; + CHECK(!laid(turnout).valid); + + turnout = rz_1_9_r190(); + turnout.path.clear(); + CHECK(!laid(turnout).valid); +} + +void a_right_hand_turnout_is_the_mirror_of_a_left_one() { + const auto left = laid(rz_1_9_r190()); auto turnout = rz_1_9_r190(); turnout.side = DivergeSide::Right; - turnout.pre_blade = 3.5; - turnout.blade_angle = 0.02; - turnout.blade_length = 6.0; - - const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + const auto geometry = laid(turnout); CHECK(geometry.valid); - CHECK(std::abs(std::atan2(pose_after(geometry, 1).hy, pose_after(geometry, 1).hx) + - 0.02) < 1e-9); - CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + - std::atan(1.0 / 9.0)) < 1e-9); + CHECK(std::abs(geometry.path[1].k0 + 1.0 / kRadius) < 1e-12); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + kAlfa19) < 1e-9); + CHECK(std::abs(geometry.through_length - left.through_length) < 1e-9); + // the residual is not signed by the hand: a right turnout closes just as well + CHECK(std::abs(geometry.angle_residual) < 1e-9); + // and the blade is dimensioned the same, on the other side + CHECK(std::abs(geometry.nose_end - left.nose_end) < 1e-12); + CHECK(std::abs(geometry.blade_angle - left.blade_angle) < 1e-12); + CHECK(std::abs(geometry.blade_nose_end.y + left.blade_nose_end.y) < 1e-12); +} + +void a_basket_is_three_pieces_and_nothing_special() { + // łuk koszowy: 190 m eased to 300 m by a clothoid, ending on the same angle + const double first = 8.0; + const double transition = 6.0; + // deflection: first/190 + transition*(1/190 + 1/300)/2 + rest/300 == alfa + const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0); + const double rest = (kAlfa19 - turned) * 300.0; + + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, + Blade{0.0049, 0.125, 0.070}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, rest, 300.0, 300.0, 0.0}); + + const auto geometry = laid(turnout); + CHECK(geometry.valid); + CHECK(std::abs(geometry.angle_residual) < 1e-9); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9); + const Pose end = pose_after(geometry, geometry.path.size() - 1); + CHECK(std::abs(end.x - geometry.frog.x) < 1e-9); + CHECK(std::abs(end.y - geometry.frog.y) < 1e-9); + // a blade starting at PR has no lead ahead of it, so a is nothing and the + // residual is what the type's own d asks for against the curve + CHECK(geometry.blade_station == 0.0); + CHECK(std::abs(geometry.blade_residual - (0.125 - geometry.nose_end)) < 1e-12); +} + +TurnoutGeometry laid_bent(const Turnout& turnout, double bend) { + return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout, bend); +} + +// The bend that turns the tor zasadniczy by @p beta over the turnout. Every leg of +// the triangle has the same tangent t, so a radius is t/tg(half its own angle). +double bend_for(double beta) { return std::tan(0.5 * beta) / kFrontTangent; } + +void a_bend_of_nothing_is_the_turnout_it_was() { + const auto plain = laid(rz_1_9_r190()); + const auto bent = laid_bent(rz_1_9_r190(), 0.0); + CHECK(bent.valid); + CHECK(bent.bend == 0.0); + CHECK(bent.diverging_curvature == 0.0); + CHECK(bent.path.size() == plain.path.size()); + for (std::size_t i = 0; i < plain.path.size(); ++i) { + CHECK(bent.path[i].k0 == plain.path[i].k0); + CHECK(bent.path[i].length == plain.path[i].length); + } + CHECK(bent.through_length == plain.through_length); + CHECK(bent.nose_end == plain.nose_end); +} + +void a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls() { + // Koc, 5.3.6: Rłs 49E1-380,292-1:9 z krzyżownicą prostą powstaje przez wygięcie + // Rz 49E1-190-1:9. Symmetric means the two tracks split the crossing angle, so + // the tor zasadniczy turns half of it the other way and both radii come out the + // same — and the catalogue's own 380,292 is what they come out at + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19)); + CHECK(geometry.valid); + CHECK(geometry.bend < 0.0); + CHECK(geometry.diverging_curvature > 0.0); + CHECK(std::abs(std::abs(1.0 / geometry.bend) - 380.292) < 0.02); + CHECK(std::abs(std::abs(1.0 / geometry.diverging_curvature) - 380.292) < 0.02); + CHECK(std::abs(geometry.bend_angle + 0.5 * kAlfa19) < 1e-12); +} + +void a_bend_lands_on_kocs_own_formulas() { + // (5.7) jednostronny and (5.8) dwustronny. The second is written here with the + // sign our curvature carries openly: Koc's R - R1 denominator is the same figure + // read off a drawing that has already decided which way the branch goes + const double t2 = kFrontTangent * kFrontTangent; + for (const double r1 : {320.0, 600.0, 2000.0}) { + const auto same = laid_bent(rz_1_9_r190(), 1.0 / r1); + CHECK(same.valid); + CHECK(std::abs(1.0 / same.diverging_curvature - + (kRadius * r1 - t2) / (kRadius + r1)) < 1e-9); + // bending with the branch makes it sharper, which is why R190 may not be + CHECK(1.0 / same.diverging_curvature < kRadius); + + const auto against = laid_bent(rz_1_9_r190(), -1.0 / r1); + CHECK(against.valid); + CHECK(std::abs(1.0 / against.diverging_curvature - + (kRadius * r1 + t2) / (r1 - kRadius)) < 1e-9); + CHECK(1.0 / against.diverging_curvature > kRadius); + } +} + +void a_bend_that_straightens_the_branch_still_lays() { + // wygięcie dwustronne aż tor zwrotny wychodzi prosty: the triangle is all + // tangent and no arc, so the path from PR is 2t of straight and the frog rail + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-kAlfa19)); + CHECK(geometry.valid); + CHECK(geometry.diverging_curvature == 0.0); + CHECK(std::abs(geometry.diverging_length - + (2.0 * kFrontTangent + kFrogRail)) < 1e-9); + CHECK(std::abs(geometry.frog.hy) < 1e-12); // it runs dead straight +} + +void bending_leaves_the_triangle_alone() { + // what łukowanie does not touch: the crossing angle, the tangent legs, and so + // the catalogue length. that is the whole point of rotating the triangle + for (const double beta : {-0.5 * kAlfa19, -0.02, 0.01, 0.03}) { + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(beta)); + CHECK(geometry.valid); + CHECK(std::abs(geometry.angle_residual) < 1e-9); + CHECK(std::abs(geometry.tangent_front - kFrontTangent) < 1e-9); + CHECK(std::abs(geometry.tangent_back - kBackTangent) < 1e-9); + CHECK(std::abs(geometry.length_residual) < 1e-9); + CHECK(std::abs(geometry.bend_angle - beta) < 1e-12); + } +} + +void a_bent_switch_keeps_its_blade() { + // Koc: zwrotnica i krzyżownica pozostają bez zmian co do wymiarów, należy je + // tylko wygiąć. The blade opens against an opornica that curves with it, so what + // dimensions it is the difference of the two — and a + d still lands on A, to + // well under the millimetre the rail is cut to + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19)); + CHECK(geometry.valid); + CHECK(geometry.has_blade); + // the rails are the same rails: a and the blade keep their lengths exactly + CHECK(geometry.blade_station == kLead); + CHECK(std::abs(geometry.path[1].length - kBlade) < 1e-12); + CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-3); + CHECK(std::abs(geometry.blade_residual) < 1e-3); + CHECK(geometry.blade_angle > 0.0); + // and the tip sits on the bent path: still off to the left, but half as far as + // it was, because the tor zasadniczy now runs away under it + const auto plain = laid(rz_1_9_r190()); + CHECK(geometry.blade_tip.y > 0.0); + CHECK(geometry.blade_tip.y < plain.blade_tip.y); +} + +void the_bent_switch_ends_on_its_own_arc() { + const double bend = bend_for(-0.5 * kAlfa19); + const auto geometry = laid_bent(rz_1_9_r190(), bend); + CHECK(geometry.valid); + // koniec rozjazdu is the frog brought back onto the tor zasadniczy along the + // radius, so it lands on the arc rather than on the straight PR set out along + const double radius = 1.0 / bend; + const double centre_y = radius; + CHECK(std::abs(std::hypot(geometry.through_end.x, + geometry.through_end.y - centre_y) - std::abs(radius)) < 1e-9); + CHECK(geometry.through_length > 0.0); + CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back); + CHECK(std::abs(geometry.through_end.y) > 1e-6); // the through track has left y=0 +} + +void a_path_with_no_one_radius_will_not_bend() { + // a łuk koszowy has no single triangle to rotate about, and a bend guessed at + // one of its radii would be a drawing of nothing + const double first = 8.0; + const double transition = 6.0; + const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0); + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, Blade{0.0049, 0.125, 0.070}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0}); + turnout.path.push_back( + TurnoutPiece{TurnoutPart::Curve, (kAlfa19 - turned) * 300.0, 300.0, 300.0, 0.0}); + CHECK(laid(turnout).valid); + CHECK(!laid_bent(turnout, 1.0 / 600.0).valid); +} + +void the_tangent_lengths_meet_where_the_tracks_would() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + // the through tangent and the frog tangent meet at one point: walking either + // way from the two ends has to land on it + const double cx = geometry.tangent_front; + CHECK(std::abs(geometry.frog.x - geometry.frog.hx * geometry.tangent_back - cx) < 1e-9); + CHECK(std::abs(geometry.frog.y - geometry.frog.hy * geometry.tangent_back) < 1e-9); } } // namespace int main() { - RUN(pre_blade_runs_along_the_through_track); - RUN(the_frog_still_lands_on_the_crossing_angle); - RUN(a_blade_reaching_the_crossing_angle_is_rejected); - RUN(a_bladeless_curve_still_starts_tangent); - RUN(a_zero_length_blade_keeps_its_break); - RUN(a_right_hand_blade_breaks_the_other_way); + RUN(a_tangential_blade_never_breaks_away); + RUN(a_planed_nose_reaches_its_thickness_where_the_table_says); + RUN(a_lead_derived_from_u_and_d_lands_on_the_table); + RUN(a_straight_blade_is_planed_by_its_break); + RUN(a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start); + RUN(a_catalogue_list_closes_on_its_own_numbers); + RUN(the_switch_ends_on_the_straight_too); + RUN(a_list_that_misses_the_mark_is_still_laid_and_said_out_loud); + RUN(a_degenerate_piece_is_refused); + RUN(a_right_hand_turnout_is_the_mirror_of_a_left_one); + RUN(a_basket_is_three_pieces_and_nothing_special); + RUN(the_tangent_lengths_meet_where_the_tracks_would); + RUN(a_bend_of_nothing_is_the_turnout_it_was); + RUN(a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls); + RUN(a_bend_lands_on_kocs_own_formulas); + RUN(a_bend_that_straightens_the_branch_still_lays); + RUN(bending_leaves_the_triangle_alone); + RUN(a_bent_switch_keeps_its_blade); + RUN(the_bent_switch_ends_on_its_own_arc); + RUN(a_path_with_no_one_radius_will_not_bend); return REPORT(); } From 61336a0660fc2297502fe2553aa931750abc5793 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 30 Aug 2026 17:14:08 +0200 Subject: [PATCH 16/17] sonar fix, registration to sbt.sources improved --- application/planpanel.cpp | 461 ++++++++++- application/planpanel.h | 29 +- maj0sted/CMakeLists.txt | 1 + maj0sted/include/maj0sted/editor/model.hpp | 5 + maj0sted/include/maj0sted/editor/sketch.hpp | 7 + maj0sted/include/maj0sted/io/scn_export.hpp | 49 +- maj0sted/src/editor/sketch.cpp | 32 +- maj0sted/src/io/document_io.cpp | 18 +- maj0sted/src/io/scn_export.cpp | 866 +++++++++++++++----- maj0sted/tests/CMakeLists.txt | 10 + maj0sted/tests/test_format.cpp | 27 +- maj0sted/tests/test_lines.cpp | 87 ++ 12 files changed, 1342 insertions(+), 250 deletions(-) diff --git a/application/planpanel.cpp b/application/planpanel.cpp index de68271f..fedec343 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -16,6 +16,7 @@ http://mozilla.org/MPL/2.0/. #include "rendering/renderer.h" #include "utilities/Globals.h" +#include "maj0sted/editor/join.hpp" #include "maj0sted/editor/sketch.hpp" #include "maj0sted/io/document_io.hpp" @@ -25,6 +26,11 @@ http://mozilla.org/MPL/2.0/. #include #include +#if !defined(_WIN32) +#include +#include +#endif + namespace { @@ -45,6 +51,83 @@ glm::dvec3 plan_to_world(double const X, double const Y) return {X - Global.scenery_origin.x, 0.0, -(Y - Global.scenery_origin.y)}; } +// where this very program stands on disk. the editor and the simulator are one exe, so running the +// exported scenery is running ourselves again with the scenery named on the command line +std::string executable_path() +{ +#if defined(_WIN32) + char buffer[MAX_PATH]{}; + auto const length{GetModuleFileNameA(nullptr, buffer, MAX_PATH)}; + return length > 0 ? std::string(buffer, length) : std::string{}; +#else + char buffer[4096]{}; + auto const length{::readlink("/proc/self/exe", buffer, sizeof(buffer) - 1)}; + return length > 0 ? std::string(buffer, static_cast(length)) : std::string{}; +#endif +} + +// starts it and lets go: the editor keeps whatever is on its screen, and the second instance is not +// ours to wait for. on unix that is two forks, so nothing is left behind for us to reap +bool spawn_detached(std::string const &Executable, std::vector const &Arguments, std::string const &Directory) +{ + if (Executable.empty()) + { + return false; + } +#if defined(_WIN32) + std::string command{'"' + Executable + '"'}; + for (auto const &argument : Arguments) + { + command += " \"" + argument + '"'; + } + STARTUPINFOA startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + if (0 == CreateProcessA(nullptr, command.data(), nullptr, nullptr, FALSE, 0, nullptr, Directory.empty() ? nullptr : Directory.c_str(), &startup, &process)) + { + return false; + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return true; +#else + // laid out before the fork: between forking and exec'ing, a process with threads in it must not + // go anywhere near the allocator + std::vector argv; + argv.push_back(const_cast(Executable.c_str())); + for (auto const &argument : Arguments) + { + argv.push_back(const_cast(argument.c_str())); + } + argv.push_back(nullptr); + + auto const first{::fork()}; + if (first < 0) + { + return false; + } + if (first == 0) + { + if (::fork() == 0) + { + ::setsid(); + if (false == Directory.empty()) + { + if (::chdir(Directory.c_str()) != 0) + { + ::_exit(127); + } + } + ::execv(Executable.c_str(), argv.data()); + } + ::_exit(0); + } + int status{0}; + ::waitpid(first, &status, 0); + return true; +#endif +} + // the construction points of a turnout, by TurnoutMark::kind. the map and the template drawing name // them from this one list char const *const mark_names[]{"PR", "ostrze", "pieta iglicy", "styk", "koniec luku", @@ -999,6 +1082,8 @@ void plan_panel::handle_scene() { m_pick_turnout = 0; m_pick_parallel = 0; + m_pick_join = 0; + m_join_first = TrackId::none; m_pending = false; clear_selection(); m_status.clear(); @@ -1041,6 +1126,31 @@ void plan_panel::handle_scene() m_pick_parallel = 0; return; } + if (m_pick_join != 0 && ImGui::IsMouseClicked(0)) + { + TrackId which{TrackId::none}; + int end{0}; + if (false == hit_track_end(mouse, 18.0f, which, end)) + { + m_status = "kliknij w koniec toru - zapalone konce sa te, ktore da sie polaczyc"; + return; + } + if (m_pick_join == 1) + { + m_join_first = which; + m_join_first_end = end; + m_pick_join = 2; + m_status = "teraz wskaz drugi koniec"; + return; + } + if (which == m_join_first) + { + m_status = "to ten sam tor"; + return; + } + join_ends(m_join_first, m_join_first_end, which, end); + return; + } // --- ctrl: grabbing a straight by one of its ends --------------------- // this is the one thing a modifier is needed for: the ends of a straight sit on the track, so @@ -1457,6 +1567,133 @@ void plan_panel::draw_on_scene() drawlist->AddCircle(screen, 6.0f, IM_COL32(255, 240, 140, 230), 0, 2.0f); } } + + // scalanie: every end a join could be made from or to, and what would be laid between the one + // already picked and whichever the cursor is over + if (m_pick_join != 0) + { + auto const mouse{ImGui::GetIO().MousePos}; + TrackId hovered{TrackId::none}; + int hovered_end{0}; + auto const over{hit_track_end(mouse, 18.0f, hovered, hovered_end)}; + + for (auto const &track : m_solution.tracks) + { + for (int end = 0; end < 2; ++end) + { + if (false == loose_end(track, end)) + { + continue; + } + auto const &pose{end == 0 ? track.start : track.end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const picked{m_pick_join == 2 && track.id == m_join_first && end == m_join_first_end}; + auto const under{over && track.id == hovered && end == hovered_end}; + auto const colour{picked ? IM_COL32(120, 255, 160, 250) : under ? IM_COL32(255, 255, 255, 250) : IM_COL32(255, 240, 140, 190)}; + // a start is a square and an end is a circle, so which way a join would run is + // there to be seen and not guessed at + if (end == 0) + { + drawlist->AddRect(ImVec2(screen.x - 6.0f, screen.y - 6.0f), ImVec2(screen.x + 6.0f, screen.y + 6.0f), colour, 0.0f, 0, picked || under ? 3.0f : 1.5f); + } + else + { + drawlist->AddCircle(screen, 6.0f, colour, 0, picked || under ? 3.0f : 1.5f); + } + // the whole track lights up with the end under the cursor, so there is no doubt + // about which one is being taken hold of + if (false == (picked || under) || track.centreline.size() < 2) + { + continue; + } + std::vector axis; + axis.reserve(track.centreline.size()); + for (auto const &point : track.centreline) + { + ImVec2 at; + if (world_to_screen(plan_to_world(point.x, point.y), at)) + { + axis.push_back(at); + } + } + if (axis.size() > 1) + { + drawlist->AddPolyline(axis.data(), static_cast(axis.size()), (colour & 0x00ffffffu) | 0x66000000u, false, 6.0f); + } + } + } + + // the proposal itself, before anything is done to the document. it runs from the + // end pointed at first to the one under the cursor, whichever way round the two + // tracks happen to be written down: an end is left the way it points, a start is + // left the way it came, and the arriving end reads the other way about + if (m_pick_join == 2 && over && hovered != m_join_first) + { + auto const *head{maj0sted::editor::find_track(m_solution, m_join_first)}; + auto const *tail{maj0sted::editor::find_track(m_solution, hovered)}; + if (head != nullptr && tail != nullptr) + { + auto const leaving{join_pose(*head, m_join_first_end, true)}; + auto const arriving{join_pose(*tail, hovered_end, false)}; + maj0sted::editor::JoinSettings settings; + settings.radius = m_join_radius; + auto const plan{maj0sted::editor::plan_join(leaving, arriving, settings)}; + + std::string label; + if (plan.ok) + { + std::vector points; + auto walker{leaving}; + for (auto const &element : plan.elements) + { + auto const k{element.kind == Kind::Line || element.radius <= 0.0 ? 0.0 : element.hand / element.radius}; + walker = maj0sted::domain::geometry::layout_segment(k, k, element.length, walker, &points); + } + std::vector line; + line.reserve(points.size()); + for (auto const &point : points) + { + ImVec2 screen; + if (world_to_screen(plan_to_world(point.x, point.y), screen)) + { + line.push_back(screen); + } + } + if (line.size() > 1) + { + dashed_polyline(drawlist, line, IM_COL32(120, 255, 160, 235), 2.5f); + } + label = maj0sted::editor::join_kind_name(plan.kind); + auto laid{0.0}; + for (auto const &element : plan.elements) + { + laid += element.length; + } + if (laid > 0.0) + { + label += ", " + to_string(laid, 1) + " m"; + } + if (plan.tightest > 0.0) + { + label += ", R=" + to_string(plan.tightest, 0) + " m"; + } + } + else + { + label = plan.why; + } + + auto const size{ImGui::CalcTextSize(label.c_str())}; + ImVec2 const corner(mouse.x + 16.0f, mouse.y + 16.0f); + drawlist->AddRectFilled(ImVec2(corner.x - 4.0f, corner.y - 3.0f), ImVec2(corner.x + size.x + 4.0f, corner.y + size.y + 3.0f), IM_COL32(15, 20, 30, 215), 3.0f); + drawlist->AddText(corner, plan.ok ? IM_COL32(160, 255, 190, 250) : IM_COL32(255, 190, 110, 250), label.c_str()); + } + } + } } void plan_panel::go_to_plan() @@ -1591,9 +1828,151 @@ void plan_panel::render_toolbar() go_to_plan(); } + render_join(); + ImGui::Separator(); } +void plan_panel::render_join() +{ + if (m_document.tracks.size() < 2) + { + return; + } + ImGui::SameLine(); + if (ImGui::Button(m_pick_join != 0 ? "Lacze... (Esc)" : "Polacz")) + { + m_pick_join = m_pick_join != 0 ? 0 : 1; + m_join_first = TrackId::none; + m_status = m_pick_join != 0 ? "wskaz dwa wolne konce torow; podglad pokaze, co miedzy nimi stanie" : ""; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("wolne konce torow zapalaja sie na mapie: kwadrat to poczatek, kolo to koniec.\n" + "wskaz dwa dowolne - wyjdzie z tego jeden tor. gdy trzeba, jeden z nich\n" + "zostaje czytany od drugiego konca: w terenie nic sie nie rusza.\n" + "gdy oba leza na jednej prostej, wychodzi jedna prosta, bez zadnego luku"); + } + if (m_pick_join != 0) + { + ImGui::SetNextItemWidth(120.0f); + ImGui::InputDouble("R polaczenia [m]", &m_join_radius, 10.0, 100.0, "%.0f"); + } +} + +void plan_panel::join_ends(TrackId const A, int const Aend, TrackId const B, int const Bend) +{ + maj0sted::editor::JoinSettings settings; + settings.radius = m_join_radius; + + auto const report{maj0sted::editor::join_ends(m_document, m_solution, A, Aend, B, Bend, settings)}; + if (false == report.ok) + { + m_status = report.why; + return; + } + + // whichever track was left standing is the one to select afterwards, and it is not + // always the first clicked - the other one may have been the only one that could turn + auto const survivor{maj0sted::editor::find_track(m_document, A) != nullptr ? A : B}; + + char status[256]; + std::snprintf(status, sizeof(status), "polaczone: %s, %d odcinkow zlanych w jeden, %d rozjazdow przeniesionych%s", maj0sted::editor::join_kind_name(report.join.kind), report.fused, + report.turnouts, report.reversed ? ", jeden tor czytany od drugiego konca" : ""); + m_status = status; + m_track = survivor; + m_pick_join = 0; + m_join_first = TrackId::none; + auto const *joined{maj0sted::editor::find_track(m_document, survivor)}; + if (joined != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", joined->name.c_str()); + } + clear_selection(); + solve(); +} + +maj0sted::domain::geometry::Pose plan_panel::join_pose(maj0sted::editor::SolvedTrack const &Track_, int const End_, bool const Leaving_) +{ + auto pose{End_ == 1 ? Track_.end : Track_.start}; + // what is laid runs out of one end and into the other, so an end that points the + // wrong way for that is read backwards - which is exactly what turning the track + // round would do to it + if (Leaving_ ? End_ == 0 : End_ == 1) + { + pose.hx = -pose.hx; + pose.hy = -pose.hy; + } + return pose; +} + +bool plan_panel::loose_end(maj0sted::editor::SolvedTrack const &Track_, int const End_) const +{ + if (Track_.elements.empty()) + { + return false; + } + if (End_ == 0) + { + // a track hanging on a turnout's port starts on the frog, and that is not a loose end + auto const *authored{maj0sted::editor::find_track(m_document, Track_.id)}; + if (authored == nullptr || false == std::holds_alternative(authored->anchor)) + { + return false; + } + } + + // and one already standing on another track's end is joined to it, whether or not the two are + // one track in the document + auto const &pose{End_ == 0 ? Track_.start : Track_.end}; + for (auto const &other : m_solution.tracks) + { + if (other.id == Track_.id || other.elements.empty()) + { + continue; + } + for (auto const &at : {other.start, other.end}) + { + if (std::hypot(at.x - pose.x, at.y - pose.y) < 0.10) + { + return false; + } + } + } + return true; +} + +bool plan_panel::hit_track_end(ImVec2 const &Mouse, float const Tolerance, TrackId &OutTrack, int &OutEnd) const +{ + auto best{Tolerance}; + auto found{false}; + for (auto const &track : m_solution.tracks) + { + for (int end = 0; end < 2; ++end) + { + if (false == loose_end(track, end)) + { + continue; + } + auto const &pose{end == 0 ? track.start : track.end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const distance{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; + if (distance < best) + { + best = static_cast(distance); + OutTrack = track.id; + OutEnd = end; + found = true; + } + } + } + return found; +} + void plan_panel::render_elements() { auto *track{current_track()}; @@ -2659,6 +3038,8 @@ void plan_panel::render_storage() m_document.origin_x = Global.scenery_origin.x; m_document.origin_y = Global.scenery_origin.y; m_pending = false; + m_pick_join = 0; + m_join_first = TrackId::none; clear_selection(); solve(); if (m_document.view_extent > 0.0) @@ -2673,6 +3054,10 @@ void plan_panel::render_storage() { go_to_plan(); } + if (false == m_document.scn_path.empty()) + { + std::snprintf(m_scn_path, sizeof(m_scn_path), "%s", m_document.scn_path.c_str()); + } m_status = "loaded " + std::string(m_path); } else @@ -2686,29 +3071,95 @@ void plan_panel::render_storage() ImGui::SameLine(); if (ImGui::Button("Export SCN")) { - export_scn(); + export_scn(false); } + ImGui::SameLine(); + if (ImGui::Button("Export i uruchom")) + { + export_scn(true); + } + ImGui::Checkbox("uruchom w edytorze", &m_scn_run_editor); + ImGui::SameLine(); + // the trainset is what the simulator looks for a player train in: without one it loads the + // scenery and drops straight back out, so driving needs it and looking around does not + ImGui::Checkbox("z pociagiem", &m_scn_trainset); if (false == m_status.empty()) { ImGui::TextDisabled("%s", m_status.c_str()); } + for (auto const &warning : m_scn_warnings) + { + ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", warning.c_str()); + } } -void plan_panel::export_scn() +void plan_panel::export_scn(bool const Run_) { solve(); + m_scn_warnings.clear(); + // where a plan exports to belongs to the plan: say it once and it is there the next time the + // project is opened, whichever MaSzyna it was drawn for + m_document.scn_path = m_scn_path; maj0sted::io::ScnExportOptions opt; opt.origin_east = Global.scenery_origin.x; opt.origin_north = Global.scenery_origin.y; + opt.trainset = m_scn_trainset; std::ofstream out(m_scn_path, std::ios::binary | std::ios::trunc); if (!out) { m_status = "could not write " + std::string(m_scn_path); return; } - auto const result{maj0sted::io::export_scn(m_solution, opt, out)}; + auto const result{maj0sted::io::export_scn(m_document, m_solution, opt, out)}; out.flush(); - m_status = out.good() ? ("exported " + std::to_string(result.tracks) + " tracks") : ("could not write " + std::string(m_scn_path)); + if (false == out.good()) + { + m_status = "could not write " + std::string(m_scn_path); + return; + } + out.close(); + m_status = "wyeksportowano " + std::to_string(result.tracks) + " odcinków i " + std::to_string(result.switches) + " zwrotnic"; + m_scn_warnings = result.warnings; + if (false == m_scn_warnings.empty()) + { + m_status += ", " + std::to_string(m_scn_warnings.size()) + " uwag"; + } + if (Run_) + { + run_scn(); + } +} + +void plan_panel::run_scn() +{ + // the simulator names a scenery relative to scenery/, so that is what is handed over rather than + // the path the file was written to + std::string scenery{m_scn_path}; + std::string root; + auto const slash{scenery.find_last_of("/\\")}; + if (slash != std::string::npos) + { + // the game is wherever the scenery folder is: exporting into another MaSzyna's scenery and + // then running ours out of this directory would find the file and none of the models + auto const folder{scenery.substr(0, slash)}; + auto const parent{folder.find_last_of("/\\")}; + root = parent != std::string::npos ? folder.substr(0, parent) : std::string{}; + scenery = scenery.substr(slash + 1); + } + + std::vector arguments{"-s", scenery}; + if (m_scn_run_editor) + { + arguments.push_back("-editor"); + } + if (spawn_detached(executable_path(), arguments, root)) + { + m_status += "; uruchomiono " + scenery + (root.empty() ? "" : " w " + root); + } + else + { + m_status += "; nie udało się uruchomić symulatora"; + } } // --------------------------------------------------------------------------- @@ -2881,6 +3332,8 @@ void plan_panel::start_map(bool const Georeferenced, double const Originx, doubl m_pick_turnout = 0; m_pick_parallel = 0; + m_pick_join = 0; + m_join_first = TrackId::none; solve(); // the scenery's zero is where the work starts, whichever frame it stands for diff --git a/application/planpanel.h b/application/planpanel.h index 786fbab5..9b14eaff 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -55,13 +55,17 @@ class plan_panel : public ui_panel void render_toolbar(); void render_elements(); void render_turnouts(); + // joining two tracks into one: the radius a join may bend at, and the button that starts + // pointing at the ends to join. everything else about it happens on the map + void render_join(); void render_turnout_type(maj0sted::editor::TurnoutType &Type_); // the catalogue drawing of the chosen type, in its own window: the diverging path piece by // piece, both tracks' rails, and the points a drawing is dimensioned from void render_template_window(); void render_diagnostics(); void render_storage(); - void export_scn(); + void export_scn(bool Run_); + void run_scn(); void seed_catalogue(); void render_newmap_dialog(); void render_location_dialog(); @@ -106,6 +110,17 @@ class plan_panel : public ui_panel bool hit_straight_end(ImVec2 const &Mouse, std::size_t &OutIndex, int &OutEnd) const; bool hit_element(ImVec2 const &Mouse, ElementId &OutElement, TrackId &OutTrack) const; bool hit_turnout(ImVec2 const &Mouse, TurnoutId &OutTurnout) const; + // the loose end of any track: OutEnd 0 for the one it starts on, 1 for the one it runs out to. + // a track hanging on a turnout's port has no free start, so that one is not offered + bool hit_track_end(ImVec2 const &Mouse, float const Tolerance, TrackId &OutTrack, int &OutEnd) const; + // whether that end is loose at all: one that already stands on another track's end is joined + // to it, and there is nothing to join twice + bool loose_end(maj0sted::editor::SolvedTrack const &Track_, int const End_) const; + // how an end reads for a join laid out of it or into it: one facing the wrong way for that + // is taken backwards, which is what turning the track round would do to it anyway + static maj0sted::domain::geometry::Pose join_pose(maj0sted::editor::SolvedTrack const &Track_, int const End_, bool const Leaving_); + // lays the join and leaves one track, saying what it cost + void join_ends(TrackId const A, int const Aend, TrackId const B, int const Bend); // nearest station along a solved track's axis, for placing and sliding turnouts bool station_on(TrackId const Track, double const Wx, double const Wy, double &OutStation) const; TrackId nearest_track_axis(ImVec2 const &Mouse, float const Tolerance) const; @@ -143,6 +158,11 @@ class plan_panel : public ui_panel int m_pick_turnout{0}; // 0 idle, 1 waiting for the element the selected one is to be held parallel to int m_pick_parallel{0}; + // scalanie by pointing at it: 0 idle, 1 waiting for the first end, 2 for the second. a track + // joins onto another end to start, so which of the two ends is clicked says which way round + int m_pick_join{0}; + TrackId m_join_first{}; + int m_join_first_end{0}; // what the next appended element is made of double m_new_radius{300.0}; @@ -150,6 +170,8 @@ class plan_panel : public ui_panel int m_new_hand{1}; // the radius a corner is rounded with when laying by clicking double m_corner_radius{300.0}; + // the radius a join may bend at + double m_join_radius{300.0}; // miedzytorze a new hold is given, metres double m_new_offset{4.75}; @@ -173,6 +195,11 @@ class plan_panel : public ui_panel std::string m_status; char m_path[256]{"editor/plan.m0s"}; char m_scn_path[256]{"scenery/plan_export.scn"}; + std::vector m_scn_warnings; + // the exported scenery is opened in the editor by default: it stands up without a locomotive, + // and looking at what was drawn is what the export is usually for + bool m_scn_run_editor{true}; + bool m_scn_trainset{true}; bool m_pickingplace{false}; double m_pickx{0.0}; diff --git a/maj0sted/CMakeLists.txt b/maj0sted/CMakeLists.txt index accc4a5b..a58be61b 100644 --- a/maj0sted/CMakeLists.txt +++ b/maj0sted/CMakeLists.txt @@ -28,6 +28,7 @@ add_library(maj0sted STATIC src/editor/solution.cpp src/editor/chain.cpp src/editor/sketch.cpp + src/editor/join.cpp src/editor/layout.cpp src/editor/render.cpp src/editor/tile_cache.cpp diff --git a/maj0sted/include/maj0sted/editor/model.hpp b/maj0sted/include/maj0sted/editor/model.hpp index 9c60df1f..4ff40db5 100644 --- a/maj0sted/include/maj0sted/editor/model.hpp +++ b/maj0sted/include/maj0sted/editor/model.hpp @@ -189,6 +189,11 @@ struct Document { std::vector turnouts; std::vector tracks; + /// Where this plan is exported to, and so which MaSzyna it is exported into: + /// the scenery folder names the game the file lands in, and running what was + /// drawn means running the simulator there. Empty until it is said once. + std::string scn_path; + double view_x{0.0}, view_y{0.0}, view_extent{0.0}; bool origin_set{false}; bool georeferenced{false}; diff --git a/maj0sted/include/maj0sted/editor/sketch.hpp b/maj0sted/include/maj0sted/editor/sketch.hpp index d7ce0294..1f793cc2 100644 --- a/maj0sted/include/maj0sted/editor/sketch.hpp +++ b/maj0sted/include/maj0sted/editor/sketch.hpp @@ -80,6 +80,13 @@ struct Skeleton { std::vector lines; double start_x{0.0}, start_y{0.0}; ///< where the first straight begins double end_x{0.0}, end_y{0.0}; ///< where the last straight ends + /// Where the chain has to begin, and whether that is a place anybody may + /// move. A branch on a turnout's port may not: its first curve is laid from + /// the frog and the line under the straight after it follows wherever that + /// curve comes out, rather than the curve being slid along a line drawn + /// before the edit. + bool pinned{false}; + domain::geometry::Pose begins{}; }; /// Pulls @p track's skeleton out of @p solved. diff --git a/maj0sted/include/maj0sted/io/scn_export.hpp b/maj0sted/include/maj0sted/io/scn_export.hpp index 4f64fc92..3cde1a1d 100644 --- a/maj0sted/include/maj0sted/io/scn_export.hpp +++ b/maj0sted/include/maj0sted/io/scn_export.hpp @@ -2,7 +2,9 @@ #include #include +#include +#include "maj0sted/editor/model.hpp" #include "maj0sted/editor/solution.hpp" namespace maj0sted::io { @@ -11,24 +13,59 @@ struct ScnExportOptions { double origin_east{0.0}; double origin_north{0.0}; double rail_y{0.2}; - double max_arc_angle{1.5707963267948966}; + /// How far one cubic may be asked to turn. A cubic stands in for an arc to + /// within about 2,7·10⁻⁴ R over a quarter circle, which is centimetres on a + /// main-line radius; a quarter of that turn is a thousandth of a millimetre + /// on anything a railway is built to. + double max_arc_angle{0.4}; + /// Emit FirstInit and a lone locomotive on the first track written, so the + /// scenery can be loaded and driven without editing it by hand. + bool trainset{true}; + /// How far along that track it stands, metres. Far enough in that the + /// vehicle is on the track and not hanging off its end. + double trainset_offset{20.0}; + /// Ask each switch for the game's own sterowanie include: the lantern, the + /// drive's sound and the events that throw it by name. + bool switch_control{true}; + /// How near the blades one has to stand for `t` and `shift+t` to throw the + /// switch, metres. Kept well under the distance between two of them, or one + /// key would throw the lot. + double switch_key_reach{12.0}; }; struct ScnExportResult { - int tracks{0}; + int tracks{0}; ///< `track normal` nodes written + int switches{0}; ///< `track switch` nodes written std::string first_track_name; double origin_east{0.0}; double origin_north{0.0}; + /// What the scenery does not say as well as the drawing did. The export is + /// written anyway: these are read by whoever asked for it, never fixed up + /// behind their back. + std::vector warnings; }; -/// Writes the solved layout as MaSzyna scenery. The axis comes straight out of -/// the solution: the exporter used to average the two drawn rails back together -/// to recover it, which put the whole geometry through the renderer and back. +/// Writes the solved layout as MaSzyna scenery. +/// +/// The axis comes straight out of the solution: the exporter used to average the +/// two drawn rails back together to recover it, which put the whole geometry +/// through the renderer and back. +/// +/// A turnout is a `track switch` node, which is two Bézier curves leaving one +/// point, so the through track is cut around it: what runs from PR to KR belongs +/// to the switch, and the rest of the track is written as ordinary pieces either +/// side. The simulator wires all of it together by nothing but the endpoints +/// standing within two centimetres of each other, so every piece is cut from the +/// same laid geometry and the shared points are written out identical. +/// +/// The document is needed for what is authored and not derived: the names the +/// nodes are written under, and which track each turnout stands on. /// /// Height is still a constant and both roll slots are still zero — the vertical /// alignment is a separate piece of work, and nothing in the editor produces one /// yet. -[[nodiscard]] ScnExportResult export_scn(const editor::Solution& solution, +[[nodiscard]] ScnExportResult export_scn(const editor::Document& document, + const editor::Solution& solution, const ScnExportOptions& options, std::ostream& out); diff --git a/maj0sted/src/editor/sketch.cpp b/maj0sted/src/editor/sketch.cpp index 9e567891..cbc8e29f 100644 --- a/maj0sted/src/editor/sketch.cpp +++ b/maj0sted/src/editor/sketch.cpp @@ -377,6 +377,8 @@ bool skeleton_of(const Track& track, const SolvedTrack& solved, Skeleton& out, out.start_y = solved.elements[straights.front()].start.y; out.end_x = solved.elements[straights.back()].end.x; out.end_y = solved.elements[straights.back()].end.y; + out.pinned = !std::holds_alternative(track.anchor); + out.begins = solved.start; return true; } @@ -392,7 +394,7 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why return false; } - const std::vector& lines = skeleton.lines; + std::vector lines = skeleton.lines; // Where each corner leaves the line before it and lands on the line after it, // as distances along those lines. Nothing is written until every one of them // works out. @@ -401,6 +403,25 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why std::vector fitted(straights.size() > 0 ? straights.size() - 1 : 0); arrives[0] = along(lines[0], skeleton.start_x, skeleton.start_y); + + if (has_head && skeleton.pinned) { + // The chain leaves a frog, so its first curve starts where it starts and + // ends where it ends: the line under the straight behind that curve is not + // the one it was drawn on but wherever the curve now comes out. What the + // change costs is then taken up by that straight's length, which is the + // one thing here free to give. + std::vector as_authored; + for (const std::size_t at : groups.front().arcs) { + as_authored.push_back(track.elements[at].length); + } + const Pose local = lay_group(track, groups.front(), as_authored); + const Pose& from = skeleton.begins; + lines[0] = Support{from.x + local.x * from.hx - local.y * from.hy, + from.y + local.x * from.hy + local.y * from.hx, + local.hx * from.hx - local.hy * from.hy, + local.hx * from.hy + local.hy * from.hx}; + arrives[0] = 0.0; + } leaves[straights.size() - 1] = along(lines[straights.size() - 1], skeleton.end_x, skeleton.end_y); @@ -454,14 +475,7 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why if (std::holds_alternative(track.anchor)) { track.anchor = AtPose{begins.x, begins.y, std::atan2(begins.hx, begins.hy)}; - } else if (has_head) { - // A branch pinned to a turnout's port and starting mid-curve: its first - // straight is wherever the frog and that curve put it, so there is nothing - // here that may move. - why = "tor jest przypięty do rozjazdu i zaczyna się łukiem — nie ma czego dopasować " - "bez odrywania go od krzyżownicy"; - return false; - } else if (std::hypot(begins.x - skeleton.start_x, begins.y - skeleton.start_y) > 1e-6) { + } else if (std::hypot(begins.x - skeleton.begins.x, begins.y - skeleton.begins.y) > 1e-6) { why = "tor jest przypięty do rozjazdu — jego początku nie da się przesunąć"; return false; } diff --git a/maj0sted/src/io/document_io.cpp b/maj0sted/src/io/document_io.cpp index fa41c9aa..4b907f6a 100644 --- a/maj0sted/src/io/document_io.cpp +++ b/maj0sted/src/io/document_io.cpp @@ -14,7 +14,8 @@ namespace { using namespace maj0sted::editor; -constexpr int kVersion = 6; +constexpr int kVersion = 7; +constexpr int kNoExportPath = 6; // before a plan remembered where it exports to constexpr int kStraightOnly = 5; // before a placement could be bent onto a łuk constexpr int kNoseless = 4; // the piece list, but before the blade carried its nose constexpr int kLegacyTurnoutVersion = 3; // arcs + blade fields, before the piece list @@ -244,6 +245,8 @@ std::string serialize(const Document& document) { out += "origin " + std::string(document.origin_set ? "1" : "0") + " " + std::string(document.georeferenced ? "1" : "0") + " " + num(document.origin_x) + " " + num(document.origin_y) + "\n"; + // the tail is kept whole, so a path may have spaces in it like any other name + out += "scn " + document.scn_path + "\n"; out += "types " + std::to_string(document.turnout_types.size()) + "\n"; for (const auto& type : document.turnout_types) { @@ -309,8 +312,8 @@ std::optional deserialize(const std::string& text) { { const std::optional header = reader.next("m0s", 2); if (!header || !to_int(header->tokens[1], version) || - (version != kVersion && version != kStraightOnly && version != kNoseless && - version != kLegacyTurnoutVersion)) { + (version != kVersion && version != kNoExportPath && version != kStraightOnly && + version != kNoseless && version != kLegacyTurnoutVersion)) { return std::nullopt; } } @@ -337,6 +340,15 @@ std::optional deserialize(const std::string& text) { document.origin_set = line->tokens[1] == "1"; document.georeferenced = line->tokens[2] == "1"; } + if (version > kNoExportPath) { + // an older plan never said where it exported to, and the host has its own + // answer for that + const std::optional line = reader.next("scn", 1); + if (!line) { + return std::nullopt; + } + document.scn_path = std::string{line->tail}; + } const auto count_of = [&](std::string_view tag, std::size_t& out) { const std::optional line = reader.next(tag, 2); diff --git a/maj0sted/src/io/scn_export.cpp b/maj0sted/src/io/scn_export.cpp index 554b6e06..e55ccf01 100644 --- a/maj0sted/src/io/scn_export.cpp +++ b/maj0sted/src/io/scn_export.cpp @@ -2,116 +2,161 @@ #include #include +#include #include +#include #include #include +#include #include namespace maj0sted::io { namespace { +using maj0sted::editor::Document; +using maj0sted::editor::Kind; using maj0sted::editor::PlanPoint; using maj0sted::editor::SolvedElement; using maj0sted::editor::SolvedTrack; +using maj0sted::editor::SolvedTurnout; +using maj0sted::editor::TurnoutPlacement; -struct AxisElement { - int kind{0}; - std::uint32_t element_id{0}; - double length{0.0}; - double radius_start{0.0}; - double radius_end{0.0}; - std::vector points; -}; +/// What `TTrack::Equal` calls one point, and so what the simulator will wire +/// together. Everything the exporter writes has to land well inside it. +constexpr double kJoin = 0.02; +/// Shorter than this and there is no piece worth writing. +constexpr double kShortest = 0.05; -struct WorldXYZ { +struct World { double x{0.0}; double y{0.0}; double z{0.0}; }; -/// The solved elements, in the shape the emitter below wants. Nothing is -/// recovered or averaged: the solution already holds the axis. -std::vector axis_of(const SolvedTrack& track) { - const auto radius_of = [](double k) { - return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0; - }; - std::vector out; - out.reserve(track.elements.size()); - for (const SolvedElement& element : track.elements) { - AxisElement axis; - axis.kind = element.kind == maj0sted::editor::Kind::Line ? 0 - : element.kind == maj0sted::editor::Kind::Arc ? 1 - : 2; - axis.element_id = static_cast(element.id); - axis.length = element.length; - axis.radius_start = radius_of(element.k0); - axis.radius_end = radius_of(element.k1); - axis.points = element.points; - out.push_back(std::move(axis)); - } - return out; -} +/// One path of a track node: two ends and the control offsets from them, in the +/// simulator's coordinates and already in the units the file wants. +struct Bezier { + World p1{}, cv1{}, cv2{}, p2{}; + double radius{0.0}; +}; -WorldXYZ to_scn(double east, double north, double height, double ox, double oy) { - return {east - ox, height, -(north - oy)}; -} +/// A pose on a sampled axis: where it is and which way it runs. +struct Step { + PlanPoint at{}; + double tx{1.0}; + double ty{0.0}; +}; -void plan_tangent_to_scn(double de, double dn, double& out_x, double& out_z) { - const double len = std::hypot(de, dn); - if (len < 1e-12) { - out_x = 0.0; - out_z = 1.0; - return; - } - out_x = de / len; - out_z = -dn / len; -} +// --------------------------------------------------------------------------- +// Sampled axes +// --------------------------------------------------------------------------- -bool axis_pose_at(const std::vector& pts, double along, PlanPoint& out_p, - double& out_tx, double& out_ty) { - if (pts.size() < 2) { - return false; - } - double a = 0.0; - for (std::size_t i = 1; i < pts.size(); ++i) { - const double dx = pts[i].x - pts[i - 1].x; - const double dy = pts[i].y - pts[i - 1].y; - const double seg = std::hypot(dx, dy); - if (a + seg >= along || i + 1 == pts.size()) { - const double t = seg > 1e-12 ? std::clamp((along - a) / seg, 0.0, 1.0) : 0.0; - out_p = {pts[i - 1].x + dx * t, pts[i - 1].y + dy * t}; - if (seg > 1e-12) { - out_tx = dx / seg; - out_ty = dy / seg; - } else { - out_tx = 1.0; - out_ty = 0.0; - } - return true; - } - a += seg; - } - return false; -} - -double axis_total_length(const std::vector& pts) { +double poly_length(const std::vector& points) { double along = 0.0; - for (std::size_t i = 1; i < pts.size(); ++i) { - along += std::hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); + for (std::size_t i = 1; i < points.size(); ++i) { + along += std::hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y); } return along; } -double circular_bezier_handle(double radius, double phi) { - const double p = std::clamp(phi, 1e-6, std::numbers::pi); - return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(p * 0.25); +bool poly_step(const std::vector& points, double along, Step& out) { + if (points.size() < 2) { + return false; + } + double walked = 0.0; + for (std::size_t i = 1; i < points.size(); ++i) { + const double dx = points[i].x - points[i - 1].x; + const double dy = points[i].y - points[i - 1].y; + const double step = std::hypot(dx, dy); + if (step < 1e-12) { + continue; + } + if (walked + step >= along || i + 1 == points.size()) { + const double t = std::clamp((along - walked) / step, 0.0, 1.0); + out.at = PlanPoint{points[i - 1].x + dx * t, points[i - 1].y + dy * t}; + out.tx = dx / step; + out.ty = dy / step; + return true; + } + walked += step; + } + return false; } -bool fit_clothoid_bezier_handles(const std::vector& pts, double s0, - double s1, const PlanPoint& p0, const PlanPoint& p3, - double t0x, double t0y, double t1x, double t1y, - double& out_l0, double& out_l1) { - const double span = s1 - s0; +/// The turnout's diverging path as one axis. The pieces are laid end to end, so +/// each one begins where the last left off and the join is dropped rather than +/// written twice. +std::vector diverging_axis(const SolvedTurnout& turnout) { + std::vector points; + for (const auto& segment : turnout.path) { + for (const auto& point : segment.points) { + if (!points.empty() && + std::hypot(point.x - points.back().x, point.y - points.back().y) < 1e-9) { + continue; + } + points.push_back(point); + } + } + return points; +} + +// --------------------------------------------------------------------------- +// Bézier +// --------------------------------------------------------------------------- + +World to_scn(const PlanPoint& plan, const ScnExportOptions& options) { + return {plan.x - options.origin_east, options.rail_y, + -(plan.y - options.origin_north)}; +} + +/// A plan tangent as a control offset: the simulator's x is east and its z runs +/// south, and the file wants the offset from the endpoint rather than the point. +World offset_scn(double tx, double ty, double length) { + const double norm = std::hypot(tx, ty); + if (norm < 1e-12) { + return {0.0, 0.0, 0.0}; + } + return {tx / norm * length, 0.0, -ty / norm * length}; +} + +/// The control offset that puts a cubic on a circle of @p radius through @p phi. +double arc_handle(double radius, double phi) { + const double turn = std::clamp(phi, 1e-6, std::numbers::pi); + return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(turn * 0.25); +} + +double deflection(const Step& from, const Step& to) { + const double cross = from.tx * to.ty - from.ty * to.tx; + const double dot = from.tx * to.tx + from.ty * to.ty; + return std::abs(std::atan2(cross, dot)); +} + +/// A straight, or an arc of a known radius: the handles follow from the geometry +/// and nothing has to be fitted. +Bezier arc_bezier(const Step& from, const Step& to, double length, double radius, + const ScnExportOptions& options) { + Bezier bezier; + bezier.p1 = to_scn(from.at, options); + bezier.p2 = to_scn(to.at, options); + if (radius < 1.0) { + return bezier; // straight: no offsets, no radius + } + const double phi = std::clamp(length / radius, 1e-6, std::numbers::pi); + const double handle = arc_handle(radius, phi); + bezier.cv1 = offset_scn(from.tx, from.ty, handle); + bezier.cv2 = offset_scn(-to.tx, -to.ty, handle); + bezier.radius = radius; + return bezier; +} + +/// The handle lengths that put a cubic closest to a sampled axis, ends and +/// tangents being already fixed. Least squares in the two lengths, which is what +/// a clothoid — and a whole turnout, which is several pieces under one curve — +/// needs, there being no one radius to take them from. +bool fit_handles(const std::vector& points, const PlanPoint& p0, + const PlanPoint& p3, const Step& from, const Step& to, double& out_first, + double& out_last) { + const double span = poly_length(points); if (span < 1e-6) { return false; } @@ -119,22 +164,19 @@ bool fit_clothoid_bezier_handles(const std::vector& pts, double s0, double m00 = 0.0, m01 = 0.0, m11 = 0.0, b0 = 0.0, b1 = 0.0; for (int i = 0; i <= samples; ++i) { const double t = static_cast(i) / static_cast(samples); - PlanPoint q{}; - double tx = 1.0, ty = 0.0; - if (!axis_pose_at(pts, s0 + span * t, q, tx, ty)) { + Step step{}; + if (!poly_step(points, span * t, step)) { continue; } const double omt = 1.0 - t; const double c0 = 3.0 * omt * omt * t; const double c1 = 3.0 * omt * t * t; - const double ax = - omt * omt * (1.0 + 2.0 * t) * p0.x + t * t * (3.0 - 2.0 * t) * p3.x; - const double ay = - omt * omt * (1.0 + 2.0 * t) * p0.y + t * t * (3.0 - 2.0 * t) * p3.y; - const double rx = q.x - ax; - const double ry = q.y - ay; - const double a0x = c0 * t0x, a0y = c0 * t0y; - const double a1x = -c1 * t1x, a1y = -c1 * t1y; + const double ax = omt * omt * (1.0 + 2.0 * t) * p0.x + t * t * (3.0 - 2.0 * t) * p3.x; + const double ay = omt * omt * (1.0 + 2.0 * t) * p0.y + t * t * (3.0 - 2.0 * t) * p3.y; + const double rx = step.at.x - ax; + const double ry = step.at.y - ay; + const double a0x = c0 * from.tx, a0y = c0 * from.ty; + const double a1x = -c1 * to.tx, a1y = -c1 * to.ty; m00 += a0x * a0x + a0y * a0y; m01 += a0x * a1x + a0y * a1y; m11 += a1x * a1x + a1y * a1y; @@ -145,55 +187,292 @@ bool fit_clothoid_bezier_handles(const std::vector& pts, double s0, if (std::abs(det) < 1e-12) { return false; } - const double l0 = (m11 * b0 - m01 * b1) / det; - const double l1 = (-m01 * b0 + m00 * b1) / det; - if (l0 < 0.05 || l1 < 0.05) { + const double first = (m11 * b0 - m01 * b1) / det; + const double last = (-m01 * b0 + m00 * b1) / det; + if (first < 0.05 || last < 0.05) { return false; } - out_l0 = std::clamp(l0, 0.05, span); - out_l1 = std::clamp(l1, 0.05, span); + out_first = std::clamp(first, 0.05, span); + out_last = std::clamp(last, 0.05, span); return true; } -void clothoid_handles_from_radii(double len, double phi, double r0, double r1, - double& out_l0, double& out_l1) { - auto handle = [&](double r) { - if (r < 1.0) { - return len / 3.0; - } - return circular_bezier_handle(r, phi); - }; - out_l0 = handle(r0); - out_l1 = handle(r1); +PlanPoint bezier_at(const PlanPoint& p0, const PlanPoint& c1, const PlanPoint& c2, + const PlanPoint& p3, double t) { + const double omt = 1.0 - t; + const double w0 = omt * omt * omt; + const double w1 = 3.0 * omt * omt * t; + const double w2 = 3.0 * omt * t * t; + const double w3 = t * t * t; + return PlanPoint{w0 * p0.x + w1 * c1.x + w2 * c2.x + w3 * p3.x, + w0 * p0.y + w1 * c1.y + w2 * c2.y + w3 * p3.y}; } -void write_scn_track(std::ostream& out, const std::string& name, const WorldXYZ& p1, - const WorldXYZ& cv1, const WorldXYZ& p2, const WorldXYZ& cv2, - double length, double radius) { - out << "node -1 0 " << name << " track normal " << length - << " 1.435 0.15 25.0 20 0 flat vis\n"; - out << " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n"; - auto fmt = [](double v) { - std::ostringstream s; - s.setf(std::ios::fixed); - s.precision(4); - s << v; - return s.str(); - }; - out << fmt(p1.x) << ' ' << fmt(p1.y) << ' ' << fmt(p1.z) << " 0\n"; - out << fmt(cv1.x) << ' ' << fmt(cv1.y) << ' ' << fmt(cv1.z) << '\n'; - out << fmt(cv2.x) << ' ' << fmt(cv2.y) << ' ' << fmt(cv2.z) << '\n'; - out << fmt(p2.x) << ' ' << fmt(p2.y) << ' ' << fmt(p2.z) << " 0\n"; - out << fmt(radius) << '\n'; +double to_segment(const PlanPoint& point, const PlanPoint& a, const PlanPoint& b) { + const double dx = b.x - a.x; + const double dy = b.y - a.y; + const double square = dx * dx + dy * dy; + const double t = square > 1e-18 + ? std::clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / square, + 0.0, 1.0) + : 0.0; + return std::hypot(point.x - (a.x + dx * t), point.y - (a.y + dy * t)); +} + +/// How far the cubic strays from the axis it stands for. A turnout is several +/// pieces of geometry — przediglicowy, iglica, łuk, szyna łącząca — under one +/// curve in the scenery, so what that costs is measured against the curve +/// itself rather than against samples of it, which would only measure how far +/// apart the samples were put. +double curve_error(const std::vector& points, const PlanPoint& p0, + const PlanPoint& p3, const Step& from, const Step& to, double first, + double last) { + if (points.size() < 3) { + return 0.0; + } + const PlanPoint c1{p0.x + from.tx * first, p0.y + from.ty * first}; + const PlanPoint c2{p3.x - to.tx * last, p3.y - to.ty * last}; + + constexpr int kSamples = 96; + std::vector curve; + curve.reserve(kSamples + 1); + for (int i = 0; i <= kSamples; ++i) { + curve.push_back(bezier_at(p0, c1, c2, p3, static_cast(i) / kSamples)); + } + + double worst = 0.0; + for (const auto& point : points) { + double nearest = std::numeric_limits::max(); + for (std::size_t i = 1; i < curve.size(); ++i) { + nearest = std::min(nearest, to_segment(point, curve[i - 1], curve[i])); + } + worst = std::max(worst, nearest); + } + return worst; +} + +/// Least squares pairs the axis with the curve by parameter, and a cubic does +/// not run at an even rate, so the handles it lands on are near but not the +/// closest. This walks downhill from there on the thing that actually matters: +/// how far the curve ever gets from the axis. +void refine_handles(const std::vector& points, const PlanPoint& p0, + const PlanPoint& p3, const Step& from, const Step& to, double& first, + double& last, double& out_error) { + double best = curve_error(points, p0, p3, from, to, first, last); + const double span = poly_length(points); + double step = std::max(0.25, span * 0.1); + int budget = 400; + while (step > 0.005 && budget > 0) { + bool better = false; + constexpr int kMoves[6][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {-1, -1}}; + for (const auto& move : kMoves) { + const double trial_first = std::max(0.05, first + move[0] * step); + const double trial_last = std::max(0.05, last + move[1] * step); + const double error = + curve_error(points, p0, p3, from, to, trial_first, trial_last); + --budget; + if (error < best - 1e-6) { + best = error; + first = trial_first; + last = trial_last; + better = true; + break; + } + } + if (!better) { + step *= 0.5; + } + } + out_error = best; +} + +/// A cubic through the ends of a sampled axis, tangent to it at both, fitted to +/// what runs between. Falls back on thirds, which is what a cubic with nothing +/// better to go on does anyway. +Bezier fitted_bezier(const std::vector& points, const Step& from, const Step& to, + double radius, const ScnExportOptions& options, double& out_error) { + const double span = poly_length(points); + double first = span / 3.0; + double last = span / 3.0; + if (!fit_handles(points, from.at, to.at, from, to, first, last)) { + const double phi = deflection(from, to); + if (radius >= 1.0 && phi > 1e-6) { + first = last = arc_handle(radius, phi); + } + } + refine_handles(points, from.at, to.at, from, to, first, last, out_error); + + Bezier bezier; + bezier.p1 = to_scn(from.at, options); + bezier.p2 = to_scn(to.at, options); + bezier.cv1 = offset_scn(from.tx, from.ty, first); + bezier.cv2 = offset_scn(-to.tx, -to.ty, last); + bezier.radius = radius; + return bezier; +} + +// --------------------------------------------------------------------------- +// Writing +// --------------------------------------------------------------------------- + +std::string fixed(double value, int decimals = 4) { + std::ostringstream text; + text.setf(std::ios::fixed); + text.precision(decimals); + text << value; + return text.str(); +} + +void write_point(std::ostream& out, const World& point, const char* trailer) { + out << fixed(point.x) << ' ' << fixed(point.y) << ' ' << fixed(point.z) << trailer + << '\n'; +} + +/// One `node ... track` with as many paths as its type takes: one for a plain +/// track, two for a switch — the straight route first, which is the one the +/// simulator holds a switch in when nothing has thrown it. +/// +/// The two material slots do not mean the same thing on both. On plain track the +/// second is the podsypka with its sleepers, laid under the rails. On a switch +/// there is no ballast to lay — that comes as a model beside it — and the slot +/// draws the rails of the second route instead, so it takes the rail material +/// again. Giving it a ballast texture there puts sleepers up the diverging rails, +/// which is what the game's own sceneries avoid by writing the rails twice. +void write_track_node(std::ostream& out, const std::string& name, const char* type, + double length, const std::vector& paths) { + const bool points = std::string_view{type} == "switch"; + out << "node -1 0 " << name << " track " << type << ' ' << fixed(length, 3) + << (points ? " 1.435 0.24 15.0 20 2 flat vis\n" : " 1.435 0.15 25.0 20 0 flat vis\n"); + out << (points ? " rail_screw_used1 6 rail_screw_used1 0.2 2.75 2.5\n" + : " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n"); + for (const auto& path : paths) { + write_point(out, path.p1, " 0"); + write_point(out, path.cv1, ""); + write_point(out, path.cv2, ""); + write_point(out, path.p2, " 0"); + out << fixed(path.radius) << '\n'; + } out << "endtrack\n\n"; } +// --------------------------------------------------------------------------- +// Names +// --------------------------------------------------------------------------- + +/// Whatever the drawing called it, in what a scenery file can be asked to hold. +std::string sanitised(const std::string& name) { + std::string out; + out.reserve(name.size()); + for (const char letter : name) { + const bool plain = (letter >= '0' && letter <= '9') || + (letter >= 'a' && letter <= 'z') || + (letter >= 'A' && letter <= 'Z') || letter == '_' || + letter == '-'; + out.push_back(plain ? letter : '_'); + } + while (!out.empty() && out.back() == '_') { + out.pop_back(); + } + return out; +} + +/// Names have to be unique: an event or a dispatcher naming a track has to reach +/// one of them. +class Names { + public: + std::string mint(std::string wanted) { + if (wanted.empty()) { + wanted = "tor"; + } + std::string candidate = wanted; + int suffix = 1; + while (std::find(m_taken.begin(), m_taken.end(), candidate) != m_taken.end()) { + candidate = wanted + '_' + std::to_string(++suffix); + } + m_taken.push_back(candidate); + return candidate; + } + + private: + std::vector m_taken; +}; + +// --------------------------------------------------------------------------- +// Turnouts on their through track +// --------------------------------------------------------------------------- + +/// A turnout as the through track sees it: the stretch of it the switch node +/// takes over, and which end of that stretch the blades stand at. +struct Span { + const SolvedTurnout* turnout{nullptr}; + const TurnoutPlacement* placement{nullptr}; + double from{0.0}; ///< lower station of the stretch + double to{0.0}; ///< upper station + bool facing{true}; +}; + +/// What the catalogue calls the turnout's radius: the one the longest curved +/// piece runs at, which is the łuk itself and not an easement into it. +double catalogue_radius(const editor::TurnoutType& type) { + double longest = 0.0; + double radius = 0.0; + for (const auto& piece : type.pieces) { + if (piece.radius_start > 0.0 && piece.length > longest) { + longest = piece.length; + radius = piece.radius_start; + } + } + return radius; +} + +/// The sterowanie a scenery hangs off a switch: the lantern that turns with it, +/// the drive's sound, and the events a dispatcher throws it by — `zwr01+` to the +/// straight route, `zwr01-` to the diverging one. The game keeps one include per +/// geometry and hand, so the nearest of them to what was drawn is asked for; a +/// MaSzyna without that file says so in its log and loads the rest. +std::string switch_include(double radius, int hand) { + struct Family { + double radius; + const char* suffix; + }; + static constexpr Family kFamilies[]{ + {190.0, "25r190"}, {300.0, "34r300"}, {500.0, "41r500"}, {1200.0, "65r1200"}}; + const Family* nearest = &kFamilies[0]; + for (const auto& family : kFamilies) { + if (std::abs(family.radius - radius) < std::abs(nearest->radius - radius)) { + nearest = &family; + } + } + return std::string(hand >= 0 ? "zwrl" : "zwrp") + nearest->suffix + ".inc"; +} + +/// Which way the switch faces, in the degrees an include's `rotate` takes: zero +/// along the simulator's +z, turning towards +x. +double heading_degrees(const domain::geometry::Pose& pose) { + return std::atan2(pose.hx, -pose.hy) * 180.0 / std::numbers::pi; +} + +/// The radius the diverging route runs at. Bent, it is the one the bend worked +/// out to; otherwise the curve piece's own. +double diverging_radius(const SolvedTurnout& turnout) { + if (std::abs(turnout.diverging_curvature) > 1e-9) { + return 1.0 / std::abs(turnout.diverging_curvature); + } + for (const auto& segment : turnout.path) { + if (std::abs(segment.k0) > 1e-9) { + return 1.0 / std::abs(segment.k0); + } + } + return 0.0; +} + } // namespace +// --------------------------------------------------------------------------- + ScnExportOptions resolve_scn_origin(const maj0sted::editor::Solution& solution, ScnExportOptions options) { - if (std::abs(options.origin_east) >= 1.0 || - std::abs(options.origin_north) >= 1.0) { + if (std::abs(options.origin_east) >= 1.0 || std::abs(options.origin_north) >= 1.0) { return options; } for (const auto& track : solution.tracks) { @@ -210,127 +489,266 @@ ScnExportOptions resolve_scn_origin(const maj0sted::editor::Solution& solution, return options; } -ScnExportResult export_scn(const maj0sted::editor::Solution& solution, +ScnExportResult export_scn(const Document& document, + const maj0sted::editor::Solution& solution, const ScnExportOptions& options, std::ostream& out) { const auto opt = resolve_scn_origin(solution, options); ScnExportResult result; result.origin_east = opt.origin_east; result.origin_north = opt.origin_north; - out << "//$n plan\n"; - out << "//$d origin " << opt.origin_east << ' ' << opt.origin_north << "\n\n"; + Names names; + // whatever was written first, for when nothing is long enough to stand a train on + std::string spare_track; + // every endpoint written, to be told afterwards which of them the simulator + // will and will not join up + struct Ending { + PlanPoint at{}; + std::string node; + bool is_switch{false}; + }; + std::vector endings; - const double kRailY = opt.rail_y; - const double kMaxArcAngle = opt.max_arc_angle; + out << "//$n plan\n"; + out << "//$d origin " << fixed(opt.origin_east, 3) << ' ' + << fixed(opt.origin_north, 3) << "\n\n"; for (const auto& track : solution.tracks) { - const auto track_id = static_cast(track.id); - const auto axis = axis_of(track); - for (std::size_t ei = 0; ei < axis.size(); ++ei) { - const auto& el = axis[ei]; - if (el.points.size() < 2) { + const auto* authored = find_track(document, track.id); + const std::string base = + names.mint(sanitised(authored != nullptr ? authored->name : std::string{})); + + // ---- where the switches sit on this track ------------------------- + std::vector spans; + for (const auto& placement : document.turnouts) { + if (placement.on != track.id) { continue; } - const double total = - el.length > 1e-6 ? el.length : axis_total_length(el.points); - if (total < 0.05) { + const SolvedTurnout* turnout = find_turnout(solution, placement.id); + if (turnout == nullptr || !turnout->valid || turnout->path.empty()) { + result.warnings.push_back("rozjazd „" + placement.type + + "” nie dał się położyć i nie wyszedł do scn"); continue; } + Span span; + span.turnout = turnout; + span.placement = &placement; + span.facing = placement.facing; + span.from = placement.facing ? placement.station + : placement.station - turnout->through_length; + span.to = placement.facing ? placement.station + turnout->through_length + : placement.station; + if (span.from < -kJoin || span.to > track.length + kJoin) { + result.warnings.push_back( + "rozjazd „" + placement.type + "” na torze „" + base + + "” wychodzi poza tor: zwrotnica wyszła, ale tor jej nie podpiera"); + } + spans.push_back(span); + } + std::sort(spans.begin(), spans.end(), + [](const Span& left, const Span& right) { return left.from < right.from; }); + for (std::size_t i = 1; i < spans.size(); ++i) { + if (spans[i].from < spans[i - 1].to - kJoin) { + result.warnings.push_back("dwa rozjazdy na torze „" + base + + "” zachodzą na siebie: w scn nakładają się " + "dwie zwrotnice"); + } + } - auto curvature_at = [&](double along) { - const double k0 = el.radius_start > 1.0 ? 1.0 / el.radius_start : 0.0; - const double k1 = el.radius_end > 1.0 ? 1.0 / el.radius_end : 0.0; - const double t = - total > 1e-9 ? std::clamp(along / total, 0.0, 1.0) : 0.0; - return k0 + (k1 - k0) * t; - }; - auto radius_from_k = [](double k) { - return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0; - }; - - auto emit_piece = [&](double s0, double s1, double piece_len, - double radius, const char* tag) { - PlanPoint p0{}, p1{}; - double t0x = 1.0, t0y = 0.0, t1x = 1.0, t1y = 0.0; - if (!axis_pose_at(el.points, s0, p0, t0x, t0y) || - !axis_pose_at(el.points, s1, p1, t1x, t1y)) { - return; + // ---- the plain track, in the gaps the switches leave -------------- + int piece = 0; + const auto write_range = [&](double from, double to) { + double walked = 0.0; + for (const SolvedElement& element : track.elements) { + const double span = poly_length(element.points); + const double starts = walked; + const double ends = walked + span; + walked = ends; + if (element.points.size() < 2 || span < kShortest) { + continue; + } + const double first = std::max(from, starts); + const double last = std::min(to, ends); + if (last - first < kShortest) { + continue; } - const auto w0 = to_scn(p0.x, p0.y, kRailY, opt.origin_east, - opt.origin_north); - const auto w1 = to_scn(p1.x, p1.y, kRailY, opt.origin_east, - opt.origin_north); - double tx0 = 0.0, tz0 = 0.0, tx1 = 0.0, tz1 = 0.0; - plan_tangent_to_scn(t0x, t0y, tx0, tz0); - plan_tangent_to_scn(t1x, t1y, tx1, tz1); - WorldXYZ cv1{}, cv2{}; - double radius_out = 0.0; - if (el.kind == 1 && radius >= 1.0) { - const double phi = std::clamp(piece_len / std::max(radius, 1.0), - 1e-6, std::numbers::pi); - const double handle = circular_bezier_handle(radius, phi); - cv1 = {tx0 * handle, 0.0, tz0 * handle}; - cv2 = {-tx1 * handle, 0.0, -tz1 * handle}; - radius_out = radius; - } else if (el.kind == 2) { - const double cross = t0x * t1y - t0y * t1x; - const double dot = t0x * t1x + t0y * t1y; - const double phi = std::clamp(std::abs(std::atan2(cross, dot)), - 1e-6, std::numbers::pi); - const double r0 = radius_from_k(curvature_at(s0)); - const double r1 = radius_from_k(curvature_at(s1)); - double l0 = piece_len / 3.0; - double l1 = piece_len / 3.0; - if (!fit_clothoid_bezier_handles(el.points, s0, s1, p0, p1, t0x, - t0y, t1x, t1y, l0, l1)) { - clothoid_handles_from_radii(piece_len, phi, r0, r1, l0, l1); + const double radius = + element.kind == Kind::Arc && std::abs(element.k0) > 1e-9 + ? 1.0 / std::abs(element.k0) + : 0.0; + // an arc has to be cut into cubics no one of which turns too far + // to stay a circle + const int cuts = + radius >= 1.0 + ? std::max(1, static_cast(std::ceil((last - first) / radius / + opt.max_arc_angle))) + : 1; + const double cut = (last - first) / static_cast(cuts); + for (int c = 0; c < cuts; ++c) { + const double s0 = first - starts + cut * static_cast(c); + const double s1 = c + 1 == cuts ? last - starts : s0 + cut; + Step from_step{}, to_step{}; + if (!poly_step(element.points, s0, from_step) || + !poly_step(element.points, s1, to_step)) { + continue; } - cv1 = {tx0 * l0, 0.0, tz0 * l0}; - cv2 = {-tx1 * l1, 0.0, -tz1 * l1}; + const std::string name = names.mint(base + "_n" + std::to_string(++piece)); + Bezier bezier; + if (element.kind == Kind::Clothoid) { + std::vector sampled; + constexpr int kSamples = 24; + for (int i = 0; i <= kSamples; ++i) { + Step step{}; + if (poly_step(element.points, + s0 + (s1 - s0) * static_cast(i) / kSamples, + step)) { + sampled.push_back(step.at); + } + } + double strayed = 0.0; + bezier = fitted_bezier(sampled, from_step, to_step, 0.0, opt, strayed); + } else { + bezier = arc_bezier(from_step, to_step, s1 - s0, radius, opt); + } + write_track_node(out, name, "normal", s1 - s0, {bezier}); + endings.push_back(Ending{from_step.at, name, false}); + endings.push_back(Ending{to_step.at, name, false}); + // the train stands a way in from the end of a track, so the first + // piece long enough to hold it is the one it is put on + if (result.first_track_name.empty() && + s1 - s0 >= opt.trainset_offset + 1.0) { + result.first_track_name = name; + } + if (spare_track.empty()) { + spare_track = name; + } + ++result.tracks; } - - std::ostringstream name; - name << "plan_t" << track_id << "_e" << el.element_id << '_' << tag - << '_' << result.tracks; - const auto track_name = name.str(); - if (result.first_track_name.empty()) { - result.first_track_name = track_name; - } - write_scn_track(out, track_name, w0, cv1, w1, cv2, piece_len, - radius_out); - ++result.tracks; - }; - - if (el.kind == 0 || (el.kind == 1 && el.radius_start < 1.0)) { - emit_piece(0.0, total, total, 0.0, "str"); - continue; } - if (el.kind == 2) { - emit_piece(0.0, total, total, 0.0, "kp"); - continue; + }; + + // ---- the switches themselves -------------------------------------- + const auto write_switch = [&](const Span& span) { + const SolvedTurnout& turnout = *span.turnout; + // PR: where the blades are, and where both routes have to start from + // the same point or the simulator will not join them + const Step blade{PlanPoint{turnout.start.x, turnout.start.y}, turnout.start.hx, + turnout.start.hy}; + + const double exit_station = span.facing ? span.to : span.from; + domain::geometry::Pose exit_pose{}; + if (!pose_at(track, exit_station, exit_pose)) { + result.warnings.push_back("rozjazd „" + span.placement->type + "” na torze „" + + base + "” nie ma końca na torze zasadniczym"); + return; + } + const double sense = span.facing ? 1.0 : -1.0; + const Step straight_end{PlanPoint{exit_pose.x, exit_pose.y}, + sense * exit_pose.hx, sense * exit_pose.hy}; + + const double bend_radius = + std::abs(turnout.bend) > 1e-9 ? 1.0 / std::abs(turnout.bend) : 0.0; + const Bezier straight = arc_bezier(blade, straight_end, turnout.through_length, + bend_radius, opt); + + const auto axis = diverging_axis(turnout); + const Step diverging_start{ + axis.empty() ? blade.at : axis.front(), + turnout.path.front().start.hx, turnout.path.front().start.hy}; + const Step frog{PlanPoint{turnout.frog.x, turnout.frog.y}, turnout.frog.hx, + turnout.frog.hy}; + const double radius = diverging_radius(turnout); + double strayed = 0.0; + Bezier diverging = + fitted_bezier(axis, diverging_start, frog, radius, opt, strayed); + // the two routes leave one point: the fit is over the laid path, but + // what is written is the blade pose the straight route also starts on + diverging.p1 = straight.p1; + + if (strayed > 0.05) { + result.warnings.push_back( + "rozjazd „" + span.placement->type + + "”: jedna krzywa scn odbiega od położonego toru zwrotnego o " + + fixed(strayed, 3) + " m"); } - const double r = std::max(el.radius_start, el.radius_end); - const double phi = total / std::max(r, 1.0); - const int pieces = - std::max(1, static_cast(std::ceil(phi / kMaxArcAngle))); - const double piece_len = total / static_cast(pieces); - for (int p = 0; p < pieces; ++p) { - const double s0 = piece_len * static_cast(p); - const double s1 = - p + 1 == pieces ? total : piece_len * static_cast(p + 1); - emit_piece(s0, s1, s1 - s0, r, "arc"); + // zwr01, zwr02: what a scenery calls its switches, and what the sterowanie + // includes hang their events off — `zwr01+` throws the one named zwr01 + char number[16]; + std::snprintf(number, sizeof(number), "zwr%02d", result.switches + 1); + const std::string name = names.mint(number); + // a switch is one curve per route in the scenery and a turnout is + // four pieces of geometry, so what the shortcut costs is written + // down beside it rather than left to be found by measuring + out << "// " << span.placement->type << ", tor zwrotny R " << fixed(radius, 1) + << " m, odchyłka od położonej osi " << fixed(strayed, 3) << " m\n"; + write_track_node(out, name, "switch", turnout.through_length, + {straight, diverging}); + if (opt.switch_control) { + const auto* type = find_type(document, span.placement->type); + const double nominal = type != nullptr ? catalogue_radius(*type) : radius; + out << "include " + << switch_include(nominal, span.placement->hand) << ' ' << name << ' ' + << fixed(straight.p1.x, 4) << " 0.0 " << fixed(straight.p1.z, 4) << ' ' + << fixed(heading_degrees(turnout.start), 1) << " end\n"; + // t and shift+t at the blades: the game has no switch key of its own, + // it looks for a launcher standing close enough to the camera. the + // includes carry one commented out, so the scenery brings its own + out << "node -1 0 " << name << "_kl eventlauncher " << fixed(straight.p1.x, 4) + << " 0.0 " << fixed(straight.p1.z, 4) << ' ' + << fixed(opt.switch_key_reach, 1) << " t 0 " << name << '+' << ' ' << name + << "- end\n\n"; + } + endings.push_back(Ending{blade.at, name, true}); + endings.push_back(Ending{straight_end.at, name, true}); + endings.push_back(Ending{frog.at, name, true}); + ++result.switches; + }; + + double cursor = 0.0; + for (const auto& span : spans) { + write_range(cursor, std::max(cursor, span.from)); + write_switch(span); + cursor = std::max(cursor, span.to); + } + write_range(cursor, track.length); + } + + // Nothing links nodes but their endpoints standing together, and only plain + // track goes looking for a neighbour. Two switches meeting head on is a + // drawing the scenery cannot hold, so it is said rather than left to be + // found by driving into it. + for (std::size_t i = 0; i < endings.size(); ++i) { + if (!endings[i].is_switch) { + continue; + } + for (std::size_t k = i + 1; k < endings.size(); ++k) { + if (!endings[k].is_switch || endings[k].node == endings[i].node) { + continue; + } + if (std::abs(endings[i].at.x - endings[k].at.x) < kJoin && + std::abs(endings[i].at.y - endings[k].at.y) < kJoin) { + result.warnings.push_back( + "zwrotnice „" + endings[i].node + "” i „" + endings[k].node + + "” stykają się końcami: symulator łączy tylko przez zwykły tor, " + "wstaw między nie odcinek"); } } } - out << "FirstInit\n\n"; - if (!result.first_track_name.empty()) { - out << "trainset none " << result.first_track_name << " 5 0\n"; - out << "node -1 0 plan_eu07 dynamic PKP\\303E_V1 303E-EP-TV-424-HIST " - "303E-EP-TV 0 headdriver 35.WH25 0 enddynamic\n"; - out << "endtrainset\n"; + if (result.first_track_name.empty()) { + result.first_track_name = spare_track; + } + if (opt.trainset) { + out << "FirstInit\n\n"; + if (!result.first_track_name.empty()) { + out << "trainset none " << result.first_track_name << ' ' + << fixed(opt.trainset_offset, 1) << " 0\n"; + out << "node -1 0 plan_eu07 dynamic PKP\\303E_V1 303E-EP-TV-424-HIST " + "303E-EP-TV 0 headdriver 35.WH25 0 enddynamic\n"; + out << "endtrainset\n"; + } } return result; } diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt index dabea4e8..e7503354 100644 --- a/maj0sted/tests/CMakeLists.txt +++ b/maj0sted/tests/CMakeLists.txt @@ -28,6 +28,16 @@ target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted) target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME turnout COMMAND maj0sted_test_turnout) +add_executable(maj0sted_test_scn test_scn.cpp) +target_link_libraries(maj0sted_test_scn PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_scn PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME scn COMMAND maj0sted_test_scn) + +add_executable(maj0sted_test_join test_join.cpp) +target_link_libraries(maj0sted_test_join PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_join PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME join COMMAND maj0sted_test_join) + 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}) diff --git a/maj0sted/tests/test_format.cpp b/maj0sted/tests/test_format.cpp index 6696fed7..eb901261 100644 --- a/maj0sted/tests/test_format.cpp +++ b/maj0sted/tests/test_format.cpp @@ -74,6 +74,8 @@ Document sample() { document.georeferenced = true; document.origin_x = 6540000.25; document.origin_y = 5760000.5; + // an absolute path into another MaSzyna, spaces and all + document.scn_path = "/home/kto to/MaSzyna/scenery/plan export.scn"; return document; } @@ -167,6 +169,8 @@ void every_field_survives() { CHECK(reloaded->origin_x == 6540000.25); CHECK(reloaded->view_extent == 2000.0); CHECK(reloaded->georeferenced); + // and where the plan is exported to, which says which MaSzyna it is drawn for + CHECK(reloaded->scn_path == "/home/kto to/MaSzyna/scenery/plan export.scn"); // Ids keep going where they left off, so nothing minted later collides. CHECK(reloaded->next_id == document.next_id); } @@ -183,7 +187,7 @@ void an_older_file_is_refused() { "tracks 0\n"; CHECK(!maj0sted::io::deserialize(version_2).has_value()); CHECK(!maj0sted::io::deserialize("").has_value()); - CHECK(!maj0sted::io::deserialize("m0s 7\n").has_value()); + CHECK(!maj0sted::io::deserialize("m0s 8\n").has_value()); } void a_file_from_before_lukowanie_still_opens() { @@ -202,7 +206,24 @@ void a_file_from_before_lukowanie_still_opens() { CHECK(document->turnouts[0].station == 100.0); CHECK(document->turnouts[0].bend_from_track); CHECK(document->turnouts[0].bend == 0.0); - CHECK(maj0sted::io::serialize(*document).find("m0s 6") != std::string::npos); + CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos); +} + +void a_file_from_before_the_export_path_still_opens() { + // Version 6 said nothing about where a plan exported to, so it opens with + // nothing said and the host falls back on its own answer. + const std::string version_6 = + "m0s 6\nnext 3\nview 0 0 0\norigin 0 0 0 0\ntypes 0\nturnouts 0\n" + "tracks 1\ntrack 1\ntkname tor\nanchor pose 0 0 0\nelements 1\n" + "element 2 line 0 0 250\n"; + const auto document = maj0sted::io::deserialize(version_6); + CHECK(document.has_value()); + if (!document) { + return; + } + CHECK(document->scn_path.empty()); + CHECK(document->tracks.size() == 1); + CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos); } void a_file_from_before_the_nose_still_opens() { @@ -225,7 +246,7 @@ void a_file_from_before_the_nose_still_opens() { CHECK(document->turnout_types[0].pieces.size() == 1); // and it is written back in the new form, with a place for the nose - CHECK(maj0sted::io::serialize(*document).find("m0s 6") != std::string::npos); + CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos); } void an_old_file_upgrades_to_the_piece_list() { diff --git a/maj0sted/tests/test_lines.cpp b/maj0sted/tests/test_lines.cpp index 27ca9066..2d730812 100644 --- a/maj0sted/tests/test_lines.cpp +++ b/maj0sted/tests/test_lines.cpp @@ -471,6 +471,92 @@ void a_chain_starting_on_a_curve_carries_its_head_along() { CHECK(near(again->elements[2].k0, 1.0 / 250.0, 1e-12)); } +/// The same chain, but hanging off a turnout's frog. Now the head corner may not +/// be slid anywhere — it leaves the frog, and that is that — so the line under +/// the straight behind it follows wherever the curve comes out, and the straight +/// gives up the difference in its length. Which is the only thing here that can. +void a_branch_off_a_frog_lets_its_first_straight_give() { + Document document; + + TurnoutType type; + type.name = "Rz 1:9 R190"; + type.crossing_n = 9.0; + type.length = 27.138; + type.blade = BladeSpec{0.005, 0.070}; + type.pieces = { + TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0}, + }; + document.turnout_types.push_back(type); + + const TrackId main = add_track(document, {line(document, 1000.0)}); + + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = type.name; + placement.on = main; + placement.station = 300.0; + placement.hand = -1; + document.turnouts.push_back(placement); + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.anchor = AtPort{placement.id, Port::Frog}; + // straight off the frog it is not: the branch carries on curving, then runs out + branch.elements = {arc(document, 300.0, -1, 120.0), line(document, 400.0), + arc(document, 500.0, 1, 150.0), line(document, 300.0)}; + document.tracks.push_back(branch); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, branch.id); + const SolvedTurnout* turnout = find_turnout(before, placement.id); + CHECK(laid != nullptr && turnout != nullptr && before.diagnostics.empty()); + if (laid == nullptr || turnout == nullptr) { + return; + } + const double straight_before = document.tracks[1].elements[1].length; + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[1], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.pinned); + + // Open out the curve leaving the frog. This used to be refused outright. + document.tracks[1].elements[0].radius = 400.0; + const bool relaid = lay_along_skeleton(document.tracks[1], skeleton, why); + CHECK(relaid); + if (!relaid) { + std::printf(" why: %s\n", why.c_str()); + return; + } + + // The head kept its length — it is authored, not fitted — and the straight + // after it took up what the wider curve cost. + CHECK(near(document.tracks[1].elements[0].length, 120.0, 1e-12)); + CHECK(std::abs(document.tracks[1].elements[1].length - straight_before) > 1.0); + + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + const SolvedTrack* again = find_track(after, branch.id); + CHECK(again != nullptr); + if (again == nullptr) { + return; + } + // Still on the frog, and the far end still runs where it ran. + CHECK(near(again->start.x, turnout->frog.x, 1e-9)); + CHECK(near(again->start.y, turnout->frog.y, 1e-9)); + CHECK(near(again->elements[3].start.hx, laid->elements[3].start.hx, 1e-9)); + CHECK(near(again->elements[3].start.hy, laid->elements[3].start.hy, 1e-9)); +} + /// A transition curve's radius is the one it ends on, so it belongs to what /// comes after it. The transition running out of an arc into a straight ends /// flat, and no amount of editing the arc may put the arc's radius on it — that @@ -543,6 +629,7 @@ int main() { RUN(a_new_radius_is_absorbed_by_its_own_corner); RUN(a_chain_ending_on_a_curve_still_re_fits); RUN(a_chain_starting_on_a_curve_carries_its_head_along); + RUN(a_branch_off_a_frog_lets_its_first_straight_give); RUN(a_joint_only_ever_looks_forward); RUN(touching_straights_are_reported); return REPORT(); From 3bdd5d8e90ee72ad675244f67379fd737ddb746c Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 13 Sep 2026 12:44:48 +0200 Subject: [PATCH 17/17] start without scenery flag --- maj0sted/include/maj0sted/editor/join.hpp | 61 +++++++++++++++++++++++ maj0sted/src/editor/join.cpp | 51 +++++++++++++++++++ simulation/simulationstateserializer.cpp | 13 ++++- simulation/simulationstateserializer.h | 2 + 4 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 maj0sted/include/maj0sted/editor/join.hpp create mode 100644 maj0sted/src/editor/join.cpp diff --git a/maj0sted/include/maj0sted/editor/join.hpp b/maj0sted/include/maj0sted/editor/join.hpp new file mode 100644 index 00000000..72fe2500 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/join.hpp @@ -0,0 +1,61 @@ +#pragma once + +// TYMCZASOWA ZASLEPKA. Modul scalania koncow (plan_join / join_ends) nie zostal nigdy +// zacommitowany: commit 4ddfeb02 dopisal `src/editor/join.cpp` do CMakeLists i `#include` w +// planpanel.cpp, ale samych plikow nie ma ani w drzewie, ani w historii, ani w stashach. Bez +// tego naglowka i join.cpp branch sie nie kompiluje wcale. Ta zaslepka odtwarza dokladnie to +// API, ktorego uzywa panel planu, i mowi "niedostepne" - zeby reszta edytora dala sie uruchomic. +// Do zastapienia prawdziwym rozwiazaniem geometrii (luk-prosta-luk) i chirurgia dokumentu. + +#include +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" // Pose +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// Co stanelo miedzy dwoma koncami. +enum class JoinKind { + None, + Straight, + Arc, + ArcLineArc, + ArcArc, +}; + +[[nodiscard]] const char* join_kind_name(JoinKind kind); + +/// Czym wolno gietc to, co lezy miedzy koncami. +struct JoinSettings { + double radius{300.0}; +}; + +/// Propozycja, przed tknieciem dokumentu. +struct JoinPlan { + bool ok{false}; + std::string why{}; + JoinKind kind{JoinKind::None}; + std::vector elements{}; + double tightest{0.0}; +}; + +[[nodiscard]] JoinPlan plan_join(const domain::geometry::Pose& leaving, + const domain::geometry::Pose& arriving, + const JoinSettings& settings); + +/// Co scalenie kosztowalo. +struct JoinReport { + bool ok{false}; + std::string why{}; + JoinPlan join{}; + int fused{0}; + int turnouts{0}; + bool reversed{false}; +}; + +[[nodiscard]] JoinReport join_ends(Document& document, const Solution& solution, TrackId a, + int a_end, TrackId b, int b_end, const JoinSettings& settings); + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/join.cpp b/maj0sted/src/editor/join.cpp new file mode 100644 index 00000000..16b38855 --- /dev/null +++ b/maj0sted/src/editor/join.cpp @@ -0,0 +1,51 @@ +// TYMCZASOWA ZASLEPKA - patrz komentarz w join.hpp. + +#include "maj0sted/editor/join.hpp" + +namespace maj0sted::editor { + +namespace { +constexpr const char* kMissing = "scalanie koncow niedostepne: brak modulu join"; +} // namespace + +const char* join_kind_name(JoinKind kind) { + switch (kind) { + case JoinKind::Straight: + return "prosta"; + case JoinKind::Arc: + return "luk"; + case JoinKind::ArcLineArc: + return "luk-prosta-luk"; + case JoinKind::ArcArc: + return "luk-luk"; + case JoinKind::None: + break; + } + return "nic"; +} + +JoinPlan plan_join(const domain::geometry::Pose& leaving, const domain::geometry::Pose& arriving, + const JoinSettings& settings) { + (void)leaving; + (void)arriving; + (void)settings; + JoinPlan plan; + plan.why = kMissing; + return plan; +} + +JoinReport join_ends(Document& document, const Solution& solution, TrackId a, int a_end, TrackId b, + int b_end, const JoinSettings& settings) { + (void)document; + (void)solution; + (void)a; + (void)a_end; + (void)b; + (void)b_end; + (void)settings; + JoinReport report; + report.why = kMissing; + return report; +} + +} // namespace maj0sted::editor diff --git a/simulation/simulationstateserializer.cpp b/simulation/simulationstateserializer.cpp index e2ab7883..cc0edae8 100644 --- a/simulation/simulationstateserializer.cpp +++ b/simulation/simulationstateserializer.cpp @@ -76,8 +76,16 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) { } scene::Groups.create(); - if( false == state->input.ok() ) - throw invalid_scenery_exception(); + if( false == state->input.ok() ) { + if( false == Global.editor_startup ) { + throw invalid_scenery_exception(); + } + // the editor is allowed to work on ground that has no scenery file yet: starting with -editor, + // or beginning a new map, names a file which will only exist once it gets saved. an unreadable + // scenery means an empty world here, not a failed load + WriteLog( "Scenery \"" + Scenariofile + "\" not found, the editor starts on empty ground" ); + state->blank = true; + } // prepare deserialization function table // since all methods use the same objects, we can have simple, hard-coded binds or lambdas for the task @@ -164,6 +172,7 @@ state_serializer::deserialize_continue(std::shared_ptr state if( true == Global.file_binary_terrain && false == state->scratchpad.binary.terrain + && false == state->blank && state->scenariofile != "$.scn" ) { // if we didn't find usable binary version of the scenario files, create them now for future use // as long as the scenario file wasn't rainsted-created base file override diff --git a/simulation/simulationstateserializer.h b/simulation/simulationstateserializer.h index a5d6f2dc..ea71a3c7 100644 --- a/simulation/simulationstateserializer.h +++ b/simulation/simulationstateserializer.h @@ -16,6 +16,8 @@ namespace simulation { struct deserializer_state { std::string scenariofile; + // the named scenery has no file on disk: the world is empty, so there's nothing to compile to SBT + bool blank{ false }; cParser input; scene::scratch_data scratchpad; using deserializefunctionbind = std::function;