17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-11-02 06:59:13 +01:00

Editor: fold the plan library into the tree as modules, drop its own name

The track layout library came in as a subproject of its own -- its own
CMakeLists, its own install and export rules, its own include tree -- which only
made the build harder to follow now that everything else is a module. It is part
of the editor, so it lives where the editor lives.

- editor/plan_*.cppm / plan_*.cpp: the fifteen interface units and twelve
  implementation units, flat in editor/ beside editorTerrain and orthophoto, with
  the plan_ prefix keeping them clear of what was already there (editor/turnouts
  is the preset catalogue, so the geometry is plan_turnout_geometry).
- modules are named eu07.editor.plan_*, like every other module in the tree.
- namespaces move under the editor's own: editor::plan, editor::plan::geometry,
  ::io, ::render. The library's old name is gone from the sources and the docs.
- every moved file carries the MPL header the rest of the tree carries; it had
  none. version.hpp went with the subproject: macros only, and unused.
- the umbrella header is dropped -- nothing imported it.
- the unit tests survive the fold: editor/tests, behind EU07_EDITOR_PLAN_TESTS
  (off by default), compiles the interfaces a second time into a small library of
  its own, because a test can only import modules from a target it links and
  linking the game would drag the whole simulator in. The code under test needs
  nothing but a compiler. 7/7 pass.
- a switch may now be written with two dashes as well: --editor beside -editor.

Builds with clang 22 (-DWITH_BETTER_RENDERER=OFF); -editor and --editor both come
up in the editor on empty ground.
This commit is contained in:
maj00r
2026-09-13 13:31:01 +02:00
parent c15e200e37
commit 321c9dd7ab
56 changed files with 931 additions and 952 deletions

View File

@@ -14,7 +14,6 @@ module;
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "maj0sted/editor/tile_cache.hpp"
#include <glad/glad.h>
#include "stb/stb_image.h"
#ifdef _WIN32
@@ -26,6 +25,7 @@ module;
#endif
module eu07.editor.orthophoto;
import eu07.editor.plan_tilecache;
import eu07.utilities.logs;
/*
@@ -209,9 +209,9 @@ bool http_get(std::string const &Url, std::vector<std::uint8_t> &Bytes, std::str
} // namespace
orthophoto_source::orthophoto_source(maj0sted::editor::WmsConfig Config, int const Gridzoom, std::string Cachesubdir)
orthophoto_source::orthophoto_source(editor::plan::WmsConfig Config, int const Gridzoom, std::string Cachesubdir)
: m_service([&] {
if (Gridzoom >= maj0sted::editor::TileGrid::kCellZoomMin)
if (Gridzoom >= editor::plan::TileGrid::kCellZoomMin)
{
Config.tile_pixels = 512; // coarse cells do not need orto's 1024 px
}
@@ -221,7 +221,7 @@ orthophoto_source::orthophoto_source(maj0sted::editor::WmsConfig Config, int con
{
// the library asks for a URL and expects the answer whenever it arrives; the transfer is queued
// here and handed back on the thread that drains it, so the service stays single threaded
m_service.set_fetcher([this](std::string const &Url, maj0sted::editor::TileService::DoneFn Done) {
m_service.set_fetcher([this](std::string const &Url, editor::plan::TileService::DoneFn Done) {
{
std::lock_guard<std::mutex> lock(m_mutex);
m_queued.push_back({Url, std::move(Done), false, {}, {}});
@@ -291,7 +291,7 @@ void orthophoto_source::worker()
}
}
std::vector<orthophoto_source::ready_tile> orthophoto_source::collect(maj0sted::editor::TileBBox const &View, int const Maxtiles)
std::vector<orthophoto_source::ready_tile> orthophoto_source::collect(editor::plan::TileBBox const &View, int const Maxtiles)
{
// hand finished transfers to the library here, on the caller's thread
std::deque<transfer> arrived;
@@ -311,7 +311,7 @@ std::vector<orthophoto_source::ready_tile> orthophoto_source::collect(maj0sted::
// fetch only what fits the budget for the current view. the window is recentred when the
// view is larger than the cap, and zoom-to-cursor moves the camera, so the set of keys asked
// for changes from frame to frame - that must not decide what is drawn
for (auto const &key : maj0sted::editor::TileGrid::tiles_for_view(View, m_gridzoom, Maxtiles))
for (auto const &key : editor::plan::TileGrid::tiles_for_view(View, m_gridzoom, Maxtiles))
{
// a tile kept from an earlier session seeds the cache, so no request goes out for it at all
if (false == m_service.has(key))
@@ -320,13 +320,13 @@ std::vector<orthophoto_source::ready_tile> orthophoto_source::collect(maj0sted::
}
auto const status = m_service.request(key);
if (status == maj0sted::editor::TileStatus::Failed)
if (status == editor::plan::TileStatus::Failed)
{
// counted once per tile, not once per frame it stays unavailable
m_failedkeys.insert(key);
continue;
}
if (status != maj0sted::editor::TileStatus::Ready)
if (status != editor::plan::TileStatus::Ready)
{
continue;
}
@@ -342,7 +342,7 @@ std::vector<orthophoto_source::ready_tile> orthophoto_source::collect(maj0sted::
// draw every texture we already hold that still covers the view. returning only the fetch
// window made tiles blink out during zoom as soon as they fell outside the cap, then blink
// back in once the window moved over them again
auto const overlaps = [](maj0sted::editor::TileBBox const &Left, maj0sted::editor::TileBBox const &Right) {
auto const overlaps = [](editor::plan::TileBBox const &Left, editor::plan::TileBBox const &Right) {
return Left.min_x < Right.max_x && Left.max_x > Right.min_x && Left.min_y < Right.max_y && Left.max_y > Right.min_y;
};
@@ -354,7 +354,7 @@ std::vector<orthophoto_source::ready_tile> orthophoto_source::collect(maj0sted::
{
continue;
}
auto const box = maj0sted::editor::TileGrid::bbox(key);
auto const box = editor::plan::TileGrid::bbox(key);
if (overlaps(box, View))
{
tiles.push_back({box, texture});
@@ -364,7 +364,7 @@ std::vector<orthophoto_source::ready_tile> orthophoto_source::collect(maj0sted::
return tiles;
}
unsigned int orthophoto_source::upload(maj0sted::editor::TileKey const &Key)
unsigned int orthophoto_source::upload(editor::plan::TileKey const &Key)
{
auto const *image = m_service.peek(Key);
if (image == nullptr || image->bytes.empty())
@@ -397,12 +397,12 @@ unsigned int orthophoto_source::upload(maj0sted::editor::TileKey const &Key)
return texture;
}
std::string orthophoto_source::tile_path(maj0sted::editor::TileKey const &Key) const
std::string orthophoto_source::tile_path(editor::plan::TileKey const &Key) const
{
return "editor/cache/" + m_cachesubdir + "/" + std::to_string(Key.zoom) + "_" + std::to_string(Key.x) + "_" + std::to_string(Key.y) + ".img";
}
bool orthophoto_source::load_from_disk(maj0sted::editor::TileKey const &Key)
bool orthophoto_source::load_from_disk(editor::plan::TileKey const &Key)
{
std::ifstream file(tile_path(Key), std::ios::binary);
if (false == file.is_open())
@@ -421,7 +421,7 @@ bool orthophoto_source::load_from_disk(maj0sted::editor::TileKey const &Key)
return true;
}
void orthophoto_source::save_to_disk(maj0sted::editor::TileKey const &Key)
void orthophoto_source::save_to_disk(editor::plan::TileKey const &Key)
{
auto const *image = m_service.peek(Key);
if (image == nullptr || image->bytes.empty())
@@ -484,7 +484,7 @@ unsigned int upload_image(std::vector<std::uint8_t> const &Bytes)
return texture;
}
bool same_box(maj0sted::editor::TileBBox const &Left, maj0sted::editor::TileBBox const &Right)
bool same_box(editor::plan::TileBBox const &Left, editor::plan::TileBBox const &Right)
{
auto const close = [](double const A, double const B) { return std::abs(A - B) < 1.0; };
return close(Left.min_x, Right.min_x) && close(Left.min_y, Right.min_y) && close(Left.max_x, Right.max_x) && close(Left.max_y, Right.max_y);
@@ -492,7 +492,7 @@ bool same_box(maj0sted::editor::TileBBox const &Left, maj0sted::editor::TileBBox
} // namespace
wms_image::wms_image(maj0sted::editor::WmsConfig Config) : m_config(std::move(Config))
wms_image::wms_image(editor::plan::WmsConfig Config) : m_config(std::move(Config))
{
m_worker = std::thread([this]() { worker(); });
}
@@ -547,7 +547,7 @@ void wms_image::worker()
}
}
unsigned int wms_image::texture_for(maj0sted::editor::TileBBox const &Box, int const Pixels)
unsigned int wms_image::texture_for(editor::plan::TileBBox const &Box, int const Pixels)
{
// a finished download becomes the texture on this thread, where the graphics API is safe to touch
{
@@ -583,7 +583,7 @@ unsigned int wms_image::texture_for(maj0sted::editor::TileBBox const &Box, int c
config.tile_pixels = Pixels;
{
std::lock_guard<std::mutex> lock(m_mutex);
m_url = maj0sted::editor::wms_getmap_url(config, Box);
m_url = editor::plan::wms_getmap_url(config, Box);
m_haswork = true;
}
m_loading = true;

View File

@@ -17,9 +17,9 @@ module;
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "maj0sted/editor/tile_cache.hpp"
export module eu07.editor.orthophoto;
import eu07.editor.plan_tilecache;
export {
@@ -39,13 +39,13 @@ class orthophoto_source
public:
struct ready_tile
{
maj0sted::editor::TileBBox box;
editor::plan::TileBBox box;
unsigned int texture{0};
};
// default: Geoportal ortophoto on the fine 100 m grid. pass topo config and a coarse
// grid zoom (e.g. 1000) for the zoomed-out topographic underlay
explicit orthophoto_source(maj0sted::editor::WmsConfig Config = maj0sted::editor::WmsConfig::geoportal_ortho(), int const Gridzoom = maj0sted::editor::TileGrid::kLevel,
explicit orthophoto_source(editor::plan::WmsConfig Config = editor::plan::WmsConfig::geoportal_ortho(), int const Gridzoom = editor::plan::TileGrid::kLevel,
std::string Cachesubdir = "orto");
~orthophoto_source();
orthophoto_source(orthophoto_source const &) = delete;
@@ -54,7 +54,7 @@ class orthophoto_source
// kicks off fetches for the view (capped) and returns every already-uploaded tile that still
// intersects it - including ones outside the fetch window, so a zoom cannot blink them away.
// tiles still on their way simply aren't in the answer yet; call again next frame
std::vector<ready_tile> collect(maj0sted::editor::TileBBox const &View, int const Maxtiles = 400);
std::vector<ready_tile> collect(editor::plan::TileBBox const &View, int const Maxtiles = 400);
// how many tiles are still being fetched, for the panel to report
std::size_t pending() const;
// how many tiles gave up after exhausting their retries
@@ -65,7 +65,7 @@ class orthophoto_source
struct transfer
{
std::string url;
maj0sted::editor::TileService::DoneFn done;
editor::plan::TileService::DoneFn done;
bool ok{false};
std::vector<std::uint8_t> bytes;
std::string content_type;
@@ -73,17 +73,17 @@ class orthophoto_source
// methods
void worker();
// decodes the cached bytes of a tile and uploads them; returns 0 when the image is unusable
unsigned int upload(maj0sted::editor::TileKey const &Key);
unsigned int upload(editor::plan::TileKey const &Key);
// imagery of a given piece of ground never changes, so a fetched tile is kept on disk and the
// next session starts with it instead of going back to a service that is slow and flaky
std::string tile_path(maj0sted::editor::TileKey const &Key) const;
bool load_from_disk(maj0sted::editor::TileKey const &Key);
void save_to_disk(maj0sted::editor::TileKey const &Key);
std::string tile_path(editor::plan::TileKey const &Key) const;
bool load_from_disk(editor::plan::TileKey const &Key);
void save_to_disk(editor::plan::TileKey const &Key);
// members
maj0sted::editor::TileService m_service;
int m_gridzoom{maj0sted::editor::TileGrid::kLevel};
editor::plan::TileService m_service;
int m_gridzoom{editor::plan::TileGrid::kLevel};
std::string m_cachesubdir{"orto"};
std::unordered_map<maj0sted::editor::TileKey, unsigned int, maj0sted::editor::TileKeyHash> m_textures;
std::unordered_map<editor::plan::TileKey, unsigned int, editor::plan::TileKeyHash> m_textures;
mutable std::mutex m_mutex;
std::condition_variable m_wakeup;
@@ -92,7 +92,7 @@ class orthophoto_source
std::vector<std::thread> m_workers;
std::atomic<bool> m_quitting{false};
std::atomic<std::size_t> m_inflight{0};
std::unordered_set<maj0sted::editor::TileKey, maj0sted::editor::TileKeyHash> m_failedkeys;
std::unordered_set<editor::plan::TileKey, editor::plan::TileKeyHash> m_failedkeys;
};
// a single WMS image covering an arbitrary box, for views too coarse for the tile grid - the whole
@@ -102,25 +102,25 @@ class wms_image
{
public:
explicit wms_image(maj0sted::editor::WmsConfig Config);
explicit wms_image(editor::plan::WmsConfig Config);
~wms_image();
wms_image(wms_image const &) = delete;
wms_image &operator=(wms_image const &) = delete;
// asks for the given box at the given size and returns the texture currently available, which
// may still be the previous box's until the new one arrives; 0 when there is nothing to show yet
unsigned int texture_for(maj0sted::editor::TileBBox const &Box, int const Pixels);
unsigned int texture_for(editor::plan::TileBBox const &Box, int const Pixels);
// the box the returned texture actually covers
maj0sted::editor::TileBBox const &covered() const { return m_covered; }
editor::plan::TileBBox const &covered() const { return m_covered; }
bool loading() const { return m_loading.load(); }
private:
void worker();
maj0sted::editor::WmsConfig m_config;
maj0sted::editor::TileBBox m_requested{};
maj0sted::editor::TileBBox m_inflightbox{};
maj0sted::editor::TileBBox m_covered{};
editor::plan::WmsConfig m_config;
editor::plan::TileBBox m_requested{};
editor::plan::TileBBox m_inflightbox{};
editor::plan::TileBBox m_covered{};
unsigned int m_texture{0};
mutable std::mutex m_mutex;

150
editor/plan_chain.cpp Normal file
View File

@@ -0,0 +1,150 @@
/*
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/.
*/
module;
#include <cmath>
#include <cstddef>
#include <string>
#include <vector>
module eu07.editor.plan_chain;
namespace editor::plan {
namespace {
constexpr double kEps = 1e-9;
using geometry::Pose;
Diagnostic make(Code code, TrackId track, ElementId element, std::string text) {
Diagnostic diagnostic;
diagnostic.code = code;
diagnostic.track = track;
diagnostic.element = element;
diagnostic.text = std::move(text);
return diagnostic;
}
Diagnostic degenerate(TrackId track, ElementId element, std::string text) {
return make(Code::DegenerateElement, track, element, std::move(text));
}
/// Signed curvature an element ends on. A straight, and a clothoid running out
/// to an infinite radius, both end flat.
double end_curvature(const Element& element) noexcept {
if (element.kind == Kind::Line || element.radius <= kEps) {
return 0.0;
}
return static_cast<double>(element.hand) / element.radius;
}
} // namespace
// ---------------------------------------------------------------------------
TrackOutcome lay_track(const Document& /*document*/, const Track& track, const Pose& start) {
TrackOutcome out;
out.track.id = track.id;
out.track.start = start;
out.track.end = start;
// Two straights end to end are one straight: there is no corner between them,
// so there is nothing to grab hold of and nothing an arc could re-fit against.
// The chain is still laid — this is a rule about how track is written down,
// not about whether the geometry closes.
for (std::size_t i = 1; i < track.elements.size(); ++i) {
if (track.elements[i].kind == Kind::Line &&
track.elements[i - 1].kind == Kind::Line) {
out.diagnostics.push_back(make(
Code::TouchingStraights, track.id, track.elements[i].id,
"dwie proste stykają się ze sobą — to jedna prosta o długości " +
std::to_string(static_cast<long long>(track.elements[i - 1].length +
track.elements[i].length)) +
" m; złącz je albo wstaw między nie łuk"));
}
}
Pose walker = start;
// curvature the chain is running at where the previous element ended, which
// is what a clothoid eases away from. It carries across the whole chain: two
// clothoids back to back are as ordinary as two arcs.
double k_prev = 0.0;
out.track.elements.reserve(track.elements.size());
for (const Element& element : track.elements) {
double k0 = 0.0;
double k1 = 0.0;
if (element.kind == Kind::Arc) {
if (element.radius <= kEps) {
out.diagnostics.push_back(
degenerate(track.id, element.id, "łuk musi mieć dodatni promień"));
out.track.complete = false;
break;
}
if (element.hand == 0) {
out.diagnostics.push_back(degenerate(
track.id, element.id, "łuk musi skręcać w lewo albo w prawo"));
out.track.complete = false;
break;
}
k0 = k1 = static_cast<double>(element.hand) / element.radius;
} else if (element.kind == Kind::Clothoid) {
k0 = k_prev;
k1 = end_curvature(element);
if (std::abs(k1 - k0) < kEps) {
out.diagnostics.push_back(degenerate(
track.id, element.id,
"krzywa przejściowa musi zmieniać krzywiznę — na jej końcu "
"jest ten sam promień, co na początku"));
out.track.complete = false;
break;
}
}
if (element.length <= kEps) {
out.diagnostics.push_back(
degenerate(track.id, element.id, "długość musi być dodatnia"));
out.track.complete = false;
break;
}
SolvedElement solved;
solved.id = element.id;
solved.kind = element.kind;
solved.start = walker;
solved.k0 = k0;
solved.k1 = k1;
solved.length = element.length;
std::vector<geometry::XY> points;
walker = geometry::layout_segment(k0, k1, element.length, walker, &points);
solved.end = walker;
solved.points.reserve(points.size());
for (const auto& point : points) {
solved.points.push_back(PlanPoint{point.x, point.y});
}
out.track.length += element.length;
out.track.elements.push_back(std::move(solved));
k_prev = k1;
}
out.track.end = walker;
for (const auto& element : out.track.elements) {
for (std::size_t i = out.track.centreline.empty() ? 0 : 1; i < element.points.size();
++i) {
out.track.centreline.push_back(element.points[i]);
}
}
return out;
}
} // namespace editor::plan

43
editor/plan_chain.cppm Normal file
View File

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

568
editor/plan_document_io.cpp Normal file
View File

@@ -0,0 +1,568 @@
/*
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/.
*/
module;
#include <optional>
#include <variant>
#include <string>
#include <charconv>
#include <cmath>
#include <cstdint>
#include <fstream>
#include <sstream>
#include <string_view>
#include <system_error>
#include <vector>
module eu07.editor.plan_document_io;
namespace editor::plan::io {
namespace {
using namespace editor::plan;
constexpr int kVersion = 7;
constexpr int kNoExportPath = 6; // before a plan remembered where it exports to
constexpr int kStraightOnly = 5; // before a placement could be bent onto a łuk
constexpr int kNoseless = 4; // the piece list, but before the blade carried its nose
constexpr int kLegacyTurnoutVersion = 3; // arcs + blade fields, before the piece list
std::string num(double value) {
char buffer[64];
const auto [end, error] = std::to_chars(buffer, buffer + sizeof(buffer), value);
if (error != std::errc{}) {
return "0";
}
return std::string{buffer, end};
}
std::string num(std::uint32_t value) { return std::to_string(value); }
/// One line of the file, split into tokens, with the tail kept whole so a name
/// may contain spaces.
struct Line {
std::vector<std::string_view> tokens;
std::string_view tail; ///< everything after the first token, verbatim
[[nodiscard]] std::string_view tag() const {
return tokens.empty() ? std::string_view{} : tokens.front();
}
[[nodiscard]] std::size_t size() const { return tokens.size(); }
};
Line split(std::string_view text) {
Line line;
std::size_t i = 0;
bool first = true;
while (i < text.size()) {
while (i < text.size() && (text[i] == ' ' || text[i] == '\t')) {
++i;
}
if (i >= text.size()) {
break;
}
if (!first && line.tail.empty()) {
line.tail = text.substr(i);
}
const std::size_t start = i;
while (i < text.size() && text[i] != ' ' && text[i] != '\t') {
++i;
}
line.tokens.push_back(text.substr(start, i - start));
first = false;
}
return line;
}
bool to_double(std::string_view token, double& out) {
const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out);
return error == std::errc{} && end == token.data() + token.size();
}
bool to_uint(std::string_view token, std::uint32_t& out) {
const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out);
return error == std::errc{} && end == token.data() + token.size();
}
bool to_int(std::string_view token, int& out) {
const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out);
return error == std::errc{} && end == token.data() + token.size();
}
/// A cursor over the file's lines, skipping blanks.
class Reader {
public:
explicit Reader(const std::string& text) {
std::istringstream stream{text};
std::string raw;
while (std::getline(stream, raw)) {
if (!raw.empty() && raw.back() == '\r') {
raw.pop_back();
}
storage_.push_back(std::move(raw));
}
}
/// The next non-blank line, or nothing at the end of the file.
std::optional<Line> next() {
while (at_ < storage_.size()) {
Line line = split(storage_[at_++]);
if (!line.tokens.empty()) {
return line;
}
}
return std::nullopt;
}
/// The tag of the next non-blank line, without consuming it. Optional
/// records — an element's hold — are recognised this way.
std::string_view peek_tag() {
while (at_ < storage_.size()) {
const Line line = split(storage_[at_]);
if (!line.tokens.empty()) {
return line.tokens.front();
}
++at_;
}
return {};
}
/// The next line, required to carry @p tag and at least @p tokens tokens.
std::optional<Line> next(std::string_view tag, std::size_t tokens) {
const std::optional<Line> line = next();
if (!line || line->tag() != tag || line->size() < tokens) {
return std::nullopt;
}
return line;
}
private:
std::vector<std::string> storage_;
std::size_t at_{0};
};
/// Reads a version-3 turnout type — arcs plus the old fixed blade fields — and
/// converts it to a piece list. The old geometry closed itself: the last arc spent
/// whatever deflection was left to the crossing angle, and the frog rail ran on to
/// the catalogue length. Both are worked out here once, so an old plan opens as the
/// same shape and is saved in the new form.
bool read_legacy_type(Reader& reader, TurnoutType& type) {
const std::optional<Line> line = reader.next("type", 9);
double entry_transition = 0.0;
double exit_transition = 0.0;
double pre_blade = 0.0;
double blade_angle = 0.0;
double blade_length = 0.0;
std::uint32_t arc_count = 0;
if (!line || !to_double(line->tokens[1], type.crossing_n) ||
!to_double(line->tokens[2], entry_transition) ||
!to_double(line->tokens[3], exit_transition) ||
!to_double(line->tokens[4], type.length) ||
!to_double(line->tokens[5], pre_blade) ||
!to_double(line->tokens[6], blade_angle) ||
!to_double(line->tokens[7], blade_length) || !to_uint(line->tokens[8], arc_count)) {
return false;
}
struct LegacyArc {
double radius{0.0};
double length{0.0};
double transition_to_next{0.0};
};
std::vector<LegacyArc> arcs;
for (std::uint32_t k = 0; k < arc_count; ++k) {
const std::optional<Line> arc_line = reader.next("tarc", 4);
LegacyArc arc;
if (!arc_line || !to_double(arc_line->tokens[1], arc.radius) ||
!to_double(arc_line->tokens[2], arc.length) ||
!to_double(arc_line->tokens[3], arc.transition_to_next)) {
return false;
}
arcs.push_back(arc);
}
if (arcs.empty() || arcs.front().radius <= 0.0 || arcs.back().radius <= 0.0) {
return false;
}
const double alfa = type.crossing_n > 0.0 ? std::atan(1.0 / type.crossing_n) : 0.0;
// heading and through-projection are accumulated as the pieces are appended, so
// the closing arc and the frog rail can be given their old lengths
double heading = 0.0;
double projection = 0.0; // along the through track
double offset = 0.0; // away from it
const auto append = [&](int part, double length, double r0, double r1, double turn_in) {
if (length <= 0.0) {
return;
}
type.pieces.push_back(TurnoutPieceSpec{part, length, r0, r1, turn_in});
heading += turn_in;
const double k0 = r0 > 0.0 ? 1.0 / r0 : 0.0;
const double k1 = r1 > 0.0 ? 1.0 / r1 : 0.0;
const double turned = 0.5 * (k0 + k1) * length;
// a straight projects whole; a turning piece projects its chord, which is
// shorter than its length by sin(theta/2)/(theta/2) and points at half the turn
const double half = 0.5 * turned;
const double chord = std::abs(half) < 1e-12 ? length : length * std::sin(half) / half;
projection += chord * std::cos(heading + half);
offset += chord * std::sin(heading + half);
heading += turned;
};
append(0, pre_blade, 0.0, 0.0, 0.0);
append(1, blade_length, 0.0, 0.0, blade_angle);
double pending_turn = blade_length > 0.0 ? 0.0 : blade_angle;
if (entry_transition > 0.0) {
append(2, entry_transition, 0.0, arcs.front().radius, pending_turn);
pending_turn = 0.0;
}
for (std::size_t k = 0; k + 1 < arcs.size(); ++k) {
append(2, arcs[k].length, arcs[k].radius, arcs[k].radius, pending_turn);
pending_turn = 0.0;
append(2, arcs[k].transition_to_next, arcs[k].radius, arcs[k + 1].radius, 0.0);
}
const double closing = alfa - heading - 0.5 * exit_transition / arcs.back().radius;
append(2, closing * arcs.back().radius, arcs.back().radius, arcs.back().radius,
pending_turn);
append(2, exit_transition, arcs.back().radius, 0.0, 0.0);
const double rail = type.length > 0.0 && std::cos(alfa) > 1e-6
? (type.length - projection) / std::cos(alfa)
: arcs.back().radius * std::tan(alfa * 0.5);
append(3, rail, 0.0, 0.0, 0.0);
// version 3 called the length the projection on the through track; version 4 uses
// the catalogue's own a + b, so it is restated here from where the frog ended up
if (std::abs(std::sin(alfa)) > 1e-9) {
const double back = offset / std::sin(alfa);
type.length = projection - back * std::cos(alfa) + back;
}
return !type.pieces.empty();
}
} // namespace
// ---------------------------------------------------------------------------
std::string serialize(const Document& document) {
std::string out;
out += "m0s " + std::to_string(kVersion) + "\n";
out += "next " + num(document.next_id) + "\n";
out += "view " + num(document.view_x) + " " + num(document.view_y) + " " +
num(document.view_extent) + "\n";
out += "origin " + std::string(document.origin_set ? "1" : "0") + " " +
std::string(document.georeferenced ? "1" : "0") + " " + num(document.origin_x) +
" " + num(document.origin_y) + "\n";
// the tail is kept whole, so a path may have spaces in it like any other name
out += "scn " + document.scn_path + "\n";
out += "types " + std::to_string(document.turnout_types.size()) + "\n";
for (const auto& type : document.turnout_types) {
out += "type " + num(type.crossing_n) + " " + num(type.length) + " " +
num(type.blade.tip_thickness) + " " + num(type.blade.nose) + " " +
num(type.blade.railtop_width) + " " + std::to_string(type.pieces.size()) +
"\n";
for (const auto& piece : type.pieces) {
out += "tseg " + std::to_string(piece.part) + " " + num(piece.length) + " " +
num(piece.radius_start) + " " + num(piece.radius_end) + " " +
num(piece.turn_in) + "\n";
}
out += "tname " + type.name + "\n";
}
out += "turnouts " + std::to_string(document.turnouts.size()) + "\n";
for (const auto& turnout : document.turnouts) {
out += "turnout " + num(static_cast<std::uint32_t>(turnout.id)) + " " +
num(static_cast<std::uint32_t>(turnout.on)) + " " + num(turnout.station) +
" " + std::to_string(turnout.hand) + " " +
std::string(turnout.facing ? "1" : "0") + " " +
std::string(turnout.bend_from_track ? "1" : "0") + " " + num(turnout.bend) +
"\n";
out += "tutype " + turnout.type + "\n";
}
out += "tracks " + std::to_string(document.tracks.size()) + "\n";
for (const auto& track : document.tracks) {
out += "track " + num(static_cast<std::uint32_t>(track.id)) + "\n";
out += "tkname " + track.name + "\n";
if (const auto* pose = std::get_if<AtPose>(&track.anchor)) {
out += "anchor pose " + num(pose->x) + " " + num(pose->y) + " " + num(pose->az) +
"\n";
} else {
const auto& port = std::get<AtPort>(track.anchor);
out += "anchor port " + num(static_cast<std::uint32_t>(port.turnout)) + " " +
std::string(port.port == Port::Frog ? "frog" : "start") + "\n";
}
out += "elems " + std::to_string(track.elements.size()) + "\n";
for (const auto& element : track.elements) {
const char* kind = element.kind == Kind::Line ? "line"
: element.kind == Kind::Arc ? "arc"
: "clot";
out += "elem " + num(static_cast<std::uint32_t>(element.id)) + " " + kind + " " +
num(element.radius) + " " + std::to_string(element.hand) + " " +
num(element.length) + "\n";
if (const auto* parallel = std::get_if<Parallel>(&element.hold)) {
out += "epar " + num(static_cast<std::uint32_t>(parallel->ref)) + " " +
num(parallel->offset) + "\n";
}
}
}
return out;
}
// ---------------------------------------------------------------------------
std::optional<Document> deserialize(const std::string& text) {
Reader reader{text};
Document document;
int version = 0;
{
const std::optional<Line> header = reader.next("m0s", 2);
if (!header || !to_int(header->tokens[1], version) ||
(version != kVersion && version != kNoExportPath && version != kStraightOnly &&
version != kNoseless && version != kLegacyTurnoutVersion)) {
return std::nullopt;
}
}
{
const std::optional<Line> line = reader.next("next", 2);
if (!line || !to_uint(line->tokens[1], document.next_id)) {
return std::nullopt;
}
}
{
const std::optional<Line> line = reader.next("view", 4);
if (!line || !to_double(line->tokens[1], document.view_x) ||
!to_double(line->tokens[2], document.view_y) ||
!to_double(line->tokens[3], document.view_extent)) {
return std::nullopt;
}
}
{
const std::optional<Line> line = reader.next("origin", 5);
if (!line || !to_double(line->tokens[3], document.origin_x) ||
!to_double(line->tokens[4], document.origin_y)) {
return std::nullopt;
}
document.origin_set = line->tokens[1] == "1";
document.georeferenced = line->tokens[2] == "1";
}
if (version > kNoExportPath) {
// an older plan never said where it exported to, and the host has its own
// answer for that
const std::optional<Line> line = reader.next("scn", 1);
if (!line) {
return std::nullopt;
}
document.scn_path = std::string{line->tail};
}
const auto count_of = [&](std::string_view tag, std::size_t& out) {
const std::optional<Line> line = reader.next(tag, 2);
std::uint32_t value = 0;
if (!line || !to_uint(line->tokens[1], value)) {
return false;
}
out = value;
return true;
};
std::size_t count = 0;
// ---- turnout types ----------------------------------------------------
if (!count_of("types", count)) {
return std::nullopt;
}
for (std::size_t i = 0; i < count; ++i) {
TurnoutType type;
if (version == kLegacyTurnoutVersion) {
if (!read_legacy_type(reader, type)) {
return std::nullopt;
}
} else {
// the nose (d) came late: before it, a type line went straight from the tip
// thickness to the railhead width
const bool has_nose = version > kNoseless;
const std::optional<Line> line = reader.next("type", has_nose ? 7 : 6);
std::uint32_t piece_count = 0;
if (!line || !to_double(line->tokens[1], type.crossing_n) ||
!to_double(line->tokens[2], type.length) ||
!to_double(line->tokens[3], type.blade.tip_thickness) ||
(has_nose && !to_double(line->tokens[4], type.blade.nose)) ||
!to_double(line->tokens[has_nose ? 5 : 4], type.blade.railtop_width) ||
!to_uint(line->tokens[has_nose ? 6 : 5], piece_count)) {
return std::nullopt;
}
for (std::uint32_t k = 0; k < piece_count; ++k) {
const std::optional<Line> piece_line = reader.next("tseg", 6);
TurnoutPieceSpec piece;
if (!piece_line || !to_int(piece_line->tokens[1], piece.part) ||
!to_double(piece_line->tokens[2], piece.length) ||
!to_double(piece_line->tokens[3], piece.radius_start) ||
!to_double(piece_line->tokens[4], piece.radius_end) ||
!to_double(piece_line->tokens[5], piece.turn_in)) {
return std::nullopt;
}
type.pieces.push_back(piece);
}
}
const std::optional<Line> name = reader.next("tname", 1);
if (!name) {
return std::nullopt;
}
type.name = std::string{name->tail};
document.turnout_types.push_back(std::move(type));
}
// ---- turnouts ---------------------------------------------------------
if (!count_of("turnouts", count)) {
return std::nullopt;
}
for (std::size_t i = 0; i < count; ++i) {
// łukowanie came late: before it every placement stood on the track as it
// found it, which is what taking the bend from the track means anyway
const bool has_bend = version > kStraightOnly;
const std::optional<Line> line = reader.next("turnout", has_bend ? 8 : 6);
TurnoutPlacement placement;
std::uint32_t id = 0;
std::uint32_t on = 0;
if (!line || !to_uint(line->tokens[1], id) || !to_uint(line->tokens[2], on) ||
!to_double(line->tokens[3], placement.station) ||
!to_int(line->tokens[4], placement.hand)) {
return std::nullopt;
}
placement.id = static_cast<TurnoutId>(id);
placement.on = static_cast<TrackId>(on);
placement.facing = line->tokens[5] == "1";
if (has_bend) {
placement.bend_from_track = line->tokens[6] == "1";
if (!to_double(line->tokens[7], placement.bend)) {
return std::nullopt;
}
}
const std::optional<Line> type = reader.next("tutype", 1);
if (!type) {
return std::nullopt;
}
placement.type = std::string{type->tail};
document.turnouts.push_back(std::move(placement));
}
// ---- tracks -----------------------------------------------------------
if (!count_of("tracks", count)) {
return std::nullopt;
}
for (std::size_t i = 0; i < count; ++i) {
const std::optional<Line> head = reader.next("track", 2);
Track track;
std::uint32_t id = 0;
if (!head || !to_uint(head->tokens[1], id)) {
return std::nullopt;
}
track.id = static_cast<TrackId>(id);
const std::optional<Line> name = reader.next("tkname", 1);
if (!name) {
return std::nullopt;
}
track.name = std::string{name->tail};
const std::optional<Line> anchor = reader.next("anchor", 2);
if (!anchor) {
return std::nullopt;
}
if (anchor->tokens[1] == "pose" && anchor->size() >= 5) {
AtPose pose;
if (!to_double(anchor->tokens[2], pose.x) ||
!to_double(anchor->tokens[3], pose.y) ||
!to_double(anchor->tokens[4], pose.az)) {
return std::nullopt;
}
track.anchor = pose;
} else if (anchor->tokens[1] == "port" && anchor->size() >= 4) {
std::uint32_t turnout_id = 0;
if (!to_uint(anchor->tokens[2], turnout_id)) {
return std::nullopt;
}
track.anchor = AtPort{static_cast<TurnoutId>(turnout_id),
anchor->tokens[3] == "start" ? Port::Start : Port::Frog};
} else {
return std::nullopt;
}
std::size_t element_count = 0;
if (!count_of("elems", element_count)) {
return std::nullopt;
}
for (std::size_t k = 0; k < element_count; ++k) {
const std::optional<Line> line = reader.next("elem", 6);
Element element;
std::uint32_t element_id = 0;
if (!line || !to_uint(line->tokens[1], element_id) ||
!to_double(line->tokens[3], element.radius) ||
!to_int(line->tokens[4], element.hand) ||
!to_double(line->tokens[5], element.length)) {
return std::nullopt;
}
element.id = static_cast<ElementId>(element_id);
if (line->tokens[2] == "line") {
element.kind = Kind::Line;
} else if (line->tokens[2] == "arc") {
element.kind = Kind::Arc;
} else if (line->tokens[2] == "clot") {
element.kind = Kind::Clothoid;
} else {
return std::nullopt;
}
// A hold, when there is one, follows its element immediately.
if (reader.peek_tag() == "epar") {
const std::optional<Line> hold = reader.next("epar", 3);
std::uint32_t ref = 0;
Parallel parallel;
if (!hold || !to_uint(hold->tokens[1], ref) ||
!to_double(hold->tokens[2], parallel.offset)) {
return std::nullopt;
}
parallel.ref = static_cast<ElementId>(ref);
element.hold = parallel;
}
track.elements.push_back(element);
}
document.tracks.push_back(std::move(track));
}
return document;
}
// ---------------------------------------------------------------------------
std::string default_project_path() { return "editor/plan.m0s"; }
bool save(const Document& document, const std::string& path) {
std::ofstream file{path, std::ios::binary | std::ios::trunc};
if (!file) {
return false;
}
const std::string text = serialize(document);
file.write(text.data(), static_cast<std::streamsize>(text.size()));
return static_cast<bool>(file);
}
std::optional<Document> load(const std::string& path) {
std::ifstream file{path, std::ios::binary};
if (!file) {
return std::nullopt;
}
std::ostringstream buffer;
buffer << file.rdbuf();
return deserialize(buffer.str());
}
} // namespace editor::plan::io

View File

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

64
editor/plan_join.cpp Normal file
View File

@@ -0,0 +1,64 @@
/*
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/.
*/
module;
#include <string>
#include <vector>
module eu07.editor.plan_join;
// TYMCZASOWA ZASLEPKA - patrz komentarz w join.hpp.
namespace editor::plan {
namespace {
constexpr const char* kMissing = "scalanie koncow niedostepne: brak modulu join";
} // namespace
const char* join_kind_name(JoinKind kind) {
switch (kind) {
case JoinKind::Straight:
return "prosta";
case JoinKind::Arc:
return "luk";
case JoinKind::ArcLineArc:
return "luk-prosta-luk";
case JoinKind::ArcArc:
return "luk-luk";
case JoinKind::None:
break;
}
return "nic";
}
JoinPlan plan_join(const geometry::Pose& leaving, const geometry::Pose& arriving,
const JoinSettings& settings) {
(void)leaving;
(void)arriving;
(void)settings;
JoinPlan plan;
plan.why = kMissing;
return plan;
}
JoinReport join_ends(Document& document, const Solution& solution, TrackId a, int a_end, TrackId b,
int b_end, const JoinSettings& settings) {
(void)document;
(void)solution;
(void)a;
(void)a_end;
(void)b;
(void)b_end;
(void)settings;
JoinReport report;
report.why = kMissing;
return report;
}
} // namespace editor::plan

75
editor/plan_join.cppm Normal file
View File

@@ -0,0 +1,75 @@
/*
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/.
*/
module;
#include <string>
#include <vector>
export module eu07.editor.plan_join;
export import eu07.editor.plan_segment;
export import eu07.editor.plan_model;
export import eu07.editor.plan_solution;
export {
// TYMCZASOWA ZASLEPKA. Modul scalania koncow (plan_join / join_ends) nie zostal nigdy
// zacommitowany: commit 4ddfeb02 dopisal `src/editor/join.cpp` do CMakeLists i `#include` w
// planpanel.cpp, ale samych plikow nie ma ani w drzewie, ani w historii, ani w stashach. Bez
// tego naglowka i join.cpp branch sie nie kompiluje wcale. Ta zaslepka odtwarza dokladnie to
// API, ktorego uzywa panel planu, i mowi "niedostepne" - zeby reszta edytora dala sie uruchomic.
// Do zastapienia prawdziwym rozwiazaniem geometrii (luk-prosta-luk) i chirurgia dokumentu.
namespace editor::plan {
/// Co stanelo miedzy dwoma koncami.
enum class JoinKind {
None,
Straight,
Arc,
ArcLineArc,
ArcArc,
};
[[nodiscard]] const char* join_kind_name(JoinKind kind);
/// Czym wolno gietc to, co lezy miedzy koncami.
struct JoinSettings {
double radius{300.0};
};
/// Propozycja, przed tknieciem dokumentu.
struct JoinPlan {
bool ok{false};
std::string why{};
JoinKind kind{JoinKind::None};
std::vector<Element> elements{};
double tightest{0.0};
};
[[nodiscard]] JoinPlan plan_join(const geometry::Pose& leaving,
const geometry::Pose& arriving,
const JoinSettings& settings);
/// Co scalenie kosztowalo.
struct JoinReport {
bool ok{false};
std::string why{};
JoinPlan join{};
int fused{0};
int turnouts{0};
bool reversed{false};
};
[[nodiscard]] JoinReport join_ends(Document& document, const Solution& solution, TrackId a,
int a_end, TrackId b, int b_end, const JoinSettings& settings);
} // namespace editor::plan
} // export

715
editor/plan_layout.cpp Normal file
View File

@@ -0,0 +1,715 @@
/*
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/.
*/
module;
#include <variant>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdio>
#include <numbers>
#include <string>
#include <vector>
module eu07.editor.plan_layout;
import eu07.editor.plan_turnout_geometry;
import eu07.editor.plan_chain;
import eu07.editor.plan_sketch;
namespace editor::plan {
namespace {
constexpr double kEps = 1e-9;
using geometry::Pose;
std::string num(double value, int decimals = 1) {
char buffer[64];
std::snprintf(buffer, sizeof(buffer), "%.*f", decimals, value);
return buffer;
}
// ---------------------------------------------------------------------------
// The dependency graph
// ---------------------------------------------------------------------------
enum class NodeKind { Turnout, Track };
struct Node {
NodeKind kind{NodeKind::Turnout};
std::size_t index{0};
std::vector<std::size_t> depends_on;
};
/// Everything a track needs laid before it can be laid itself: the turnout a
/// branch hangs off, and every track it holds an element parallel to — however
/// deep either goes. A hold that runs in a circle is caught as a cycle, the same
/// as anything else.
void track_dependencies(const Document& document, const Track& track,
const std::vector<std::size_t>& turnout_node,
const std::vector<std::size_t>& track_node, Node& node) {
if (const auto* at_port = std::get_if<AtPort>(&track.anchor)) {
for (std::size_t i = 0; i < document.turnouts.size(); ++i) {
if (document.turnouts[i].id == at_port->turnout) {
node.depends_on.push_back(turnout_node[i]);
break;
}
}
}
for (const auto& element : track.elements) {
const auto* parallel = std::get_if<Parallel>(&element.hold);
if (parallel == nullptr) {
continue;
}
for (std::size_t i = 0; i < document.tracks.size(); ++i) {
if (document.tracks[i].id == track.id) {
continue;
}
for (const auto& candidate : document.tracks[i].elements) {
if (candidate.id == parallel->ref) {
node.depends_on.push_back(track_node[i]);
break;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Holds: parallelism between elements
// ---------------------------------------------------------------------------
/// Where a held element has to be, and what shape it has to be, to lie at the
/// offset asked for. Everything is worked out from the reference as solved, so
/// this is geometry and not a guess.
struct Held {
bool ok{false};
Kind kind{Kind::Line};
double radius{0.0};
int hand{0};
Pose target{}; ///< the pose the held element must start on
double actual{0.0}; ///< the offset that came out, for a track that cannot move
std::string why; ///< why not, when !ok
};
/// The offset curve of @p reference at @p offset, taken where @p current is now.
///
/// Across the reference the answer is forced; along it, nothing is: the point
/// nearest to where the element already sits is the one taken, so holding a
/// track parallel never slides it sideways along the reference.
Held hold_against(const SolvedElement& reference, double offset, const Pose& current) {
Held held;
if (std::abs(reference.k1 - reference.k0) > kEps) {
held.why =
"równoległości nie da się trzymać do krzywej przejściowej — odsunięcie "
"krzywej przejściowej nie jest krzywą przejściową";
return held;
}
const Pose& base = reference.start;
// Which way this element runs along the reference. The offset is always
// measured to the left of the *reference's* travel, whichever way this one
// goes, so a track laid against the run of another still sits where it looks.
const double sense = (current.hx * base.hx + current.hy * base.hy) >= 0.0 ? 1.0 : -1.0;
const double nx = -base.hy;
const double ny = base.hx;
const double k = reference.k0;
if (std::abs(k) < kEps) {
const double px = base.x + nx * offset;
const double py = base.y + ny * offset;
const double hx = sense * base.hx;
const double hy = sense * base.hy;
const double along = (current.x - px) * hx + (current.y - py) * hy;
held.ok = true;
held.kind = Kind::Line;
held.target = Pose{px + hx * along, py + hy * along, hx, hy};
held.actual = (current.x - base.x) * nx + (current.y - base.y) * ny;
return held;
}
// Concentric with the reference: same centre, radius R∓d.
const double radius = 1.0 / std::abs(k);
const double cx = base.x + nx / k;
const double cy = base.y + ny / k;
const double curvature = k / (1.0 - offset * k);
if (!std::isfinite(curvature) || std::abs(1.0 - offset * k) < kEps) {
held.why = "odstęp " + num(offset, 2) + " m trafia w środek łuku R=" +
num(radius) + " m";
return held;
}
double ux = current.x - cx;
double uy = current.y - cy;
const double reach = std::hypot(ux, uy);
if (reach < kEps) {
held.why = "odcinek zaczyna się w środku łuku, do którego ma trzymać "
"równoległość";
return held;
}
ux /= reach;
uy /= reach;
const double held_radius = 1.0 / std::abs(curvature);
const double turn = curvature > 0.0 ? 1.0 : -1.0;
// The centre lies to the left of travel for a left-hand curve, so the
// heading follows from where the point sits on the circle.
const double hx = sense * turn * uy;
const double hy = sense * turn * -ux;
held.ok = true;
held.kind = Kind::Arc;
held.radius = held_radius;
held.hand = (sense * turn) > 0.0 ? 1 : -1;
held.target = Pose{cx + ux * held_radius, cy + uy * held_radius, hx, hy};
// What offset this element actually keeps, as the drawing stands.
held.actual = (k > 0.0 ? 1.0 : -1.0) * (radius - reach);
return held;
}
/// Moves a whole chain, as one rigid piece, so that the pose @p from becomes the
/// pose @p to. Returns the anchor pose to lay it from again.
Pose rigid_move(const Pose& anchor, const Pose& from, const Pose& to) {
const double cos_t = from.hx * to.hx + from.hy * to.hy;
const double sin_t = from.hx * to.hy - from.hy * to.hx;
const double dx = anchor.x - from.x;
const double dy = anchor.y - from.y;
return Pose{to.x + dx * cos_t - dy * sin_t, to.y + dx * sin_t + dy * cos_t,
anchor.hx * cos_t - anchor.hy * sin_t,
anchor.hx * sin_t + anchor.hy * cos_t};
}
// ---------------------------------------------------------------------------
// Turnouts
// ---------------------------------------------------------------------------
plan::TurnoutPart part_of(int part) {
switch (part) {
case 0: return plan::TurnoutPart::PreBlade;
case 1: return plan::TurnoutPart::Blade;
case 3: return plan::TurnoutPart::FrogRail;
default: return plan::TurnoutPart::Curve;
}
}
int mark_kind_for(plan::TurnoutPart part) {
switch (part) {
case plan::TurnoutPart::PreBlade: return 0; // PR
case plan::TurnoutPart::Blade: return 1; // ostrze iglicy
case plan::TurnoutPart::Curve: return 2; // pięta iglicy
case plan::TurnoutPart::FrogRail: return 4; // koniec krzywej
}
return 3;
}
} // namespace
// ---------------------------------------------------------------------------
plan::Turnout to_domain(const TurnoutType& type, int hand) {
plan::Turnout turnout{
plan::CrossingMark{type.crossing_n},
hand >= 0 ? plan::DivergeSide::Left : plan::DivergeSide::Right,
{},
type.length,
plan::Blade{type.blade.tip_thickness, type.blade.nose, type.blade.railtop_width},
};
turnout.path.reserve(type.pieces.size());
for (const auto& piece : type.pieces) {
turnout.path.push_back(plan::TurnoutPiece{part_of(piece.part), piece.length,
piece.radius_start, piece.radius_end,
piece.turn_in});
}
return turnout;
}
std::vector<TurnoutMark> turnout_marks(const plan::TurnoutGeometry& geometry,
const geometry::Pose& start) {
std::vector<TurnoutMark> marks;
if (!geometry.valid) {
return marks;
}
// PR stands whatever the path starts with, so it is said once here
marks.push_back(TurnoutMark{start.x, start.y, 0, 0.0});
geometry::Pose walker = start;
double station = 0.0;
plan::TurnoutPart previous = plan::TurnoutPart::PreBlade;
bool first = true;
for (const auto& segment : geometry.path) {
if (std::abs(segment.turn_in) > kEps) {
const double cos_t = std::cos(segment.turn_in);
const double sin_t = std::sin(segment.turn_in);
walker = geometry::Pose{walker.x, walker.y,
walker.hx * cos_t - walker.hy * sin_t,
walker.hx * sin_t + walker.hy * cos_t};
}
if ((first || segment.part != previous) &&
!(first && segment.part == plan::TurnoutPart::PreBlade)) {
marks.push_back(
TurnoutMark{walker.x, walker.y, mark_kind_for(segment.part), station});
}
previous = segment.part;
first = false;
walker = geometry::layout_segment(segment.k0, segment.k1, segment.length,
walker, nullptr);
station += segment.length;
}
marks.push_back(TurnoutMark{geometry.frog.x, geometry.frog.y, 5, station});
marks.push_back(TurnoutMark{start.x + start.hx * geometry.tangent_front,
start.y + start.hy * geometry.tangent_front, 6, 0.0});
// PR is on the through track already; this is where the switch ends on it, so the
// straight carries both ends of the rozjazd
marks.push_back(TurnoutMark{geometry.through_end.x, geometry.through_end.y, 9,
geometry.through_length});
// the ostrze itself is marked with the blade piece; what the drawing dimensions on
// top of it is where the planed nose ends (A, the rail u thick) and where the
// planing does
if (geometry.has_blade && geometry.nose_end > geometry.blade_station) {
marks.push_back(TurnoutMark{geometry.blade_nose_end.x, geometry.blade_nose_end.y,
7, geometry.nose_end});
}
if (geometry.has_blade && geometry.planing_length > 0.0) {
marks.push_back(TurnoutMark{geometry.blade_planed.x, geometry.blade_planed.y, 8,
geometry.planing_end});
}
return marks;
}
// ---------------------------------------------------------------------------
Solution solve(const Document& document) {
Solution solution;
const std::size_t turnout_count = document.turnouts.size();
const std::size_t track_count = document.tracks.size();
std::vector<Node> nodes;
nodes.reserve(turnout_count + track_count);
std::vector<std::size_t> turnout_node(turnout_count, 0);
std::vector<std::size_t> track_node(track_count, 0);
for (std::size_t i = 0; i < turnout_count; ++i) {
turnout_node[i] = nodes.size();
nodes.push_back(Node{NodeKind::Turnout, i, {}});
}
for (std::size_t i = 0; i < track_count; ++i) {
track_node[i] = nodes.size();
nodes.push_back(Node{NodeKind::Track, i, {}});
}
// Edges.
for (std::size_t i = 0; i < turnout_count; ++i) {
for (std::size_t k = 0; k < track_count; ++k) {
if (document.tracks[k].id == document.turnouts[i].on) {
nodes[turnout_node[i]].depends_on.push_back(track_node[k]);
break;
}
}
}
for (std::size_t i = 0; i < track_count; ++i) {
track_dependencies(document, document.tracks[i], turnout_node, track_node,
nodes[track_node[i]]);
}
// Topological order, Kahn's algorithm.
std::vector<std::size_t> remaining(nodes.size(), 0);
std::vector<std::vector<std::size_t>> dependents(nodes.size());
for (std::size_t i = 0; i < nodes.size(); ++i) {
remaining[i] = nodes[i].depends_on.size();
for (const std::size_t dependency : nodes[i].depends_on) {
dependents[dependency].push_back(i);
}
}
std::vector<std::size_t> order;
order.reserve(nodes.size());
for (std::size_t i = 0; i < nodes.size(); ++i) {
if (remaining[i] == 0) {
order.push_back(i);
}
}
for (std::size_t head = 0; head < order.size(); ++head) {
for (const std::size_t next : dependents[order[head]]) {
if (--remaining[next] == 0) {
order.push_back(next);
}
}
}
// Whatever never came free is in a cycle.
if (order.size() != nodes.size()) {
for (std::size_t i = 0; i < nodes.size(); ++i) {
if (remaining[i] == 0) {
continue;
}
Diagnostic diagnostic;
diagnostic.code = Code::CyclicDependency;
switch (nodes[i].kind) {
case NodeKind::Track:
diagnostic.track = document.tracks[nodes[i].index].id;
diagnostic.text = "tor „" + document.tracks[nodes[i].index].name +
"” zależy sam od siebie w kółko";
break;
case NodeKind::Turnout:
diagnostic.turnout = document.turnouts[nodes[i].index].id;
diagnostic.text = "rozjazd zależy sam od siebie w kółko";
break;
}
solution.diagnostics.push_back(std::move(diagnostic));
}
}
// ---- walk the order ---------------------------------------------------
for (const std::size_t index : order) {
const Node& node = nodes[index];
switch (node.kind) {
case NodeKind::Turnout: {
const TurnoutPlacement& placement = document.turnouts[node.index];
SolvedTurnout solved;
solved.id = placement.id;
const TurnoutType* type = find_type(document, placement.type);
if (type == nullptr) {
Diagnostic diagnostic;
diagnostic.code = Code::UnknownReference;
diagnostic.turnout = placement.id;
diagnostic.text = "rozjazd odwołuje się do typu „" + placement.type +
"”, którego nie ma w katalogu";
solution.diagnostics.push_back(std::move(diagnostic));
solution.turnouts.push_back(std::move(solved));
break;
}
const SolvedTrack* through = find_track(solution, placement.on);
Pose at{};
if (through == nullptr || !pose_at(*through, placement.station, at)) {
Diagnostic diagnostic;
diagnostic.code = Code::StationOffTrack;
diagnostic.turnout = placement.id;
diagnostic.track = placement.on;
diagnostic.text = "rozjazd stoi na torze, którego nie da się położyć";
solution.diagnostics.push_back(std::move(diagnostic));
solution.turnouts.push_back(std::move(solved));
break;
}
// łukowanie: the turnout is bent onto whatever the track does under
// it, unless it was asked to carry a bend of its own. Read the room
// here too — both answers come off the same element
double curvature = 0.0;
double behind = 0.0;
double ahead = 0.0;
const bool steady = steady_room(*through, placement.station, curvature, behind,
ahead);
double bend = placement.bend_from_track ? (steady ? curvature : 0.0)
: placement.bend;
if (!placement.facing) {
at = Pose{at.x, at.y, -at.hx, -at.hy};
// laid against the way the track runs, a left curve is a right one
bend = -bend;
}
const plan::TurnoutGeometry geometry =
plan::lay_turnout(at, to_domain(*type, placement.hand), bend);
if (!geometry.valid) {
Diagnostic diagnostic;
diagnostic.code = Code::TurnoutInvalid;
diagnostic.turnout = placement.id;
diagnostic.text =
bend != 0.0
? "typu „" + type->name + "” nie da się wygiąć na łuk R " +
num(std::abs(1.0 / bend)) +
" m: łukowanie wymaga jednego promienia i szyn łączących, "
"które je przyjmą"
: "typ „" + type->name +
"” nie daje się złożyć: sprawdź skos, promienie i iglicę";
solution.diagnostics.push_back(std::move(diagnostic));
solution.turnouts.push_back(std::move(solved));
break;
}
// a turnout is one piece of geometry bent to the one radius under it, so it has to
// stand on a single element and fit on it whole, or the drawing says something the
// ground would not. facing turnouts eat the room ahead, trailing ones behind
if (!steady) {
Diagnostic diagnostic;
diagnostic.code = Code::StationOffTrack;
diagnostic.turnout = placement.id;
diagnostic.track = placement.on;
diagnostic.text =
through->length > 0.0 && placement.station > through->length + 1e-6
? "rozjazd stoi " + num(placement.station - through->length) +
" m za końcem toru zasadniczego"
: "rozjazd stoi na krzywej przejściowej: nie ma jednego promienia, "
"na który dałoby się go wygiąć";
solution.diagnostics.push_back(std::move(diagnostic));
} else if (const double room = placement.facing ? ahead : behind;
geometry.through_length > room + 1e-6) {
Diagnostic diagnostic;
diagnostic.code = Code::StationOffTrack;
diagnostic.turnout = placement.id;
diagnostic.track = placement.on;
diagnostic.text = "rozjazd wychodzi " + num(geometry.through_length - room) +
" m poza odcinek toru, na którym stoi: potrzebuje " +
num(geometry.through_length) + " m, a zostało " + num(room);
solution.diagnostics.push_back(std::move(diagnostic));
}
solved.valid = true;
solved.bend = geometry.bend;
solved.diverging_curvature = geometry.diverging_curvature;
solved.bend_angle = geometry.bend_angle;
solved.start = at;
solved.frog = geometry.frog;
solved.tangent_front = geometry.tangent_front;
solved.tangent_back = geometry.tangent_back;
solved.diverging_length = geometry.diverging_length;
solved.through_length = geometry.through_length;
solved.has_blade = geometry.has_blade;
solved.blade_station = geometry.blade_station;
solved.nose_end = geometry.nose_end;
solved.planing_length = geometry.planing_length;
solved.blade_angle = geometry.blade_angle;
solved.blade_residual = geometry.blade_residual;
solved.angle_residual = geometry.angle_residual;
solved.length_residual = geometry.length_residual;
solved.marks = turnout_marks(geometry, at);
Pose walker = at;
for (const auto& segment : geometry.path) {
if (std::abs(segment.turn_in) > kEps) {
const double cos_t = std::cos(segment.turn_in);
const double sin_t = std::sin(segment.turn_in);
walker = Pose{walker.x, walker.y,
walker.hx * cos_t - walker.hy * sin_t,
walker.hx * sin_t + walker.hy * cos_t};
}
SolvedTurnoutSegment out_segment;
out_segment.start = walker;
out_segment.k0 = segment.k0;
out_segment.k1 = segment.k1;
out_segment.length = segment.length;
std::vector<geometry::XY> points;
walker = geometry::layout_segment(segment.k0, segment.k1,
segment.length, walker,
&points);
out_segment.part = segment.part;
out_segment.points.reserve(points.size());
for (const auto& point : points) {
out_segment.points.push_back(PlanPoint{point.x, point.y});
}
solved.path.push_back(std::move(out_segment));
}
// the list is laid as authored; whether it keeps the catalogue's
// promises is said out loud rather than fitted away
if (std::abs(geometry.angle_residual) > 1e-6 ||
(type->length > 0.0 && std::abs(geometry.length_residual) > 1e-3)) {
Diagnostic diagnostic;
diagnostic.code = Code::TurnoutNotClosed;
diagnostic.turnout = placement.id;
diagnostic.text =
"typ „" + type->name + "” nie domyka się: kąt o " +
num(geometry.angle_residual * 180.0 / std::numbers::pi) +
" st., długość o " + num(geometry.length_residual) + " m";
solution.diagnostics.push_back(std::move(diagnostic));
}
solution.turnouts.push_back(std::move(solved));
break;
}
case NodeKind::Track: {
const Track& track = document.tracks[node.index];
Pose start{0.0, 0.0, 0.0, 1.0};
bool anchored = true;
if (const auto* pose = std::get_if<AtPose>(&track.anchor)) {
start = Pose{pose->x, pose->y, std::sin(pose->az), std::cos(pose->az)};
} else if (const auto* at_port = std::get_if<AtPort>(&track.anchor)) {
const SolvedTurnout* turnout = find_turnout(solution, at_port->turnout);
if (turnout == nullptr || !turnout->valid) {
anchored = false;
} else {
start = at_port->port == Port::Frog ? turnout->frog : turnout->start;
}
}
if (!anchored) {
Diagnostic diagnostic;
diagnostic.code = Code::UnknownReference;
diagnostic.track = track.id;
diagnostic.text = "tor „" + track.name +
"” jest zakotwiczony w czymś, czego nie ma "
"albo czego nie dało się położyć";
solution.diagnostics.push_back(std::move(diagnostic));
SolvedTrack empty;
empty.id = track.id;
empty.complete = false;
solution.tracks.push_back(std::move(empty));
break;
}
// Laying is a walk, so a hold is resolved around it rather than
// inside it: lay once to see which way the held element runs,
// take its shape from the reference, lay again, and finally move
// the whole chain onto the offset it is held at.
const bool movable = std::holds_alternative<AtPose>(track.anchor);
Track resolved = track;
TrackOutcome outcome = lay_track(document, resolved, start);
std::size_t held_index = resolved.elements.size();
for (std::size_t e = 0; e < resolved.elements.size(); ++e) {
const auto* parallel = std::get_if<Parallel>(&resolved.elements[e].hold);
if (parallel == nullptr) {
continue;
}
const SolvedElement* reference =
find_element(solution, parallel->ref);
if (reference == nullptr || e >= outcome.track.elements.size()) {
Diagnostic diagnostic;
diagnostic.code = Code::UnknownReference;
diagnostic.track = track.id;
diagnostic.element = resolved.elements[e].id;
diagnostic.text =
"odcinek ma trzymać równoległość do czegoś, czego nie ma "
"albo czego nie dało się położyć";
solution.diagnostics.push_back(std::move(diagnostic));
continue;
}
const Held held = hold_against(*reference, parallel->offset,
outcome.track.elements[e].start);
if (!held.ok) {
Diagnostic diagnostic;
diagnostic.code = Code::DegenerateElement;
diagnostic.track = track.id;
diagnostic.element = resolved.elements[e].id;
diagnostic.text = held.why;
solution.diagnostics.push_back(std::move(diagnostic));
continue;
}
resolved.elements[e].kind = held.kind;
resolved.elements[e].radius = held.radius;
resolved.elements[e].hand = held.hand;
if (held_index == resolved.elements.size()) {
held_index = e;
} else if (movable) {
Diagnostic diagnostic;
diagnostic.code = Code::HoldConflict;
diagnostic.track = track.id;
diagnostic.element = resolved.elements[e].id;
diagnostic.text =
"tor „" + track.name +
"” trzyma równoległość dwoma odcinkami naraz; przystawiony "
"jest do pierwszego z nich";
solution.diagnostics.push_back(std::move(diagnostic));
}
}
if (held_index < resolved.elements.size()) {
outcome = lay_track(document, resolved, start);
if (held_index < outcome.track.elements.size()) {
const auto* parallel =
std::get_if<Parallel>(&resolved.elements[held_index].hold);
const SolvedElement* reference =
find_element(solution, parallel->ref);
const Held held =
hold_against(*reference, parallel->offset,
outcome.track.elements[held_index].start);
if (held.ok && movable) {
const Pose moved =
rigid_move(start, outcome.track.elements[held_index].start,
held.target);
outcome = lay_track(document, resolved, moved);
} else if (held.ok) {
// A track pinned to a turnout's port cannot be moved
// onto the offset, so the corner between the port and
// the held element has to take up the difference —
// the same fit as pulling a straight about by hand.
std::string why;
Skeleton skeleton;
Track fitted = resolved;
bool done = false;
if (resolved.elements[held_index].kind == Kind::Line &&
skeleton_of(resolved, outcome.track, skeleton, why)) {
std::size_t line_index = 0;
for (std::size_t e = 0; e < held_index; ++e) {
if (resolved.elements[e].kind == Kind::Line) {
++line_index;
}
}
if (line_index == 0) {
why = "przed odcinkiem trzymającym równoległość nie ma "
"łuku, który mógłby na nią wyprowadzić";
} else {
skeleton.lines[line_index] =
Support{held.target.x, held.target.y, held.target.hx,
held.target.hy};
if (line_index + 1 == skeleton.lines.size()) {
skeleton.end_x = held.target.x;
skeleton.end_y = held.target.y;
}
done = lay_along_skeleton(fitted, skeleton, why);
}
}
if (done) {
resolved = std::move(fitted);
outcome = lay_track(document, resolved, start);
} else if (std::abs(held.actual - parallel->offset) > 1e-3) {
Diagnostic diagnostic;
diagnostic.code = Code::OffsetNotHeld;
diagnostic.track = track.id;
diagnostic.element = resolved.elements[held_index].id;
diagnostic.text =
"tor „" + track.name +
"” jest przypięty do rozjazdu i nie da się go dopasować (" +
why + "): odstęp wychodzi " + num(held.actual, 2) +
" m zamiast " + num(parallel->offset, 2) + " m";
solution.diagnostics.push_back(std::move(diagnostic));
}
}
}
}
for (auto& diagnostic : outcome.diagnostics) {
solution.diagnostics.push_back(std::move(diagnostic));
}
solution.tracks.push_back(std::move(outcome.track));
break;
}
}
}
// Anything left out by a cycle still gets an entry, so the host can index by
// id without checking for holes.
for (const auto& track : document.tracks) {
if (find_track(solution, track.id) == nullptr) {
SolvedTrack empty;
empty.id = track.id;
empty.complete = false;
solution.tracks.push_back(std::move(empty));
}
}
for (const auto& turnout : document.turnouts) {
if (find_turnout(solution, turnout.id) == nullptr) {
SolvedTurnout empty;
empty.id = turnout.id;
solution.turnouts.push_back(std::move(empty));
}
}
return solution;
}
} // namespace editor::plan

52
editor/plan_layout.cppm Normal file
View File

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

88
editor/plan_model.cpp Normal file
View File

@@ -0,0 +1,88 @@
/*
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/.
*/
module;
#include <cstdint>
#include <string>
#include <variant>
#include <vector>
module eu07.editor.plan_model;
namespace editor::plan {
namespace {
template <typename Container, typename Id>
auto* lookup(Container& items, Id id) {
if (id == Id::none) {
return decltype(&items[0]){nullptr};
}
for (auto& item : items) {
if (item.id == id) {
return &item;
}
}
return decltype(&items[0]){nullptr};
}
} // namespace
ElementId mint_element(Document& document) { return static_cast<ElementId>(document.next_id++); }
TrackId mint_track(Document& document) { return static_cast<TrackId>(document.next_id++); }
TurnoutId mint_turnout(Document& document) { return static_cast<TurnoutId>(document.next_id++); }
const Track* find_track(const Document& document, TrackId id) {
return lookup(document.tracks, id);
}
Track* find_track(Document& document, TrackId id) { return lookup(document.tracks, id); }
const TurnoutPlacement* find_turnout(const Document& document, TurnoutId id) {
return lookup(document.turnouts, id);
}
TurnoutPlacement* find_turnout(Document& document, TurnoutId id) {
return lookup(document.turnouts, id);
}
const TurnoutType* find_type(const Document& document, const std::string& name) {
for (const auto& type : document.turnout_types) {
if (type.name == name) {
return &type;
}
}
return nullptr;
}
const Element* find_element(const Document& document, ElementId id) {
if (id == ElementId::none) {
return nullptr;
}
for (const auto& track : document.tracks) {
for (const auto& element : track.elements) {
if (element.id == id) {
return &element;
}
}
}
return nullptr;
}
const Track* find_element_track(const Document& document, ElementId id) {
if (id == ElementId::none) {
return nullptr;
}
for (const auto& track : document.tracks) {
for (const auto& element : track.elements) {
if (element.id == id) {
return &track;
}
}
}
return nullptr;
}
} // namespace editor::plan

235
editor/plan_model.cppm Normal file
View File

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

View File

@@ -0,0 +1,71 @@
/*
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/.
*/
module;
#include <cstdint>
#include <vector>
#include <cmath>
#include <cstddef>
#include <utility>
module eu07.editor.plan_rail_renderer;
namespace editor::plan::render {
namespace {
// Offsets a centreline polyline sideways by @p dist metres (signed) along the
// per-vertex normal. The vertex tangent uses a central difference of the
// neighbours (forward/backward at the ends); the left normal of (dx, dy) is
// (-dy, dx), matching the project's (east=x, north=y) convention.
std::vector<Point> offset(const std::vector<Point>& pts, double dist) {
const std::size_t m = pts.size();
std::vector<Point> out(m);
for (std::size_t i = 0; i < m; ++i) {
double dx = 1.0, dy = 0.0;
if (m >= 2) {
if (i == 0) {
dx = pts[1].x - pts[0].x;
dy = pts[1].y - pts[0].y;
} else if (i + 1 == m) {
dx = pts[i].x - pts[i - 1].x;
dy = pts[i].y - pts[i - 1].y;
} else {
dx = pts[i + 1].x - pts[i - 1].x;
dy = pts[i + 1].y - pts[i - 1].y;
}
}
const double len = std::hypot(dx, dy);
const double nx = len > 0.0 ? -dy / len : 0.0;
const double ny = len > 0.0 ? dx / len : 0.0;
out[i] = Point{pts[i].x + nx * dist, pts[i].y + ny * dist};
}
return out;
}
} // namespace
RailRenderer::RailRenderer(double gauge_metres) noexcept
: half_gauge_{gauge_metres * 0.5} {}
void RailRenderer::add(const CentrelineElement& element) {
for (const double side : {half_gauge_, -half_gauge_}) {
RailPolyline rail;
rail.kind = element.kind;
rail.element_id = element.element_id;
rail.owner_id = element.owner_id;
rail.from_turnout = element.from_turnout;
rail.length = element.length;
rail.radius_start = element.radius_start;
rail.radius_end = element.radius_end;
rail.points = offset(element.points, side);
rails_.push_back(std::move(rail));
}
}
} // namespace editor::plan::render

View File

@@ -0,0 +1,55 @@
/*
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/.
*/
module;
#include <cstdint>
#include <vector>
export module eu07.editor.plan_rail_renderer;
export import eu07.editor.plan_track_renderer;
export {
namespace editor::plan::render {
/// A drawable polyline produced by RailRenderer: one rail, carrying the logical
/// metadata of the centreline element it came from so the host can colour and
/// hit-test it.
struct RailPolyline {
ElementKind kind{ElementKind::Straight};
std::vector<Point> points;
std::uint32_t element_id{0};
std::uint32_t owner_id{0};
bool from_turnout{false};
double length{0.0};
double radius_start{0.0};
double radius_end{0.0};
};
/// Renders each centreline as two parallel rails a fixed gauge apart — the track
/// is drawn as its rails, not its axis. A concrete TrackRenderer; substituting a
/// different one (single-axis, SVG, ...) needs no change to the solver.
class RailRenderer : public TrackRenderer {
public:
/// @param gauge_metres distance between the two rails (default 1.5 m).
explicit RailRenderer(double gauge_metres = 1.5) noexcept;
void add(const CentrelineElement& element) override;
[[nodiscard]] const std::vector<RailPolyline>& rails() const noexcept {
return rails_;
}
private:
double half_gauge_;
std::vector<RailPolyline> rails_;
};
} // namespace editor::plan::render
} // export

132
editor/plan_render.cpp Normal file
View File

@@ -0,0 +1,132 @@
/*
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/.
*/
module;
#include <vector>
#include <cstdint>
#include <cmath>
#include <utility>
module eu07.editor.plan_render;
import eu07.editor.plan_rail_renderer;
namespace editor::plan {
namespace {
render::ElementKind kind_of(Kind kind) {
switch (kind) {
case Kind::Line: return render::ElementKind::Straight;
case Kind::Arc: return render::ElementKind::Arc;
case Kind::Clothoid: return render::ElementKind::Transition;
}
return render::ElementKind::Straight;
}
Kind kind_from(render::ElementKind kind) {
switch (kind) {
case render::ElementKind::Straight: return Kind::Line;
case render::ElementKind::Arc: return Kind::Arc;
case render::ElementKind::Transition: return Kind::Clothoid;
}
return Kind::Line;
}
double radius_of(double curvature) {
return std::abs(curvature) > 1e-9 ? 1.0 / std::abs(curvature) : 0.0;
}
render::CentrelineElement centreline_of(Kind kind, double k0, double k1, double length,
const std::vector<PlanPoint>& points,
std::uint32_t element_id, std::uint32_t owner_id,
bool from_turnout) {
render::CentrelineElement element;
element.kind = kind_of(kind);
element.element_id = element_id;
element.owner_id = owner_id;
element.from_turnout = from_turnout;
element.length = length;
element.radius_start = radius_of(k0);
element.radius_end = radius_of(k1);
element.points.reserve(points.size());
for (const auto& point : points) {
element.points.push_back(render::Point{point.x, point.y});
}
return element;
}
Kind kind_of_segment(double k0, double k1) {
if (std::abs(k0) < 1e-12 && std::abs(k1) < 1e-12) {
return Kind::Line;
}
return std::abs(k1 - k0) < 1e-12 ? Kind::Arc : Kind::Clothoid;
}
} // namespace
std::vector<PlanPolyline> render_rails(const Solution& solution, double gauge_metres) {
render::RailRenderer rail_renderer{gauge_metres};
render::TrackRenderer& renderer = rail_renderer;
// Owner ids are recorded alongside so a rail can be traced back without
// widening the render layer's vocabulary.
for (const auto& track : solution.tracks) {
for (const auto& element : track.elements) {
if (element.points.size() < 2) {
continue;
}
renderer.add(centreline_of(element.kind, element.k0, element.k1, element.length,
element.points,
static_cast<std::uint32_t>(element.id),
static_cast<std::uint32_t>(track.id), false));
}
}
for (const auto& turnout : solution.turnouts) {
if (!turnout.valid) {
continue;
}
std::uint32_t index = 0;
for (const auto& segment : turnout.path) {
if (segment.points.size() < 2) {
++index;
continue;
}
renderer.add(centreline_of(kind_of_segment(segment.k0, segment.k1), segment.k0,
segment.k1, segment.length, segment.points, index++,
static_cast<std::uint32_t>(turnout.id), true));
}
}
std::vector<PlanPolyline> out;
out.reserve(rail_renderer.rails().size());
for (const auto& rail : rail_renderer.rails()) {
PlanPolyline polyline;
polyline.kind = kind_from(rail.kind);
polyline.length = rail.length;
polyline.radius_start = rail.radius_start;
polyline.radius_end = rail.radius_end;
if (rail.from_turnout) {
polyline.turnout = static_cast<TurnoutId>(rail.owner_id);
polyline.piece = rail.element_id;
const SolvedTurnout* owner = find_turnout(solution, polyline.turnout);
if (owner != nullptr && polyline.piece < owner->path.size()) {
polyline.part = static_cast<int>(owner->path[polyline.piece].part);
}
} else {
polyline.element = static_cast<ElementId>(rail.element_id);
polyline.track = static_cast<TrackId>(rail.owner_id);
}
polyline.points.reserve(rail.points.size());
for (const auto& point : rail.points) {
polyline.points.push_back(PlanPoint{point.x, point.y});
}
out.push_back(std::move(polyline));
}
return out;
}
} // namespace editor::plan

47
editor/plan_render.cppm Normal file
View File

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

26
editor/plan_scene.cppm Normal file
View File

@@ -0,0 +1,26 @@
/*
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/.
*/
export module eu07.editor.plan_scene;
export {
namespace editor::plan::render {
/// A point in the project's CRS (metres for EPSG:2180).
struct Point {
double x;
double y;
};
/// Which kind of plan element a polyline came from (lets a GUI colour it).
enum class ElementKind { Straight, Arc, Transition };
} // namespace editor::plan::render
} // export

766
editor/plan_scn_export.cpp Normal file
View File

@@ -0,0 +1,766 @@
/*
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/.
*/
module;
#include <ostream>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <numbers>
#include <sstream>
#include <string>
#include <vector>
module eu07.editor.plan_scn_export;
namespace editor::plan::io {
namespace {
using editor::plan::Document;
using editor::plan::Kind;
using editor::plan::PlanPoint;
using editor::plan::SolvedElement;
using editor::plan::SolvedTrack;
using editor::plan::SolvedTurnout;
using editor::plan::TurnoutPlacement;
/// What `TTrack::Equal` calls one point, and so what the simulator will wire
/// together. Everything the exporter writes has to land well inside it.
constexpr double kJoin = 0.02;
/// Shorter than this and there is no piece worth writing.
constexpr double kShortest = 0.05;
struct World {
double x{0.0};
double y{0.0};
double z{0.0};
};
/// One path of a track node: two ends and the control offsets from them, in the
/// simulator's coordinates and already in the units the file wants.
struct Bezier {
World p1{}, cv1{}, cv2{}, p2{};
double radius{0.0};
};
/// A pose on a sampled axis: where it is and which way it runs.
struct Step {
PlanPoint at{};
double tx{1.0};
double ty{0.0};
};
// ---------------------------------------------------------------------------
// Sampled axes
// ---------------------------------------------------------------------------
double poly_length(const std::vector<PlanPoint>& points) {
double along = 0.0;
for (std::size_t i = 1; i < points.size(); ++i) {
along += std::hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
}
return along;
}
bool poly_step(const std::vector<PlanPoint>& points, double along, Step& out) {
if (points.size() < 2) {
return false;
}
double walked = 0.0;
for (std::size_t i = 1; i < points.size(); ++i) {
const double dx = points[i].x - points[i - 1].x;
const double dy = points[i].y - points[i - 1].y;
const double step = std::hypot(dx, dy);
if (step < 1e-12) {
continue;
}
if (walked + step >= along || i + 1 == points.size()) {
const double t = std::clamp((along - walked) / step, 0.0, 1.0);
out.at = PlanPoint{points[i - 1].x + dx * t, points[i - 1].y + dy * t};
out.tx = dx / step;
out.ty = dy / step;
return true;
}
walked += step;
}
return false;
}
/// The turnout's diverging path as one axis. The pieces are laid end to end, so
/// each one begins where the last left off and the join is dropped rather than
/// written twice.
std::vector<PlanPoint> diverging_axis(const SolvedTurnout& turnout) {
std::vector<PlanPoint> points;
for (const auto& segment : turnout.path) {
for (const auto& point : segment.points) {
if (!points.empty() &&
std::hypot(point.x - points.back().x, point.y - points.back().y) < 1e-9) {
continue;
}
points.push_back(point);
}
}
return points;
}
// ---------------------------------------------------------------------------
// Bézier
// ---------------------------------------------------------------------------
World to_scn(const PlanPoint& plan, const ScnExportOptions& options) {
return {plan.x - options.origin_east, options.rail_y,
-(plan.y - options.origin_north)};
}
/// A plan tangent as a control offset: the simulator's x is east and its z runs
/// south, and the file wants the offset from the endpoint rather than the point.
World offset_scn(double tx, double ty, double length) {
const double norm = std::hypot(tx, ty);
if (norm < 1e-12) {
return {0.0, 0.0, 0.0};
}
return {tx / norm * length, 0.0, -ty / norm * length};
}
/// The control offset that puts a cubic on a circle of @p radius through @p phi.
double arc_handle(double radius, double phi) {
const double turn = std::clamp(phi, 1e-6, std::numbers::pi);
return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(turn * 0.25);
}
double deflection(const Step& from, const Step& to) {
const double cross = from.tx * to.ty - from.ty * to.tx;
const double dot = from.tx * to.tx + from.ty * to.ty;
return std::abs(std::atan2(cross, dot));
}
/// A straight, or an arc of a known radius: the handles follow from the geometry
/// and nothing has to be fitted.
Bezier arc_bezier(const Step& from, const Step& to, double length, double radius,
const ScnExportOptions& options) {
Bezier bezier;
bezier.p1 = to_scn(from.at, options);
bezier.p2 = to_scn(to.at, options);
if (radius < 1.0) {
return bezier; // straight: no offsets, no radius
}
const double phi = std::clamp(length / radius, 1e-6, std::numbers::pi);
const double handle = arc_handle(radius, phi);
bezier.cv1 = offset_scn(from.tx, from.ty, handle);
bezier.cv2 = offset_scn(-to.tx, -to.ty, handle);
bezier.radius = radius;
return bezier;
}
/// The handle lengths that put a cubic closest to a sampled axis, ends and
/// tangents being already fixed. Least squares in the two lengths, which is what
/// a clothoid — and a whole turnout, which is several pieces under one curve —
/// needs, there being no one radius to take them from.
bool fit_handles(const std::vector<PlanPoint>& points, const PlanPoint& p0,
const PlanPoint& p3, const Step& from, const Step& to, double& out_first,
double& out_last) {
const double span = poly_length(points);
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);
Step step{};
if (!poly_step(points, span * t, step)) {
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 = step.at.x - ax;
const double ry = step.at.y - ay;
const double a0x = c0 * from.tx, a0y = c0 * from.ty;
const double a1x = -c1 * to.tx, a1y = -c1 * to.ty;
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 first = (m11 * b0 - m01 * b1) / det;
const double last = (-m01 * b0 + m00 * b1) / det;
if (first < 0.05 || last < 0.05) {
return false;
}
out_first = std::clamp(first, 0.05, span);
out_last = std::clamp(last, 0.05, span);
return true;
}
PlanPoint bezier_at(const PlanPoint& p0, const PlanPoint& c1, const PlanPoint& c2,
const PlanPoint& p3, double t) {
const double omt = 1.0 - t;
const double w0 = omt * omt * omt;
const double w1 = 3.0 * omt * omt * t;
const double w2 = 3.0 * omt * t * t;
const double w3 = t * t * t;
return PlanPoint{w0 * p0.x + w1 * c1.x + w2 * c2.x + w3 * p3.x,
w0 * p0.y + w1 * c1.y + w2 * c2.y + w3 * p3.y};
}
double to_segment(const PlanPoint& point, const PlanPoint& a, const PlanPoint& b) {
const double dx = b.x - a.x;
const double dy = b.y - a.y;
const double square = dx * dx + dy * dy;
const double t = square > 1e-18
? std::clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / square,
0.0, 1.0)
: 0.0;
return std::hypot(point.x - (a.x + dx * t), point.y - (a.y + dy * t));
}
/// How far the cubic strays from the axis it stands for. A turnout is several
/// pieces of geometry — przediglicowy, iglica, łuk, szyna łącząca — under one
/// curve in the scenery, so what that costs is measured against the curve
/// itself rather than against samples of it, which would only measure how far
/// apart the samples were put.
double curve_error(const std::vector<PlanPoint>& points, const PlanPoint& p0,
const PlanPoint& p3, const Step& from, const Step& to, double first,
double last) {
if (points.size() < 3) {
return 0.0;
}
const PlanPoint c1{p0.x + from.tx * first, p0.y + from.ty * first};
const PlanPoint c2{p3.x - to.tx * last, p3.y - to.ty * last};
constexpr int kSamples = 96;
std::vector<PlanPoint> curve;
curve.reserve(kSamples + 1);
for (int i = 0; i <= kSamples; ++i) {
curve.push_back(bezier_at(p0, c1, c2, p3, static_cast<double>(i) / kSamples));
}
double worst = 0.0;
for (const auto& point : points) {
double nearest = std::numeric_limits<double>::max();
for (std::size_t i = 1; i < curve.size(); ++i) {
nearest = std::min(nearest, to_segment(point, curve[i - 1], curve[i]));
}
worst = std::max(worst, nearest);
}
return worst;
}
/// Least squares pairs the axis with the curve by parameter, and a cubic does
/// not run at an even rate, so the handles it lands on are near but not the
/// closest. This walks downhill from there on the thing that actually matters:
/// how far the curve ever gets from the axis.
void refine_handles(const std::vector<PlanPoint>& points, const PlanPoint& p0,
const PlanPoint& p3, const Step& from, const Step& to, double& first,
double& last, double& out_error) {
double best = curve_error(points, p0, p3, from, to, first, last);
const double span = poly_length(points);
double step = std::max(0.25, span * 0.1);
int budget = 400;
while (step > 0.005 && budget > 0) {
bool better = false;
constexpr int kMoves[6][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {-1, -1}};
for (const auto& move : kMoves) {
const double trial_first = std::max(0.05, first + move[0] * step);
const double trial_last = std::max(0.05, last + move[1] * step);
const double error =
curve_error(points, p0, p3, from, to, trial_first, trial_last);
--budget;
if (error < best - 1e-6) {
best = error;
first = trial_first;
last = trial_last;
better = true;
break;
}
}
if (!better) {
step *= 0.5;
}
}
out_error = best;
}
/// A cubic through the ends of a sampled axis, tangent to it at both, fitted to
/// what runs between. Falls back on thirds, which is what a cubic with nothing
/// better to go on does anyway.
Bezier fitted_bezier(const std::vector<PlanPoint>& points, const Step& from, const Step& to,
double radius, const ScnExportOptions& options, double& out_error) {
const double span = poly_length(points);
double first = span / 3.0;
double last = span / 3.0;
if (!fit_handles(points, from.at, to.at, from, to, first, last)) {
const double phi = deflection(from, to);
if (radius >= 1.0 && phi > 1e-6) {
first = last = arc_handle(radius, phi);
}
}
refine_handles(points, from.at, to.at, from, to, first, last, out_error);
Bezier bezier;
bezier.p1 = to_scn(from.at, options);
bezier.p2 = to_scn(to.at, options);
bezier.cv1 = offset_scn(from.tx, from.ty, first);
bezier.cv2 = offset_scn(-to.tx, -to.ty, last);
bezier.radius = radius;
return bezier;
}
// ---------------------------------------------------------------------------
// Writing
// ---------------------------------------------------------------------------
std::string fixed(double value, int decimals = 4) {
std::ostringstream text;
text.setf(std::ios::fixed);
text.precision(decimals);
text << value;
return text.str();
}
void write_point(std::ostream& out, const World& point, const char* trailer) {
out << fixed(point.x) << ' ' << fixed(point.y) << ' ' << fixed(point.z) << trailer
<< '\n';
}
/// One `node ... track` with as many paths as its type takes: one for a plain
/// track, two for a switch — the straight route first, which is the one the
/// simulator holds a switch in when nothing has thrown it.
///
/// The two material slots do not mean the same thing on both. On plain track the
/// second is the podsypka with its sleepers, laid under the rails. On a switch
/// there is no ballast to lay — that comes as a model beside it — and the slot
/// draws the rails of the second route instead, so it takes the rail material
/// again. Giving it a ballast texture there puts sleepers up the diverging rails,
/// which is what the game's own sceneries avoid by writing the rails twice.
void write_track_node(std::ostream& out, const std::string& name, const char* type,
double length, const std::vector<Bezier>& paths) {
const bool points = std::string_view{type} == "switch";
out << "node -1 0 " << name << " track " << type << ' ' << fixed(length, 3)
<< (points ? " 1.435 0.24 15.0 20 2 flat vis\n" : " 1.435 0.15 25.0 20 0 flat vis\n");
out << (points ? " rail_screw_used1 6 rail_screw_used1 0.2 2.75 2.5\n"
: " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n");
for (const auto& path : paths) {
write_point(out, path.p1, " 0");
write_point(out, path.cv1, "");
write_point(out, path.cv2, "");
write_point(out, path.p2, " 0");
out << fixed(path.radius) << '\n';
}
out << "endtrack\n\n";
}
// ---------------------------------------------------------------------------
// Names
// ---------------------------------------------------------------------------
/// Whatever the drawing called it, in what a scenery file can be asked to hold.
std::string sanitised(const std::string& name) {
std::string out;
out.reserve(name.size());
for (const char letter : name) {
const bool plain = (letter >= '0' && letter <= '9') ||
(letter >= 'a' && letter <= 'z') ||
(letter >= 'A' && letter <= 'Z') || letter == '_' ||
letter == '-';
out.push_back(plain ? letter : '_');
}
while (!out.empty() && out.back() == '_') {
out.pop_back();
}
return out;
}
/// Names have to be unique: an event or a dispatcher naming a track has to reach
/// one of them.
class Names {
public:
std::string mint(std::string wanted) {
if (wanted.empty()) {
wanted = "tor";
}
std::string candidate = wanted;
int suffix = 1;
while (std::find(m_taken.begin(), m_taken.end(), candidate) != m_taken.end()) {
candidate = wanted + '_' + std::to_string(++suffix);
}
m_taken.push_back(candidate);
return candidate;
}
private:
std::vector<std::string> m_taken;
};
// ---------------------------------------------------------------------------
// Turnouts on their through track
// ---------------------------------------------------------------------------
/// A turnout as the through track sees it: the stretch of it the switch node
/// takes over, and which end of that stretch the blades stand at.
struct Span {
const SolvedTurnout* turnout{nullptr};
const TurnoutPlacement* placement{nullptr};
double from{0.0}; ///< lower station of the stretch
double to{0.0}; ///< upper station
bool facing{true};
};
/// What the catalogue calls the turnout's radius: the one the longest curved
/// piece runs at, which is the łuk itself and not an easement into it.
double catalogue_radius(const plan::TurnoutType& type) {
double longest = 0.0;
double radius = 0.0;
for (const auto& piece : type.pieces) {
if (piece.radius_start > 0.0 && piece.length > longest) {
longest = piece.length;
radius = piece.radius_start;
}
}
return radius;
}
/// The sterowanie a scenery hangs off a switch: the lantern that turns with it,
/// the drive's sound, and the events a dispatcher throws it by — `zwr01+` to the
/// straight route, `zwr01-` to the diverging one. The game keeps one include per
/// geometry and hand, so the nearest of them to what was drawn is asked for; a
/// MaSzyna without that file says so in its log and loads the rest.
std::string switch_include(double radius, int hand) {
struct Family {
double radius;
const char* suffix;
};
static constexpr Family kFamilies[]{
{190.0, "25r190"}, {300.0, "34r300"}, {500.0, "41r500"}, {1200.0, "65r1200"}};
const Family* nearest = &kFamilies[0];
for (const auto& family : kFamilies) {
if (std::abs(family.radius - radius) < std::abs(nearest->radius - radius)) {
nearest = &family;
}
}
return std::string(hand >= 0 ? "zwrl" : "zwrp") + nearest->suffix + ".inc";
}
/// Which way the switch faces, in the degrees an include's `rotate` takes: zero
/// along the simulator's +z, turning towards +x.
double heading_degrees(const geometry::Pose& pose) {
return std::atan2(pose.hx, -pose.hy) * 180.0 / std::numbers::pi;
}
/// The radius the diverging route runs at. Bent, it is the one the bend worked
/// out to; otherwise the curve piece's own.
double diverging_radius(const SolvedTurnout& turnout) {
if (std::abs(turnout.diverging_curvature) > 1e-9) {
return 1.0 / std::abs(turnout.diverging_curvature);
}
for (const auto& segment : turnout.path) {
if (std::abs(segment.k0) > 1e-9) {
return 1.0 / std::abs(segment.k0);
}
}
return 0.0;
}
} // namespace
// ---------------------------------------------------------------------------
ScnExportOptions resolve_scn_origin(const editor::plan::Solution& solution,
ScnExportOptions options) {
if (std::abs(options.origin_east) >= 1.0 || std::abs(options.origin_north) >= 1.0) {
return options;
}
for (const auto& track : solution.tracks) {
if (track.elements.empty() || track.elements.front().points.empty()) {
continue;
}
const auto& point = track.elements.front().points.front();
if (std::abs(point.x) > 10000.0 || std::abs(point.y) > 10000.0) {
options.origin_east = point.x;
options.origin_north = point.y;
}
break;
}
return options;
}
ScnExportResult export_scn(const Document& document,
const editor::plan::Solution& solution,
const ScnExportOptions& options, std::ostream& out) {
const auto opt = resolve_scn_origin(solution, options);
ScnExportResult result;
result.origin_east = opt.origin_east;
result.origin_north = opt.origin_north;
Names names;
// whatever was written first, for when nothing is long enough to stand a train on
std::string spare_track;
// every endpoint written, to be told afterwards which of them the simulator
// will and will not join up
struct Ending {
PlanPoint at{};
std::string node;
bool is_switch{false};
};
std::vector<Ending> endings;
out << "//$n plan\n";
out << "//$d origin " << fixed(opt.origin_east, 3) << ' '
<< fixed(opt.origin_north, 3) << "\n\n";
for (const auto& track : solution.tracks) {
const auto* authored = find_track(document, track.id);
const std::string base =
names.mint(sanitised(authored != nullptr ? authored->name : std::string{}));
// ---- where the switches sit on this track -------------------------
std::vector<Span> spans;
for (const auto& placement : document.turnouts) {
if (placement.on != track.id) {
continue;
}
const SolvedTurnout* turnout = find_turnout(solution, placement.id);
if (turnout == nullptr || !turnout->valid || turnout->path.empty()) {
result.warnings.push_back("rozjazd „" + placement.type +
"” nie dał się położyć i nie wyszedł do scn");
continue;
}
Span span;
span.turnout = turnout;
span.placement = &placement;
span.facing = placement.facing;
span.from = placement.facing ? placement.station
: placement.station - turnout->through_length;
span.to = placement.facing ? placement.station + turnout->through_length
: placement.station;
if (span.from < -kJoin || span.to > track.length + kJoin) {
result.warnings.push_back(
"rozjazd „" + placement.type + "” na torze „" + base +
"” wychodzi poza tor: zwrotnica wyszła, ale tor jej nie podpiera");
}
spans.push_back(span);
}
std::sort(spans.begin(), spans.end(),
[](const Span& left, const Span& right) { return left.from < right.from; });
for (std::size_t i = 1; i < spans.size(); ++i) {
if (spans[i].from < spans[i - 1].to - kJoin) {
result.warnings.push_back("dwa rozjazdy na torze „" + base +
"” zachodzą na siebie: w scn nakładają się "
"dwie zwrotnice");
}
}
// ---- the plain track, in the gaps the switches leave --------------
int piece = 0;
const auto write_range = [&](double from, double to) {
double walked = 0.0;
for (const SolvedElement& element : track.elements) {
const double span = poly_length(element.points);
const double starts = walked;
const double ends = walked + span;
walked = ends;
if (element.points.size() < 2 || span < kShortest) {
continue;
}
const double first = std::max(from, starts);
const double last = std::min(to, ends);
if (last - first < kShortest) {
continue;
}
const double radius =
element.kind == Kind::Arc && std::abs(element.k0) > 1e-9
? 1.0 / std::abs(element.k0)
: 0.0;
// an arc has to be cut into cubics no one of which turns too far
// to stay a circle
const int cuts =
radius >= 1.0
? std::max(1, static_cast<int>(std::ceil((last - first) / radius /
opt.max_arc_angle)))
: 1;
const double cut = (last - first) / static_cast<double>(cuts);
for (int c = 0; c < cuts; ++c) {
const double s0 = first - starts + cut * static_cast<double>(c);
const double s1 = c + 1 == cuts ? last - starts : s0 + cut;
Step from_step{}, to_step{};
if (!poly_step(element.points, s0, from_step) ||
!poly_step(element.points, s1, to_step)) {
continue;
}
const std::string name = names.mint(base + "_n" + std::to_string(++piece));
Bezier bezier;
if (element.kind == Kind::Clothoid) {
std::vector<PlanPoint> sampled;
constexpr int kSamples = 24;
for (int i = 0; i <= kSamples; ++i) {
Step step{};
if (poly_step(element.points,
s0 + (s1 - s0) * static_cast<double>(i) / kSamples,
step)) {
sampled.push_back(step.at);
}
}
double strayed = 0.0;
bezier = fitted_bezier(sampled, from_step, to_step, 0.0, opt, strayed);
} else {
bezier = arc_bezier(from_step, to_step, s1 - s0, radius, opt);
}
write_track_node(out, name, "normal", s1 - s0, {bezier});
endings.push_back(Ending{from_step.at, name, false});
endings.push_back(Ending{to_step.at, name, false});
// the train stands a way in from the end of a track, so the first
// piece long enough to hold it is the one it is put on
if (result.first_track_name.empty() &&
s1 - s0 >= opt.trainset_offset + 1.0) {
result.first_track_name = name;
}
if (spare_track.empty()) {
spare_track = name;
}
++result.tracks;
}
}
};
// ---- the switches themselves --------------------------------------
const auto write_switch = [&](const Span& span) {
const SolvedTurnout& turnout = *span.turnout;
// PR: where the blades are, and where both routes have to start from
// the same point or the simulator will not join them
const Step blade{PlanPoint{turnout.start.x, turnout.start.y}, turnout.start.hx,
turnout.start.hy};
const double exit_station = span.facing ? span.to : span.from;
geometry::Pose exit_pose{};
if (!pose_at(track, exit_station, exit_pose)) {
result.warnings.push_back("rozjazd „" + span.placement->type + "” na torze „" +
base + "” nie ma końca na torze zasadniczym");
return;
}
const double sense = span.facing ? 1.0 : -1.0;
const Step straight_end{PlanPoint{exit_pose.x, exit_pose.y},
sense * exit_pose.hx, sense * exit_pose.hy};
const double bend_radius =
std::abs(turnout.bend) > 1e-9 ? 1.0 / std::abs(turnout.bend) : 0.0;
const Bezier straight = arc_bezier(blade, straight_end, turnout.through_length,
bend_radius, opt);
const auto axis = diverging_axis(turnout);
const Step diverging_start{
axis.empty() ? blade.at : axis.front(),
turnout.path.front().start.hx, turnout.path.front().start.hy};
const Step frog{PlanPoint{turnout.frog.x, turnout.frog.y}, turnout.frog.hx,
turnout.frog.hy};
const double radius = diverging_radius(turnout);
double strayed = 0.0;
Bezier diverging =
fitted_bezier(axis, diverging_start, frog, radius, opt, strayed);
// the two routes leave one point: the fit is over the laid path, but
// what is written is the blade pose the straight route also starts on
diverging.p1 = straight.p1;
if (strayed > 0.05) {
result.warnings.push_back(
"rozjazd „" + span.placement->type +
"”: jedna krzywa scn odbiega od położonego toru zwrotnego o " +
fixed(strayed, 3) + " m");
}
// zwr01, zwr02: what a scenery calls its switches, and what the sterowanie
// includes hang their events off — `zwr01+` throws the one named zwr01
char number[16];
std::snprintf(number, sizeof(number), "zwr%02d", result.switches + 1);
const std::string name = names.mint(number);
// a switch is one curve per route in the scenery and a turnout is
// four pieces of geometry, so what the shortcut costs is written
// down beside it rather than left to be found by measuring
out << "// " << span.placement->type << ", tor zwrotny R " << fixed(radius, 1)
<< " m, odchyłka od położonej osi " << fixed(strayed, 3) << " m\n";
write_track_node(out, name, "switch", turnout.through_length,
{straight, diverging});
if (opt.switch_control) {
const auto* type = find_type(document, span.placement->type);
const double nominal = type != nullptr ? catalogue_radius(*type) : radius;
out << "include "
<< switch_include(nominal, span.placement->hand) << ' ' << name << ' '
<< fixed(straight.p1.x, 4) << " 0.0 " << fixed(straight.p1.z, 4) << ' '
<< fixed(heading_degrees(turnout.start), 1) << " end\n";
// t and shift+t at the blades: the game has no switch key of its own,
// it looks for a launcher standing close enough to the camera. the
// includes carry one commented out, so the scenery brings its own
out << "node -1 0 " << name << "_kl eventlauncher " << fixed(straight.p1.x, 4)
<< " 0.0 " << fixed(straight.p1.z, 4) << ' '
<< fixed(opt.switch_key_reach, 1) << " t 0 " << name << '+' << ' ' << name
<< "- end\n\n";
}
endings.push_back(Ending{blade.at, name, true});
endings.push_back(Ending{straight_end.at, name, true});
endings.push_back(Ending{frog.at, name, true});
++result.switches;
};
double cursor = 0.0;
for (const auto& span : spans) {
write_range(cursor, std::max(cursor, span.from));
write_switch(span);
cursor = std::max(cursor, span.to);
}
write_range(cursor, track.length);
}
// Nothing links nodes but their endpoints standing together, and only plain
// track goes looking for a neighbour. Two switches meeting head on is a
// drawing the scenery cannot hold, so it is said rather than left to be
// found by driving into it.
for (std::size_t i = 0; i < endings.size(); ++i) {
if (!endings[i].is_switch) {
continue;
}
for (std::size_t k = i + 1; k < endings.size(); ++k) {
if (!endings[k].is_switch || endings[k].node == endings[i].node) {
continue;
}
if (std::abs(endings[i].at.x - endings[k].at.x) < kJoin &&
std::abs(endings[i].at.y - endings[k].at.y) < kJoin) {
result.warnings.push_back(
"zwrotnice „" + endings[i].node + "” i „" + endings[k].node +
"” stykają się końcami: symulator łączy tylko przez zwykły tor, "
"wstaw między nie odcinek");
}
}
}
if (result.first_track_name.empty()) {
result.first_track_name = spare_track;
}
if (opt.trainset) {
out << "FirstInit\n\n";
if (!result.first_track_name.empty()) {
out << "trainset none " << result.first_track_name << ' '
<< fixed(opt.trainset_offset, 1) << " 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 editor::plan::io

View File

@@ -0,0 +1,87 @@
/*
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/.
*/
module;
#include <ostream>
#include <string>
#include <vector>
export module eu07.editor.plan_scn_export;
export import eu07.editor.plan_model;
export import eu07.editor.plan_solution;
export {
namespace editor::plan::io {
struct ScnExportOptions {
double origin_east{0.0};
double origin_north{0.0};
double rail_y{0.2};
/// How far one cubic may be asked to turn. A cubic stands in for an arc to
/// within about 2,7·10⁻⁴ R over a quarter circle, which is centimetres on a
/// main-line radius; a quarter of that turn is a thousandth of a millimetre
/// on anything a railway is built to.
double max_arc_angle{0.4};
/// Emit FirstInit and a lone locomotive on the first track written, so the
/// scenery can be loaded and driven without editing it by hand.
bool trainset{true};
/// How far along that track it stands, metres. Far enough in that the
/// vehicle is on the track and not hanging off its end.
double trainset_offset{20.0};
/// Ask each switch for the game's own sterowanie include: the lantern, the
/// drive's sound and the events that throw it by name.
bool switch_control{true};
/// How near the blades one has to stand for `t` and `shift+t` to throw the
/// switch, metres. Kept well under the distance between two of them, or one
/// key would throw the lot.
double switch_key_reach{12.0};
};
struct ScnExportResult {
int tracks{0}; ///< `track normal` nodes written
int switches{0}; ///< `track switch` nodes written
std::string first_track_name;
double origin_east{0.0};
double origin_north{0.0};
/// What the scenery does not say as well as the drawing did. The export is
/// written anyway: these are read by whoever asked for it, never fixed up
/// behind their back.
std::vector<std::string> warnings;
};
/// Writes the solved layout as MaSzyna scenery.
///
/// The axis comes straight out of the solution: the exporter used to average the
/// two drawn rails back together to recover it, which put the whole geometry
/// through the renderer and back.
///
/// A turnout is a `track switch` node, which is two Bézier curves leaving one
/// point, so the through track is cut around it: what runs from PR to KR belongs
/// to the switch, and the rest of the track is written as ordinary pieces either
/// side. The simulator wires all of it together by nothing but the endpoints
/// standing within two centimetres of each other, so every piece is cut from the
/// same laid geometry and the shared points are written out identical.
///
/// The document is needed for what is authored and not derived: the names the
/// nodes are written under, and which track each turnout stands on.
///
/// Height is still a constant and both roll slots are still zero — the vertical
/// alignment is a separate piece of work, and nothing in the editor produces one
/// yet.
[[nodiscard]] ScnExportResult export_scn(const plan::Document& document,
const plan::Solution& solution,
const ScnExportOptions& options,
std::ostream& out);
[[nodiscard]] ScnExportOptions resolve_scn_origin(const plan::Solution& solution,
ScnExportOptions options);
} // namespace editor::plan::io
} // export

103
editor/plan_segment.cppm Normal file
View File

@@ -0,0 +1,103 @@
/*
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/.
*/
module;
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <vector>
export module eu07.editor.plan_segment;
export {
namespace editor::plan::geometry {
struct XY {
double x;
double y;
};
/// Position plus unit heading, in (east=x, north=y).
struct Pose {
double x;
double y;
double hx;
double hy;
};
/// Lays out one segment of linearly varying signed curvature, starting at
/// @p start: a circular arc when @p k0 == @p k1, a clothoid otherwise, a
/// straight when both are zero. When @p out is non-null the sampled points
/// (including the start point) are appended to it. Returns the segment's end
/// pose.
///
/// Both the fitting solver and the renderer call this so their geometry is
/// bit-for-bit identical — the fitted tangent points then coincide exactly with
/// the drawn curve, with no lateral gap even for tight arcs.
inline Pose layout_segment(double k0, double k1, double length, Pose start,
std::vector<XY>* out) {
const double left_x = -start.hy;
const double left_y = start.hx;
const auto emit = [&](double local_x, double local_y) {
if (out != nullptr) {
out->push_back({start.x + local_x * start.hx + local_y * left_x,
start.y + local_x * start.hy + local_y * left_y});
}
};
emit(0.0, 0.0);
double end_x = 0.0;
double end_y = 0.0;
if (length > 0.0) {
const double turned = std::abs(0.5 * (k0 + k1) * length);
const int steps = std::clamp(
static_cast<int>(std::ceil(std::max(
length / 0.5, turned * 180.0 / std::numbers::pi / 2.0))),
8, 512);
const double ds = length / steps;
if (std::abs(k1 - k0) < 1e-12) {
const double k = k0;
if (std::abs(k) < 1e-12) {
end_x = length; // straight
emit(end_x, 0.0);
} else {
for (int i = 1; i <= steps; ++i) { // circular arc, exact
const double s = ds * i;
end_x = std::sin(k * s) / k;
end_y = (1.0 - std::cos(k * s)) / k;
emit(end_x, end_y);
}
}
} else {
const double slope = (k1 - k0) / (2.0 * length); // clothoid
for (int i = 1; i <= steps; ++i) {
const double s_mid = ds * (i - 0.5);
const double theta = k0 * s_mid + slope * s_mid * s_mid;
end_x += std::cos(theta) * ds;
end_y += std::sin(theta) * ds;
emit(end_x, end_y);
}
}
}
const double dtheta = 0.5 * (k0 + k1) * length;
const double cos_t = std::cos(dtheta);
const double sin_t = std::sin(dtheta);
return Pose{start.x + end_x * start.hx + end_y * left_x,
start.y + end_x * start.hy + end_y * left_y,
start.hx * cos_t - start.hy * sin_t,
start.hx * sin_t + start.hy * cos_t};
}
} // namespace editor::plan::geometry
} // export

505
editor/plan_sketch.cpp Normal file
View File

@@ -0,0 +1,505 @@
/*
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/.
*/
module;
#include <cmath>
#include <variant>
#include <cstdio>
#include <numbers>
#include <string>
#include <vector>
module eu07.editor.plan_sketch;
namespace editor::plan {
namespace {
constexpr double kEps = 1e-9;
} // namespace
using geometry::Pose;
Corner fit_corner(double in_hx, double in_hy, double out_hx, double out_hy, double radius) {
Corner corner;
if (radius <= kEps) {
return corner;
}
const double in_length = std::hypot(in_hx, in_hy);
const double out_length = std::hypot(out_hx, out_hy);
if (in_length < kEps || out_length < kEps) {
return corner;
}
in_hx /= in_length;
in_hy /= in_length;
out_hx /= out_length;
out_hy /= out_length;
// On the short branch: a corner never turns further than it actually bends.
const double deflection = std::atan2(in_hx * out_hy - in_hy * out_hx,
in_hx * out_hx + in_hy * out_hy);
const double turn = std::abs(deflection);
if (turn < 1e-6 || turn > std::numbers::pi - 1e-6) {
return corner;
}
corner.ok = true;
corner.deflection = deflection;
corner.hand = deflection > 0.0 ? 1 : -1;
corner.tangent = radius * std::tan(0.5 * turn);
corner.arc_length = radius * turn;
return corner;
}
Element make_arc(Document& document, double radius, int hand, double length) {
Element element;
element.id = mint_element(document);
element.kind = Kind::Arc;
element.radius = radius;
element.hand = hand >= 0 ? 1 : -1;
element.length = length;
return element;
}
Element make_line(Document& document, double length) {
Element element;
element.id = mint_element(document);
element.kind = Kind::Line;
element.length = length;
return element;
}
Element make_clothoid(Document& document, double end_radius, int hand, double length) {
Element element;
element.id = mint_element(document);
element.kind = Kind::Clothoid;
element.radius = end_radius;
element.hand = hand >= 0 ? 1 : -1;
element.length = length;
return element;
}
namespace {
std::string metres(double value) {
char buffer[64];
std::snprintf(buffer, sizeof(buffer), "%.1f", value);
return buffer;
}
/// Signed distance from a point to a line, positive to the left of travel.
double left_of(const Support& line, double x, double y) noexcept {
return -(x - line.px) * line.dy + (y - line.py) * line.dx;
}
/// How far along a line a point sits.
double along(const Support& line, double x, double y) noexcept {
return (x - line.px) * line.dx + (y - line.py) * line.dy;
}
} // namespace
namespace {
/// What sits between two straights: one or more arcs turning the same way, with
/// transition curves easing into them, between them and out of them. One arc is
/// the everyday corner; several of them are a basket curve, and nothing here
/// treats that as a special case.
struct Group {
std::vector<std::size_t> elements; ///< in order, between the two straights
std::vector<std::size_t> arcs; ///< the arcs among them
int hand{0};
};
/// Signed curvature an element ends on, the way the chain reads it.
double end_curvature(const Element& element) noexcept {
if (element.kind == Kind::Line || element.radius <= kEps) {
return 0.0;
}
return static_cast<double>(element.hand) / element.radius;
}
/// Cuts a chain into straights and the corners between them, or says why it is
/// not one. A corner may hang off either end, where there is no second straight
/// to fit it against: that is a track that starts or finishes mid-curve, which is
/// ordinary and is simply carried along as authored.
bool parse_chain(const Track& track, std::vector<std::size_t>& straights,
std::vector<Group>& groups, bool& has_head, std::string& why) {
straights.clear();
groups.clear();
has_head = false;
const std::size_t count = track.elements.size();
if (count == 0) {
why = "tor jest pusty";
return false;
}
std::size_t i = 0;
while (i < count) {
if (track.elements[i].kind == Kind::Line) {
if (!straights.empty() && straights.back() + 1 == i) {
why = "dwie proste stykają się ze sobą — to jedna prosta";
return false;
}
straights.push_back(i++);
continue;
}
// A corner: everything up to the next straight, turning one way.
Group group;
const std::size_t first = i;
while (i < count && track.elements[i].kind != Kind::Line) {
if (track.elements[i].kind == Kind::Arc) {
if (track.elements[i].hand == 0) {
why = "łuk nie mówi, w którą stronę skręca";
return false;
}
if (group.hand != 0 && track.elements[i].hand != group.hand) {
why = "łuki w jednym narożniku skręcają w różne strony — to nie jest "
"łuk koszowy, tylko esica, i nie ma jej co dopasowywać do dwóch "
"prostych";
return false;
}
group.hand = track.elements[i].hand;
group.arcs.push_back(i);
}
group.elements.push_back(i++);
}
if (group.arcs.empty()) {
why = "krzywa przejściowa nie wchodzi w żaden łuk — nie ma tu narożnika";
return false;
}
// Every transition must ease to whatever it runs into: to the radius of
// the arc after it, or out into the straight after it. Anything else is
// a kink dressed up as an easement.
for (const std::size_t at : group.elements) {
if (track.elements[at].kind != Kind::Clothoid || at + 1 >= count) {
continue;
}
const Element& next = track.elements[at + 1];
if (next.kind == Kind::Line) {
if (track.elements[at].radius > kEps) {
why = "krzywa przejściowa przed prostą musi wybiegać w prostą (R=0)";
return false;
}
} else if (std::abs(track.elements[at].radius - next.radius) > 1e-6 ||
track.elements[at].hand != next.hand) {
why = "krzywa przejściowa nie schodzi się z łukiem: kończy się na R=" +
metres(track.elements[at].radius) + " m, a łuk ma R=" +
metres(next.radius) + " m";
return false;
}
}
if (first == 0) {
// A corner before the first straight: it hangs off the anchor and is
// carried along, not fitted.
has_head = true;
} else if (i >= count) {
// And one after the last straight, which simply runs out.
break;
}
groups.push_back(std::move(group));
}
if (straights.empty()) {
why = "tor nie ma ani jednej prostej — nie ma na czym oprzeć dopasowania";
return false;
}
return true;
}
/// Lays a corner on its own, from the origin heading along +x, and reports where
/// it ends up. The arc lengths are given; everything else is as authored.
Pose lay_group(const Track& track, const Group& group, const std::vector<double>& arc_length) {
Pose pose{0.0, 0.0, 1.0, 0.0};
double k_prev = 0.0;
std::size_t arc_at = 0;
for (const std::size_t at : group.elements) {
const Element& element = track.elements[at];
const double k1 = end_curvature(element);
const double k0 = element.kind == Kind::Clothoid ? k_prev : k1;
const double length =
element.kind == Kind::Arc ? arc_length[arc_at++] : element.length;
pose = geometry::layout_segment(k0, k1, length, pose, nullptr);
k_prev = k1;
}
return pose;
}
/// The arc lengths a corner comes out with, and where it puts the chain down.
struct Fitted {
std::vector<double> arc_length;
double leaves{0.0}; ///< along the line in, where the straight gives way
double arrives{0.0}; ///< along the line out, where the straight picks up
};
/// Fits one corner between two lines. @p keep_at is where it sits when the lines
/// are parallel and nothing else decides.
bool fit_group(const Track& track, const Group& group, const Support& in,
const Support& out_line, double keep_at, std::size_t ordinal, Fitted& fitted,
std::string& why) {
const double side = group.hand >= 0 ? 1.0 : -1.0;
// What the corner was drawn as: the transitions turn what they turn, the arcs
// turn the rest. Both are read in the hand's convention, so both come out
// positive for a corner going the way its arcs go.
double spent = 0.0;
double authored = 0.0;
double k_prev = 0.0;
for (const std::size_t at : group.elements) {
const Element& element = track.elements[at];
const double k1 = end_curvature(element);
if (element.kind == Kind::Clothoid) {
spent += side * 0.5 * (k_prev + k1) * element.length;
} else {
if (element.radius <= kEps) {
why = "łuk w narożniku " + std::to_string(ordinal) + " nie ma promienia";
return false;
}
authored += element.length / element.radius;
}
k_prev = k1;
}
const double as_drawn = spent + authored;
// How far it has to turn now. The angle between two lines says that only to
// within a full circle, so the turn taken is the one nearest to what was
// drawn: a corner drawn at 240° stays about 240°, and one drawn at 10° stays
// about 10° instead of looping the long way round to 350°.
const double raw = std::atan2(in.dx * out_line.dy - in.dy * out_line.dx,
in.dx * out_line.dx + in.dy * out_line.dy);
double turn = side * raw;
turn += 2.0 * std::numbers::pi *
std::round((as_drawn - turn) / (2.0 * std::numbers::pi));
if (turn <= kEps) {
why = "narożnik " + std::to_string(ordinal) +
" musiałby skręcać w drugą stronę niż jego łuki";
return false;
}
// The transitions are authored, so what they turn is theirs; the arcs share
// out what is left, keeping the proportions they were drawn with. A basket
// curve therefore stays a basket curve — the same curve, tighter or wider,
// not one arc quietly swallowing the others.
const double left_for_arcs = turn - spent;
if (left_for_arcs <= kEps) {
why = "krzywe przejściowe w narożniku " + std::to_string(ordinal) +
" zużywają cały zwrot — na łuki nie zostaje nic";
return false;
}
fitted.arc_length.clear();
fitted.arc_length.reserve(group.arcs.size());
for (const std::size_t at : group.arcs) {
const Element& arc = track.elements[at];
const double share = authored > kEps ? (arc.length / arc.radius) / authored
: 1.0 / static_cast<double>(group.arcs.size());
fitted.arc_length.push_back(arc.radius * left_for_arcs * share);
}
// The corner is rigid now, so all that is left is sliding it along the line it
// leaves until it lands on the line it joins.
const Pose local = lay_group(track, group, fitted.arc_length);
const double reach_x = local.x * in.dx - local.y * in.dy;
const double reach_y = local.x * in.dy + local.y * in.dx;
const double determinant = in.dx * out_line.dy - in.dy * out_line.dx;
double at_line = 0.0;
if (std::abs(determinant) < 1e-12) {
// Parallel lines: the half circle, and the full one. Their spacing fixes
// the geometry outright and says nothing about where along them the
// corner sits, so it stays where it is.
at_line = keep_at;
const double miss = left_of(out_line, in.px + in.dx * at_line + reach_x,
in.py + in.dy * at_line + reach_y);
if (std::abs(miss) > 1e-6) {
why = "proste przy narożniku " + std::to_string(ordinal) +
" są równoległe, więc ich rozstaw wyznacza geometrię: tak dopasowany "
"narożnik mija prostą o " + metres(std::abs(miss)) + " m";
return false;
}
} else {
at_line = left_of(out_line, in.px + reach_x, in.py + reach_y) / determinant;
}
fitted.leaves = at_line;
fitted.arrives = along(out_line, in.px + in.dx * at_line + reach_x,
in.py + in.dy * at_line + reach_y);
return true;
}
} // namespace
void sync_joint(Track& track, std::size_t at) {
if (at >= track.elements.size()) {
return;
}
const Element& edited = track.elements[at];
if (edited.kind == Kind::Arc) {
// The transition that eases into this arc is the one before it: its end
// radius is this arc's radius. What comes after the arc eases into
// something else, and is none of this arc's business.
if (at == 0 || track.elements[at - 1].kind != Kind::Clothoid) {
return;
}
track.elements[at - 1].radius = edited.radius;
track.elements[at - 1].hand = edited.hand;
return;
}
if (edited.kind == Kind::Clothoid && edited.radius > kEps) {
// A transition that ends curved ends on an arc, and that arc is it.
if (at + 1 >= track.elements.size() ||
track.elements[at + 1].kind != Kind::Arc) {
return;
}
track.elements[at + 1].radius = edited.radius;
track.elements[at + 1].hand = edited.hand;
}
}
bool skeleton_of(const Track& track, const SolvedTrack& solved, Skeleton& out,
std::string& why) {
out.lines.clear();
if (solved.elements.size() != track.elements.size()) {
why = "tor nie jest położony";
return false;
}
std::vector<std::size_t> straights;
std::vector<Group> groups;
bool has_head = false;
if (!parse_chain(track, straights, groups, has_head, why)) {
return false;
}
for (const std::size_t i : straights) {
const Pose& start = solved.elements[i].start;
out.lines.push_back(Support{start.x, start.y, start.hx, start.hy});
}
out.start_x = solved.elements[straights.front()].start.x;
out.start_y = solved.elements[straights.front()].start.y;
out.end_x = solved.elements[straights.back()].end.x;
out.end_y = solved.elements[straights.back()].end.y;
out.pinned = !std::holds_alternative<AtPose>(track.anchor);
out.begins = solved.start;
return true;
}
bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why) {
std::vector<std::size_t> straights;
std::vector<Group> groups;
bool has_head = false;
if (!parse_chain(track, straights, groups, has_head, why)) {
return false;
}
if (skeleton.lines.size() != straights.size()) {
why = "proste nie pasują do łańcucha";
return false;
}
std::vector<Support> lines = skeleton.lines;
// Where each corner leaves the line before it and lands on the line after it,
// as distances along those lines. Nothing is written until every one of them
// works out.
std::vector<double> leaves(straights.size(), 0.0);
std::vector<double> arrives(straights.size(), 0.0);
std::vector<Fitted> fitted(straights.size() > 0 ? straights.size() - 1 : 0);
arrives[0] = along(lines[0], skeleton.start_x, skeleton.start_y);
if (has_head && skeleton.pinned) {
// The chain leaves a frog, so its first curve starts where it starts and
// ends where it ends: the line under the straight behind that curve is not
// the one it was drawn on but wherever the curve now comes out. What the
// change costs is then taken up by that straight's length, which is the
// one thing here free to give.
std::vector<double> as_authored;
for (const std::size_t at : groups.front().arcs) {
as_authored.push_back(track.elements[at].length);
}
const Pose local = lay_group(track, groups.front(), as_authored);
const Pose& from = skeleton.begins;
lines[0] = Support{from.x + local.x * from.hx - local.y * from.hy,
from.y + local.x * from.hy + local.y * from.hx,
local.hx * from.hx - local.hy * from.hy,
local.hx * from.hy + local.hy * from.hx};
arrives[0] = 0.0;
}
leaves[straights.size() - 1] =
along(lines[straights.size() - 1], skeleton.end_x, skeleton.end_y);
// The corners with a straight on either side are the ones that get fitted. A
// head or a tail corner has nothing on its far side, so it keeps what it was
// given and the chain simply walks through it.
const std::size_t first_interior = has_head ? 1u : 0u;
for (std::size_t i = 0; i + 1 < straights.size(); ++i) {
const Group& group = groups[first_interior + i];
const double keep_at = arrives[i] + track.elements[straights[i]].length;
if (!fit_group(track, group, lines[i], lines[i + 1], keep_at, i + 1, fitted[i], why)) {
return false;
}
leaves[i] = fitted[i].leaves;
arrives[i + 1] = fitted[i].arrives;
}
std::vector<double> straight_length(straights.size(), 0.0);
for (std::size_t i = 0; i < straights.size(); ++i) {
const double left = leaves[i] - arrives[i];
if (left <= kEps) {
why = "narożniki zachodzą na siebie na prostej " + std::to_string(i + 1) +
": brakuje " + metres(-left) + " m";
return false;
}
straight_length[i] = left;
}
// Where the chain has to start for all of that to stand where it stands. With
// a corner hanging off the front, that is worked out by laying it and putting
// its far end on the first straight.
const Pose head_end{lines[0].px + lines[0].dx * arrives[0],
lines[0].py + lines[0].dy * arrives[0], lines[0].dx, lines[0].dy};
Pose begins = head_end;
if (has_head) {
std::vector<double> as_authored;
for (const std::size_t at : groups.front().arcs) {
as_authored.push_back(track.elements[at].length);
}
const Pose local = lay_group(track, groups.front(), as_authored);
// Undo the corner: rotate its local end onto the pose it has to reach,
// and carry its local start along.
const double cos_t = local.hx * head_end.hx + local.hy * head_end.hy;
const double sin_t = local.hx * head_end.hy - local.hy * head_end.hx;
// the corner's local start heads along +x, so turning it by the same angle
// gives the heading the chain has to set off on
begins = Pose{head_end.x - (local.x * cos_t - local.y * sin_t),
head_end.y - (local.x * sin_t + local.y * cos_t),
cos_t, sin_t};
}
if (std::holds_alternative<AtPose>(track.anchor)) {
track.anchor = AtPose{begins.x, begins.y, std::atan2(begins.hx, begins.hy)};
} else if (std::hypot(begins.x - skeleton.begins.x, begins.y - skeleton.begins.y) > 1e-6) {
why = "tor jest przypięty do rozjazdu — jego początku nie da się przesunąć";
return false;
}
for (std::size_t i = 0; i < straights.size(); ++i) {
track.elements[straights[i]].length = straight_length[i];
}
for (std::size_t i = 0; i + 1 < straights.size(); ++i) {
const Group& group = groups[first_interior + i];
for (std::size_t k = 0; k < group.arcs.size(); ++k) {
track.elements[group.arcs[k]].length = fitted[i].arc_length[k];
}
}
return true;
}
} // namespace editor::plan

137
editor/plan_sketch.cppm Normal file
View File

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

129
editor/plan_solution.cpp Normal file
View File

@@ -0,0 +1,129 @@
/*
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/.
*/
module;
#include <string>
#include <vector>
#include <algorithm>
#include <cmath>
module eu07.editor.plan_solution;
namespace editor::plan {
const char* code_name(Code code) {
switch (code) {
case Code::UnknownReference: return "UnknownReference";
case Code::CyclicDependency: return "CyclicDependency";
case Code::DegenerateElement: return "DegenerateElement";
case Code::TurnoutInvalid: return "TurnoutInvalid";
case Code::TurnoutNotClosed: return "TurnoutNotClosed";
case Code::StationOffTrack: return "StationOffTrack";
case Code::TouchingStraights: return "TouchingStraights";
case Code::HoldConflict: return "HoldConflict";
case Code::OffsetNotHeld: return "OffsetNotHeld";
}
return "Unknown";
}
const SolvedTrack* find_track(const Solution& solution, TrackId id) {
if (id == TrackId::none) {
return nullptr;
}
for (const auto& track : solution.tracks) {
if (track.id == id) {
return &track;
}
}
return nullptr;
}
const SolvedTurnout* find_turnout(const Solution& solution, TurnoutId id) {
if (id == TurnoutId::none) {
return nullptr;
}
for (const auto& turnout : solution.turnouts) {
if (turnout.id == id) {
return &turnout;
}
}
return nullptr;
}
const SolvedElement* find_element(const Solution& solution, ElementId id) {
if (id == ElementId::none) {
return nullptr;
}
for (const auto& track : solution.tracks) {
for (const auto& element : track.elements) {
if (element.id == id) {
return &element;
}
}
}
return nullptr;
}
bool pose_at(const SolvedTrack& track, double station, geometry::Pose& out) {
const auto& line = track.centreline;
if (line.empty()) {
return false;
}
if (line.size() == 1) {
out = geometry::Pose{line[0].x, line[0].y, track.start.hx, track.start.hy};
return true;
}
double walked = 0.0;
for (std::size_t i = 1; i < line.size(); ++i) {
const double dx = line[i].x - line[i - 1].x;
const double dy = line[i].y - line[i - 1].y;
const double step = std::hypot(dx, dy);
if (step <= 0.0) {
continue;
}
if (station <= walked + step) {
const double t = std::clamp((station - walked) / step, 0.0, 1.0);
out = geometry::Pose{line[i - 1].x + dx * t, line[i - 1].y + dy * t,
dx / step, dy / step};
return true;
}
walked += step;
}
out = track.end;
return true;
}
bool steady_room(const SolvedTrack& track, double station, double& curvature, double& behind,
double& ahead) {
constexpr double kEps = 1e-6;
if (station < -kEps) {
return false;
}
double walked = 0.0;
for (std::size_t i = 0; i < track.elements.size(); ++i) {
const SolvedElement& element = track.elements[i];
const double ends = walked + element.length;
// a station sitting exactly on a joint belongs to the element ahead of it
const bool last = i + 1 == track.elements.size();
if (station < ends - kEps || (last && station <= ends + kEps)) {
if (std::abs(element.k1 - element.k0) > 1e-12) {
return false; // a clothoid: no one radius to bend a turnout to
}
curvature = element.k0;
behind = station - walked;
ahead = ends - station;
return true;
}
walked = ends;
}
return false;
}
} // namespace editor::plan

181
editor/plan_solution.cppm Normal file
View File

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

275
editor/plan_tilecache.cpp Normal file
View File

@@ -0,0 +1,275 @@
/*
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/.
*/
module;
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
#include <algorithm>
#include <charconv>
#include <cmath>
#include <limits>
#include <numeric>
#include <utility>
module eu07.editor.plan_tilecache;
namespace editor::plan {
namespace {
// Shortest round-trippable, locale-independent representation of a double —
// same convention as editor::plan::io so URLs are stable and precise.
std::string num(double value) {
char buffer[64];
const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value);
return std::string(buffer, result.ptr);
}
// Maximum number of retry attempts per tile after the initial fetch fails.
constexpr int kMaxRetries = 10;
} // namespace
// --- TileGrid ------------------------------------------------------------
double TileGrid::cell_metres(const TileKey& key) noexcept {
return key.zoom >= kCellZoomMin ? static_cast<double>(key.zoom) : kCellMetres;
}
double TileGrid::resolution(int zoom) noexcept {
return tile_span(zoom) / static_cast<double>(kTilePixels);
}
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();
}
if (value >= static_cast<double>(std::numeric_limits<int>::max())) {
return std::numeric_limits<int>::max();
}
return static_cast<int>(value);
};
return TileKey{key_zoom, safe_index(x, kOriginX), safe_index(y, kOriginY)};
}
TileBBox TileGrid::bbox(const TileKey& key) noexcept {
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,
int max_tiles) {
std::vector<TileKey> tiles;
if (max_tiles <= 0 || !std::isfinite(view.min_x) ||
!std::isfinite(view.min_y) || !std::isfinite(view.max_x) ||
!std::isfinite(view.max_y)) {
return tiles;
}
const double min_x = std::min(view.min_x, view.max_x);
const double max_x = std::max(view.min_x, view.max_x);
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, 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;
const Wide height = static_cast<Wide>(hi.y) - lo.y + 1;
if (width <= 0 || height <= 0) return tiles;
Wide columns = width;
Wide rows = height;
const Wide cap = max_tiles;
if (height > cap || width > cap / height) {
// A fixed 100 m grid can contain millions of cells when zoomed far out.
// Return a small, viewport-centred window instead of walking from a
// remote corner or allocating/requesting the whole range.
const long double aspect =
static_cast<long double>(width) / static_cast<long double>(height);
columns = std::clamp<Wide>(
static_cast<Wide>(std::sqrt(static_cast<long double>(cap) * aspect)),
1, std::min(width, cap));
rows = std::clamp<Wide>(cap / columns, 1, height);
}
const Wide centre_x =
std::midpoint(static_cast<Wide>(lo.x), static_cast<Wide>(hi.x));
const Wide centre_y =
std::midpoint(static_cast<Wide>(lo.y), static_cast<Wide>(hi.y));
const Wide first_col = std::clamp(
centre_x - columns / 2, static_cast<Wide>(lo.x),
static_cast<Wide>(hi.x) - columns + 1);
const Wide first_row = std::clamp(
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{key_zoom, static_cast<int>(col), static_cast<int>(row)});
}
}
return tiles;
}
// --- WmsConfig -----------------------------------------------------------
WmsConfig WmsConfig::geoportal_ortho() {
WmsConfig cfg;
// 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";
cfg.format = "image/jpeg";
return cfg;
}
WmsConfig WmsConfig::geoportal_topo() {
WmsConfig cfg;
cfg.base_url =
"https://mapy.geoportal.gov.pl/wss/service/img/guest/TOPO/MapServer/WMSServer";
cfg.layers = "Raster";
cfg.format = "image/png";
return cfg;
}
std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box) {
std::string url = cfg.base_url;
url += (url.find('?') == std::string::npos) ? '?' : '&';
url += "SERVICE=WMS";
url += "&VERSION=" + cfg.version;
url += "&REQUEST=GetMap";
url += "&LAYERS=" + cfg.layers;
url += "&STYLES=";
url += "&SRS=" + cfg.srs;
url += "&FORMAT=" + cfg.format;
url += "&WIDTH=" + std::to_string(cfg.tile_pixels);
url += "&HEIGHT=" + std::to_string(cfg.tile_pixels);
// WMS 1.1.1: BBOX = minx,miny,maxx,maxy in the SRS axis order (easting,
// northing for EPSG:2180).
url += "&BBOX=" + num(box.min_x) + "," + num(box.min_y) + "," + num(box.max_x) +
"," + num(box.max_y);
return url;
}
// --- TileService ---------------------------------------------------------
TileService::TileService(WmsConfig cfg) : cfg_{std::move(cfg)} {}
std::string TileService::url_for(const TileKey& key) const {
return wms_getmap_url(cfg_, TileGrid::bbox(key));
}
bool TileService::has(const TileKey& key) const {
const auto it = cache_.find(key);
return it != cache_.end() && it->second.status == TileStatus::Ready;
}
const TileImage* TileService::peek(const TileKey& key) const {
const auto it = cache_.find(key);
if (it != cache_.end() && it->second.status == TileStatus::Ready) {
return &it->second;
}
return nullptr;
}
void TileService::store(const TileKey& key, std::vector<std::uint8_t> bytes,
std::string content_type) {
TileImage& image = cache_[key];
image.status = TileStatus::Ready;
image.bytes = std::move(bytes);
image.content_type = std::move(content_type);
}
TileStatus TileService::request(const TileKey& key) {
const auto it = cache_.find(key);
if (it != cache_.end()) {
// A tile that gave up (10 failed attempts) is retried the next time it is
// asked for (a pan/zoom/toggle), so the underlay self-heals once the
// endpoint is reachable again — no page reload needed.
if (it->second.status == TileStatus::Failed) {
it->second.attempts = 0;
it->second.status = TileStatus::Loading;
begin_fetch(key);
}
return it->second.status; // otherwise: hit, no network
}
cache_[key].status = TileStatus::Loading;
begin_fetch(key);
return cache_[key].status;
}
void TileService::complete_fetch(const TileKey& key, bool ok,
std::vector<std::uint8_t> bytes,
std::string content_type) {
if (ok) {
store(key, std::move(bytes), std::move(content_type));
if (on_ready_) on_ready_(key);
return;
}
// Retry up to kMaxRetries times per tile; after that give up (Failed).
TileImage& image = cache_[key];
if (++image.attempts <= kMaxRetries) {
image.status = TileStatus::Loading;
schedule_retry(key);
} else {
// Give up quietly: do NOT notify the host, so a Failed tile does not by
// itself trigger a redraw → re-request → refetch loop in the background.
// It is retried only on the next user-driven request (see request()).
image.status = TileStatus::Failed;
}
}
void TileService::begin_fetch(const TileKey& key) {
const std::string url = url_for(key);
// 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,
std::string content_type) {
complete_fetch(captured, ok, std::move(bytes), std::move(content_type));
});
return;
}
complete_fetch(key, false, {}, {});
}
void TileService::retry_now(const TileKey& key) { begin_fetch(key); }
void TileService::schedule_retry(const TileKey& key) {
// Re-issue at once; the injected fetcher owns the timing, so a fetcher that
// eventually succeeds ends the retry loop.
begin_fetch(key);
}
} // namespace editor::plan

195
editor/plan_tilecache.cppm Normal file
View File

@@ -0,0 +1,195 @@
/*
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/.
*/
module;
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
export module eu07.editor.plan_tilecache;
export {
namespace editor::plan {
/// A stable tile in the EPSG:2180 tiling pyramid. Tiles are addressed by their
/// zoom level and integer column/row, so the same ground square always maps to
/// the same key regardless of the current viewport — that is what makes the
/// cache reusable across pans and zooms.
struct TileKey {
int zoom{0};
int x{0}; ///< column (increases with easting)
int y{0}; ///< row (increases with northing)
friend bool operator==(const TileKey&, const TileKey&) = default;
};
struct TileKeyHash {
std::size_t operator()(const TileKey& k) const noexcept {
// Mix the three small integers into one hash.
std::uint64_t h = static_cast<std::uint64_t>(static_cast<std::uint32_t>(k.x));
h = h * 1099511628211ULL ^
static_cast<std::uint64_t>(static_cast<std::uint32_t>(k.y));
h = h * 1099511628211ULL ^ static_cast<std::uint64_t>(k.zoom);
return static_cast<std::size_t>(h);
}
};
/// A bounding box in EPSG:2180 metres (easting = x, northing = y).
struct TileBBox {
double min_x{0.0};
double min_y{0.0};
double max_x{0.0};
double max_y{0.0};
};
/// The tiling scheme. A pure, static value type defining a 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
/// Default cache cell size: 100 m × 100 m ground square.
static constexpr double kCellMetres = 100.0;
/// 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;
/// 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 for the given zoom/cell encoding.
[[nodiscard]] static double tile_span(int zoom = kLevel) noexcept;
/// 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 for the fine grid.
[[nodiscard]] static int zoom_for_resolution(double metres_per_pixel) noexcept;
/// 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);
};
/// Configuration for a WMS GetMap source. Defaults target a public EPSG:2180
/// endpoint (Polish Geoportal). Every field is data, so a different backend is a
/// pure configuration change with no code edits.
struct WmsConfig {
std::string base_url;
std::string layers;
std::string format{"image/jpeg"};
std::string version{"1.1.1"}; ///< 1.1.1 keeps BBOX as minx,miny,maxx,maxy
std::string srs{"EPSG:2180"};
int tile_pixels{TileGrid::kTilePixels};
/// Geoportal ortophoto (ortofotomapa), EPSG:2180, JPEG.
[[nodiscard]] static WmsConfig geoportal_ortho();
/// Geoportal topographic base map (BDOT/raster), EPSG:2180, PNG.
[[nodiscard]] static WmsConfig geoportal_topo();
};
/// Builds the full WMS 1.1.1 GetMap URL for @p box using @p cfg. Numbers are
/// formatted locale-independently (std::to_chars), matching the rest of the
/// library's serialization.
[[nodiscard]] std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box);
enum class TileStatus { Missing, Loading, Ready, Failed };
/// A cached tile image: raw encoded bytes (JPEG/PNG) plus their MIME type.
struct TileImage {
TileStatus status{TileStatus::Missing};
std::vector<std::uint8_t> bytes;
std::string content_type;
int attempts{0}; ///< failed fetch attempts so far (see TileService retry cap)
};
/// Owns the tile grid, the tile cache and the WMS fetch. This is the single
/// place that decides which tile is needed 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).
using DoneFn =
std::function<void(bool, std::vector<std::uint8_t>, std::string)>;
/// Injectable network backend: given a URL, eventually invoke DoneFn.
using Fetcher = std::function<void(const std::string& url, DoneFn)>;
explicit TileService(WmsConfig cfg = WmsConfig::geoportal_ortho());
[[nodiscard]] const WmsConfig& config() const noexcept { return cfg_; }
void set_config(WmsConfig cfg) { cfg_ = std::move(cfg); }
/// 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
/// Failed. Lets the renderer schedule a redraw when an async tile arrives.
void set_on_ready(std::function<void(TileKey)> cb) { on_ready_ = std::move(cb); }
/// Ensures @p key is cached or being fetched, and returns its current status.
/// A cache hit returns Ready immediately and issues no network request.
TileStatus request(const TileKey& key);
/// The cached image for @p key, or nullptr if it is not Ready.
[[nodiscard]] const TileImage* peek(const TileKey& key) const;
/// Stores bytes for @p key directly, marking it Ready. Exposed so a caller
/// (or a test) can seed the cache without going through the fetcher.
void store(const TileKey& key, std::vector<std::uint8_t> bytes,
std::string content_type = {});
[[nodiscard]] bool has(const TileKey& key) const;
[[nodiscard]] std::size_t size() const noexcept { return cache_.size(); }
void clear() { cache_.clear(); }
/// The GetMap URL this service would use for @p key.
[[nodiscard]] std::string url_for(const TileKey& key) const;
/// Finalises an in-flight fetch: on success stores the bytes (Ready), on
/// failure marks the tile Failed, then fires the on_ready callback. Public so
/// the 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.
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. The fetcher owns the timing.
void schedule_retry(const TileKey& key);
WmsConfig cfg_;
std::unordered_map<TileKey, TileImage, TileKeyHash> cache_;
Fetcher fetcher_;
std::function<void(TileKey)> on_ready_;
};
} // namespace editor::plan
} // export

View File

@@ -0,0 +1,51 @@
/*
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/.
*/
module;
#include <cstdint>
#include <vector>
export module eu07.editor.plan_track_renderer;
export import eu07.editor.plan_scene;
export {
namespace editor::plan::render {
/// One sampled centreline element of a solved niweleta, tagged with its logical
/// origin so a UI can colour, label and hit-test it. Rendering-neutral: it holds
/// the track axis only — no rails, no styling. This is the contract the solver
/// hands to a renderer.
struct CentrelineElement {
ElementKind kind{ElementKind::Straight};
std::vector<Point> points; ///< sampled axis in CRS metres
std::uint32_t element_id{0}; ///< the authored element this came from
std::uint32_t owner_id{0}; ///< the track or turnout it belongs to
bool from_turnout{false}; ///< true when owner_id names a turnout
double length{0.0}; ///< element length (metres)
double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end)
double radius_end{0.0};
};
/// Interface every renderer must satisfy.
///
/// The solver emits rendering-neutral CentrelineElements; a renderer turns each
/// into concrete drawable geometry (a single axis line, two rails, an SVG path,
/// ...). A renderer holds NO business logic — it only shapes presentation, so
/// swapping renderers never changes what geometry the solver computed.
class TrackRenderer {
public:
virtual ~TrackRenderer() = default;
/// Called once per centreline element, in plan order.
virtual void add(const CentrelineElement& element) = 0;
};
} // namespace editor::plan::render
} // export

View File

@@ -0,0 +1,323 @@
/*
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/.
*/
module;
#include <algorithm>
#include <cmath>
#include <numbers>
#include <vector>
module eu07.editor.plan_turnout_geometry;
namespace editor::plan {
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;
}
// Turns @p pose in place by @p angle (a heading break, no travel).
Pose turn(const Pose& pose, double angle) {
if (angle == 0.0) return pose;
const double c = std::cos(angle);
const double s = std::sin(angle);
return Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, pose.hx * s + pose.hy * c};
}
// Advances @p pose over @p segment (without sampling points; that is the
// renderer's job): the heading break first, then the travel. Returns the end pose.
Pose advance(const TurnoutSegment& segment, const Pose& pose) {
return layout_segment(segment.k0, segment.k1, segment.length,
turn(pose, segment.turn_in), 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);
}
// The heading of @p pose off the through track that runs through @p start, signed
// so that it grows as the path leaves it.
double heading_off(const Pose& start, const Pose& pose, double sign) {
return sign * std::atan2(pose.hy * start.hx - pose.hx * start.hy,
pose.hx * start.hx + pose.hy * start.hy);
}
// The pose @p station metres along @p path. Stations are the same whichever frame
// the path is measured in, because bending moves curvature and never length.
Pose pose_at_station(const std::vector<TurnoutSegment>& path, const Pose& start,
double station) {
Pose pose = start;
double walked = 0.0;
for (const auto& segment : path) {
const Pose from = turn(pose, segment.turn_in);
if (station <= walked + segment.length) {
return layout_segment(segment.k0, segment.k1, station - walked, from, nullptr);
}
walked += segment.length;
pose = layout_segment(segment.k0, segment.k1, segment.length, from, nullptr);
}
return pose;
}
// Bends the laid path onto a through track of curvature @p bend, the way a rozjazd
// łukowy is made: the triangle the turnout is dimensioned by is rotated about its
// centre, so `t` and the crossing angle stand and every arc takes the radius that
// follows from them.
//
// What is re-cut and what is not comes straight from Koc: the zwrotnica and the
// krzyżownica keep their dimensions and are only bent, so the blade, the odcinek
// przediglicowy and the frog rail keep their lengths to the millimetre. The szyny
// łączące — the plain curve between them — take up the whole difference, which is
// small: the arc between two tangent points barely changes length when it is bent.
//
// False when there is no single arc radius to rotate about, nothing flexible to
// take up the change, or the bend is sharp enough to eat the connecting rails whole.
bool bend_path(std::vector<TurnoutSegment>& path, double alpha, double sign, double bend,
double& tangent_out, double& curvature_out, double& beta_out) {
double radius = 0.0;
double cut = 0.0; // what bending does not re-cut: switch and frog
double flexible = 0.0; // the connecting rails, which it does
for (const auto& segment : path) {
if (segment.k0 == 0.0 && segment.k1 == 0.0) continue;
if (segment.k0 != segment.k1) return false; // a clothoid has no one radius
const double own = std::abs(1.0 / segment.k0);
if (radius != 0.0 && std::abs(own - radius) > 1e-6 * radius) return false;
radius = own;
(segment.part == TurnoutPart::Curve ? flexible : cut) += segment.length;
}
if (radius <= 0.0 || flexible <= 0.0 || alpha <= 0.0) return false;
const double tangent = radius * std::tan(0.5 * alpha);
const double beta = 2.0 * std::atan(tangent * bend);
const double turn_to = beta + sign * alpha;
if (std::abs(turn_to) >= std::numbers::pi - 1e-9) return false;
// t/tg(theta/2) is the radius of every leg of the triangle, the through track's
// as much as the branch's. At theta = 0 the branch comes out straight, and its
// two tangents are then all the length there is between PR and the crossing
const double curvature =
std::abs(turn_to) > 1e-12 ? std::tan(0.5 * turn_to) / tangent : 0.0;
const double run = curvature != 0.0 ? turn_to / curvature : 2.0 * tangent;
const double spare = run - cut;
if (spare <= 0.0) return false;
for (auto& segment : path) {
if (segment.k0 == 0.0 && segment.k1 == 0.0) continue;
segment.k0 = curvature;
segment.k1 = curvature;
if (segment.part == TurnoutPart::Curve) {
segment.length *= spare / flexible;
}
}
tangent_out = tangent;
curvature_out = curvature;
beta_out = beta;
return true;
}
// Where along the laid path the blade first stands @p target away from the stock
// rail — the dimension a catalogue drawing gives its blade in (the tip is where the
// rail is u thick, the planing ends where it is a railhead wide). The gap is not the
// offset of the axis: it opens on the blade's own curve, half a gauge wider than the
// axis it is offset from (Koc, 5.2), so it is carried segment by segment and the one
// that crosses it is solved in place. The station is on the axis. Returns false when
// the path never gets that far.
bool station_at_offset(const std::vector<TurnoutSegment>& path, const Pose& start,
double target, double sign, double& station_out, Pose& pose_out) {
if (target <= 0.0) return false;
Pose pose = start;
double station = 0.0;
double gap = 0.0;
for (const auto& segment : path) {
const Pose from = turn(pose, segment.turn_in);
const Pose to = advance(segment, pose);
const double heading = heading_off(start, from, sign);
const double radius = segment.k0 != 0.0 ? std::abs(1.0 / segment.k0) : 0.0;
const double opened =
radius > 0.0
? (radius + kHalfGauge) *
(std::cos(heading) - std::cos(heading_off(start, to, sign)))
: segment.length * std::sin(heading);
if (gap <= target && gap + opened >= target) {
const double run = std::clamp(
blade_run_to_offset(radius, heading, target - gap), 0.0, segment.length);
station_out = station + run;
pose_out = layout_segment(segment.k0, segment.k1, run, from, nullptr);
return true;
}
gap += opened;
station += segment.length;
pose = to;
}
return false;
}
// Fills the blade dimensions: where its tip stands, how much of the curve runs ahead
// of it as the odcinek przediglicowy, where its planed nose ends and where it reaches
// full railhead width.
// @p relative is the path with the through track's own curvature taken out of it:
// the blade opens against the opornica, so what dimensions it is how the two part
// company, not how either of them runs across the ground. For a rozjazd zwyczajny
// the two paths are the same thing.
void dimension_blade(const Turnout& turnout, const Pose& start,
const std::vector<TurnoutSegment>& relative, TurnoutGeometry& geometry,
double sign) {
double station = 0.0;
Pose pose = start;
for (const auto& segment : geometry.path) {
if (segment.part != TurnoutPart::Blade) {
station += segment.length;
pose = advance(segment, pose);
continue;
}
geometry.has_blade = true;
// the blade begins where its piece does; everything ahead of it is the lead
geometry.blade_station = station;
geometry.blade_tip = turn(pose, segment.turn_in);
double at{0.0};
Pose at_pose{};
if (station_at_offset(relative, start, turnout.blade.tip_thickness, sign, at, at_pose)) {
geometry.nose_end = at;
geometry.blade_nose_end = pose_at_station(geometry.path, start, at);
geometry.blade_angle = heading_off(start, at_pose, sign);
geometry.blade_residual = station + turnout.blade.nose - at;
}
if (station_at_offset(relative, start, turnout.blade.railtop_width, sign, at, at_pose)) {
geometry.planing_end = at;
geometry.blade_planed = pose_at_station(geometry.path, start, at);
geometry.planing_length = at - geometry.blade_station;
}
return;
}
}
} // namespace
double blade_run_to_offset(double radius, double angle, double offset) {
if (offset <= 0.0) return 0.0;
if (radius > 0.0) {
// the blade is an arc: the gap opens as `R1(cos beta - cos(beta + theta))`
// on the rail's own curve, and the axis spends `R theta` getting there
const double rail = radius + kHalfGauge;
const double target = std::cos(angle) - offset / rail;
if (target < -1.0) return 0.0; // the offset is more than the arc ever reaches
return radius * (std::acos(std::clamp(target, -1.0, 1.0)) - angle);
}
// a straight blade: the offset grows with the sine of the break
const double s = std::sin(angle);
return s > 1e-12 ? offset / s : 0.0;
}
TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout, double bend) {
TurnoutGeometry geometry;
if (turnout.path.empty()) return geometry;
const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0;
for (const auto& piece : turnout.path) {
if (piece.length <= 0.0) return geometry;
if (piece.radius_start < 0.0 || piece.radius_end < 0.0) return geometry;
if (piece.turn_in < 0.0) return geometry;
geometry.path.push_back(TurnoutSegment{
sign * piece.turn_in, piece.radius_start > 0.0 ? sign / piece.radius_start : 0.0,
piece.radius_end > 0.0 ? sign / piece.radius_end : 0.0, piece.length, piece.part});
}
double tangent = 0.0;
double beta = 0.0;
if (bend != 0.0) {
double curvature = 0.0;
if (!bend_path(geometry.path, turnout.mark.angle(), sign, bend, tangent, curvature,
beta)) {
return geometry;
}
geometry.bend = bend;
geometry.bend_angle = beta;
geometry.diverging_curvature = curvature;
}
// the path is laid exactly as authored: what it works out to is then checked
// against the catalogue numbers rather than fitted to them
Pose pose = start;
double deflected = 0.0;
for (const auto& segment : geometry.path) {
deflected += segment.turn_in + deflection(segment);
pose = advance(segment, pose);
geometry.diverging_length += segment.length;
}
geometry.valid = true;
geometry.frog = pose;
tangent_lengths(start, pose, geometry.tangent_front, geometry.tangent_back);
// the blade is dimensioned against the opornica, which bends with the turnout
std::vector<TurnoutSegment> relative = geometry.path;
for (auto& segment : relative) {
segment.k0 -= bend;
segment.k1 -= bend;
}
dimension_blade(turnout, start, relative, geometry, sign);
// deflected is signed by the hand, and it is the through track the crossing
// angle is measured from — which turns by beta when the turnout is bent
geometry.angle_residual = sign * (deflected - beta) - turnout.mark.angle();
// where the switch ends on the tor zasadniczy: the frog, projected back onto it.
// this is the second point a drawing marks on the through track, and on a bent
// turnout the projection runs along the radius rather than square to a straight
if (bend == 0.0) {
geometry.through_length =
(pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy;
geometry.through_end = Pose{start.x + start.hx * geometry.through_length,
start.y + start.hy * geometry.through_length, start.hx,
start.hy};
} else {
const double centre_x = start.x - start.hy / bend;
const double centre_y = start.y + start.hx / bend;
const double from = std::atan2(start.y - centre_y, start.x - centre_x);
const double to = std::atan2(pose.y - centre_y, pose.x - centre_x);
double swept = to - from;
while (swept > std::numbers::pi) swept -= 2.0 * std::numbers::pi;
while (swept < -std::numbers::pi) swept += 2.0 * std::numbers::pi;
geometry.through_length = swept / bend;
geometry.through_end =
layout_segment(bend, bend, geometry.through_length, start, nullptr);
}
// the catalogue length is a + b, the two tangent legs of the drawing
if (turnout.length > 0.0) {
geometry.length_residual =
geometry.tangent_front + geometry.tangent_back - turnout.length;
}
return geometry;
}
} // namespace editor::plan

View File

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

View File

@@ -0,0 +1,52 @@
# Unit tests for the plan library (the geometry and the solver behind the track
# layout tool). They are off by default: switched on, the interface units are
# compiled a second time into a small library of their own, because a test
# executable can only import modules from a target it links, and linking the game
# would drag the whole simulator in. The code under test needs nothing but a
# C++20 compiler - no glm, no engine, no imgui.
option(EU07_EDITOR_PLAN_TESTS "Build the editor plan library unit tests" OFF)
if (NOT EU07_EDITOR_PLAN_TESTS)
return()
endif()
enable_testing()
set(PLAN_INTERFACES
../plan_segment.cppm
../plan_turnout_geometry.cppm
../plan_model.cppm
../plan_solution.cppm
../plan_chain.cppm
../plan_join.cppm
../plan_layout.cppm
../plan_render.cppm
../plan_sketch.cppm
../plan_tilecache.cppm
../plan_document_io.cppm
../plan_scn_export.cppm
../plan_scene.cppm
../plan_track_renderer.cppm
../plan_rail_renderer.cppm)
file(GLOB PLAN_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../plan_*.cpp")
add_library(editor_plan STATIC ${PLAN_SOURCES})
target_sources(editor_plan
PUBLIC FILE_SET CXX_MODULES
BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/..
FILES ${PLAN_INTERFACES})
set_target_properties(editor_plan PROPERTIES
CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
POSITION_INDEPENDENT_CODE ON FOLDER "editor")
foreach(name chain hold lines graph format turnout tile_cache)
add_executable(editor_plan_test_${name} test_${name}.cpp)
target_link_libraries(editor_plan_test_${name} PRIVATE editor_plan)
target_include_directories(editor_plan_test_${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
set_target_properties(editor_plan_test_${name} PROPERTIES
CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF FOLDER "editor")
add_test(NAME plan_${name} COMMAND editor_plan_test_${name})
endforeach()
# test_scn.cpp and test_join.cpp are missing: they were never committed, along
# with the join module those two exercised (see the stub in plan_join.cpp).

59
editor/tests/check.hpp Normal file
View File

@@ -0,0 +1,59 @@
/*
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
// A tiny zero-dependency check framework so the test suite needs nothing but a
// C++20 compiler. Each test executable returns 0 on success, 1 on any failure,
// which is exactly what CTest interprets as pass/fail.
#include <cstdio>
namespace plantest {
inline int checks = 0;
inline int failures = 0;
} // namespace plantest
// Variadic so that brace-init lists with commas, e.g. CHECK(Point{1, 2} == ...),
// are passed through as a single condition instead of several macro arguments.
#define CHECK(...) \
do { \
++::plantest::checks; \
if (!(__VA_ARGS__)) { \
++::plantest::failures; \
std::printf(" [FAIL] %s:%d CHECK(%s)\n", __FILE__, __LINE__, \
#__VA_ARGS__); \
} \
} while (0)
#define CHECK_THROWS(...) \
do { \
++::plantest::checks; \
bool threw = false; \
try { \
(void)(__VA_ARGS__); \
} catch (...) { \
threw = true; \
} \
if (!threw) { \
++::plantest::failures; \
std::printf(" [FAIL] %s:%d CHECK_THROWS(%s)\n", __FILE__, \
__LINE__, #__VA_ARGS__); \
} \
} while (0)
#define RUN(fn) \
do { \
std::printf("[RUN ] %s\n", #fn); \
fn(); \
} while (0)
#define REPORT() \
(std::printf("\n%d checks, %d failures\n", ::plantest::checks, \
::plantest::failures), \
::plantest::failures == 0 ? 0 : 1)

239
editor/tests/test_chain.cpp Normal file
View File

@@ -0,0 +1,239 @@
/*
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 <cmath>
#include <numbers>
#include "check.hpp"
import eu07.editor.plan_chain;
// The element chain: a walk, one element at a time, with every length said
// outright. Nothing here is solved for — what the document says is what is laid.
using namespace editor::plan;
using editor::plan::geometry::Pose;
namespace {
constexpr double kTol = 1e-6;
bool near(double a, double b, double tolerance = 1e-6) {
return std::abs(a - b) <= tolerance;
}
Pose pose_at(double x, double y, double azimuth) {
return Pose{x, y, std::sin(azimuth), std::cos(azimuth)};
}
Element line(Document& document, double metres) {
return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}};
}
Element arc(Document& document, double radius, int hand, double metres) {
return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}};
}
Element clothoid(Document& document, double radius, int hand, double metres) {
return Element{mint_element(document), Kind::Clothoid, radius, hand, metres, Free{}};
}
bool has(const TrackOutcome& outcome, Code code) {
for (const auto& diagnostic : outcome.diagnostics) {
if (diagnostic.code == code) {
return true;
}
}
return false;
}
// -------------------------------------------------------------------------
/// A straight, an arc and a straight laid head to tail: no construction lines,
/// no fitting, nothing to close onto.
void open_chain_lays_head_to_tail() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(document, 100.0), arc(document, 300.0, 1, 150.0),
line(document, 50.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.complete);
CHECK(out.track.elements.size() == 3);
CHECK(near(out.track.length, 300.0));
// Heading turned by L/R = 150/300 = 0.5 rad, to the left (hand = +1).
const double turned = 0.5;
CHECK(near(out.track.end.hx, std::sin(-turned) * -1.0 + 0.0, 1e-9) ||
near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9));
// Start pose points north; after a left turn the azimuth decreases.
CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9));
// The first element starts exactly at the anchor.
CHECK(near(out.track.elements[0].start.x, 0.0));
CHECK(near(out.track.elements[0].start.y, 0.0));
// The straight really is 100 m due north.
CHECK(near(out.track.elements[0].end.x, 0.0, 1e-9));
CHECK(near(out.track.elements[0].end.y, 100.0, 1e-9));
}
/// An arc as the very first element — the "łuk wprost z krzyżownicy" case. The
/// old model could not say this: a curve only existed between two straights.
void a_chain_may_start_with_an_arc() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 190.0, -1, 60.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(10.0, 20.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.elements.size() == 1);
CHECK(near(out.track.elements[0].start.x, 10.0));
CHECK(near(out.track.elements[0].start.y, 20.0));
CHECK(near(out.track.elements[0].k0, -1.0 / 190.0));
CHECK(near(out.track.length, 60.0));
}
/// Two arcs back to back with no straight between them — also unsayable before.
void arcs_may_follow_each_other() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 400.0, 1, 100.0), arc(document, 250.0, -1, 80.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.elements.size() == 2);
// The second starts exactly where the first ends, tangentially.
CHECK(near(out.track.elements[1].start.x, out.track.elements[0].end.x, 1e-12));
CHECK(near(out.track.elements[1].start.y, out.track.elements[0].end.y, 1e-12));
CHECK(near(out.track.elements[1].start.hx, out.track.elements[0].end.hx, 1e-12));
// Net turn: +100/400 - 80/250 = 0.25 - 0.32 = -0.07 rad.
CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), 0.07, 1e-9));
}
/// A chain that simply stops. Nothing is synthesised to close it onto.
void an_open_chain_need_not_close() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 300.0, 1, 500.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.complete);
CHECK(out.track.elements.size() == 1);
}
/// What cannot be laid is said out loud, and the chain still gives up whatever
/// stood before it.
void a_bad_element_is_reported_and_stops_the_chain() {
{ // A length of nothing.
Document document;
Track track;
track.id = mint_track(document);
track.elements = {line(document, 100.0), arc(document, 300.0, 1, 0.0),
line(document, 50.0)};
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(has(out, Code::DegenerateElement));
CHECK(!out.track.complete);
CHECK(out.track.elements.size() == 1); // the straight before it still stands
CHECK(near(out.track.length, 100.0));
}
{ // An arc without a radius.
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 0.0, 1, 50.0)};
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(has(out, Code::DegenerateElement));
CHECK(out.track.elements.empty());
}
{ // An arc that does not say which way it turns.
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 300.0, 0, 50.0)};
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(has(out, Code::DegenerateElement));
}
{ // A transition curve that eases nothing: flat to flat.
Document document;
Track track;
track.id = mint_track(document);
track.elements = {line(document, 20.0), clothoid(document, 0.0, 1, 40.0)};
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(has(out, Code::DegenerateElement));
CHECK(out.track.elements.size() == 1);
}
}
/// A transition curve takes its near-end curvature from wherever the chain has
/// got to, and hands its far-end curvature on. That is the only way one element
/// depends on another.
void a_transition_eases_between_what_it_finds() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 300.0, 1, 100.0), clothoid(document, 600.0, 1, 50.0),
arc(document, 600.0, 1, 80.0), clothoid(document, 0.0, 1, 40.0),
line(document, 30.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.elements.size() == 5);
CHECK(near(out.track.elements[1].k0, 1.0 / 300.0));
CHECK(near(out.track.elements[1].k1, 1.0 / 600.0));
CHECK(near(out.track.elements[3].k0, 1.0 / 600.0));
CHECK(near(out.track.elements[3].k1, 0.0));
// Every element kept the length it was given.
CHECK(near(out.track.length, 100.0 + 50.0 + 80.0 + 40.0 + 30.0));
}
/// Solving twice gives the same answer: nothing is written back anywhere.
void solving_is_idempotent() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {line(document, 120.0), clothoid(document, 500.0, -1, 40.0),
arc(document, 500.0, -1, 180.0), clothoid(document, 0.0, -1, 40.0),
line(document, 60.0)};
const TrackOutcome first = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
const TrackOutcome second = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(first.diagnostics.empty());
CHECK(first.track.elements.size() == second.track.elements.size());
for (std::size_t i = 0; i < first.track.elements.size(); ++i) {
CHECK(near(first.track.elements[i].length, second.track.elements[i].length, kTol));
CHECK(near(first.track.elements[i].start.x, second.track.elements[i].start.x, kTol));
}
}
} // namespace
int main() {
RUN(open_chain_lays_head_to_tail);
RUN(a_chain_may_start_with_an_arc);
RUN(arcs_may_follow_each_other);
RUN(an_open_chain_need_not_close);
RUN(a_bad_element_is_reported_and_stops_the_chain);
RUN(a_transition_eases_between_what_it_finds);
RUN(solving_is_idempotent);
return REPORT();
}

View File

@@ -0,0 +1,311 @@
/*
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 <variant>
#include <cmath>
#include <string>
#include "check.hpp"
import eu07.editor.plan_layout;
import eu07.editor.plan_document_io;
// The .m0s format, version 5: everything by id, an explicit version, and a
// round trip that is byte-identical. Older files still open: a version-3 one has
// its arcs and blade fields converted to the piece list on the way in, and a
// version-4 one simply had no nose on its blade yet.
using namespace editor::plan;
namespace {
Document sample() {
Document document;
TurnoutType type;
type.name = "Rz 1:9 R190 z iglicą nagiętą"; // spaces and non-ASCII on purpose
type.crossing_n = 9.0;
type.length = 27.1;
type.pieces = {
TurnoutPieceSpec{0, 2.42, 0.0, 0.0, 0.0},
TurnoutPieceSpec{1, 8.3, 190.0, 190.0, 0.0058},
TurnoutPieceSpec{2, 12.75, 190.0, 250.0, 0.0},
TurnoutPieceSpec{3, 4.125, 0.0, 0.0, 0.0},
};
type.blade.tip_thickness = 0.005;
type.blade.nose = 0.125;
type.blade.railtop_width = 0.07;
document.turnout_types.push_back(type);
Track through;
through.id = mint_track(document);
through.name = "tor zasadniczy 1";
through.anchor = AtPose{100.5, -2.25, 0.7853981633974483};
through.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, 412.5, Free{}},
Element{mint_element(document), Kind::Clothoid, 600.0, -1, 40.0, Free{}},
Element{mint_element(document), Kind::Arc, 600.0, -1, 235.125, Free{}},
Element{mint_element(document), Kind::Line, 0.0, 0, 180.0, Free{}},
};
document.tracks.push_back(through);
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = type.name;
placement.on = through.id;
placement.station = 312.75;
placement.hand = -1;
placement.facing = false;
placement.bend_from_track = false;
placement.bend = -1.0 / 380.292; // wygięty dwustronnie, na własny łuk
document.turnouts.push_back(placement);
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga";
branch.anchor = AtPort{placement.id, Port::Frog};
branch.elements = {
Element{mint_element(document), Kind::Arc, 190.0, -1, 45.5, Free{}},
Element{mint_element(document), Kind::Line, 0.0, 0, 123.5, Free{}},
};
// Held parallel to the through track's last straight, at międzytorze.
branch.elements[1].hold = Parallel{through.elements[3].id, 4.75};
document.tracks.push_back(branch);
document.view_x = 500.0;
document.view_y = 250.0;
document.view_extent = 2000.0;
document.origin_set = true;
document.georeferenced = true;
document.origin_x = 6540000.25;
document.origin_y = 5760000.5;
// an absolute path into another MaSzyna, spaces and all
document.scn_path = "/home/kto to/MaSzyna/scenery/plan export.scn";
return document;
}
bool same(const Document& a, const Document& b) {
if (a.tracks.size() != b.tracks.size() ||
a.turnouts.size() != b.turnouts.size() ||
a.turnout_types.size() != b.turnout_types.size() || a.next_id != b.next_id) {
return false;
}
for (std::size_t i = 0; i < a.tracks.size(); ++i) {
if (a.tracks[i].id != b.tracks[i].id || a.tracks[i].name != b.tracks[i].name ||
a.tracks[i].elements.size() != b.tracks[i].elements.size()) {
return false;
}
if (a.tracks[i].anchor.index() != b.tracks[i].anchor.index()) {
return false;
}
for (std::size_t k = 0; k < a.tracks[i].elements.size(); ++k) {
const Element& x = a.tracks[i].elements[k];
const Element& y = b.tracks[i].elements[k];
if (x.id != y.id || x.kind != y.kind || x.radius != y.radius ||
x.hand != y.hand || x.length != y.length ||
x.hold.index() != y.hold.index()) {
return false;
}
}
}
return true;
}
void round_trip_is_byte_identical() {
const Document document = sample();
const std::string text = editor::plan::io::serialize(document);
const auto reloaded = editor::plan::io::deserialize(text);
CHECK(reloaded.has_value());
if (!reloaded) {
return;
}
CHECK(same(document, *reloaded));
CHECK(editor::plan::io::serialize(*reloaded) == text);
}
void every_field_survives() {
const Document document = sample();
const auto reloaded = editor::plan::io::deserialize(editor::plan::io::serialize(document));
CHECK(reloaded.has_value());
if (!reloaded) {
return;
}
// A name with spaces and non-ASCII.
CHECK(reloaded->turnout_types[0].name == "Rz 1:9 R190 z iglicą nagiętą");
// The geometry list, every piece of it, and the blade's own dimensions.
CHECK(reloaded->turnout_types[0].pieces.size() == 4);
CHECK(reloaded->turnout_types[0].pieces[0].part == 0);
CHECK(reloaded->turnout_types[0].pieces[0].length == 2.42);
CHECK(reloaded->turnout_types[0].pieces[1].part == 1);
CHECK(reloaded->turnout_types[0].pieces[1].radius_start == 190.0);
CHECK(reloaded->turnout_types[0].pieces[1].turn_in == 0.0058);
CHECK(reloaded->turnout_types[0].pieces[2].radius_end == 250.0);
CHECK(reloaded->turnout_types[0].pieces[3].part == 3);
CHECK(reloaded->turnout_types[0].blade.tip_thickness == 0.005);
CHECK(reloaded->turnout_types[0].blade.nose == 0.125);
CHECK(reloaded->turnout_types[0].blade.railtop_width == 0.07);
// Placement, including a backward-facing turnout.
CHECK(reloaded->turnouts[0].station == 312.75);
CHECK(reloaded->turnouts[0].hand == -1);
CHECK(reloaded->turnouts[0].facing == false);
CHECK(reloaded->turnouts[0].bend_from_track == false);
CHECK(std::abs(reloaded->turnouts[0].bend + 1.0 / 380.292) < 1e-12);
CHECK(reloaded->turnouts[0].type == "Rz 1:9 R190 z iglicą nagiętą");
// Every element says its own shape and its own length.
CHECK(reloaded->tracks[0].elements[0].kind == Kind::Line);
CHECK(reloaded->tracks[0].elements[0].length == 412.5);
CHECK(reloaded->tracks[0].elements[1].kind == Kind::Clothoid);
CHECK(reloaded->tracks[0].elements[1].radius == 600.0);
CHECK(reloaded->tracks[0].elements[2].length == 235.125);
CHECK(reloaded->tracks[0].elements[2].hand == -1);
// A hold, and the one element that carries it.
CHECK(std::holds_alternative<Free>(reloaded->tracks[1].elements[0].hold));
CHECK(std::holds_alternative<Parallel>(reloaded->tracks[1].elements[1].hold));
CHECK(std::get<Parallel>(reloaded->tracks[1].elements[1].hold).offset == 4.75);
CHECK(std::get<Parallel>(reloaded->tracks[1].elements[1].hold).ref ==
reloaded->tracks[0].elements[3].id);
// Anchors.
CHECK(std::holds_alternative<AtPose>(reloaded->tracks[0].anchor));
CHECK(std::holds_alternative<AtPort>(reloaded->tracks[1].anchor));
CHECK(std::get<AtPort>(reloaded->tracks[1].anchor).port == Port::Frog);
// Georeference and view.
CHECK(reloaded->origin_x == 6540000.25);
CHECK(reloaded->view_extent == 2000.0);
CHECK(reloaded->georeferenced);
// and where the plan is exported to, which says which MaSzyna it is drawn for
CHECK(reloaded->scn_path == "/home/kto to/MaSzyna/scenery/plan export.scn");
// Ids keep going where they left off, so nothing minted later collides.
CHECK(reloaded->next_id == document.next_id);
}
void an_older_file_is_refused() {
// Version 1 and version 2 described different models; both are refused
// outright rather than half-understood. Version 3 is not refused — it is
// converted, which an_old_file_upgrades_to_the_piece_list checks.
const std::string version_1 =
"the plan library 1\ncrs 2180\nniwelety 0\neditor 1\neniw 2 1\nename niweleta 1\n";
CHECK(!editor::plan::io::deserialize(version_1).has_value());
const std::string version_2 =
"m0s 2\nnext 9\nview 0 0 0\norigin 0 0 0 0\nlines 0\ntypes 0\n"
"tracks 0\n";
CHECK(!editor::plan::io::deserialize(version_2).has_value());
CHECK(!editor::plan::io::deserialize("").has_value());
CHECK(!editor::plan::io::deserialize("m0s 8\n").has_value());
}
void a_file_from_before_lukowanie_still_opens() {
// Version 5 placements had nothing to say about a bend, because a turnout stood
// on the track as it found it. That is what taking the bend from the track means,
// so they open as exactly that and nothing about them moves.
const std::string version_5 =
"m0s 5\nnext 5\nview 0 0 0\norigin 0 0 0 0\ntypes 0\n"
"turnouts 1\nturnout 2 1 100 1 1\ntutype Rz 1:9 R190\ntracks 0\n";
const auto document = editor::plan::io::deserialize(version_5);
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->turnouts.size() == 1);
CHECK(document->turnouts[0].station == 100.0);
CHECK(document->turnouts[0].bend_from_track);
CHECK(document->turnouts[0].bend == 0.0);
CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos);
}
void a_file_from_before_the_export_path_still_opens() {
// Version 6 said nothing about where a plan exported to, so it opens with
// nothing said and the host falls back on its own answer.
const std::string version_6 =
"m0s 6\nnext 3\nview 0 0 0\norigin 0 0 0 0\ntypes 0\nturnouts 0\n"
"tracks 1\ntrack 1\ntkname tor\nanchor pose 0 0 0\nelements 1\n"
"element 2 line 0 0 250\n";
const auto document = editor::plan::io::deserialize(version_6);
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->scn_path.empty());
CHECK(document->tracks.size() == 1);
CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos);
}
void a_file_from_before_the_nose_still_opens() {
// Version 4 had the piece list but no nose on the blade: the type line went
// straight from the tip thickness to the railhead width. It opens with the nose
// at nothing, which is what the type then reports against the curve it lays.
const std::string version_4 =
"m0s 4\nnext 1\nview 0 0 0\norigin 0 0 0 0\n"
"types 1\ntype 9 27.138 0.0049 0.07 1\ntseg 2 21.02 190 190 0\n"
"tname Rz 1:9 R190\nturnouts 0\ntracks 0\n";
const auto document = editor::plan::io::deserialize(version_4);
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->turnout_types.size() == 1);
CHECK(document->turnout_types[0].blade.tip_thickness == 0.0049);
CHECK(document->turnout_types[0].blade.nose == 0.0);
CHECK(document->turnout_types[0].blade.railtop_width == 0.07);
CHECK(document->turnout_types[0].pieces.size() == 1);
// and it is written back in the new form, with a place for the nose
CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos);
}
void an_old_file_upgrades_to_the_piece_list() {
// A version-3 catalogue: one arc, no blade, and a closing arc that was worked
// out on the fly. It has to come back as an explicit list that lays the very
// same shape — on the crossing angle and on the catalogue length.
const std::string version_3 =
"m0s 3\nnext 1\nview 0 0 0\norigin 0 0 0 0\n"
"types 1\ntype 9 0 0 27.138 0 0 0 1\ntarc 190 0 0\ntname Rz 1:9 R190\n"
"turnouts 0\ntracks 0\n";
const auto document = editor::plan::io::deserialize(version_3);
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->turnout_types.size() == 1);
const TurnoutType& type = document->turnout_types[0];
CHECK(type.name == "Rz 1:9 R190");
CHECK(type.pieces.size() == 2); // the closing arc and the frog rail
CHECK(type.pieces[0].part == 2);
CHECK(std::abs(type.pieces[0].length - 190.0 * std::atan(1.0 / 9.0)) < 1e-9);
CHECK(type.pieces[1].part == 3);
const auto laid = editor::plan::lay_turnout(
editor::plan::geometry::Pose{0.0, 0.0, 1.0, 0.0}, to_domain(type, 1));
CHECK(laid.valid);
CHECK(std::abs(laid.angle_residual) < 1e-9);
CHECK(std::abs(laid.length_residual) < 1e-9);
// and it is written back in the new form
CHECK(editor::plan::io::serialize(*document).find("tseg ") != std::string::npos);
}
void a_truncated_file_is_refused() {
const std::string text = editor::plan::io::serialize(sample());
// Cut off half way: the counts no longer match what follows.
CHECK(!editor::plan::io::deserialize(text.substr(0, text.size() / 2)).has_value());
}
} // namespace
int main() {
RUN(round_trip_is_byte_identical);
RUN(every_field_survives);
RUN(an_older_file_is_refused);
RUN(an_old_file_upgrades_to_the_piece_list);
RUN(a_file_from_before_the_nose_still_opens);
RUN(a_file_from_before_lukowanie_still_opens);
RUN(a_truncated_file_is_refused);
return REPORT();
}

234
editor/tests/test_graph.cpp Normal file
View File

@@ -0,0 +1,234 @@
/*
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 <cmath>
#include "check.hpp"
import eu07.editor.plan_layout;
// What refers to what decides the order things are laid in. A turnout on the
// branch of a turnout is not a special case, and a reference that runs in a
// circle is reported rather than relaxed at for a fixed number of passes.
using namespace editor::plan;
namespace {
bool near(double a, double b, double tolerance = 1e-6) {
return std::abs(a - b) <= tolerance;
}
bool has(const Solution& solution, Code code) {
for (const auto& diagnostic : solution.diagnostics) {
if (diagnostic.code == code) {
return true;
}
}
return false;
}
/// A plain Rz 1:9 R190, the everyday Polish turnout.
void add_catalogue(Document& document) {
TurnoutType type;
type.name = "Rz 1:9 R190";
type.crossing_n = 9.0;
type.length = 27.138; // the catalogue's a + b
type.blade = BladeSpec{0.005, 0.070};
type.pieces = {
// przediglicowy, then blade and curve as one arc of the crossing angle, then
// the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232
TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0},
TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0},
};
document.turnout_types.push_back(type);
}
TurnoutId add_turnout(Document& document, TrackId on, double station, int hand) {
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = "Rz 1:9 R190";
placement.on = on;
placement.station = station;
placement.hand = hand;
document.turnouts.push_back(placement);
return placement.id;
}
TrackId add_straight_track(Document& document, const char* name, double x, double y,
double azimuth, double length) {
Track track;
track.id = mint_track(document);
track.name = name;
track.anchor = AtPose{x, y, azimuth};
track.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, length, Free{}}};
document.tracks.push_back(track);
return track.id;
}
// -------------------------------------------------------------------------
/// A branch anchored to a frog is laid after the turnout, which is laid after
/// its through track — whatever order they appear in the document.
void a_branch_follows_its_frog() {
Document document;
add_catalogue(document);
// The branch is declared BEFORE the turnout and the through track it needs.
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga";
branch.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, 100.0, Free{}}};
document.tracks.push_back(branch);
const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0);
const TurnoutId turnout = add_turnout(document, through, 300.0, 1);
document.tracks[0].anchor = AtPort{turnout, Port::Frog};
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTurnout* solved_turnout = find_turnout(solution, turnout);
CHECK(solved_turnout != nullptr && solved_turnout->valid);
const SolvedTrack* solved_branch = find_track(solution, branch.id);
CHECK(solved_branch != nullptr);
CHECK(solved_branch->elements.size() == 1);
// The branch starts exactly at the frog, on the frog's heading. It was never
// anywhere else, so there is nothing to pin back.
CHECK(near(solved_branch->start.x, solved_turnout->frog.x));
CHECK(near(solved_branch->start.y, solved_turnout->frog.y));
CHECK(near(solved_branch->start.hx, solved_turnout->frog.hx));
CHECK(near(solved_branch->start.hy, solved_turnout->frog.hy));
// It diverges: the frog is off the through track's straight line.
CHECK(std::abs(solved_turnout->frog.x) > 0.5);
}
/// A turnout on the branch of a turnout. The old solver ran a fixed two passes,
/// so this depth had no way to settle.
void a_turnout_on_a_branch_of_a_turnout() {
Document document;
add_catalogue(document);
const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 2000.0);
const TurnoutId first = add_turnout(document, through, 300.0, 1);
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga 1";
branch.anchor = AtPort{first, Port::Frog};
branch.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, 400.0, Free{}}};
document.tracks.push_back(branch);
const TurnoutId second = add_turnout(document, branch.id, 150.0, -1);
Track branch2;
branch2.id = mint_track(document);
branch2.name = "odnoga 2";
branch2.anchor = AtPort{second, Port::Frog};
branch2.elements = {
Element{mint_element(document), Kind::Arc, 300.0, -1, 120.0, Free{}}};
document.tracks.push_back(branch2);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
CHECK(find_turnout(solution, second) != nullptr);
CHECK(find_turnout(solution, second)->valid);
const SolvedTrack* deep = find_track(solution, branch2.id);
CHECK(deep != nullptr);
CHECK(deep->elements.size() == 1);
CHECK(near(deep->start.x, find_turnout(solution, second)->frog.x));
CHECK(near(deep->start.y, find_turnout(solution, second)->frog.y));
// Three levels deep, well away from the through track.
CHECK(std::abs(deep->start.x) > 1.0);
}
/// References that run in a circle are named, not relaxed at.
void a_cycle_is_reported() {
// Two tracks, each held parallel to the other's element.
Document document;
const TrackId first = add_straight_track(document, "tor 1", 0.0, 0.0, 0.0, 500.0);
const TrackId second = add_straight_track(document, "tor 2", 20.0, 0.0, 0.0, 500.0);
document.tracks[0].elements[0].hold =
Parallel{document.tracks[1].elements[0].id, 4.75};
document.tracks[1].elements[0].hold =
Parallel{document.tracks[0].elements[0].id, 4.75};
(void)first;
(void)second;
const Solution solution = solve(document);
CHECK(has(solution, Code::CyclicDependency));
}
/// A turnout whose catalogue type is missing says so, and the branch that hangs
/// off it says why it could not be laid — neither is silently dropped.
void a_missing_type_is_reported() {
Document document;
const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 500.0);
const TurnoutId turnout = add_turnout(document, through, 100.0, 1); // no catalogue
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga";
branch.anchor = AtPort{turnout, Port::Frog};
branch.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, 50.0, Free{}}};
document.tracks.push_back(branch);
const Solution solution = solve(document);
CHECK(has(solution, Code::UnknownReference));
CHECK(solution.diagnostics.size() >= 2);
// Every track still has an entry, so a host can index by id without holes.
CHECK(find_track(solution, branch.id) != nullptr);
CHECK(find_track(solution, through) != nullptr);
}
/// Solving twice gives the same answer, and solving does not touch the document.
void solving_leaves_the_document_alone() {
Document document;
add_catalogue(document);
const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0);
const TurnoutId turnout = add_turnout(document, through, 300.0, 1);
Track branch;
branch.id = mint_track(document);
branch.anchor = AtPort{turnout, Port::Frog};
branch.elements = {
Element{mint_element(document), Kind::Arc, 300.0, 1, 200.0, Free{}}};
document.tracks.push_back(branch);
const Document before = document;
const Solution first = solve(document);
const Solution second = solve(document);
CHECK(document.tracks.size() == before.tracks.size());
CHECK(document.next_id == before.next_id);
CHECK(first.tracks.size() == second.tracks.size());
for (std::size_t i = 0; i < first.tracks.size(); ++i) {
CHECK(near(first.tracks[i].length, second.tracks[i].length));
CHECK(near(first.tracks[i].end.x, second.tracks[i].end.x));
CHECK(near(first.tracks[i].end.y, second.tracks[i].end.y));
}
}
} // namespace
int main() {
RUN(a_branch_follows_its_frog);
RUN(a_turnout_on_a_branch_of_a_turnout);
RUN(a_cycle_is_reported);
RUN(a_missing_type_is_reported);
RUN(solving_leaves_the_document_alone);
return REPORT();
}

319
editor/tests/test_hold.cpp Normal file
View File

@@ -0,0 +1,319 @@
/*
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 <cmath>
#include <cstdio>
#include <numbers>
#include "check.hpp"
import eu07.editor.plan_layout;
import eu07.editor.plan_sketch;
// What one track keeps against another, and what clicking along the ground
// works out at the click. Both are the same idea from two ends: geometry is
// decided once, in numbers, and nothing is left pointing at a drawing.
using namespace editor::plan;
namespace {
bool near(double a, double b, double tolerance = 1e-6) {
return std::abs(a - b) <= tolerance;
}
Element line(Document& document, double metres) {
return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}};
}
Element arc(Document& document, double radius, int hand, double metres) {
return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}};
}
/// A straight due north from the origin, 1000 m of it.
TrackId add_main(Document& document, ElementId& out_element) {
Track track;
track.id = mint_track(document);
track.name = "tor 1";
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(document, 1000.0)};
out_element = track.elements.front().id;
document.tracks.push_back(track);
return track.id;
}
bool has(const Solution& solution, Code code) {
for (const auto& diagnostic : solution.diagnostics) {
if (diagnostic.code == code) {
return true;
}
}
return false;
}
// -------------------------------------------------------------------------
/// A corner rounded at the click: the tangent comes off both straights, and the
/// arc turns exactly as far as the corner bends.
void a_corner_is_rounded_with_a_tangent_arc() {
// Due north, then due east: a 90° corner to the right.
const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0);
CHECK(corner.ok);
CHECK(corner.hand == -1);
CHECK(near(corner.deflection, -std::numbers::pi / 2.0));
CHECK(near(corner.tangent, 300.0)); // R·tan(45°)
CHECK(near(corner.arc_length, 300.0 * std::numbers::pi / 2.0));
// Straight on is not a corner, and neither is doubling back.
CHECK(!fit_corner(0.0, 1.0, 0.0, 1.0, 300.0).ok);
CHECK(!fit_corner(0.0, 1.0, 0.0, -1.0, 300.0).ok);
CHECK(!fit_corner(0.0, 1.0, 1.0, 0.0, 0.0).ok);
}
/// The corner really does come out tangent: laying the three elements it
/// produces lands the track on the second leg, heading along it.
void a_rounded_corner_lands_on_the_next_leg() {
Document document;
const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0);
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(document, 1000.0 - corner.tangent),
arc(document, 300.0, corner.hand, corner.arc_length),
line(document, 800.0 - corner.tangent)};
document.tracks.push_back(track);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTrack* laid = find_track(solution, track.id);
CHECK(laid != nullptr);
// The corner was at (0, 1000); the second leg runs east from there to (800, 1000).
CHECK(near(laid->end.x, 800.0, 1e-6));
CHECK(near(laid->end.y, 1000.0, 1e-6));
CHECK(near(laid->end.hx, 1.0, 1e-9));
CHECK(near(laid->end.hy, 0.0, 1e-9));
}
/// Międzytorze, said once: the held track is moved bodily onto the offset, and
/// stays where it was put along it.
void a_track_held_parallel_is_moved_onto_the_offset() {
Document document;
ElementId reference{ElementId::none};
add_main(document, reference);
Track siding;
siding.id = mint_track(document);
siding.name = "tor 2";
// Put it well off the offset, and part way up the main track.
siding.anchor = AtPose{40.0, 300.0, 0.0};
siding.elements = {line(document, 500.0)};
siding.elements.front().hold = Parallel{reference, 4.75};
document.tracks.push_back(siding);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTrack* laid = find_track(solution, siding.id);
CHECK(laid != nullptr && laid->elements.size() == 1);
// The main track heads north, so left of its travel is -x.
CHECK(near(laid->elements[0].start.x, -4.75, 1e-9));
// Nothing slid it along: it kept the y it was anchored at.
CHECK(near(laid->elements[0].start.y, 300.0, 1e-9));
CHECK(near(laid->elements[0].start.hy, 1.0, 1e-9));
CHECK(near(laid->length, 500.0));
// The offset is signed: the other side is the other side.
document.tracks.back().elements.front().hold = Parallel{reference, -4.75};
const Solution mirrored = solve(document);
const SolvedTrack* other = find_track(mirrored, document.tracks.back().id);
CHECK(other != nullptr && near(other->elements[0].start.x, 4.75, 1e-9));
}
/// Parallel to an arc is concentric with it: same centre, R∓d.
void a_track_held_parallel_to_an_arc_is_concentric() {
Document document;
Track main;
main.id = mint_track(document);
main.name = "tor 1";
main.anchor = AtPose{0.0, 0.0, 0.0};
main.elements = {arc(document, 500.0, 1, 400.0)};
const ElementId reference = main.elements.front().id;
document.tracks.push_back(main);
Track siding;
siding.id = mint_track(document);
siding.name = "tor 2";
siding.anchor = AtPose{-60.0, 10.0, 0.0};
// The radius and hand asked for here are beside the point: the reference
// fixes the shape.
siding.elements = {arc(document, 999.0, -1, 200.0)};
siding.elements.front().hold = Parallel{reference, 50.0};
document.tracks.push_back(siding);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTrack* laid = find_track(solution, siding.id);
CHECK(laid != nullptr && laid->elements.size() == 1);
// Left of a left-hand curve is the inside: R - d.
CHECK(near(laid->elements[0].k0, 1.0 / 450.0, 1e-12));
// The main track's centre of curvature is 500 m to the left of its start.
CHECK(near(std::hypot(laid->elements[0].start.x + 500.0, laid->elements[0].start.y),
450.0, 1e-6));
CHECK(near(laid->length, 200.0));
}
/// A hold is a reference like any other: it orders the solve, and a circle of
/// them is reported rather than relaxed at.
void holds_are_ordered_and_cycles_are_caught() {
Document document;
ElementId reference{ElementId::none};
add_main(document, reference);
// Held to a track that is itself held: laid third, and still exact.
Track second;
second.id = mint_track(document);
second.name = "tor 2";
second.anchor = AtPose{40.0, 0.0, 0.0};
second.elements = {line(document, 400.0)};
second.elements.front().hold = Parallel{reference, 4.75};
const ElementId middle = second.elements.front().id;
document.tracks.push_back(second);
Track third;
third.id = mint_track(document);
third.name = "tor 3";
third.anchor = AtPose{80.0, 0.0, 0.0};
third.elements = {line(document, 400.0)};
third.elements.front().hold = Parallel{middle, 4.75};
document.tracks.push_back(third);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTrack* laid = find_track(solution, third.id);
CHECK(laid != nullptr && near(laid->elements[0].start.x, -9.5, 1e-9));
// Now point the first one back at the last: a circle.
document.tracks[0].elements[0].hold =
Parallel{document.tracks[2].elements[0].id, 4.75};
CHECK(has(solve(document), Code::CyclicDependency));
}
/// Two elements of one movable track cannot both decide where it goes.
void two_holds_on_one_movable_track_conflict() {
Document document;
ElementId reference{ElementId::none};
add_main(document, reference);
Track siding;
siding.id = mint_track(document);
siding.name = "tor 2";
siding.anchor = AtPose{40.0, 0.0, 0.0};
siding.elements = {line(document, 200.0), line(document, 200.0)};
siding.elements[0].hold = Parallel{reference, 4.75};
siding.elements[1].hold = Parallel{reference, 9.50};
document.tracks.push_back(siding);
CHECK(has(solve(document), Code::HoldConflict));
}
/// A branch pinned to a turnout's frog cannot be moved onto the offset, so the
/// corner between the frog and the held straight takes up the difference — the
/// same fit as pulling a straight about by hand, and no full turn anywhere.
void a_pinned_branch_fits_its_way_onto_the_offset() {
Document document;
TurnoutType type;
type.name = "Rz 1:9 R190";
type.crossing_n = 9.0;
type.length = 27.138; // the catalogue's a + b
type.blade = BladeSpec{0.005, 0.070};
type.pieces = {
// przediglicowy, then blade and curve as one arc of the crossing angle, then
// the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232
TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0},
TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0},
};
document.turnout_types.push_back(type);
ElementId reference{ElementId::none};
const TrackId main = add_main(document, reference);
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = type.name;
placement.on = main;
placement.station = 300.0;
placement.hand = -1; // diverging to the right of a northbound track
document.turnouts.push_back(placement);
// Off the frog: a straight, a corner, and the straight that is to run beside
// the main track. Nothing about it is right to begin with.
Track branch;
branch.id = mint_track(document);
branch.name = "bocznica";
branch.anchor = AtPort{placement.id, Port::Frog};
branch.elements = {line(document, 40.0), arc(document, 300.0, 1, 30.0),
line(document, 400.0)};
branch.elements[2].hold = Parallel{reference, -4.75};
document.tracks.push_back(branch);
const Solution solution = solve(document);
for (const auto& diagnostic : solution.diagnostics) {
std::printf(" diag: %s\n", diagnostic.text.c_str());
}
CHECK(!has(solution, Code::OffsetNotHeld));
const SolvedTrack* laid = find_track(solution, branch.id);
CHECK(laid != nullptr && laid->elements.size() == 3);
const SolvedTurnout* turnout = find_turnout(solution, placement.id);
CHECK(turnout != nullptr && turnout->valid);
// It still starts exactly on the frog: the fit moved lengths, not the branch.
CHECK(near(laid->start.x, turnout->frog.x, 1e-6));
CHECK(near(laid->start.y, turnout->frog.y, 1e-6));
// And the held straight really lies 4.75 m to the right of the main track,
// pointing the same way.
CHECK(near(laid->elements[2].start.x, 4.75, 1e-6));
CHECK(near(laid->elements[2].start.hy, 1.0, 1e-9));
// The corner took up the difference without looping the long way round.
CHECK(laid->elements[1].length < 300.0 * std::numbers::pi);
}
/// A reference that is not there is said so, and the track is still laid.
void a_missing_reference_is_reported() {
Document document;
Track siding;
siding.id = mint_track(document);
siding.name = "tor 2";
siding.anchor = AtPose{0.0, 0.0, 0.0};
siding.elements = {line(document, 100.0)};
siding.elements.front().hold = Parallel{static_cast<ElementId>(9999), 4.75};
document.tracks.push_back(siding);
const Solution solution = solve(document);
CHECK(has(solution, Code::UnknownReference));
const SolvedTrack* laid = find_track(solution, siding.id);
CHECK(laid != nullptr && laid->elements.size() == 1);
}
} // namespace
int main() {
RUN(a_corner_is_rounded_with_a_tangent_arc);
RUN(a_rounded_corner_lands_on_the_next_leg);
RUN(a_track_held_parallel_is_moved_onto_the_offset);
RUN(a_track_held_parallel_to_an_arc_is_concentric);
RUN(holds_are_ordered_and_cycles_are_caught);
RUN(two_holds_on_one_movable_track_conflict);
RUN(a_pinned_branch_fits_its_way_onto_the_offset);
RUN(a_missing_reference_is_reported);
return REPORT();
}

647
editor/tests/test_lines.cpp Normal file
View File

@@ -0,0 +1,647 @@
/*
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 <variant>
#include <cmath>
#include <numbers>
#include <string>
#include <vector>
#include "check.hpp"
import eu07.editor.plan_layout;
import eu07.editor.plan_sketch;
// Pulling a straight about by one of its ends. The arcs beside it re-fit, every
// other straight keeps its bearing, and an arc that turns past a half circle
// stays as far round as it was drawn — that last one is the whole reason the
// construction goes through the arc's centre and not through where the tangents
// would have crossed.
using namespace editor::plan;
namespace {
bool near(double a, double b, double tolerance = 1e-6) {
return std::abs(a - b) <= tolerance;
}
Element line(Document& document, double metres) {
return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}};
}
Element arc(Document& document, double radius, int hand, double metres) {
return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}};
}
double azimuth(const SolvedElement& element) {
return std::atan2(element.start.hx, element.start.hy);
}
/// The shape most of this is about: straight, corner, straight, corner, straight.
TrackId add_track(Document& document, std::vector<Element> elements) {
Track track;
track.id = mint_track(document);
track.name = "tor 1";
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = std::move(elements);
document.tracks.push_back(track);
return track.id;
}
// -------------------------------------------------------------------------
/// Taking a track apart into lines and putting it back together, without moving
/// anything, gives back exactly the track that went in.
void a_track_survives_being_taken_apart_and_put_back() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr && before.diagnostics.empty());
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
CHECK(skeleton.lines.size() == 3);
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) {
CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-9));
CHECK(rebuilt.elements[i].radius == document.tracks[0].elements[i].radius);
CHECK(rebuilt.elements[i].hand == document.tracks[0].elements[i].hand);
}
}
/// An arc of 240°, which has no tangent crossing to be found: the two straights
/// it joins meet at 120° the other way round. Nothing may quietly turn it into
/// the 120° arc that would fit between the same two lines.
void an_arc_past_a_half_circle_stays_where_it_was_drawn() {
Document document;
const double turn = 240.0 * std::numbers::pi / 180.0;
const TrackId id = add_track(document, {line(document, 200.0),
arc(document, 200.0, 1, 200.0 * turn),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr && before.diagnostics.empty());
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
CHECK(near(rebuilt.elements[1].length, 200.0 * turn, 1e-9));
CHECK(near(rebuilt.elements[0].length, 200.0, 1e-9));
CHECK(near(rebuilt.elements[2].length, 200.0, 1e-9));
}
/// Exactly a half circle: the two straights are antiparallel and never cross at
/// all. Tangency fixes their spacing and nothing else, so the arc stays put.
void a_half_circle_is_laid_and_not_refused() {
Document document;
const double turn = std::numbers::pi;
const TrackId id = add_track(document, {line(document, 150.0),
arc(document, 250.0, -1, 250.0 * turn),
line(document, 400.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr && before.diagnostics.empty());
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
Track rebuilt = document.tracks[0];
const bool ok = lay_along_skeleton(rebuilt, skeleton, why);
CHECK(ok);
if (!ok) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(near(rebuilt.elements[1].length, 250.0 * turn, 1e-6));
CHECK(near(rebuilt.elements[0].length, 150.0, 1e-6));
CHECK(near(rebuilt.elements[2].length, 400.0, 1e-6));
}
/// Swinging the middle straight: the arcs beside it re-fit, keeping their radii,
/// and the straights that do not touch it keep their bearing to the last bit.
void swinging_one_straight_leaves_the_others_pointing_where_they_did() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr);
const double first_azimuth = azimuth(laid->elements[0]);
const double last_azimuth = azimuth(laid->elements[4]);
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
// Swing the middle line about the point the straight starts on, by 4°.
const double angle = 4.0 * std::numbers::pi / 180.0;
Support& middle = skeleton.lines[1];
middle.px = laid->elements[2].start.x;
middle.py = laid->elements[2].start.y;
const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle);
const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle);
middle.dx = dx;
middle.dy = dy;
Track moved = document.tracks[0];
CHECK(lay_along_skeleton(moved, skeleton, why));
document.tracks[0] = moved;
const Solution after = solve(document);
const SolvedTrack* again = find_track(after, id);
CHECK(again != nullptr && after.diagnostics.empty());
// The straights at either end never moved: same bearing, to the micro-radian.
CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12));
CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12));
// The arcs kept their radii and changed only how far they run.
CHECK(near(again->elements[1].k0, 1.0 / 400.0, 1e-12));
CHECK(near(again->elements[3].k0, -1.0 / 300.0, 1e-12));
CHECK(!near(again->elements[1].length, 210.0, 1e-6));
// And the free ends are still exactly where they were.
CHECK(near(again->start.x, laid->start.x, 1e-9));
CHECK(near(again->end.y, laid->end.y, 1e-9));
}
/// What cannot be laid says why, and leaves the caller's track alone.
void a_move_that_cannot_be_laid_is_refused() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
{ // Swing the middle straight far enough that the two arcs overlap on it.
Skeleton bent = skeleton;
const double angle = 60.0 * std::numbers::pi / 180.0;
Support& middle = bent.lines[1];
const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle);
const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle);
middle.dx = dx;
middle.dy = dy;
Track candidate = document.tracks[0];
CHECK(!lay_along_skeleton(candidate, bent, why));
CHECK(!why.empty());
// Nothing was written: the caller's copy still reads as it did.
CHECK(candidate.elements[0].length == document.tracks[0].elements[0].length);
}
{ // A transition that does not run out into the straight after it is a kink.
Document other;
Track track;
track.id = mint_track(other);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0),
Element{mint_element(other), Kind::Clothoid, 400.0, 1, 40.0, Free{}},
line(other, 100.0)};
other.tracks.push_back(track);
const Solution solution = solve(other);
Skeleton none;
CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why));
}
{ // Two arcs turning opposite ways inside one corner is an S, not a basket.
Document other;
Track track;
track.id = mint_track(other);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0),
arc(other, 400.0, -1, 100.0), line(other, 100.0)};
other.tracks.push_back(track);
const Solution solution = solve(other);
Skeleton none;
CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why));
}
}
/// The everyday main-line corner: straight, transition, arc, transition, straight.
/// The transitions are authored, so they keep their lengths; the arc takes what
/// is left of the turn.
void a_corner_with_transitions_re_fits() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {
line(document, 400.0),
Element{mint_element(document), Kind::Clothoid, 600.0, 1, 80.0, Free{}},
arc(document, 600.0, 1, 300.0),
Element{mint_element(document), Kind::Clothoid, 0.0, 1, 80.0, Free{}},
line(document, 400.0)};
document.tracks.push_back(track);
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, track.id);
CHECK(laid != nullptr && before.diagnostics.empty());
const double first_azimuth = azimuth(laid->elements[0]);
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(skeleton.lines.size() == 2);
// Put it back untouched: everything comes out as it went in.
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) {
CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-6));
}
// Now swing the second straight by 3°: the transitions keep their lengths,
// the arc takes up the difference, and the first straight does not budge.
const double angle = 3.0 * std::numbers::pi / 180.0;
Support& second = skeleton.lines[1];
const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle);
const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle);
second.dx = dx;
second.dy = dy;
Track moved = document.tracks[0];
CHECK(lay_along_skeleton(moved, skeleton, why));
CHECK(near(moved.elements[1].length, 80.0, 1e-12));
CHECK(near(moved.elements[3].length, 80.0, 1e-12));
CHECK(!near(moved.elements[2].length, 300.0, 1e-6));
document.tracks[0] = moved;
const Solution after = solve(document);
CHECK(after.diagnostics.empty());
const SolvedTrack* again = find_track(after, track.id);
CHECK(again != nullptr);
CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12));
// The transition still hands the arc exactly the curvature it eased to.
CHECK(near(again->elements[1].k1, 1.0 / 600.0, 1e-12));
CHECK(near(again->elements[2].k0, 1.0 / 600.0, 1e-12));
}
/// A basket curve — two arcs of different radii turning the same way. Re-fitting
/// shares the corner's turn out between them in the proportions they were drawn
/// with, so it stays the same basket, tighter or wider.
void a_basket_curve_keeps_its_proportions() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(document, 400.0), arc(document, 800.0, 1, 160.0),
arc(document, 400.0, 1, 120.0), line(document, 400.0)};
document.tracks.push_back(track);
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, track.id);
CHECK(laid != nullptr && before.diagnostics.empty());
const double wide = 160.0 / 800.0; // what each arc turns, as drawn
const double tight = 120.0 / 400.0;
const double first_azimuth = azimuth(laid->elements[0]);
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
CHECK(near(rebuilt.elements[1].length, 160.0, 1e-9));
CHECK(near(rebuilt.elements[2].length, 120.0, 1e-9));
// Swing the far straight: both arcs give, in proportion, and neither radius
// changes.
const double angle = 5.0 * std::numbers::pi / 180.0;
Support& second = skeleton.lines[1];
const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle);
const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle);
second.dx = dx;
second.dy = dy;
Track moved = document.tracks[0];
CHECK(lay_along_skeleton(moved, skeleton, why));
CHECK(moved.elements[1].radius == 800.0);
CHECK(moved.elements[2].radius == 400.0);
const double now_wide = moved.elements[1].length / 800.0;
const double now_tight = moved.elements[2].length / 400.0;
CHECK(!near(now_wide, wide, 1e-9));
CHECK(near(now_wide / now_tight, wide / tight, 1e-9));
document.tracks[0] = moved;
const Solution after = solve(document);
CHECK(after.diagnostics.empty());
CHECK(near(azimuth(find_track(after, track.id)->elements[0]), first_azimuth, 1e-12));
}
/// Changing a radius is the corner's business and nobody else's: the straights
/// on either side give, and nothing past them moves a millimetre.
void a_new_radius_is_absorbed_by_its_own_corner() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr);
const double last_azimuth = azimuth(laid->elements[4]);
const double end_x = laid->end.x;
const double end_y = laid->end.y;
const double second_arc_length = document.tracks[0].elements[3].length;
// The lines are taken off the track as it stands, then the radius changes.
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
document.tracks[0].elements[1].radius = 250.0;
CHECK(lay_along_skeleton(document.tracks[0], skeleton, why));
const Solution after = solve(document);
const SolvedTrack* again = find_track(after, id);
CHECK(again != nullptr && after.diagnostics.empty());
// A tighter arc turns the same corner, so it is shorter and the straights on
// either side of it grow. The corner beyond is untouched, and so is the end.
CHECK(near(again->elements[1].k0, 1.0 / 250.0, 1e-12));
CHECK(near(document.tracks[0].elements[3].length, second_arc_length, 1e-9));
CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12));
CHECK(near(again->end.x, end_x, 1e-9));
CHECK(near(again->end.y, end_y, 1e-9));
CHECK(near(again->start.x, laid->start.x, 1e-9));
}
/// A track that finishes mid-curve — laid by clicking and then given an arc, so
/// there is no straight after it. The corner at the end has nothing on its far
/// side to be fitted against, so it keeps what it was given and the track simply
/// ends where it takes it. Turning it the other way is an ordinary edit, not a
/// reason to refuse the whole chain.
void a_chain_ending_on_a_curve_still_re_fits() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr && before.diagnostics.empty());
const double first_azimuth = azimuth(laid->elements[0]);
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(skeleton.lines.size() == 2);
// Turn the trailing arc the other way. Everything before it stays put; the
// arc keeps the length it was given and takes the track off elsewhere.
document.tracks[0].elements[3].hand = 1;
CHECK(lay_along_skeleton(document.tracks[0], skeleton, why));
CHECK(near(document.tracks[0].elements[3].length, 180.0, 1e-12));
const Solution after = solve(document);
const SolvedTrack* again = find_track(after, id);
CHECK(again != nullptr && after.diagnostics.empty());
CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12));
CHECK(near(again->elements[2].length, laid->elements[2].length, 1e-9));
CHECK(near(again->elements[3].start.x, laid->elements[3].start.x, 1e-9));
CHECK(near(again->elements[3].k0, 1.0 / 300.0, 1e-12));
}
/// And one that starts mid-curve: a branch leaving a frog on a curve. The corner
/// at the front rides along, and the anchor is worked back through it.
void a_chain_starting_on_a_curve_carries_its_head_along() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{10.0, 20.0, 0.3};
track.elements = {arc(document, 250.0, -1, 90.0), line(document, 300.0),
arc(document, 400.0, 1, 210.0), line(document, 250.0)};
document.tracks.push_back(track);
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, track.id);
CHECK(laid != nullptr && before.diagnostics.empty());
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(skeleton.lines.size() == 2);
// Untouched, it comes back exactly as it was — anchor included.
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
CHECK(near(rebuilt.elements[0].length, 90.0, 1e-12));
CHECK(near(std::get<AtPose>(rebuilt.anchor).x, 10.0, 1e-9));
CHECK(near(std::get<AtPose>(rebuilt.anchor).y, 20.0, 1e-9));
CHECK(near(std::get<AtPose>(rebuilt.anchor).az, 0.3, 1e-9));
// Tighten the interior corner: the head keeps its length, and the straights
// beside the corner give.
document.tracks[0].elements[2].radius = 250.0;
CHECK(lay_along_skeleton(document.tracks[0], skeleton, why));
CHECK(near(document.tracks[0].elements[0].length, 90.0, 1e-12));
const Solution after = solve(document);
CHECK(after.diagnostics.empty());
const SolvedTrack* again = find_track(after, track.id);
CHECK(near(again->elements[1].start.x, laid->elements[1].start.x, 1e-9));
CHECK(near(again->elements[2].k0, 1.0 / 250.0, 1e-12));
}
/// The same chain, but hanging off a turnout's frog. Now the head corner may not
/// be slid anywhere — it leaves the frog, and that is that — so the line under
/// the straight behind it follows wherever the curve comes out, and the straight
/// gives up the difference in its length. Which is the only thing here that can.
void a_branch_off_a_frog_lets_its_first_straight_give() {
Document document;
TurnoutType type;
type.name = "Rz 1:9 R190";
type.crossing_n = 9.0;
type.length = 27.138;
type.blade = BladeSpec{0.005, 0.070};
type.pieces = {
TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0},
TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0},
};
document.turnout_types.push_back(type);
const TrackId main = add_track(document, {line(document, 1000.0)});
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = type.name;
placement.on = main;
placement.station = 300.0;
placement.hand = -1;
document.turnouts.push_back(placement);
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga";
branch.anchor = AtPort{placement.id, Port::Frog};
// straight off the frog it is not: the branch carries on curving, then runs out
branch.elements = {arc(document, 300.0, -1, 120.0), line(document, 400.0),
arc(document, 500.0, 1, 150.0), line(document, 300.0)};
document.tracks.push_back(branch);
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, branch.id);
const SolvedTurnout* turnout = find_turnout(before, placement.id);
CHECK(laid != nullptr && turnout != nullptr && before.diagnostics.empty());
if (laid == nullptr || turnout == nullptr) {
return;
}
const double straight_before = document.tracks[1].elements[1].length;
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[1], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(skeleton.pinned);
// Open out the curve leaving the frog. This used to be refused outright.
document.tracks[1].elements[0].radius = 400.0;
const bool relaid = lay_along_skeleton(document.tracks[1], skeleton, why);
CHECK(relaid);
if (!relaid) {
std::printf(" why: %s\n", why.c_str());
return;
}
// The head kept its length — it is authored, not fitted — and the straight
// after it took up what the wider curve cost.
CHECK(near(document.tracks[1].elements[0].length, 120.0, 1e-12));
CHECK(std::abs(document.tracks[1].elements[1].length - straight_before) > 1.0);
const Solution after = solve(document);
CHECK(after.diagnostics.empty());
const SolvedTrack* again = find_track(after, branch.id);
CHECK(again != nullptr);
if (again == nullptr) {
return;
}
// Still on the frog, and the far end still runs where it ran.
CHECK(near(again->start.x, turnout->frog.x, 1e-9));
CHECK(near(again->start.y, turnout->frog.y, 1e-9));
CHECK(near(again->elements[3].start.hx, laid->elements[3].start.hx, 1e-9));
CHECK(near(again->elements[3].start.hy, laid->elements[3].start.hy, 1e-9));
}
/// A transition curve's radius is the one it ends on, so it belongs to what
/// comes after it. The transition running out of an arc into a straight ends
/// flat, and no amount of editing the arc may put the arc's radius on it — that
/// would be a transition easing from R to R, which eases nothing.
void a_joint_only_ever_looks_forward() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {
line(document, 400.0),
Element{mint_element(document), Kind::Clothoid, 600.0, 1, 80.0, Free{}},
arc(document, 600.0, 1, 300.0),
Element{mint_element(document), Kind::Clothoid, 0.0, 1, 80.0, Free{}},
line(document, 400.0)};
document.tracks.push_back(track);
// Editing the arc carries its radius back onto the transition that eases into
// it, and leaves the one running out of it flat.
document.tracks[0].elements[2].radius = 450.0;
sync_joint(document.tracks[0], 2);
CHECK(document.tracks[0].elements[1].radius == 450.0);
CHECK(document.tracks[0].elements[3].radius == 0.0);
// Turning it the other way carries the hand back the same way.
document.tracks[0].elements[2].hand = -1;
sync_joint(document.tracks[0], 2);
CHECK(document.tracks[0].elements[1].hand == -1);
// And the chain still lays: nothing anywhere eases from a radius to itself.
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
// Editing the entry transition carries forward onto its arc.
document.tracks[0].elements[1].radius = 700.0;
sync_joint(document.tracks[0], 1);
CHECK(document.tracks[0].elements[2].radius == 700.0);
// A transition that ends flat has no joint to carry anywhere.
document.tracks[0].elements[3].hand = 1;
sync_joint(document.tracks[0], 3);
CHECK(document.tracks[0].elements[4].kind == Kind::Line);
CHECK(solve(document).diagnostics.empty());
}
/// Two straights end to end are one straight, and the solver says so.
void touching_straights_are_reported() {
Document document;
add_track(document, {line(document, 100.0), line(document, 150.0)});
const Solution solution = solve(document);
bool found = false;
for (const auto& diagnostic : solution.diagnostics) {
found = found || diagnostic.code == Code::TouchingStraights;
}
CHECK(found);
// It is a rule about writing track down, not about the geometry: it still lays.
CHECK(find_track(solution, document.tracks[0].id)->elements.size() == 2);
}
} // namespace
int main() {
RUN(a_track_survives_being_taken_apart_and_put_back);
RUN(an_arc_past_a_half_circle_stays_where_it_was_drawn);
RUN(a_half_circle_is_laid_and_not_refused);
RUN(swinging_one_straight_leaves_the_others_pointing_where_they_did);
RUN(a_move_that_cannot_be_laid_is_refused);
RUN(a_corner_with_transitions_re_fits);
RUN(a_basket_curve_keeps_its_proportions);
RUN(a_new_radius_is_absorbed_by_its_own_corner);
RUN(a_chain_ending_on_a_curve_still_re_fits);
RUN(a_chain_starting_on_a_curve_carries_its_head_along);
RUN(a_branch_off_a_frog_lets_its_first_straight_give);
RUN(a_joint_only_ever_looks_forward);
RUN(touching_straights_are_reported);
return REPORT();
}

View File

@@ -0,0 +1,216 @@
/*
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 <cmath>
#include <limits>
#include <string>
#include <vector>
#include "check.hpp"
#include <cstdint>
import eu07.editor.plan_tilecache;
using namespace editor::plan;
namespace {
// --- tile grid geometry --------------------------------------------------
void grid_is_fixed_100m_cells() {
// One fixed grid: every cell is 100 m × 100 m regardless of the zoom arg.
CHECK(std::abs(TileGrid::kCellMetres - 100.0) < 1e-9);
CHECK(std::abs(TileGrid::tile_span() - 100.0) < 1e-6);
CHECK(std::abs(TileGrid::tile_span(14) - 100.0) < 1e-6); // zoom ignored
// Resolution is constant: 100 m over 1024 px.
CHECK(std::abs(TileGrid::resolution() - 100.0 / 1024.0) < 1e-9);
CHECK(std::abs(TileGrid::resolution(7) - 100.0 / 1024.0) < 1e-9);
}
void tile_at_and_bbox_are_consistent() {
const double x = 500123.0; // somewhere in EPSG:2180 (Poland)
const double y = 300456.0;
const TileKey key = TileGrid::tile_at(x, y);
const TileBBox box = TileGrid::bbox(key);
// Every tile key sits on the single fixed level.
CHECK(key.zoom == TileGrid::kLevel);
// The point must fall inside its own cell's ground box.
CHECK(box.min_x <= x && x < box.max_x);
CHECK(box.min_y <= y && y < box.max_y);
// The box edge length must equal the fixed 100 m cell span.
CHECK(std::abs((box.max_x - box.min_x) - 100.0) < 1e-6);
CHECK(std::abs((box.max_y - box.min_y) - 100.0) < 1e-6);
// Neighbouring cells are exactly one span (100 m) apart.
const TileBBox right = TileGrid::bbox(TileKey{TileGrid::kLevel, key.x + 1, key.y});
CHECK(std::abs(right.min_x - box.max_x) < 1e-6);
}
void zoom_for_resolution_is_single_level() {
// There is no pyramid: any target resolution maps to the one fixed level.
for (double mpp : {50.0, 8.0, 1.0, 0.25}) {
CHECK(TileGrid::zoom_for_resolution(mpp) == TileGrid::kLevel);
}
}
void tiles_for_view_covers_and_caps() {
const double span = 100.0; // fixed cell
// A view roughly 3x2 cells.
TileBBox view{500000.0, 300000.0, 500000.0 + 2.5 * span, 300000.0 + 1.5 * span};
const auto tiles = TileGrid::tiles_for_view(view);
CHECK(!tiles.empty());
// Every corner cell of the view must be present.
const TileKey lo = TileGrid::tile_at(view.min_x, view.min_y);
const TileKey hi = TileGrid::tile_at(view.max_x, view.max_y);
bool has_lo = false, has_hi = false;
for (const auto& t : tiles) {
if (t == lo) has_lo = true;
if (t == hi) has_hi = true;
}
CHECK(has_lo && has_hi);
// The cap is honoured.
TileBBox huge{0.0, 0.0, 1e9, 1e9};
const auto capped = TileGrid::tiles_for_view(huge, TileGrid::kLevel, 32);
CHECK(static_cast<int>(capped.size()) <= 32);
const TileKey centre = TileGrid::tile_at(5e8, 5e8);
bool has_centre = false;
for (const auto& tile : capped) {
if (tile == centre) has_centre = true;
}
CHECK(has_centre); // cap is centred, not taken from a remote corner
const double infinity = std::numeric_limits<double>::infinity();
CHECK(TileGrid::tiles_for_view({0.0, 0.0, infinity, 1.0}).empty());
}
// --- WMS URL -------------------------------------------------------------
void getmap_url_is_wms_1_1_1_epsg2180() {
WmsConfig cfg = WmsConfig::geoportal_ortho();
const TileBBox box{100.0, 200.0, 300.0, 400.0};
const std::string url = wms_getmap_url(cfg, box);
CHECK(url.find("SERVICE=WMS") != std::string::npos);
CHECK(url.find("VERSION=1.1.1") != std::string::npos);
CHECK(url.find("REQUEST=GetMap") != std::string::npos);
CHECK(url.find("SRS=EPSG:2180") != std::string::npos);
// BBOX in minx,miny,maxx,maxy order.
CHECK(url.find("BBOX=100,200,300,400") != std::string::npos);
CHECK(url.find("WIDTH=1024") != std::string::npos);
}
// --- cache + injected fetch ---------------------------------------------
void miss_fetches_then_hit_is_served_from_cache() {
TileService service;
int fetch_calls = 0;
// Synchronous injected fetcher that returns three canned bytes.
service.set_fetcher([&](const std::string& url,
TileService::DoneFn done) {
++fetch_calls;
CHECK(url.find("BBOX=") != std::string::npos);
done(true, std::vector<std::uint8_t>{1, 2, 3}, "image/jpeg");
});
int ready_events = 0;
TileKey last_ready{};
service.set_on_ready([&](TileKey k) {
++ready_events;
last_ready = k;
});
const TileKey key{12, 3, 4};
CHECK(!service.has(key));
// First request: a miss, so exactly one fetch and the tile becomes Ready.
const TileStatus first = service.request(key);
CHECK(first == TileStatus::Ready); // fetcher was synchronous
CHECK(fetch_calls == 1);
CHECK(ready_events == 1);
CHECK(last_ready == key);
CHECK(service.has(key));
const TileImage* image = service.peek(key);
CHECK(image != nullptr);
CHECK(image->bytes.size() == 3);
CHECK(image->content_type == "image/jpeg");
// Second request for the same tile: a hit, no extra fetch.
const TileStatus second = service.request(key);
CHECK(second == TileStatus::Ready);
CHECK(fetch_calls == 1); // unchanged: served from cache
CHECK(service.size() == 1);
}
void failed_fetch_is_retried_until_success() {
// A failure is not terminal: the service retries until a tile arrives. With
// a synchronous injected fetcher the retries unwind inline, so a fetcher that
// fails twice then succeeds must end Ready after exactly three attempts.
TileService service;
int calls = 0;
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
++calls;
if (calls < 3) {
done(false, {}, {}); // fail
} else {
done(true, std::vector<std::uint8_t>{7}, "image/jpeg"); // then succeed
}
});
const TileKey key{10, 1, 1};
const TileStatus status = service.request(key);
CHECK(status == TileStatus::Ready);
CHECK(calls == 3);
CHECK(service.has(key));
const TileImage* image = service.peek(key);
CHECK(image != nullptr);
CHECK(image->bytes.size() == 1);
}
void retry_gives_up_after_ten_attempts() {
// A tile that never loads is retried at most 10 times, then marked Failed.
// With a synchronous always-failing fetcher that is 1 initial + 10 retries.
TileService service;
int calls = 0;
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
++calls;
done(false, {}, {});
});
const TileKey key{2, 2, 2};
CHECK(service.request(key) == TileStatus::Failed);
CHECK(calls == 11); // initial + kMaxRetries (10)
CHECK(!service.has(key));
CHECK(service.peek(key) == nullptr);
}
void store_seeds_cache_directly() {
TileService service;
const TileKey key{9, 2, 2};
service.store(key, std::vector<std::uint8_t>{9, 9}, "image/png");
CHECK(service.has(key));
// A request now is a pure hit even with no fetcher installed.
CHECK(service.request(key) == TileStatus::Ready);
}
} // namespace
int main() {
RUN(grid_is_fixed_100m_cells);
RUN(tile_at_and_bbox_are_consistent);
RUN(zoom_for_resolution_is_single_level);
RUN(tiles_for_view_covers_and_caps);
RUN(getmap_url_is_wms_1_1_1_epsg2180);
RUN(miss_fetches_then_hit_is_served_from_cache);
RUN(failed_fetch_is_retried_until_success);
RUN(retry_gives_up_after_ten_attempts);
RUN(store_seeds_cache_directly);
return REPORT();
}

View File

@@ -0,0 +1,457 @@
/*
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 <cmath>
#include <numbers>
#include "check.hpp"
#include <initializer_list>
#include <vector>
import eu07.editor.plan_turnout_geometry;
// A turnout is laid from the list its catalogue entry gives, piece by piece, and
// nothing in it is fitted: whether the list keeps the promises of the crossing
// mark and the catalogue length is reported, not corrected. The blade is
// dimensioned on top of that, the way Koc's rys. 5.9 dimensions an iglica styczna
// do opornicy o ściętym ostrzu: the curve is theoretically tangent to the stock
// rail at PR, but the blade only starts at a, and its planed nose runs on to A
// where the rail is u thick.
using namespace editor::plan;
using editor::plan::geometry::Pose;
namespace {
constexpr double kAlfa19 = 0.11065722117389563; // atan(1/9)
constexpr double kStart[]{0.0, 0.0, 1.0, 0.0};
// Rz 49E1-190-1:9 as the drawings have it: u and d from Koc's tablica 5.1, the
// rear tangent from rys. 5.13 (27138 = 10523 + 16615, krzyżownica prosta). The
// blade ends where the gap to the stock rail takes the przekładka in its heel.
constexpr double kRadius = 190.0;
constexpr double kTipThickness = 0.0049; // u
constexpr double kNose = 0.1250; // d
constexpr double kRailtop = 0.070; // w
constexpr double kHeelOffset = 0.136;
constexpr double kBackTangent = 16.615; // b
// a: the odcinek przediglicowy, derived from u and d rather than copied — the gap
// opens on the blade's own curve, and the station it opens at is on the axis
const double kLead = blade_run_to_offset(kRadius, 0.0, kTipThickness) - kNose;
const double kBlade = blade_run_to_offset(kRadius, 0.0, kHeelOffset) - kLead;
// The gap @p pose has opened between the blade and the stock rail: the axis offset
// plus what half a gauge of rail swings out of the way as the path turns off.
double gap_at(const Pose& start, const Pose& pose) {
const double offset = (pose.x - start.x) * -start.hy + (pose.y - start.y) * start.hx;
const double heading = std::atan2(pose.hy * start.hx - pose.hx * start.hy,
pose.hx * start.hx + pose.hy * start.hy);
return offset + kHalfGauge * (1.0 - std::cos(heading));
}
const double kFrontTangent = kRadius * std::tan(0.5 * kAlfa19);
const double kFrogRail = kBackTangent - kFrontTangent;
Turnout rz_1_9_r190() {
Turnout turnout{CrossingMark{9.0},
DivergeSide::Left,
{},
kFrontTangent + kBackTangent,
Blade{kTipThickness, kNose, kRailtop}};
turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, kLead, kRadius, kRadius, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, kBlade, kRadius, kRadius, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve,
kRadius * kAlfa19 - kLead - kBlade, kRadius,
kRadius, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::FrogRail, kFrogRail, 0.0, 0.0, 0.0});
return turnout;
}
TurnoutGeometry laid(const Turnout& turnout) {
return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout);
}
// Walks the laid path and returns the pose at the end of segment @p index.
Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) {
Pose pose{0.0, 0.0, 1.0, 0.0};
for (std::size_t i = 0; i <= index; ++i) {
const auto& segment = geometry.path[i];
if (segment.turn_in != 0.0) {
const double c = std::cos(segment.turn_in);
const double s = std::sin(segment.turn_in);
pose = Pose{pose.x, pose.y, pose.hx * c - pose.hy * s,
pose.hx * s + pose.hy * c};
}
pose = editor::plan::geometry::layout_segment(segment.k0, segment.k1, segment.length, pose,
nullptr);
}
return pose;
}
void a_tangential_blade_never_breaks_away() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
for (const auto& segment : geometry.path) {
CHECK(segment.turn_in == 0.0);
}
// the curve is there from PR: the odcinek przediglicowy is already on it, the
// vehicle is simply still on the through track
CHECK(std::abs(geometry.path[0].k0 - 1.0 / kRadius) < 1e-12);
CHECK(std::abs(geometry.path[1].k0 - 1.0 / kRadius) < 1e-12);
// the blade begins where its piece does, a from PR
CHECK(geometry.has_blade);
CHECK(std::abs(geometry.blade_station - kLead) < 1e-12);
CHECK(std::abs(geometry.blade_tip.x - kRadius * std::sin(kLead / kRadius)) < 1e-9);
CHECK(std::abs(geometry.blade_tip.y -
kRadius * (1.0 - std::cos(kLead / kRadius))) < 1e-9);
}
void a_planed_nose_reaches_its_thickness_where_the_table_says() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
const Pose start{kStart[0], kStart[1], kStart[2], kStart[3]};
// A: where the blade stands u away from the stock rail, and where the tangent
// there is the kąt przylegania — a couple of dozen minutes
CHECK(std::abs(gap_at(start, geometry.blade_nose_end) - kTipThickness) < 1e-9);
CHECK(std::abs(geometry.blade_angle - geometry.nose_end / kRadius) < 1e-9);
CHECK(geometry.blade_angle * 180.0 / std::numbers::pi > 0.40);
CHECK(geometry.blade_angle * 180.0 / std::numbers::pi < 0.42);
// a was cut so that A lands on a + d, so a type carrying all three agrees with
// the curve it is laid on and the residual is nothing
CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-12);
CHECK(std::abs(geometry.blade_residual) < 1e-12);
// the planing runs from the ostrze to full railhead width
CHECK(std::abs(geometry.planing_end - blade_run_to_offset(kRadius, 0.0, kRailtop)) <
1e-12);
CHECK(std::abs(gap_at(start, geometry.blade_planed) - kRailtop) < 1e-9);
CHECK(std::abs(geometry.planing_length - (geometry.planing_end - kLead)) < 1e-12);
CHECK(geometry.planing_end > geometry.nose_end);
}
void a_lead_derived_from_u_and_d_lands_on_the_table() {
// Koc, tablica 5.1: a, d and u for the iglice styczne do opornicy o ściętym
// ostrzu. They obey a + d = the run to a gap of u, which is what lets a preset
// carry only u and d. The table dimensions it on the rail, as a drawing does, so
// the axis station is scaled back up by R1/R; the numbers then agree to a hair at
// 1200 m and to millimetres at 190
struct Row {
double radius, a, d, u, tolerance;
};
constexpr Row rows[]{
{190.0, 1.2483, 0.1250, 0.0049, 7e-3}, {300.0, 1.5000, 0.1250, 0.0044, 2e-3},
{500.0, 2.1650, 0.1250, 0.0053, 1.4e-2}, {760.0, 2.6330, 0.1250, 0.0050, 2e-4},
{1200.0, 3.3347, 0.1304, 0.0050, 1e-4},
};
for (const auto& row : rows) {
const double rail = row.radius + kHalfGauge;
const double drawn =
blade_run_to_offset(row.radius, 0.0, row.u) * rail / row.radius;
CHECK(std::abs(drawn - (row.a + row.d)) < row.tolerance);
}
}
void a_straight_blade_is_planed_by_its_break() {
// iglica prosta sieczna: the same two dimensions, taken off the break angle
const double beta = 0.02;
Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0,
Blade{0.005, 0.0, 0.070}};
turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, 3.5, 0.0, 0.0, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, 6.0, 0.0, 0.0, beta});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, 190.0 * (kAlfa19 - beta),
190.0, 190.0, 0.0});
const auto geometry = laid(turnout);
CHECK(geometry.valid);
// the lead runs along the through track and the blade tip sits at its end
CHECK(geometry.blade_station == 3.5);
CHECK(std::abs(geometry.blade_tip.x - 3.5) < 1e-9);
CHECK(geometry.blade_tip.y == 0.0);
CHECK(std::abs(geometry.nose_end - (3.5 + 0.005 / std::sin(beta))) < 1e-12);
CHECK(std::abs(geometry.planing_length - 0.070 / std::sin(beta)) < 1e-12);
// Templot says the same length along the stock rail rather than along the blade
CHECK(std::abs(geometry.planing_length * std::cos(beta) - 0.070 / std::tan(beta)) <
1e-12);
// the break itself is the kąt przylegania here: the blade is straight
CHECK(std::abs(geometry.blade_angle - beta) < 1e-12);
}
void a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start() {
auto turnout = rz_1_9_r190();
turnout.blade = Blade{0.0, 0.0, 0.0};
const auto geometry = laid(turnout);
CHECK(geometry.valid);
CHECK(geometry.has_blade);
CHECK(std::abs(geometry.blade_station - kLead) < 1e-12);
CHECK(geometry.nose_end == 0.0);
CHECK(geometry.planing_length == 0.0);
CHECK(geometry.blade_residual == 0.0);
}
void a_catalogue_list_closes_on_its_own_numbers() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
CHECK(std::abs(geometry.angle_residual) < 1e-9);
CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9);
// the two tangent legs are the drawing's a and b, and L is their sum
CHECK(std::abs(geometry.tangent_front - 10.523) < 1e-3);
CHECK(std::abs(geometry.tangent_back - 16.615) < 1e-3);
CHECK(std::abs(geometry.tangent_front + geometry.tangent_back - 27.138) < 1e-3);
CHECK(std::abs(geometry.length_residual) < 1e-9);
}
void the_switch_ends_on_the_straight_too() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
// koniec rozjazdu as the straight sees it: the frog projected back onto it, which
// is shorter than a + b and is the second point marked on the tor zasadniczy
CHECK(std::abs(geometry.through_length -
(kRadius * std::sin(kAlfa19) + kFrogRail * std::cos(kAlfa19))) < 1e-9);
CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back);
CHECK(std::abs(geometry.through_end.x - geometry.through_length) < 1e-12);
CHECK(geometry.through_end.y == 0.0);
CHECK(geometry.through_end.hx == 1.0);
}
void a_list_that_misses_the_mark_is_still_laid_and_said_out_loud() {
auto turnout = rz_1_9_r190();
turnout.path[2].length -= 2.0; // two metres of curve short of the crossing angle
const auto geometry = laid(turnout);
CHECK(geometry.valid); // it lays: the numbers are the author's business
CHECK(std::abs(geometry.angle_residual + 2.0 / kRadius) < 1e-9);
CHECK(geometry.length_residual != 0.0);
}
void a_degenerate_piece_is_refused() {
auto turnout = rz_1_9_r190();
turnout.path[2].length = 0.0;
CHECK(!laid(turnout).valid);
turnout = rz_1_9_r190();
turnout.path[1].radius_start = -190.0;
CHECK(!laid(turnout).valid);
turnout = rz_1_9_r190();
turnout.path.clear();
CHECK(!laid(turnout).valid);
}
void a_right_hand_turnout_is_the_mirror_of_a_left_one() {
const auto left = laid(rz_1_9_r190());
auto turnout = rz_1_9_r190();
turnout.side = DivergeSide::Right;
const auto geometry = laid(turnout);
CHECK(geometry.valid);
CHECK(std::abs(geometry.path[1].k0 + 1.0 / kRadius) < 1e-12);
CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + kAlfa19) < 1e-9);
CHECK(std::abs(geometry.through_length - left.through_length) < 1e-9);
// the residual is not signed by the hand: a right turnout closes just as well
CHECK(std::abs(geometry.angle_residual) < 1e-9);
// and the blade is dimensioned the same, on the other side
CHECK(std::abs(geometry.nose_end - left.nose_end) < 1e-12);
CHECK(std::abs(geometry.blade_angle - left.blade_angle) < 1e-12);
CHECK(std::abs(geometry.blade_nose_end.y + left.blade_nose_end.y) < 1e-12);
}
void a_basket_is_three_pieces_and_nothing_special() {
// łuk koszowy: 190 m eased to 300 m by a clothoid, ending on the same angle
const double first = 8.0;
const double transition = 6.0;
// deflection: first/190 + transition*(1/190 + 1/300)/2 + rest/300 == alfa
const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0);
const double rest = (kAlfa19 - turned) * 300.0;
Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0,
Blade{0.0049, 0.125, 0.070}};
turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, rest, 300.0, 300.0, 0.0});
const auto geometry = laid(turnout);
CHECK(geometry.valid);
CHECK(std::abs(geometry.angle_residual) < 1e-9);
CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9);
const Pose end = pose_after(geometry, geometry.path.size() - 1);
CHECK(std::abs(end.x - geometry.frog.x) < 1e-9);
CHECK(std::abs(end.y - geometry.frog.y) < 1e-9);
// a blade starting at PR has no lead ahead of it, so a is nothing and the
// residual is what the type's own d asks for against the curve
CHECK(geometry.blade_station == 0.0);
CHECK(std::abs(geometry.blade_residual - (0.125 - geometry.nose_end)) < 1e-12);
}
TurnoutGeometry laid_bent(const Turnout& turnout, double bend) {
return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout, bend);
}
// The bend that turns the tor zasadniczy by @p beta over the turnout. Every leg of
// the triangle has the same tangent t, so a radius is t/tg(half its own angle).
double bend_for(double beta) { return std::tan(0.5 * beta) / kFrontTangent; }
void a_bend_of_nothing_is_the_turnout_it_was() {
const auto plain = laid(rz_1_9_r190());
const auto bent = laid_bent(rz_1_9_r190(), 0.0);
CHECK(bent.valid);
CHECK(bent.bend == 0.0);
CHECK(bent.diverging_curvature == 0.0);
CHECK(bent.path.size() == plain.path.size());
for (std::size_t i = 0; i < plain.path.size(); ++i) {
CHECK(bent.path[i].k0 == plain.path[i].k0);
CHECK(bent.path[i].length == plain.path[i].length);
}
CHECK(bent.through_length == plain.through_length);
CHECK(bent.nose_end == plain.nose_end);
}
void a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls() {
// Koc, 5.3.6: Rłs 49E1-380,292-1:9 z krzyżownicą prostą powstaje przez wygięcie
// Rz 49E1-190-1:9. Symmetric means the two tracks split the crossing angle, so
// the tor zasadniczy turns half of it the other way and both radii come out the
// same — and the catalogue's own 380,292 is what they come out at
const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19));
CHECK(geometry.valid);
CHECK(geometry.bend < 0.0);
CHECK(geometry.diverging_curvature > 0.0);
CHECK(std::abs(std::abs(1.0 / geometry.bend) - 380.292) < 0.02);
CHECK(std::abs(std::abs(1.0 / geometry.diverging_curvature) - 380.292) < 0.02);
CHECK(std::abs(geometry.bend_angle + 0.5 * kAlfa19) < 1e-12);
}
void a_bend_lands_on_kocs_own_formulas() {
// (5.7) jednostronny and (5.8) dwustronny. The second is written here with the
// sign our curvature carries openly: Koc's R - R1 denominator is the same figure
// read off a drawing that has already decided which way the branch goes
const double t2 = kFrontTangent * kFrontTangent;
for (const double r1 : {320.0, 600.0, 2000.0}) {
const auto same = laid_bent(rz_1_9_r190(), 1.0 / r1);
CHECK(same.valid);
CHECK(std::abs(1.0 / same.diverging_curvature -
(kRadius * r1 - t2) / (kRadius + r1)) < 1e-9);
// bending with the branch makes it sharper, which is why R190 may not be
CHECK(1.0 / same.diverging_curvature < kRadius);
const auto against = laid_bent(rz_1_9_r190(), -1.0 / r1);
CHECK(against.valid);
CHECK(std::abs(1.0 / against.diverging_curvature -
(kRadius * r1 + t2) / (r1 - kRadius)) < 1e-9);
CHECK(1.0 / against.diverging_curvature > kRadius);
}
}
void a_bend_that_straightens_the_branch_still_lays() {
// wygięcie dwustronne aż tor zwrotny wychodzi prosty: the triangle is all
// tangent and no arc, so the path from PR is 2t of straight and the frog rail
const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-kAlfa19));
CHECK(geometry.valid);
CHECK(geometry.diverging_curvature == 0.0);
CHECK(std::abs(geometry.diverging_length -
(2.0 * kFrontTangent + kFrogRail)) < 1e-9);
CHECK(std::abs(geometry.frog.hy) < 1e-12); // it runs dead straight
}
void bending_leaves_the_triangle_alone() {
// what łukowanie does not touch: the crossing angle, the tangent legs, and so
// the catalogue length. that is the whole point of rotating the triangle
for (const double beta : {-0.5 * kAlfa19, -0.02, 0.01, 0.03}) {
const auto geometry = laid_bent(rz_1_9_r190(), bend_for(beta));
CHECK(geometry.valid);
CHECK(std::abs(geometry.angle_residual) < 1e-9);
CHECK(std::abs(geometry.tangent_front - kFrontTangent) < 1e-9);
CHECK(std::abs(geometry.tangent_back - kBackTangent) < 1e-9);
CHECK(std::abs(geometry.length_residual) < 1e-9);
CHECK(std::abs(geometry.bend_angle - beta) < 1e-12);
}
}
void a_bent_switch_keeps_its_blade() {
// Koc: zwrotnica i krzyżownica pozostają bez zmian co do wymiarów, należy je
// tylko wygiąć. The blade opens against an opornica that curves with it, so what
// dimensions it is the difference of the two — and a + d still lands on A, to
// well under the millimetre the rail is cut to
const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19));
CHECK(geometry.valid);
CHECK(geometry.has_blade);
// the rails are the same rails: a and the blade keep their lengths exactly
CHECK(geometry.blade_station == kLead);
CHECK(std::abs(geometry.path[1].length - kBlade) < 1e-12);
CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-3);
CHECK(std::abs(geometry.blade_residual) < 1e-3);
CHECK(geometry.blade_angle > 0.0);
// and the tip sits on the bent path: still off to the left, but half as far as
// it was, because the tor zasadniczy now runs away under it
const auto plain = laid(rz_1_9_r190());
CHECK(geometry.blade_tip.y > 0.0);
CHECK(geometry.blade_tip.y < plain.blade_tip.y);
}
void the_bent_switch_ends_on_its_own_arc() {
const double bend = bend_for(-0.5 * kAlfa19);
const auto geometry = laid_bent(rz_1_9_r190(), bend);
CHECK(geometry.valid);
// koniec rozjazdu is the frog brought back onto the tor zasadniczy along the
// radius, so it lands on the arc rather than on the straight PR set out along
const double radius = 1.0 / bend;
const double centre_y = radius;
CHECK(std::abs(std::hypot(geometry.through_end.x,
geometry.through_end.y - centre_y) - std::abs(radius)) < 1e-9);
CHECK(geometry.through_length > 0.0);
CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back);
CHECK(std::abs(geometry.through_end.y) > 1e-6); // the through track has left y=0
}
void a_path_with_no_one_radius_will_not_bend() {
// a łuk koszowy has no single triangle to rotate about, and a bend guessed at
// one of its radii would be a drawing of nothing
const double first = 8.0;
const double transition = 6.0;
const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0);
Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, Blade{0.0049, 0.125, 0.070}};
turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0});
turnout.path.push_back(
TurnoutPiece{TurnoutPart::Curve, (kAlfa19 - turned) * 300.0, 300.0, 300.0, 0.0});
CHECK(laid(turnout).valid);
CHECK(!laid_bent(turnout, 1.0 / 600.0).valid);
}
void the_tangent_lengths_meet_where_the_tracks_would() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
// the through tangent and the frog tangent meet at one point: walking either
// way from the two ends has to land on it
const double cx = geometry.tangent_front;
CHECK(std::abs(geometry.frog.x - geometry.frog.hx * geometry.tangent_back - cx) < 1e-9);
CHECK(std::abs(geometry.frog.y - geometry.frog.hy * geometry.tangent_back) < 1e-9);
}
} // namespace
int main() {
RUN(a_tangential_blade_never_breaks_away);
RUN(a_planed_nose_reaches_its_thickness_where_the_table_says);
RUN(a_lead_derived_from_u_and_d_lands_on_the_table);
RUN(a_straight_blade_is_planed_by_its_break);
RUN(a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start);
RUN(a_catalogue_list_closes_on_its_own_numbers);
RUN(the_switch_ends_on_the_straight_too);
RUN(a_list_that_misses_the_mark_is_still_laid_and_said_out_loud);
RUN(a_degenerate_piece_is_refused);
RUN(a_right_hand_turnout_is_the_mirror_of_a_left_one);
RUN(a_basket_is_three_pieces_and_nothing_special);
RUN(the_tangent_lengths_meet_where_the_tracks_would);
RUN(a_bend_of_nothing_is_the_turnout_it_was);
RUN(a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls);
RUN(a_bend_lands_on_kocs_own_formulas);
RUN(a_bend_that_straightens_the_branch_still_lays);
RUN(bending_leaves_the_triangle_alone);
RUN(a_bent_switch_keeps_its_blade);
RUN(the_bent_switch_ends_on_its_own_arc);
RUN(a_path_with_no_one_radius_will_not_bend);
return REPORT();
}

View File

@@ -2,9 +2,9 @@ module;
#include <cmath>
#include <string>
#include <vector>
#include "maj0sted/domain/geometry/turnout.hpp"
module eu07.editor.turnouts;
import eu07.editor.plan_turnout_geometry;
/*
This Source Code Form is subject to the
@@ -47,8 +47,8 @@ turnout_preset curved_tangent(std::string name, double crossing_n, double radius
auto const alfa{std::atan(1.0 / crossing_n)};
auto const tangent{radius * std::tan(0.5 * alfa)};
auto const b{back_tangent > 0.0 ? back_tangent : tangent};
auto const lead{maj0sted::domain::blade_run_to_offset(radius, 0.0, tip_thickness) - nose};
auto const blade{maj0sted::domain::blade_run_to_offset(radius, 0.0, heel_offset) - lead};
auto const lead{editor::plan::blade_run_to_offset(radius, 0.0, tip_thickness) - nose};
auto const blade{editor::plan::blade_run_to_offset(radius, 0.0, heel_offset) - lead};
turnout_preset preset{std::move(name), crossing_n, tangent + b, tip_thickness, nose, railtop_width, {}};
preset.pieces.push_back({0, lead, radius, radius, 0.0});

View File

@@ -19,7 +19,7 @@ namespace editor
{
// one piece of a preset's diverging path, in catalogue order from PR. mirrors
// maj0sted::editor::TurnoutPieceSpec so this header stays free of the library
// editor::plan::TurnoutPieceSpec so this header stays free of the library
struct turnout_preset_piece
{
int part; // 0 przediglicowy, 1 iglica, 2 luk, 3 krzyzownicowa