17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 12:09:19 +02:00
Files
maszyna/editor/plan_document_io.cpp
maj00r 366645ef59 Plan tool: cofanie, złącza, przejścia rozjazdowe, and a demo to open
What the plan tool was missing was not geometry but the ordinary business of
editing: taking a mistake back, dividing what is drawn, and seeing what the next
click will do before making it.

Cofanie. The document is plain data, so a step is a copy of it. What counts as one
step is decided by watching the document - a fingerprint over the tracks, turnouts
and catalogue - rather than by dressing every edit with a call: a frame that
changes anything opens a step, and the step closes when the drawing settles. That
is what makes a drag, or a number typed digit by digit, one step and not thirty,
and nothing can be forgotten on the way in. Ctrl+Z / Ctrl+Y; the scene editor
listens for the same keys, so it hands them over while the plan has the keyboard.

Złącze i wycięcie. `insert_joint` divides whichever element a station lands in and
leaves one chain: the ground does not move, the curvature at the new joint is the
one that was always there, and the fit reads the chain the same way afterwards. It
is refused on a straight, because two straights end to end are one straight and the
skeleton will not read that, and under a turnout, which is one piece of geometry on
one element. `cut_track` takes a piece out and does leave two tracks - a gap is the
ground between them, not a hole in a chain.

Przejście rozjazdowe. A turnout may now stand against another one: where it stands
is then worked out from that one's frog, not authored, so moving either carries the
other with it and a trapez stays shut. The frog has to land on the line leaving the
other frog - not on the track's axis, which is 1,469 m further on for a 1:9 - so
the station is settled by laying the turnout and walking it along until it does.

Drawing is a tool now, not what a click happens to do: until Rysuj is on, a click
picks what is under it. With it on, the cursor at the growing end shows the corner
and the straight the next click would lay, worked out by the same fit_corner the
click itself uses. A chain only grows at a loose end, and the first element of a
track is put down by two clicks instead of landing on the plan's zero.

Also here: the curvature strip under the panel, pikietaż in the rows and at the
cursor, how far the laid axis runs from the clicks it was drawn from, the file
format saying which frame and which units it is in (v9), plans chosen from the
directory instead of typed, and editor/samples/demo.m0s - two parallel tracks and a
trapez - with the generator that wrote it and a test that opens it and demands it
lays clean.
2026-09-14 16:35:36 +02:00

617 lines
24 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 <filesystem>
#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 = 9;
constexpr int kNoPairing = 8; // before a turnout could stand against another one
constexpr int kNoCrs = 7; // before the header said which frame and which units
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";
// which frame the numbers are in, and what they are. everything in here is metres,
// radians and 1/m in PUWG 1992 — said out loud so a reader never has to assume it
out += "crs 2180\n";
out += "units m rad 1/m\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) +
" " + num(static_cast<std::uint32_t>(turnout.opposite)) + "\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 != kNoPairing && version != kNoCrs &&
version != kNoExportPath && version != kStraightOnly && version != kNoseless &&
version != kLegacyTurnoutVersion)) {
return std::nullopt;
}
}
if (version > kNoCrs) {
// the only frame the editor can draw in is PUWG 1992: a plan that says it is in
// another one is not something to place by guesswork
const std::optional<Line> crs = reader.next("crs", 2);
int epsg = 0;
if (!crs || !to_int(crs->tokens[1], epsg) || epsg != 2180) {
return std::nullopt;
}
// units are the same for every plan there is; the line is there to be read by
// whatever comes next, not to be chosen
if (!reader.next("units", 4)) {
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 bool has_pairing = version > kNoPairing;
const std::optional<Line> line =
reader.next("turnout", has_pairing ? 9 : (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;
}
}
if (has_pairing) {
// przejście rozjazdowe: which turnout this one stands against, 0 for none
std::uint32_t opposite = 0;
if (!to_uint(line->tokens[8], opposite)) {
return std::nullopt;
}
placement.opposite = static_cast<TurnoutId>(opposite);
}
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"; }
std::string project_file(const std::string& path) {
std::filesystem::path file{path.empty() ? default_project_path() : path};
if (!file.has_parent_path()) {
// a bare name is a plan, and plans live together in one place rather than
// wherever the program happened to be started from
file = std::filesystem::path{"editor"} / file;
}
return file.generic_string();
}
bool save(const Document& document, const std::string& path) {
const std::filesystem::path file_path{project_file(path)};
// the directory is part of where plans live, so it is made rather than demanded
if (file_path.has_parent_path()) {
std::error_code ignored;
std::filesystem::create_directories(file_path.parent_path(), ignored);
}
std::ofstream file{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{std::filesystem::path{project_file(path)}, std::ios::binary};
if (!file) {
return std::nullopt;
}
std::ostringstream buffer;
buffer << file.rdbuf();
return deserialize(buffer.str());
}
} // namespace editor::plan::io