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:
49
maj0sted/tests/CMakeLists.txt
Normal file
49
maj0sted/tests/CMakeLists.txt
Normal file
@@ -0,0 +1,49 @@
|
||||
add_executable(maj0sted_test_map_project test_map_project.cpp)
|
||||
target_link_libraries(maj0sted_test_map_project PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_map_project PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME map_project COMMAND maj0sted_test_map_project)
|
||||
|
||||
add_executable(maj0sted_test_niweleta test_niweleta.cpp)
|
||||
target_link_libraries(maj0sted_test_niweleta PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_niweleta PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME niweleta COMMAND maj0sted_test_niweleta)
|
||||
|
||||
add_executable(maj0sted_test_plan_alignment test_plan_alignment.cpp)
|
||||
target_link_libraries(maj0sted_test_plan_alignment PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_plan_alignment PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME plan_alignment COMMAND maj0sted_test_plan_alignment)
|
||||
|
||||
add_executable(maj0sted_test_parallelism test_parallelism.cpp)
|
||||
target_link_libraries(maj0sted_test_parallelism PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_parallelism PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME parallelism COMMAND maj0sted_test_parallelism)
|
||||
|
||||
add_executable(maj0sted_test_fitting test_fitting.cpp)
|
||||
target_link_libraries(maj0sted_test_fitting PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_fitting PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME fitting COMMAND maj0sted_test_fitting)
|
||||
|
||||
add_executable(maj0sted_test_serialization test_serialization.cpp)
|
||||
target_link_libraries(maj0sted_test_serialization PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_serialization PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME serialization COMMAND maj0sted_test_serialization)
|
||||
|
||||
add_executable(maj0sted_test_render test_render.cpp)
|
||||
target_link_libraries(maj0sted_test_render PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_render PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME render COMMAND maj0sted_test_render)
|
||||
|
||||
add_executable(maj0sted_test_web test_web.cpp)
|
||||
target_link_libraries(maj0sted_test_web PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_web PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME web COMMAND maj0sted_test_web)
|
||||
|
||||
add_executable(maj0sted_test_tile_cache test_tile_cache.cpp)
|
||||
target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME tile_cache COMMAND maj0sted_test_tile_cache)
|
||||
|
||||
add_executable(maj0sted_test_document test_document.cpp)
|
||||
target_link_libraries(maj0sted_test_document PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_document PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME document COMMAND maj0sted_test_document)
|
||||
51
maj0sted/tests/check.hpp
Normal file
51
maj0sted/tests/check.hpp
Normal file
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
// A tiny zero-dependency check framework so the test suite needs nothing but a
|
||||
// C++20 compiler. Each test executable returns 0 on success, 1 on any failure,
|
||||
// which is exactly what CTest interprets as pass/fail.
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace maj0test {
|
||||
inline int checks = 0;
|
||||
inline int failures = 0;
|
||||
} // namespace maj0test
|
||||
|
||||
// Variadic so that brace-init lists with commas, e.g. CHECK(Point{1, 2} == ...),
|
||||
// are passed through as a single condition instead of several macro arguments.
|
||||
#define CHECK(...) \
|
||||
do { \
|
||||
++::maj0test::checks; \
|
||||
if (!(__VA_ARGS__)) { \
|
||||
++::maj0test::failures; \
|
||||
std::printf(" [FAIL] %s:%d CHECK(%s)\n", __FILE__, __LINE__, \
|
||||
#__VA_ARGS__); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_THROWS(...) \
|
||||
do { \
|
||||
++::maj0test::checks; \
|
||||
bool threw = false; \
|
||||
try { \
|
||||
(void)(__VA_ARGS__); \
|
||||
} catch (...) { \
|
||||
threw = true; \
|
||||
} \
|
||||
if (!threw) { \
|
||||
++::maj0test::failures; \
|
||||
std::printf(" [FAIL] %s:%d CHECK_THROWS(%s)\n", __FILE__, \
|
||||
__LINE__, #__VA_ARGS__); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define RUN(fn) \
|
||||
do { \
|
||||
std::printf("[RUN ] %s\n", #fn); \
|
||||
fn(); \
|
||||
} while (0)
|
||||
|
||||
#define REPORT() \
|
||||
(std::printf("\n%d checks, %d failures\n", ::maj0test::checks, \
|
||||
::maj0test::failures), \
|
||||
::maj0test::failures == 0 ? 0 : 1)
|
||||
123
maj0sted/tests/test_document.cpp
Normal file
123
maj0sted/tests/test_document.cpp
Normal file
@@ -0,0 +1,123 @@
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/app/editor_document.hpp"
|
||||
|
||||
using namespace maj0sted::app;
|
||||
using maj0sted::web::GapFit;
|
||||
using maj0sted::web::NiweletaSpec;
|
||||
using maj0sted::web::StraightSpec;
|
||||
|
||||
namespace {
|
||||
|
||||
EditorDocument sample_document() {
|
||||
EditorDocument doc;
|
||||
|
||||
NiweletaSpec a;
|
||||
a.name = "Niweleta 1";
|
||||
a.straights = {StraightSpec{0.0, 0.0, 120.0, 0.0},
|
||||
StraightSpec{120.0, 0.0, 120.0, 120.0, true}};
|
||||
GapFit fit;
|
||||
fit.gap = 0;
|
||||
fit.mode = 2; // arc + transitions
|
||||
fit.radius = 40.0;
|
||||
fit.transition = 12.5;
|
||||
a.fits = {fit};
|
||||
|
||||
NiweletaSpec b;
|
||||
b.name = "Bocznica z spacją";
|
||||
b.straights = {StraightSpec{500000.25, 300000.75, 500200.0, 300000.0}};
|
||||
|
||||
doc.niwelety = {a, b};
|
||||
return doc;
|
||||
}
|
||||
|
||||
bool same(const StraightSpec& x, const StraightSpec& y) {
|
||||
return x.x1 == y.x1 && x.y1 == y.y1 && x.x2 == y.x2 && x.y2 == y.y2 &&
|
||||
x.hidden == y.hidden;
|
||||
}
|
||||
|
||||
bool same(const GapFit& x, const GapFit& y) {
|
||||
return x.gap == y.gap && x.mode == y.mode && x.radius == y.radius &&
|
||||
x.transition == y.transition && x.r1 == y.r1 && x.arc1_len == y.arc1_len &&
|
||||
x.between == y.between && x.r2 == y.r2 && x.entry_t == y.entry_t &&
|
||||
x.exit_t == y.exit_t;
|
||||
}
|
||||
|
||||
void check_equal(const EditorDocument& x, const EditorDocument& y) {
|
||||
CHECK(x.niwelety.size() == y.niwelety.size());
|
||||
if (x.niwelety.size() != y.niwelety.size()) return;
|
||||
for (std::size_t n = 0; n < x.niwelety.size(); ++n) {
|
||||
const auto& a = x.niwelety[n];
|
||||
const auto& b = y.niwelety[n];
|
||||
CHECK(a.name == b.name);
|
||||
CHECK(a.straights.size() == b.straights.size());
|
||||
for (std::size_t i = 0; i < a.straights.size() && i < b.straights.size(); ++i) {
|
||||
CHECK(same(a.straights[i], b.straights[i]));
|
||||
}
|
||||
CHECK(a.fits.size() == b.fits.size());
|
||||
for (std::size_t i = 0; i < a.fits.size() && i < b.fits.size(); ++i) {
|
||||
CHECK(same(a.fits[i], b.fits[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void serialize_round_trips_exactly() {
|
||||
const EditorDocument doc = sample_document();
|
||||
const std::string text = serialize_document(doc);
|
||||
const EditorDocument back = deserialize_document(text);
|
||||
check_equal(doc, back);
|
||||
|
||||
// The file really embeds a maj0sted project (reuses io::serialize).
|
||||
CHECK(text.rfind("maj0sted", 0) == 0);
|
||||
CHECK(text.find("\neditor 2\n") != std::string::npos);
|
||||
|
||||
// Serialization is stable (byte-for-byte) across a round trip.
|
||||
CHECK(serialize_document(back) == text);
|
||||
}
|
||||
|
||||
void embedded_project_is_valid_and_readable() {
|
||||
const EditorDocument doc = sample_document();
|
||||
// to_map_project keeps one niweleta per editor niweleta and the straights.
|
||||
const auto project = to_map_project(doc);
|
||||
CHECK(project.niwelety().size() == 2);
|
||||
const EditorDocument imported = from_map_project(project);
|
||||
CHECK(imported.niwelety.size() == 2);
|
||||
CHECK(imported.niwelety[0].straights.size() == 2);
|
||||
CHECK(imported.niwelety[0].name == "Niweleta 1");
|
||||
}
|
||||
|
||||
void save_then_load_from_disk() {
|
||||
const EditorDocument doc = sample_document();
|
||||
const std::filesystem::path dir =
|
||||
std::filesystem::temp_directory_path() / "maj0sted_test";
|
||||
std::filesystem::remove_all(dir);
|
||||
const std::string path = (dir / "sub" / "proj.m0s").string();
|
||||
|
||||
CHECK(save_project(doc, path)); // creates parent dirs
|
||||
CHECK(std::filesystem::exists(path));
|
||||
|
||||
const auto loaded = load_project(path);
|
||||
CHECK(loaded.has_value());
|
||||
if (loaded) check_equal(doc, *loaded);
|
||||
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
|
||||
void load_missing_file_is_nullopt() {
|
||||
const std::string path =
|
||||
(std::filesystem::temp_directory_path() / "maj0sted_nope_12345.m0s").string();
|
||||
std::filesystem::remove(path);
|
||||
CHECK(!load_project(path).has_value());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(serialize_round_trips_exactly);
|
||||
RUN(embedded_project_is_valid_and_readable);
|
||||
RUN(save_then_load_from_disk);
|
||||
RUN(load_missing_file_is_nullopt);
|
||||
return REPORT();
|
||||
}
|
||||
346
maj0sted/tests/test_fitting.cpp
Normal file
346
maj0sted/tests/test_fitting.cpp
Normal file
@@ -0,0 +1,346 @@
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <variant>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
// A 90-degree left corner: entry heads east into (100,0), exit leaves north.
|
||||
Niweleta make_corner() {
|
||||
Niweleta n{NiweletaId{1}, "Corner"};
|
||||
n.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}});
|
||||
n.add_plan_element(Straight{StraightId{2}, CartesianPosition{100.0, 0.0},
|
||||
CartesianPosition{100.0, 100.0}},
|
||||
JointContinuity::AzimuthBreak);
|
||||
return n;
|
||||
}
|
||||
|
||||
void fit_plain_arc() {
|
||||
const Niweleta corner = make_corner();
|
||||
const FitParameters params{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.radius = Radius::from_metres(50.0),
|
||||
.transition_length = std::nullopt};
|
||||
const FitResult fit = FittingService::fit_between_straights(corner, params);
|
||||
|
||||
CHECK(fit.direction == TurnDirection::Left);
|
||||
|
||||
// Tangent length = R*tan(45) = 50 -> tangent points at (50,0) and (100,50).
|
||||
CHECK(std::abs(fit.tangent_in.x() - 50.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_out.y() - 50.0) < 1e-6);
|
||||
|
||||
// The trimmed straights keep their ids and outer endpoints.
|
||||
CHECK(fit.entry.id() == StraightId{1});
|
||||
CHECK(std::abs(fit.entry.end().x() - 50.0) < 1e-6);
|
||||
CHECK(fit.exit.id() == StraightId{2});
|
||||
CHECK(std::abs(fit.exit.start().y() - 50.0) < 1e-6);
|
||||
CHECK(std::abs(fit.exit.end().y() - 100.0) < 1e-6);
|
||||
|
||||
// A single circular arc, length R*delta = 50*pi/2.
|
||||
CHECK(fit.curve.size() == 1);
|
||||
const auto* arc = std::get_if<CircularArc>(&fit.curve.front());
|
||||
CHECK(arc != nullptr);
|
||||
CHECK(arc->direction() == TurnDirection::Left);
|
||||
CHECK(arc->length().has_value());
|
||||
CHECK(std::abs(arc->length().value().metres() - 50.0 * std::numbers::pi / 2.0) < 1e-6);
|
||||
}
|
||||
|
||||
void fit_with_symmetric_transitions() {
|
||||
const Niweleta corner = make_corner();
|
||||
const FitParameters params{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.radius = Radius::from_metres(50.0),
|
||||
.transition_length = Length::from_metres(10.0)};
|
||||
const FitResult fit = FittingService::fit_between_straights(corner, params);
|
||||
|
||||
// klotoida -> łuk -> klotoida
|
||||
CHECK(fit.curve.size() == 3);
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[0]));
|
||||
CHECK(std::holds_alternative<CircularArc>(fit.curve[1]));
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[2]));
|
||||
|
||||
// Entry transition runs from an infinite radius (straight) into R.
|
||||
const auto& entry_kp = std::get<TransitionCurve>(fit.curve[0]);
|
||||
CHECK(!entry_kp.start_radius().has_value());
|
||||
CHECK(entry_kp.end_radius().has_value());
|
||||
CHECK(entry_kp.length().metres() == 10.0);
|
||||
|
||||
// Arc central angle = delta - 2*tau, tau = Lk/(2R) = 0.1 -> arc len = 50*(pi/2 - 0.2).
|
||||
const auto& arc = std::get<CircularArc>(fit.curve[1]);
|
||||
const double expected = 50.0 * (std::numbers::pi / 2.0 - 0.2);
|
||||
CHECK(arc.length().has_value());
|
||||
CHECK(std::abs(arc.length().value().metres() - expected) < 1e-6);
|
||||
|
||||
// Entry tangent point from the exact clothoid layout (not the L^2/24R
|
||||
// approximation, which was ~1.8 mm off and caused a visible lateral gap).
|
||||
CHECK(std::abs(fit.tangent_in.x() - 44.9185491305) < 1e-6);
|
||||
}
|
||||
|
||||
void invalid_inputs_are_rejected() {
|
||||
const Niweleta corner = make_corner();
|
||||
const Radius r = Radius::from_metres(50.0);
|
||||
|
||||
// Same straight.
|
||||
CHECK_THROWS(FittingService::fit_between_straights(
|
||||
corner, FitParameters{StraightId{1}, StraightId{1}, r, std::nullopt}));
|
||||
// Unknown id.
|
||||
CHECK_THROWS(FittingService::fit_between_straights(
|
||||
corner, FitParameters{StraightId{1}, StraightId{99}, r, std::nullopt}));
|
||||
|
||||
// Collinear straights -> nothing to fit.
|
||||
Niweleta line{NiweletaId{2}};
|
||||
line.add_plan_element(Straight{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{50.0, 0.0}});
|
||||
line.add_plan_element(Straight{StraightId{2}, CartesianPosition{50.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}});
|
||||
CHECK_THROWS(FittingService::fit_between_straights(
|
||||
line, FitParameters{StraightId{1}, StraightId{2}, r, std::nullopt}));
|
||||
|
||||
// Transitions too long for this radius/deflection.
|
||||
CHECK_THROWS(FittingService::fit_between_straights(
|
||||
corner, FitParameters{StraightId{1}, StraightId{2}, r,
|
||||
Length::from_metres(200.0)}));
|
||||
}
|
||||
|
||||
void fit_compound_curve_two_arcs() {
|
||||
const Niweleta corner = make_corner(); // 90-degree left corner, IP at (100,0)
|
||||
|
||||
// First arc delta1 = 45 deg -> length = R1 * pi/4 = 60 * pi/4; last arc computed.
|
||||
const CompoundFitParameters params{
|
||||
.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {CompoundArc{Radius::from_metres(60.0),
|
||||
Length::from_metres(60.0 * std::numbers::pi / 4.0)},
|
||||
CompoundArc{Radius::from_metres(40.0), std::nullopt}}};
|
||||
const FitResult fit = FittingService::fit_compound(corner, params);
|
||||
|
||||
CHECK(fit.direction == TurnDirection::Left);
|
||||
CHECK(fit.curve.size() == 2);
|
||||
const auto& arc1 = std::get<CircularArc>(fit.curve[0]);
|
||||
const auto& arc2 = std::get<CircularArc>(fit.curve[1]);
|
||||
CHECK(arc1.radius().metres() == 60.0);
|
||||
CHECK(arc2.radius().metres() == 40.0);
|
||||
|
||||
// Same hand-computed geometry as before (rigid-chain solver reproduces it).
|
||||
CHECK(std::abs(arc1.length().value().metres() - 60.0 * std::numbers::pi / 4.0) < 1e-6);
|
||||
CHECK(std::abs(arc2.length().value().metres() - 40.0 * std::numbers::pi / 4.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_in.x() - 45.85786) < 1e-4);
|
||||
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-4);
|
||||
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-4);
|
||||
CHECK(std::abs(fit.tangent_out.y() - 45.85786) < 1e-4);
|
||||
}
|
||||
|
||||
void fit_compound_curve_three_arcs() {
|
||||
const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100
|
||||
|
||||
// 30 + 30 + (remaining 30) degrees, radii 80 / 50 / 30.
|
||||
const CompoundFitParameters params{
|
||||
.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {CompoundArc{Radius::from_metres(80.0),
|
||||
Length::from_metres(80.0 * std::numbers::pi / 6.0)},
|
||||
CompoundArc{Radius::from_metres(50.0),
|
||||
Length::from_metres(50.0 * std::numbers::pi / 6.0)},
|
||||
CompoundArc{Radius::from_metres(30.0), std::nullopt}}};
|
||||
const FitResult fit = FittingService::fit_compound(corner, params);
|
||||
|
||||
CHECK(fit.curve.size() == 3);
|
||||
CHECK(std::get<CircularArc>(fit.curve[0]).radius().metres() == 80.0);
|
||||
CHECK(std::get<CircularArc>(fit.curve[1]).radius().metres() == 50.0);
|
||||
CHECK(std::get<CircularArc>(fit.curve[2]).radius().metres() == 30.0);
|
||||
|
||||
// Last arc takes the remaining 30 deg -> length = 30 * pi/6.
|
||||
CHECK(std::abs(std::get<CircularArc>(fit.curve[2]).length().value().metres() -
|
||||
30.0 * std::numbers::pi / 6.0) < 1e-6);
|
||||
|
||||
// The chain closes onto both fixed lines: tangent_in on y=0, tangent_out on x=100.
|
||||
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
|
||||
CHECK(fit.direction == TurnDirection::Left);
|
||||
}
|
||||
|
||||
void fit_compound_with_transitions() {
|
||||
const Niweleta corner = make_corner(); // 90 deg; entry line y=0, exit line x=100
|
||||
|
||||
const CompoundFitParameters params{
|
||||
.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.entry_transition = Length::from_metres(10.0),
|
||||
.exit_transition = Length::from_metres(10.0),
|
||||
.arcs = {CompoundArc{Radius::from_metres(60.0), Length::from_metres(20.0),
|
||||
Length::from_metres(8.0)}, // arc(60), KP 60->40 of 8 m
|
||||
CompoundArc{Radius::from_metres(40.0), std::nullopt, std::nullopt}}};
|
||||
const FitResult fit = FittingService::fit_compound(corner, params);
|
||||
|
||||
// Sequence: KP -> arc(60) -> KP(60->40) -> arc(40) -> KP.
|
||||
CHECK(fit.curve.size() == 5);
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[0]));
|
||||
CHECK(std::holds_alternative<CircularArc>(fit.curve[1]));
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[2]));
|
||||
CHECK(std::holds_alternative<CircularArc>(fit.curve[3]));
|
||||
CHECK(std::holds_alternative<TransitionCurve>(fit.curve[4]));
|
||||
|
||||
// Entry transition: straight (inf) -> R60.
|
||||
const auto& kp_in = std::get<TransitionCurve>(fit.curve[0]);
|
||||
CHECK(!kp_in.start_radius().has_value());
|
||||
CHECK(kp_in.end_radius().value().metres() == 60.0);
|
||||
CHECK(kp_in.length().metres() == 10.0);
|
||||
|
||||
// Between transition: R60 -> R40.
|
||||
const auto& kp_mid = std::get<TransitionCurve>(fit.curve[2]);
|
||||
CHECK(kp_mid.start_radius().value().metres() == 60.0);
|
||||
CHECK(kp_mid.end_radius().value().metres() == 40.0);
|
||||
CHECK(kp_mid.length().metres() == 8.0);
|
||||
|
||||
// Exit transition: R40 -> straight (inf).
|
||||
const auto& kp_out = std::get<TransitionCurve>(fit.curve[4]);
|
||||
CHECK(kp_out.start_radius().value().metres() == 40.0);
|
||||
CHECK(!kp_out.end_radius().has_value());
|
||||
|
||||
// The chain still closes onto both fixed lines.
|
||||
CHECK(std::abs(fit.tangent_in.y() - 0.0) < 1e-6);
|
||||
CHECK(std::abs(fit.tangent_out.x() - 100.0) < 1e-6);
|
||||
CHECK(fit.direction == TurnDirection::Left);
|
||||
}
|
||||
|
||||
void compound_invalid_inputs_are_rejected() {
|
||||
const Niweleta corner = make_corner();
|
||||
const auto arc = [](double r, double len) {
|
||||
return CompoundArc{Radius::from_metres(r), Length::from_metres(len)};
|
||||
};
|
||||
const auto last = [](double r) { return CompoundArc{Radius::from_metres(r)}; };
|
||||
|
||||
// Equal neighbouring radii.
|
||||
CHECK_THROWS(FittingService::fit_compound(
|
||||
corner, CompoundFitParameters{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {arc(50.0, 10.0), last(50.0)}}));
|
||||
|
||||
// Split lengths exceed the total deflection.
|
||||
CHECK_THROWS(FittingService::fit_compound(
|
||||
corner, CompoundFitParameters{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {arc(60.0, 1000.0), last(40.0)}}));
|
||||
|
||||
// A non-last arc without a length.
|
||||
CHECK_THROWS(FittingService::fit_compound(
|
||||
corner, CompoundFitParameters{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.arcs = {last(60.0), last(40.0)}}));
|
||||
}
|
||||
|
||||
void fit_equal_radius_reverse_curve_between_parallel_straights() {
|
||||
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{200.0, 0.0}};
|
||||
const Straight exit{StraightId{2}, CartesianPosition{0.0, 20.0},
|
||||
CartesianPosition{200.0, 20.0}};
|
||||
|
||||
const FitResult fit =
|
||||
FittingService::fit_reverse(entry, exit, Radius::from_metres(50.0));
|
||||
CHECK(fit.curve.size() == 2);
|
||||
const auto& first = std::get<CircularArc>(fit.curve[0]);
|
||||
const auto& second = std::get<CircularArc>(fit.curve[1]);
|
||||
CHECK(first.radius() == second.radius());
|
||||
CHECK(first.radius() == Radius::from_metres(50.0));
|
||||
CHECK(first.direction() != second.direction());
|
||||
CHECK(first.length().has_value());
|
||||
CHECK(second.length().has_value());
|
||||
CHECK(std::abs(first.length()->metres() - second.length()->metres()) < 1e-9);
|
||||
CHECK(std::abs(fit.tangent_in.y()) < 1e-9);
|
||||
CHECK(std::abs(fit.tangent_out.y() - 20.0) < 1e-6);
|
||||
CHECK(fit.tangent_in.x() > 0.0 && fit.tangent_in.x() <= 200.0);
|
||||
CHECK(fit.tangent_out.x() >= 0.0 && fit.tangent_out.x() < 200.0);
|
||||
}
|
||||
|
||||
void reverse_curve_rejects_collinear_and_antiparallel_straights() {
|
||||
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
const Straight collinear{StraightId{2}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
const Straight antiparallel{StraightId{3}, CartesianPosition{100.0, 20.0},
|
||||
CartesianPosition{0.0, 20.0}};
|
||||
const Radius radius = Radius::from_metres(30.0);
|
||||
|
||||
CHECK_THROWS(FittingService::fit_reverse(entry, collinear, radius));
|
||||
CHECK_THROWS(FittingService::fit_reverse(entry, antiparallel, radius));
|
||||
}
|
||||
|
||||
void fit_reverse_curve_between_nonparallel_straights() {
|
||||
const Straight entry{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
// This exit line is constructed around the end pose of a R=30 m chain:
|
||||
// left 60 degrees followed by right 30 degrees.
|
||||
const double heading = std::numbers::pi / 6.0;
|
||||
const double tangent_x = 86.9615242271;
|
||||
const double tangent_y = 25.9807621135;
|
||||
const double dx = std::cos(heading);
|
||||
const double dy = std::sin(heading);
|
||||
const Straight exit{
|
||||
StraightId{2},
|
||||
CartesianPosition{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy},
|
||||
CartesianPosition{tangent_x + 70.0 * dx, tangent_y + 70.0 * dy}};
|
||||
|
||||
const FitResult fit =
|
||||
FittingService::fit_reverse(entry, exit, Radius::from_metres(30.0));
|
||||
CHECK(fit.curve.size() == 2);
|
||||
const auto& first = std::get<CircularArc>(fit.curve[0]);
|
||||
const auto& second = std::get<CircularArc>(fit.curve[1]);
|
||||
CHECK(first.direction() != second.direction());
|
||||
CHECK(first.radius() == second.radius());
|
||||
CHECK(std::abs(fit.tangent_in.y()) < 1e-6);
|
||||
const double exit_cross =
|
||||
(fit.tangent_out.x() - exit.start().x()) * dy -
|
||||
(fit.tangent_out.y() - exit.start().y()) * dx;
|
||||
CHECK(std::abs(exit_cross) < 1e-5);
|
||||
}
|
||||
|
||||
void apply_fit_replaces_straights_in_plan() {
|
||||
Niweleta corner = make_corner(); // straight(1) --break--> straight(2)
|
||||
const FitParameters params{.entry = StraightId{1},
|
||||
.exit = StraightId{2},
|
||||
.radius = Radius::from_metres(50.0),
|
||||
.transition_length = std::nullopt};
|
||||
const FitResult fit = FittingService::fit_between_straights(corner, params);
|
||||
|
||||
corner.apply_fit(fit);
|
||||
|
||||
// Plan is now: straight(1) -> arc -> straight(2), tangent joints, no break.
|
||||
CHECK(corner.plan().size() == 3);
|
||||
CHECK(std::holds_alternative<Straight>(corner.plan().elements()[0]));
|
||||
CHECK(std::holds_alternative<CircularArc>(corner.plan().elements()[1]));
|
||||
CHECK(std::holds_alternative<Straight>(corner.plan().elements()[2]));
|
||||
CHECK(corner.plan().joint(0) == JointContinuity::Tangent);
|
||||
CHECK(corner.plan().joint(1) == JointContinuity::Tangent);
|
||||
|
||||
// Straights keep their ids and are trimmed to the tangent points.
|
||||
const auto& first = std::get<Straight>(corner.plan().elements()[0]);
|
||||
const auto& last = std::get<Straight>(corner.plan().elements()[2]);
|
||||
CHECK(first.id() == StraightId{1});
|
||||
CHECK(last.id() == StraightId{2});
|
||||
CHECK(std::abs(first.end().x() - 50.0) < 1e-6);
|
||||
CHECK(std::abs(last.start().y() - 50.0) < 1e-6);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(fit_plain_arc);
|
||||
RUN(fit_with_symmetric_transitions);
|
||||
RUN(invalid_inputs_are_rejected);
|
||||
RUN(fit_compound_curve_two_arcs);
|
||||
RUN(fit_compound_curve_three_arcs);
|
||||
RUN(fit_compound_with_transitions);
|
||||
RUN(compound_invalid_inputs_are_rejected);
|
||||
RUN(fit_equal_radius_reverse_curve_between_parallel_straights);
|
||||
RUN(reverse_curve_rejects_collinear_and_antiparallel_straights);
|
||||
RUN(fit_reverse_curve_between_nonparallel_straights);
|
||||
RUN(apply_fit_replaces_straights_in_plan);
|
||||
return REPORT();
|
||||
}
|
||||
25
maj0sted/tests/test_map_project.cpp
Normal file
25
maj0sted/tests/test_map_project.cpp
Normal file
@@ -0,0 +1,25 @@
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
int main() {
|
||||
// A map project defaults to the Cartesian CRS EPSG:2180.
|
||||
const MapProject project;
|
||||
CHECK(project.crs() == Crs::default_crs());
|
||||
CHECK(project.crs().epsg() == 2180);
|
||||
|
||||
// An explicit CRS is honoured.
|
||||
const MapProject wgs84{Crs{4326}};
|
||||
CHECK(wgs84.crs().epsg() == 4326);
|
||||
|
||||
// Cartesian position value object.
|
||||
const CartesianPosition p{100.5, 200.25};
|
||||
CHECK(p.x() == 100.5);
|
||||
CHECK(p.y() == 200.25);
|
||||
CHECK(CartesianPosition{1.0, 2.0} == CartesianPosition{1.0, 2.0});
|
||||
CHECK(CartesianPosition{1.0, 2.0} != CartesianPosition{2.0, 1.0});
|
||||
CHECK(CartesianPosition{}.x() == 0.0);
|
||||
|
||||
return REPORT();
|
||||
}
|
||||
110
maj0sted/tests/test_niweleta.cpp
Normal file
110
maj0sted/tests/test_niweleta.cpp
Normal file
@@ -0,0 +1,110 @@
|
||||
#include <variant>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
void plan_and_profile_element_lengths() {
|
||||
const PlanElement straight =
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
|
||||
const PlanElement arc = CircularArc{Radius::from_metres(300.0),
|
||||
TurnDirection::Left,
|
||||
Length::from_metres(50.0)};
|
||||
const PlanElement transition = TransitionCurve{
|
||||
Length::from_metres(30.0), std::nullopt, Radius::from_metres(300.0),
|
||||
TurnDirection::Left};
|
||||
CHECK(length_of(straight).value().metres() == 100.0);
|
||||
CHECK(length_of(arc).value().metres() == 50.0);
|
||||
CHECK(length_of(transition).value().metres() == 30.0);
|
||||
|
||||
const ProfileElement grade = Grade{Length::from_metres(120.0), 5.0};
|
||||
const ProfileElement vcurve = VerticalCurve{
|
||||
Length::from_metres(60.0), Radius::from_metres(4000.0),
|
||||
VerticalCurvature::Crest};
|
||||
CHECK(length_of(grade).metres() == 120.0);
|
||||
CHECK(length_of(vcurve).metres() == 60.0);
|
||||
}
|
||||
|
||||
void value_objects_guard_invariants() {
|
||||
CHECK_THROWS(Length::from_metres(-1.0));
|
||||
CHECK_THROWS(Radius::from_metres(0.0));
|
||||
CHECK_THROWS(Radius::from_metres(-5.0));
|
||||
}
|
||||
|
||||
void niweleta_aggregates_plan_and_profile() {
|
||||
Niweleta n{NiweletaId{1}, "Odcinek 1"};
|
||||
CHECK(n.name() == "Odcinek 1");
|
||||
CHECK(n.plan().empty());
|
||||
CHECK(n.profile().empty());
|
||||
|
||||
// Plan: prosta -> klotoida -> łuk -> klotoida -> prosta (razem 180 m)
|
||||
n.add_plan_element(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
n.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt,
|
||||
Radius::from_metres(300.0),
|
||||
TurnDirection::Right});
|
||||
n.add_plan_element(CircularArc{Radius::from_metres(300.0),
|
||||
TurnDirection::Right,
|
||||
Length::from_metres(20.0)});
|
||||
n.add_plan_element(TransitionCurve{Length::from_metres(30.0),
|
||||
Radius::from_metres(300.0), std::nullopt,
|
||||
TurnDirection::Right});
|
||||
n.add_plan_element(
|
||||
Straight{CartesianPosition{100.0, 0.0}, CartesianPosition{150.0, 0.0}});
|
||||
|
||||
// Profil: pochylenie -> łuk pionowy -> pochylenie (razem 180 m)
|
||||
n.add_profile_element(Grade{Length::from_metres(80.0), 3.0});
|
||||
n.add_profile_element(VerticalCurve{Length::from_metres(40.0),
|
||||
Radius::from_metres(5000.0),
|
||||
VerticalCurvature::Sag});
|
||||
n.add_profile_element(Grade{Length::from_metres(60.0), -2.0});
|
||||
|
||||
CHECK(n.plan().size() == 5);
|
||||
CHECK(n.profile().size() == 3);
|
||||
CHECK(n.plan_length().metres() == 180.0);
|
||||
CHECK(n.profile_length().metres() == 180.0);
|
||||
CHECK(n.lengths_consistent());
|
||||
}
|
||||
|
||||
void inconsistent_lengths_are_detected() {
|
||||
Niweleta n{NiweletaId{2}};
|
||||
n.add_plan_element(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}});
|
||||
n.add_profile_element(Grade{Length::from_metres(90.0), 0.0});
|
||||
CHECK(!n.lengths_consistent());
|
||||
CHECK(n.plan_length().metres() == 100.0);
|
||||
CHECK(n.profile_length().metres() == 90.0);
|
||||
}
|
||||
|
||||
void niweleta_assigns_straight_ids() {
|
||||
Niweleta n{NiweletaId{3}};
|
||||
// add_straight mints and returns fresh ids.
|
||||
const StraightId a =
|
||||
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0});
|
||||
const StraightId b =
|
||||
n.add_straight(CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0});
|
||||
CHECK(!a.is_null());
|
||||
CHECK(!b.is_null());
|
||||
CHECK(a != b);
|
||||
|
||||
// An anonymous straight added via add_plan_element is identified too.
|
||||
Niweleta m{NiweletaId{4}};
|
||||
m.add_plan_element(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
const auto& element = m.plan().elements().front();
|
||||
CHECK(!std::get<Straight>(element).id().is_null());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(plan_and_profile_element_lengths);
|
||||
RUN(value_objects_guard_invariants);
|
||||
RUN(niweleta_aggregates_plan_and_profile);
|
||||
RUN(inconsistent_lengths_are_detected);
|
||||
RUN(niweleta_assigns_straight_ids);
|
||||
return REPORT();
|
||||
}
|
||||
85
maj0sted/tests/test_parallelism.cpp
Normal file
85
maj0sted/tests/test_parallelism.cpp
Normal file
@@ -0,0 +1,85 @@
|
||||
#include <cmath>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
void mark_parallel_references_two_straights() {
|
||||
// Two identified straights (typically from different niwelety).
|
||||
const Straight origin{StraightId{10}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
const Straight parallel{StraightId{20}, CartesianPosition{0.0, 5.0},
|
||||
CartesianPosition{100.0, 5.0}};
|
||||
|
||||
const TrackOffset offset{Length::from_metres(5.0), Side::Left};
|
||||
const auto relation = ParallelismService::mark_parallel(origin, parallel, offset);
|
||||
|
||||
CHECK(relation.origin() == StraightId{10});
|
||||
CHECK(relation.parallel() == StraightId{20});
|
||||
CHECK(relation.offset().distance().metres() == 5.0);
|
||||
CHECK(relation.offset().side() == Side::Left);
|
||||
}
|
||||
|
||||
void marking_requires_identified_distinct_straights() {
|
||||
const Straight anonymous{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
|
||||
const Straight a{StraightId{1}, CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{100.0, 0.0}};
|
||||
const Straight b{StraightId{2}, CartesianPosition{0.0, 5.0},
|
||||
CartesianPosition{100.0, 5.0}};
|
||||
const TrackOffset offset{Length::from_metres(5.0), Side::Left};
|
||||
|
||||
// A straight without an id cannot be referenced.
|
||||
CHECK_THROWS(ParallelismService::mark_parallel(anonymous, b, offset));
|
||||
CHECK_THROWS(ParallelismService::mark_parallel(a, anonymous, offset));
|
||||
|
||||
// A straight cannot be parallel to itself (same id).
|
||||
const Straight a_again{StraightId{1}, CartesianPosition{0.0, 10.0},
|
||||
CartesianPosition{100.0, 10.0}};
|
||||
CHECK_THROWS(ParallelismService::mark_parallel(a, a_again, offset));
|
||||
}
|
||||
|
||||
void offset_must_be_positive() {
|
||||
CHECK_THROWS(TrackOffset(Length::from_metres(0.0), Side::Left));
|
||||
}
|
||||
|
||||
void parallelism_geometry() {
|
||||
const Straight origin{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
|
||||
const Straight same_direction{CartesianPosition{0.0, 5.0},
|
||||
CartesianPosition{100.0, 5.0}};
|
||||
const Straight opposite{CartesianPosition{100.0, 5.0}, CartesianPosition{0.0, 5.0}};
|
||||
const Straight perpendicular{CartesianPosition{0.0, 0.0},
|
||||
CartesianPosition{0.0, 50.0}};
|
||||
|
||||
CHECK(ParallelismService::are_parallel(origin, same_direction));
|
||||
CHECK(ParallelismService::are_parallel(origin, opposite)); // anti-parallel lines
|
||||
CHECK(!ParallelismService::are_parallel(origin, perpendicular));
|
||||
|
||||
// Derive a line 5 m to the LEFT of an east-going origin -> to the north (y = +5).
|
||||
const Straight left = ParallelismService::derive_parallel_line(
|
||||
origin, TrackOffset{Length::from_metres(5.0), Side::Left});
|
||||
CHECK(std::abs(left.start().x() - 0.0) < 1e-9);
|
||||
CHECK(std::abs(left.start().y() - 5.0) < 1e-9);
|
||||
CHECK(std::abs(left.end().y() - 5.0) < 1e-9);
|
||||
CHECK(ParallelismService::are_parallel(origin, left));
|
||||
|
||||
// To the RIGHT -> to the south (y = -5).
|
||||
const Straight right = ParallelismService::derive_parallel_line(
|
||||
origin, TrackOffset{Length::from_metres(5.0), Side::Right});
|
||||
CHECK(std::abs(right.start().y() + 5.0) < 1e-9);
|
||||
|
||||
CHECK(std::abs(ParallelismService::perpendicular_distance(origin, same_direction) -
|
||||
5.0) < 1e-9);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(mark_parallel_references_two_straights);
|
||||
RUN(marking_requires_identified_distinct_straights);
|
||||
RUN(offset_must_be_positive);
|
||||
RUN(parallelism_geometry);
|
||||
return REPORT();
|
||||
}
|
||||
132
maj0sted/tests/test_plan_alignment.cpp
Normal file
132
maj0sted/tests/test_plan_alignment.cpp
Normal file
@@ -0,0 +1,132 @@
|
||||
#include <cmath>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
void straight_computes_azimuth_and_length() {
|
||||
// Going east (+x): azimuth = 90 deg (clockwise from north).
|
||||
const Straight east{CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0}};
|
||||
CHECK(std::abs(east.length().metres() - 100.0) < 1e-9);
|
||||
CHECK(std::abs(east.azimuth().degrees() - 90.0) < 1e-9);
|
||||
|
||||
// Going north (+y): azimuth = 0 deg.
|
||||
const Straight north{CartesianPosition{0.0, 0.0}, CartesianPosition{0.0, 50.0}};
|
||||
CHECK(std::abs(north.azimuth().degrees() - 0.0) < 1e-9);
|
||||
CHECK(std::abs(north.length().metres() - 50.0) < 1e-9);
|
||||
|
||||
// A zero-length straight has no azimuth and is rejected.
|
||||
CHECK_THROWS(Straight(CartesianPosition{1.0, 1.0}, CartesianPosition{1.0, 1.0}));
|
||||
}
|
||||
|
||||
void arc_carries_radius_direction_and_optional_length() {
|
||||
const CircularArc without_length{Radius::from_metres(300.0), TurnDirection::Left};
|
||||
CHECK(!without_length.length().has_value());
|
||||
|
||||
const CircularArc with_length{Radius::from_metres(300.0), TurnDirection::Left,
|
||||
Length::from_metres(45.0)};
|
||||
CHECK(with_length.length().has_value());
|
||||
CHECK(with_length.length().value().metres() == 45.0);
|
||||
}
|
||||
|
||||
void adjacent_same_arcs_are_rejected() {
|
||||
HorizontalAlignment same;
|
||||
same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
|
||||
// same radius + same direction -> forbidden
|
||||
CHECK_THROWS(
|
||||
same.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left}));
|
||||
|
||||
HorizontalAlignment opposite;
|
||||
opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
|
||||
// same radius, different direction -> allowed (S-curve)
|
||||
opposite.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Right});
|
||||
CHECK(opposite.size() == 2);
|
||||
|
||||
HorizontalAlignment compound;
|
||||
compound.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left});
|
||||
// same direction, different radius -> allowed (compound curve)
|
||||
compound.append(CircularArc{Radius::from_metres(500.0), TurnDirection::Left});
|
||||
CHECK(compound.size() == 2);
|
||||
}
|
||||
|
||||
void straight_to_straight_tangent_requires_equal_azimuth() {
|
||||
// Collinear straights, same azimuth, touching -> tangent OK.
|
||||
HorizontalAlignment collinear;
|
||||
collinear.append(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
collinear.append(
|
||||
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}});
|
||||
CHECK(collinear.size() == 2);
|
||||
CHECK(collinear.joint(0) == JointContinuity::Tangent);
|
||||
|
||||
// Touching but different azimuth with a Tangent joint -> must be a break.
|
||||
HorizontalAlignment unmarked;
|
||||
unmarked.append(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
CHECK_THROWS(unmarked.append(
|
||||
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}}));
|
||||
|
||||
// Non-touching tangent straights -> invalid.
|
||||
HorizontalAlignment gap;
|
||||
gap.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
CHECK_THROWS(gap.append(
|
||||
Straight{CartesianPosition{60.0, 0.0}, CartesianPosition{100.0, 0.0}}));
|
||||
}
|
||||
|
||||
void azimuth_break_between_straights_must_be_marked() {
|
||||
// Corner marked as AzimuthBreak -> OK.
|
||||
HorizontalAlignment kink;
|
||||
kink.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
kink.append(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}},
|
||||
JointContinuity::AzimuthBreak);
|
||||
CHECK(kink.size() == 2);
|
||||
CHECK(kink.joint(0) == JointContinuity::AzimuthBreak);
|
||||
|
||||
// AzimuthBreak with equal azimuth is pointless -> invalid.
|
||||
HorizontalAlignment straight_break;
|
||||
straight_break.append(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
CHECK_THROWS(straight_break.append(
|
||||
Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{100.0, 0.0}},
|
||||
JointContinuity::AzimuthBreak));
|
||||
|
||||
// AzimuthBreak is only allowed between two straights.
|
||||
HorizontalAlignment arc_break;
|
||||
arc_break.append(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
CHECK_THROWS(arc_break.append(
|
||||
CircularArc{Radius::from_metres(300.0), TurnDirection::Left},
|
||||
JointContinuity::AzimuthBreak));
|
||||
}
|
||||
|
||||
void straight_curve_joints_are_accepted_pending_fit() {
|
||||
// prosta -> klotoida -> łuk -> klotoida -> prosta
|
||||
HorizontalAlignment plan;
|
||||
plan.append(Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
plan.append(TransitionCurve{Length::from_metres(30.0), std::nullopt,
|
||||
Radius::from_metres(300.0), TurnDirection::Left});
|
||||
plan.append(CircularArc{Radius::from_metres(300.0), TurnDirection::Left,
|
||||
Length::from_metres(40.0)});
|
||||
plan.append(TransitionCurve{Length::from_metres(30.0),
|
||||
Radius::from_metres(300.0), std::nullopt,
|
||||
TurnDirection::Left});
|
||||
plan.append(Straight{CartesianPosition{200.0, 0.0}, CartesianPosition{260.0, 0.0}});
|
||||
CHECK(plan.size() == 5);
|
||||
// Known lengths: 50 + 30 + 40 + 30 + 60.
|
||||
CHECK(plan.total_length().metres() == 210.0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(straight_computes_azimuth_and_length);
|
||||
RUN(arc_carries_radius_direction_and_optional_length);
|
||||
RUN(adjacent_same_arcs_are_rejected);
|
||||
RUN(straight_to_straight_tangent_requires_equal_azimuth);
|
||||
RUN(azimuth_break_between_straights_must_be_marked);
|
||||
RUN(straight_curve_joints_are_accepted_pending_fit);
|
||||
return REPORT();
|
||||
}
|
||||
78
maj0sted/tests/test_render.cpp
Normal file
78
maj0sted/tests/test_render.cpp
Normal file
@@ -0,0 +1,78 @@
|
||||
#include <cmath>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/io/svg_writer.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
#include "maj0sted/render/plan_sampler.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
void samples_one_polyline_per_element() {
|
||||
Niweleta n{NiweletaId{1}};
|
||||
const StraightId a =
|
||||
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
|
||||
const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0},
|
||||
CartesianPosition{100.0, 100.0},
|
||||
JointContinuity::AzimuthBreak);
|
||||
const FitResult fit = FittingService::fit_between_straights(
|
||||
n, FitParameters{.entry = a,
|
||||
.exit = b,
|
||||
.radius = Radius::from_metres(40.0),
|
||||
.transition_length = Length::from_metres(10.0)});
|
||||
n.apply_fit(fit); // straight -> KP -> arc -> KP -> straight (5 elements)
|
||||
|
||||
const auto scene = maj0sted::render::sample(n.plan());
|
||||
CHECK(scene.polylines.size() == n.plan().size());
|
||||
CHECK(scene.polylines.size() == 5);
|
||||
CHECK(!scene.empty);
|
||||
|
||||
// First polyline is the (straight) entry, drawn from its anchored endpoints.
|
||||
const auto& first = scene.polylines.front();
|
||||
CHECK(first.kind == maj0sted::render::ElementKind::Straight);
|
||||
CHECK(first.points.size() == 2);
|
||||
CHECK(std::abs(first.points.front().x - 0.0) < 1e-9);
|
||||
|
||||
// Curves are sampled into many points.
|
||||
CHECK(scene.polylines[2].kind == maj0sted::render::ElementKind::Arc);
|
||||
CHECK(scene.polylines[2].points.size() > 2);
|
||||
}
|
||||
|
||||
void curve_geometry_is_continuous() {
|
||||
// The sampled points should join up (each element starts where the previous
|
||||
// ended) for a properly fitted, tangent-continuous plan.
|
||||
Niweleta n{NiweletaId{2}};
|
||||
const StraightId a =
|
||||
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
|
||||
const StraightId b = n.add_straight(CartesianPosition{100.0, 0.0},
|
||||
CartesianPosition{100.0, 100.0},
|
||||
JointContinuity::AzimuthBreak);
|
||||
n.apply_fit(FittingService::fit_between_straights(
|
||||
n, FitParameters{.entry = a, .exit = b, .radius = Radius::from_metres(40.0)}));
|
||||
|
||||
const auto scene = maj0sted::render::sample(n.plan());
|
||||
for (std::size_t i = 1; i < scene.polylines.size(); ++i) {
|
||||
const auto& prev_end = scene.polylines[i - 1].points.back();
|
||||
const auto& next_start = scene.polylines[i].points.front();
|
||||
CHECK(std::abs(prev_end.x - next_start.x) < 1e-6);
|
||||
CHECK(std::abs(prev_end.y - next_start.y) < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
void svg_is_produced() {
|
||||
Niweleta n{NiweletaId{3}};
|
||||
n.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0});
|
||||
const auto svg = maj0sted::io::to_svg(maj0sted::render::sample(n.plan()));
|
||||
CHECK(svg.rfind("<svg", 0) == 0);
|
||||
CHECK(svg.find("<polyline") != std::string::npos);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(samples_one_polyline_per_element);
|
||||
RUN(curve_geometry_is_continuous);
|
||||
RUN(svg_is_produced);
|
||||
return REPORT();
|
||||
}
|
||||
92
maj0sted/tests/test_serialization.cpp
Normal file
92
maj0sted/tests/test_serialization.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
#include <variant>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/maj0sted.hpp"
|
||||
|
||||
using namespace maj0sted::domain;
|
||||
|
||||
namespace {
|
||||
|
||||
MapProject make_project() {
|
||||
MapProject project{Crs{2180}};
|
||||
|
||||
Niweleta a{NiweletaId{5}, "Odcinek A"};
|
||||
const StraightId s1 =
|
||||
a.add_straight(CartesianPosition{0.0, 0.0}, CartesianPosition{100.0, 0.0});
|
||||
(void)s1;
|
||||
a.add_plan_element(TransitionCurve{Length::from_metres(30.0), std::nullopt,
|
||||
Radius::from_metres(300.0), TurnDirection::Right});
|
||||
a.add_plan_element(CircularArc{Radius::from_metres(300.0), TurnDirection::Right,
|
||||
Length::from_metres(40.0)});
|
||||
a.add_profile_element(Grade{Length::from_metres(80.0), 3.0});
|
||||
a.add_profile_element(VerticalCurve{Length::from_metres(40.0),
|
||||
Radius::from_metres(5000.0),
|
||||
VerticalCurvature::Sag});
|
||||
project.add_niweleta(std::move(a));
|
||||
|
||||
// A second niweleta with an azimuth break, to exercise joints.
|
||||
Niweleta b{NiweletaId{9}, "Odcinek B z załamaniem"};
|
||||
b.add_plan_element(
|
||||
Straight{CartesianPosition{0.0, 0.0}, CartesianPosition{50.0, 0.0}});
|
||||
b.add_plan_element(Straight{CartesianPosition{50.0, 0.0}, CartesianPosition{50.0, 50.0}},
|
||||
JointContinuity::AzimuthBreak);
|
||||
project.add_niweleta(std::move(b));
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
void round_trip_is_stable() {
|
||||
const MapProject project = make_project();
|
||||
const std::string text = maj0sted::io::serialize(project);
|
||||
const MapProject loaded = maj0sted::io::deserialize(text);
|
||||
|
||||
// Re-serializing the loaded project yields byte-identical text.
|
||||
CHECK(maj0sted::io::serialize(loaded) == text);
|
||||
|
||||
CHECK(loaded.crs().epsg() == 2180);
|
||||
CHECK(loaded.niwelety().size() == 2);
|
||||
}
|
||||
|
||||
void niweleta_and_geometry_survive() {
|
||||
const MapProject loaded = maj0sted::io::deserialize(maj0sted::io::serialize(make_project()));
|
||||
|
||||
const Niweleta& a = loaded.niwelety()[0];
|
||||
CHECK(a.id() == NiweletaId{5});
|
||||
CHECK(a.name() == "Odcinek A");
|
||||
CHECK(a.plan().size() == 3);
|
||||
CHECK(a.profile().size() == 2);
|
||||
|
||||
// Straight kept its id and geometry.
|
||||
const auto& straight = std::get<Straight>(a.plan().elements()[0]);
|
||||
CHECK(straight.id() == StraightId{1});
|
||||
CHECK(straight.length().metres() == 100.0);
|
||||
|
||||
// Arc kept radius, direction and length.
|
||||
const auto& arc = std::get<CircularArc>(a.plan().elements()[2]);
|
||||
CHECK(arc.radius().metres() == 300.0);
|
||||
CHECK(arc.direction() == TurnDirection::Right);
|
||||
CHECK(arc.length().value().metres() == 40.0);
|
||||
|
||||
// Profile survived.
|
||||
const auto& grade = std::get<Grade>(a.profile().elements()[0]);
|
||||
CHECK(grade.slope_permille() == 3.0);
|
||||
|
||||
// Name with spaces and the azimuth-break joint survived on the 2nd niweleta.
|
||||
const Niweleta& b = loaded.niwelety()[1];
|
||||
CHECK(b.name() == "Odcinek B z załamaniem");
|
||||
CHECK(b.plan().joint(0) == JointContinuity::AzimuthBreak);
|
||||
}
|
||||
|
||||
void malformed_input_throws() {
|
||||
CHECK_THROWS(maj0sted::io::deserialize("not a maj0sted file"));
|
||||
CHECK_THROWS(maj0sted::io::deserialize("maj0sted 1\ncrs 2180\nniwelety 3\n"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(round_trip_is_stable);
|
||||
RUN(niweleta_and_geometry_survive);
|
||||
RUN(malformed_input_throws);
|
||||
return REPORT();
|
||||
}
|
||||
208
maj0sted/tests/test_tile_cache.cpp
Normal file
208
maj0sted/tests/test_tile_cache.cpp
Normal file
@@ -0,0 +1,208 @@
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/web/tile_cache.hpp"
|
||||
|
||||
using namespace maj0sted::web;
|
||||
|
||||
namespace {
|
||||
|
||||
// --- tile grid geometry --------------------------------------------------
|
||||
|
||||
void grid_is_fixed_100m_cells() {
|
||||
// One fixed grid: every cell is 100 m × 100 m regardless of the zoom arg.
|
||||
CHECK(std::abs(TileGrid::kCellMetres - 100.0) < 1e-9);
|
||||
CHECK(std::abs(TileGrid::tile_span() - 100.0) < 1e-6);
|
||||
CHECK(std::abs(TileGrid::tile_span(14) - 100.0) < 1e-6); // zoom ignored
|
||||
// Resolution is constant: 100 m over 1024 px.
|
||||
CHECK(std::abs(TileGrid::resolution() - 100.0 / 1024.0) < 1e-9);
|
||||
CHECK(std::abs(TileGrid::resolution(7) - 100.0 / 1024.0) < 1e-9);
|
||||
}
|
||||
|
||||
void tile_at_and_bbox_are_consistent() {
|
||||
const double x = 500123.0; // somewhere in EPSG:2180 (Poland)
|
||||
const double y = 300456.0;
|
||||
const TileKey key = TileGrid::tile_at(x, y);
|
||||
const TileBBox box = TileGrid::bbox(key);
|
||||
|
||||
// Every tile key sits on the single fixed level.
|
||||
CHECK(key.zoom == TileGrid::kLevel);
|
||||
|
||||
// The point must fall inside its own cell's ground box.
|
||||
CHECK(box.min_x <= x && x < box.max_x);
|
||||
CHECK(box.min_y <= y && y < box.max_y);
|
||||
|
||||
// The box edge length must equal the fixed 100 m cell span.
|
||||
CHECK(std::abs((box.max_x - box.min_x) - 100.0) < 1e-6);
|
||||
CHECK(std::abs((box.max_y - box.min_y) - 100.0) < 1e-6);
|
||||
|
||||
// Neighbouring cells are exactly one span (100 m) apart.
|
||||
const TileBBox right = TileGrid::bbox(TileKey{TileGrid::kLevel, key.x + 1, key.y});
|
||||
CHECK(std::abs(right.min_x - box.max_x) < 1e-6);
|
||||
}
|
||||
|
||||
void zoom_for_resolution_is_single_level() {
|
||||
// There is no pyramid: any target resolution maps to the one fixed level.
|
||||
for (double mpp : {50.0, 8.0, 1.0, 0.25}) {
|
||||
CHECK(TileGrid::zoom_for_resolution(mpp) == TileGrid::kLevel);
|
||||
}
|
||||
}
|
||||
|
||||
void tiles_for_view_covers_and_caps() {
|
||||
const double span = 100.0; // fixed cell
|
||||
// A view roughly 3x2 cells.
|
||||
TileBBox view{500000.0, 300000.0, 500000.0 + 2.5 * span, 300000.0 + 1.5 * span};
|
||||
const auto tiles = TileGrid::tiles_for_view(view);
|
||||
CHECK(!tiles.empty());
|
||||
// Every corner cell of the view must be present.
|
||||
const TileKey lo = TileGrid::tile_at(view.min_x, view.min_y);
|
||||
const TileKey hi = TileGrid::tile_at(view.max_x, view.max_y);
|
||||
bool has_lo = false, has_hi = false;
|
||||
for (const auto& t : tiles) {
|
||||
if (t == lo) has_lo = true;
|
||||
if (t == hi) has_hi = true;
|
||||
}
|
||||
CHECK(has_lo && has_hi);
|
||||
|
||||
// The cap is honoured.
|
||||
TileBBox huge{0.0, 0.0, 1e9, 1e9};
|
||||
const auto capped = TileGrid::tiles_for_view(huge, TileGrid::kLevel, 32);
|
||||
CHECK(static_cast<int>(capped.size()) <= 32);
|
||||
const TileKey centre = TileGrid::tile_at(5e8, 5e8);
|
||||
bool has_centre = false;
|
||||
for (const auto& tile : capped) {
|
||||
if (tile == centre) has_centre = true;
|
||||
}
|
||||
CHECK(has_centre); // cap is centred, not taken from a remote corner
|
||||
|
||||
const double infinity = std::numeric_limits<double>::infinity();
|
||||
CHECK(TileGrid::tiles_for_view({0.0, 0.0, infinity, 1.0}).empty());
|
||||
}
|
||||
|
||||
// --- WMS URL -------------------------------------------------------------
|
||||
|
||||
void getmap_url_is_wms_1_1_1_epsg2180() {
|
||||
WmsConfig cfg = WmsConfig::geoportal_ortho();
|
||||
const TileBBox box{100.0, 200.0, 300.0, 400.0};
|
||||
const std::string url = wms_getmap_url(cfg, box);
|
||||
CHECK(url.find("SERVICE=WMS") != std::string::npos);
|
||||
CHECK(url.find("VERSION=1.1.1") != std::string::npos);
|
||||
CHECK(url.find("REQUEST=GetMap") != std::string::npos);
|
||||
CHECK(url.find("SRS=EPSG:2180") != std::string::npos);
|
||||
// BBOX in minx,miny,maxx,maxy order.
|
||||
CHECK(url.find("BBOX=100,200,300,400") != std::string::npos);
|
||||
CHECK(url.find("WIDTH=1024") != std::string::npos);
|
||||
}
|
||||
|
||||
// --- cache + injected fetch ---------------------------------------------
|
||||
|
||||
void miss_fetches_then_hit_is_served_from_cache() {
|
||||
TileService service;
|
||||
int fetch_calls = 0;
|
||||
// Synchronous injected fetcher that returns three canned bytes.
|
||||
service.set_fetcher([&](const std::string& url,
|
||||
TileService::DoneFn done) {
|
||||
++fetch_calls;
|
||||
CHECK(url.find("BBOX=") != std::string::npos);
|
||||
done(true, std::vector<std::uint8_t>{1, 2, 3}, "image/jpeg");
|
||||
});
|
||||
|
||||
int ready_events = 0;
|
||||
TileKey last_ready{};
|
||||
service.set_on_ready([&](TileKey k) {
|
||||
++ready_events;
|
||||
last_ready = k;
|
||||
});
|
||||
|
||||
const TileKey key{12, 3, 4};
|
||||
CHECK(!service.has(key));
|
||||
|
||||
// First request: a miss, so exactly one fetch and the tile becomes Ready.
|
||||
const TileStatus first = service.request(key);
|
||||
CHECK(first == TileStatus::Ready); // fetcher was synchronous
|
||||
CHECK(fetch_calls == 1);
|
||||
CHECK(ready_events == 1);
|
||||
CHECK(last_ready == key);
|
||||
CHECK(service.has(key));
|
||||
|
||||
const TileImage* image = service.peek(key);
|
||||
CHECK(image != nullptr);
|
||||
CHECK(image->bytes.size() == 3);
|
||||
CHECK(image->content_type == "image/jpeg");
|
||||
|
||||
// Second request for the same tile: a hit, no extra fetch.
|
||||
const TileStatus second = service.request(key);
|
||||
CHECK(second == TileStatus::Ready);
|
||||
CHECK(fetch_calls == 1); // unchanged: served from cache
|
||||
CHECK(service.size() == 1);
|
||||
}
|
||||
|
||||
void failed_fetch_is_retried_until_success() {
|
||||
// A failure is not terminal: the service retries until a tile arrives. With
|
||||
// a synchronous injected fetcher the retries unwind inline, so a fetcher that
|
||||
// fails twice then succeeds must end Ready after exactly three attempts.
|
||||
TileService service;
|
||||
int calls = 0;
|
||||
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
|
||||
++calls;
|
||||
if (calls < 3) {
|
||||
done(false, {}, {}); // fail
|
||||
} else {
|
||||
done(true, std::vector<std::uint8_t>{7}, "image/jpeg"); // then succeed
|
||||
}
|
||||
});
|
||||
|
||||
const TileKey key{10, 1, 1};
|
||||
const TileStatus status = service.request(key);
|
||||
CHECK(status == TileStatus::Ready);
|
||||
CHECK(calls == 3);
|
||||
CHECK(service.has(key));
|
||||
|
||||
const TileImage* image = service.peek(key);
|
||||
CHECK(image != nullptr);
|
||||
CHECK(image->bytes.size() == 1);
|
||||
}
|
||||
|
||||
void retry_gives_up_after_ten_attempts() {
|
||||
// A tile that never loads is retried at most 10 times, then marked Failed.
|
||||
// With a synchronous always-failing fetcher that is 1 initial + 10 retries.
|
||||
TileService service;
|
||||
int calls = 0;
|
||||
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
|
||||
++calls;
|
||||
done(false, {}, {});
|
||||
});
|
||||
|
||||
const TileKey key{2, 2, 2};
|
||||
CHECK(service.request(key) == TileStatus::Failed);
|
||||
CHECK(calls == 11); // initial + kMaxRetries (10)
|
||||
CHECK(!service.has(key));
|
||||
CHECK(service.peek(key) == nullptr);
|
||||
}
|
||||
|
||||
void store_seeds_cache_directly() {
|
||||
TileService service;
|
||||
const TileKey key{9, 2, 2};
|
||||
service.store(key, std::vector<std::uint8_t>{9, 9}, "image/png");
|
||||
CHECK(service.has(key));
|
||||
// A request now is a pure hit even with no fetcher installed.
|
||||
CHECK(service.request(key) == TileStatus::Ready);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(grid_is_fixed_100m_cells);
|
||||
RUN(tile_at_and_bbox_are_consistent);
|
||||
RUN(zoom_for_resolution_is_single_level);
|
||||
RUN(tiles_for_view_covers_and_caps);
|
||||
RUN(getmap_url_is_wms_1_1_1_epsg2180);
|
||||
RUN(miss_fetches_then_hit_is_served_from_cache);
|
||||
RUN(failed_fetch_is_retried_until_success);
|
||||
RUN(retry_gives_up_after_ten_attempts);
|
||||
RUN(store_seeds_cache_directly);
|
||||
return REPORT();
|
||||
}
|
||||
229
maj0sted/tests/test_web.cpp
Normal file
229
maj0sted/tests/test_web.cpp
Normal file
@@ -0,0 +1,229 @@
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "maj0sted/web/editor.hpp"
|
||||
#include "maj0sted/web/solve.hpp"
|
||||
|
||||
using namespace maj0sted::web;
|
||||
|
||||
namespace {
|
||||
|
||||
bool has_kind(const std::vector<WebPolyline>& polylines, int kind) {
|
||||
for (const auto& p : polylines) {
|
||||
if (p.kind == kind) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void arc_with_transitions_produces_full_chain() {
|
||||
SolveRequest r{};
|
||||
r.ax = 0.0; r.ay = 0.0;
|
||||
r.bx = 300.0; r.by = 0.0;
|
||||
r.cx = 300.0; r.cy = 300.0;
|
||||
r.mode = 1;
|
||||
r.radius = 80.0;
|
||||
r.transition = 20.0;
|
||||
|
||||
const auto polylines = solve_scene(r);
|
||||
CHECK(!polylines.empty());
|
||||
CHECK(has_kind(polylines, 0)); // straight
|
||||
CHECK(has_kind(polylines, 1)); // arc
|
||||
CHECK(has_kind(polylines, 2)); // transition
|
||||
// Each polyline has at least two points.
|
||||
for (const auto& p : polylines) CHECK(p.points.size() >= 2);
|
||||
}
|
||||
|
||||
void compound_mode_produces_two_arcs() {
|
||||
SolveRequest r{};
|
||||
r.ax = 0.0; r.ay = 0.0;
|
||||
r.bx = 300.0; r.by = 0.0;
|
||||
r.cx = 300.0; r.cy = 300.0;
|
||||
r.mode = 2;
|
||||
r.r1 = 120.0; r.arc1_len = 60.0; r.between = 25.0; r.r2 = 80.0;
|
||||
r.entry_t = 30.0; r.exit_t = 30.0;
|
||||
|
||||
const auto polylines = solve_scene(r);
|
||||
int arcs = 0;
|
||||
for (const auto& p : polylines) {
|
||||
if (p.kind == 1) ++arcs;
|
||||
}
|
||||
CHECK(arcs == 2);
|
||||
CHECK(has_kind(polylines, 2)); // transitions present
|
||||
}
|
||||
|
||||
void degenerate_input_falls_back_to_straights() {
|
||||
// Collinear vertices -> no fit; still returns the straights, never throws.
|
||||
SolveRequest r{};
|
||||
r.ax = 0.0; r.ay = 0.0;
|
||||
r.bx = 100.0; r.by = 0.0;
|
||||
r.cx = 200.0; r.cy = 0.0;
|
||||
r.mode = 0;
|
||||
r.radius = 50.0;
|
||||
|
||||
const auto polylines = solve_scene(r);
|
||||
CHECK(!polylines.empty());
|
||||
CHECK(has_kind(polylines, 0)); // straights
|
||||
CHECK(!has_kind(polylines, 1)); // no arc
|
||||
}
|
||||
|
||||
NiweletaSpec short_corner(double radius, double transition = 0.0) {
|
||||
NiweletaSpec spec;
|
||||
spec.straights = {
|
||||
StraightSpec{0.0, 0.0, 10.0, 0.0},
|
||||
StraightSpec{10.0, 0.0, 10.0, 10.0},
|
||||
};
|
||||
spec.fits = {GapFit{.gap = 0,
|
||||
.mode = transition > 0.0 ? 2 : 1,
|
||||
.radius = radius,
|
||||
.transition = transition}};
|
||||
return spec;
|
||||
}
|
||||
|
||||
void editor_clamps_oversized_arc_to_fitting_radius() {
|
||||
const auto solved = solve_project({short_corner(60.0)});
|
||||
CHECK(solved.size() == 1);
|
||||
CHECK(solved[0].applied_fits.size() == 1);
|
||||
const double applied = solved[0].applied_fits[0].radius;
|
||||
CHECK(applied > 9.9);
|
||||
CHECK(applied < 10.1);
|
||||
}
|
||||
|
||||
void editor_leaves_gap_unfitted_when_radius_below_floor() {
|
||||
// A radius too small for the requested transitions is NEVER enlarged (the
|
||||
// fitting rule only ever reduces). When nothing at or below the request fits,
|
||||
// the gap is simply left unconnected.
|
||||
NiweletaSpec spec;
|
||||
spec.straights = {
|
||||
StraightSpec{0.0, 0.0, 100.0, 0.0},
|
||||
StraightSpec{100.0, 0.0, 100.0, 100.0},
|
||||
};
|
||||
spec.fits = {GapFit{.gap = 0, .mode = 2, .radius = 1.0, .transition = 20.0}};
|
||||
|
||||
const auto solved = solve_project({spec});
|
||||
CHECK(solved.size() == 1);
|
||||
CHECK(solved[0].applied_fits.empty());
|
||||
CHECK(!has_kind(solved[0].polylines, 1)); // no arc rendered
|
||||
}
|
||||
|
||||
void editor_emits_two_rails_at_standard_gauge() {
|
||||
const auto solved = solve_project({short_corner(5.0)});
|
||||
CHECK(solved[0].polylines.size() == 6);
|
||||
CHECK(solved[0].rendered_straights.size() == 2);
|
||||
CHECK(std::abs(solved[0].rendered_straights[0].x2 - 5.0) < 1e-6);
|
||||
CHECK(std::abs(solved[0].rendered_straights[1].y1 - 5.0) < 1e-6);
|
||||
const auto& left = solved[0].polylines[0].points;
|
||||
const auto& right = solved[0].polylines[1].points;
|
||||
CHECK(left.size() == right.size());
|
||||
CHECK(std::abs(std::hypot(left[0].x - right[0].x, left[0].y - right[0].y) -
|
||||
1.5) < 1e-9);
|
||||
CHECK(solved[0].polylines[0].straight_index == 0);
|
||||
CHECK(solved[0].polylines[0].gap == -1);
|
||||
CHECK(std::abs(solved[0].polylines[0].length - 5.0) < 1e-9);
|
||||
const WebPolyline* arc = nullptr;
|
||||
for (const auto& polyline : solved[0].polylines) {
|
||||
if (polyline.kind == 1) {
|
||||
arc = &polyline;
|
||||
break;
|
||||
}
|
||||
}
|
||||
CHECK(arc != nullptr);
|
||||
CHECK(arc->straight_index == -1);
|
||||
CHECK(arc->gap == 0);
|
||||
CHECK(arc->element_index == 0);
|
||||
CHECK(std::abs(arc->radius_start - 5.0) < 1e-9);
|
||||
CHECK(std::abs(arc->radius_end - 5.0) < 1e-9);
|
||||
CHECK(std::abs(arc->length - 5.0 * std::numbers::pi / 2.0) < 1e-9);
|
||||
}
|
||||
|
||||
void editor_exposes_transition_radius_range() {
|
||||
const auto solved = solve_project({short_corner(5.0, 2.0)});
|
||||
const WebPolyline* transition = nullptr;
|
||||
for (const auto& polyline : solved[0].polylines) {
|
||||
if (polyline.kind == 2) {
|
||||
transition = &polyline;
|
||||
break;
|
||||
}
|
||||
}
|
||||
CHECK(transition != nullptr);
|
||||
CHECK(transition->gap == 0);
|
||||
CHECK(std::abs(transition->length - 2.0) < 1e-9);
|
||||
CHECK(transition->radius_start == 0.0);
|
||||
CHECK(std::abs(transition->radius_end - 5.0) < 1e-9);
|
||||
}
|
||||
|
||||
void editor_leaves_parallel_offset_unfitted() {
|
||||
// Two parallel, laterally offset straights admit no plain arc (a single arc
|
||||
// cannot connect parallels) and reverse S-curves are never substituted, so the
|
||||
// gap stays unconnected rather than inventing a curve.
|
||||
NiweletaSpec spec;
|
||||
spec.straights = {
|
||||
StraightSpec{0.0, 0.0, 200.0, 0.0},
|
||||
StraightSpec{0.0, 20.0, 200.0, 20.0},
|
||||
};
|
||||
spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 50.0}};
|
||||
|
||||
const auto solved = solve_project({spec});
|
||||
CHECK(solved[0].applied_fits.empty());
|
||||
CHECK(!has_kind(solved[0].polylines, 1)); // no arc, no reverse pair
|
||||
}
|
||||
|
||||
void editor_honours_requested_radius_via_single_arc() {
|
||||
// A requested radius that lands on the straights (extending them toward the
|
||||
// corner if needed) is used verbatim as a single arc — never enlarged and
|
||||
// never swapped for a reverse pair.
|
||||
const double heading = std::numbers::pi / 6.0;
|
||||
const double tangent_x = 86.9615242271;
|
||||
const double tangent_y = 25.9807621135;
|
||||
const double dx = std::cos(heading);
|
||||
const double dy = std::sin(heading);
|
||||
NiweletaSpec spec;
|
||||
spec.straights = {
|
||||
StraightSpec{0.0, 0.0, 100.0, 0.0},
|
||||
StraightSpec{tangent_x - 30.0 * dx, tangent_y - 30.0 * dy,
|
||||
tangent_x + 70.0 * dx, tangent_y + 70.0 * dy},
|
||||
};
|
||||
spec.fits = {GapFit{.gap = 0, .mode = 1, .radius = 30.0}};
|
||||
|
||||
const auto solved = solve_project({spec});
|
||||
CHECK(solved[0].applied_fits.size() == 1);
|
||||
CHECK(std::abs(solved[0].applied_fits[0].radius - 30.0) < 1e-9);
|
||||
int arc_rails = 0;
|
||||
for (const auto& polyline : solved[0].polylines) {
|
||||
if (polyline.kind == 1) ++arc_rails;
|
||||
}
|
||||
CHECK(arc_rails == 2); // a single arc (two rails), not a reverse pair
|
||||
}
|
||||
|
||||
void hidden_tangent_guide_fits_curve_without_rendering_track() {
|
||||
NiweletaSpec spec = short_corner(5.0);
|
||||
spec.straights[1].hidden = true;
|
||||
|
||||
const auto solved = solve_project({spec});
|
||||
CHECK(solved[0].applied_fits.size() == 1);
|
||||
CHECK(solved[0].rendered_straights[1].hidden);
|
||||
int straight_rails = 0;
|
||||
int arc_rails = 0;
|
||||
for (const auto& polyline : solved[0].polylines) {
|
||||
if (polyline.kind == 0) ++straight_rails;
|
||||
if (polyline.kind == 1) ++arc_rails;
|
||||
}
|
||||
CHECK(straight_rails == 2); // only the visible entry straight
|
||||
CHECK(arc_rails == 2);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
RUN(arc_with_transitions_produces_full_chain);
|
||||
RUN(compound_mode_produces_two_arcs);
|
||||
RUN(degenerate_input_falls_back_to_straights);
|
||||
RUN(editor_clamps_oversized_arc_to_fitting_radius);
|
||||
RUN(editor_leaves_gap_unfitted_when_radius_below_floor);
|
||||
RUN(editor_emits_two_rails_at_standard_gauge);
|
||||
RUN(editor_exposes_transition_radius_range);
|
||||
RUN(editor_leaves_parallel_offset_unfitted);
|
||||
RUN(editor_honours_requested_radius_via_single_arc);
|
||||
RUN(hidden_tangent_guide_fits_curve_without_rendering_track);
|
||||
return REPORT();
|
||||
}
|
||||
Reference in New Issue
Block a user