17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-31 18:09:04 +01:00

Editor checkpoint: turnout domain service, web->editor rename, Id-1 templates

Checkpoint of the editor branch so work isn't carried loose across a branch
switch. Builds.

- maj0sted turnout geometry moved into the domain: domain::lay_turnout lays the
  diverging path (pre-blade lead, blade at beta, arc of alfa-beta, frog rail) and
  solve_junction is a thin adapter onto it.
- the maj0sted web/ tree renamed to editor/ (namespace and paths) - there is no
  WASM here, it's plain native C++ the editor calls.
- click places the switch at its start (początek rozjazdu); the branch's leaving
  straight is locked tangent to the frog; switches can be slid along the through
  track and flipped L/P.
- Polish turnout templates carry catalogue length (Id-1-consistent lengths), plus
  blade-angle and pre-blade fields (left 0 until the catalogue figures are in).
- TURNOUT_SPEC.md: the Bezier-based runtime/model contract.
- also folds in other pending branch changes (scenery, tests) to leave a clean tree.
This commit is contained in:
maj00r
2026-08-01 19:55:33 +02:00
committed by maj00r
parent d069743265
commit 707b4de2f4
41 changed files with 2224 additions and 471 deletions

View File

@@ -31,15 +31,19 @@ add_library(maj0sted STATIC
src/domain/parallelism_service.cpp
src/domain/fitting_service.cpp
src/domain/gap_fitter.cpp
src/domain/turnout.cpp
src/io/project_serializer.cpp
src/io/svg_writer.cpp
src/io/scn_export.cpp
src/render/plan_sampler.cpp
src/render/rail_renderer.cpp
src/web/solve.cpp
src/web/editor.cpp
src/web/ribbon.cpp
src/web/tile_cache.cpp
src/editor/solve.cpp
src/editor/editor.cpp
src/editor/ribbon.cpp
src/editor/tile_cache.cpp
src/app/editor_document.cpp
src/app/straight_constraints.cpp
src/app/basket_draft.cpp
)
add_library(maj0sted::maj0sted ALIAS maj0sted)

View File

@@ -19,14 +19,14 @@ Toolchain WASM: `emsdk` jest w `~/emsdk` (`source ~/emsdk/emsdk_env.sh` dodaje `
```bash
cd /home/maj00r/Projects/maj0sted
FLAGS="-std=c++20 -Iinclude -Wall -Wextra -Wpedantic -Wconversion -Wshadow"
SRC="src/domain/*.cpp src/io/*.cpp src/render/*.cpp src/web/solve.cpp src/web/tile_cache.cpp src/app/editor_document.cpp"
SRC="src/domain/*.cpp src/io/*.cpp src/render/*.cpp src/editor/solve.cpp src/editor/tile_cache.cpp src/app/editor_document.cpp"
for t in map_project niweleta plan_alignment parallelism fitting serialization render web tile_cache document; do
g++ $FLAGS -Itests $SRC tests/test_$t.cpp -o /tmp/t_$t && /tmp/t_$t | tail -1
done
```
Wszystkie testy przechodzą, kompilacja bez ostrzeżeń. Oszczędnie: kompiluj tylko
zmieniony test z minimalnym zestawem źródeł (np. `tile_cache` wymaga tylko
`src/web/tile_cache.cpp`; `document` — `src/domain/*.cpp src/io/project_serializer.cpp
`src/editor/tile_cache.cpp`; `document` — `src/domain/*.cpp src/io/project_serializer.cpp
src/app/editor_document.cpp`). Szybki syntax-check: `g++ $FLAGS -fsyntax-only ...`.
Budowa WASM: `source ~/emsdk/emsdk_env.sh && cd web && ./build.sh`
@@ -90,7 +90,7 @@ Budowa WASM: `source ~/emsdk/emsdk_env.sh && cd web && ./build.sh`
równoległości (patrz niżej — StraightId nie są globalnie unikalne).
### Rendering: dwie szyny
`src/web/editor.cpp` rysuje **dwie szyny w rozstawie 1500 mm** (`kHalfGauge=0.75`,
`src/editor/editor.cpp` rysuje **dwie szyny w rozstawie 1500 mm** (`kHalfGauge=0.75`,
±750 mm od osi) zamiast samej osi. `offset_points` przesuwa linię osi wzdłuż
normalnej per-wierzchołek (różnica centralna, normalna lewa `(-dy,dx)`),
`push_rails` emituje lewą i prawą szynę zachowując `kind` (kolor). Uchwyty edycji
@@ -100,7 +100,7 @@ normalnej per-wierzchołek (różnica centralna, normalna lewa `(-dy,dx)`),
Zasada: **cała logika jest w libce**; `web/` to cienki renderer + przekazywanie
wejścia. `web/maj0sted_bindings.cpp` (embind) to jedyny plik zależny od emscripten.
- **Kafle podkładu WMS** (`maj0sted::web`, `web/tile_cache.hpp/.cpp`) ✓
- **Kafle podkładu WMS** (`maj0sted::editor`, `web/tile_cache.hpp/.cpp`) ✓
- `TileGrid` — **jeden stały grid kafli 100 m × 100 m** w **EPSG:2180**
(`kCellMetres=100`, **1024 px/kafel** → 100/1024 m/px, origin (0,0), jeden
poziom `kLevel=0`, **bez piramidy per-zoom**); `tile_at`, `bbox`,

View File

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

View File

@@ -5,13 +5,13 @@
#include <vector>
#include "maj0sted/domain/map_project.hpp"
#include "maj0sted/web/editor.hpp"
#include "maj0sted/editor/editor.hpp"
namespace maj0sted::app {
struct EditorDocument {
std::vector<maj0sted::web::NiweletaSpec> niwelety;
std::vector<maj0sted::web::Junction> junctions;
std::vector<maj0sted::editor::NiweletaSpec> niwelety;
std::vector<maj0sted::editor::Junction> junctions;
double view_x{0.0};
double view_y{0.0};
double view_extent{0.0};

View File

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

View File

@@ -0,0 +1,85 @@
#pragma once
#include <vector>
#include "maj0sted/domain/geometry/segment_layout.hpp" // Pose
namespace maj0sted::domain {
/// The crossing mark of a turnout — the skos 1:n. It is the tangent of the angle
/// between the through and diverging tracks, so it fixes the total angle the
/// diverging curve must turn through: a turnout is 1:9, 1:12, 1:18,5 and so on.
class CrossingMark {
public:
explicit CrossingMark(double denominator) noexcept : denominator_{denominator} {}
[[nodiscard]] double denominator() const noexcept { return denominator_; }
/// The angle between the tracks, radians. Zero for a non-positive mark.
[[nodiscard]] double angle() const noexcept;
private:
double denominator_;
};
/// Which way the branch leaves the through track.
enum class DivergeSide { Left, Right };
/// One arc of a turnout's diverging curve: its radius and — unless it is the
/// closing arc — its fixed length and the clothoid easing it into the next arc.
struct TurnoutArc {
double radius{0.0};
double length{0.0}; ///< fixed length; ignored for the closing arc
double transition_to_next{0.0}; ///< clothoid to the next arc (0 = none)
};
/// The shape of a turnout's diverging curve. One arc is the everyday turnout; two
/// or more make a łuk koszowy. Whatever the shape, the curve turns through exactly
/// the crossing angle — the closing (last) arc spends whatever deflection the
/// earlier arcs and clothoids leave.
struct DivergingCurve {
std::vector<TurnoutArc> arcs; ///< at least one; the last one closes the turn
double entry_transition{0.0}; ///< clothoid from the through track into the first arc
double exit_transition{0.0}; ///< clothoid from the last arc back toward tangent
};
/// A turnout (rozjazd), described the way a catalogue does: a crossing mark, the
/// side it opens to, the free diverging curve inside it, and the length it spans
/// from its start (początek rozjazdu) to its end (koniec rozjazdu) measured along
/// the through track. The curve turns to the crossing angle and a straight frog
/// rail carries the track on to that length.
struct Turnout {
CrossingMark mark;
DivergeSide side{DivergeSide::Left};
DivergingCurve curve;
double length{0.0}; ///< catalogue length along the through track; 0 = a tangent-length rail
};
/// One laid segment of the diverging path, as the constant/linear curvature the
/// renderer and the fitter already share (see segment_layout): a straight when
/// both curvatures are zero, an arc when they are equal, a clothoid otherwise.
struct TurnoutSegment {
double k0{0.0};
double k1{0.0};
double length{0.0};
};
/// What a turnout works out to when laid from its start pose.
struct TurnoutGeometry {
bool valid{false};
std::vector<TurnoutSegment> path; ///< diverging segments from the switch start to the frog
geometry::Pose frog{}; ///< the end (koniec rozjazdu): position + heading at the crossing angle
double tangent_front{0.0}; ///< theoretical tangent length, switch start to the crossing point
double tangent_back{0.0}; ///< theoretical tangent length, crossing point to the frog
double diverging_length{0.0}; ///< total length of the diverging path (curve + frog rail)
};
/// Lays a turnout forward from @p start — the switch start, tangent to the through
/// track — turning toward its side until the heading has swung by the crossing
/// angle, then running straight to the catalogue length. Never throws; returns
/// `valid == false` when the parameters admit no geometry: a non-positive radius,
/// or a curve whose fixed parts already overshoot the crossing angle before the
/// closing arc.
[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start,
const Turnout& turnout);
} // namespace maj0sted::domain

View File

@@ -30,6 +30,10 @@ public:
/// Perpendicular distance (>= 0) from @p other's start point to the
/// (infinite) line carrying @p origin.
static double perpendicular_distance(const Straight& origin, const Straight& other);
[[nodiscard]] static Straight project_onto_offset_line(const Straight& origin,
double offset_m,
const Straight& segment);
};
} // namespace maj0sted::domain

View File

@@ -3,9 +3,9 @@
#include <string>
#include <vector>
#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline
#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline
namespace maj0sted::web {
namespace maj0sted::editor {
/// An anchored straight segment (two XY endpoints).
struct StraightSpec {
@@ -63,7 +63,7 @@ struct NiweletaSpec {
};
struct NiweletaPolys {
std::vector<WebPolyline> polylines;
std::vector<PlanPolyline> polylines;
/// Centreline segments after adjacent fits trim the input straights. Kept
/// index-aligned with NiweletaSpec::straights for editor hit handles.
std::vector<StraightSpec> rendered_straights;
@@ -74,7 +74,7 @@ struct NiweletaPolys {
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;
std::vector<PlanPoint> centreline;
};
/// Builds each niweleta, fits every requested gap, and returns the sampled plan
@@ -98,6 +98,7 @@ struct Junction {
int side{0}; ///< 0 = diverges left, 1 = diverges right
bool facing{true}; ///< true opens toward increasing station, false back
double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n)
double length{0.0}; ///< catalogue length (PR→KR along the through track); 0 = just the curve
GapFit curve; ///< the internal diverging curve: arc / compound / basket
int branch{-1}; ///< index of the branch niweleta, pinned to the frog
};
@@ -114,7 +115,7 @@ struct JunctionGeom {
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;
std::vector<PlanPolyline> polylines;
};
/// A whole layout solved at once: the niwelety plus every switch. Returned by
@@ -132,4 +133,4 @@ struct LayoutSolution {
[[nodiscard]] LayoutSolution solve_layout(std::vector<NiweletaSpec> niwelety,
const std::vector<Junction>& junctions);
} // namespace maj0sted::web
} // namespace maj0sted::editor

View File

@@ -2,9 +2,9 @@
#include <vector>
#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline
#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline
namespace maj0sted::web {
namespace maj0sted::editor {
/// One element of an alignment "ribbon" (element chain), defined intrinsically.
struct RibbonElement {
@@ -27,6 +27,6 @@ struct RibbonRequest {
};
/// Lays the ribbon out and returns one polyline per element, ready to draw.
[[nodiscard]] std::vector<WebPolyline> solve_ribbon(const RibbonRequest& request);
[[nodiscard]] std::vector<PlanPolyline> solve_ribbon(const RibbonRequest& request);
} // namespace maj0sted::web
} // namespace maj0sted::editor

View File

@@ -2,12 +2,11 @@
#include <vector>
namespace maj0sted::web {
namespace maj0sted::editor {
/// A request from the GUI: three draggable vertices (entry straight A->B, exit
/// straight B->C) plus the fit parameters. Plain doubles 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.
/// straight B->C) plus the fit parameters. Plain doubles, a flat DTO across the
/// boundary between the editor UI and this solver.
struct SolveRequest {
double ax{0.0}, ay{0.0}; ///< vertex A (entry start)
double bx{0.0}, by{0.0}; ///< vertex B (corner: entry end = exit start)
@@ -28,13 +27,13 @@ struct SolveRequest {
};
/// A point in the CRS.
struct WebPoint {
struct PlanPoint {
double x{0.0};
double y{0.0};
};
/// One polyline; @c kind is render::ElementKind (0 straight, 1 arc, 2 transition).
struct WebPolyline {
struct PlanPolyline {
int kind{0};
/// Logical owner used by map hit-testing. Exactly one is non-negative.
int straight_index{-1};
@@ -45,12 +44,12 @@ struct WebPolyline {
/// Zero denotes an infinite radius (straight end of a transition).
double radius_start{0.0};
double radius_end{0.0};
std::vector<WebPoint> points;
std::vector<PlanPoint> points;
};
/// Builds the niweleta, runs the requested fit (falling back to the bare
/// straights if the parameters do not admit a fit) and returns the sampled plan
/// as polylines ready to draw.
[[nodiscard]] std::vector<WebPolyline> solve_scene(const SolveRequest& request);
[[nodiscard]] std::vector<PlanPolyline> solve_scene(const SolveRequest& request);
} // namespace maj0sted::web
} // namespace maj0sted::editor

View File

@@ -6,7 +6,7 @@
#include <unordered_map>
#include <vector>
namespace maj0sted::web {
namespace maj0sted::editor {
/// 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
@@ -39,42 +39,43 @@ struct TileBBox {
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.
/// The tiling scheme. A pure, static value type defining a square grid over the
/// EPSG:2180 plane. The default grid is fixed 100 m × 100 m cells (key.zoom =
/// kLevel). Coarser overview grids encode the cell size in metres in
/// @c TileKey::zoom when that value is >= 100 (e.g. zoom 1000 → 1 km cells for
/// topo underlays). Nothing here touches the network.
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.
/// Default cache cell size: 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).
/// Default grid level stored in TileKey::zoom for the 100 m grid.
static constexpr int kLevel = 0;
/// Zoom values at or above this are treated as the cell edge in metres.
static constexpr int kCellZoomMin = 100;
/// Ground resolution (metres per pixel) of a cell. Constant (zoom ignored).
/// Cell edge in metres for @p key (100 m default, or key.zoom when coarse).
[[nodiscard]] static double cell_metres(const TileKey& key) noexcept;
/// Ground resolution (metres per pixel) of a cell.
[[nodiscard]] static double resolution(int zoom = kLevel) noexcept;
/// Edge length of a cell in metres — always kCellMetres (zoom ignored).
/// Edge length of a cell in metres for the given zoom/cell encoding.
[[nodiscard]] static double tile_span(int zoom = kLevel) noexcept;
/// The 100 m cell whose ground square contains (@p x, @p y). Zoom ignored.
/// The cell whose ground square contains (@p x, @p y).
[[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.
/// The grid level for a target resolution. Always kLevel for the fine grid.
[[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.
/// Every cell whose ground square intersects @p view, capped at @p max_tiles.
/// Pass a coarse cell size (e.g. 1000) as @p zoom to get a 1 km overview grid.
[[nodiscard]] static std::vector<TileKey> tiles_for_view(const TileBBox& view,
int zoom = kLevel,
int max_tiles = 64);
@@ -114,10 +115,9 @@ struct TileImage {
};
/// 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.
/// place that decides which tile is needed and whether it is already cached. The
/// download itself is the host's: it injects a Fetcher, so the tiling and caching
/// logic here stays transport-free and is exercised in tests without a network.
class TileService {
public:
/// Called when a fetch finishes: (success, bytes, content_type).
@@ -131,9 +131,8 @@ public:
[[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.
/// Sets the transport. When unset, a requested tile fails at once (there is
/// no built-in downloader — the host owns that).
void set_fetcher(Fetcher fetcher) { fetcher_ = std::move(fetcher); }
/// Notified (with the tile's key) whenever a tile transitions to Ready or
@@ -161,20 +160,17 @@ public:
/// 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.
/// the host's fetcher callback can hand the result back 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.
/// Re-issues the fetch for @p key.
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.
/// kMaxRetries times, then marks the tile Failed. The fetcher owns the timing.
void schedule_retry(const TileKey& key);
WmsConfig cfg_;
@@ -183,4 +179,4 @@ private:
std::function<void(TileKey)> on_ready_;
};
} // namespace maj0sted::web
} // namespace maj0sted::editor

View File

@@ -0,0 +1,33 @@
#pragma once
#include <ostream>
#include <string>
#include <vector>
#include "maj0sted/editor/editor.hpp"
namespace maj0sted::io {
struct ScnExportOptions {
double origin_east{0.0};
double origin_north{0.0};
double rail_y{0.2};
double max_arc_angle{1.5707963267948966};
};
struct ScnExportResult {
int tracks{0};
std::string first_track_name;
double origin_east{0.0};
double origin_north{0.0};
};
[[nodiscard]] ScnExportResult export_scn(
const std::vector<maj0sted::editor::NiweletaPolys>& solved,
const ScnExportOptions& options, std::ostream& out);
[[nodiscard]] ScnExportOptions resolve_scn_origin(
const std::vector<maj0sted::editor::NiweletaPolys>& solved,
ScnExportOptions options);
} // namespace maj0sted::io

View File

@@ -0,0 +1,389 @@
#include "maj0sted/app/basket_draft.hpp"
#include <algorithm>
#include <cmath>
#include "maj0sted/app/straight_constraints.hpp"
namespace maj0sted::app {
namespace {
using maj0sted::editor::CompoundArcSpec;
using maj0sted::editor::GapFit;
using maj0sted::editor::NiweletaPolys;
using maj0sted::editor::NiweletaSpec;
using maj0sted::editor::RibbonElement;
using maj0sted::editor::RibbonRequest;
using maj0sted::editor::StraightSpec;
using maj0sted::editor::PlanPolyline;
void sanitize_draft_arcs(BasketDraft& draft) {
for (auto& arc : draft.arcs) {
if (!(arc.radius >= 30.0)) {
arc.radius = 30.0;
}
if (!(arc.length > 0.0)) {
arc.length = 5.0;
}
if (arc.transition_to_next < 0.0) {
arc.transition_to_next = 0.0;
}
}
if (draft.entry_t < 0.0) {
draft.entry_t = 0.0;
}
if (draft.exit_t < 0.0) {
draft.exit_t = 0.0;
}
}
bool draft_end_pose(const BasketDraft& draft,
const std::vector<PlanPolyline>& polys, double& out_x,
double& out_y, double& out_az) {
if (!draft.active) {
return false;
}
out_az = draft.az0;
if (!polys.empty() && !polys.back().points.empty()) {
const auto& pts = polys.back().points;
out_x = pts.back().x;
out_y = pts.back().y;
if (pts.size() >= 2) {
out_az = std::atan2(pts.back().x - pts[pts.size() - 2].x,
pts.back().y - pts[pts.size() - 2].y);
}
return true;
}
out_x = draft.x0;
out_y = draft.y0;
return true;
}
} // namespace
RibbonRequest draft_to_ribbon(const BasketDraft& draft) {
RibbonRequest req;
req.x0 = draft.x0;
req.y0 = draft.y0;
req.az0 = draft.az0;
if (draft.arcs.empty()) {
return req;
}
const int dir = draft.dir >= 0 ? 1 : -1;
if (draft.entry_t > 1e-6) {
RibbonElement el;
el.kind = 2;
el.length = draft.entry_t;
el.radius_start = 0.0;
el.radius = draft.arcs.front().radius;
el.dir = dir;
req.elements.push_back(el);
}
for (std::size_t i = 0; i < draft.arcs.size(); ++i) {
const auto& arc = draft.arcs[i];
const double r = std::max(30.0, arc.radius);
const double len = std::max(1e-3, arc.length);
RibbonElement a;
a.kind = 1;
a.radius = r;
a.angle = len / r;
a.dir = dir;
req.elements.push_back(a);
if (arc.transition_to_next > 1e-6) {
RibbonElement kp;
kp.kind = 2;
kp.length = arc.transition_to_next;
kp.radius_start = r;
if (i + 1 < draft.arcs.size()) {
kp.radius = std::max(30.0, draft.arcs[i + 1].radius);
} else {
kp.radius = std::max(30.0, draft.preview_next_r);
}
kp.dir = dir;
req.elements.push_back(kp);
}
}
if (draft.exit_t > 1e-6) {
RibbonElement el;
el.kind = 2;
el.length = draft.exit_t;
el.radius_start = draft.arcs.back().radius;
el.radius = 0.0;
el.dir = dir;
req.elements.push_back(el);
}
return req;
}
std::vector<PlanPolyline> preview_basket_draft(BasketDraft& draft,
double fallback_next_r) {
if (!draft.active || draft.arcs.empty()) {
return {};
}
sanitize_draft_arcs(draft);
draft.preview_next_r = std::max(30.0, fallback_next_r);
return maj0sted::editor::solve_ribbon(draft_to_ribbon(draft));
}
void start_basket_draft_from_end(BasketDraft& draft, const StraightSpec& straight,
int straight_index, int end, double default_r) {
draft = {};
draft.active = true;
draft.attach_str = straight_index;
draft.attach_end = end;
draft.dir = 1;
draft.preview_next_r = default_r;
draft.reedit_gap = -1;
draft.reedit_backup = {};
if (end == 1) {
draft.x0 = straight.x2;
draft.y0 = straight.y2;
draft.az0 = std::atan2(straight.x2 - straight.x1, straight.y2 - straight.y1);
} else {
draft.x0 = straight.x1;
draft.y0 = straight.y1;
draft.az0 = std::atan2(straight.x1 - straight.x2, straight.y1 - straight.y2);
}
draft.arcs.push_back({600.0, 60.0, 40.0});
draft.arcs.push_back({std::max(30.0, default_r), 80.0, 0.0});
}
bool reopen_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta,
std::size_t gap, const NiweletaPolys* solved) {
if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) {
return false;
}
const auto& fit = niweleta.fits[gap];
if (fit.mode != 3 || fit.arcs.empty()) {
return false;
}
std::vector<double> solved_arc_lengths;
if (solved != nullptr) {
for (const auto& poly : solved->polylines) {
if (poly.gap != static_cast<int>(gap) || poly.kind != 1 ||
poly.element_index < 0) {
continue;
}
bool seen = false;
for (const auto& other : solved->polylines) {
if (&other == &poly) {
break;
}
if (other.gap == poly.gap &&
other.element_index == poly.element_index) {
seen = true;
break;
}
}
if (!seen) {
solved_arc_lengths.push_back(std::max(5.0, poly.length));
}
}
}
const auto& entry = niweleta.straights[gap];
const auto& exit = niweleta.straights[gap + 1];
const double edx = entry.x2 - entry.x1;
const double edy = entry.y2 - entry.y1;
const double xdx = exit.x2 - exit.x1;
const double xdy = exit.y2 - exit.y1;
const double turn = edx * xdy - edy * xdx;
draft = {};
draft.active = true;
draft.reedit_backup = fit;
draft.reedit_gap = static_cast<int>(gap);
draft.attach_str = static_cast<int>(gap);
draft.attach_end = 1;
draft.x0 = entry.x2;
draft.y0 = entry.y2;
draft.az0 = std::atan2(edx, edy);
draft.dir = turn >= 0.0 ? 1 : -1;
draft.entry_t = std::max(0.0, fit.entry_t);
draft.exit_t = std::max(0.0, fit.exit_t);
draft.arcs = fit.arcs;
for (std::size_t i = 0; i < draft.arcs.size(); ++i) {
if (!(draft.arcs[i].radius >= 30.0)) {
draft.arcs[i].radius = 30.0;
}
if (draft.arcs[i].length <= 1e-6) {
draft.arcs[i].length =
i < solved_arc_lengths.size() ? solved_arc_lengths[i] : 60.0;
}
if (draft.arcs[i].transition_to_next < 0.0) {
draft.arcs[i].transition_to_next = 0.0;
}
}
if (!draft.arcs.empty()) {
draft.arcs.back().transition_to_next = 0.0;
draft.preview_next_r = draft.arcs.back().radius;
}
niweleta.fits[gap] = {};
niweleta.fits[gap].gap = static_cast<int>(gap);
niweleta.fits[gap].mode = 0;
return true;
}
void discard_basket_draft(BasketDraft& draft, NiweletaSpec* niweleta) {
if (draft.reedit_gap >= 0 && niweleta != nullptr &&
static_cast<std::size_t>(draft.reedit_gap) < niweleta->fits.size()) {
niweleta->fits[static_cast<std::size_t>(draft.reedit_gap)] =
draft.reedit_backup;
}
draft = {};
}
bool anchor_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta,
std::size_t gap) {
if (!draft.active || draft.arcs.empty() || niweleta.straights.size() < 2) {
return false;
}
if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) {
return false;
}
const GapFit previous = niweleta.fits[gap];
const BasketDraft draft_backup = draft;
GapFit fit{};
fit.gap = static_cast<int>(gap);
fit.mode = 3;
fit.entry_t = std::max(0.0, draft.entry_t);
fit.exit_t = std::max(0.0, draft.exit_t);
fit.arcs = draft.arcs;
for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) {
if (!(fit.arcs[i].radius >= 30.0)) {
fit.arcs[i].radius = 30.0;
}
if (!(fit.arcs[i].length > 0.0)) {
fit.arcs[i].length = 40.0;
}
}
fit.arcs.back().length = 0.0;
fit.arcs.back().transition_to_next = 0.0;
if (!(fit.arcs.back().radius >= 30.0)) {
fit.arcs.back().radius = 30.0;
}
for (std::size_t i = 1; i < fit.arcs.size(); ++i) {
if (std::abs(fit.arcs[i].radius - fit.arcs[i - 1].radius) < 1.0) {
fit.arcs[i].radius = std::max(30.0, fit.arcs[i - 1].radius * 0.75);
}
}
niweleta.fits[gap] = fit;
niweleta.straights[gap].rel_kind = 0;
niweleta.straights[gap + 1].rel_kind = 0;
const auto probe = maj0sted::editor::solve_project(
std::vector<NiweletaSpec>{niweleta});
bool applied = false;
if (!probe.empty()) {
for (const auto& af : probe.front().applied_fits) {
if (af.gap == static_cast<int>(gap) && af.mode == 3) {
applied = true;
break;
}
}
}
if (!applied) {
niweleta.fits[gap] = previous;
draft = draft_backup;
return false;
}
draft = {};
return true;
}
FinishBasketDraftResult finish_basket_draft(
BasketDraft& draft, NiweletaSpec& niweleta,
std::vector<NiweletaSpec>& all_niwelety, int niweleta_index,
double finish_straight_m) {
FinishBasketDraftResult result;
if (!draft.active || draft.arcs.empty() || draft.attach_str < 0 ||
static_cast<std::size_t>(draft.attach_str) >= niweleta.straights.size()) {
result.status = "brak łuku";
return result;
}
if (draft.reedit_gap >= 0) {
const auto gap = static_cast<std::size_t>(draft.reedit_gap);
const GapFit backup = draft.reedit_backup;
niweleta.fits[gap] = backup;
if (!anchor_basket_draft(draft, niweleta, gap)) {
draft.reedit_gap = static_cast<int>(gap);
draft.reedit_backup = backup;
result.status = "nie da się (kierunek / L+KP)";
return result;
}
result.ok = true;
result.status = "luka " + std::to_string(gap);
return result;
}
auto polys = preview_basket_draft(draft);
double tipx = 0.0, tipy = 0.0, tipaz = 0.0;
if (!draft_end_pose(draft, polys, tipx, tipy, tipaz)) {
result.status = "brak końca kosza";
return result;
}
const NiweletaSpec backup = niweleta;
const BasketDraft draft_backup = draft;
const int attach = draft.attach_str;
auto& entry = niweleta.straights[static_cast<std::size_t>(attach)];
if (draft.attach_end == 0) {
std::swap(entry.x1, entry.x2);
std::swap(entry.y1, entry.y2);
draft.attach_end = 1;
}
entry.x2 = draft.x0;
entry.y2 = draft.y0;
entry.rel_kind = 0;
const double sl = std::max(20.0, finish_straight_m);
StraightSpec exit_st{};
exit_st.x1 = tipx;
exit_st.y1 = tipy;
exit_st.x2 = tipx + std::sin(tipaz) * sl;
exit_st.y2 = tipy + std::cos(tipaz) * sl;
const auto insert_at = static_cast<std::size_t>(attach + 1);
for (auto& fit : niweleta.fits) {
if (fit.gap >= attach) {
++fit.gap;
}
}
auto bump_rel = [&](int delta) {
for (std::size_t n = 0; n < all_niwelety.size(); ++n) {
for (auto& st : all_niwelety[n].straights) {
if (st.rel_kind != 0 && st.rel_niw == niweleta_index &&
st.rel_str >= static_cast<int>(insert_at)) {
st.rel_str += delta;
}
}
}
};
bump_rel(+1);
niweleta.straights.insert(
niweleta.straights.begin() + static_cast<std::ptrdiff_t>(insert_at),
exit_st);
align_gap_fits(niweleta);
if (!anchor_basket_draft(draft, niweleta, static_cast<std::size_t>(attach))) {
bump_rel(-1);
niweleta = backup;
draft = draft_backup;
result.status = "nie da się (kierunek / L+KP)";
return result;
}
result.ok = true;
result.status = "kosz + " + std::to_string(static_cast<int>(sl)) + " m";
return result;
}
} // namespace maj0sted::app

View File

@@ -17,9 +17,9 @@
namespace maj0sted::app {
using namespace maj0sted::domain;
using maj0sted::web::GapFit;
using maj0sted::web::NiweletaSpec;
using maj0sted::web::StraightSpec;
using maj0sted::editor::GapFit;
using maj0sted::editor::NiweletaSpec;
using maj0sted::editor::StraightSpec;
namespace {
@@ -201,6 +201,8 @@ std::string serialize_document(const EditorDocument& document) {
out += " " + num(a.radius) + " " + num(a.length) + " " +
num(a.transition_to_next);
}
// catalogue length trails the arcs, so files written before it still parse (length stays 0)
out += " " + num(j.length);
out += "\n";
}
return out;
@@ -397,7 +399,7 @@ EditorDocument deserialize_document(const std::string& text) {
if (base + 2 >= e.size()) {
throw std::runtime_error{"maj0sted: truncated 'efit2' arcs"};
}
f.arcs.push_back(maj0sted::web::CompoundArcSpec{
f.arcs.push_back(maj0sted::editor::CompoundArcSpec{
to_double(e[base]), to_double(e[base + 1]),
to_double(e[base + 2])});
}
@@ -445,7 +447,7 @@ EditorDocument deserialize_document(const std::string& text) {
if (e.size() < 12 || e[0] != "ejunc") {
throw std::runtime_error{"maj0sted: bad 'ejunc' line"};
}
maj0sted::web::Junction junction;
maj0sted::editor::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]));
@@ -463,10 +465,15 @@ EditorDocument deserialize_document(const std::string& text) {
if (base + 2 >= e.size()) {
throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"};
}
junction.curve.arcs.push_back(maj0sted::web::CompoundArcSpec{
junction.curve.arcs.push_back(maj0sted::editor::CompoundArcSpec{
to_double(e[base]), to_double(e[base + 1]),
to_double(e[base + 2])});
}
// optional catalogue length after the arcs (absent in the earliest files)
const std::size_t after_arcs = 13 + static_cast<std::size_t>(arc_count) * 3;
if (e.size() > after_arcs) {
junction.length = to_double(e[after_arcs]);
}
document.junctions.push_back(std::move(junction));
}
}
@@ -476,9 +483,9 @@ EditorDocument deserialize_document(const std::string& text) {
}
std::string default_project_path() {
// Under Emscripten the host mounts IDBFS at /data (see web/index.html), so
// this path is persistent across reloads once FS.syncfs has flushed.
return "/data/maj0sted/project.m0s";
// Fallback only; the editor passes its own path (see plan_panel). Relative, so
// it lands under the working directory rather than at a filesystem root.
return "editor/project.m0s";
}
bool save_project(const EditorDocument& document, const std::string& path) {

View File

@@ -0,0 +1,112 @@
#include "maj0sted/app/straight_constraints.hpp"
#include <algorithm>
#include <cmath>
#include "maj0sted/domain/geometry/plan_element.hpp"
#include "maj0sted/domain/parallelism/parallelism_service.hpp"
#include "maj0sted/domain/value_objects/cartesian_position.hpp"
namespace maj0sted::app {
namespace {
using maj0sted::domain::CartesianPosition;
using maj0sted::domain::ParallelismService;
using maj0sted::domain::Straight;
using maj0sted::editor::NiweletaSpec;
using maj0sted::editor::StraightSpec;
Straight to_straight(const StraightSpec& s) {
return Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}};
}
} // namespace
void align_gap_fits(NiweletaSpec& niweleta) {
const auto gaps = niweleta.straights.empty() ? std::size_t{0}
: niweleta.straights.size() - 1;
std::vector<maj0sted::editor::GapFit> slots(gaps);
for (std::size_t gap = 0; gap < gaps; ++gap) {
slots[gap].gap = static_cast<int>(gap);
}
for (const auto& fit : niweleta.fits) {
if (fit.gap >= 0 && static_cast<std::size_t>(fit.gap) < gaps) {
slots[static_cast<std::size_t>(fit.gap)] = fit;
}
}
niweleta.fits = std::move(slots);
}
void apply_straight_constraints(std::vector<NiweletaSpec>& niwelety) {
for (int pass = 0; pass < 3; ++pass) {
bool changed = false;
for (auto& niweleta : niwelety) {
for (auto& st : niweleta.straights) {
if (st.rel_kind == 0 || st.rel_niw < 0 || st.rel_str < 0) {
continue;
}
if (static_cast<std::size_t>(st.rel_niw) >= niwelety.size()) {
continue;
}
const auto& ref_niw = niwelety[static_cast<std::size_t>(st.rel_niw)];
if (static_cast<std::size_t>(st.rel_str) >= ref_niw.straights.size()) {
continue;
}
const auto& ref = ref_niw.straights[static_cast<std::size_t>(st.rel_str)];
if (&ref == &st) {
continue;
}
const double dx = ref.x2 - ref.x1;
const double dy = ref.y2 - ref.y1;
const double len = std::hypot(dx, dy);
if (len < 1e-6) {
continue;
}
double x1 = st.x1, y1 = st.y1, x2 = st.x2, y2 = st.y2;
if (st.rel_kind == 1) {
const Straight projected = ParallelismService::project_onto_offset_line(
to_straight(ref), st.rel_offset, to_straight(st));
x1 = projected.start().x();
y1 = projected.start().y();
x2 = projected.end().x();
y2 = projected.end().y();
} else if (st.rel_kind == 2) {
const double a =
std::atan2(1.0, std::max(1e-9, st.rel_cot)) *
(st.rel_side < 0 ? -1.0 : 1.0);
const double ux = dx / len;
const double uy = dy / len;
const double ca = std::cos(a);
const double sa = std::sin(a);
const double rx = ux * ca - uy * sa;
const double ry = ux * sa + uy * ca;
double L = std::hypot(st.x2 - st.x1, st.y2 - st.y1);
if (L < 1.0) {
L = st.rel_length > 0.0 ? st.rel_length : len;
}
st.rel_length = L;
x1 = st.x1;
y1 = st.y1;
x2 = st.x1 + rx * L;
y2 = st.y1 + ry * L;
}
if (std::abs(st.x1 - x1) > 1e-9 || std::abs(st.y1 - y1) > 1e-9 ||
std::abs(st.x2 - x2) > 1e-9 || std::abs(st.y2 - y2) > 1e-9) {
st.x1 = x1;
st.y1 = y1;
st.x2 = x2;
st.y2 = y2;
changed = true;
}
}
}
if (!changed) {
break;
}
}
}
} // namespace maj0sted::app

View File

@@ -55,4 +55,29 @@ double ParallelismService::perpendicular_distance(const Straight& origin,
return std::abs(d_east * normal.east + d_north * normal.north);
}
Straight ParallelismService::project_onto_offset_line(const Straight& origin,
double offset_m,
const Straight& segment) {
const double az = origin.azimuth().radians();
const double ux = std::sin(az);
const double uy = std::cos(az);
const Vec normal = left_normal(origin.azimuth());
const double ox = origin.start().x() + normal.east * offset_m;
const double oy = origin.start().y() + normal.north * offset_m;
auto station = [&](double x, double y) {
return (x - ox) * ux + (y - oy) * uy;
};
double t1 = station(segment.start().x(), segment.start().y());
double t2 = station(segment.end().x(), segment.end().y());
if (std::abs(t2 - t1) < 1.0) {
const double tm = 0.5 * (t1 + t2);
const double half = (t2 >= t1) ? 0.5 : -0.5;
t1 = tm - half;
t2 = tm + half;
}
return Straight{CartesianPosition{ox + ux * t1, oy + uy * t1},
CartesianPosition{ox + ux * t2, oy + uy * t2}};
}
} // namespace maj0sted::domain

View File

@@ -0,0 +1,127 @@
#include "maj0sted/domain/geometry/turnout.hpp"
#include <cmath>
namespace maj0sted::domain {
using geometry::layout_segment;
using geometry::Pose;
using geometry::XY;
double CrossingMark::angle() const noexcept {
return denominator_ > 0.0 ? std::atan(1.0 / denominator_) : 0.0;
}
namespace {
// The deflection (radians) a segment of linearly varying curvature turns through.
double deflection(const TurnoutSegment& segment) {
return 0.5 * (segment.k0 + segment.k1) * segment.length;
}
// Builds the diverging curve as signed-curvature segments that together turn
// through exactly @p total_angle. Every part but the closing arc is fixed; the
// closing arc's length is chosen to spend whatever deflection is left. Returns
// false when a radius is non-positive or the fixed parts overshoot the angle.
bool build_curve(const DivergingCurve& curve, double total_angle, double sign,
std::vector<TurnoutSegment>& out) {
if (curve.arcs.empty()) return false;
const double first_radius = curve.arcs.front().radius;
const double last_radius = curve.arcs.back().radius;
if (first_radius <= 0.0 || last_radius <= 0.0) return false;
double consumed = 0.0; // deflection of everything before the closing arc
if (curve.entry_transition > 0.0) {
out.push_back({0.0, sign / first_radius, curve.entry_transition});
consumed += std::abs(deflection(out.back()));
}
// every arc but the last contributes its fixed length, eased to the next
for (std::size_t i = 0; i + 1 < curve.arcs.size(); ++i) {
const double radius = curve.arcs[i].radius;
if (radius <= 0.0) return false;
out.push_back({sign / radius, sign / radius, curve.arcs[i].length});
consumed += std::abs(deflection(out.back()));
const double next_radius = curve.arcs[i + 1].radius;
if (curve.arcs[i].transition_to_next > 0.0 && next_radius > 0.0) {
out.push_back({sign / radius, sign / next_radius,
curve.arcs[i].transition_to_next});
consumed += std::abs(deflection(out.back()));
}
}
const double exit_deflection = 0.5 * curve.exit_transition / last_radius;
const double closing = total_angle - consumed - exit_deflection;
if (closing <= 0.0) return false; // the fixed parts already reach the crossing angle
out.push_back({sign / last_radius, sign / last_radius, closing * last_radius});
if (curve.exit_transition > 0.0) {
out.push_back({sign / last_radius, 0.0, curve.exit_transition});
}
return true;
}
// Advances @p pose along @p segment (without sampling points; that is the
// renderer's job) and returns the end pose.
Pose advance(const TurnoutSegment& segment, const Pose& pose) {
return layout_segment(segment.k0, segment.k1, segment.length, pose, nullptr);
}
// Where the through tangent (through @p a along @p a heading) meets the diverging
// tangent (through @p b along @p b heading). Writes the two tangent lengths; both
// stay zero when the tangents are parallel (a straight-through, angle-less turnout).
void tangent_lengths(const Pose& a, const Pose& b, double& front, double& back) {
const double denominator = a.hx * b.hy - a.hy * b.hx;
if (std::abs(denominator) < 1e-9) return;
const double u = ((b.x - a.x) * b.hy - (b.y - a.y) * b.hx) / denominator;
const double cx = a.x + a.hx * u;
const double cy = a.y + a.hy * u;
front = std::hypot(cx - a.x, cy - a.y);
back = std::hypot(cx - b.x, cy - b.y);
}
} // namespace
TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout) {
TurnoutGeometry geometry;
const double angle = turnout.mark.angle();
if (angle <= 0.0) return geometry;
const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0;
if (!build_curve(turnout.curve, angle, sign, geometry.path)) {
geometry.path.clear();
return geometry;
}
// walk the curve to the frog direction (the crossing angle)
Pose pose = start;
for (const auto& segment : geometry.path) {
pose = advance(segment, pose);
}
// a turnout always runs on past the curve as a straight frog rail. its length
// takes the switch to its catalogue length, measured along the through track;
// without a catalogue length a tangent-length rail keeps a frog rail present.
const double through_projection =
(pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy;
const double cos_angle = pose.hx * start.hx + pose.hy * start.hy;
double rail = turnout.length > 0.0 && cos_angle > 1e-6
? (turnout.length - through_projection) / cos_angle
: turnout.curve.arcs.back().radius * std::tan(angle * 0.5);
if (rail > 0.0) {
const TurnoutSegment frog_rail{0.0, 0.0, rail};
geometry.path.push_back(frog_rail);
pose = advance(frog_rail, pose);
}
geometry.valid = true;
geometry.frog = pose;
for (const auto& segment : geometry.path) geometry.diverging_length += segment.length;
tangent_lengths(start, pose, geometry.tangent_front, geometry.tangent_back);
return geometry;
}
} // namespace maj0sted::domain

View File

@@ -1,4 +1,4 @@
#include "maj0sted/web/editor.hpp"
#include "maj0sted/editor/editor.hpp"
#include <cmath>
#include <cstdint>
@@ -9,6 +9,7 @@
#include "maj0sted/domain/fitting/gap_fitter.hpp"
#include "maj0sted/domain/geometry/segment_layout.hpp"
#include "maj0sted/domain/geometry/turnout.hpp"
#include "maj0sted/maj0sted.hpp"
#include "maj0sted/render/rail_renderer.hpp"
#include "maj0sted/render/track_renderer.hpp"
@@ -19,7 +20,7 @@
// All fitting business logic lives in maj0sted::domain::GapFitter; all rail
// geometry lives in maj0sted::render::RailRenderer. Nothing here decides how a
// curve is fitted or how it is drawn.
namespace maj0sted::web {
namespace maj0sted::editor {
using namespace maj0sted::domain;
using maj0sted::domain::geometry::layout_segment;
@@ -156,19 +157,19 @@ std::vector<render::CentrelineElement> sample_centrelines(
return axis;
}
// Renders the centreline elements to WebPolylines through the renderer
// Renders the centreline elements to PlanPolylines through the renderer
// interface. A different render::TrackRenderer could be dropped in here without
// touching anything above.
std::vector<WebPolyline> render_rails(
std::vector<PlanPolyline> render_rails(
const std::vector<render::CentrelineElement>& axis) {
render::RailRenderer rail_renderer;
render::TrackRenderer& renderer = rail_renderer;
for (const auto& element : axis) renderer.add(element);
std::vector<WebPolyline> polylines;
std::vector<PlanPolyline> polylines;
polylines.reserve(rail_renderer.rails().size());
for (const auto& rail : rail_renderer.rails()) {
WebPolyline poly;
PlanPolyline poly;
poly.kind = static_cast<int>(rail.kind);
poly.straight_index = rail.straight_index;
poly.gap = rail.gap;
@@ -177,7 +178,7 @@ std::vector<WebPolyline> render_rails(
poly.radius_start = rail.radius_start;
poly.radius_end = rail.radius_end;
poly.points.reserve(rail.points.size());
for (const auto& p : rail.points) poly.points.push_back(WebPoint{p.x, p.y});
for (const auto& p : rail.points) poly.points.push_back(PlanPoint{p.x, p.y});
polylines.push_back(std::move(poly));
}
return polylines;
@@ -226,7 +227,7 @@ NiweletaPolys build(const NiweletaSpec& spec) {
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});
result.centreline.push_back(PlanPoint{p.x, p.y});
}
}
@@ -238,7 +239,7 @@ NiweletaPolys build(const NiweletaSpec& spec) {
// 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) {
bool pose_at(const std::vector<PlanPoint>& centreline, double station, Pose& out) {
if (centreline.size() < 2) return false;
double travelled = 0.0;
@@ -261,36 +262,32 @@ bool pose_at(const std::vector<WebPoint>& centreline, double station, Pose& out)
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));
// Translates the editor's switch DTO into a domain Turnout: a plain-arc curve
// (mode 1) or a basket of arcs eased by clothoids (mode 3). All the turnout
// business — turning to the crossing angle, closing the last arc, the frog rail —
// lives in the domain; nothing here decides geometry.
domain::Turnout to_turnout(const Junction& junction) {
domain::DivergingCurve curve;
if (junction.curve.mode == 3 && junction.curve.arcs.size() >= 2) {
for (const auto& arc : junction.curve.arcs) {
curve.arcs.push_back(
domain::TurnoutArc{arc.radius, arc.length, arc.transition_to_next});
}
curve.entry_transition = junction.curve.entry_t;
curve.exit_transition = junction.curve.exit_t;
} else {
curve.arcs.push_back(domain::TurnoutArc{junction.curve.radius, 0.0, 0.0});
}
return axis;
return domain::Turnout{
domain::CrossingMark{junction.crossing_n},
junction.side == 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right,
std::move(curve), junction.length};
}
// 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.
// The drawable geometry of one switch. @p station is the switch's start on the
// through track (początek rozjazdu) — the point the user clicks. The diverging
// path and frog come from the domain; here we only place them on the through
// track and sample them into rails.
JunctionGeom solve_junction(const Junction& junction,
const std::vector<NiweletaPolys>& solved) {
JunctionGeom geom;
@@ -298,61 +295,50 @@ JunctionGeom solve_junction(const Junction& junction,
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)) {
Pose start{};
if (!pose_at(solved[junction.through].centreline, junction.station, start)) {
return geom;
}
// a trailing switch opens against the running direction: flip the heading
double thx = theoretical.hx;
double thy = theoretical.hy;
if (!junction.facing) {
thx = -thx;
thy = -thy;
start.hx = -start.hx;
start.hy = -start.hy;
}
// 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);
const auto turnout = domain::lay_turnout(start, to_turnout(junction));
if (!turnout.valid) return geom;
// 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
// sample the domain segments into drawable rails
std::vector<render::CentrelineElement> axis;
Pose pose = start;
for (const auto& segment : turnout.path) {
std::vector<XY> pts;
render::CentrelineElement element;
element.kind =
std::abs(segment.k1 - segment.k0) >= 1e-12 ? render::ElementKind::Transition
: std::abs(segment.k0) >= 1e-12 ? render::ElementKind::Arc
: render::ElementKind::Straight;
pose = layout_segment(segment.k0, segment.k1, segment.length, pose, &pts);
element.points.reserve(pts.size());
for (const auto& p : pts) element.points.push_back(render::Point{p.x, p.y});
element.length = segment.length;
element.radius_start = radius_of(segment.k0);
element.radius_end = radius_of(segment.k1);
axis.push_back(std::move(element));
}
geom.valid = true;
geom.px = 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.px = start.x;
geom.py = start.y;
geom.fx = turnout.frog.x;
geom.fy = turnout.frog.y;
geom.fhx = turnout.frog.hx;
geom.fhy = turnout.frog.hy;
geom.tangent_front = turnout.tangent_front;
geom.tangent_back = turnout.tangent_back;
geom.length = turnout.diverging_length;
geom.polylines = render_rails(axis);
return geom;
}
@@ -405,4 +391,4 @@ LayoutSolution solve_layout(std::vector<NiweletaSpec> niwelety,
return out;
}
} // namespace maj0sted::web
} // namespace maj0sted::editor

View File

@@ -1,17 +1,17 @@
#include "maj0sted/web/ribbon.hpp"
#include "maj0sted/editor/ribbon.hpp"
#include <cmath>
#include "maj0sted/domain/geometry/segment_layout.hpp"
namespace maj0sted::web {
namespace maj0sted::editor {
using maj0sted::domain::geometry::layout_segment;
using maj0sted::domain::geometry::Pose;
using maj0sted::domain::geometry::XY;
std::vector<WebPolyline> solve_ribbon(const RibbonRequest& request) {
std::vector<WebPolyline> result;
std::vector<PlanPolyline> solve_ribbon(const RibbonRequest& request) {
std::vector<PlanPolyline> result;
result.reserve(request.elements.size());
Pose pose{request.x0, request.y0, std::sin(request.az0), std::cos(request.az0)};
@@ -41,15 +41,15 @@ std::vector<WebPolyline> solve_ribbon(const RibbonRequest& request) {
std::vector<XY> points;
pose = layout_segment(k0, k1, length, pose, &points);
WebPolyline polyline;
PlanPolyline polyline;
polyline.kind = kind;
polyline.points.reserve(points.size());
for (const auto& p : points) {
polyline.points.push_back(WebPoint{p.x, p.y});
polyline.points.push_back(PlanPoint{p.x, p.y});
}
result.push_back(std::move(polyline));
}
return result;
}
} // namespace maj0sted::web
} // namespace maj0sted::editor

View File

@@ -1,11 +1,11 @@
#include "maj0sted/web/solve.hpp"
#include "maj0sted/editor/solve.hpp"
#include <optional>
#include "maj0sted/maj0sted.hpp"
#include "maj0sted/render/plan_sampler.hpp"
namespace maj0sted::web {
namespace maj0sted::editor {
using namespace maj0sted::domain;
@@ -41,15 +41,15 @@ FitResult make_fit(const Niweleta& niweleta, StraightId entry, StraightId exit,
: std::nullopt});
}
std::vector<WebPolyline> to_polylines(const render::Scene& scene) {
std::vector<WebPolyline> result;
std::vector<PlanPolyline> to_polylines(const render::Scene& scene) {
std::vector<PlanPolyline> result;
result.reserve(scene.polylines.size());
for (const auto& polyline : scene.polylines) {
WebPolyline out;
PlanPolyline out;
out.kind = static_cast<int>(polyline.kind);
out.points.reserve(polyline.points.size());
for (const auto& p : polyline.points) {
out.points.push_back(WebPoint{p.x, p.y});
out.points.push_back(PlanPoint{p.x, p.y});
}
result.push_back(std::move(out));
}
@@ -58,7 +58,7 @@ std::vector<WebPolyline> to_polylines(const render::Scene& scene) {
} // namespace
std::vector<WebPolyline> solve_scene(const SolveRequest& request) {
std::vector<PlanPolyline> solve_scene(const SolveRequest& request) {
Niweleta niweleta{NiweletaId{1}};
const CartesianPosition a{request.ax, request.ay};
const CartesianPosition b{request.bx, request.by};
@@ -95,4 +95,4 @@ std::vector<WebPolyline> solve_scene(const SolveRequest& request) {
return to_polylines(render::sample(niweleta.plan()));
}
} // namespace maj0sted::web
} // namespace maj0sted::editor

View File

@@ -1,4 +1,4 @@
#include "maj0sted/web/tile_cache.hpp"
#include "maj0sted/editor/tile_cache.hpp"
#include <algorithm>
#include <charconv>
@@ -7,14 +7,7 @@
#include <numeric>
#include <utility>
#ifdef __EMSCRIPTEN__
#include <cstring>
#include <emscripten/emscripten.h>
#include <emscripten/fetch.h>
#endif
namespace maj0sted::web {
namespace maj0sted::editor {
namespace {
@@ -33,17 +26,23 @@ constexpr int kMaxRetries = 10;
// --- TileGrid ------------------------------------------------------------
double TileGrid::resolution(int /*zoom*/) noexcept {
return kCellMetres / static_cast<double>(kTilePixels); // constant, single grid
double TileGrid::cell_metres(const TileKey& key) noexcept {
return key.zoom >= kCellZoomMin ? static_cast<double>(key.zoom) : kCellMetres;
}
double TileGrid::tile_span(int /*zoom*/) noexcept {
return kCellMetres; // every cell is 100 m × 100 m
double TileGrid::resolution(int zoom) noexcept {
return tile_span(zoom) / static_cast<double>(kTilePixels);
}
TileKey TileGrid::tile_at(double x, double y, int /*zoom*/) noexcept {
const auto safe_index = [](double coordinate, double origin) {
const double value = std::floor((coordinate - origin) / kCellMetres);
double TileGrid::tile_span(int zoom) noexcept {
return zoom >= kCellZoomMin ? static_cast<double>(zoom) : kCellMetres;
}
TileKey TileGrid::tile_at(double x, double y, int zoom) noexcept {
const double cell = tile_span(zoom);
const int key_zoom = zoom >= kCellZoomMin ? zoom : kLevel;
const auto safe_index = [cell](double coordinate, double origin) {
const double value = std::floor((coordinate - origin) / cell);
if (!std::isfinite(value)) return 0;
if (value <= static_cast<double>(std::numeric_limits<int>::min())) {
return std::numeric_limits<int>::min();
@@ -53,20 +52,21 @@ TileKey TileGrid::tile_at(double x, double y, int /*zoom*/) noexcept {
}
return static_cast<int>(value);
};
return TileKey{kLevel, safe_index(x, kOriginX), safe_index(y, kOriginY)};
return TileKey{key_zoom, safe_index(x, kOriginX), safe_index(y, kOriginY)};
}
TileBBox TileGrid::bbox(const TileKey& key) noexcept {
const double min_x = kOriginX + static_cast<double>(key.x) * kCellMetres;
const double min_y = kOriginY + static_cast<double>(key.y) * kCellMetres;
return TileBBox{min_x, min_y, min_x + kCellMetres, min_y + kCellMetres};
const double cell = cell_metres(key);
const double min_x = kOriginX + static_cast<double>(key.x) * cell;
const double min_y = kOriginY + static_cast<double>(key.y) * cell;
return TileBBox{min_x, min_y, min_x + cell, min_y + cell};
}
int TileGrid::zoom_for_resolution(double /*metres_per_pixel*/) noexcept {
return kLevel; // one fixed 100 m grid, no pyramid
}
std::vector<TileKey> TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/,
std::vector<TileKey> TileGrid::tiles_for_view(const TileBBox& view, int zoom,
int max_tiles) {
std::vector<TileKey> tiles;
if (max_tiles <= 0 || !std::isfinite(view.min_x) ||
@@ -79,8 +79,8 @@ std::vector<TileKey> TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/
const double min_y = std::min(view.min_y, view.max_y);
const double max_y = std::max(view.min_y, view.max_y);
const TileKey lo = tile_at(min_x, min_y);
const TileKey hi = tile_at(max_x, max_y);
const TileKey lo = tile_at(min_x, min_y, zoom);
const TileKey hi = tile_at(max_x, max_y, zoom);
using Wide = std::int64_t;
const Wide width = static_cast<Wide>(hi.x) - lo.x + 1;
@@ -113,11 +113,12 @@ std::vector<TileKey> TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/
centre_y - rows / 2, static_cast<Wide>(lo.y),
static_cast<Wide>(hi.y) - rows + 1);
const int key_zoom = zoom >= kCellZoomMin ? zoom : kLevel;
tiles.reserve(static_cast<std::size_t>(columns * rows));
for (Wide row = first_row; row < first_row + rows; ++row) {
for (Wide col = first_col; col < first_col + columns; ++col) {
tiles.push_back(
TileKey{kLevel, static_cast<int>(col), static_cast<int>(row)});
TileKey{key_zoom, static_cast<int>(col), static_cast<int>(row)});
}
}
return tiles;
@@ -127,8 +128,8 @@ std::vector<TileKey> TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/
WmsConfig WmsConfig::geoportal_ortho() {
WmsConfig cfg;
// Public Geoportal ortophoto WMS (EPSG:2180). If this endpoint blocks CORS
// for emscripten_fetch, swap base_url/layers via set_config() from the host.
// Public Geoportal ortophoto WMS (EPSG:2180). Swap base_url/layers via
// set_config() from the host to point at a different backend.
cfg.base_url =
"https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution";
cfg.layers = "Raster";
@@ -233,59 +234,11 @@ void TileService::complete_fetch(const TileKey& key, bool ok,
}
}
#ifdef __EMSCRIPTEN__
namespace {
// Delay between retry attempts, so a persistently failing endpoint is retried
// steadily rather than in a tight loop.
constexpr int kRetryDelayMs = 1500;
struct FetchCtx {
TileService* self;
TileKey key;
};
struct RetryCtx {
TileService* self;
TileKey key;
};
void on_retry(void* user_data) {
auto* ctx = static_cast<RetryCtx*>(user_data);
TileService* self = ctx->self;
const TileKey key = ctx->key;
delete ctx;
self->retry_now(key);
}
void on_fetch_success(emscripten_fetch_t* fetch) {
auto* ctx = static_cast<FetchCtx*>(fetch->userData);
std::vector<std::uint8_t> bytes(
reinterpret_cast<const std::uint8_t*>(fetch->data),
reinterpret_cast<const std::uint8_t*>(fetch->data) +
static_cast<std::size_t>(fetch->numBytes));
TileService* self = ctx->self;
const TileKey key = ctx->key;
delete ctx;
emscripten_fetch_close(fetch);
self->complete_fetch(key, true, std::move(bytes), {});
}
void on_fetch_error(emscripten_fetch_t* fetch) {
auto* ctx = static_cast<FetchCtx*>(fetch->userData);
TileService* self = ctx->self;
const TileKey key = ctx->key;
delete ctx;
emscripten_fetch_close(fetch);
self->complete_fetch(key, false, {}, {});
}
} // namespace
#endif
void TileService::begin_fetch(const TileKey& key) {
const std::string url = url_for(key);
// An injected fetcher wins on every platform (used by native tests and any
// host that wants to route the request itself).
// The host injects the fetcher (the editor's threaded downloader, or a test
// stub). Without one there is no transport, so the tile simply fails.
if (fetcher_) {
const TileKey captured = key;
fetcher_(url, [this, captured](bool ok, std::vector<std::uint8_t> bytes,
@@ -294,33 +247,15 @@ void TileService::begin_fetch(const TileKey& key) {
});
return;
}
#ifdef __EMSCRIPTEN__
emscripten_fetch_attr_t attr;
emscripten_fetch_attr_init(&attr);
std::strcpy(attr.requestMethod, "GET");
attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY;
attr.userData = new FetchCtx{this, key};
attr.onsuccess = on_fetch_success;
attr.onerror = on_fetch_error;
emscripten_fetch(&attr, url.c_str());
#else
// No network backend available (native, no injected fetcher): mark failed.
complete_fetch(key, false, {}, {});
#endif
}
void TileService::retry_now(const TileKey& key) { begin_fetch(key); }
void TileService::schedule_retry(const TileKey& key) {
#ifdef __EMSCRIPTEN__
// Defer asynchronously: infinite retry without blocking or recursion.
emscripten_async_call(&on_retry, new RetryCtx{this, key}, kRetryDelayMs);
#else
// Injected-fetcher path (native/tests): re-issue immediately. The fetcher
// controls timing, so a fetcher that eventually succeeds ends the loop.
// Re-issue at once; the injected fetcher owns the timing, so a fetcher that
// eventually succeeds ends the retry loop.
begin_fetch(key);
#endif
}
} // namespace maj0sted::web
} // namespace maj0sted::editor

View File

@@ -0,0 +1,367 @@
#include "maj0sted/io/scn_export.hpp"
#include <algorithm>
#include <cmath>
#include <numbers>
#include <sstream>
#include <vector>
namespace maj0sted::io {
namespace {
using maj0sted::editor::NiweletaPolys;
using maj0sted::editor::PlanPoint;
using maj0sted::editor::PlanPolyline;
struct AxisElement {
int kind{0};
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};
std::vector<PlanPoint> points;
};
struct WorldXYZ {
double x{0.0};
double y{0.0};
double z{0.0};
};
std::vector<AxisElement> recover_axis(const NiweletaPolys& solved) {
std::vector<AxisElement> out;
const auto& polys = solved.polylines;
for (std::size_t i = 0; i < polys.size();) {
const auto& a = polys[i];
if (a.points.size() < 2) {
++i;
continue;
}
if (i + 1 < polys.size()) {
const auto& b = polys[i + 1];
if (b.straight_index == a.straight_index && b.gap == a.gap &&
b.element_index == a.element_index &&
b.points.size() == a.points.size()) {
AxisElement el;
el.kind = a.kind;
el.straight_index = a.straight_index;
el.gap = a.gap;
el.element_index = a.element_index;
el.length = a.length;
el.radius_start = a.radius_start;
el.radius_end = a.radius_end;
el.points.resize(a.points.size());
for (std::size_t k = 0; k < a.points.size(); ++k) {
el.points[k] = {(a.points[k].x + b.points[k].x) * 0.5,
(a.points[k].y + b.points[k].y) * 0.5};
}
out.push_back(std::move(el));
i += 2;
continue;
}
}
AxisElement el;
el.kind = a.kind;
el.straight_index = a.straight_index;
el.gap = a.gap;
el.element_index = a.element_index;
el.length = a.length;
el.radius_start = a.radius_start;
el.radius_end = a.radius_end;
el.points = a.points;
out.push_back(std::move(el));
++i;
}
return out;
}
WorldXYZ to_scn(double east, double north, double height, double ox, double oy) {
return {east - ox, height, -(north - oy)};
}
void plan_tangent_to_scn(double de, double dn, double& out_x, double& out_z) {
const double len = std::hypot(de, dn);
if (len < 1e-12) {
out_x = 0.0;
out_z = 1.0;
return;
}
out_x = de / len;
out_z = -dn / len;
}
bool axis_pose_at(const std::vector<PlanPoint>& pts, double along, PlanPoint& out_p,
double& out_tx, double& out_ty) {
if (pts.size() < 2) {
return false;
}
double a = 0.0;
for (std::size_t i = 1; i < pts.size(); ++i) {
const double dx = pts[i].x - pts[i - 1].x;
const double dy = pts[i].y - pts[i - 1].y;
const double seg = std::hypot(dx, dy);
if (a + seg >= along || i + 1 == pts.size()) {
const double t = seg > 1e-12 ? std::clamp((along - a) / seg, 0.0, 1.0) : 0.0;
out_p = {pts[i - 1].x + dx * t, pts[i - 1].y + dy * t};
if (seg > 1e-12) {
out_tx = dx / seg;
out_ty = dy / seg;
} else {
out_tx = 1.0;
out_ty = 0.0;
}
return true;
}
a += seg;
}
return false;
}
double axis_total_length(const std::vector<PlanPoint>& pts) {
double along = 0.0;
for (std::size_t i = 1; i < pts.size(); ++i) {
along += std::hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
}
return along;
}
double circular_bezier_handle(double radius, double phi) {
const double p = std::clamp(phi, 1e-6, std::numbers::pi);
return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(p * 0.25);
}
bool fit_clothoid_bezier_handles(const std::vector<PlanPoint>& pts, double s0,
double s1, const PlanPoint& p0, const PlanPoint& p3,
double t0x, double t0y, double t1x, double t1y,
double& out_l0, double& out_l1) {
const double span = s1 - s0;
if (span < 1e-6) {
return false;
}
const int samples = std::max(8, static_cast<int>(std::ceil(span / 2.0)));
double m00 = 0.0, m01 = 0.0, m11 = 0.0, b0 = 0.0, b1 = 0.0;
for (int i = 0; i <= samples; ++i) {
const double t = static_cast<double>(i) / static_cast<double>(samples);
PlanPoint q{};
double tx = 1.0, ty = 0.0;
if (!axis_pose_at(pts, s0 + span * t, q, tx, ty)) {
continue;
}
const double omt = 1.0 - t;
const double c0 = 3.0 * omt * omt * t;
const double c1 = 3.0 * omt * t * t;
const double ax =
omt * omt * (1.0 + 2.0 * t) * p0.x + t * t * (3.0 - 2.0 * t) * p3.x;
const double ay =
omt * omt * (1.0 + 2.0 * t) * p0.y + t * t * (3.0 - 2.0 * t) * p3.y;
const double rx = q.x - ax;
const double ry = q.y - ay;
const double a0x = c0 * t0x, a0y = c0 * t0y;
const double a1x = -c1 * t1x, a1y = -c1 * t1y;
m00 += a0x * a0x + a0y * a0y;
m01 += a0x * a1x + a0y * a1y;
m11 += a1x * a1x + a1y * a1y;
b0 += a0x * rx + a0y * ry;
b1 += a1x * rx + a1y * ry;
}
const double det = m00 * m11 - m01 * m01;
if (std::abs(det) < 1e-12) {
return false;
}
const double l0 = (m11 * b0 - m01 * b1) / det;
const double l1 = (-m01 * b0 + m00 * b1) / det;
if (l0 < 0.05 || l1 < 0.05) {
return false;
}
out_l0 = std::clamp(l0, 0.05, span);
out_l1 = std::clamp(l1, 0.05, span);
return true;
}
void clothoid_handles_from_radii(double len, double phi, double r0, double r1,
double& out_l0, double& out_l1) {
auto handle = [&](double r) {
if (r < 1.0) {
return len / 3.0;
}
return circular_bezier_handle(r, phi);
};
out_l0 = handle(r0);
out_l1 = handle(r1);
}
void write_scn_track(std::ostream& out, const std::string& name, const WorldXYZ& p1,
const WorldXYZ& cv1, const WorldXYZ& p2, const WorldXYZ& cv2,
double length, double radius) {
out << "node -1 0 " << name << " track normal " << length
<< " 1.435 0.15 25.0 20 0 flat vis\n";
out << " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n";
auto fmt = [](double v) {
std::ostringstream s;
s.setf(std::ios::fixed);
s.precision(4);
s << v;
return s.str();
};
out << fmt(p1.x) << ' ' << fmt(p1.y) << ' ' << fmt(p1.z) << " 0\n";
out << fmt(cv1.x) << ' ' << fmt(cv1.y) << ' ' << fmt(cv1.z) << '\n';
out << fmt(cv2.x) << ' ' << fmt(cv2.y) << ' ' << fmt(cv2.z) << '\n';
out << fmt(p2.x) << ' ' << fmt(p2.y) << ' ' << fmt(p2.z) << " 0\n";
out << fmt(radius) << '\n';
out << "endtrack\n\n";
}
} // namespace
ScnExportOptions resolve_scn_origin(const std::vector<NiweletaPolys>& solved,
ScnExportOptions options) {
if (std::abs(options.origin_east) >= 1.0 ||
std::abs(options.origin_north) >= 1.0) {
return options;
}
for (const auto& s : solved) {
const auto axis = recover_axis(s);
if (!axis.empty() && !axis.front().points.empty()) {
const auto& p = axis.front().points.front();
if (std::abs(p.x) > 10000.0 || std::abs(p.y) > 10000.0) {
options.origin_east = p.x;
options.origin_north = p.y;
}
break;
}
}
return options;
}
ScnExportResult export_scn(const std::vector<NiweletaPolys>& solved,
const ScnExportOptions& options, std::ostream& out) {
const auto opt = resolve_scn_origin(solved, options);
ScnExportResult result;
result.origin_east = opt.origin_east;
result.origin_north = opt.origin_north;
out << "//$n plan\n";
out << "//$d origin " << opt.origin_east << ' ' << opt.origin_north << "\n\n";
const double kRailY = opt.rail_y;
const double kMaxArcAngle = opt.max_arc_angle;
for (std::size_t n = 0; n < solved.size(); ++n) {
const auto axis = recover_axis(solved[n]);
for (std::size_t ei = 0; ei < axis.size(); ++ei) {
const auto& el = axis[ei];
if (el.points.size() < 2) {
continue;
}
const double total =
el.length > 1e-6 ? el.length : axis_total_length(el.points);
if (total < 0.05) {
continue;
}
auto curvature_at = [&](double along) {
const double k0 = el.radius_start > 1.0 ? 1.0 / el.radius_start : 0.0;
const double k1 = el.radius_end > 1.0 ? 1.0 / el.radius_end : 0.0;
const double t =
total > 1e-9 ? std::clamp(along / total, 0.0, 1.0) : 0.0;
return k0 + (k1 - k0) * t;
};
auto radius_from_k = [](double k) {
return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0;
};
auto emit_piece = [&](double s0, double s1, double piece_len,
double radius, const char* tag) {
PlanPoint p0{}, p1{};
double t0x = 1.0, t0y = 0.0, t1x = 1.0, t1y = 0.0;
if (!axis_pose_at(el.points, s0, p0, t0x, t0y) ||
!axis_pose_at(el.points, s1, p1, t1x, t1y)) {
return;
}
const auto w0 = to_scn(p0.x, p0.y, kRailY, opt.origin_east,
opt.origin_north);
const auto w1 = to_scn(p1.x, p1.y, kRailY, opt.origin_east,
opt.origin_north);
double tx0 = 0.0, tz0 = 0.0, tx1 = 0.0, tz1 = 0.0;
plan_tangent_to_scn(t0x, t0y, tx0, tz0);
plan_tangent_to_scn(t1x, t1y, tx1, tz1);
WorldXYZ cv1{}, cv2{};
double radius_out = 0.0;
if (el.kind == 1 && radius >= 1.0) {
const double phi = std::clamp(piece_len / std::max(radius, 1.0),
1e-6, std::numbers::pi);
const double handle = circular_bezier_handle(radius, phi);
cv1 = {tx0 * handle, 0.0, tz0 * handle};
cv2 = {-tx1 * handle, 0.0, -tz1 * handle};
radius_out = radius;
} else if (el.kind == 2) {
const double cross = t0x * t1y - t0y * t1x;
const double dot = t0x * t1x + t0y * t1y;
const double phi = std::clamp(std::abs(std::atan2(cross, dot)),
1e-6, std::numbers::pi);
const double r0 = radius_from_k(curvature_at(s0));
const double r1 = radius_from_k(curvature_at(s1));
double l0 = piece_len / 3.0;
double l1 = piece_len / 3.0;
if (!fit_clothoid_bezier_handles(el.points, s0, s1, p0, p1, t0x,
t0y, t1x, t1y, l0, l1)) {
clothoid_handles_from_radii(piece_len, phi, r0, r1, l0, l1);
}
cv1 = {tx0 * l0, 0.0, tz0 * l0};
cv2 = {-tx1 * l1, 0.0, -tz1 * l1};
}
std::ostringstream name;
name << "plan_n" << n;
if (el.straight_index >= 0) {
name << "_s" << el.straight_index;
} else {
name << "_g" << el.gap << "_e" << el.element_index;
}
name << '_' << tag << '_' << result.tracks;
const auto track_name = name.str();
if (result.first_track_name.empty()) {
result.first_track_name = track_name;
}
write_scn_track(out, track_name, w0, cv1, w1, cv2, piece_len,
radius_out);
++result.tracks;
};
if (el.kind == 0 || (el.kind == 1 && el.radius_start < 1.0)) {
emit_piece(0.0, total, total, 0.0, "str");
continue;
}
if (el.kind == 2) {
emit_piece(0.0, total, total, 0.0, "kp");
continue;
}
const double r = std::max(el.radius_start, el.radius_end);
const double phi = total / std::max(r, 1.0);
const int pieces =
std::max(1, static_cast<int>(std::ceil(phi / kMaxArcAngle)));
const double piece_len = total / static_cast<double>(pieces);
for (int p = 0; p < pieces; ++p) {
const double s0 = piece_len * static_cast<double>(p);
const double s1 =
p + 1 == pieces ? total : piece_len * static_cast<double>(p + 1);
emit_piece(s0, s1, s1 - s0, r, "arc");
}
}
}
out << "FirstInit\n\n";
if (!result.first_track_name.empty()) {
out << "trainset none " << result.first_track_name << " 5 0\n";
out << "node -1 0 plan_eu07 dynamic PKP\\303E_V1 303E-EP-TV-424-HIST "
"303E-EP-TV 0 headdriver 35.WH25 0 enddynamic\n";
out << "endtrainset\n";
}
return result;
}
} // namespace maj0sted::io

View File

@@ -33,10 +33,10 @@ target_link_libraries(maj0sted_test_render PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_render PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME render COMMAND maj0sted_test_render)
add_executable(maj0sted_test_web test_web.cpp)
target_link_libraries(maj0sted_test_web PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_web PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME web COMMAND maj0sted_test_web)
add_executable(maj0sted_test_editor test_editor.cpp)
target_link_libraries(maj0sted_test_editor PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_editor PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME editor COMMAND maj0sted_test_editor)
add_executable(maj0sted_test_tile_cache test_tile_cache.cpp)
target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted)

View File

@@ -5,9 +5,9 @@
#include "maj0sted/app/editor_document.hpp"
using namespace maj0sted::app;
using maj0sted::web::GapFit;
using maj0sted::web::NiweletaSpec;
using maj0sted::web::StraightSpec;
using maj0sted::editor::GapFit;
using maj0sted::editor::NiweletaSpec;
using maj0sted::editor::StraightSpec;
namespace {
@@ -28,14 +28,28 @@ EditorDocument sample_document() {
NiweletaSpec b;
b.name = "Bocznica z spacją";
b.straights = {StraightSpec{500000.25, 300000.75, 500200.0, 300000.0}};
b.straights[0].rel_kind = 1;
b.straights[0].rel_niw = 0;
b.straights[0].rel_str = 0;
b.straights[0].rel_offset = -4.5;
doc.niwelety = {a, b};
doc.view_x = 500100.5;
doc.view_y = 300050.25;
doc.view_extent = 420.0;
doc.origin_set = true;
doc.georeferenced = true;
doc.origin_x = 500000.0;
doc.origin_y = 300000.0;
return doc;
}
bool same(const StraightSpec& x, const StraightSpec& y) {
return x.x1 == y.x1 && x.y1 == y.y1 && x.x2 == y.x2 && x.y2 == y.y2 &&
x.hidden == y.hidden;
x.hidden == y.hidden && x.rel_kind == y.rel_kind &&
x.rel_niw == y.rel_niw && x.rel_str == y.rel_str &&
x.rel_offset == y.rel_offset && x.rel_cot == y.rel_cot &&
x.rel_side == y.rel_side && x.rel_length == y.rel_length;
}
bool same(const GapFit& x, const GapFit& y) {
@@ -46,6 +60,13 @@ bool same(const GapFit& x, const GapFit& y) {
}
void check_equal(const EditorDocument& x, const EditorDocument& y) {
CHECK(x.view_x == y.view_x);
CHECK(x.view_y == y.view_y);
CHECK(x.view_extent == y.view_extent);
CHECK(x.origin_set == y.origin_set);
CHECK(x.georeferenced == y.georeferenced);
CHECK(x.origin_x == y.origin_x);
CHECK(x.origin_y == y.origin_y);
CHECK(x.niwelety.size() == y.niwelety.size());
if (x.niwelety.size() != y.niwelety.size()) return;
for (std::size_t n = 0; n < x.niwelety.size(); ++n) {

View File

@@ -2,14 +2,14 @@
#include <numbers>
#include "check.hpp"
#include "maj0sted/web/editor.hpp"
#include "maj0sted/web/solve.hpp"
#include "maj0sted/editor/editor.hpp"
#include "maj0sted/editor/solve.hpp"
using namespace maj0sted::web;
using namespace maj0sted::editor;
namespace {
bool has_kind(const std::vector<WebPolyline>& polylines, int kind) {
bool has_kind(const std::vector<PlanPolyline>& polylines, int kind) {
for (const auto& p : polylines) {
if (p.kind == kind) return true;
}
@@ -120,7 +120,7 @@ void editor_emits_two_rails_at_standard_gauge() {
CHECK(solved[0].polylines[0].straight_index == 0);
CHECK(solved[0].polylines[0].gap == -1);
CHECK(std::abs(solved[0].polylines[0].length - 5.0) < 1e-9);
const WebPolyline* arc = nullptr;
const PlanPolyline* arc = nullptr;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 1) {
arc = &polyline;
@@ -138,7 +138,7 @@ void editor_emits_two_rails_at_standard_gauge() {
void editor_exposes_transition_radius_range() {
const auto solved = solve_project({short_corner(5.0, 2.0)});
const WebPolyline* transition = nullptr;
const PlanPolyline* transition = nullptr;
for (const auto& polyline : solved[0].polylines) {
if (polyline.kind == 2) {
transition = &polyline;

View File

@@ -4,9 +4,9 @@
#include <vector>
#include "check.hpp"
#include "maj0sted/web/tile_cache.hpp"
#include "maj0sted/editor/tile_cache.hpp"
using namespace maj0sted::web;
using namespace maj0sted::editor;
namespace {