17
0
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:
maj00r
2026-07-31 18:27:02 +02:00
committed by maj00r
parent 9a0b63a97c
commit a083269867
72 changed files with 6344 additions and 0 deletions

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View 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

View 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

View File

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

View 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

View File

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

View File

@@ -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

View 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

View 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

View 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"

View 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

View 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

View 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

View 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

View 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"

View 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

View 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

View 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

View 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