From 4ae24114bafc2ff01da3c7227e9938992d791fe8 Mon Sep 17 00:00:00 2001 From: maj00r Date: Thu, 20 Aug 2026 20:42:28 +0200 Subject: [PATCH] Editor: pin the whole branch rigidly to its frog, not just the leaving straight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning previously rewrote only the branch's first (leaving) straight to start at the frog and run along the frog heading. Any straight appended behind it stayed where the user drew it, so its gap fit was solved against a leaving straight that had jumped out from under it — the appended fragment could end up ~1.4 km from the frog. Move the branch as one rigid body instead: the leaving straight fixes the rotation+translation onto the frog, and every appended straight rides along under the same motion, so the shapes and inter-fragment fits are preserved and the branch still meets the switch tangentially by construction. Add a test that draws an L-shaped branch far from the frog and checks the whole centreline lands next to the frog after pinning. --- maj0sted/src/editor/editor.cpp | 61 +++++++++++++++++++++++++--------- maj0sted/tests/test_editor.cpp | 42 +++++++++++++++++++++++ 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/maj0sted/src/editor/editor.cpp b/maj0sted/src/editor/editor.cpp index 5338a486..19a4af35 100644 --- a/maj0sted/src/editor/editor.cpp +++ b/maj0sted/src/editor/editor.cpp @@ -342,6 +342,47 @@ JunctionGeom solve_junction(const Junction& junction, return geom; } +// Rigidly repositions a whole branch niweleta so its leaving straight starts at +// the frog and runs along the frog heading. The branch becomes tangent to the +// switch curve however it was drawn, and — crucially — every straight appended +// behind the leaving straight rides along under the SAME rigid motion, so the +// shape of those fragments (and the fits between them) is preserved. Rewriting +// only the first straight would snap it to the frog while the appended fragments +// stayed where they were drawn, so their gap fits would be solved against a +// straight that had jumped out from under them. +void pin_branch_to_frog(NiweletaSpec& branch, const JunctionGeom& frog) { + if (branch.straights.empty()) return; + + // The leaving straight fixes the rigid motion: its start maps to the frog and + // its drawn direction rotates onto the frog heading. A leaving straight too + // short to have a direction contributes no rotation. + const StraightSpec& leaving = branch.straights.front(); + const double anchor_x = leaving.x1; + const double anchor_y = leaving.y1; + const double dx = leaving.x2 - leaving.x1; + const double dy = leaving.y2 - leaving.y1; + const double drawn_length = std::hypot(dx, dy); + double cos_r = 1.0; + double sin_r = 0.0; + if (drawn_length > 1e-9) { + const double hx = dx / drawn_length; + const double hy = dy / drawn_length; + cos_r = hx * frog.fhx + hy * frog.fhy; // drawn direction · frog heading + sin_r = hx * frog.fhy - hy * frog.fhx; // drawn direction × frog heading + } + + const auto move = [&](double& x, double& y) { + const double vx = x - anchor_x; + const double vy = y - anchor_y; + x = frog.fx + cos_r * vx - sin_r * vy; + y = frog.fy + sin_r * vx + cos_r * vy; + }; + for (auto& straight : branch.straights) { + move(straight.x1, straight.y1); + move(straight.x2, straight.y2); + } +} + } // namespace std::vector solve_project(const std::vector& niwelety) { @@ -356,12 +397,10 @@ LayoutSolution solve_layout(std::vector niwelety, // Pass 1: solve unpinned, to read the through centrelines the frogs sit on. auto solved = solve_project(niwelety); - // Lock each branch's leaving straight to the frog: it starts at the frog and - // runs along the frog heading, so the branch is tangent to the switch curve - // no matter what the user draws. Only its length is theirs — the projection of - // wherever they dragged its far end onto the frog heading. Because the first - // straight is pinned tangent, any fit in the branch's first gap (a plain arc - // or a łuk koszowy) meets the switch tangentially by construction. + // Pin each branch to its frog. The whole branch is moved rigidly so its + // leaving straight starts at the frog and runs along the frog heading (tangent + // to the switch curve), carrying every fragment appended behind it along + // unchanged — so any fit in the branch still meets the switch tangentially. for (const auto& junction : junctions) { const auto geom = solve_junction(junction, solved); if (!geom.valid) continue; @@ -369,15 +408,7 @@ LayoutSolution solve_layout(std::vector niwelety, static_cast(junction.branch) >= niwelety.size()) { continue; } - auto& branch = niwelety[junction.branch]; - if (branch.straights.empty()) continue; - auto& first = branch.straights.front(); - double length = (first.x2 - geom.fx) * geom.fhx + (first.y2 - geom.fy) * geom.fhy; - if (!(length > 1.0)) length = 1.0; // keep a usable minimum, also catches NaN - first.x1 = geom.fx; - first.y1 = geom.fy; - first.x2 = geom.fx + geom.fhx * length; - first.y2 = geom.fy + geom.fhy * length; + pin_branch_to_frog(niwelety[junction.branch], geom); } // Pass 2: solve with the branches pinned, then read the switches off the diff --git a/maj0sted/tests/test_editor.cpp b/maj0sted/tests/test_editor.cpp index 69b88367..afc3eb72 100644 --- a/maj0sted/tests/test_editor.cpp +++ b/maj0sted/tests/test_editor.cpp @@ -212,6 +212,47 @@ void hidden_tangent_guide_fits_curve_without_rendering_track() { CHECK(arc_rails == 2); } +void branch_rides_rigidly_when_pinned_to_frog() { + // Through track along +x; a switch diverges left at station 100. + NiweletaSpec through; + through.straights = {StraightSpec{0.0, 0.0, 200.0, 0.0}}; + + // Branch drawn far away as an L: a leaving straight plus one appended straight + // joined by an arc. Pinning must move the WHOLE branch rigidly onto the frog, + // so the appended fragment rides along instead of being left ~1.4 km away + // (the pre-fix behaviour, which only relocated the leaving straight). + NiweletaSpec branch; + branch.straights = {StraightSpec{1000.0, 1000.0, 1090.0, 1000.0}, + StraightSpec{1100.0, 1000.0, 1100.0, 1090.0}}; + branch.fits = {GapFit{.gap = 0, .mode = 1, .radius = 20.0}}; + + Junction j{}; + j.through = 0; + j.station = 100.0; + j.side = 0; // diverge left + j.facing = true; + j.crossing_n = 9.0; + j.curve.mode = 1; + j.curve.radius = 190.0; + j.branch = 1; + + const auto solution = solve_layout({through, branch}, {j}); + CHECK(solution.junctions.size() == 1); + CHECK(solution.junctions[0].valid); + CHECK(solution.niwelety.size() == 2); + + const auto& g = solution.junctions[0]; + const auto& branch_solved = solution.niwelety[1]; + CHECK(!branch_solved.centreline.empty()); + // The branch starts exactly at the frog... + CHECK(std::hypot(branch_solved.centreline.front().x - g.fx, + branch_solved.centreline.front().y - g.fy) < 1e-6); + // ...and the appended fragment rode along: nothing sits far from the frog. + for (const auto& p : branch_solved.centreline) { + CHECK(std::hypot(p.x - g.fx, p.y - g.fy) < 300.0); + } +} + } // namespace int main() { @@ -225,5 +266,6 @@ int main() { RUN(editor_leaves_parallel_offset_unfitted); RUN(editor_honours_requested_radius_via_single_arc); RUN(hidden_tangent_guide_fits_curve_without_rendering_track); + RUN(branch_rides_rigidly_when_pinned_to_frog); return REPORT(); }