17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 22:39:19 +02:00
Files
maszyna/editor/plan_document_io.cpp
maj00r 321c9dd7ab 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.
2026-09-13 13:31:01 +02:00

569 lines
22 KiB
C++

/*
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