diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 09bd0b0c..6a91492e 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -2337,6 +2337,9 @@ void plan_panel::render_switches() m_sw_crossing = preset.crossing_n; m_sw_radius = preset.radius; m_sw_length = preset.length; + m_sw_pre_blade = preset.pre_blade; + m_sw_blade_angle = preset.blade_angle; + m_sw_blade_length = preset.blade_length; } } ImGui::EndCombo(); @@ -2559,6 +2562,10 @@ void plan_panel::add_switch_on(std::size_t const Through, double const Wx, doubl junction.facing = true; junction.crossing_n = m_sw_crossing; junction.length = m_sw_length; // catalogue length from the template, 0 for a custom switch + // the blade figures come from the template too; all zero lays a tangent arc from PR + junction.pre_blade = m_sw_pre_blade; + junction.blade_angle = m_sw_blade_angle; + junction.blade_length = m_sw_blade_length; junction.curve.mode = 1; // plain arc; the internal curve can be made compound later junction.curve.radius = m_sw_radius; junction.branch = static_cast(m_document.niwelety.size()); diff --git a/application/planpanel.h b/application/planpanel.h index 1046df1b..1ebbf6cb 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -104,6 +104,9 @@ class plan_panel : public ui_panel double m_sw_crossing{9.0}; // skos 1:n double m_sw_radius{190.0}; // internal arc radius (metres) double m_sw_length{0.0}; // catalogue length from the chosen template (0 = just the curve) + double m_sw_pre_blade{0.0}; // odcinek przediglicowy from the template, metres + double m_sw_blade_angle{0.0}; // kąt nagięcia iglicy (beta) from the template, radians + double m_sw_blade_length{0.0};// the straight blade from the template, metres int m_drag_junction{-1}; // index of the switch being slid along its through track, or -1 int m_sel_straight{-1}; diff --git a/editor/turnouts.cpp b/editor/turnouts.cpp index a9726e3f..9cadad2b 100644 --- a/editor/turnouts.cpp +++ b/editor/turnouts.cpp @@ -20,16 +20,17 @@ std::vector const &turnout_presets() // the type tables (metres). the diverging leaves not tangent but at the blade angle (beta) after a // straight pre-blade lead, then arcs by (alfa - beta) to the frog. // - // blade_angle and pre_blade are NOT yet from Id-1 - left at 0 (a tangent arc from PR) until the - // catalogue figures are in. Fill them per type when we have them; the geometry already supports it. + // blade_angle, pre_blade and blade_length are NOT yet from Id-1 - left at 0, which lays a tangent + // arc straight from PR. Fill them per type when the catalogue figures are in; the geometry + // (domain::lay_turnout) already lays the pre-blade lead, the blade break and the shortened arc. static std::vector const presets{ - {"Rz 1:7,5 R190", 7.5, 190.0, 25.222, 0.0, 0.0}, - {"Rz 1:9 R190", 9.0, 190.0, 27.138, 0.0, 0.0}, - {"Rz 1:9 R300", 9.0, 300.0, 33.230, 0.0, 0.0}, - {"Rz 1:12 R500", 12.0, 500.0, 41.594, 0.0, 0.0}, - {"Rz 1:14 R500", 14.0, 500.0, 42.371, 0.0, 0.0}, - {"Rz 1:14 R760", 14.0, 760.0, 54.216, 0.0, 0.0}, - {"Rz 1:18,5 R1200", 18.5, 1200.0, 64.818, 0.0, 0.0}}; + {"Rz 1:7,5 R190", 7.5, 190.0, 25.222, 0.0, 0.0, 0.0}, + {"Rz 1:9 R190", 9.0, 190.0, 27.138, 0.0, 0.0, 0.0}, + {"Rz 1:9 R300", 9.0, 300.0, 33.230, 0.0, 0.0, 0.0}, + {"Rz 1:12 R500", 12.0, 500.0, 41.594, 0.0, 0.0, 0.0}, + {"Rz 1:14 R500", 14.0, 500.0, 42.371, 0.0, 0.0, 0.0}, + {"Rz 1:14 R760", 14.0, 760.0, 54.216, 0.0, 0.0, 0.0}, + {"Rz 1:18,5 R1200", 18.5, 1200.0, 64.818, 0.0, 0.0, 0.0}}; return presets; } diff --git a/editor/turnouts.h b/editor/turnouts.h index b4a3e442..357430e3 100644 --- a/editor/turnouts.h +++ b/editor/turnouts.h @@ -27,6 +27,7 @@ struct turnout_preset double length; // catalogue length PR->KR along the through track, metres double blade_angle; // kąt nagięcia iglicy (beta), radians; the diverging leaves the straight here double pre_blade; // odcinek przediglicowy: straight lead PR -> blade tip, metres + double blade_length; // the straight blade itself, tip -> heel where the arc starts, metres }; // typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard; diff --git a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp index 7fc1326b..104a6974 100644 --- a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp +++ b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp @@ -52,12 +52,24 @@ struct Turnout { DivergeSide side{DivergeSide::Left}; DivergingCurve curve; double length{0.0}; ///< catalogue length along the through track; 0 = a tangent-length rail + /// Odcinek przediglicowy: the straight lead from the switch start to the blade + /// tip, still along the through track. Zero for a curve tangent from the start. + double pre_blade{0.0}; + /// Kąt nagięcia iglicy (beta), radians: the heading break at the blade tip. The + /// diverging track leaves the through track here, not tangentially, and the + /// curve then turns through only the remaining `alfa - beta`. Zero for a + /// tangential (curved-blade) turnout. + double blade_angle{0.0}; + /// The straight blade itself, from its tip to its heel where the curve begins. + /// Zero starts the curve at the tip. + double blade_length{0.0}; }; /// One laid segment of the diverging path, as the constant/linear curvature the /// renderer and the fitter already share (see segment_layout): a straight when /// both curvatures are zero, an arc when they are equal, a clothoid otherwise. struct TurnoutSegment { + double turn_in{0.0}; ///< signed heading break applied before laying this segment (the blade) double k0{0.0}; double k1{0.0}; double length{0.0}; @@ -73,12 +85,13 @@ struct TurnoutGeometry { double diverging_length{0.0}; ///< total length of the diverging path (curve + frog rail) }; -/// Lays a turnout forward from @p start — the switch start, tangent to the through -/// track — turning toward its side until the heading has swung by the crossing -/// angle, then running straight to the catalogue length. Never throws; returns +/// Lays a turnout forward from @p start — the switch start (początek rozjazdu), +/// tangent to the through track: the pre-blade straight, the blade breaking away +/// by `blade_angle`, the curve turning through what is left of the crossing angle, +/// then the frog rail out to the catalogue length. Never throws; returns /// `valid == false` when the parameters admit no geometry: a non-positive radius, -/// or a curve whose fixed parts already overshoot the crossing angle before the -/// closing arc. +/// a blade angle that is negative or already reaches the crossing angle, or a +/// curve whose fixed parts overshoot the remaining angle before the closing arc. [[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, const Turnout& turnout); diff --git a/maj0sted/include/maj0sted/editor/editor.hpp b/maj0sted/include/maj0sted/editor/editor.hpp index 15ff6a9b..c57cee45 100644 --- a/maj0sted/include/maj0sted/editor/editor.hpp +++ b/maj0sted/include/maj0sted/editor/editor.hpp @@ -99,6 +99,9 @@ struct Junction { bool facing{true}; ///< true opens toward increasing station, false back double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n) double length{0.0}; ///< catalogue length (PR→KR along the through track); 0 = just the curve + double pre_blade{0.0}; ///< odcinek przediglicowy: straight lead PR → blade tip, metres + double blade_angle{0.0}; ///< kąt nagięcia iglicy (beta), radians; 0 = curve tangent from PR + double blade_length{0.0};///< the straight blade, tip → heel where the curve starts, metres GapFit curve; ///< the internal diverging curve: arc / compound / basket int branch{-1}; ///< index of the branch niweleta, pinned to the frog }; diff --git a/maj0sted/src/domain/turnout.cpp b/maj0sted/src/domain/turnout.cpp index d87e6c4f..4e9bb569 100644 --- a/maj0sted/src/domain/turnout.cpp +++ b/maj0sted/src/domain/turnout.cpp @@ -33,7 +33,7 @@ bool build_curve(const DivergingCurve& curve, double total_angle, double sign, double consumed = 0.0; // deflection of everything before the closing arc if (curve.entry_transition > 0.0) { - out.push_back({0.0, sign / first_radius, curve.entry_transition}); + out.push_back({0.0, 0.0, sign / first_radius, curve.entry_transition}); consumed += std::abs(deflection(out.back())); } @@ -41,12 +41,12 @@ bool build_curve(const DivergingCurve& curve, double total_angle, double sign, for (std::size_t i = 0; i + 1 < curve.arcs.size(); ++i) { const double radius = curve.arcs[i].radius; if (radius <= 0.0) return false; - out.push_back({sign / radius, sign / radius, curve.arcs[i].length}); + out.push_back({0.0, sign / radius, sign / radius, curve.arcs[i].length}); consumed += std::abs(deflection(out.back())); const double next_radius = curve.arcs[i + 1].radius; if (curve.arcs[i].transition_to_next > 0.0 && next_radius > 0.0) { - out.push_back({sign / radius, sign / next_radius, + out.push_back({0.0, sign / radius, sign / next_radius, curve.arcs[i].transition_to_next}); consumed += std::abs(deflection(out.back())); } @@ -56,17 +56,26 @@ bool build_curve(const DivergingCurve& curve, double total_angle, double sign, const double closing = total_angle - consumed - exit_deflection; if (closing <= 0.0) return false; // the fixed parts already reach the crossing angle - out.push_back({sign / last_radius, sign / last_radius, closing * last_radius}); + out.push_back({0.0, sign / last_radius, sign / last_radius, closing * last_radius}); if (curve.exit_transition > 0.0) { - out.push_back({sign / last_radius, 0.0, curve.exit_transition}); + out.push_back({0.0, sign / last_radius, 0.0, curve.exit_transition}); } return true; } -// Advances @p pose along @p segment (without sampling points; that is the -// renderer's job) and returns the end pose. +// Turns @p pose in place by @p angle (a heading break, no travel). +Pose turn(const Pose& pose, double angle) { + if (angle == 0.0) return pose; + const double c = std::cos(angle); + const double s = std::sin(angle); + return Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, pose.hx * s + pose.hy * c}; +} + +// Advances @p pose over @p segment (without sampling points; that is the +// renderer's job): the heading break first, then the travel. Returns the end pose. Pose advance(const TurnoutSegment& segment, const Pose& pose) { - return layout_segment(segment.k0, segment.k1, segment.length, pose, nullptr); + return layout_segment(segment.k0, segment.k1, segment.length, + turn(pose, segment.turn_in), nullptr); } // Where the through tangent (through @p a along @p a heading) meets the diverging @@ -91,10 +100,27 @@ TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout) { if (angle <= 0.0) return geometry; const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0; - if (!build_curve(turnout.curve, angle, sign, geometry.path)) { + const double blade = turnout.blade_angle; + if (blade < 0.0 || blade >= angle) return geometry; // the blade may not reach the frog on its own + + // odcinek przediglicowy: still along the through track, the blade tip sits at its end + if (turnout.pre_blade > 0.0) { + geometry.path.push_back(TurnoutSegment{0.0, 0.0, 0.0, turnout.pre_blade}); + } + // the blade breaks away by beta and runs straight to its heel; with no blade + // length the break still stands and the curve starts at the tip + if (turnout.blade_length > 0.0) { + geometry.path.push_back(TurnoutSegment{sign * blade, 0.0, 0.0, turnout.blade_length}); + } + + // the curve only has `alfa - beta` left to turn through + std::vector curve_path; + if (!build_curve(turnout.curve, angle - blade, sign, curve_path)) { geometry.path.clear(); return geometry; } + if (turnout.blade_length <= 0.0) curve_path.front().turn_in += sign * blade; + geometry.path.insert(geometry.path.end(), curve_path.begin(), curve_path.end()); // walk the curve to the frog direction (the crossing angle) Pose pose = start; @@ -112,7 +138,7 @@ TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout) { ? (turnout.length - through_projection) / cos_angle : turnout.curve.arcs.back().radius * std::tan(angle * 0.5); if (rail > 0.0) { - const TurnoutSegment frog_rail{0.0, 0.0, rail}; + const TurnoutSegment frog_rail{0.0, 0.0, 0.0, rail}; geometry.path.push_back(frog_rail); pose = advance(frog_rail, pose); } diff --git a/maj0sted/src/editor/editor.cpp b/maj0sted/src/editor/editor.cpp index 19a4af35..78ad0ba7 100644 --- a/maj0sted/src/editor/editor.cpp +++ b/maj0sted/src/editor/editor.cpp @@ -281,7 +281,9 @@ domain::Turnout to_turnout(const Junction& junction) { return domain::Turnout{ domain::CrossingMark{junction.crossing_n}, junction.side == 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right, - std::move(curve), junction.length}; + std::move(curve), junction.length, + junction.pre_blade, junction.blade_angle, + junction.blade_length}; } // The drawable geometry of one switch. @p station is the switch's start on the @@ -319,6 +321,13 @@ JunctionGeom solve_junction(const Junction& junction, std::abs(segment.k1 - segment.k0) >= 1e-12 ? render::ElementKind::Transition : std::abs(segment.k0) >= 1e-12 ? render::ElementKind::Arc : render::ElementKind::Straight; + // the blade's heading break happens in place, before the segment is laid + if (segment.turn_in != 0.0) { + const double c = std::cos(segment.turn_in); + const double s = std::sin(segment.turn_in); + pose = Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, + pose.hx * s + pose.hy * c}; + } pose = layout_segment(segment.k0, segment.k1, segment.length, pose, &pts); element.points.reserve(pts.size()); for (const auto& p : pts) element.points.push_back(render::Point{p.x, p.y}); diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt index 3d187671..94e2b978 100644 --- a/maj0sted/tests/CMakeLists.txt +++ b/maj0sted/tests/CMakeLists.txt @@ -47,3 +47,8 @@ add_executable(maj0sted_test_document test_document.cpp) target_link_libraries(maj0sted_test_document PRIVATE maj0sted::maj0sted) target_include_directories(maj0sted_test_document PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME document COMMAND maj0sted_test_document) + +add_executable(maj0sted_test_turnout test_turnout.cpp) +target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME turnout COMMAND maj0sted_test_turnout) diff --git a/maj0sted/tests/test_turnout.cpp b/maj0sted/tests/test_turnout.cpp new file mode 100644 index 00000000..d1ae8ae0 --- /dev/null +++ b/maj0sted/tests/test_turnout.cpp @@ -0,0 +1,130 @@ +#include +#include + +#include "check.hpp" +#include "maj0sted/domain/geometry/turnout.hpp" + +using namespace maj0sted::domain; +using maj0sted::domain::geometry::Pose; + +namespace { + +Turnout rz_1_9_r190() { + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, DivergingCurve{}, 27.138}; + turnout.curve.arcs.push_back(TurnoutArc{190.0, 0.0, 0.0}); + return turnout; +} + +// Walks the laid path and returns the pose at the end of segment @p index. +Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) { + Pose pose{0.0, 0.0, 1.0, 0.0}; + for (std::size_t i = 0; i <= index; ++i) { + const auto& segment = geometry.path[i]; + if (segment.turn_in != 0.0) { + const double c = std::cos(segment.turn_in); + const double s = std::sin(segment.turn_in); + pose = Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, + pose.hx * s + pose.hy * c}; + } + pose = geometry::layout_segment(segment.k0, segment.k1, segment.length, pose, + nullptr); + } + return pose; +} + +void pre_blade_runs_along_the_through_track() { + auto turnout = rz_1_9_r190(); + turnout.pre_blade = 3.5; + turnout.blade_angle = 0.02; + turnout.blade_length = 6.0; + + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + CHECK(geometry.valid); + + // the lead is straight, on the through track, and the blade tip sits at its end + const Pose tip = pose_after(geometry, 0); + CHECK(std::abs(tip.x - 3.5) < 1e-9); + CHECK(std::abs(tip.y) < 1e-12); + CHECK(std::abs(tip.hy) < 1e-12); + + // the blade breaks away by beta exactly, and stays straight over its length + const Pose heel = pose_after(geometry, 1); + CHECK(std::abs(std::atan2(heel.hy, heel.hx) - 0.02) < 1e-9); + CHECK(std::abs(std::hypot(heel.x - tip.x, heel.y - tip.y) - 6.0) < 1e-9); +} + +void the_frog_still_lands_on_the_crossing_angle() { + const double alfa = std::atan(1.0 / 9.0); + + auto tangential = rz_1_9_r190(); + auto bladed = rz_1_9_r190(); + bladed.pre_blade = 3.5; + bladed.blade_angle = 0.02; + bladed.blade_length = 6.0; + + for (const auto& turnout : {tangential, bladed}) { + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + CHECK(geometry.valid); + // the curve spends only alfa - beta, so the frog heading is alfa either way + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - alfa) < 1e-9); + // and the catalogue length is still measured along the through track + CHECK(std::abs(geometry.frog.x - 27.138) < 1e-6); + } +} + +void a_blade_reaching_the_crossing_angle_is_rejected() { + auto turnout = rz_1_9_r190(); + turnout.blade_angle = std::atan(1.0 / 9.0); // nothing left for the curve to turn + CHECK(!lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout).valid); + + turnout.blade_angle = -0.01; + CHECK(!lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout).valid); +} + +void a_bladeless_curve_still_starts_tangent() { + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, rz_1_9_r190()); + CHECK(geometry.valid); + for (const auto& segment : geometry.path) CHECK(segment.turn_in == 0.0); +} + +// With no blade length the break still stands: the curve simply starts at the tip. +void a_zero_length_blade_keeps_its_break() { + auto turnout = rz_1_9_r190(); + turnout.pre_blade = 3.5; + turnout.blade_angle = 0.02; + + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + CHECK(geometry.valid); + CHECK(geometry.path.size() >= 2); + CHECK(std::abs(geometry.path[1].turn_in - 0.02) < 1e-12); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - + std::atan(1.0 / 9.0)) < 1e-9); +} + +// A right-hand turnout is the mirror image: the blade breaks the other way. +void a_right_hand_blade_breaks_the_other_way() { + auto turnout = rz_1_9_r190(); + turnout.side = DivergeSide::Right; + turnout.pre_blade = 3.5; + turnout.blade_angle = 0.02; + turnout.blade_length = 6.0; + + const auto geometry = lay_turnout(Pose{0.0, 0.0, 1.0, 0.0}, turnout); + CHECK(geometry.valid); + CHECK(std::abs(std::atan2(pose_after(geometry, 1).hy, pose_after(geometry, 1).hx) + + 0.02) < 1e-9); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + + std::atan(1.0 / 9.0)) < 1e-9); +} + +} // namespace + +int main() { + RUN(pre_blade_runs_along_the_through_track); + RUN(the_frog_still_lands_on_the_crossing_angle); + RUN(a_blade_reaching_the_crossing_angle_is_rejected); + RUN(a_bladeless_curve_still_starts_tangent); + RUN(a_zero_length_blade_keeps_its_break); + RUN(a_right_hand_blade_breaks_the_other_way); + return REPORT(); +}