17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-11-07 06:28:08 +01:00

Editor: rebuild the plan library around a solved document, and bend turnouts onto curves

The library was a heap of half-built services - fitting, parallelism, niwelety,
an SVG writer - that never met each other. In their place is one authored
Document and one solver that lays it whole: tracks as element chains, turnouts
placed on them, and a Solution that is rebuilt from scratch every time and never
fed back into the document. What cannot be laid comes back as a diagnostic with
the number in it rather than as silence or a fitted-away error.

Turnouts are dimensioned the way the drawings dimension them. The blade follows
Koc's rys. 5.9 - the curve is theoretically tangent to the opornica at PR, the
blade starts at a with a planed nose of d, and A is where the rail stands u
thick - and a + d derived that way lands on his tablica 5.1. Bending follows
5.3.6: it rotates the triangle OBC about the centre of the turnout, so the
tangent t and the crossing angle stand and both radii follow from them. That is
Koc's (5.7) and (5.8) without coding either, and it puts a symmetrically bent
Rz 49E1-190-1:9 on the catalogue's Rlus 49E1-380,292-1:9. A rozjazd standing on
a curve is bent onto it; one on a track drawn straight can carry a bend of its
own. The szyny laczace take up the difference, because the switch and the frog
are only bent, not re-cut.

The plan panel draws all of it: the catalogue drawing of a type with its
construction points, the theoretical arc dashed where no rail runs, and the
blade nose as the straight it is cut as. Dragging a straight now stops where it
would pull the track out from under a turnout.

File format goes to m0s 6. Versions 3 to 5 still open.
This commit is contained in:
maj00r
2026-08-29 19:32:46 +02:00
parent 8cd42980e6
commit 2376e4867b
97 changed files with 8608 additions and 9792 deletions

View File

@@ -1,58 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include "maj0sted/editor/editor.hpp"
#include "maj0sted/editor/ribbon.hpp"
#include "maj0sted/editor/solve.hpp"
namespace maj0sted::app {
struct BasketDraft {
bool active{false};
double x0{0.0};
double y0{0.0};
double az0{0.0};
int dir{1};
double entry_t{0.0};
double exit_t{0.0};
std::vector<maj0sted::editor::CompoundArcSpec> arcs;
double preview_next_r{300.0};
int attach_str{-1};
int attach_end{-1};
int reedit_gap{-1};
maj0sted::editor::GapFit reedit_backup{};
};
[[nodiscard]] maj0sted::editor::RibbonRequest draft_to_ribbon(const BasketDraft& draft);
[[nodiscard]] std::vector<maj0sted::editor::PlanPolyline> preview_basket_draft(
BasketDraft& draft, double fallback_next_r = 300.0);
void start_basket_draft_from_end(BasketDraft& draft,
const maj0sted::editor::StraightSpec& straight,
int straight_index, int end, double default_r = 300.0);
[[nodiscard]] bool reopen_basket_draft(BasketDraft& draft,
maj0sted::editor::NiweletaSpec& niweleta,
std::size_t gap,
const maj0sted::editor::NiweletaPolys* solved);
void discard_basket_draft(BasketDraft& draft, maj0sted::editor::NiweletaSpec* niweleta);
[[nodiscard]] bool anchor_basket_draft(BasketDraft& draft,
maj0sted::editor::NiweletaSpec& niweleta,
std::size_t gap);
struct FinishBasketDraftResult {
bool ok{false};
std::string status;
};
[[nodiscard]] FinishBasketDraftResult finish_basket_draft(
BasketDraft& draft, maj0sted::editor::NiweletaSpec& niweleta,
std::vector<maj0sted::editor::NiweletaSpec>& all_niwelety, int niweleta_index,
double finish_straight_m);
} // namespace maj0sted::app

View File

@@ -1,34 +0,0 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "maj0sted/domain/map_project.hpp"
#include "maj0sted/editor/editor.hpp"
namespace maj0sted::app {
struct EditorDocument {
std::vector<maj0sted::editor::NiweletaSpec> niwelety;
std::vector<maj0sted::editor::Junction> junctions;
double view_x{0.0};
double view_y{0.0};
double view_extent{0.0};
bool origin_set{false};
bool georeferenced{false};
double origin_x{0.0};
double origin_y{0.0};
};
[[nodiscard]] maj0sted::domain::MapProject to_map_project(const EditorDocument& document);
[[nodiscard]] EditorDocument from_map_project(const maj0sted::domain::MapProject& project);
[[nodiscard]] std::string serialize_document(const EditorDocument& document);
[[nodiscard]] EditorDocument deserialize_document(const std::string& text);
[[nodiscard]] std::string default_project_path();
bool save_project(const EditorDocument& document,
const std::string& path = default_project_path());
[[nodiscard]] std::optional<EditorDocument> load_project(
const std::string& path = default_project_path());
} // namespace maj0sted::app

View File

@@ -1,12 +0,0 @@
#pragma once
#include <vector>
#include "maj0sted/editor/editor.hpp"
namespace maj0sted::app {
void apply_straight_constraints(std::vector<maj0sted::editor::NiweletaSpec>& niwelety);
void align_gap_fits(maj0sted::editor::NiweletaSpec& niweleta);
} // namespace maj0sted::app

View File

@@ -1,55 +0,0 @@
#pragma once
#include <optional>
#include <vector>
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
#include "maj0sted/domain/value_objects/straight_id.hpp"
namespace maj0sted::domain {
/// Input parameters for fitting a curve between two straights of one niweleta.
///
/// These map directly onto a GUI form: the user picks the entry and exit
/// straights, types a radius, and optionally a (symmetric) transition-curve
/// length. The turn direction is NOT an input — it follows from the geometry of
/// the two straights (their deflection angle).
///
/// * transition_length absent -> a plain circular arc is fitted;
/// * transition_length present -> a symmetric klotoida-arc-klotoida is fitted,
/// with that length on both ends.
struct FitParameters {
StraightId entry{}; ///< pierwsza (wejściowa) prosta
StraightId exit{}; ///< druga (wyjściowa) prosta
Radius radius; ///< promień łuku kołowego
std::optional<Length> transition_length{}; ///< symetryczna klotoida; brak = sam łuk
};
/// One arc of a compound curve.
struct CompoundArc {
Radius radius; ///< promień łuku
std::optional<Length> length{}; ///< długość łuku; wymagana OPRÓCZ ostatniego
std::optional<Length> transition_to_next{}; ///< klotoida do następnego łuku (ignorowana dla ostatniego)
};
/// Input parameters for fitting a compound curve — a sequence of one or more
/// circular arcs (of different neighbouring radii, same direction) with optional
/// transition curves (klotoidy) between the arcs and at the ends — between two
/// straights. This single shape covers both a "łuk + klotoidy" (one arc eased by
/// symmetric clothoids) and a "łuk koszowy" (two or more arcs).
///
/// Both straight lines stay put (position and azimuth). Each arc except the last
/// carries its length, which fixes its central angle (`δ = L / R`); the last (or
/// only) arc takes whatever deflection remains after the other arcs and every
/// transition curve. The two straights are re-trimmed in length so the whole
/// chain closes.
struct CompoundFitParameters {
StraightId entry{}; ///< pierwsza (wejściowa) prosta
StraightId exit{}; ///< druga (wyjściowa) prosta
std::optional<Length> entry_transition{}; ///< klotoida prosta -> pierwszy łuk
std::optional<Length> exit_transition{}; ///< klotoida ostatni łuk -> prosta
std::vector<CompoundArc> arcs{}; ///< >= 1 łuk; sąsiednie promienie różne
};
} // namespace maj0sted::domain

View File

@@ -1,25 +0,0 @@
#pragma once
#include <vector>
#include "maj0sted/domain/geometry/plan_element.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
namespace maj0sted::domain {
/// Result of fitting a curve between two straights.
///
/// The two straights keep their identity but are trimmed back to the tangent
/// points; @c curve is the klotoida?/łuk/klotoida? to splice between them. The
/// caller (or an aggregate command) replaces the original entry/exit straights
/// with @c entry, then @c curve, then @c exit.
struct FitResult {
Straight entry; ///< trimmed entry straight (ends at tangent_in)
std::vector<PlanElement> curve; ///< klotoida? / łuk / klotoida?
Straight exit; ///< trimmed exit straight (starts at tangent_out)
CartesianPosition tangent_in; ///< entry -> curve tangent point
CartesianPosition tangent_out; ///< curve -> exit tangent point
TurnDirection direction; ///< derived turn direction
};
} // namespace maj0sted::domain

View File

@@ -1,69 +0,0 @@
#pragma once
#include <optional>
#include "maj0sted/domain/fitting/fit_parameters.hpp"
#include "maj0sted/domain/fitting/fit_result.hpp"
#include "maj0sted/domain/niweleta.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
namespace maj0sted::domain {
/// Domain Service: fits a curve between two straights of a single niweleta.
class FittingService {
public:
// --- Overloads that fit two standalone straights (no niweleta) ---
/// Rounds the corner between two straight lines with an arc (and optional
/// symmetric transition curves). Works for any deflection strictly between
/// 0° and 180°. For a 180° reversal use fit_uturn instead.
static FitResult fit_between_straights(
const Straight& entry, const Straight& exit, Radius radius,
std::optional<Length> transition = std::nullopt);
/// Fits a compound curve between two standalone straight lines.
static FitResult fit_compound(const Straight& entry, const Straight& exit,
const CompoundFitParameters& params);
/// Connects two (anti-)parallel straights with a 180° reversal ("nawrót").
/// The radius is derived from the perpendicular offset between the two lines
/// (a plain arc uses R = offset / 2). Optional symmetric transition curves
/// are solved for numerically.
/// @throws std::invalid_argument if the straights are not anti-parallel or
/// their offset is zero.
static FitResult fit_uturn(const Straight& entry, const Straight& exit,
std::optional<Length> transition = std::nullopt);
/// Fits an S-curve made of two tangent circular arcs with the same radius
/// and opposite turn directions. Supports non-parallel straights and
/// same-direction parallel straights with a non-zero lateral offset.
/// Tangent points are required to lie on both finite straight segments.
/// @throws std::invalid_argument if no equal-radius reverse curve fits.
static FitResult fit_reverse(const Straight& entry, const Straight& exit,
Radius radius);
/// Computes the curve (and the trimmed straights) that connects the two
/// straights named in @p params, both belonging to @p niweleta.
///
/// @throws std::invalid_argument if a straight is missing, the two are the
/// same, the straights are parallel/collinear, or the transition
/// curves are too long for the chosen radius and deflection.
static FitResult fit_between_straights(const Niweleta& niweleta,
const FitParameters& params);
/// Computes a compound curve ("łuk koszowy") — a chain of N arcs of
/// different neighbouring radii, same direction — between the two straights
/// named in @p params. Both straight lines stay fixed and are re-trimmed in
/// length; each arc's length sets its central angle, and the last arc takes
/// the remaining deflection.
///
/// @throws std::invalid_argument if a straight is missing, the two are the
/// same, there are no arcs, neighbouring radii are equal, a non-last
/// arc has no length, the straights are parallel/collinear, or the
/// split lengths exceed the total deflection.
static FitResult fit_compound(const Niweleta& niweleta,
const CompoundFitParameters& params);
};
} // namespace maj0sted::domain

View File

@@ -1,74 +0,0 @@
#pragma once
#include <optional>
#include <vector>
#include "maj0sted/domain/fitting/fit_result.hpp"
#include "maj0sted/domain/geometry/plan_element.hpp" // Straight
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
namespace maj0sted::domain {
/// How the gap between two independent, finite straights should be connected.
/// A pure domain concept: the editor's numeric fit modes map onto it, but this
/// enum carries no UI knowledge. A plain circular arc (Arc) and a compound curve
/// (Compound) are the only kinds — a "łuk + klotoidy" is just a Compound with a
/// single arc eased by symmetric clothoids.
enum class GapConnectionMode { Arc, Compound };
/// One arc of a compound connection.
struct CompoundGapArc {
double radius{0.0}; ///< arc radius (metres)
double length{0.0}; ///< fixed arc length (metres); ignored for the last arc
double transition_to_next{0.0}; ///< clothoid to the next arc (metres, 0 = none)
};
/// Extra parameters for a compound connection: entry/exit easing clothoids plus a
/// sequence of one or more arcs. A single arc yields "łuk + klotoidy"; two or
/// more yield a "łuk koszowy". The last arc absorbs the remaining deflection, so
/// its @c length / @c transition_to_next are ignored.
struct CompoundGapParameters {
double entry_transition{0.0};
double exit_transition{0.0};
std::vector<CompoundGapArc> arcs{};
};
/// A request to connect two finite straights with a curve.
struct GapConnectionRequest {
GapConnectionMode mode{GapConnectionMode::Arc};
double radius{0.0}; ///< plain-arc radius (metres)
CompoundGapParameters compound{}; ///< compound only
};
/// The connection actually produced.
struct GapConnection {
FitResult fit; ///< trimmed straights + spliced curve
double applied_radius{0.0}; ///< radius truly used (may be reduced to fit)
};
/// Domain policy connecting two independent, finite straights for the editor.
///
/// This is the single source of truth for the fitting business rules:
/// * a 180° reversal is detected from the geometry and handled as a nawrót,
/// never surfaced as a user-chosen mode;
/// * a plain arc uses the requested radius verbatim whenever it fits; the
/// radius is NEVER enlarged. A tighter-than-needed arc fits by extending the
/// straights toward the corner; only a radius too large to land on the
/// straights is reduced, to the largest one that fits. No other curve type is
/// ever substituted;
/// * a compound curve (one arc eased by clothoids, or a multi-arc basket) is
/// built as specified;
/// * a tangent point may extend a straight toward the corner, but must not cross
/// behind its outer, anchored end.
///
/// Returns nullopt when nothing admissible fits (the caller then leaves the gap
/// unconnected). Never throws.
class GapFitter {
public:
[[nodiscard]] static std::optional<GapConnection> connect(
const Straight& entry, const Straight& exit,
const GapConnectionRequest& request);
};
} // namespace maj0sted::domain

View File

@@ -1,60 +0,0 @@
#pragma once
#include <cstddef>
#include <utility>
#include <vector>
#include "maj0sted/domain/geometry/plan_element.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
namespace maj0sted::domain {
/// How two consecutive plan elements meet.
enum class JointContinuity {
/// Uniform azimuth ("styczność") — the rule for every joint.
Tangent,
/// An explicitly marked azimuth discontinuity ("załamanie"). Allowed only
/// between two straights that touch at different azimuths.
AzimuthBreak,
};
/// "Niweleta w planie": a directional (ordered) list of plan elements.
///
/// Straights are anchored in XY; the curves float and are positioned by later
/// fitting. The list enforces the connection rules between consecutive elements:
///
/// * as a rule consecutive elements meet under a uniform azimuth (Tangent);
/// * two straights may meet at different azimuths only if the joint is
/// explicitly marked as an AzimuthBreak;
/// * two circular arcs with the same radius and the same direction may not
/// be adjacent (it would be a single arc).
///
/// Joints that involve a floating curve cannot be fully checked yet and are
/// accepted pending the fitting step.
class HorizontalAlignment {
public:
/// Appends an element. The first element ignores @p joint; every later
/// element is validated against the current last element.
/// @throws std::logic_error if the joint violates a connection rule.
void append(PlanElement element, JointContinuity joint = JointContinuity::Tangent);
[[nodiscard]] const std::vector<PlanElement>& elements() const noexcept {
return elements_;
}
/// Joint between element @p index and @p index + 1 (index < size() - 1).
[[nodiscard]] JointContinuity joint(std::size_t index) const {
return joints_.at(index);
}
[[nodiscard]] bool empty() const noexcept { return elements_.empty(); }
[[nodiscard]] std::size_t size() const noexcept { return elements_.size(); }
/// Sum of the known element lengths; arcs without a length are skipped.
[[nodiscard]] Length total_length() const;
private:
std::vector<PlanElement> elements_;
std::vector<JointContinuity> joints_; // size() == elements_.size() - 1
};
} // namespace maj0sted::domain

View File

@@ -1,127 +0,0 @@
#pragma once
#include <cmath>
#include <optional>
#include <variant>
#include "maj0sted/domain/value_objects/azimuth.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
#include "maj0sted/domain/value_objects/straight_id.hpp"
namespace maj0sted::domain {
/// Direction of a horizontal turn: left ("w lewo") or right ("w prawo").
enum class TurnDirection { Left, Right };
/// Krzywa w planie: odcinek prosty.
///
/// A straight is *anchored* by both of its XY endpoints; its azimuth and length
/// are computed from them. During fitting a straight keeps its supporting line
/// (position + azimuth) fixed — only its length may change as an endpoint slides
/// along that line. The curves between straights are positioned as a result of
/// that fitting, not the other way round.
///
/// A straight also carries an optional identity (StraightId). References between
/// straight segments — e.g. parallelism — point at that id directly. Straights
/// used purely as geometry may stay anonymous (null id).
class Straight {
public:
/// Anonymous straight (null id).
/// @throws std::invalid_argument if @p start and @p end coincide (a
/// zero-length straight has no defined azimuth).
Straight(CartesianPosition start, CartesianPosition end)
: Straight{StraightId{}, start, end} {}
/// Identified straight.
/// @throws std::invalid_argument if @p start and @p end coincide.
Straight(StraightId id, CartesianPosition start, CartesianPosition end)
: id_{id},
start_{start},
end_{end},
azimuth_{Azimuth::of_direction(start, end)},
length_{Length::from_metres(distance(start, end))} {}
[[nodiscard]] StraightId id() const noexcept { return id_; }
[[nodiscard]] CartesianPosition start() const noexcept { return start_; }
[[nodiscard]] CartesianPosition end() const noexcept { return end_; }
[[nodiscard]] Azimuth azimuth() const noexcept { return azimuth_; }
[[nodiscard]] Length length() const noexcept { return length_; }
private:
static double distance(CartesianPosition a, CartesianPosition b) noexcept {
const double dx = b.x() - a.x();
const double dy = b.y() - a.y();
return std::sqrt(dx * dx + dy * dy);
}
StraightId id_;
CartesianPosition start_;
CartesianPosition end_;
Azimuth azimuth_;
Length length_;
};
/// Krzywa w planie: łuk kołowy o stałym promieniu.
///
/// A floating element: it is defined by its radius and turn direction. Its
/// length is not essential and is therefore optional — typically it becomes
/// known only as a resultant of fitting between the anchored straights.
class CircularArc {
public:
CircularArc(Radius radius, TurnDirection direction,
std::optional<Length> length = std::nullopt) noexcept
: radius_{radius}, direction_{direction}, length_{length} {}
[[nodiscard]] Radius radius() const noexcept { return radius_; }
[[nodiscard]] TurnDirection direction() const noexcept { return direction_; }
[[nodiscard]] std::optional<Length> length() const noexcept { return length_; }
private:
Radius radius_;
TurnDirection direction_;
std::optional<Length> length_;
};
/// Krzywa w planie: krzywa przejściowa (klotoida).
///
/// A floating element defined by its length, the radii at each end and its turn
/// direction. A missing radius (nullopt) denotes an infinite radius — a tangent
/// to a straight — so one type covers straight->arc, arc->arc and arc->straight.
class TransitionCurve {
public:
TransitionCurve(Length length, std::optional<Radius> start_radius,
std::optional<Radius> end_radius, TurnDirection direction) noexcept
: length_{length},
start_radius_{start_radius},
end_radius_{end_radius},
direction_{direction} {}
[[nodiscard]] Length length() const noexcept { return length_; }
[[nodiscard]] std::optional<Radius> start_radius() const noexcept { return start_radius_; }
[[nodiscard]] std::optional<Radius> end_radius() const noexcept { return end_radius_; }
[[nodiscard]] TurnDirection direction() const noexcept { return direction_; }
private:
Length length_;
std::optional<Radius> start_radius_;
std::optional<Radius> end_radius_;
TurnDirection direction_;
};
/// Any single element of the horizontal alignment.
using PlanElement = std::variant<Straight, CircularArc, TransitionCurve>;
/// Length of a plan element, if known. A circular arc may not carry one.
[[nodiscard]] inline std::optional<Length> length_of(const PlanElement& element) {
if (const auto* straight = std::get_if<Straight>(&element)) {
return straight->length();
}
if (const auto* transition = std::get_if<TransitionCurve>(&element)) {
return transition->length();
}
return std::get<CircularArc>(element).length();
}
} // namespace maj0sted::domain

View File

@@ -1,53 +0,0 @@
#pragma once
#include <variant>
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
namespace maj0sted::domain {
/// Sense of a vertical curve: a crest ("wypukły" — hilltop) or a sag
/// ("wklęsły" — valley).
enum class VerticalCurvature { Crest, Sag };
/// Krzywa w profilu: pochylenie o stałym nachyleniu, wyrażonym w promilach (‰).
/// Wartość dodatnia oznacza wznoszenie, ujemna — opadanie.
class Grade {
public:
Grade(Length length, double slope_permille) noexcept
: length_{length}, slope_permille_{slope_permille} {}
[[nodiscard]] Length length() const noexcept { return length_; }
[[nodiscard]] double slope_permille() const noexcept { return slope_permille_; }
private:
Length length_;
double slope_permille_;
};
/// Krzywa w profilu: łuk pionowy o stałym promieniu, łączący dwa pochylenia.
class VerticalCurve {
public:
VerticalCurve(Length length, Radius radius, VerticalCurvature curvature) noexcept
: length_{length}, radius_{radius}, curvature_{curvature} {}
[[nodiscard]] Length length() const noexcept { return length_; }
[[nodiscard]] Radius radius() const noexcept { return radius_; }
[[nodiscard]] VerticalCurvature curvature() const noexcept { return curvature_; }
private:
Length length_;
Radius radius_;
VerticalCurvature curvature_;
};
/// Any single element of the vertical alignment.
using ProfileElement = std::variant<Grade, VerticalCurve>;
/// Length of a profile element, whatever its concrete kind.
[[nodiscard]] inline Length length_of(const ProfileElement& element) {
return std::visit([](const auto& e) { return e.length(); }, element);
}
} // namespace maj0sted::domain

View File

@@ -9,6 +9,7 @@ namespace maj0sted::domain {
/// The crossing mark of a turnout — the skos 1:n. It is the tangent of the angle
/// between the through and diverging tracks, so it fixes the total angle the
/// diverging curve must turn through: a turnout is 1:9, 1:12, 1:18,5 and so on.
/// Nothing is fitted to it any more: it is what the laid path is checked against.
class CrossingMark {
public:
explicit CrossingMark(double denominator) noexcept : denominator_{denominator} {}
@@ -24,75 +25,155 @@ private:
/// Which way the branch leaves the through track.
enum class DivergeSide { Left, Right };
/// One arc of a turnout's diverging curve: its radius and — unless it is the
/// closing arc — its fixed length and the clothoid easing it into the next arc.
struct TurnoutArc {
double radius{0.0};
double length{0.0}; ///< fixed length; ignored for the closing arc
double transition_to_next{0.0}; ///< clothoid to the next arc (0 = none)
/// Which part of the turnout a piece of the diverging path is. The role names the
/// piece in switch terms; it does not constrain its shape — a blade may be straight
/// (iglica prosta sieczna) or an arc (iglica łukowa styczna).
enum class TurnoutPart {
PreBlade, ///< odcinek przediglicowy: PR to the blade tip, along the through track
Blade, ///< iglica, from its (theoretical) tip to its heel
Curve, ///< the diverging curve between the blade heel and the frog rail
FrogRail, ///< the straight frog rail out to KR
};
/// The shape of a turnout's diverging curve. One arc is the everyday turnout; two
/// or more make a łuk koszowy. Whatever the shape, the curve turns through exactly
/// the crossing angle — the closing (last) arc spends whatever deflection the
/// earlier arcs and clothoids leave.
struct DivergingCurve {
std::vector<TurnoutArc> arcs; ///< at least one; the last one closes the turn
double entry_transition{0.0}; ///< clothoid from the through track into the first arc
double exit_transition{0.0}; ///< clothoid from the last arc back toward tangent
/// One authored piece of the diverging path, in order from PR. Every length is
/// explicit — the catalogue says it, and nothing here is solved for. A piece is a
/// straight when both radii are zero, an arc when they are equal, a clothoid
/// otherwise; radii are unsigned, the hand comes from DivergeSide.
struct TurnoutPiece {
TurnoutPart part{TurnoutPart::Curve};
double length{0.0};
double radius_start{0.0}; ///< 0 = straight
double radius_end{0.0}; ///< != radius_start = clothoid
/// Kąt nagięcia iglicy (beta): the heading break applied before this piece,
/// radians, unsigned. Zero everywhere for a blade tangent to the stock rail.
double turn_in{0.0};
};
/// A turnout (rozjazd), described the way a catalogue does: a crossing mark, the
/// side it opens to, the free diverging curve inside it, and the length it spans
/// from its start (początek rozjazdu) to its end (koniec rozjazdu) measured along
/// the through track. The curve turns to the crossing angle and a straight frog
/// rail carries the track on to that length.
/// Iglica — not the shape of the axis (that is a TurnoutPiece), but the two
/// dimensions of the blade rail itself that place its real tip. A blade cannot be
/// ground to nothing, so its tip is cut back to a thickness and the point where
/// the axis leaves the stock rail is only a theoretical one.
struct Blade {
double tip_thickness{0.0}; ///< u: the blade's width where its planed nose ends
double nose{0.0}; ///< d: the planed nose itself, from the tip to that point
double railtop_width{0.0}; ///< w: railhead width, metres (49E1 ≈ 0,070)
};
/// Half the standard gauge. The gap the blade opens is taken between two rails,
/// and the blade's own curve is `R + s/2` where R is the diverging track's radius
/// (Koc, 5.2), so this is what turns a gap into an angle. Stations stay on the
/// axis: the rail radius converts, it does not measure.
inline constexpr double kHalfGauge = 0.7175;
/// A turnout (rozjazd), described the way a catalogue drawing is: the crossing
/// mark and length it is meant to work out to, and the list of pieces the
/// diverging path is actually laid from.
struct Turnout {
CrossingMark mark;
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};
std::vector<TurnoutPiece> path; ///< PR -> KR, in order; nothing implicit
/// Catalogue length: a + b, the two tangent legs of the drawing — PR to the
/// theoretical crossing point along the through track, and that point to KR
/// along the diverging one. This is the L the type tables give (Rz 190-1:9:
/// a 13,834 + b 13,304 = 27,138), not the projection on the through track.
double length{0.0};
Blade blade{};
};
/// 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.
/// One laid segment of the diverging path: a piece with its curvature signed for
/// the hand it diverges to, ready for layout_segment.
struct TurnoutSegment {
double turn_in{0.0}; ///< signed heading break applied before laying this segment (the blade)
double turn_in{0.0}; ///< signed heading break applied before laying this segment
double k0{0.0};
double k1{0.0};
double length{0.0};
TurnoutPart part{TurnoutPart::Curve};
};
/// What a turnout works out to when laid from its start pose.
struct TurnoutGeometry {
bool valid{false};
std::vector<TurnoutSegment> path; ///< diverging segments from the switch start to the frog
geometry::Pose frog{}; ///< the end (koniec rozjazdu): position + heading at the crossing angle
geometry::Pose frog{}; ///< the end (koniec rozjazdu): position + heading
double tangent_front{0.0}; ///< theoretical tangent length, switch start to the crossing point
double tangent_back{0.0}; ///< theoretical tangent length, crossing point to the frog
double diverging_length{0.0}; ///< total length of the diverging path (curve + frog rail)
double diverging_length{0.0}; ///< total length of the diverging path
/// Where the turnout ends as measured on the through track: the frog projected
/// back onto it. This is the point that closes the switch on the straight — or
/// on the arc, when the turnout is bent.
geometry::Pose through_end{};
double through_length{0.0};
// --- łukowanie ----------------------------------------------------------
/// Signed curvature of the through track the turnout was laid against, and
/// what the diverging path came out at. Both zero for a rozjazd zwyczajny;
/// `diverging_curvature` is zero too when a two-sided bend straightens the
/// branch out. Positive turns left.
double bend{0.0};
double diverging_curvature{0.0};
/// Beta: how far the through track turns over the turnout. The bend is this
/// angle as much as it is a radius, and it is what the crossing angle is
/// measured from once the tor zasadniczy no longer runs straight.
double bend_angle{0.0};
// --- the blade, as dimensioned rather than as laid ----------------------
// The curve is theoretically tangent to the stock rail at the switch start, but
// the blade only begins where there is metal enough to begin it. What runs from
// PR to that tip is the odcinek przediglicowy: the curve is already there, the
// vehicle is still on the through track.
bool has_blade{false};
geometry::Pose blade_tip{}; ///< ostrze iglicy, where the blade piece starts
geometry::Pose blade_nose_end{}; ///< A: where the planed nose ends and the blade is u thick
geometry::Pose blade_planed{}; ///< where the blade reaches full railhead width
double blade_station{0.0}; ///< a: PR -> the tip, the odcinek przediglicowy
double nose_end{0.0}; ///< station of A, theoretically a + d
double planing_length{0.0}; ///< struganie: the tip to full railhead width
double planing_end{0.0}; ///< station where the planing ends
double blade_angle{0.0}; ///< delta: kąt przylegania, the tangent angle at A
/// (a + d) minus where the rail really stands `u` off the stock rail. Zero means
/// the type's own a, d and u agree with the curve it is laid on.
double blade_residual{0.0};
// --- what the catalogue numbers say about all that ----------------------
/// Total deflection of the path minus the crossing angle. Zero = the path
/// closes on the mark; non-zero is reported, never fitted away.
double angle_residual{0.0};
/// The laid tangent legs (a + b) minus the catalogue length.
double length_residual{0.0};
};
/// 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,
/// 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);
/// How far along an axis of radius @p radius, leaving the stock rail at angle
/// @p angle, the blade stands @p offset away from it: `R·(acos(cos β − offset/R1) − β)`
/// for a curved blade, `offset / sin β` for a straight one. The gap opens on the
/// blade's own curve `R1 = R + s/2`, but the answer is a station on the axis, which
/// is what every length here is measured in.
///
/// This is the relation the catalogue's own numbers obey. A drawing dimensions the
/// blade on the rail rather than on the axis, so what Koc's Tablica 5.1 gives as
/// `a + d` is this run scaled by `R1/R` — at 1200-1:18,5 the two agree to a tenth
/// of a millimetre. Zero when the curve neither turns nor breaks away, or when the
/// offset is out of its reach.
[[nodiscard]] double blade_run_to_offset(double radius, double angle, double offset);
/// Lays @p turnout from @p start. @p bend is the signed curvature (1/m, positive
/// turns left) of the through track under it: zero for a rozjazd zwyczajny, and
/// anything else for a rozjazd łukowy.
///
/// Bending is not adding curvature. Koc, 5.3.6: it rotates the triangle OBC about
/// the centre of the turnout, so the crossing angle alfa and the tangent
/// `t = R·tg(alfa/2)` come through untouched and every radius follows from them —
/// the through track turns by `beta = 2·arctg(t·bend)`, the diverging path by
/// `beta + alfa`, and each gets the radius `t/tg(its own angle/2)`. That is where
/// Koc's (5.7) and (5.8) come from, and it is why a Rz 49E1-190-1:9 bent
/// symmetrically is the catalogue's Rłs 49E1-380,292-1:9.
///
/// The switch and the frog are not re-cut, only bent: a straight krzyżownica stays
/// straight and the blade keeps its own dimensions, measured — as on the ground —
/// against the opornica that curves with it.
///
/// Invalid when the path has no arc to bend or its arcs disagree on a radius:
/// there is then no single triangle to rotate, and guessing one would be a lie.
[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, const Turnout& turnout,
double bend = 0.0);
} // namespace maj0sted::domain

View File

@@ -1,31 +0,0 @@
#pragma once
#include <cstddef>
#include <utility>
#include <vector>
#include "maj0sted/domain/geometry/profile_element.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
namespace maj0sted::domain {
/// Geometria w profilu (rzut pionowy toru): uporządkowany ciąg pochyleń i łuków
/// pionowych, gdzie każdy element zaczyna się tam, gdzie kończy się poprzedni.
class VerticalAlignment {
public:
void append(ProfileElement element) { elements_.push_back(std::move(element)); }
[[nodiscard]] const std::vector<ProfileElement>& elements() const noexcept {
return elements_;
}
[[nodiscard]] bool empty() const noexcept { return elements_.empty(); }
[[nodiscard]] std::size_t size() const noexcept { return elements_.size(); }
/// Sumaryczna długość toru w profilu.
[[nodiscard]] Length total_length() const;
private:
std::vector<ProfileElement> elements_;
};
} // namespace maj0sted::domain

View File

@@ -1,32 +0,0 @@
#pragma once
#include <vector>
#include "maj0sted/domain/niweleta.hpp"
#include "maj0sted/domain/value_objects/crs.hpp"
namespace maj0sted::domain {
/// The map project ("projekt mapy").
///
/// It defines the Cartesian coordinate reference system in which every position
/// in the project is expressed (by default EPSG:2180, metres) and owns the
/// niwelety that make up the layout.
class MapProject {
public:
/// Creates a project using @p crs, or EPSG:2180 by default.
explicit MapProject(Crs crs = Crs::default_crs());
[[nodiscard]] Crs crs() const noexcept { return crs_; }
void add_niweleta(Niweleta niweleta);
[[nodiscard]] const std::vector<Niweleta>& niwelety() const noexcept {
return niwelety_;
}
private:
Crs crs_;
std::vector<Niweleta> niwelety_;
};
} // namespace maj0sted::domain

View File

@@ -1,70 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include "maj0sted/domain/fitting/fit_result.hpp"
#include "maj0sted/domain/geometry/horizontal_alignment.hpp"
#include "maj0sted/domain/geometry/vertical_alignment.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/niweleta_id.hpp"
#include "maj0sted/domain/value_objects/straight_id.hpp"
namespace maj0sted::domain {
/// Niweleta — jednolity odcinek toru złożony z wielu krzywych w planie i w
/// profilu.
///
/// Aggregate Root: to granica spójności całego odcinka. Geometria w planie i w
/// profilu opisują ten sam fizyczny tor, więc ich sumaryczne długości muszą być
/// zgodne — pilnuje tego lengths_consistent(). Krzywe dokłada się wyłącznie
/// przez metody agregatu, który utrzymuje ich kolejność.
class Niweleta {
public:
explicit Niweleta(NiweletaId id, std::string name = {});
[[nodiscard]] NiweletaId id() const noexcept { return id_; }
[[nodiscard]] const std::string& name() const noexcept { return name_; }
/// Dodaje prostą na końcu planu, nadając jej automatycznie StraightId,
/// który zwraca (do późniejszych referencji: równoległość, fitowanie).
StraightId add_straight(CartesianPosition start, CartesianPosition end,
JointContinuity joint = JointContinuity::Tangent);
/// Dokłada krzywą na końcu geometrii w planie, łącząc ją z poprzednim
/// elementem zadanym stykiem (domyślnie stycznym). Jeśli elementem jest
/// prosta bez tożsamości, niweleta nadaje jej StraightId.
void add_plan_element(PlanElement element,
JointContinuity joint = JointContinuity::Tangent);
/// Wpina wynik fitowania w plan: podmienia dwie sąsiednie proste (entry,
/// exit z @p result) na entry(przyciętą) → curve... → exit(przyciętą),
/// stykami stycznymi (Tangent). Proste zachowują swoje StraightId.
/// @throws std::invalid_argument jeśli proste nie zostaną znalezione lub nie
/// są sąsiednie (entry bezpośrednio przed exit).
void apply_fit(const FitResult& result);
/// Dokłada krzywą na końcu geometrii w profilu.
void add_profile_element(ProfileElement element);
[[nodiscard]] const HorizontalAlignment& plan() const noexcept { return plan_; }
[[nodiscard]] const VerticalAlignment& profile() const noexcept { return profile_; }
[[nodiscard]] Length plan_length() const { return plan_.total_length(); }
[[nodiscard]] Length profile_length() const { return profile_.total_length(); }
/// Czy plan i profil opisują tor tej samej długości (z zadaną tolerancją)?
[[nodiscard]] bool lengths_consistent(double tolerance_m = 1e-6) const;
private:
StraightId next_straight_id() noexcept;
NiweletaId id_;
std::string name_;
HorizontalAlignment plan_;
VerticalAlignment profile_;
std::uint64_t straight_seq_{0};
};
} // namespace maj0sted::domain

View File

@@ -1,39 +0,0 @@
#pragma once
#include "maj0sted/domain/geometry/plan_element.hpp"
#include "maj0sted/domain/parallelism/track_parallelism.hpp"
#include "maj0sted/domain/value_objects/track_offset.hpp"
namespace maj0sted::domain {
/// Domain Service: creates and reasons about parallelism between straight
/// segments.
class ParallelismService {
public:
/// Marks @p origin as the origin of a parallel relation with @p parallel,
/// separated by @p offset. Both straights must be identified.
/// @throws std::invalid_argument if a straight has no id or the two are the
/// same straight.
static TrackParallelism mark_parallel(const Straight& origin,
const Straight& parallel,
TrackOffset offset);
/// Are two straights parallel as lines (same or opposite azimuth)?
static bool are_parallel(const Straight& a, const Straight& b,
double tolerance_rad = 1e-9);
/// The line the parallel straight lies on: @p origin shifted sideways by
/// @p offset, perpendicular to its direction of travel. Same length and
/// azimuth as the origin; the returned straight is anonymous.
static Straight derive_parallel_line(const Straight& origin, TrackOffset offset);
/// Perpendicular distance (>= 0) from @p other's start point to the
/// (infinite) line carrying @p origin.
static double perpendicular_distance(const Straight& origin, const Straight& other);
[[nodiscard]] static Straight project_onto_offset_line(const Straight& origin,
double offset_m,
const Straight& segment);
};
} // namespace maj0sted::domain

View File

@@ -1,38 +0,0 @@
#pragma once
#include <stdexcept>
#include "maj0sted/domain/value_objects/straight_id.hpp"
#include "maj0sted/domain/value_objects/track_offset.hpp"
namespace maj0sted::domain {
/// A reference-based relation between two straight segments, identified by their
/// StraightId. @c origin is the authoritative reference track; @c parallel runs
/// alongside it at @c offset (a perpendicular distance on a given side).
///
/// Invariants: both straights must be identified (non-null id), and a straight
/// cannot be parallel to itself.
class TrackParallelism {
public:
TrackParallelism(StraightId origin, StraightId parallel, TrackOffset offset)
: origin_{origin}, parallel_{parallel}, offset_{offset} {
if (origin.is_null() || parallel.is_null()) {
throw std::invalid_argument{"Parallelism requires identified straights"};
}
if (origin == parallel) {
throw std::invalid_argument{"A straight cannot be parallel to itself"};
}
}
[[nodiscard]] StraightId origin() const noexcept { return origin_; }
[[nodiscard]] StraightId parallel() const noexcept { return parallel_; }
[[nodiscard]] TrackOffset offset() const noexcept { return offset_; }
private:
StraightId origin_;
StraightId parallel_;
TrackOffset offset_;
};
} // namespace maj0sted::domain

View File

@@ -1,67 +0,0 @@
#pragma once
#include <cmath>
#include <compare>
#include <numbers>
#include <stdexcept>
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
namespace maj0sted::domain {
/// Value Object: a horizontal azimuth — the direction of travel in the plan,
/// measured clockwise from north (the +y / northing axis) and normalised to
/// [0, 2*pi) radians.
class Azimuth {
public:
static Azimuth from_radians(double radians) noexcept {
return Azimuth{normalise(radians)};
}
static Azimuth from_degrees(double degrees) noexcept {
return from_radians(degrees * std::numbers::pi / 180.0);
}
/// Azimuth of the direction from @p from to @p to.
/// @throws std::invalid_argument if the two points coincide.
static Azimuth of_direction(CartesianPosition from, CartesianPosition to) {
const double d_east = to.x() - from.x();
const double d_north = to.y() - from.y();
if (d_east == 0.0 && d_north == 0.0) {
throw std::invalid_argument{"Azimuth is undefined for coincident points"};
}
return from_radians(std::atan2(d_east, d_north));
}
[[nodiscard]] double radians() const noexcept { return radians_; }
[[nodiscard]] double degrees() const noexcept {
return radians_ * 180.0 / std::numbers::pi;
}
/// Smallest absolute angular difference to @p other, in [0, pi].
[[nodiscard]] double angular_distance(Azimuth other) const noexcept {
double difference = std::fabs(radians_ - other.radians_);
if (difference > std::numbers::pi) {
difference = 2.0 * std::numbers::pi - difference;
}
return difference;
}
friend auto operator<=>(const Azimuth&, const Azimuth&) noexcept = default;
private:
explicit Azimuth(double radians) noexcept : radians_{radians} {}
static double normalise(double radians) noexcept {
const double two_pi = 2.0 * std::numbers::pi;
double result = std::fmod(radians, two_pi);
if (result < 0.0) {
result += two_pi;
}
return result;
}
double radians_;
};
} // namespace maj0sted::domain

View File

@@ -1,27 +0,0 @@
#pragma once
#include <compare>
namespace maj0sted::domain {
/// Value Object: a position in a Cartesian coordinate reference system.
///
/// Coordinates are expressed in the units of the project's CRS. For the default
/// EPSG:2180 these are metres, with @c x as easting and @c y as northing.
class CartesianPosition {
public:
constexpr CartesianPosition() noexcept = default;
constexpr CartesianPosition(double x, double y) noexcept : x_{x}, y_{y} {}
[[nodiscard]] constexpr double x() const noexcept { return x_; }
[[nodiscard]] constexpr double y() const noexcept { return y_; }
friend constexpr bool operator==(const CartesianPosition&,
const CartesianPosition&) noexcept = default;
private:
double x_{0.0};
double y_{0.0};
};
} // namespace maj0sted::domain

View File

@@ -1,29 +0,0 @@
#pragma once
#include <compare>
namespace maj0sted::domain {
/// Value Object: a Coordinate Reference System, identified by its EPSG code.
///
/// The default for a map project is EPSG:2180 (PUWG 1992 / Poland CS92), a
/// Cartesian, metre-based projected system.
class Crs {
public:
// EPSG code of the default map-project CRS.
static constexpr int kDefaultEpsg = 2180;
explicit constexpr Crs(int epsg) noexcept : epsg_{epsg} {}
/// The default CRS: EPSG:2180.
static constexpr Crs default_crs() noexcept { return Crs{kDefaultEpsg}; }
[[nodiscard]] constexpr int epsg() const noexcept { return epsg_; }
friend constexpr auto operator<=>(const Crs&, const Crs&) noexcept = default;
private:
int epsg_;
};
} // namespace maj0sted::domain

View File

@@ -1,37 +0,0 @@
#pragma once
#include <compare>
#include <stdexcept>
namespace maj0sted::domain {
/// Value Object: a non-negative length, in metres (the unit of the default
/// EPSG:2180 CRS). Lengths add and compare by value.
class Length {
public:
constexpr Length() noexcept = default;
static constexpr Length from_metres(double metres) {
if (metres < 0.0) {
throw std::invalid_argument{"Length cannot be negative"};
}
return Length{metres};
}
static constexpr Length zero() noexcept { return Length{}; }
[[nodiscard]] constexpr double metres() const noexcept { return metres_; }
[[nodiscard]] constexpr Length operator+(Length other) const noexcept {
return Length{metres_ + other.metres_};
}
friend constexpr auto operator<=>(const Length&, const Length&) noexcept = default;
private:
explicit constexpr Length(double metres) noexcept : metres_{metres} {}
double metres_{0.0};
};
} // namespace maj0sted::domain

View File

@@ -1,36 +0,0 @@
#pragma once
#include <compare>
#include <cstddef>
#include <cstdint>
#include <functional>
namespace maj0sted::domain {
/// Value Object: identity of a Niweleta aggregate. Cross-aggregate references
/// (e.g. parallelism) point at niwelety by this id, never by pointer. Value 0
/// denotes "no identity".
class NiweletaId {
public:
using value_type = std::uint64_t;
constexpr NiweletaId() noexcept = default;
explicit constexpr NiweletaId(value_type value) noexcept : value_{value} {}
[[nodiscard]] constexpr value_type value() const noexcept { return value_; }
[[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; }
friend constexpr auto operator<=>(const NiweletaId&, const NiweletaId&) noexcept = default;
private:
value_type value_{0};
};
} // namespace maj0sted::domain
template <>
struct std::hash<maj0sted::domain::NiweletaId> {
std::size_t operator()(const maj0sted::domain::NiweletaId& id) const noexcept {
return std::hash<std::uint64_t>{}(id.value());
}
};

View File

@@ -1,30 +0,0 @@
#pragma once
#include <compare>
#include <stdexcept>
namespace maj0sted::domain {
/// Value Object: a radius of curvature, in metres. Always strictly positive —
/// the turn direction (plan) or curve sense (profile) is carried separately by
/// the geometric element, so the radius itself is just a magnitude.
class Radius {
public:
static constexpr Radius from_metres(double metres) {
if (!(metres > 0.0)) {
throw std::invalid_argument{"Radius must be positive"};
}
return Radius{metres};
}
[[nodiscard]] constexpr double metres() const noexcept { return metres_; }
friend constexpr auto operator<=>(const Radius&, const Radius&) noexcept = default;
private:
explicit constexpr Radius(double metres) noexcept : metres_{metres} {}
double metres_;
};
} // namespace maj0sted::domain

View File

@@ -1,36 +0,0 @@
#pragma once
#include <compare>
#include <cstddef>
#include <cstdint>
#include <functional>
namespace maj0sted::domain {
/// Value Object: identity of a straight segment. A straight that takes part in
/// references (e.g. parallelism) carries a non-null id; anonymous straights used
/// purely as geometry may leave it null. Value 0 denotes "no identity".
class StraightId {
public:
using value_type = std::uint64_t;
constexpr StraightId() noexcept = default;
explicit constexpr StraightId(value_type value) noexcept : value_{value} {}
[[nodiscard]] constexpr value_type value() const noexcept { return value_; }
[[nodiscard]] constexpr bool is_null() const noexcept { return value_ == 0; }
friend constexpr auto operator<=>(const StraightId&, const StraightId&) noexcept = default;
private:
value_type value_{0};
};
} // namespace maj0sted::domain
template <>
struct std::hash<maj0sted::domain::StraightId> {
std::size_t operator()(const maj0sted::domain::StraightId& id) const noexcept {
return std::hash<std::uint64_t>{}(id.value());
}
};

View File

@@ -1,31 +0,0 @@
#pragma once
#include <stdexcept>
#include "maj0sted/domain/value_objects/length.hpp"
namespace maj0sted::domain {
/// Which side a parallel track runs on, relative to the origin straight's
/// direction of travel: left ("po lewej") or right ("po prawej").
enum class Side { Left, Right };
/// Value Object: the sideways offset of a parallel track from its origin — a
/// strictly positive perpendicular distance plus the side it lies on.
class TrackOffset {
public:
TrackOffset(Length distance, Side side) : distance_{distance}, side_{side} {
if (!(distance.metres() > 0.0)) {
throw std::invalid_argument{"Track offset distance must be positive"};
}
}
[[nodiscard]] Length distance() const noexcept { return distance_; }
[[nodiscard]] Side side() const noexcept { return side_; }
private:
Length distance_;
Side side_;
};
} // namespace maj0sted::domain

View File

@@ -0,0 +1,31 @@
#pragma once
#include <vector>
#include "maj0sted/domain/geometry/segment_layout.hpp"
#include "maj0sted/editor/model.hpp"
#include "maj0sted/editor/solution.hpp"
namespace maj0sted::editor {
/// What laying one track produced, and everything that went wrong doing it.
struct TrackOutcome {
SolvedTrack track;
std::vector<Diagnostic> diagnostics;
};
/// Lays @p track from @p start.
///
/// Every element states its own length, so laying is a walk: each element starts
/// on the pose the one before it ended on, and nothing is solved for. A clothoid
/// takes its near-end curvature from where the chain has got to, which is the
/// only way one element depends on another.
///
/// Never throws. An element that cannot be laid — a non-positive length, an arc
/// without a radius or a hand, a clothoid that changes nothing — is reported and
/// ends the chain there; everything before it is still returned and still
/// drawable.
[[nodiscard]] TrackOutcome lay_track(const Document& document, const Track& track,
const domain::geometry::Pose& start);
} // namespace maj0sted::editor

View File

@@ -1,139 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline
namespace maj0sted::editor {
/// An anchored straight segment (two XY endpoints).
struct StraightSpec {
double x1{0.0}, y1{0.0}, x2{0.0}, y2{0.0};
/// Internal tangent guide used to solve a terminal curve. It participates
/// in fitting but is not rendered as track or exposed as an editor handle.
bool hidden{false};
/// Live constraint the host reapplies before each solve (same model as the
/// web editor's `par` / `skew`). 0 = none, 1 = parallel offset from another
/// straight, 2 = skew 1:n from another straight.
int rel_kind{0};
int rel_niw{-1}; ///< index of the reference niweleta
int rel_str{-1}; ///< index of the reference straight
double rel_offset{0.0}; ///< parallel: signed metres (+ = left of travel)
double rel_cot{0.0}; ///< skew: n of a 1:n turnout
int rel_side{1}; ///< skew: +1 left / -1 right of the reference
double rel_length{0.0}; ///< skew: length of the dependent arm
};
/// One arc of a compound fit: its radius, its fixed length (ignored for the last
/// arc, which absorbs the remaining deflection) and an optional clothoid to the
/// next arc.
struct CompoundArcSpec {
double radius{0.0};
double length{0.0};
double transition_to_next{0.0};
};
/// A fit in the gap between straight @c gap and straight @c gap + 1.
struct GapFit {
int gap{0};
int mode{0}; ///< 0 none, 1 arc, 2 legacy arc+clothoids, 3 compound, 4 U-turn
double radius{0.0}; ///< plain arc (mode 1)
double transition{0.0}; ///< legacy mode-2 symmetric clothoid
// Unified compound (mode 3): end clothoids plus one or more arcs.
double entry_t{0.0}, exit_t{0.0};
std::vector<CompoundArcSpec> arcs{};
// Legacy fixed two-arc fields, kept so old saved projects still deserialize;
// superseded by @c arcs. Used only when @c arcs is empty.
double r1{0.0}, arc1_len{0.0}, between{0.0}, r2{0.0};
};
/// A niweleta as edited in the GUI: independent anchored straights plus fits
/// connecting consecutive straights. Because the straights are independent, a
/// gap can be a normal corner (straights meet at an intersection) OR a 180°
/// reversal (parallel, offset straights) — no 180° limitation.
struct NiweletaSpec {
std::string name;
std::vector<StraightSpec> straights;
std::vector<GapFit> fits;
};
struct NiweletaPolys {
std::vector<PlanPolyline> polylines;
/// Centreline segments after adjacent fits trim the input straights. Kept
/// index-aligned with NiweletaSpec::straights for editor hit handles.
std::vector<StraightSpec> rendered_straights;
/// The authoritative fit set after solving: exactly the requested fits that
/// actually succeeded. A requested fit that could not be produced (bad
/// parameters or geometry) is simply absent here — the library, not the host,
/// decides that a failed fit becomes "no fit". The host mirrors this list.
std::vector<GapFit> applied_fits;
/// The solved axis as one polyline, in order, with no duplicate join points.
/// Lets a station along the niweleta be resolved to a pose (see pose_at).
std::vector<PlanPoint> centreline;
};
/// Builds each niweleta, fits every requested gap, and returns the sampled plan
/// per niweleta. Never throws: gaps whose parameters don't admit a fit are left
/// unconnected (the two straights simply stay as drawn).
[[nodiscard]] std::vector<NiweletaPolys> solve_project(
const std::vector<NiweletaSpec>& niwelety);
/// A switch (rozjazd): the point where a branch leaves a through niweleta. It is
/// a relation, not a niweleta — the through track is not split. The switch is
/// described the surveyor's way: a crossing mark 1:n (skos) that fixes the angle
/// the branch leaves at, and an interior curve that is free to be anything — a
/// single arc, or a compound basket (łuk koszowy). @c station marks the switch's
/// theoretical point (środek rozjazdu, where the through and diverging tangent
/// lines cross); the curve is fitted around it and the branch niweleta is pinned
/// to the frog it ends on. The theoretical tangent lengths follow from the curve
/// (see JunctionGeom) — they are read off, not set.
struct Junction {
int through{-1}; ///< index of the through niweleta (not modified)
double station{0.0}; ///< theoretical point: arc length along the through centreline
int side{0}; ///< 0 = diverges left, 1 = diverges right
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
};
/// The solved geometry of one switch: the switch point where the branch leaves
/// the through track, the frog it ends on, the diverging curve between them and
/// the theoretical tangent lengths that curve works out to.
struct JunctionGeom {
bool valid{false};
double px{0.0}, py{0.0}; ///< switch point (diverging leaves the through here)
double fx{0.0}, fy{0.0}; ///< frog: where the branch niweleta is pinned
double fhx{0.0}, fhy{0.0}; ///< unit heading the branch leaves the frog on
double tangent_front{0.0}; ///< theoretical tangent length before the curve
double tangent_back{0.0}; ///< theoretical tangent length after the curve
double length{0.0}; ///< diverging curve length (switch point to frog), metres
/// The diverging curve, as rails ready to draw.
std::vector<PlanPolyline> polylines;
};
/// A whole layout solved at once: the niwelety plus every switch. Returned by
/// solve_layout, which additionally pins each branch's start to its frog.
struct LayoutSolution {
std::vector<NiweletaPolys> niwelety;
std::vector<JunctionGeom> junctions; ///< index-aligned with the input switches
};
/// Solves @p niwelety together with @p junctions: each branch niweleta's start is
/// pinned to its switch's frog before solving, and every switch's diverging arc
/// is computed from the through niweleta's centreline. Never throws; a switch
/// whose through/branch is out of range or whose geometry is degenerate comes
/// back with valid == false. Takes @p niwelety by value because it repins them.
[[nodiscard]] LayoutSolution solve_layout(std::vector<NiweletaSpec> niwelety,
const std::vector<Junction>& junctions);
} // namespace maj0sted::editor

View File

@@ -0,0 +1,37 @@
#pragma once
#include "maj0sted/domain/geometry/turnout.hpp"
#include "maj0sted/editor/model.hpp"
#include "maj0sted/editor/solution.hpp"
namespace maj0sted::editor {
/// Solves a whole document: lays every turnout on its through track, lays every
/// track from its anchor, and moves the tracks that are held parallel to another
/// track's element until they really lie at the offset they were given.
///
/// The order is not fixed in advance — it is worked out from what refers to
/// what. A branch anchored to a turnout's frog is laid after that turnout, which
/// is laid after its through track, however deep that goes: a turnout on the
/// branch of a turnout is nothing special. A reference that runs in a circle is
/// reported and its members are left unlaid, rather than being fed through a
/// fixed number of relaxation passes and whatever came out.
///
/// Nothing here writes to @p document. Never throws.
[[nodiscard]] Solution solve(const Document& document);
/// The catalogue type as the geometry layer wants it: the piece list with its
/// curvatures signed for @p hand (+1 diverges left). Public because the editor
/// dimensions a *type* with it — laying one from an identity pose is how the
/// catalogue numbers are checked before any instance stands on a track.
[[nodiscard]] domain::Turnout to_domain(const TurnoutType& type, int hand);
/// The construction points of a turnout laid from @p start: where its parts meet,
/// where its blade is dimensioned, and the points the drawing takes its lengths
/// between. Public for the same reason as to_domain — so a bare type is drawn from
/// the very same list the solver reports for a placement, rather than from a second
/// idea of what a construction point is.
[[nodiscard]] std::vector<TurnoutMark> turnout_marks(const domain::TurnoutGeometry& geometry,
const domain::geometry::Pose& start);
} // namespace maj0sted::editor

View File

@@ -0,0 +1,217 @@
#pragma once
#include <cstdint>
#include <string>
#include <variant>
#include <vector>
namespace maj0sted::editor {
// ---------------------------------------------------------------------------
// Identity
// ---------------------------------------------------------------------------
/// Stable handles. Every reference in a document points at one of these, never
/// at a position in a vector: inserting or deleting anything must not silently
/// re-target a reference somewhere else. Ids are minted from one counter per
/// document (Document::next_id), so they are unique across all four spaces —
/// which makes a stray reference a lookup miss rather than a wrong hit.
enum class ElementId : std::uint32_t { none = 0 };
enum class TrackId : std::uint32_t { none = 0 };
enum class TurnoutId : std::uint32_t { none = 0 };
// ---------------------------------------------------------------------------
// The element
// ---------------------------------------------------------------------------
/// What an element is, geometrically. These are the three shapes
/// `geometry::layout_segment` lays: constant zero curvature, constant non-zero
/// curvature, linearly varying curvature.
enum class Kind {
Line,
Arc,
Clothoid,
};
// ---------------------------------------------------------------------------
// What holds an element
// ---------------------------------------------------------------------------
/// Nothing holds it: its shape and its place are its own.
struct Free {};
/// Held parallel to another track's element at a signed offset (+ = left of
/// *that* element's travel, whichever way this one runs).
///
/// This is międzytorze, said once: a straight beside a straight, an arc
/// concentric with an arc at R∓d. It fixes two things — the shape (kind, radius,
/// hand, taken from the reference) and where the track runs across the
/// reference. It fixes nothing along it, so the track stays where it was put and
/// slides nowhere when the reference is edited.
///
/// A track that can move — one anchored at a pose of its own — is moved as one
/// rigid piece until the held element really lies at the offset. A track pinned
/// to a turnout's port cannot move: there the reference still fixes the shape,
/// and the offset that actually came out is reported rather than forced.
struct Parallel {
ElementId ref{ElementId::none};
double offset{0.0};
};
using Hold = std::variant<Free, Parallel>;
/// One element of a track. It starts where the previous element ends — by pose,
/// not by coordinates — so tangency is a property of the representation and not
/// a rule anyone has to check or restore.
///
/// Everything about it is stated outright: what shape it is, how long it is, and
/// how tightly it turns. Nothing here is left for a solver to decide — there is
/// no "run on until you reach that line". Laying track by clicking along the
/// ground still works, but the corner is worked out at the click and written
/// down as numbers, so what the document says is what stands on the ground.
struct Element {
ElementId id{ElementId::none};
Kind kind{Kind::Line};
/// Arc: the radius, metres, > 0. Clothoid: the radius at its far end, with
/// 0 meaning infinite (a clothoid running out to a straight); its near-end
/// radius is whatever the previous element ends on. Line: ignored.
double radius{0.0};
/// +1 turns left, -1 turns right, 0 for a straight. Signed curvature is
/// exactly `hand / radius`, and positive curvature turns left in
/// `geometry::layout_segment` — so no sign is flipped anywhere downstream.
int hand{0};
/// Along the axis, metres, > 0.
double length{0.0};
/// What holds it to something else. Never changes its length.
Hold hold{Free{}};
};
// ---------------------------------------------------------------------------
// Anchors
// ---------------------------------------------------------------------------
/// Start at an explicit pose. @c az is an azimuth: radians clockwise from north,
/// i.e. atan2(dx, dy), matching the rest of the editor.
struct AtPose {
double x{0.0}, y{0.0}, az{0.0};
};
/// Which end of a turnout a track leaves from.
enum class Port {
Frog, ///< KR — koniec rozjazdu, the diverging track's end
Start, ///< PR — początek rozjazdu
};
/// Start at a turnout's port. This is what used to be `frog_of` plus the pinning
/// pass: the branch no longer gets moved back into place after the fact, it was
/// never anywhere else.
struct AtPort {
TurnoutId turnout{TurnoutId::none};
Port port{Port::Frog};
};
using Anchor = std::variant<AtPose, AtPort>;
// ---------------------------------------------------------------------------
// Track
// ---------------------------------------------------------------------------
/// A niweleta: an anchor plus an ordered chain of elements. Straights, arcs and
/// clothoids are equal citizens here — a chain may start with an arc, put two
/// arcs back to back, or simply stop without closing onto anything.
struct Track {
TrackId id{TrackId::none};
std::string name;
Anchor anchor{AtPose{}};
std::vector<Element> elements;
};
// ---------------------------------------------------------------------------
// Turnouts: catalogue type + placement
// ---------------------------------------------------------------------------
/// One piece of a catalogue turnout's diverging path. Mirrors
/// domain::TurnoutPiece; kept here so the document does not depend on the
/// geometry header.
struct TurnoutPieceSpec {
int part{2}; ///< 0 przediglicowy, 1 iglica, 2 łuk, 3 krzyżownicowa
double length{0.0}; ///< always explicit; nothing is fitted
double radius_start{0.0}; ///< 0 = straight
double radius_end{0.0}; ///< != radius_start = clothoid
double turn_in{0.0}; ///< kąt nagięcia iglicy, radians, unsigned
};
/// The blade's own dimensions, which place its cut tip. Mirrors domain::Blade.
struct BladeSpec {
double tip_thickness{0.0}; ///< u: width where the planed nose ends
double nose{0.0}; ///< d: the planed nose, from the tip to that point
double railtop_width{0.0}; ///< w: railhead width
};
/// A catalogue turnout: the list of pieces its diverging path is laid from, plus
/// the crossing mark and length the catalogue says it should work out to — which
/// the solver checks the list against and never fits it to. Every number that
/// belongs to the *type* lives here, so adding one is a catalogue change and
/// never touches the file format for placements.
struct TurnoutType {
std::string name;
double crossing_n{9.0}; ///< skos 1:n, checked against the laid deflection
double length{0.0}; ///< catalogue length PR->KR along the through track, checked
std::vector<TurnoutPieceSpec> pieces;
BladeSpec blade{};
};
/// An instance of a type, laid on a through track. There is nothing here to
/// edit but where it sits — the geometry belongs to the type.
struct TurnoutPlacement {
TurnoutId id{TurnoutId::none};
std::string type; ///< catalogue name
TrackId on{TrackId::none}; ///< the through track, which is not split
double station{0.0}; ///< PR, as arc length along the through centreline
int hand{1}; ///< +1 diverges left, -1 diverges right
bool facing{true}; ///< opens toward increasing station
/// Łukowanie. A turnout standing on a curve is bent onto it, and normally
/// that is all there is to say: the tor zasadniczy is whatever the track does
/// under the PR. Clearing this asks for a bend of its own instead — a rozjazd
/// łukowy on a track drawn straight — and then `bend` is the signed curvature
/// (1/m, positive turns left) the through track is taken to have. What the
/// bend works out to is the solver's business and is never written back here.
bool bend_from_track{true};
double bend{0.0};
};
// ---------------------------------------------------------------------------
// Document
// ---------------------------------------------------------------------------
/// Everything that is authored. Nothing in here is ever written by the solver.
struct Document {
std::vector<TurnoutType> turnout_types;
std::vector<TurnoutPlacement> turnouts;
std::vector<Track> tracks;
double view_x{0.0}, view_y{0.0}, view_extent{0.0};
bool origin_set{false};
bool georeferenced{false};
double origin_x{0.0}, origin_y{0.0};
/// Next handle to hand out. One counter for every id space.
std::uint32_t next_id{1};
};
/// Mints the next handle. The four overloads exist so a caller states which kind
/// of thing it is naming.
[[nodiscard]] ElementId mint_element(Document& document);
[[nodiscard]] TrackId mint_track(Document& document);
[[nodiscard]] TurnoutId mint_turnout(Document& document);
[[nodiscard]] const Track* find_track(const Document& document, TrackId id);
[[nodiscard]] Track* find_track(Document& document, TrackId id);
[[nodiscard]] const TurnoutPlacement* find_turnout(const Document& document, TurnoutId id);
[[nodiscard]] TurnoutPlacement* find_turnout(Document& document, TurnoutId id);
[[nodiscard]] const TurnoutType* find_type(const Document& document, const std::string& name);
/// The element itself, wherever it is; and the track it belongs to. Null when
/// unknown.
[[nodiscard]] const Element* find_element(const Document& document, ElementId id);
[[nodiscard]] const Track* find_element_track(const Document& document, ElementId id);
} // namespace maj0sted::editor

View File

@@ -0,0 +1,35 @@
#pragma once
#include <vector>
#include "maj0sted/editor/solution.hpp"
namespace maj0sted::editor {
/// One drawable rail, tagged with what it came from so the host can colour it
/// and hit-test it. Exactly one of @c element / @c turnout is set.
struct PlanPolyline {
Kind kind{Kind::Line};
ElementId element{ElementId::none};
TrackId track{TrackId::none};
TurnoutId turnout{TurnoutId::none};
/// For a turnout rail: which piece of the diverging path it is, and what part
/// that piece plays (0 przediglicowy, 1 iglica, 2 łuk, 3 krzyżownicowa), so a
/// drawing can say every element apart. -1 on a plain track rail.
int part{-1};
std::size_t piece{0};
double length{0.0};
double radius_start{0.0}; ///< 0 == infinite
double radius_end{0.0};
std::vector<PlanPoint> points;
};
/// Draws a solved layout as rails: two per element, a gauge apart. Turnout
/// diverging paths are drawn too, tagged with their turnout.
///
/// This is presentation only. It decides nothing about geometry, and the
/// exporter no longer reads it back — the axis it needs is in the Solution.
[[nodiscard]] std::vector<PlanPolyline> render_rails(const Solution& solution,
double gauge_metres = 1.5);
} // namespace maj0sted::editor

View File

@@ -1,32 +0,0 @@
#pragma once
#include <vector>
#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline
namespace maj0sted::editor {
/// One element of an alignment "ribbon" (element chain), defined intrinsically.
struct RibbonElement {
int kind{0}; ///< 0 = line, 1 = arc, 2 = spiral (clothoid)
double length{0.0}; ///< line / spiral length (arc length is derived)
double radius{0.0}; ///< arc radius; spiral end radius (0 = straight / infinite)
double angle{0.0}; ///< arc central angle (radians)
double radius_start{0.0}; ///< spiral start radius (0 = infinite)
int dir{1}; ///< +1 left, -1 right (arc / spiral)
};
/// A whole alignment defined as a start pose plus an ordered element chain,
/// laid out head-to-tail. Handles any arc angle (including 180°+, loops) because
/// nothing depends on tangent intersection.
struct RibbonRequest {
double x0{0.0};
double y0{0.0};
double az0{0.0}; ///< start azimuth (radians, clockwise from north)
std::vector<RibbonElement> elements;
};
/// Lays the ribbon out and returns one polyline per element, ready to draw.
[[nodiscard]] std::vector<PlanPolyline> solve_ribbon(const RibbonRequest& request);
} // namespace maj0sted::editor

View File

@@ -0,0 +1,118 @@
#pragma once
#include <string>
#include <vector>
#include "maj0sted/editor/model.hpp"
#include "maj0sted/editor/solution.hpp"
namespace maj0sted::editor {
/// Laying track by clicking along the ground.
///
/// The corner is worked out here, at the click, and written into the document as
/// ordinary elements with ordinary lengths — a straight shortened by the
/// tangent, an arc of the radius asked for, a straight running out to the point
/// clicked. Nothing is left pointing at the drawing, so the numbers in the
/// document are the numbers on the ground.
/// What rounding one corner costs.
struct Corner {
bool ok{false};
/// How far back from the corner the arc starts, and how far past it the arc
/// ends: R·tan(|Δ|/2). Both straights lose this much.
double tangent{0.0};
double arc_length{0.0}; ///< R·|Δ|
int hand{0}; ///< +1 left, -1 right
double deflection{0.0}; ///< Δ, radians, signed, + = left
};
/// Rounds the corner between a heading in and a heading out with an arc of
/// @p radius. Both headings are unit vectors in the plan's frame.
///
/// Not ok for a corner that does not bend, one that doubles back on itself, or a
/// non-positive radius: none of those is an arc, and guessing one is how a fit
/// used to quietly lay geometry nobody asked for.
[[nodiscard]] Corner fit_corner(double in_hx, double in_hy, double out_hx, double out_hy,
double radius);
/// The three elements a straight-arc-straight corner is made of, ready to append
/// after @p incoming has been shortened by `corner.tangent`.
[[nodiscard]] Element make_arc(Document& document, double radius, int hand, double length);
[[nodiscard]] Element make_line(Document& document, double length);
[[nodiscard]] Element make_clothoid(Document& document, double end_radius, int hand,
double length);
/// Carries a change of radius or hand across the joint it belongs to.
///
/// A transition curve's radius is the radius it ends on, so it belongs to
/// whatever comes after it: the arc it eases into, or nothing at all when a
/// straight follows and it runs out flat at R=0. That is one number, not two, so
/// editing either side of the joint carries the other with it — and the joint
/// only ever looks forward. The transition behind an arc is the one that eases
/// into it; the one in front of it eases into whatever is beyond, which is none
/// of that arc's business.
void sync_joint(Track& track, std::size_t at);
// ---------------------------------------------------------------------------
// Straights as lines, and the arcs between them
// ---------------------------------------------------------------------------
/// The line a straight lies on: a point on it and the direction of travel.
///
/// This is what a straight really is once it is drawn — where it lies and which
/// way it points. Its length is not part of it: that falls out of where the arcs
/// at either end come off it.
struct Support {
double px{0.0}, py{0.0};
double dx{1.0}, dy{0.0};
};
/// What a track hangs on: the line under every one of its straights, and where
/// the first and last of them begin and end.
///
/// The corners between two straights are not in here, because they do not have
/// to be: given the lines, each of them is worked out. A corner hanging off
/// either end of the chain — a track that starts or finishes mid-curve — is not
/// in here either, for the opposite reason: it has nothing on its far side to be
/// fitted against, so it is carried along exactly as it was authored.
struct Skeleton {
std::vector<Support> lines;
double start_x{0.0}, start_y{0.0}; ///< where the first straight begins
double end_x{0.0}, end_y{0.0}; ///< where the last straight ends
};
/// Pulls @p track's skeleton out of @p solved.
///
/// A chain must read as straights with corners between them — a corner being any
/// run of arcs and transition curves that turns one way, one arc or a basket of
/// them. Two straights may never touch: two straights end to end are one
/// straight. A track with no straight at all has no skeleton, and says so.
[[nodiscard]] bool skeleton_of(const Track& track, const SolvedTrack& solved,
Skeleton& out, std::string& why);
/// Rewrites @p track to run along @p skeleton, keeping every arc's radius and
/// hand, and every transition curve's length.
///
/// Each corner with a straight on both sides is fitted: the transitions turn what
/// they turn, the arcs share out what is left of the corner in the proportions
/// they were drawn with — so a basket curve stays that basket, tighter or wider —
/// and the whole corner is then slid along the line it leaves until it lands on
/// the line it joins. The turn is taken the way the arcs go, so a corner may run
/// past a half circle, up to a full one, and nothing shortens it to the way round
/// that happens to be shorter.
///
/// A corner hanging off either end of the chain keeps exactly what it was given:
/// there is no second line to fit it against, so a track ending mid-curve simply
/// ends where that curve takes it.
///
/// The skeleton's ends stay put and the anchor follows them — unless the track is
/// pinned to a turnout's port, which is not an anchor anyone may move: a change
/// that would drag the branch off its frog is refused instead.
///
/// False, with a reason, when the skeleton cannot carry the chain. Nothing is
/// written to @p track unless all of it works out.
[[nodiscard]] bool lay_along_skeleton(Track& track, const Skeleton& skeleton,
std::string& why);
} // namespace maj0sted::editor

View File

@@ -0,0 +1,169 @@
#pragma once
#include <string>
#include <vector>
#include "maj0sted/domain/geometry/segment_layout.hpp" // Pose
#include "maj0sted/domain/geometry/turnout.hpp" // TurnoutPart
#include "maj0sted/editor/model.hpp"
namespace maj0sted::editor {
/// A point in the project's CRS (metres for EPSG:2180).
struct PlanPoint {
double x{0.0};
double y{0.0};
};
// ---------------------------------------------------------------------------
// Solved geometry
// ---------------------------------------------------------------------------
/// What one authored element works out to on the ground. Curvatures rather than
/// radii, because that is what `geometry::layout_segment` takes and what makes a
/// straight (0, 0), an arc (k, k) and a clothoid (k0, k1) one shape.
struct SolvedElement {
ElementId id{ElementId::none};
Kind kind{Kind::Line};
domain::geometry::Pose start{}; ///< pose the element is laid from
domain::geometry::Pose end{}; ///< pose it ends on = the next element's start
double k0{0.0}; ///< signed curvature at the start (1/m)
double k1{0.0}; ///< signed curvature at the end
double length{0.0};
std::vector<PlanPoint> points; ///< sampled axis, first point == start
};
/// A solved track: its elements in order, plus the axis flattened into one
/// polyline with no duplicated join points, which is what a station along the
/// track is resolved against.
struct SolvedTrack {
TrackId id{TrackId::none};
std::vector<SolvedElement> elements;
std::vector<PlanPoint> centreline;
domain::geometry::Pose start{};
domain::geometry::Pose end{};
double length{0.0};
/// True when the chain was laid whole. False means it was cut short by a
/// diagnostic — the elements up to the cut are still here and still drawable.
bool complete{true};
};
/// One laid segment of a turnout's diverging path.
struct SolvedTurnoutSegment {
domain::geometry::Pose start{};
double k0{0.0}, k1{0.0}, length{0.0};
domain::TurnoutPart part{domain::TurnoutPart::Curve};
std::vector<PlanPoint> points;
};
/// A construction point of a turnout: where two of its parts meet, or a point
/// the catalogue drawing is dimensioned from. Reported rather than left to be
/// eyeballed off the drawn curve.
struct TurnoutMark {
double x{0.0}, y{0.0};
/// 0 PR, 1 ostrze iglicy (where the blade begins, the odcinek przediglicowy
/// behind it), 2 pięta iglicy, 3 joint inside the curve, 4 end of curve /
/// start of the frog rail, 5 KR, 6 punkt teoretyczny, 7 koniec dzioba iglicy
/// (A, where the blade is u thick), 8 koniec strugania, 9 koniec rozjazdu on
/// the through track (the frog projected back onto the straight).
int kind{0};
double station{0.0}; ///< along the diverging path from PR; 0 for the theoretical point
};
struct SolvedTurnout {
TurnoutId id{TurnoutId::none};
bool valid{false};
domain::geometry::Pose start{}; ///< PR, on the through track
domain::geometry::Pose frog{}; ///< KR, where a branch anchors
double tangent_front{0.0}, tangent_back{0.0};
double diverging_length{0.0};
double through_length{0.0}; ///< PR to the frog's projection, along the through track
std::vector<SolvedTurnoutSegment> path;
std::vector<TurnoutMark> marks;
/// The blade as dimensioned: where its tip stands (the odcinek przediglicowy
/// is everything before it), how long the planed nose and the planing run,
/// the kąt przylegania, and whether the type's own a/d/u agree with the curve.
/// Łukowanie, as laid: the signed curvature of the through track under it, the
/// one the branch came out at, and how far the through track turns over the
/// turnout. All zero for a rozjazd zwyczajny.
double bend{0.0};
double diverging_curvature{0.0};
double bend_angle{0.0};
bool has_blade{false};
double blade_station{0.0};
double nose_end{0.0};
double planing_length{0.0};
double blade_angle{0.0};
double blade_residual{0.0};
/// What the laid path works out to, against what the catalogue promised:
/// deflection minus the crossing angle, and through-length minus the
/// catalogue length. Reported, never fitted away.
double angle_residual{0.0};
double length_residual{0.0};
};
// ---------------------------------------------------------------------------
// Diagnostics
// ---------------------------------------------------------------------------
/// Why something could not be laid. Reported, never silently dropped: the user
/// gets the reason along with whatever part of the drawing did stand up.
enum class Code {
UnknownReference, ///< an anchor points at an id that is not there
CyclicDependency, ///< tracks/turnouts depend on each other in a circle
DegenerateElement, ///< non-positive radius or length, a clothoid that eases nothing
TurnoutInvalid, ///< catalogue numbers admit no geometry
TurnoutNotClosed, ///< the laid path misses the crossing angle or the catalogue length
StationOffTrack, ///< a turnout sits past the end of its through track
TouchingStraights, ///< two straights end to end, which is one straight
HoldConflict, ///< two elements of one movable track both hold it parallel
OffsetNotHeld, ///< a pinned track cannot move, so its offset is what it is
};
[[nodiscard]] const char* code_name(Code code);
struct Diagnostic {
Code code{Code::DegenerateElement};
TrackId track{TrackId::none};
ElementId element{ElementId::none};
TurnoutId turnout{TurnoutId::none};
/// Human-readable, with the number that matters in it ("R=300 m nie mieści
/// się, maksimum 214,4 m"). The host shows this; it does not compose it.
std::string text;
};
// ---------------------------------------------------------------------------
// The whole solution
// ---------------------------------------------------------------------------
/// Everything derived. Rebuilt whole on each solve, never fed back into the
/// document. Vectors rather than maps so iteration order is the document's and
/// two solves of the same document compare equal.
struct Solution {
std::vector<SolvedTrack> tracks;
std::vector<SolvedTurnout> turnouts;
std::vector<Diagnostic> diagnostics;
};
[[nodiscard]] const SolvedTrack* find_track(const Solution& solution, TrackId id);
[[nodiscard]] const SolvedTurnout* find_turnout(const Solution& solution, TurnoutId id);
[[nodiscard]] const SolvedElement* find_element(const Solution& solution, ElementId id);
/// Pose at @p station metres along a solved track's centreline. Clamped to the
/// ends. Returns false for an empty track.
[[nodiscard]] bool pose_at(const SolvedTrack& track, double station,
domain::geometry::Pose& out);
/// How much track of one steady curvature there is around @p station: @p behind
/// back to where the element begins, @p ahead on to where it ends, and
/// @p curvature what it runs at (signed, positive turns left; zero on a
/// straight). A turnout is laid as one piece of geometry, bent to the one radius
/// under it, so it belongs on a single element and has to fit on it whole.
/// False on a clothoid, where there is no one radius to bend to, and past the
/// end of the track. Nothing is written then.
[[nodiscard]] bool steady_room(const SolvedTrack& track, double station, double& curvature,
double& behind, double& ahead);
} // namespace maj0sted::editor

View File

@@ -1,55 +0,0 @@
#pragma once
#include <vector>
namespace maj0sted::editor {
/// A request from the GUI: three draggable vertices (entry straight A->B, exit
/// straight B->C) plus the fit parameters. Plain doubles, a flat DTO across the
/// boundary between the editor UI and this solver.
struct SolveRequest {
double ax{0.0}, ay{0.0}; ///< vertex A (entry start)
double bx{0.0}, by{0.0}; ///< vertex B (corner: entry end = exit start)
double cx{0.0}, cy{0.0}; ///< vertex C (exit end)
int mode{0}; ///< 0 = arc, 1 = arc + symmetric transitions, 2 = compound (łuk koszowy)
double radius{0.0}; ///< modes 0/1: arc radius
double transition{0.0}; ///< mode 1: symmetric transition-curve length
// Mode 2 (compound):
double r1{0.0}; ///< first arc radius
double arc1_len{0.0}; ///< first arc length (split place)
double between{0.0}; ///< transition length between the two arcs
double r2{0.0}; ///< second arc radius
double entry_t{0.0}; ///< entry transition length
double exit_t{0.0}; ///< exit transition length
};
/// A point in the CRS.
struct PlanPoint {
double x{0.0};
double y{0.0};
};
/// One polyline; @c kind is render::ElementKind (0 straight, 1 arc, 2 transition).
struct PlanPolyline {
int kind{0};
/// Logical owner used by map hit-testing. Exactly one is non-negative.
int straight_index{-1};
int gap{-1};
/// Position inside a fitted curve chain (arc/clothoid/arc...).
int element_index{-1};
double length{0.0};
/// Zero denotes an infinite radius (straight end of a transition).
double radius_start{0.0};
double radius_end{0.0};
std::vector<PlanPoint> points;
};
/// Builds the niweleta, runs the requested fit (falling back to the bare
/// straights if the parameters do not admit a fit) and returns the sampled plan
/// as polylines ready to draw.
[[nodiscard]] std::vector<PlanPolyline> solve_scene(const SolveRequest& request);
} // namespace maj0sted::editor

View File

@@ -0,0 +1,31 @@
#pragma once
#include <optional>
#include <string>
#include "maj0sted/editor/model.hpp"
namespace maj0sted::io {
/// The `.m0s` format, version 3.
///
/// Line-oriented text, one concept per token, every reference by id, and an
/// explicit version on the first line. Earlier versions are not read: they
/// described different models — anchored straights with mode-tagged fits between
/// them (1), then elements whose lengths were solved against construction lines
/// (2) — and translating either would have meant keeping those concepts alive in
/// the reader after they were removed from everything else.
///
/// Doubles go through std::to_chars/from_chars, so a round trip is exact and
/// does not depend on the locale.
[[nodiscard]] std::string serialize(const editor::Document& document);
/// Returns nothing when @p text is not a version 3 document or is malformed.
[[nodiscard]] std::optional<editor::Document> deserialize(const std::string& text);
[[nodiscard]] std::string default_project_path();
bool save(const editor::Document& document, const std::string& path = default_project_path());
[[nodiscard]] std::optional<editor::Document> load(
const std::string& path = default_project_path());
} // namespace maj0sted::io

View File

@@ -1,19 +0,0 @@
#pragma once
#include <string>
#include "maj0sted/domain/map_project.hpp"
namespace maj0sted::io {
/// Serializes a whole map project (CRS + niwelety, each with its plan and
/// profile) to a self-contained textual format. The format is line-oriented and
/// dependency-free; @c deserialize round-trips it exactly.
[[nodiscard]] std::string serialize(const maj0sted::domain::MapProject& project);
/// Rebuilds a map project from text produced by @c serialize.
/// @throws std::runtime_error on malformed input; std::invalid_argument if the
/// reconstructed geometry violates a domain invariant.
[[nodiscard]] maj0sted::domain::MapProject deserialize(const std::string& text);
} // namespace maj0sted::io

View File

@@ -2,9 +2,8 @@
#include <ostream>
#include <string>
#include <vector>
#include "maj0sted/editor/editor.hpp"
#include "maj0sted/editor/solution.hpp"
namespace maj0sted::io {
@@ -22,12 +21,18 @@ struct ScnExportResult {
double origin_north{0.0};
};
[[nodiscard]] ScnExportResult export_scn(
const std::vector<maj0sted::editor::NiweletaPolys>& solved,
const ScnExportOptions& options, std::ostream& out);
/// Writes the solved layout as MaSzyna scenery. The axis comes straight out of
/// the solution: the exporter used to average the two drawn rails back together
/// to recover it, which put the whole geometry through the renderer and back.
///
/// Height is still a constant and both roll slots are still zero — the vertical
/// alignment is a separate piece of work, and nothing in the editor produces one
/// yet.
[[nodiscard]] ScnExportResult export_scn(const editor::Solution& solution,
const ScnExportOptions& options,
std::ostream& out);
[[nodiscard]] ScnExportOptions resolve_scn_origin(
const std::vector<maj0sted::editor::NiweletaPolys>& solved,
ScnExportOptions options);
[[nodiscard]] ScnExportOptions resolve_scn_origin(const editor::Solution& solution,
ScnExportOptions options);
} // namespace maj0sted::io

View File

@@ -1,20 +0,0 @@
#pragma once
#include <string>
#include "maj0sted/render/scene.hpp"
namespace maj0sted::io {
struct SvgOptions {
double width_px = 900.0; ///< target drawing width
double margin_px = 24.0; ///< padding around the drawing
double stroke_px = 2.0; ///< line width
};
/// Renders a scene to a standalone SVG document (north up). Straights, arcs and
/// transition curves are drawn in distinct colours. Dependency-free — just a
/// simple viewer; a real GUI would consume render::Scene directly.
[[nodiscard]] std::string to_svg(const render::Scene& scene, const SvgOptions& options = {});
} // namespace maj0sted::io

View File

@@ -1,39 +1,27 @@
#pragma once
// maj0sted — a Domain-Driven Design library for a railway track-layout editor
// ("edytor torowiska"). Include this single header to pull in the whole public
// domain model.
// maj0sted — the model behind a railway track-layout editor ("edytor
// torowiska"). One header pulls in the whole public surface.
//
// The shape of it: a Document is authored (construction lines, catalogue
// turnout types and their placements, tracks made of elements), `solve` turns it
// into a Solution, and nothing ever travels back the other way.
#include "maj0sted/version.hpp"
// Value Objects
#include "maj0sted/domain/value_objects/azimuth.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
#include "maj0sted/domain/value_objects/crs.hpp"
#include "maj0sted/domain/value_objects/length.hpp"
#include "maj0sted/domain/value_objects/niweleta_id.hpp"
#include "maj0sted/domain/value_objects/radius.hpp"
#include "maj0sted/domain/value_objects/straight_id.hpp"
#include "maj0sted/domain/value_objects/track_offset.hpp"
// The authored model, and what solving it produces.
#include "maj0sted/editor/model.hpp"
#include "maj0sted/editor/solution.hpp"
// Geometry — krzywe w planie i w profilu
#include "maj0sted/domain/geometry/horizontal_alignment.hpp"
#include "maj0sted/domain/geometry/plan_element.hpp"
#include "maj0sted/domain/geometry/profile_element.hpp"
#include "maj0sted/domain/geometry/vertical_alignment.hpp"
// Solving: one track's chain, and a whole document in dependency order.
#include "maj0sted/editor/chain.hpp"
#include "maj0sted/editor/layout.hpp"
// Aggregates
#include "maj0sted/domain/map_project.hpp"
#include "maj0sted/domain/niweleta.hpp"
// Geometry primitives shared by the solver and the renderer.
#include "maj0sted/domain/geometry/segment_layout.hpp"
#include "maj0sted/domain/geometry/turnout.hpp"
// Parallelism — równoległość torów
#include "maj0sted/domain/parallelism/parallelism_service.hpp"
#include "maj0sted/domain/parallelism/track_parallelism.hpp"
// Fitting — dopasowanie krzywej między prostymi
#include "maj0sted/domain/fitting/fit_parameters.hpp"
#include "maj0sted/domain/fitting/fit_result.hpp"
#include "maj0sted/domain/fitting/fitting_service.hpp"
// IO — serializacja projektu
#include "maj0sted/io/project_serializer.hpp"
// Presentation and IO.
#include "maj0sted/editor/render.hpp"
#include "maj0sted/io/document_io.hpp"
#include "maj0sted/io/scn_export.hpp"

View File

@@ -1,18 +0,0 @@
#pragma once
#include "maj0sted/domain/geometry/horizontal_alignment.hpp"
#include "maj0sted/domain/map_project.hpp"
#include "maj0sted/render/scene.hpp"
namespace maj0sted::render {
/// Samples a plan into absolute polylines in the CRS. Straights are
/// authoritative for position; curves (arcs, clothoids) are laid out continuing
/// from the previous element's end and heading. This is the reusable bridge
/// between the domain model and any renderer.
[[nodiscard]] Scene sample(const maj0sted::domain::HorizontalAlignment& plan);
/// Samples the plans of every niweleta in the project into one scene.
[[nodiscard]] Scene sample(const maj0sted::domain::MapProject& project);
} // namespace maj0sted::render

View File

@@ -1,5 +1,6 @@
#pragma once
#include <cstdint>
#include <vector>
#include "maj0sted/render/track_renderer.hpp"
@@ -12,9 +13,9 @@ namespace maj0sted::render {
struct RailPolyline {
ElementKind kind{ElementKind::Straight};
std::vector<Point> points;
int straight_index{-1};
int gap{-1};
int element_index{-1};
std::uint32_t element_id{0};
std::uint32_t owner_id{0};
bool from_turnout{false};
double length{0.0};
double radius_start{0.0};
double radius_end{0.0};

View File

@@ -1,7 +1,5 @@
#pragma once
#include <vector>
namespace maj0sted::render {
/// A point in the project's CRS (metres for EPSG:2180).
@@ -13,21 +11,4 @@ struct Point {
/// Which kind of plan element a polyline came from (lets a GUI colour it).
enum class ElementKind { Straight, Arc, Transition };
/// A polyline approximating one plan element, in CRS coordinates.
struct Polyline {
ElementKind kind;
std::vector<Point> points;
};
/// A rendering-agnostic view of one or more plans: polylines plus a bounding box.
/// Any GUI can draw this — the library computes the geometry, the GUI only paints.
struct Scene {
std::vector<Polyline> polylines;
double min_x{0.0};
double min_y{0.0};
double max_x{0.0};
double max_y{0.0};
bool empty{true};
};
} // namespace maj0sted::render

View File

@@ -1,5 +1,6 @@
#pragma once
#include <cstdint>
#include <vector>
#include "maj0sted/render/scene.hpp" // Point, ElementKind
@@ -12,12 +13,12 @@ namespace maj0sted::render {
/// hands to a renderer.
struct CentrelineElement {
ElementKind kind{ElementKind::Straight};
std::vector<Point> points; ///< sampled axis in CRS metres
int straight_index{-1}; ///< owning straight, or -1 for a curve element
int gap{-1}; ///< owning gap, or -1 for a straight
int element_index{-1}; ///< position within a gap's curve chain
double length{0.0}; ///< element length (metres)
double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end)
std::vector<Point> points; ///< sampled axis in CRS metres
std::uint32_t element_id{0}; ///< the authored element this came from
std::uint32_t owner_id{0}; ///< the track or turnout it belongs to
bool from_turnout{false}; ///< true when owner_id names a turnout
double length{0.0}; ///< element length (metres)
double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end)
double radius_end{0.0};
};