17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 12:09:19 +02:00

Switches: enforced branch tangency, internal basket, Polish turnout templates

A switch (rozjazd) is a relation on a through niweleta, not a niweleta - the
through track is never split. It marks the theoretical point on the through
centreline; two tangent lines cross there (the through heading and that heading
turned by the crossing mark 1:n), and the internal diverging curve is fitted to
round the corner between them, reusing the same GapFitter the gap fits go
through. So the curve is free: a plain arc, or a łuk koszowy right inside the
switch. The theoretical tangent lengths and the curve length follow from it and
are read off, not set.

The branch niweleta's leaving straight is now locked to the frog: it starts at
the frog and runs along the frog heading every solve, only its length is the
user's. That enforces tangency no matter what is drawn, and because the first
straight is pinned tangent, any fit in the branch's first gap - including a
basket - meets the switch tangentially by construction.

solve_layout solves the niwelety and the switches together in two passes: solve,
read each frog, pin the branches, solve again. Switches serialize as an ejuncs /
ejunc section; older files without it still load.

Typical Polish turnouts (rozjazdy zwyczajne) are offered as templates that set
the crossing mark and radius together; the length each spans is computed from
them (arc = R*atan(1/n)) and shown, so how long the switch is is known.
This commit is contained in:
maj00r
2026-08-01 07:44:58 +02:00
committed by maj00r
parent d4b1d73338
commit d069743265
9 changed files with 3381 additions and 214 deletions

View File

@@ -169,6 +169,7 @@ set(SOURCES
"editor/editorSettings.cpp"
"editor/puwg1992.cpp"
"editor/polandmap.cpp"
"editor/turnouts.cpp"
"editor/orthophoto.cpp"
"editor/editorTerrain.cpp"
"editor/editorTerrainStreamer.cpp"

File diff suppressed because it is too large Load Diff

View File

@@ -12,13 +12,19 @@ http://mozilla.org/MPL/2.0/.
#include "application/uilayer.h"
#include "editor/orthophoto.h"
#include "maj0sted/app/basket_draft.hpp"
#include "maj0sted/app/editor_document.hpp"
#include "maj0sted/app/straight_constraints.hpp"
#include "maj0sted/io/scn_export.hpp"
#include "maj0sted/web/editor.hpp"
#include "maj0sted/web/ribbon.hpp"
#include <vector>
// track layout tool: straights laid down by hand on the scenery itself and the curves fitted into
// the gaps between them. the panel holds only the controls - the drawing happens in the viewport,
// which the tool puts into a top-down orthographic view for the purpose. none of the geometry is
// computed here: maj0sted solves the whole document and the panel paints what comes back.
// which the tool puts into a top-down orthographic plan view. none of the geometry is computed
// here: maj0sted solves the whole document and the panel paints what comes back.
class plan_panel : public ui_panel
{
@@ -31,56 +37,134 @@ class plan_panel : public ui_panel
private:
// methods
void render_toolbar();
void render_draft();
void render_gaps();
// parameters of a basket curve: the end clothoids and the chain of arcs between them
void render_compound(maj0sted::web::GapFit &Fit);
void render_compound(maj0sted::web::GapFit &Fit, std::size_t const Gap, bool &Dirty);
void fit_compound_to_guides();
void clear_fit_guides();
void render_storage();
// asks what kind of scenery is being started, and where on the map it sits
void export_scn();
void render_newmap_dialog();
void render_location_dialog();
// clears the drawing and sets the point of EPSG:2180 that the scenery's zero stands for
void start_map(bool const Georeferenced, double const Originx, double const Originy);
// paints the solved plan over the rendered scenery and takes the clicks that build it
void handle_scene();
void draw_on_scene();
// re-runs the solver over the whole document; cheap enough to do on every edit
void solve();
void apply_constraints();
maj0sted::web::NiweletaSpec *current_niweleta();
maj0sted::web::NiweletaSpec const *current_niweleta() const;
// keeps exactly one fit slot per gap, so a gap's parameters survive edits to its neighbours
void align_fits();
// true when the solver actually produced the fit requested for the given gap
bool fit_applied(std::size_t const Gap) const;
// the panel edits a chained polyline, so a vertex is shared by the two straights meeting at it.
// the library allows fully independent straights; that freedom just isn't exposed here yet
std::size_t vertex_count() const;
void vertex_position(std::size_t const Index, double &X, double &Y) const;
// independent anchored straights: each endpoint is its own handle
maj0sted::web::StraightSpec const *handle_straight(std::size_t const Niw, std::size_t const Index) const;
void append_vertex(double const X, double const Y);
void move_vertex(std::size_t const Index, double const X, double const Y);
void drop_last_vertex();
// takes the camera to the middle of the drawing
void delete_selected_straight();
void delete_current_niweleta();
void clear_selection();
// hit tests (screen pixels). niw=-1 means "current niweleta only"
bool hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const;
bool hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const;
// compound basket: draggable ends of entry/exit KP, free arcs and between-KP
bool hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const;
void drag_compound_station(double const X, double const Y);
// arc-first draft: continue from a straight end, click appends luk/KP by length
void begin_draft();
void discard_draft();
void start_draft_from_end(std::size_t const Str, int const End);
void append_draft_at(double const X, double const Y);
void undo_draft_fragment();
bool finish_draft();
bool reopen_draft_from_gap(std::size_t const Gap);
void rebuild_draft_preview();
bool draft_tip(double &OutX, double &OutY) const;
bool draft_end_pose(double &OutX, double &OutY, double &OutAz) const;
bool hit_draft_station(ImVec2 const &Mouse, int &OutKind, int &OutArc) const;
void drag_draft_station(double const X, double const Y);
bool anchor_draft_to_gap(std::size_t const Gap);
// switches (rozjazdy): a branch leaves a through niweleta without splitting it. clicking the
// through track drops a switch there and starts a branch niweleta pinned to its frog
void add_switch_on(std::size_t const Through, double const Wx, double const Wy);
void render_switches();
void go_to_plan();
// members
maj0sted::app::EditorDocument m_document;
std::vector<maj0sted::web::NiweletaPolys> m_solved;
std::size_t m_niweleta{0}; // niweleta being drawn into
int m_draggedvertex{-1};
bool m_drawing{true}; // whether a click on the scenery lays track or only picks existing points
// first corner of a niweleta has nothing to attach to, so it waits for the second click
std::vector<maj0sted::web::JunctionGeom> m_junctions;
std::size_t m_niweleta{0};
// switch placement: 0 idle, 1 waiting for a click on the through track
int m_pick_switch{0};
int m_sw_side{0}; // 0 left, 1 right
double m_sw_crossing{9.0}; // skos 1:n
double m_sw_radius{190.0}; // internal arc radius (metres)
int m_sel_straight{-1};
int m_sel_end{-1};
bool m_dragging{false};
// captured at drag start so Shift keeps the angle while the length changes
double m_drag_fixed_x{0.0};
double m_drag_fixed_y{0.0};
double m_drag_ux{1.0};
double m_drag_uy{0.0};
// compound station drag: kind 0=entry KP end, 1=free-arc end, 2=between-KP end, 3=exit KP start
bool m_dragging_cmp{false};
int m_cmp_gap{-1};
int m_cmp_kind{-1};
int m_cmp_arc{-1};
bool m_drawing{true};
bool m_pending{false};
double m_pendingx{0.0};
double m_pendingy{0.0};
// 0 = idle, 1 = picking parallel reference, 2 = picking skew reference
int m_pick_rel{0};
// after a reference is chosen, ask for the numeric parameter
bool m_ask_rel_param{false};
double m_ask_rel_value{4.5};
// compound: gap being edited; pick mode accumulates guide points, then Fit runs the solver
int m_sel_gap{-1};
bool m_pick_compound_point{false};
struct FitGuide
{
double x{0.0};
double y{0.0};
};
std::vector<FitGuide> m_fit_guides;
maj0sted::app::BasketDraft m_draft;
std::vector<maj0sted::web::WebPolyline> m_draft_polys;
// 0 idle, 1 pick straight end to continue from
int m_draft_place{0};
// next click appends: 0 = luk, 1 = KP (wej/między), 2 = KP wyjście (kończy: prosta + kotwica)
int m_draft_append_kind{0};
double m_draft_r{300.0};
double m_draft_finish_straight{100.0};
// 0 idle, 1 click a straight to choose the gap to anchor into
int m_anchor_pick{0};
bool m_dragging_draft{false};
int m_draft_kind{-1};
int m_draft_arc{-1};
char m_namebuf[128]{};
std::string m_status;
char m_path[256]{"editor/plan.m0s"};
// location picker: the point being considered, and the view over the schematic map of Poland
char m_scn_path[256]{"scenery/plan_export.scn"};
bool m_pickingplace{false};
double m_pickx{0.0};
double m_picky{0.0};
double m_mapviewx{0.0};
double m_mapviewy{0.0};
double m_mapscale{0.0};
// topographic base map behind the picker, and the orthophoto under the plan itself
editor::wms_image m_topomap{maj0sted::web::WmsConfig::geoportal_topo()};
editor::orthophoto_source m_ortho;
// 1 km topo cells for views wider than a kilometre (orto's 100 m grid is too dense there)
editor::orthophoto_source m_topo{maj0sted::web::WmsConfig::geoportal_topo(), 1000, "topo"};
bool m_showortho{true};
};

30
editor/turnouts.cpp Normal file
View File

@@ -0,0 +1,30 @@
/*
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
*/
#include "stdafx.h"
#include "editor/turnouts.h"
namespace editor
{
std::vector<turnout_preset> const &turnout_presets()
{
// the everyday PKP rozjazdy zwyczajne. the crossing mark and radius are the defining pair;
// the length the switch spans follows from them and is shown in the editor
static std::vector<turnout_preset> const presets{
{"Rz 1:9 R190", 9.0, 190.0},
{"Rz 1:9 R300", 9.0, 300.0},
{"Rz 1:12 R300", 12.0, 300.0},
{"Rz 1:14 R500", 14.0, 500.0},
{"Rz 1:18,5 R1200", 18.5, 1200.0},
{"Rz 1:26,5 R2500", 26.5, 2500.0}};
return presets;
}
} // namespace editor

33
editor/turnouts.h Normal file
View File

@@ -0,0 +1,33 @@
/*
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
*/
#pragma once
#include <string>
#include <vector>
namespace editor
{
// a standard turnout template: its designation and the defining pair a catalogue entry is built on -
// the crossing mark 1:n (skos) and the radius of the diverging arc. the actual length the switch
// works out to is computed geometrically from these (arc = R * atan(1/n)), shown next to it; the
// exact catalogue length can be pinned later if the Id-1 tables call for a different figure
struct turnout_preset
{
std::string name;
double crossing_n;
double radius;
};
// typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard;
// confirm against Id-1 before treating the derived length as the catalogue length
std::vector<turnout_preset> const &turnout_presets();
} // namespace editor

View File

@@ -5,52 +5,29 @@
#include <vector>
#include "maj0sted/domain/map_project.hpp"
#include "maj0sted/web/editor.hpp" // NiweletaSpec (the editor's GUI model)
#include "maj0sted/web/editor.hpp"
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;
std::vector<maj0sted::web::Junction> junctions;
double view_x{0.0};
double view_y{0.0};
double view_extent{0.0};
bool origin_set{false};
bool georeferenced{false};
double origin_x{0.0};
double origin_y{0.0};
};
/// 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());

View File

@@ -13,6 +13,17 @@ struct StraightSpec {
/// Internal tangent guide used to solve a terminal curve. It participates
/// in fitting but is not rendered as track or exposed as an editor handle.
bool hidden{false};
/// Live constraint the host reapplies before each solve (same model as the
/// web editor's `par` / `skew`). 0 = none, 1 = parallel offset from another
/// straight, 2 = skew 1:n from another straight.
int rel_kind{0};
int rel_niw{-1}; ///< index of the reference niweleta
int rel_str{-1}; ///< index of the reference straight
double rel_offset{0.0}; ///< parallel: signed metres (+ = left of travel)
double rel_cot{0.0}; ///< skew: n of a 1:n turnout
int rel_side{1}; ///< skew: +1 left / -1 right of the reference
double rel_length{0.0}; ///< skew: length of the dependent arm
};
/// One arc of a compound fit: its radius, its fixed length (ignored for the last
@@ -61,6 +72,9 @@ struct NiweletaPolys {
/// parameters or geometry) is simply absent here — the library, not the host,
/// decides that a failed fit becomes "no fit". The host mirrors this list.
std::vector<GapFit> applied_fits;
/// The solved axis as one polyline, in order, with no duplicate join points.
/// Lets a station along the niweleta be resolved to a pose (see pose_at).
std::vector<WebPoint> centreline;
};
/// Builds each niweleta, fits every requested gap, and returns the sampled plan
@@ -69,4 +83,53 @@ struct NiweletaPolys {
[[nodiscard]] std::vector<NiweletaPolys> solve_project(
const std::vector<NiweletaSpec>& niwelety);
/// A switch (rozjazd): the point where a branch leaves a through niweleta. It is
/// a relation, not a niweleta — the through track is not split. The switch is
/// described the surveyor's way: a crossing mark 1:n (skos) that fixes the angle
/// the branch leaves at, and an interior curve that is free to be anything — a
/// single arc, or a compound basket (łuk koszowy). @c station marks the switch's
/// theoretical point (środek rozjazdu, where the through and diverging tangent
/// lines cross); the curve is fitted around it and the branch niweleta is pinned
/// to the frog it ends on. The theoretical tangent lengths follow from the curve
/// (see JunctionGeom) — they are read off, not set.
struct Junction {
int through{-1}; ///< index of the through niweleta (not modified)
double station{0.0}; ///< theoretical point: arc length along the through centreline
int side{0}; ///< 0 = diverges left, 1 = diverges right
bool facing{true}; ///< true opens toward increasing station, false back
double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n)
GapFit curve; ///< the internal diverging curve: arc / compound / basket
int branch{-1}; ///< index of the branch niweleta, pinned to the frog
};
/// The solved geometry of one switch: the switch point where the branch leaves
/// the through track, the frog it ends on, the diverging curve between them and
/// the theoretical tangent lengths that curve works out to.
struct JunctionGeom {
bool valid{false};
double px{0.0}, py{0.0}; ///< switch point (diverging leaves the through here)
double fx{0.0}, fy{0.0}; ///< frog: where the branch niweleta is pinned
double fhx{0.0}, fhy{0.0}; ///< unit heading the branch leaves the frog on
double tangent_front{0.0}; ///< theoretical tangent length before the curve
double tangent_back{0.0}; ///< theoretical tangent length after the curve
double length{0.0}; ///< diverging curve length (switch point to frog), metres
/// The diverging curve, as rails ready to draw.
std::vector<WebPolyline> polylines;
};
/// A whole layout solved at once: the niwelety plus every switch. Returned by
/// solve_layout, which additionally pins each branch's start to its frog.
struct LayoutSolution {
std::vector<NiweletaPolys> niwelety;
std::vector<JunctionGeom> junctions; ///< index-aligned with the input switches
};
/// Solves @p niwelety together with @p junctions: each branch niweleta's start is
/// pinned to its switch's frog before solving, and every switch's diverging arc
/// is computed from the through niweleta's centreline. Never throws; a switch
/// whose through/branch is out of range or whose geometry is degenerate comes
/// back with valid == false. Takes @p niwelety by value because it repins them.
[[nodiscard]] LayoutSolution solve_layout(std::vector<NiweletaSpec> niwelety,
const std::vector<Junction>& junctions);
} // namespace maj0sted::web

View File

@@ -144,6 +144,14 @@ std::string serialize_document(const EditorDocument& document) {
// ...then the lossless editor section (straights + fit parameters).
out += "editor " + std::to_string(document.niwelety.size()) + "\n";
if (document.view_extent > 0.0) {
out += "eview " + num(document.view_x) + " " + num(document.view_y) + " " +
num(document.view_extent) + "\n";
}
if (document.origin_set) {
out += "eorigin " + (document.georeferenced ? std::string{"1"} : "0") + " " +
num(document.origin_x) + " " + num(document.origin_y) + "\n";
}
for (const auto& spec : document.niwelety) {
out += "eniw " + std::to_string(spec.straights.size()) + " " +
std::to_string(spec.fits.size()) + "\n";
@@ -152,6 +160,19 @@ std::string serialize_document(const EditorDocument& document) {
out += "estr " + num(s.x1) + " " + num(s.y1) + " " + num(s.x2) + " " +
num(s.y2) + " " + (s.hidden ? "1" : "0") + "\n";
}
for (std::size_t i = 0; i < spec.straights.size(); ++i) {
const auto& s = spec.straights[i];
if (s.rel_kind == 1) {
out += "epar " + std::to_string(i) + " " +
std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) +
" " + num(s.rel_offset) + "\n";
} else if (s.rel_kind == 2) {
out += "eskew " + std::to_string(i) + " " +
std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) +
" " + num(s.rel_cot) + " " + std::to_string(s.rel_side) + " " +
num(s.rel_length) + "\n";
}
}
for (const auto& f : spec.fits) {
// efit2: gap mode radius transition entry_t exit_t <n> [r len trans]*
// The variable arc list supersedes the legacy fixed two-arc 'efit'.
@@ -166,6 +187,22 @@ std::string serialize_document(const EditorDocument& document) {
out += "\n";
}
}
// switches: through/station/side/facing/skew/branch, then the internal curve
// inline in the same shape as an efit2 tail (mode radius trans entry exit <n> …)
out += "ejuncs " + std::to_string(document.junctions.size()) + "\n";
for (const auto& j : document.junctions) {
out += "ejunc " + std::to_string(j.through) + " " + num(j.station) + " " +
std::to_string(j.side) + " " + (j.facing ? std::string{"1"} : "0") +
" " + num(j.crossing_n) + " " + std::to_string(j.branch) + " " +
std::to_string(j.curve.mode) + " " + num(j.curve.radius) + " " +
num(j.curve.transition) + " " + num(j.curve.entry_t) + " " +
num(j.curve.exit_t) + " " + std::to_string(j.curve.arcs.size());
for (const auto& a : j.curve.arcs) {
out += " " + num(a.radius) + " " + num(a.length) + " " +
num(a.transition_to_next);
}
out += "\n";
}
return out;
}
@@ -214,6 +251,57 @@ EditorDocument deserialize_document(const std::string& text) {
throw std::runtime_error{"maj0sted: expected 'editor <count>'"};
}
const long niweleta_count = to_long(header[1]);
// Optional camera/zoom line (absent in older files).
{
std::size_t peek = cursor;
while (peek < lines.size()) {
bool only_ws = true;
for (const char c : lines[peek]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) {
break;
}
++peek;
}
if (peek < lines.size()) {
const auto view_tok = tokenize(lines[peek]);
if (view_tok.size() >= 4 && view_tok[0] == "eview") {
next();
document.view_x = to_double(view_tok[1]);
document.view_y = to_double(view_tok[2]);
document.view_extent = to_double(view_tok[3]);
}
}
// Optional local-zero line (absent in older files). May follow eview.
peek = cursor;
while (peek < lines.size()) {
bool only_ws = true;
for (const char c : lines[peek]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) {
break;
}
++peek;
}
if (peek < lines.size()) {
const auto origin_tok = tokenize(lines[peek]);
if (origin_tok.size() >= 4 && origin_tok[0] == "eorigin") {
next();
document.origin_set = true;
document.georeferenced = to_long(origin_tok[1]) != 0;
document.origin_x = to_double(origin_tok[2]);
document.origin_y = to_double(origin_tok[3]);
}
}
}
for (long n = 0; n < niweleta_count; ++n) {
const auto eniw = tokenize(next());
if (eniw.size() < 3 || eniw[0] != "eniw") {
@@ -238,6 +326,57 @@ EditorDocument deserialize_document(const std::string& text) {
StraightSpec{to_double(e[1]), to_double(e[2]), to_double(e[3]),
to_double(e[4]), e.size() >= 6 && to_long(e[5]) != 0});
}
// Optional parallel/skew lines sit between straights and fits; older files omit them.
auto apply_relation = [&](const std::vector<std::string>& e) -> bool {
if (e.empty()) return false;
if (e[0] == "epar" && e.size() >= 5) {
const long idx = to_long(e[1]);
if (idx < 0 || static_cast<std::size_t>(idx) >= spec.straights.size()) {
throw std::runtime_error{"maj0sted: bad 'epar' index"};
}
auto& s = spec.straights[static_cast<std::size_t>(idx)];
s.rel_kind = 1;
s.rel_niw = static_cast<int>(to_long(e[2]));
s.rel_str = static_cast<int>(to_long(e[3]));
s.rel_offset = to_double(e[4]);
return true;
}
if (e[0] == "eskew" && e.size() >= 7) {
const long idx = to_long(e[1]);
if (idx < 0 || static_cast<std::size_t>(idx) >= spec.straights.size()) {
throw std::runtime_error{"maj0sted: bad 'eskew' index"};
}
auto& s = spec.straights[static_cast<std::size_t>(idx)];
s.rel_kind = 2;
s.rel_niw = static_cast<int>(to_long(e[2]));
s.rel_str = static_cast<int>(to_long(e[3]));
s.rel_cot = to_double(e[4]);
s.rel_side = static_cast<int>(to_long(e[5]));
s.rel_length = to_double(e[6]);
return true;
}
return false;
};
auto peek_tokens = [&]() -> std::vector<std::string> {
std::size_t i = cursor;
while (i < lines.size()) {
bool only_ws = true;
for (const char c : lines[i]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) {
return tokenize(lines[i]);
}
++i;
}
return {};
};
while (apply_relation(peek_tokens())) {
next(); // consume the relation line
}
for (long i = 0; i < fit_count; ++i) {
const auto e = tokenize(next());
GapFit f;
@@ -282,6 +421,57 @@ EditorDocument deserialize_document(const std::string& text) {
}
document.niwelety.push_back(std::move(spec));
}
// Optional switches section (absent in older files); mirrors the eview/eorigin peek.
{
std::size_t peek = cursor;
while (peek < lines.size()) {
bool only_ws = true;
for (const char c : lines[peek]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) break;
++peek;
}
if (peek < lines.size()) {
const auto head = tokenize(lines[peek]);
if (head.size() >= 2 && head[0] == "ejuncs") {
next(); // consume the 'ejuncs <count>' header
const long junction_count = to_long(head[1]);
for (long j = 0; j < junction_count; ++j) {
const auto e = tokenize(next());
if (e.size() < 12 || e[0] != "ejunc") {
throw std::runtime_error{"maj0sted: bad 'ejunc' line"};
}
maj0sted::web::Junction junction;
junction.through = static_cast<int>(to_long(e[1]));
junction.station = to_double(e[2]);
junction.side = static_cast<int>(to_long(e[3]));
junction.facing = to_long(e[4]) != 0;
junction.crossing_n = to_double(e[5]);
junction.branch = static_cast<int>(to_long(e[6]));
junction.curve.mode = static_cast<int>(to_long(e[7]));
junction.curve.radius = to_double(e[8]);
junction.curve.transition = to_double(e[9]);
junction.curve.entry_t = to_double(e[10]);
junction.curve.exit_t = to_double(e[11]);
const long arc_count = e.size() >= 13 ? to_long(e[12]) : 0;
for (long a = 0; a < arc_count; ++a) {
const std::size_t base = 13 + static_cast<std::size_t>(a) * 3;
if (base + 2 >= e.size()) {
throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"};
}
junction.curve.arcs.push_back(maj0sted::web::CompoundArcSpec{
to_double(e[base]), to_double(e[base + 1]),
to_double(e[base + 2])});
}
document.junctions.push_back(std::move(junction));
}
}
}
}
return document;
}

View File

@@ -218,11 +218,144 @@ NiweletaPolys build(const NiweletaSpec& spec) {
const auto axis =
sample_centrelines(spec, straights, fits, result.rendered_straights);
// Flatten the axis into one polyline for station lookups, dropping the join
// points each element repeats at its start.
for (const auto& element : axis) {
for (const auto& p : element.points) {
if (!result.centreline.empty()) {
const auto& last = result.centreline.back();
if (std::hypot(p.x - last.x, p.y - last.y) < 1e-9) continue;
}
result.centreline.push_back(WebPoint{p.x, p.y});
}
}
// 3. Presentation: hand the centreline to a renderer.
result.polylines = render_rails(axis);
return result;
}
// The pose (position + unit heading) at arc length @p station along a centreline
// polyline. Clamped to the ends; heading comes from the segment the station falls
// on. Returns false only for a centreline too short to have a direction.
bool pose_at(const std::vector<WebPoint>& centreline, double station, Pose& out) {
if (centreline.size() < 2) return false;
double travelled = 0.0;
for (std::size_t i = 0; i + 1 < centreline.size(); ++i) {
const auto& a = centreline[i];
const auto& b = centreline[i + 1];
const double dx = b.x - a.x;
const double dy = b.y - a.y;
const double seg = std::hypot(dx, dy);
if (seg < 1e-12) continue;
// the last segment also catches any station beyond the centreline's end
if (station <= travelled + seg || i + 2 == centreline.size()) {
const double t = std::clamp((station - travelled) / seg, 0.0, 1.0);
out = Pose{a.x + dx * t, a.y + dy * t, dx / seg, dy / seg};
return true;
}
travelled += seg;
}
return false;
}
// Samples a fitted curve chain into centreline elements, starting from @p start.
// Same layout the gap fits go through, so the switch curve is drawn identically.
std::vector<render::CentrelineElement> sample_curve(
const std::vector<PlanElement>& curve, Pose start) {
std::vector<render::CentrelineElement> axis;
Pose pose = start;
int element_index = 0;
for (const auto& element : curve) {
double k0 = 0.0, k1 = 0.0, length = 0.0;
render::CentrelineElement out;
out.kind = sample_params(element, k0, k1, length);
std::vector<XY> pts;
pose = layout_segment(k0, k1, length, pose, &pts);
out.points.reserve(pts.size());
for (const auto& p : pts) out.points.push_back(render::Point{p.x, p.y});
out.element_index = element_index++;
out.length = length;
out.radius_start = radius_of(k0);
out.radius_end = radius_of(k1);
axis.push_back(std::move(out));
}
return axis;
}
// The diverging curve and frog of one switch, from the solved through niweleta.
// The switch's theoretical point sits at @p station on the through track; two
// tangent lines cross there (the through heading and that heading turned by the
// crossing angle), and the internal curve is fitted to round the corner between
// them — a plain arc, or a compound basket, whatever the switch asks for. The
// branch begins where the curve ends.
JunctionGeom solve_junction(const Junction& junction,
const std::vector<NiweletaPolys>& solved) {
JunctionGeom geom;
if (junction.through < 0 ||
static_cast<std::size_t>(junction.through) >= solved.size()) {
return geom;
}
if (junction.crossing_n <= 0.0) return geom;
Pose theoretical{};
if (!pose_at(solved[junction.through].centreline, junction.station,
theoretical)) {
return geom;
}
// a trailing switch opens against the running direction: flip the heading
double thx = theoretical.hx;
double thy = theoretical.hy;
if (!junction.facing) {
thx = -thx;
thy = -thy;
}
// the diverging tangent line: the through heading turned by the crossing
// angle, left (positive) or right (negative)
const double angle =
(junction.side == 0 ? 1.0 : -1.0) * std::atan(1.0 / junction.crossing_n);
const double dhx = thx * std::cos(angle) - thy * std::sin(angle);
const double dhy = thx * std::sin(angle) + thy * std::cos(angle);
// two long guide straights meeting at the theoretical point X; the fitter
// rounds the corner and hands back the tangent points that become the switch
// start and the frog. long enough that the tangent points land on them.
const double guide = 5000.0;
const CartesianPosition x{theoretical.x, theoretical.y};
Straight entry{CartesianPosition{x.x() - thx * guide, x.y() - thy * guide}, x};
Straight exit{x, CartesianPosition{x.x() + dhx * guide, x.y() + dhy * guide}};
const auto request = to_request(junction.curve);
if (!request) return geom; // a switch with no curve is not a switch
const auto connection = GapFitter::connect(entry, exit, *request);
if (!connection) return geom;
const FitResult& fit = connection->fit;
const CartesianPosition s = fit.tangent_in; // switch start
const CartesianPosition f = fit.tangent_out; // frog
geom.valid = true;
geom.px = s.x();
geom.py = s.y();
geom.fx = f.x();
geom.fy = f.y();
geom.fhx = dhx;
geom.fhy = dhy;
// theoretical tangent lengths: from the crossing point to each tangent point
geom.tangent_front = std::hypot(s.x() - x.x(), s.y() - x.y());
geom.tangent_back = std::hypot(f.x() - x.x(), f.y() - x.y());
const auto axis = sample_curve(fit.curve, Pose{s.x(), s.y(), thx, thy});
for (const auto& element : axis) geom.length += element.length;
geom.polylines = render_rails(axis);
return geom;
}
} // namespace
std::vector<NiweletaPolys> solve_project(const std::vector<NiweletaSpec>& niwelety) {
@@ -232,4 +365,44 @@ std::vector<NiweletaPolys> solve_project(const std::vector<NiweletaSpec>& niwele
return result;
}
LayoutSolution solve_layout(std::vector<NiweletaSpec> niwelety,
const std::vector<Junction>& junctions) {
// Pass 1: solve unpinned, to read the through centrelines the frogs sit on.
auto solved = solve_project(niwelety);
// Lock each branch's leaving straight to the frog: it starts at the frog and
// runs along the frog heading, so the branch is tangent to the switch curve
// no matter what the user draws. Only its length is theirs — the projection of
// wherever they dragged its far end onto the frog heading. Because the first
// straight is pinned tangent, any fit in the branch's first gap (a plain arc
// or a łuk koszowy) meets the switch tangentially by construction.
for (const auto& junction : junctions) {
const auto geom = solve_junction(junction, solved);
if (!geom.valid) continue;
if (junction.branch < 0 ||
static_cast<std::size_t>(junction.branch) >= niwelety.size()) {
continue;
}
auto& branch = niwelety[junction.branch];
if (branch.straights.empty()) continue;
auto& first = branch.straights.front();
double length = (first.x2 - geom.fx) * geom.fhx + (first.y2 - geom.fy) * geom.fhy;
if (!(length > 1.0)) length = 1.0; // keep a usable minimum, also catches NaN
first.x1 = geom.fx;
first.y1 = geom.fy;
first.x2 = geom.fx + geom.fhx * length;
first.y2 = geom.fy + geom.fhy * length;
}
// Pass 2: solve with the branches pinned, then read the switches off the
// final centrelines (the through track may itself be a pinned branch).
LayoutSolution out;
out.niwelety = solve_project(niwelety);
out.junctions.reserve(junctions.size());
for (const auto& junction : junctions) {
out.junctions.push_back(solve_junction(junction, out.niwelety));
}
return out;
}
} // namespace maj0sted::web