17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-11-05 19:29:11 +01:00

Vendor the maj0sted track layout library and link it into the editor

Drop-in copy of C:\Users\maj00r\Projects\maj0sted-master minus its web/
directory - the WASM frontend is not being ported, the editor drives the
same C++ API natively. What is left is plain C++20 with no dependencies:
domain model (niweleta, plan and profile geometry, fitting, parallelism),
project serialization, rail sampling and the editor document.

src/web/ribbon.cpp was missing from the library source list; added, since
without it the ribbon symbols never make it into the archive.
This commit is contained in:
maj00r
2026-07-31 18:27:02 +02:00
committed by maj00r
parent 9a0b63a97c
commit a083269867
72 changed files with 6344 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
add_executable(maj0sted_test_map_project test_map_project.cpp)
target_link_libraries(maj0sted_test_map_project PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_map_project PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME map_project COMMAND maj0sted_test_map_project)
add_executable(maj0sted_test_niweleta test_niweleta.cpp)
target_link_libraries(maj0sted_test_niweleta PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_niweleta PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME niweleta COMMAND maj0sted_test_niweleta)
add_executable(maj0sted_test_plan_alignment test_plan_alignment.cpp)
target_link_libraries(maj0sted_test_plan_alignment PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_plan_alignment PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME plan_alignment COMMAND maj0sted_test_plan_alignment)
add_executable(maj0sted_test_parallelism test_parallelism.cpp)
target_link_libraries(maj0sted_test_parallelism PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_parallelism PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME parallelism COMMAND maj0sted_test_parallelism)
add_executable(maj0sted_test_fitting test_fitting.cpp)
target_link_libraries(maj0sted_test_fitting PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_fitting PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME fitting COMMAND maj0sted_test_fitting)
add_executable(maj0sted_test_serialization test_serialization.cpp)
target_link_libraries(maj0sted_test_serialization PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_serialization PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME serialization COMMAND maj0sted_test_serialization)
add_executable(maj0sted_test_render test_render.cpp)
target_link_libraries(maj0sted_test_render PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_render PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME render COMMAND maj0sted_test_render)
add_executable(maj0sted_test_web test_web.cpp)
target_link_libraries(maj0sted_test_web PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_web PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME web COMMAND maj0sted_test_web)
add_executable(maj0sted_test_tile_cache test_tile_cache.cpp)
target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME tile_cache COMMAND maj0sted_test_tile_cache)
add_executable(maj0sted_test_document test_document.cpp)
target_link_libraries(maj0sted_test_document PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_document PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME document COMMAND maj0sted_test_document)

51
maj0sted/tests/check.hpp Normal file
View File

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

View File

@@ -0,0 +1,123 @@
#include <filesystem>
#include <string>
#include "check.hpp"
#include "maj0sted/app/editor_document.hpp"
using namespace maj0sted::app;
using maj0sted::web::GapFit;
using maj0sted::web::NiweletaSpec;
using maj0sted::web::StraightSpec;
namespace {
EditorDocument sample_document() {
EditorDocument doc;
NiweletaSpec a;
a.name = "Niweleta 1";
a.straights = {StraightSpec{0.0, 0.0, 120.0, 0.0},
StraightSpec{120.0, 0.0, 120.0, 120.0, true}};
GapFit fit;
fit.gap = 0;
fit.mode = 2; // arc + transitions
fit.radius = 40.0;
fit.transition = 12.5;
a.fits = {fit};
NiweletaSpec b;
b.name = "Bocznica z spacją";
b.straights = {StraightSpec{500000.25, 300000.75, 500200.0, 300000.0}};
doc.niwelety = {a, b};
return doc;
}
bool same(const StraightSpec& x, const StraightSpec& y) {
return x.x1 == y.x1 && x.y1 == y.y1 && x.x2 == y.x2 && x.y2 == y.y2 &&
x.hidden == y.hidden;
}
bool same(const GapFit& x, const GapFit& y) {
return x.gap == y.gap && x.mode == y.mode && x.radius == y.radius &&
x.transition == y.transition && x.r1 == y.r1 && x.arc1_len == y.arc1_len &&
x.between == y.between && x.r2 == y.r2 && x.entry_t == y.entry_t &&
x.exit_t == y.exit_t;
}
void check_equal(const EditorDocument& x, const EditorDocument& y) {
CHECK(x.niwelety.size() == y.niwelety.size());
if (x.niwelety.size() != y.niwelety.size()) return;
for (std::size_t n = 0; n < x.niwelety.size(); ++n) {
const auto& a = x.niwelety[n];
const auto& b = y.niwelety[n];
CHECK(a.name == b.name);
CHECK(a.straights.size() == b.straights.size());
for (std::size_t i = 0; i < a.straights.size() && i < b.straights.size(); ++i) {
CHECK(same(a.straights[i], b.straights[i]));
}
CHECK(a.fits.size() == b.fits.size());
for (std::size_t i = 0; i < a.fits.size() && i < b.fits.size(); ++i) {
CHECK(same(a.fits[i], b.fits[i]));
}
}
}
void serialize_round_trips_exactly() {
const EditorDocument doc = sample_document();
const std::string text = serialize_document(doc);
const EditorDocument back = deserialize_document(text);
check_equal(doc, back);
// The file really embeds a maj0sted project (reuses io::serialize).
CHECK(text.rfind("maj0sted", 0) == 0);
CHECK(text.find("\neditor 2\n") != std::string::npos);
// Serialization is stable (byte-for-byte) across a round trip.
CHECK(serialize_document(back) == text);
}
void embedded_project_is_valid_and_readable() {
const EditorDocument doc = sample_document();
// to_map_project keeps one niweleta per editor niweleta and the straights.
const auto project = to_map_project(doc);
CHECK(project.niwelety().size() == 2);
const EditorDocument imported = from_map_project(project);
CHECK(imported.niwelety.size() == 2);
CHECK(imported.niwelety[0].straights.size() == 2);
CHECK(imported.niwelety[0].name == "Niweleta 1");
}
void save_then_load_from_disk() {
const EditorDocument doc = sample_document();
const std::filesystem::path dir =
std::filesystem::temp_directory_path() / "maj0sted_test";
std::filesystem::remove_all(dir);
const std::string path = (dir / "sub" / "proj.m0s").string();
CHECK(save_project(doc, path)); // creates parent dirs
CHECK(std::filesystem::exists(path));
const auto loaded = load_project(path);
CHECK(loaded.has_value());
if (loaded) check_equal(doc, *loaded);
std::filesystem::remove_all(dir);
}
void load_missing_file_is_nullopt() {
const std::string path =
(std::filesystem::temp_directory_path() / "maj0sted_nope_12345.m0s").string();
std::filesystem::remove(path);
CHECK(!load_project(path).has_value());
}
} // namespace
int main() {
RUN(serialize_round_trips_exactly);
RUN(embedded_project_is_valid_and_readable);
RUN(save_then_load_from_disk);
RUN(load_missing_file_is_nullopt);
return REPORT();
}

View File

@@ -0,0 +1,346 @@
#include <cmath>
#include <numbers>
#include <variant>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
// A 90-degree left corner: entry heads east into (100,0), exit leaves north.
Niweleta make_corner() {
Niweleta n{NiweletaId{1}, "Corner"};
n.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}});
n.add_plan_element(Straight{StraightId{2}, CartesianPosition{100.0, 0.0},
CartesianPosition{100.0, 100.0}},
JointContinuity::AzimuthBreak);
return n;
}
void fit_plain_arc() {
const Niweleta corner = make_corner();
const FitParameters params{.entry = StraightId{1},
.exit = StraightId{2},
.radius = Radius::from_metres(50.0),
.transition_length = std::nullopt};
const FitResult fit = FittingService::fit_between_straights(corner, params);
CHECK(fit.direction == TurnDirection::Left);
// Tangent length = R*tan(45) = 50 -> tangent points at (50,0) and (100,50).
CHECK(std::abs(fit.tangent_in.x() - 50.0) < 1e-6);
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
CHECK(std::abs(fit.tangent_out.y() - 50.0) < 1e-6);
// The trimmed straights keep their ids and outer endpoints.
CHECK(fit.entry.id() == StraightId{1});
CHECK(std::abs(fit.entry.end().x() - 50.0) < 1e-6);
CHECK(fit.exit.id() == StraightId{2});
CHECK(std::abs(fit.exit.start().y() - 50.0) < 1e-6);
CHECK(std::abs(fit.exit.end().y() - 100.0) < 1e-6);
// A single circular arc, length R*delta = 50*pi/2.
CHECK(fit.curve.size() == 1);
const auto* arc = std::get_if<CircularArc>(&fit.curve.front());
CHECK(arc != nullptr);
CHECK(arc->direction() == TurnDirection::Left);
CHECK(arc->length().has_value());
CHECK(std::abs(arc->length().value().metres() - 50.0 * std::numbers::pi / 2.0) < 1e-6);
}
void fit_with_symmetric_transitions() {
const Niweleta corner = make_corner();
const FitParameters params{.entry = StraightId{1},
.exit = StraightId{2},
.radius = Radius::from_metres(50.0),
.transition_length = Length::from_metres(10.0)};
const FitResult fit = FittingService::fit_between_straights(corner, params);
// klotoida -> łuk -> klotoida
CHECK(fit.curve.size() == 3);
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[0]));
CHECK(std::holds_alternative<CircularArc>(fit.curve[1]));
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[2]));
// Entry transition runs from an infinite radius (straight) into R.
const auto& entry_kp = std::get<TransitionCurve>(fit.curve[0]);
CHECK(!entry_kp.start_radius().has_value());
CHECK(entry_kp.end_radius().has_value());
CHECK(entry_kp.length().metres() == 10.0);
// Arc central angle = delta - 2*tau, tau = Lk/(2R) = 0.1 -> arc len = 50*(pi/2 - 0.2).
const auto& arc = std::get<CircularArc>(fit.curve[1]);
const double expected = 50.0 * (std::numbers::pi / 2.0 - 0.2);
CHECK(arc.length().has_value());
CHECK(std::abs(arc.length().value().metres() - expected) < 1e-6);
// Entry tangent point from the exact clothoid layout (not the L^2/24R
// approximation, which was ~1.8 mm off and caused a visible lateral gap).
CHECK(std::abs(fit.tangent_in.x() - 44.9185491305) < 1e-6);
}
void invalid_inputs_are_rejected() {
const Niweleta corner = make_corner();
const Radius r = Radius::from_metres(50.0);
// Same straight.
CHECK_THROWS(FittingService::fit_between_straights(
corner, FitParameters{StraightId{1}, StraightId{1}, r, std::nullopt}));
// Unknown id.
CHECK_THROWS(FittingService::fit_between_straights(
corner, FitParameters{StraightId{1}, StraightId{99}, r, std::nullopt}));
// Collinear straights -> nothing to fit.
Niweleta line{NiweletaId{2}};
line.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{50.0, 0.0}});
line.add_plan_element(Straight{StraightId{2}, CartesianPosition{50.0, 0.0},
CartesianPosition{100.0, 0.0}});
CHECK_THROWS(FittingService::fit_between_straights(
line, FitParameters{StraightId{1}, StraightId{2}, r, std::nullopt}));
// Transitions too long for this radius/deflection.
CHECK_THROWS(FittingService::fit_between_straights(
corner, FitParameters{StraightId{1}, StraightId{2}, r,
Length::from_metres(200.0)}));
}
void fit_compound_curve_two_arcs() {
const Niweleta corner = make_corner(); // 90-degree left corner, IP at (100,0)
// First arc delta1 = 45 deg -> length = R1 * pi/4 = 60 * pi/4; last arc computed.
const CompoundFitParameters params{
.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {CompoundArc{Radius::from_metres(60.0),
Length::from_metres(60.0 * std::numbers::pi / 4.0)},
CompoundArc{Radius::from_metres(40.0), std::nullopt}}};
const FitResult fit = FittingService::fit_compound(corner, params);
CHECK(fit.direction == TurnDirection::Left);
CHECK(fit.curve.size() == 2);
const auto& arc1 = std::get<CircularArc>(fit.curve[0]);
const auto& arc2 = std::get<CircularArc>(fit.curve[1]);
CHECK(arc1.radius().metres() == 60.0);
CHECK(arc2.radius().metres() == 40.0);
// Same hand-computed geometry as before (rigid-chain solver reproduces it).
CHECK(std::abs(arc1.length().value().metres() - 60.0 * std::numbers::pi / 4.0) < 1e-6);
CHECK(std::abs(arc2.length().value().metres() - 40.0 * std::numbers::pi / 4.0) < 1e-6);
CHECK(std::abs(fit.tangent_in.x() - 45.85786) < 1e-4);
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-4);
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-4);
CHECK(std::abs(fit.tangent_out.y() - 45.85786) < 1e-4);
}
void fit_compound_curve_three_arcs() {
const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100
// 30 + 30 + (remaining 30) degrees, radii 80 / 50 / 30.
const CompoundFitParameters params{
.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {CompoundArc{Radius::from_metres(80.0),
Length::from_metres(80.0 * std::numbers::pi / 6.0)},
CompoundArc{Radius::from_metres(50.0),
Length::from_metres(50.0 * std::numbers::pi / 6.0)},
CompoundArc{Radius::from_metres(30.0), std::nullopt}}};
const FitResult fit = FittingService::fit_compound(corner, params);
CHECK(fit.curve.size() == 3);
CHECK(std::get<CircularArc>(fit.curve[0]).radius().metres() == 80.0);
CHECK(std::get<CircularArc>(fit.curve[1]).radius().metres() == 50.0);
CHECK(std::get<CircularArc>(fit.curve[2]).radius().metres() == 30.0);
// Last arc takes the remaining 30 deg -> length = 30 * pi/6.
CHECK(std::abs(std::get<CircularArc>(fit.curve[2]).length().value().metres() -
30.0 * std::numbers::pi / 6.0) < 1e-6);
// The chain closes onto both fixed lines: tangent_in on y=0, tangent_out on x=100.
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
CHECK(fit.direction == TurnDirection::Left);
}
void fit_compound_with_transitions() {
const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100
const CompoundFitParameters params{
.entry = StraightId{1},
.exit = StraightId{2},
.entry_transition = Length::from_metres(10.0),
.exit_transition = Length::from_metres(10.0),
.arcs = {CompoundArc{Radius::from_metres(60.0), Length::from_metres(20.0),
Length::from_metres(8.0)}, // arc(60), KP 60->40 of 8 m
CompoundArc{Radius::from_metres(40.0), std::nullopt, std::nullopt}}};
const FitResult fit = FittingService::fit_compound(corner, params);
// Sequence: KP -> arc(60) -> KP(60->40) -> arc(40) -> KP.
CHECK(fit.curve.size() == 5);
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[0]));
CHECK(std::holds_alternative<CircularArc>(fit.curve[1]));
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[2]));
CHECK(std::holds_alternative<CircularArc>(fit.curve[3]));
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[4]));
// Entry transition: straight (inf) -> R60.
const auto& kp_in = std::get<TransitionCurve>(fit.curve[0]);
CHECK(!kp_in.start_radius().has_value());
CHECK(kp_in.end_radius().value().metres() == 60.0);
CHECK(kp_in.length().metres() == 10.0);
// Between transition: R60 -> R40.
const auto& kp_mid = std::get<TransitionCurve>(fit.curve[2]);
CHECK(kp_mid.start_radius().value().metres() == 60.0);
CHECK(kp_mid.end_radius().value().metres() == 40.0);
CHECK(kp_mid.length().metres() == 8.0);
// Exit transition: R40 -> straight (inf).
const auto& kp_out = std::get<TransitionCurve>(fit.curve[4]);
CHECK(kp_out.start_radius().value().metres() == 40.0);
CHECK(!kp_out.end_radius().has_value());
// The chain still closes onto both fixed lines.
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
CHECK(fit.direction == TurnDirection::Left);
}
void compound_invalid_inputs_are_rejected() {
const Niweleta corner = make_corner();
const auto arc = [](double r, double len) {
return CompoundArc{Radius::from_metres(r), Length::from_metres(len)};
};
const auto last = [](double r) { return CompoundArc{Radius::from_metres(r)}; };
// Equal neighbouring radii.
CHECK_THROWS(FittingService::fit_compound(
corner, CompoundFitParameters{.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {arc(50.0, 10.0), last(50.0)}}));
// Split lengths exceed the total deflection.
CHECK_THROWS(FittingService::fit_compound(
corner, CompoundFitParameters{.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {arc(60.0, 1000.0), last(40.0)}}));
// A non-last arc without a length.
CHECK_THROWS(FittingService::fit_compound(
corner, CompoundFitParameters{.entry = StraightId{1},
.exit = StraightId{2},
.arcs = {last(60.0), last(40.0)}}));
}
void fit_equal_radius_reverse_curve_between_parallel_straights() {
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{200.0, 0.0}};
const Straight exit{StraightId{2}, CartesianPosition{0.0, 20.0},
CartesianPosition{200.0, 20.0}};
const FitResult fit =
FittingService::fit_reverse(entry, exit, Radius::from_metres(50.0));
CHECK(fit.curve.size() == 2);
const auto& first = std::get<CircularArc>(fit.curve[0]);
const auto& second = std::get<CircularArc>(fit.curve[1]);
CHECK(first.radius() == second.radius());
CHECK(first.radius() == Radius::from_metres(50.0));
CHECK(first.direction() != second.direction());
CHECK(first.length().has_value());
CHECK(second.length().has_value());
CHECK(std::abs(first.length()->metres() - second.length()->metres()) < 1e-9);
CHECK(std::abs(fit.tangent_in.y()) < 1e-9);
CHECK(std::abs(fit.tangent_out.y() - 20.0) < 1e-6);
CHECK(fit.tangent_in.x() > 0.0 && fit.tangent_in.x() <= 200.0);
CHECK(fit.tangent_out.x() >= 0.0 && fit.tangent_out.x() < 200.0);
}
void reverse_curve_rejects_collinear_and_antiparallel_straights() {
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
const Straight collinear{StraightId{2}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
const Straight antiparallel{StraightId{3}, CartesianPosition{100.0, 20.0},
CartesianPosition{0.0, 20.0}};
const Radius radius = Radius::from_metres(30.0);
CHECK_THROWS(FittingService::fit_reverse(entry, collinear, radius));
CHECK_THROWS(FittingService::fit_reverse(entry, antiparallel, radius));
}
void fit_reverse_curve_between_nonparallel_straights() {
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
// This exit line is constructed around the end pose of a R=30 m chain:
// left 60 degrees followed by right 30 degrees.
const double heading = std::numbers::pi / 6.0;
const double tangent_x = 86.9615242271;
const double tangent_y = 25.9807621135;
const double dx = std::cos(heading);
const double dy = std::sin(heading);
const Straight exit{
StraightId{2},
CartesianPosition{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy},
CartesianPosition{tangent_x + 70.0 * dx, tangent_y + 70.0 * dy}};
const FitResult fit =
FittingService::fit_reverse(entry, exit, Radius::from_metres(30.0));
CHECK(fit.curve.size() == 2);
const auto& first = std::get<CircularArc>(fit.curve[0]);
const auto& second = std::get<CircularArc>(fit.curve[1]);
CHECK(first.direction() != second.direction());
CHECK(first.radius() == second.radius());
CHECK(std::abs(fit.tangent_in.y()) < 1e-6);
const double exit_cross =
(fit.tangent_out.x() - exit.start().x()) * dy -
(fit.tangent_out.y() - exit.start().y()) * dx;
CHECK(std::abs(exit_cross) < 1e-5);
}
void apply_fit_replaces_straights_in_plan() {
Niweleta corner = make_corner(); // straight(1) --break--> straight(2)
const FitParameters params{.entry = StraightId{1},
.exit = StraightId{2},
.radius = Radius::from_metres(50.0),
.transition_length = std::nullopt};
const FitResult fit = FittingService::fit_between_straights(corner, params);
corner.apply_fit(fit);
// Plan is now: straight(1) -> arc -> straight(2), tangent joints, no break.
CHECK(corner.plan().size() == 3);
CHECK(std::holds_alternative<Straight>(corner.plan().elements()[0]));
CHECK(std::holds_alternative<CircularArc>(corner.plan().elements()[1]));
CHECK(std::holds_alternative<Straight>(corner.plan().elements()[2]));
CHECK(corner.plan().joint(0) == JointContinuity::Tangent);
CHECK(corner.plan().joint(1) == JointContinuity::Tangent);
// Straights keep their ids and are trimmed to the tangent points.
const auto& first = std::get<Straight>(corner.plan().elements()[0]);
const auto& last = std::get<Straight>(corner.plan().elements()[2]);
CHECK(first.id() == StraightId{1});
CHECK(last.id() == StraightId{2});
CHECK(std::abs(first.end().x() - 50.0) < 1e-6);
CHECK(std::abs(last.start().y() - 50.0) < 1e-6);
}
} // namespace
int main() {
RUN(fit_plain_arc);
RUN(fit_with_symmetric_transitions);
RUN(invalid_inputs_are_rejected);
RUN(fit_compound_curve_two_arcs);
RUN(fit_compound_curve_three_arcs);
RUN(fit_compound_with_transitions);
RUN(compound_invalid_inputs_are_rejected);
RUN(fit_equal_radius_reverse_curve_between_parallel_straights);
RUN(reverse_curve_rejects_collinear_and_antiparallel_straights);
RUN(fit_reverse_curve_between_nonparallel_straights);
RUN(apply_fit_replaces_straights_in_plan);
return REPORT();
}

View File

@@ -0,0 +1,25 @@
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
int main() {
// A map project defaults to the Cartesian CRS EPSG:2180.
const MapProject project;
CHECK(project.crs() == Crs::default_crs());
CHECK(project.crs().epsg() == 2180);
// An explicit CRS is honoured.
const MapProject wgs84{Crs{4326}};
CHECK(wgs84.crs().epsg() == 4326);
// Cartesian position value object.
const CartesianPosition p{100.5, 200.25};
CHECK(p.x() == 100.5);
CHECK(p.y() == 200.25);
CHECK(CartesianPosition{1.0, 2.0} == CartesianPosition{1.0, 2.0});
CHECK(CartesianPosition{1.0, 2.0} != CartesianPosition{2.0, 1.0});
CHECK(CartesianPosition{}.x() == 0.0);
return REPORT();
}

View File

@@ -0,0 +1,110 @@
#include <variant>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
void plan_and_profile_element_lengths() {
const PlanElement straight =
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
const PlanElement arc = CircularArc{Radius::from_metres(300.0),
TurnDirection::Left,
Length::from_metres(50.0)};
const PlanElement transition = TransitionCurve{
Length::from_metres(30.0), std::nullopt, Radius::from_metres(300.0),
TurnDirection::Left};
CHECK(length_of(straight).value().metres() == 100.0);
CHECK(length_of(arc).value().metres() == 50.0);
CHECK(length_of(transition).value().metres() == 30.0);
const ProfileElement grade = Grade{Length::from_metres(120.0), 5.0};
const ProfileElement vcurve = VerticalCurve{
Length::from_metres(60.0), Radius::from_metres(4000.0),
VerticalCurvature::Crest};
CHECK(length_of(grade).metres() == 120.0);
CHECK(length_of(vcurve).metres() == 60.0);
}
void value_objects_guard_invariants() {
CHECK_THROWS(Length::from_metres(-1.0));
CHECK_THROWS(Radius::from_metres(0.0));
CHECK_THROWS(Radius::from_metres(-5.0));
}
void niweleta_aggregates_plan_and_profile() {
Niweleta n{NiweletaId{1}, "Odcinek 1"};
CHECK(n.name() == "Odcinek 1");
CHECK(n.plan().empty());
CHECK(n.profile().empty());
// Plan: prosta -> klotoida -> łuk -> klotoida -> prosta (razem 180 m)
n.add_plan_element(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
n.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt,
Radius::from_metres(300.0),
TurnDirection::Right});
n.add_plan_element(CircularArc{Radius::from_metres(300.0),
TurnDirection::Right,
Length::from_metres(20.0)});
n.add_plan_element(TransitionCurve{Length::from_metres(30.0),
Radius::from_metres(300.0), std::nullopt,
TurnDirection::Right});
n.add_plan_element(
Straight{CartesianPosition{100.0, 0.0}, CartesianPosition{150.0, 0.0}});
// Profil: pochylenie -> łuk pionowy -> pochylenie (razem 180 m)
n.add_profile_element(Grade{Length::from_metres(80.0), 3.0});
n.add_profile_element(VerticalCurve{Length::from_metres(40.0),
Radius::from_metres(5000.0),
VerticalCurvature::Sag});
n.add_profile_element(Grade{Length::from_metres(60.0), -2.0});
CHECK(n.plan().size() == 5);
CHECK(n.profile().size() == 3);
CHECK(n.plan_length().metres() == 180.0);
CHECK(n.profile_length().metres() == 180.0);
CHECK(n.lengths_consistent());
}
void inconsistent_lengths_are_detected() {
Niweleta n{NiweletaId{2}};
n.add_plan_element(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}});
n.add_profile_element(Grade{Length::from_metres(90.0), 0.0});
CHECK(!n.lengths_consistent());
CHECK(n.plan_length().metres() == 100.0);
CHECK(n.profile_length().metres() == 90.0);
}
void niweleta_assigns_straight_ids() {
Niweleta n{NiweletaId{3}};
// add_straight mints and returns fresh ids.
const StraightId a =
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0});
const StraightId b =
n.add_straight(CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0});
CHECK(!a.is_null());
CHECK(!b.is_null());
CHECK(a != b);
// An anonymous straight added via add_plan_element is identified too.
Niweleta m{NiweletaId{4}};
m.add_plan_element(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
const auto& element = m.plan().elements().front();
CHECK(!std::get<Straight>(element).id().is_null());
}
} // namespace
int main() {
RUN(plan_and_profile_element_lengths);
RUN(value_objects_guard_invariants);
RUN(niweleta_aggregates_plan_and_profile);
RUN(inconsistent_lengths_are_detected);
RUN(niweleta_assigns_straight_ids);
return REPORT();
}

View File

@@ -0,0 +1,85 @@
#include <cmath>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
void mark_parallel_references_two_straights() {
// Two identified straights (typically from different niwelety).
const Straight origin{StraightId{10}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
const Straight parallel{StraightId{20}, CartesianPosition{0.0, 5.0},
CartesianPosition{100.0, 5.0}};
const TrackOffset offset{Length::from_metres(5.0), Side::Left};
const auto relation = ParallelismService::mark_parallel(origin, parallel, offset);
CHECK(relation.origin() == StraightId{10});
CHECK(relation.parallel() == StraightId{20});
CHECK(relation.offset().distance().metres() == 5.0);
CHECK(relation.offset().side() == Side::Left);
}
void marking_requires_identified_distinct_straights() {
const Straight anonymous{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
const Straight a{StraightId{1}, CartesianPosition{0.0, 0.0},
CartesianPosition{100.0, 0.0}};
const Straight b{StraightId{2}, CartesianPosition{0.0, 5.0},
CartesianPosition{100.0, 5.0}};
const TrackOffset offset{Length::from_metres(5.0), Side::Left};
// A straight without an id cannot be referenced.
CHECK_THROWS(ParallelismService::mark_parallel(anonymous, b, offset));
CHECK_THROWS(ParallelismService::mark_parallel(a, anonymous, offset));
// A straight cannot be parallel to itself (same id).
const Straight a_again{StraightId{1}, CartesianPosition{0.0, 10.0},
CartesianPosition{100.0, 10.0}};
CHECK_THROWS(ParallelismService::mark_parallel(a, a_again, offset));
}
void offset_must_be_positive() {
CHECK_THROWS(TrackOffset(Length::from_metres(0.0), Side::Left));
}
void parallelism_geometry() {
const Straight origin{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
const Straight same_direction{CartesianPosition{0.0, 5.0},
CartesianPosition{100.0, 5.0}};
const Straight opposite{CartesianPosition{100.0, 5.0}, CartesianPosition{0.0, 5.0}};
const Straight perpendicular{CartesianPosition{0.0, 0.0},
CartesianPosition{0.0, 50.0}};
CHECK(ParallelismService::are_parallel(origin, same_direction));
CHECK(ParallelismService::are_parallel(origin, opposite)); // anti-parallel lines
CHECK(!ParallelismService::are_parallel(origin, perpendicular));
// Derive a line 5 m to the LEFT of an east-going origin -> to the north (y = +5).
const Straight left = ParallelismService::derive_parallel_line(
origin, TrackOffset{Length::from_metres(5.0), Side::Left});
CHECK(std::abs(left.start().x() - 0.0) < 1e-9);
CHECK(std::abs(left.start().y() - 5.0) < 1e-9);
CHECK(std::abs(left.end().y() - 5.0) < 1e-9);
CHECK(ParallelismService::are_parallel(origin, left));
// To the RIGHT -> to the south (y = -5).
const Straight right = ParallelismService::derive_parallel_line(
origin, TrackOffset{Length::from_metres(5.0), Side::Right});
CHECK(std::abs(right.start().y() + 5.0) < 1e-9);
CHECK(std::abs(ParallelismService::perpendicular_distance(origin, same_direction) -
5.0) < 1e-9);
}
} // namespace
int main() {
RUN(mark_parallel_references_two_straights);
RUN(marking_requires_identified_distinct_straights);
RUN(offset_must_be_positive);
RUN(parallelism_geometry);
return REPORT();
}

View File

@@ -0,0 +1,132 @@
#include <cmath>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
void straight_computes_azimuth_and_length() {
// Going east (+x): azimuth = 90 deg (clockwise from north).
const Straight east{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
CHECK(std::abs(east.length().metres() - 100.0) < 1e-9);
CHECK(std::abs(east.azimuth().degrees() - 90.0) < 1e-9);
// Going north (+y): azimuth = 0 deg.
const Straight north{CartesianPosition{0.0, 0.0}, CartesianPosition{0.0, 50.0}};
CHECK(std::abs(north.azimuth().degrees() - 0.0) < 1e-9);
CHECK(std::abs(north.length().metres() - 50.0) < 1e-9);
// A zero-length straight has no azimuth and is rejected.
CHECK_THROWS(Straight(CartesianPosition{1.0, 1.0}, CartesianPosition{1.0, 1.0}));
}
void arc_carries_radius_direction_and_optional_length() {
const CircularArc without_length{Radius::from_metres(300.0), TurnDirection::Left};
CHECK(!without_length.length().has_value());
const CircularArc with_length{Radius::from_metres(300.0), TurnDirection::Left,
Length::from_metres(45.0)};
CHECK(with_length.length().has_value());
CHECK(with_length.length().value().metres() == 45.0);
}
void adjacent_same_arcs_are_rejected() {
HorizontalAlignment same;
same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
// same radius + same direction -> forbidden
CHECK_THROWS(
same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}));
HorizontalAlignment opposite;
opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
// same radius, different direction -> allowed (S-curve)
opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Right});
CHECK(opposite.size() == 2);
HorizontalAlignment compound;
compound.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
// same direction, different radius -> allowed (compound curve)
compound.append(CircularArc{Radius::from_metres(500.0), TurnDirection::Left});
CHECK(compound.size() == 2);
}
void straight_to_straight_tangent_requires_equal_azimuth() {
// Collinear straights, same azimuth, touching -> tangent OK.
HorizontalAlignment collinear;
collinear.append(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
collinear.append(
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}});
CHECK(collinear.size() == 2);
CHECK(collinear.joint(0) == JointContinuity::Tangent);
// Touching but different azimuth with a Tangent joint -> must be a break.
HorizontalAlignment unmarked;
unmarked.append(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
CHECK_THROWS(unmarked.append(
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}}));
// Non-touching tangent straights -> invalid.
HorizontalAlignment gap;
gap.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
CHECK_THROWS(gap.append(
Straight{CartesianPosition{60.0, 0.0}, CartesianPosition{100.0, 0.0}}));
}
void azimuth_break_between_straights_must_be_marked() {
// Corner marked as AzimuthBreak -> OK.
HorizontalAlignment kink;
kink.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
kink.append(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}},
JointContinuity::AzimuthBreak);
CHECK(kink.size() == 2);
CHECK(kink.joint(0) == JointContinuity::AzimuthBreak);
// AzimuthBreak with equal azimuth is pointless -> invalid.
HorizontalAlignment straight_break;
straight_break.append(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
CHECK_THROWS(straight_break.append(
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}},
JointContinuity::AzimuthBreak));
// AzimuthBreak is only allowed between two straights.
HorizontalAlignment arc_break;
arc_break.append(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
CHECK_THROWS(arc_break.append(
CircularArc{Radius::from_metres(300.0), TurnDirection::Left},
JointContinuity::AzimuthBreak));
}
void straight_curve_joints_are_accepted_pending_fit() {
// prosta -> klotoida -> łuk -> klotoida -> prosta
HorizontalAlignment plan;
plan.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
plan.append(TransitionCurve{Length::from_metres(30.0), std::nullopt,
Radius::from_metres(300.0), TurnDirection::Left});
plan.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left,
Length::from_metres(40.0)});
plan.append(TransitionCurve{Length::from_metres(30.0),
Radius::from_metres(300.0), std::nullopt,
TurnDirection::Left});
plan.append(Straight{CartesianPosition{200.0, 0.0}, CartesianPosition{260.0, 0.0}});
CHECK(plan.size() == 5);
// Known lengths: 50 + 30 + 40 + 30 + 60.
CHECK(plan.total_length().metres() == 210.0);
}
} // namespace
int main() {
RUN(straight_computes_azimuth_and_length);
RUN(arc_carries_radius_direction_and_optional_length);
RUN(adjacent_same_arcs_are_rejected);
RUN(straight_to_straight_tangent_requires_equal_azimuth);
RUN(azimuth_break_between_straights_must_be_marked);
RUN(straight_curve_joints_are_accepted_pending_fit);
return REPORT();
}

View File

@@ -0,0 +1,78 @@
#include <cmath>
#include "check.hpp"
#include "maj0sted/io/svg_writer.hpp"
#include "maj0sted/maj0sted.hpp"
#include "maj0sted/render/plan_sampler.hpp"
using namespace maj0sted::domain;
namespace {
void samples_one_polyline_per_element() {
Niweleta n{NiweletaId{1}};
const StraightId a =
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0},
CartesianPosition{100.0, 100.0},
JointContinuity::AzimuthBreak);
const FitResult fit = FittingService::fit_between_straights(
n, FitParameters{.entry = a,
.exit = b,
.radius = Radius::from_metres(40.0),
.transition_length = Length::from_metres(10.0)});
n.apply_fit(fit); // straight -> KP -> arc -> KP -> straight (5 elements)
const auto scene = maj0sted::render::sample(n.plan());
CHECK(scene.polylines.size() == n.plan().size());
CHECK(scene.polylines.size() == 5);
CHECK(!scene.empty);
// First polyline is the (straight) entry, drawn from its anchored endpoints.
const auto& first = scene.polylines.front();
CHECK(first.kind == maj0sted::render::ElementKind::Straight);
CHECK(first.points.size() == 2);
CHECK(std::abs(first.points.front().x - 0.0) < 1e-9);
// Curves are sampled into many points.
CHECK(scene.polylines[2].kind == maj0sted::render::ElementKind::Arc);
CHECK(scene.polylines[2].points.size() > 2);
}
void curve_geometry_is_continuous() {
// The sampled points should join up (each element starts where the previous
// ended) for a properly fitted, tangent-continuous plan.
Niweleta n{NiweletaId{2}};
const StraightId a =
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0},
CartesianPosition{100.0, 100.0},
JointContinuity::AzimuthBreak);
n.apply_fit(FittingService::fit_between_straights(
n, FitParameters{.entry = a, .exit = b, .radius = Radius::from_metres(40.0)}));
const auto scene = maj0sted::render::sample(n.plan());
for (std::size_t i = 1; i < scene.polylines.size(); ++i) {
const auto& prev_end = scene.polylines[i - 1].points.back();
const auto& next_start = scene.polylines[i].points.front();
CHECK(std::abs(prev_end.x - next_start.x) < 1e-6);
CHECK(std::abs(prev_end.y - next_start.y) < 1e-6);
}
}
void svg_is_produced() {
Niweleta n{NiweletaId{3}};
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0});
const auto svg = maj0sted::io::to_svg(maj0sted::render::sample(n.plan()));
CHECK(svg.rfind("<svg", 0) == 0);
CHECK(svg.find("<polyline") != std::string::npos);
}
} // namespace
int main() {
RUN(samples_one_polyline_per_element);
RUN(curve_geometry_is_continuous);
RUN(svg_is_produced);
return REPORT();
}

View File

@@ -0,0 +1,92 @@
#include <variant>
#include "check.hpp"
#include "maj0sted/maj0sted.hpp"
using namespace maj0sted::domain;
namespace {
MapProject make_project() {
MapProject project{Crs{2180}};
Niweleta a{NiweletaId{5}, "Odcinek A"};
const StraightId s1 =
a.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
(void)s1;
a.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt,
Radius::from_metres(300.0), TurnDirection::Right});
a.add_plan_element(CircularArc{Radius::from_metres(300.0), TurnDirection::Right,
Length::from_metres(40.0)});
a.add_profile_element(Grade{Length::from_metres(80.0), 3.0});
a.add_profile_element(VerticalCurve{Length::from_metres(40.0),
Radius::from_metres(5000.0),
VerticalCurvature::Sag});
project.add_niweleta(std::move(a));
// A second niweleta with an azimuth break, to exercise joints.
Niweleta b{NiweletaId{9}, "Odcinek B z załamaniem"};
b.add_plan_element(
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
b.add_plan_element(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}},
JointContinuity::AzimuthBreak);
project.add_niweleta(std::move(b));
return project;
}
void round_trip_is_stable() {
const MapProject project = make_project();
const std::string text = maj0sted::io::serialize(project);
const MapProject loaded = maj0sted::io::deserialize(text);
// Re-serializing the loaded project yields byte-identical text.
CHECK(maj0sted::io::serialize(loaded) == text);
CHECK(loaded.crs().epsg() == 2180);
CHECK(loaded.niwelety().size() == 2);
}
void niweleta_and_geometry_survive() {
const MapProject loaded = maj0sted::io::deserialize(maj0sted::io::serialize(make_project()));
const Niweleta& a = loaded.niwelety()[0];
CHECK(a.id() == NiweletaId{5});
CHECK(a.name() == "Odcinek A");
CHECK(a.plan().size() == 3);
CHECK(a.profile().size() == 2);
// Straight kept its id and geometry.
const auto& straight = std::get<Straight>(a.plan().elements()[0]);
CHECK(straight.id() == StraightId{1});
CHECK(straight.length().metres() == 100.0);
// Arc kept radius, direction and length.
const auto& arc = std::get<CircularArc>(a.plan().elements()[2]);
CHECK(arc.radius().metres() == 300.0);
CHECK(arc.direction() == TurnDirection::Right);
CHECK(arc.length().value().metres() == 40.0);
// Profile survived.
const auto& grade = std::get<Grade>(a.profile().elements()[0]);
CHECK(grade.slope_permille() == 3.0);
// Name with spaces and the azimuth-break joint survived on the 2nd niweleta.
const Niweleta& b = loaded.niwelety()[1];
CHECK(b.name() == "Odcinek B z załamaniem");
CHECK(b.plan().joint(0) == JointContinuity::AzimuthBreak);
}
void malformed_input_throws() {
CHECK_THROWS(maj0sted::io::deserialize("not a maj0sted file"));
CHECK_THROWS(maj0sted::io::deserialize("maj0sted 1\ncrs 2180\nniwelety 3\n"));
}
} // namespace
int main() {
RUN(round_trip_is_stable);
RUN(niweleta_and_geometry_survive);
RUN(malformed_input_throws);
return REPORT();
}

View File

@@ -0,0 +1,208 @@
#include <cmath>
#include <limits>
#include <string>
#include <vector>
#include "check.hpp"
#include "maj0sted/web/tile_cache.hpp"
using namespace maj0sted::web;
namespace {
// --- tile grid geometry --------------------------------------------------
void grid_is_fixed_100m_cells() {
// One fixed grid: every cell is 100 m × 100 m regardless of the zoom arg.
CHECK(std::abs(TileGrid::kCellMetres - 100.0) < 1e-9);
CHECK(std::abs(TileGrid::tile_span() - 100.0) < 1e-6);
CHECK(std::abs(TileGrid::tile_span(14) - 100.0) < 1e-6); // zoom ignored
// Resolution is constant: 100 m over 1024 px.
CHECK(std::abs(TileGrid::resolution() - 100.0 / 1024.0) < 1e-9);
CHECK(std::abs(TileGrid::resolution(7) - 100.0 / 1024.0) < 1e-9);
}
void tile_at_and_bbox_are_consistent() {
const double x = 500123.0; // somewhere in EPSG:2180 (Poland)
const double y = 300456.0;
const TileKey key = TileGrid::tile_at(x, y);
const TileBBox box = TileGrid::bbox(key);
// Every tile key sits on the single fixed level.
CHECK(key.zoom == TileGrid::kLevel);
// The point must fall inside its own cell's ground box.
CHECK(box.min_x <= x && x < box.max_x);
CHECK(box.min_y <= y && y < box.max_y);
// The box edge length must equal the fixed 100 m cell span.
CHECK(std::abs((box.max_x - box.min_x) - 100.0) < 1e-6);
CHECK(std::abs((box.max_y - box.min_y) - 100.0) < 1e-6);
// Neighbouring cells are exactly one span (100 m) apart.
const TileBBox right = TileGrid::bbox(TileKey{TileGrid::kLevel, key.x + 1, key.y});
CHECK(std::abs(right.min_x - box.max_x) < 1e-6);
}
void zoom_for_resolution_is_single_level() {
// There is no pyramid: any target resolution maps to the one fixed level.
for (double mpp : {50.0, 8.0, 1.0, 0.25}) {
CHECK(TileGrid::zoom_for_resolution(mpp) == TileGrid::kLevel);
}
}
void tiles_for_view_covers_and_caps() {
const double span = 100.0; // fixed cell
// A view roughly 3x2 cells.
TileBBox view{500000.0, 300000.0, 500000.0 + 2.5 * span, 300000.0 + 1.5 * span};
const auto tiles = TileGrid::tiles_for_view(view);
CHECK(!tiles.empty());
// Every corner cell of the view must be present.
const TileKey lo = TileGrid::tile_at(view.min_x, view.min_y);
const TileKey hi = TileGrid::tile_at(view.max_x, view.max_y);
bool has_lo = false, has_hi = false;
for (const auto& t : tiles) {
if (t == lo) has_lo = true;
if (t == hi) has_hi = true;
}
CHECK(has_lo && has_hi);
// The cap is honoured.
TileBBox huge{0.0, 0.0, 1e9, 1e9};
const auto capped = TileGrid::tiles_for_view(huge, TileGrid::kLevel, 32);
CHECK(static_cast<int>(capped.size()) <= 32);
const TileKey centre = TileGrid::tile_at(5e8, 5e8);
bool has_centre = false;
for (const auto& tile : capped) {
if (tile == centre) has_centre = true;
}
CHECK(has_centre); // cap is centred, not taken from a remote corner
const double infinity = std::numeric_limits<double>::infinity();
CHECK(TileGrid::tiles_for_view({0.0, 0.0, infinity, 1.0}).empty());
}
// --- WMS URL -------------------------------------------------------------
void getmap_url_is_wms_1_1_1_epsg2180() {
WmsConfig cfg = WmsConfig::geoportal_ortho();
const TileBBox box{100.0, 200.0, 300.0, 400.0};
const std::string url = wms_getmap_url(cfg, box);
CHECK(url.find("SERVICE=WMS") != std::string::npos);
CHECK(url.find("VERSION=1.1.1") != std::string::npos);
CHECK(url.find("REQUEST=GetMap") != std::string::npos);
CHECK(url.find("SRS=EPSG:2180") != std::string::npos);
// BBOX in minx,miny,maxx,maxy order.
CHECK(url.find("BBOX=100,200,300,400") != std::string::npos);
CHECK(url.find("WIDTH=1024") != std::string::npos);
}
// --- cache + injected fetch ---------------------------------------------
void miss_fetches_then_hit_is_served_from_cache() {
TileService service;
int fetch_calls = 0;
// Synchronous injected fetcher that returns three canned bytes.
service.set_fetcher([&](const std::string& url,
TileService::DoneFn done) {
++fetch_calls;
CHECK(url.find("BBOX=") != std::string::npos);
done(true, std::vector<std::uint8_t>{1, 2, 3}, "image/jpeg");
});
int ready_events = 0;
TileKey last_ready{};
service.set_on_ready([&](TileKey k) {
++ready_events;
last_ready = k;
});
const TileKey key{12, 3, 4};
CHECK(!service.has(key));
// First request: a miss, so exactly one fetch and the tile becomes Ready.
const TileStatus first = service.request(key);
CHECK(first == TileStatus::Ready); // fetcher was synchronous
CHECK(fetch_calls == 1);
CHECK(ready_events == 1);
CHECK(last_ready == key);
CHECK(service.has(key));
const TileImage* image = service.peek(key);
CHECK(image != nullptr);
CHECK(image->bytes.size() == 3);
CHECK(image->content_type == "image/jpeg");
// Second request for the same tile: a hit, no extra fetch.
const TileStatus second = service.request(key);
CHECK(second == TileStatus::Ready);
CHECK(fetch_calls == 1); // unchanged: served from cache
CHECK(service.size() == 1);
}
void failed_fetch_is_retried_until_success() {
// A failure is not terminal: the service retries until a tile arrives. With
// a synchronous injected fetcher the retries unwind inline, so a fetcher that
// fails twice then succeeds must end Ready after exactly three attempts.
TileService service;
int calls = 0;
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
++calls;
if (calls < 3) {
done(false, {}, {}); // fail
} else {
done(true, std::vector<std::uint8_t>{7}, "image/jpeg"); // then succeed
}
});
const TileKey key{10, 1, 1};
const TileStatus status = service.request(key);
CHECK(status == TileStatus::Ready);
CHECK(calls == 3);
CHECK(service.has(key));
const TileImage* image = service.peek(key);
CHECK(image != nullptr);
CHECK(image->bytes.size() == 1);
}
void retry_gives_up_after_ten_attempts() {
// A tile that never loads is retried at most 10 times, then marked Failed.
// With a synchronous always-failing fetcher that is 1 initial + 10 retries.
TileService service;
int calls = 0;
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
++calls;
done(false, {}, {});
});
const TileKey key{2, 2, 2};
CHECK(service.request(key) == TileStatus::Failed);
CHECK(calls == 11); // initial + kMaxRetries (10)
CHECK(!service.has(key));
CHECK(service.peek(key) == nullptr);
}
void store_seeds_cache_directly() {
TileService service;
const TileKey key{9, 2, 2};
service.store(key, std::vector<std::uint8_t>{9, 9}, "image/png");
CHECK(service.has(key));
// A request now is a pure hit even with no fetcher installed.
CHECK(service.request(key) == TileStatus::Ready);
}
} // namespace
int main() {
RUN(grid_is_fixed_100m_cells);
RUN(tile_at_and_bbox_are_consistent);
RUN(zoom_for_resolution_is_single_level);
RUN(tiles_for_view_covers_and_caps);
RUN(getmap_url_is_wms_1_1_1_epsg2180);
RUN(miss_fetches_then_hit_is_served_from_cache);
RUN(failed_fetch_is_retried_until_success);
RUN(retry_gives_up_after_ten_attempts);
RUN(store_seeds_cache_directly);
return REPORT();
}

229
maj0sted/tests/test_web.cpp Normal file
View File

@@ -0,0 +1,229 @@
#include <cmath>
#include <numbers>
#include "check.hpp"
#include "maj0sted/web/editor.hpp"
#include "maj0sted/web/solve.hpp"
using namespace maj0sted::web;
namespace {
bool has_kind(const std::vector<WebPolyline>& polylines, int kind) {
for (const auto& p : polylines) {
if (p.kind == kind) return true;
}
return false;
}
void arc_with_transitions_produces_full_chain() {
SolveRequest r{};
r.ax = 0.0; r.ay = 0.0;
r.bx = 300.0; r.by = 0.0;
r.cx = 300.0; r.cy = 300.0;
r.mode = 1;
r.radius = 80.0;
r.transition = 20.0;
const auto polylines = solve_scene(r);
CHECK(!polylines.empty());
CHECK(has_kind(polylines, 0)); // straight
CHECK(has_kind(polylines, 1)); // arc
CHECK(has_kind(polylines, 2)); // transition
// Each polyline has at least two points.
for (const auto& p : polylines) CHECK(p.points.size() >= 2);
}
void compound_mode_produces_two_arcs() {
SolveRequest r{};
r.ax = 0.0; r.ay = 0.0;
r.bx = 300.0; r.by = 0.0;
r.cx = 300.0; r.cy = 300.0;
r.mode = 2;
r.r1 = 120.0; r.arc1_len = 60.0; r.between = 25.0; r.r2 = 80.0;
r.entry_t = 30.0; r.exit_t = 30.0;
const auto polylines = solve_scene(r);
int arcs = 0;
for (const auto& p : polylines) {
if (p.kind == 1) ++arcs;
}
CHECK(arcs == 2);
CHECK(has_kind(polylines, 2)); // transitions present
}
void degenerate_input_falls_back_to_straights() {
// Collinear vertices -> no fit; still returns the straights, never throws.
SolveRequest r{};
r.ax = 0.0; r.ay = 0.0;
r.bx = 100.0; r.by = 0.0;
r.cx = 200.0; r.cy = 0.0;
r.mode = 0;
r.radius = 50.0;
const auto polylines = solve_scene(r);
CHECK(!polylines.empty());
CHECK(has_kind(polylines, 0)); // straights
CHECK(!has_kind(polylines, 1)); // no arc
}
NiweletaSpec short_corner(double radius, double transition = 0.0) {
NiweletaSpec spec;
spec.straights = {
StraightSpec{0.0, 0.0, 10.0, 0.0},
StraightSpec{10.0, 0.0, 10.0, 10.0},
};
spec.fits = {GapFit{.gap = 0,
.mode = transition > 0.0 ? 2 : 1,
.radius = radius,
.transition = transition}};
return spec;
}
void editor_clamps_oversized_arc_to_fitting_radius() {
const auto solved = solve_project({short_corner(60.0)});
CHECK(solved.size() == 1);
CHECK(solved[0].applied_fits.size() == 1);
const double applied = solved[0].applied_fits[0].radius;
CHECK(applied > 9.9);
CHECK(applied < 10.1);
}
void editor_leaves_gap_unfitted_when_radius_below_floor() {
// A radius too small for the requested transitions is NEVER enlarged (the
// fitting rule only ever reduces). When nothing at or below the request fits,
// the gap is simply left unconnected.
NiweletaSpec spec;
spec.straights = {
StraightSpec{0.0, 0.0, 100.0, 0.0},
StraightSpec{100.0, 0.0, 100.0, 100.0},
};
spec.fits = {GapFit{.gap = 0, .mode = 2, .radius = 1.0, .transition = 20.0}};
const auto solved = solve_project({spec});
CHECK(solved.size() == 1);
CHECK(solved[0].applied_fits.empty());
CHECK(!has_kind(solved[0].polylines, 1)); // no arc rendered
}
void editor_emits_two_rails_at_standard_gauge() {
const auto solved = solve_project({short_corner(5.0)});
CHECK(solved[0].polylines.size() == 6);
CHECK(solved[0].rendered_straights.size() == 2);
CHECK(std::abs(solved[0].rendered_straights[0].x2 - 5.0) < 1e-6);
CHECK(std::abs(solved[0].rendered_straights[1].y1 - 5.0) < 1e-6);
const auto& left = solved[0].polylines[0].points;
const auto& right = solved[0].polylines[1].points;
CHECK(left.size() == right.size());
CHECK(std::abs(std::hypot(left[0].x - right[0].x, left[0].y - right[0].y) -
1.5) < 1e-9);
CHECK(solved[0].polylines[0].straight_index == 0);
CHECK(solved[0].polylines[0].gap == -1);
CHECK(std::abs(solved[0].polylines[0].length - 5.0) < 1e-9);
const WebPolyline* arc = nullptr;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 1) {
arc = &polyline;
break;
}
}
CHECK(arc != nullptr);
CHECK(arc->straight_index == -1);
CHECK(arc->gap == 0);
CHECK(arc->element_index == 0);
CHECK(std::abs(arc->radius_start - 5.0) < 1e-9);
CHECK(std::abs(arc->radius_end - 5.0) < 1e-9);
CHECK(std::abs(arc->length - 5.0 * std::numbers::pi / 2.0) < 1e-9);
}
void editor_exposes_transition_radius_range() {
const auto solved = solve_project({short_corner(5.0, 2.0)});
const WebPolyline* transition = nullptr;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 2) {
transition = &polyline;
break;
}
}
CHECK(transition != nullptr);
CHECK(transition->gap == 0);
CHECK(std::abs(transition->length - 2.0) < 1e-9);
CHECK(transition->radius_start == 0.0);
CHECK(std::abs(transition->radius_end - 5.0) < 1e-9);
}
void editor_leaves_parallel_offset_unfitted() {
// Two parallel, laterally offset straights admit no plain arc (a single arc
// cannot connect parallels) and reverse S-curves are never substituted, so the
// gap stays unconnected rather than inventing a curve.
NiweletaSpec spec;
spec.straights = {
StraightSpec{0.0, 0.0, 200.0, 0.0},
StraightSpec{0.0, 20.0, 200.0, 20.0},
};
spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 50.0}};
const auto solved = solve_project({spec});
CHECK(solved[0].applied_fits.empty());
CHECK(!has_kind(solved[0].polylines, 1)); // no arc, no reverse pair
}
void editor_honours_requested_radius_via_single_arc() {
// A requested radius that lands on the straights (extending them toward the
// corner if needed) is used verbatim as a single arc — never enlarged and
// never swapped for a reverse pair.
const double heading = std::numbers::pi / 6.0;
const double tangent_x = 86.9615242271;
const double tangent_y = 25.9807621135;
const double dx = std::cos(heading);
const double dy = std::sin(heading);
NiweletaSpec spec;
spec.straights = {
StraightSpec{0.0, 0.0, 100.0, 0.0},
StraightSpec{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy,
tangent_x + 70.0 * dx, tangent_y + 70.0 * dy},
};
spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 30.0}};
const auto solved = solve_project({spec});
CHECK(solved[0].applied_fits.size() == 1);
CHECK(std::abs(solved[0].applied_fits[0].radius - 30.0) < 1e-9);
int arc_rails = 0;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 1) ++arc_rails;
}
CHECK(arc_rails == 2); // a single arc (two rails), not a reverse pair
}
void hidden_tangent_guide_fits_curve_without_rendering_track() {
NiweletaSpec spec = short_corner(5.0);
spec.straights[1].hidden = true;
const auto solved = solve_project({spec});
CHECK(solved[0].applied_fits.size() == 1);
CHECK(solved[0].rendered_straights[1].hidden);
int straight_rails = 0;
int arc_rails = 0;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 0) ++straight_rails;
if (polyline.kind == 1) ++arc_rails;
}
CHECK(straight_rails == 2); // only the visible entry straight
CHECK(arc_rails == 2);
}
} // namespace
int main() {
RUN(arc_with_transitions_produces_full_chain);
RUN(compound_mode_produces_two_arcs);
RUN(degenerate_input_falls_back_to_straights);
RUN(editor_clamps_oversized_arc_to_fitting_radius);
RUN(editor_leaves_gap_unfitted_when_radius_below_floor);
RUN(editor_emits_two_rails_at_standard_gauge);
RUN(editor_exposes_transition_radius_range);
RUN(editor_leaves_parallel_offset_unfitted);
RUN(editor_honours_requested_radius_via_single_arc);
RUN(hidden_tangent_guide_fits_curve_without_rendering_track);
return REPORT();
}