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(); }