#include "maj0sted/editor/editor.hpp" #include #include #include #include #include #include #include "maj0sted/domain/fitting/gap_fitter.hpp" #include "maj0sted/domain/geometry/segment_layout.hpp" #include "maj0sted/domain/geometry/turnout.hpp" #include "maj0sted/maj0sted.hpp" #include "maj0sted/render/rail_renderer.hpp" #include "maj0sted/render/track_renderer.hpp" // This file is orchestration only: it turns the editor's request DTOs into // domain calls, samples the solved geometry into rendering-neutral centreline // elements, and drives a render::TrackRenderer to produce the drawable output. // All fitting business logic lives in maj0sted::domain::GapFitter; all rail // geometry lives in maj0sted::render::RailRenderer. Nothing here decides how a // curve is fitted or how it is drawn. namespace maj0sted::editor { using namespace maj0sted::domain; using maj0sted::domain::geometry::layout_segment; using maj0sted::domain::geometry::Pose; using maj0sted::domain::geometry::XY; namespace { std::optional make_straight(const StraightSpec& s, std::uint64_t id) { try { return Straight{StraightId{id}, CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}}; } catch (...) { return std::nullopt; // degenerate (zero length) } } // Maps an editor gap-fit DTO onto a domain connection request. Returns nullopt // for "no fit" (mode 0) or an unknown mode. std::optional to_request(const GapFit& fit) { GapConnectionRequest request; switch (fit.mode) { case 1: // plain arc // Legacy explicit nawrót (mode 4): treated as a plain-arc request; a // genuine 180° reversal is then detected from the geometry in GapFitter. case 4: request.mode = GapConnectionMode::Arc; request.radius = fit.radius; return request; case 2: // legacy "łuk + klotoidy" -> compound with a single eased arc request.mode = GapConnectionMode::Compound; request.compound.entry_transition = fit.transition; request.compound.exit_transition = fit.transition; request.compound.arcs.push_back(CompoundGapArc{fit.radius, 0.0, 0.0}); return request; case 3: // unified compound: any number of arcs eased by clothoids request.mode = GapConnectionMode::Compound; request.compound.entry_transition = fit.entry_t; request.compound.exit_transition = fit.exit_t; if (!fit.arcs.empty()) { for (const auto& a : fit.arcs) { request.compound.arcs.push_back( CompoundGapArc{a.radius, a.length, a.transition_to_next}); } } else if (fit.r2 > 0.0) { // legacy two-arc basket request.compound.arcs.push_back( CompoundGapArc{fit.r1, fit.arc1_len, fit.between}); request.compound.arcs.push_back(CompoundGapArc{fit.r2, 0.0, 0.0}); } else { // legacy single arc request.compound.arcs.push_back(CompoundGapArc{fit.r1, 0.0, 0.0}); } return request; default: return std::nullopt; } } double radius_of(double curvature) { return std::abs(curvature) > 1e-12 ? 1.0 / std::abs(curvature) : 0.0; } // Describes one domain plan element as sampling parameters for layout_segment, // returning its render kind. A circular arc has constant curvature; a clothoid // varies linearly; a straight has zero curvature. render::ElementKind sample_params(const PlanElement& element, double& k0, double& k1, double& length) { if (const auto* arc = std::get_if(&element)) { const double sign = arc->direction() == TurnDirection::Left ? 1.0 : -1.0; k0 = k1 = sign / arc->radius().metres(); length = arc->length() ? arc->length()->metres() : 0.0; return render::ElementKind::Arc; } const auto& transition = std::get(element); const double sign = transition.direction() == TurnDirection::Left ? 1.0 : -1.0; k0 = transition.start_radius() ? sign / transition.start_radius()->metres() : 0.0; k1 = transition.end_radius() ? sign / transition.end_radius()->metres() : 0.0; length = transition.length().metres(); return render::ElementKind::Transition; } // Samples the solved niweleta (trimmed straights + fitted curves) into // rendering-neutral centreline elements, and records the trimmed straights. std::vector sample_centrelines( const NiweletaSpec& spec, const std::vector>& straights, const std::vector>& fits, std::vector& rendered_straights) { std::vector axis; const std::size_t n = straights.size(); for (std::size_t i = 0; i < n; ++i) { if (!straights[i]) continue; // Trim the straight to the tangent points of its adjacent fits. CartesianPosition a = straights[i]->start(); CartesianPosition b = straights[i]->end(); if (i > 0 && fits[i - 1]) a = fits[i - 1]->tangent_out; if (i < fits.size() && fits[i]) b = fits[i]->tangent_in; rendered_straights[i] = StraightSpec{a.x(), a.y(), b.x(), b.y(), spec.straights[i].hidden}; if (!spec.straights[i].hidden) { render::CentrelineElement straight_element; straight_element.kind = render::ElementKind::Straight; straight_element.straight_index = static_cast(i); straight_element.length = std::hypot(b.x() - a.x(), b.y() - a.y()); straight_element.points = {render::Point{a.x(), a.y()}, render::Point{b.x(), b.y()}}; axis.push_back(std::move(straight_element)); } if (i < fits.size() && fits[i]) { Pose pose{fits[i]->tangent_in.x(), fits[i]->tangent_in.y(), std::sin(straights[i]->azimuth().radians()), std::cos(straights[i]->azimuth().radians())}; int element_index = 0; for (const auto& element : fits[i]->curve) { double k0 = 0.0, k1 = 0.0, length = 0.0; render::CentrelineElement curve_element; curve_element.kind = sample_params(element, k0, k1, length); std::vector pts; pose = layout_segment(k0, k1, length, pose, &pts); curve_element.points.reserve(pts.size()); for (const auto& p : pts) { curve_element.points.push_back(render::Point{p.x, p.y}); } curve_element.gap = static_cast(i); curve_element.element_index = element_index++; curve_element.length = length; curve_element.radius_start = radius_of(k0); curve_element.radius_end = radius_of(k1); axis.push_back(std::move(curve_element)); } } } return axis; } // Renders the centreline elements to PlanPolylines through the renderer // interface. A different render::TrackRenderer could be dropped in here without // touching anything above. std::vector render_rails( const std::vector& axis) { render::RailRenderer rail_renderer; render::TrackRenderer& renderer = rail_renderer; for (const auto& element : axis) renderer.add(element); std::vector polylines; polylines.reserve(rail_renderer.rails().size()); for (const auto& rail : rail_renderer.rails()) { PlanPolyline poly; poly.kind = static_cast(rail.kind); poly.straight_index = rail.straight_index; poly.gap = rail.gap; poly.element_index = rail.element_index; poly.length = rail.length; poly.radius_start = rail.radius_start; poly.radius_end = rail.radius_end; poly.points.reserve(rail.points.size()); for (const auto& p : rail.points) poly.points.push_back(PlanPoint{p.x, p.y}); polylines.push_back(std::move(poly)); } return polylines; } NiweletaPolys build(const NiweletaSpec& spec) { const std::size_t n = spec.straights.size(); std::vector> straights(n); for (std::size_t i = 0; i < n; ++i) { straights[i] = make_straight(spec.straights[i], i + 1); } NiweletaPolys result; result.rendered_straights = spec.straights; // 1. Business: connect each requested gap. The domain decides the curve type // and the radius actually used; a gap that admits no fit is simply left // out (the library, not the host, turns a failed fit into "no fit"). Only // fits that succeeded are echoed in applied_fits for the host to mirror. std::vector> fits(n == 0 ? 0 : n - 1); for (const auto& gap : spec.fits) { const int g = gap.gap; if (g < 0 || static_cast(g) + 1 >= n) continue; if (!straights[g] || !straights[g + 1]) continue; const auto request = to_request(gap); if (!request) continue; const auto connection = GapFitter::connect(*straights[g], *straights[g + 1], *request); if (!connection) continue; fits[g] = connection->fit; GapFit applied = gap; applied.radius = connection->applied_radius; result.applied_fits.push_back(applied); } // 2. Geometry: sample trimmed straights and fitted curves into a neutral, // renderer-agnostic centreline. const auto axis = sample_centrelines(spec, straights, fits, result.rendered_straights); // Flatten the axis into one polyline for station lookups, dropping the join // points each element repeats at its start. for (const auto& element : axis) { for (const auto& p : element.points) { if (!result.centreline.empty()) { const auto& last = result.centreline.back(); if (std::hypot(p.x - last.x, p.y - last.y) < 1e-9) continue; } result.centreline.push_back(PlanPoint{p.x, p.y}); } } // 3. Presentation: hand the centreline to a renderer. result.polylines = render_rails(axis); return result; } // The pose (position + unit heading) at arc length @p station along a centreline // polyline. Clamped to the ends; heading comes from the segment the station falls // on. Returns false only for a centreline too short to have a direction. bool pose_at(const std::vector& centreline, double station, Pose& out) { if (centreline.size() < 2) return false; double travelled = 0.0; for (std::size_t i = 0; i + 1 < centreline.size(); ++i) { const auto& a = centreline[i]; const auto& b = centreline[i + 1]; const double dx = b.x - a.x; const double dy = b.y - a.y; const double seg = std::hypot(dx, dy); if (seg < 1e-12) continue; // the last segment also catches any station beyond the centreline's end if (station <= travelled + seg || i + 2 == centreline.size()) { const double t = std::clamp((station - travelled) / seg, 0.0, 1.0); out = Pose{a.x + dx * t, a.y + dy * t, dx / seg, dy / seg}; return true; } travelled += seg; } return false; } // Translates the editor's switch DTO into a domain Turnout: a plain-arc curve // (mode 1) or a basket of arcs eased by clothoids (mode 3). All the turnout // business — turning to the crossing angle, closing the last arc, the frog rail — // lives in the domain; nothing here decides geometry. domain::Turnout to_turnout(const Junction& junction) { domain::DivergingCurve curve; if (junction.curve.mode == 3 && junction.curve.arcs.size() >= 2) { for (const auto& arc : junction.curve.arcs) { curve.arcs.push_back( domain::TurnoutArc{arc.radius, arc.length, arc.transition_to_next}); } curve.entry_transition = junction.curve.entry_t; curve.exit_transition = junction.curve.exit_t; } else { curve.arcs.push_back(domain::TurnoutArc{junction.curve.radius, 0.0, 0.0}); } return domain::Turnout{ domain::CrossingMark{junction.crossing_n}, junction.side == 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right, 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 // through track (początek rozjazdu) — the point the user clicks. The diverging // path and frog come from the domain; here we only place them on the through // track and sample them into rails. JunctionGeom solve_junction(const Junction& junction, const std::vector& solved) { JunctionGeom geom; if (junction.through < 0 || static_cast(junction.through) >= solved.size()) { return geom; } Pose start{}; if (!pose_at(solved[junction.through].centreline, junction.station, start)) { return geom; } // a trailing switch opens against the running direction: flip the heading if (!junction.facing) { start.hx = -start.hx; start.hy = -start.hy; } const auto turnout = domain::lay_turnout(start, to_turnout(junction)); if (!turnout.valid) return geom; // sample the domain segments into drawable rails std::vector axis; Pose pose = start; for (const auto& segment : turnout.path) { std::vector pts; render::CentrelineElement element; element.kind = 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}); element.length = segment.length; element.radius_start = radius_of(segment.k0); element.radius_end = radius_of(segment.k1); axis.push_back(std::move(element)); } geom.valid = true; geom.px = start.x; geom.py = start.y; geom.fx = turnout.frog.x; geom.fy = turnout.frog.y; geom.fhx = turnout.frog.hx; geom.fhy = turnout.frog.hy; geom.tangent_front = turnout.tangent_front; geom.tangent_back = turnout.tangent_back; geom.length = turnout.diverging_length; geom.polylines = render_rails(axis); 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) { std::vector result; result.reserve(niwelety.size()); for (const auto& spec : niwelety) result.push_back(build(spec)); return result; } LayoutSolution solve_layout(std::vector niwelety, const std::vector& junctions) { // Pass 1: solve unpinned, to read the through centrelines the frogs sit on. auto solved = solve_project(niwelety); // 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; if (junction.branch < 0 || static_cast(junction.branch) >= niwelety.size()) { continue; } pin_branch_to_frog(niwelety[junction.branch], geom); } // Pass 2: solve with the branches pinned, then read the switches off the // final centrelines (the through track may itself be a pinned branch). LayoutSolution out; out.niwelety = solve_project(niwelety); out.junctions.reserve(junctions.size()); for (const auto& junction : junctions) { out.junctions.push_back(solve_junction(junction, out.niwelety)); } return out; } } // namespace maj0sted::editor