mirror of
https://github.com/MaSzyna-EU07/maszyna.git
synced 2026-11-03 19:00:22 +01:00
Vendor the maj0sted track layout library and link it into the editor
Drop-in copy of C:\Users\maj00r\Projects\maj0sted-master minus its web/ directory - the WASM frontend is not being ported, the editor drives the same C++ API natively. What is left is plain C++20 with no dependencies: domain model (niweleta, plan and profile geometry, fitting, parallelism), project serialization, rail sampling and the editor document. src/web/ribbon.cpp was missing from the library source list; added, since without it the ribbon symbols never make it into the archive.
This commit is contained in:
57
maj0sted/include/maj0sted/app/editor_document.hpp
Normal file
57
maj0sted/include/maj0sted/app/editor_document.hpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/domain/map_project.hpp"
|
||||
#include "maj0sted/web/editor.hpp" // NiweletaSpec (the editor's GUI model)
|
||||
|
||||
namespace maj0sted::app {
|
||||
|
||||
/// The editor's persistable document: the GUI model exactly as the user built
|
||||
/// it — independent anchored straights plus the parametric fits between them.
|
||||
///
|
||||
/// This is the source of truth for save/restore because it is lossless: it keeps
|
||||
/// the untrimmed straights and the fit *parameters* (radius, transition lengths,
|
||||
/// compound arcs, ...), which the resolved domain geometry alone cannot express.
|
||||
struct EditorDocument {
|
||||
std::vector<maj0sted::web::NiweletaSpec> niwelety;
|
||||
};
|
||||
|
||||
/// Best-effort resolved-domain view of @p document: one Niweleta per editor
|
||||
/// niweleta, keeping the anchored straights (a floating placeholder arc sits in
|
||||
/// each gap so the alignment stays valid). Used for interop / inspection and so
|
||||
/// the on-disk file embeds a real maj0sted project. Never throws.
|
||||
[[nodiscard]] maj0sted::domain::MapProject to_map_project(const EditorDocument& document);
|
||||
|
||||
/// Imports a plain domain project into the editor model: every Straight in each
|
||||
/// niweleta's plan becomes an editor straight (fits are left empty — resolved
|
||||
/// geometry carries no fit parameters to recover).
|
||||
[[nodiscard]] EditorDocument from_map_project(const maj0sted::domain::MapProject& project);
|
||||
|
||||
/// Serializes @p document to the library's textual format. The document embeds a
|
||||
/// real maj0sted project (via maj0sted::io::serialize of to_map_project) followed
|
||||
/// by an `editor` section that captures the straights and fit parameters
|
||||
/// losslessly. @c deserialize_document restores it exactly.
|
||||
[[nodiscard]] std::string serialize_document(const EditorDocument& document);
|
||||
|
||||
/// Rebuilds an EditorDocument from text produced by @c serialize_document.
|
||||
/// @throws std::runtime_error on malformed input.
|
||||
[[nodiscard]] EditorDocument deserialize_document(const std::string& text);
|
||||
|
||||
/// The default on-disk project path. Under Emscripten this lives beneath the
|
||||
/// IDBFS mount point (see web/index.html), so it survives reloads once flushed.
|
||||
[[nodiscard]] std::string default_project_path();
|
||||
|
||||
/// Serializes @p document and writes it to @p path (creating parent directories).
|
||||
/// @returns true on success; never throws.
|
||||
bool save_project(const EditorDocument& document,
|
||||
const std::string& path = default_project_path());
|
||||
|
||||
/// Reads and deserializes the project at @p path, or std::nullopt if the file is
|
||||
/// absent or unreadable/malformed. Never throws.
|
||||
[[nodiscard]] std::optional<EditorDocument> load_project(
|
||||
const std::string& path = default_project_path());
|
||||
|
||||
} // namespace maj0sted::app
|
||||
55
maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp
Normal file
55
maj0sted/include/maj0sted/domain/fitting/fit_parameters.hpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#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
|
||||
25
maj0sted/include/maj0sted/domain/fitting/fit_result.hpp
Normal file
25
maj0sted/include/maj0sted/domain/fitting/fit_result.hpp
Normal file
@@ -0,0 +1,25 @@
|
||||
#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
|
||||
69
maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp
Normal file
69
maj0sted/include/maj0sted/domain/fitting/fitting_service.hpp
Normal file
@@ -0,0 +1,69 @@
|
||||
#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
|
||||
74
maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp
Normal file
74
maj0sted/include/maj0sted/domain/fitting/gap_fitter.hpp
Normal file
@@ -0,0 +1,74 @@
|
||||
#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
|
||||
@@ -0,0 +1,60 @@
|
||||
#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
|
||||
127
maj0sted/include/maj0sted/domain/geometry/plan_element.hpp
Normal file
127
maj0sted/include/maj0sted/domain/geometry/plan_element.hpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#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
|
||||
@@ -0,0 +1,53 @@
|
||||
#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
|
||||
90
maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp
Normal file
90
maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp
Normal file
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
namespace maj0sted::domain::geometry {
|
||||
|
||||
struct XY {
|
||||
double x;
|
||||
double y;
|
||||
};
|
||||
|
||||
/// Position plus unit heading, in (east=x, north=y).
|
||||
struct Pose {
|
||||
double x;
|
||||
double y;
|
||||
double hx;
|
||||
double hy;
|
||||
};
|
||||
|
||||
/// Lays out one segment of linearly varying signed curvature, starting at
|
||||
/// @p start: a circular arc when @p k0 == @p k1, a clothoid otherwise, a
|
||||
/// straight when both are zero. When @p out is non-null the sampled points
|
||||
/// (including the start point) are appended to it. Returns the segment's end
|
||||
/// pose.
|
||||
///
|
||||
/// Both the fitting solver and the renderer call this so their geometry is
|
||||
/// bit-for-bit identical — the fitted tangent points then coincide exactly with
|
||||
/// the drawn curve, with no lateral gap even for tight arcs.
|
||||
inline Pose layout_segment(double k0, double k1, double length, Pose start,
|
||||
std::vector<XY>* out) {
|
||||
const double left_x = -start.hy;
|
||||
const double left_y = start.hx;
|
||||
const auto emit = [&](double local_x, double local_y) {
|
||||
if (out != nullptr) {
|
||||
out->push_back({start.x + local_x * start.hx + local_y * left_x,
|
||||
start.y + local_x * start.hy + local_y * left_y});
|
||||
}
|
||||
};
|
||||
|
||||
emit(0.0, 0.0);
|
||||
|
||||
double end_x = 0.0;
|
||||
double end_y = 0.0;
|
||||
if (length > 0.0) {
|
||||
const double turned = std::abs(0.5 * (k0 + k1) * length);
|
||||
const int steps = std::clamp(
|
||||
static_cast<int>(std::ceil(std::max(
|
||||
length / 0.5, turned * 180.0 / std::numbers::pi / 2.0))),
|
||||
8, 512);
|
||||
const double ds = length / steps;
|
||||
|
||||
if (std::abs(k1 - k0) < 1e-12) {
|
||||
const double k = k0;
|
||||
if (std::abs(k) < 1e-12) {
|
||||
end_x = length; // straight
|
||||
emit(end_x, 0.0);
|
||||
} else {
|
||||
for (int i = 1; i <= steps; ++i) { // circular arc, exact
|
||||
const double s = ds * i;
|
||||
end_x = std::sin(k * s) / k;
|
||||
end_y = (1.0 - std::cos(k * s)) / k;
|
||||
emit(end_x, end_y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const double slope = (k1 - k0) / (2.0 * length); // clothoid
|
||||
for (int i = 1; i <= steps; ++i) {
|
||||
const double s_mid = ds * (i - 0.5);
|
||||
const double theta = k0 * s_mid + slope * s_mid * s_mid;
|
||||
end_x += std::cos(theta) * ds;
|
||||
end_y += std::sin(theta) * ds;
|
||||
emit(end_x, end_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const double dtheta = 0.5 * (k0 + k1) * length;
|
||||
const double cos_t = std::cos(dtheta);
|
||||
const double sin_t = std::sin(dtheta);
|
||||
return Pose{start.x + end_x * start.hx + end_y * left_x,
|
||||
start.y + end_x * start.hy + end_y * left_y,
|
||||
start.hx * cos_t - start.hy * sin_t,
|
||||
start.hx * sin_t + start.hy * cos_t};
|
||||
}
|
||||
|
||||
} // namespace maj0sted::domain::geometry
|
||||
@@ -0,0 +1,31 @@
|
||||
#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
|
||||
32
maj0sted/include/maj0sted/domain/map_project.hpp
Normal file
32
maj0sted/include/maj0sted/domain/map_project.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#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
|
||||
70
maj0sted/include/maj0sted/domain/niweleta.hpp
Normal file
70
maj0sted/include/maj0sted/domain/niweleta.hpp
Normal file
@@ -0,0 +1,70 @@
|
||||
#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
|
||||
@@ -0,0 +1,35 @@
|
||||
#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);
|
||||
};
|
||||
|
||||
} // namespace maj0sted::domain
|
||||
@@ -0,0 +1,38 @@
|
||||
#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
|
||||
67
maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp
Normal file
67
maj0sted/include/maj0sted/domain/value_objects/azimuth.hpp
Normal file
@@ -0,0 +1,67 @@
|
||||
#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
|
||||
@@ -0,0 +1,27 @@
|
||||
#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
|
||||
29
maj0sted/include/maj0sted/domain/value_objects/crs.hpp
Normal file
29
maj0sted/include/maj0sted/domain/value_objects/crs.hpp
Normal file
@@ -0,0 +1,29 @@
|
||||
#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
|
||||
37
maj0sted/include/maj0sted/domain/value_objects/length.hpp
Normal file
37
maj0sted/include/maj0sted/domain/value_objects/length.hpp
Normal file
@@ -0,0 +1,37 @@
|
||||
#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
|
||||
@@ -0,0 +1,36 @@
|
||||
#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());
|
||||
}
|
||||
};
|
||||
30
maj0sted/include/maj0sted/domain/value_objects/radius.hpp
Normal file
30
maj0sted/include/maj0sted/domain/value_objects/radius.hpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#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
|
||||
@@ -0,0 +1,36 @@
|
||||
#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());
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
#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
|
||||
19
maj0sted/include/maj0sted/io/project_serializer.hpp
Normal file
19
maj0sted/include/maj0sted/io/project_serializer.hpp
Normal file
@@ -0,0 +1,19 @@
|
||||
#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
|
||||
20
maj0sted/include/maj0sted/io/svg_writer.hpp
Normal file
20
maj0sted/include/maj0sted/io/svg_writer.hpp
Normal file
@@ -0,0 +1,20 @@
|
||||
#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
|
||||
39
maj0sted/include/maj0sted/maj0sted.hpp
Normal file
39
maj0sted/include/maj0sted/maj0sted.hpp
Normal file
@@ -0,0 +1,39 @@
|
||||
#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.
|
||||
|
||||
#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"
|
||||
|
||||
// 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"
|
||||
|
||||
// Aggregates
|
||||
#include "maj0sted/domain/map_project.hpp"
|
||||
#include "maj0sted/domain/niweleta.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"
|
||||
18
maj0sted/include/maj0sted/render/plan_sampler.hpp
Normal file
18
maj0sted/include/maj0sted/render/plan_sampler.hpp
Normal file
@@ -0,0 +1,18 @@
|
||||
#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
|
||||
42
maj0sted/include/maj0sted/render/rail_renderer.hpp
Normal file
42
maj0sted/include/maj0sted/render/rail_renderer.hpp
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/render/track_renderer.hpp"
|
||||
|
||||
namespace maj0sted::render {
|
||||
|
||||
/// A drawable polyline produced by RailRenderer: one rail, carrying the logical
|
||||
/// metadata of the centreline element it came from so the host can colour and
|
||||
/// hit-test it.
|
||||
struct RailPolyline {
|
||||
ElementKind kind{ElementKind::Straight};
|
||||
std::vector<Point> points;
|
||||
int straight_index{-1};
|
||||
int gap{-1};
|
||||
int element_index{-1};
|
||||
double length{0.0};
|
||||
double radius_start{0.0};
|
||||
double radius_end{0.0};
|
||||
};
|
||||
|
||||
/// Renders each centreline as two parallel rails a fixed gauge apart — the track
|
||||
/// is drawn as its rails, not its axis. A concrete TrackRenderer; substituting a
|
||||
/// different one (single-axis, SVG, ...) needs no change to the solver.
|
||||
class RailRenderer : public TrackRenderer {
|
||||
public:
|
||||
/// @param gauge_metres distance between the two rails (default 1.5 m).
|
||||
explicit RailRenderer(double gauge_metres = 1.5) noexcept;
|
||||
|
||||
void add(const CentrelineElement& element) override;
|
||||
|
||||
[[nodiscard]] const std::vector<RailPolyline>& rails() const noexcept {
|
||||
return rails_;
|
||||
}
|
||||
|
||||
private:
|
||||
double half_gauge_;
|
||||
std::vector<RailPolyline> rails_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::render
|
||||
33
maj0sted/include/maj0sted/render/scene.hpp
Normal file
33
maj0sted/include/maj0sted/render/scene.hpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace maj0sted::render {
|
||||
|
||||
/// A point in the project's CRS (metres for EPSG:2180).
|
||||
struct Point {
|
||||
double x;
|
||||
double y;
|
||||
};
|
||||
|
||||
/// 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
|
||||
38
maj0sted/include/maj0sted/render/track_renderer.hpp
Normal file
38
maj0sted/include/maj0sted/render/track_renderer.hpp
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/render/scene.hpp" // Point, ElementKind
|
||||
|
||||
namespace maj0sted::render {
|
||||
|
||||
/// One sampled centreline element of a solved niweleta, tagged with its logical
|
||||
/// origin so a UI can colour, label and hit-test it. Rendering-neutral: it holds
|
||||
/// the track axis only — no rails, no styling. This is the contract the solver
|
||||
/// 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)
|
||||
double radius_end{0.0};
|
||||
};
|
||||
|
||||
/// Interface every renderer must satisfy.
|
||||
///
|
||||
/// The solver emits rendering-neutral CentrelineElements; a renderer turns each
|
||||
/// into concrete drawable geometry (a single axis line, two rails, an SVG path,
|
||||
/// ...). A renderer holds NO business logic — it only shapes presentation, so
|
||||
/// swapping renderers never changes what geometry the solver computed.
|
||||
class TrackRenderer {
|
||||
public:
|
||||
virtual ~TrackRenderer() = default;
|
||||
|
||||
/// Called once per centreline element, in plan order.
|
||||
virtual void add(const CentrelineElement& element) = 0;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::render
|
||||
7
maj0sted/include/maj0sted/version.hpp
Normal file
7
maj0sted/include/maj0sted/version.hpp
Normal file
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// Version of the maj0sted library. Keep in sync with project() in CMakeLists.txt.
|
||||
#define MAJ0STED_VERSION_MAJOR 0
|
||||
#define MAJ0STED_VERSION_MINOR 1
|
||||
#define MAJ0STED_VERSION_PATCH 0
|
||||
#define MAJ0STED_VERSION_STRING "0.1.0"
|
||||
72
maj0sted/include/maj0sted/web/editor.hpp
Normal file
72
maj0sted/include/maj0sted/web/editor.hpp
Normal file
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline
|
||||
|
||||
namespace maj0sted::web {
|
||||
|
||||
/// 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};
|
||||
};
|
||||
|
||||
/// 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<WebPolyline> 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;
|
||||
};
|
||||
|
||||
/// 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);
|
||||
|
||||
} // namespace maj0sted::web
|
||||
32
maj0sted/include/maj0sted/web/ribbon.hpp
Normal file
32
maj0sted/include/maj0sted/web/ribbon.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline
|
||||
|
||||
namespace maj0sted::web {
|
||||
|
||||
/// 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<WebPolyline> solve_ribbon(const RibbonRequest& request);
|
||||
|
||||
} // namespace maj0sted::web
|
||||
56
maj0sted/include/maj0sted/web/solve.hpp
Normal file
56
maj0sted/include/maj0sted/web/solve.hpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace maj0sted::web {
|
||||
|
||||
/// A request from the GUI: three draggable vertices (entry straight A->B, exit
|
||||
/// straight B->C) plus the fit parameters. Plain doubles so it maps trivially
|
||||
/// onto a JS object / WASM boundary. This layer is pure C++ — the emscripten
|
||||
/// bindings are a thin wrapper around it, and it is unit-tested natively.
|
||||
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 WebPoint {
|
||||
double x{0.0};
|
||||
double y{0.0};
|
||||
};
|
||||
|
||||
/// One polyline; @c kind is render::ElementKind (0 straight, 1 arc, 2 transition).
|
||||
struct WebPolyline {
|
||||
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<WebPoint> 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<WebPolyline> solve_scene(const SolveRequest& request);
|
||||
|
||||
} // namespace maj0sted::web
|
||||
186
maj0sted/include/maj0sted/web/tile_cache.hpp
Normal file
186
maj0sted/include/maj0sted/web/tile_cache.hpp
Normal file
@@ -0,0 +1,186 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace maj0sted::web {
|
||||
|
||||
/// A stable tile in the EPSG:2180 tiling pyramid. Tiles are addressed by their
|
||||
/// zoom level and integer column/row, so the same ground square always maps to
|
||||
/// the same key regardless of the current viewport — that is what makes the
|
||||
/// cache reusable across pans and zooms.
|
||||
struct TileKey {
|
||||
int zoom{0};
|
||||
int x{0}; ///< column (increases with easting)
|
||||
int y{0}; ///< row (increases with northing)
|
||||
|
||||
friend bool operator==(const TileKey&, const TileKey&) = default;
|
||||
};
|
||||
|
||||
struct TileKeyHash {
|
||||
std::size_t operator()(const TileKey& k) const noexcept {
|
||||
// Mix the three small integers into one hash.
|
||||
std::uint64_t h = static_cast<std::uint64_t>(static_cast<std::uint32_t>(k.x));
|
||||
h = h * 1099511628211ULL ^
|
||||
static_cast<std::uint64_t>(static_cast<std::uint32_t>(k.y));
|
||||
h = h * 1099511628211ULL ^ static_cast<std::uint64_t>(k.zoom);
|
||||
return static_cast<std::size_t>(h);
|
||||
}
|
||||
};
|
||||
|
||||
/// A bounding box in EPSG:2180 metres (easting = x, northing = y).
|
||||
struct TileBBox {
|
||||
double min_x{0.0};
|
||||
double min_y{0.0};
|
||||
double max_x{0.0};
|
||||
double max_y{0.0};
|
||||
};
|
||||
|
||||
/// The tiling scheme. A pure, static value type defining a single fixed square
|
||||
/// grid of 100 m × 100 m ground cells over the EPSG:2180 plane — the same grid
|
||||
/// at every zoom, *not* a per-zoom pyramid. Each cell has a deterministic ground
|
||||
/// bounding box, so it is cached once and reused across all pans and zooms.
|
||||
/// Nothing here touches the network — it is trivially unit testable natively.
|
||||
///
|
||||
/// The zoom parameters on the methods below are ignored; they are kept only so
|
||||
/// the host boundary (bindings/JS) stays unchanged.
|
||||
class TileGrid {
|
||||
public:
|
||||
static constexpr int kTilePixels = 1024; ///< pixels per cell edge (GetMap WIDTH/HEIGHT)
|
||||
static constexpr double kOriginX = 0.0; ///< easting of cell (0,0)'s left edge
|
||||
static constexpr double kOriginY = 0.0; ///< northing of cell (0,0)'s bottom edge
|
||||
/// Fixed cache cell size: every tile is a 100 m × 100 m ground square.
|
||||
static constexpr double kCellMetres = 100.0;
|
||||
/// The single grid level stored in every TileKey (there is only one grid).
|
||||
static constexpr int kLevel = 0;
|
||||
|
||||
/// Ground resolution (metres per pixel) of a cell. Constant (zoom ignored).
|
||||
[[nodiscard]] static double resolution(int zoom = kLevel) noexcept;
|
||||
|
||||
/// Edge length of a cell in metres — always kCellMetres (zoom ignored).
|
||||
[[nodiscard]] static double tile_span(int zoom = kLevel) noexcept;
|
||||
|
||||
/// The 100 m cell whose ground square contains (@p x, @p y). Zoom ignored.
|
||||
[[nodiscard]] static TileKey tile_at(double x, double y, int zoom = kLevel) noexcept;
|
||||
|
||||
/// The ground bounding box (EPSG:2180) of @p key.
|
||||
[[nodiscard]] static TileBBox bbox(const TileKey& key) noexcept;
|
||||
|
||||
/// The grid level for a target resolution. Always kLevel — the grid has a
|
||||
/// single fixed 100 m level. Kept for boundary compatibility.
|
||||
[[nodiscard]] static int zoom_for_resolution(double metres_per_pixel) noexcept;
|
||||
|
||||
/// Every 100 m cell whose ground square intersects @p view, capped at
|
||||
/// @p max_tiles to guard against absurd requests. Zoom ignored.
|
||||
[[nodiscard]] static std::vector<TileKey> tiles_for_view(const TileBBox& view,
|
||||
int zoom = kLevel,
|
||||
int max_tiles = 64);
|
||||
};
|
||||
|
||||
/// Configuration for a WMS GetMap source. Defaults target a public EPSG:2180
|
||||
/// endpoint (Polish Geoportal). Every field is data, so a different backend is a
|
||||
/// pure configuration change with no code edits.
|
||||
struct WmsConfig {
|
||||
std::string base_url;
|
||||
std::string layers;
|
||||
std::string format{"image/jpeg"};
|
||||
std::string version{"1.1.1"}; ///< 1.1.1 keeps BBOX as minx,miny,maxx,maxy
|
||||
std::string srs{"EPSG:2180"};
|
||||
int tile_pixels{TileGrid::kTilePixels};
|
||||
|
||||
/// Geoportal ortophoto (ortofotomapa), EPSG:2180, JPEG.
|
||||
[[nodiscard]] static WmsConfig geoportal_ortho();
|
||||
|
||||
/// Geoportal topographic base map (BDOT/raster), EPSG:2180, PNG.
|
||||
[[nodiscard]] static WmsConfig geoportal_topo();
|
||||
};
|
||||
|
||||
/// Builds the full WMS 1.1.1 GetMap URL for @p box using @p cfg. Numbers are
|
||||
/// formatted locale-independently (std::to_chars), matching the rest of the
|
||||
/// library's serialization.
|
||||
[[nodiscard]] std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box);
|
||||
|
||||
enum class TileStatus { Missing, Loading, Ready, Failed };
|
||||
|
||||
/// A cached tile image: raw encoded bytes (JPEG/PNG) plus their MIME type.
|
||||
struct TileImage {
|
||||
TileStatus status{TileStatus::Missing};
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::string content_type;
|
||||
int attempts{0}; ///< failed fetch attempts so far (see TileService retry cap)
|
||||
};
|
||||
|
||||
/// Owns the tile grid, the tile cache and the WMS fetch. This is the single
|
||||
/// place that decides which tile is needed, whether it is already cached, and —
|
||||
/// on a miss — issues the network GetMap request itself. Under Emscripten the
|
||||
/// fetch uses the Emscripten Fetch API; natively (and in tests) a fetcher can be
|
||||
/// injected, so the tiling/caching logic is exercised without a real network.
|
||||
class TileService {
|
||||
public:
|
||||
/// Called when a fetch finishes: (success, bytes, content_type).
|
||||
using DoneFn =
|
||||
std::function<void(bool, std::vector<std::uint8_t>, std::string)>;
|
||||
/// Injectable network backend: given a URL, eventually invoke DoneFn.
|
||||
using Fetcher = std::function<void(const std::string& url, DoneFn)>;
|
||||
|
||||
explicit TileService(WmsConfig cfg = WmsConfig::geoportal_ortho());
|
||||
|
||||
[[nodiscard]] const WmsConfig& config() const noexcept { return cfg_; }
|
||||
void set_config(WmsConfig cfg) { cfg_ = std::move(cfg); }
|
||||
|
||||
/// Overrides the network backend. When set it is used on both native and
|
||||
/// Emscripten builds; when unset, Emscripten builds fall back to
|
||||
/// emscripten_fetch and native builds mark the tile Failed.
|
||||
void set_fetcher(Fetcher fetcher) { fetcher_ = std::move(fetcher); }
|
||||
|
||||
/// Notified (with the tile's key) whenever a tile transitions to Ready or
|
||||
/// Failed. Lets the renderer schedule a redraw when an async tile arrives.
|
||||
void set_on_ready(std::function<void(TileKey)> cb) { on_ready_ = std::move(cb); }
|
||||
|
||||
/// Ensures @p key is cached or being fetched, and returns its current status.
|
||||
/// A cache hit returns Ready immediately and issues no network request.
|
||||
TileStatus request(const TileKey& key);
|
||||
|
||||
/// The cached image for @p key, or nullptr if it is not Ready.
|
||||
[[nodiscard]] const TileImage* peek(const TileKey& key) const;
|
||||
|
||||
/// Stores bytes for @p key directly, marking it Ready. Exposed so a caller
|
||||
/// (or a test) can seed the cache without going through the fetcher.
|
||||
void store(const TileKey& key, std::vector<std::uint8_t> bytes,
|
||||
std::string content_type = {});
|
||||
|
||||
[[nodiscard]] bool has(const TileKey& key) const;
|
||||
[[nodiscard]] std::size_t size() const noexcept { return cache_.size(); }
|
||||
void clear() { cache_.clear(); }
|
||||
|
||||
/// The GetMap URL this service would use for @p key.
|
||||
[[nodiscard]] std::string url_for(const TileKey& key) const;
|
||||
|
||||
/// Finalises an in-flight fetch: on success stores the bytes (Ready), on
|
||||
/// failure marks the tile Failed, then fires the on_ready callback. Public so
|
||||
/// the Emscripten fetch glue (which needs free C callbacks) can delegate here.
|
||||
void complete_fetch(const TileKey& key, bool ok, std::vector<std::uint8_t> bytes,
|
||||
std::string content_type);
|
||||
|
||||
/// Re-issues the fetch for @p key. Public so the Emscripten async-retry glue
|
||||
/// (a free C callback) can delegate here.
|
||||
void retry_now(const TileKey& key);
|
||||
|
||||
private:
|
||||
void begin_fetch(const TileKey& key);
|
||||
/// Re-issues a fetch for @p key after a failure — the service retries up to
|
||||
/// kMaxRetries times, then marks the tile Failed. Under Emscripten the retry
|
||||
/// is deferred asynchronously (after a short delay) so it never blocks or
|
||||
/// recurses; with an injected fetcher it re-issues immediately.
|
||||
void schedule_retry(const TileKey& key);
|
||||
|
||||
WmsConfig cfg_;
|
||||
std::unordered_map<TileKey, TileImage, TileKeyHash> cache_;
|
||||
Fetcher fetcher_;
|
||||
std::function<void(TileKey)> on_ready_;
|
||||
};
|
||||
|
||||
} // namespace maj0sted::web
|
||||
Reference in New Issue
Block a user