17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-11-03 17:50:21 +01:00
Files
maszyna/maj0sted/src/app/editor_document.cpp
maj00r 707b4de2f4 Editor checkpoint: turnout domain service, web->editor rename, Id-1 templates
Checkpoint of the editor branch so work isn't carried loose across a branch
switch. Builds.

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

521 lines
21 KiB
C++

#include "maj0sted/app/editor_document.hpp"
#include <cctype>
#include <charconv>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <variant>
#include <vector>
#include "maj0sted/io/project_serializer.hpp"
#include "maj0sted/maj0sted.hpp"
namespace maj0sted::app {
using namespace maj0sted::domain;
using maj0sted::editor::GapFit;
using maj0sted::editor::NiweletaSpec;
using maj0sted::editor::StraightSpec;
namespace {
// Shortest round-trippable, locale-independent double — matches maj0sted::io.
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);
}
std::vector<std::string> tokenize(const std::string& line) {
std::vector<std::string> tokens;
std::size_t i = 0;
const std::size_t n = line.size();
while (i < n) {
while (i < n && std::isspace(static_cast<unsigned char>(line[i]))) ++i;
std::size_t j = i;
while (j < n && !std::isspace(static_cast<unsigned char>(line[j]))) ++j;
if (j > i) tokens.push_back(line.substr(i, j - i));
i = j;
}
return tokens;
}
double to_double(const std::string& text) {
double value{};
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{}) {
throw std::runtime_error{"maj0sted: bad number '" + text + "'"};
}
return value;
}
long to_long(const std::string& text) {
long value{};
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{}) {
throw std::runtime_error{"maj0sted: bad integer '" + text + "'"};
}
return value;
}
// Placeholder turn direction for the resolved-domain view: derived from the two
// straights' headings (sign of the cross product).
TurnDirection gap_direction(const StraightSpec& a, const StraightSpec& b) noexcept {
const double ax = a.x2 - a.x1, ay = a.y2 - a.y1;
const double bx = b.x2 - b.x1, by = b.y2 - b.y1;
return (ax * by - ay * bx) >= 0.0 ? TurnDirection::Left : TurnDirection::Right;
}
double gap_radius(const GapFit* fit) noexcept {
if (fit != nullptr) {
if (fit->radius > 0.0) return fit->radius;
if (!fit->arcs.empty() && fit->arcs.front().radius > 0.0) {
return fit->arcs.front().radius;
}
if (fit->r1 > 0.0) return fit->r1;
}
return 300.0; // benign placeholder; arcs carry no length in this view
}
} // namespace
MapProject to_map_project(const EditorDocument& document) {
MapProject project{Crs::default_crs()};
for (std::size_t index = 0; index < document.niwelety.size(); ++index) {
const NiweletaSpec& spec = document.niwelety[index];
try {
Niweleta niweleta{NiweletaId{static_cast<std::uint64_t>(index + 1)},
spec.name};
for (std::size_t i = 0; i < spec.straights.size(); ++i) {
const StraightSpec& s = spec.straights[i];
if (i > 0) {
// Keep two anchored straights from being adjacent (which the
// alignment forbids) by threading a floating placeholder arc.
const GapFit* fit = nullptr;
for (const auto& f : spec.fits) {
if (static_cast<std::size_t>(f.gap) == i - 1 && f.mode > 0) {
fit = &f;
break;
}
}
niweleta.add_plan_element(
CircularArc{Radius::from_metres(gap_radius(fit)),
gap_direction(spec.straights[i - 1], s)});
}
niweleta.add_plan_element(
Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}});
}
project.add_niweleta(std::move(niweleta));
} catch (...) {
// Degenerate/inconsistent niweleta: keep the (named) shell so the
// project still lists it; the editor section restores the details.
project.add_niweleta(Niweleta{
NiweletaId{static_cast<std::uint64_t>(index + 1)}, spec.name});
}
}
return project;
}
EditorDocument from_map_project(const MapProject& project) {
EditorDocument document;
for (const auto& niweleta : project.niwelety()) {
NiweletaSpec spec;
spec.name = niweleta.name();
for (const auto& element : niweleta.plan().elements()) {
if (const auto* straight = std::get_if<Straight>(&element)) {
spec.straights.push_back(StraightSpec{
straight->start().x(), straight->start().y(),
straight->end().x(), straight->end().y()});
}
}
document.niwelety.push_back(std::move(spec));
}
return document;
}
std::string serialize_document(const EditorDocument& document) {
// Reuse the domain serializer for a real, inspectable maj0sted project...
std::string out = maj0sted::io::serialize(to_map_project(document));
// ...then the lossless editor section (straights + fit parameters).
out += "editor " + std::to_string(document.niwelety.size()) + "\n";
if (document.view_extent > 0.0) {
out += "eview " + num(document.view_x) + " " + num(document.view_y) + " " +
num(document.view_extent) + "\n";
}
if (document.origin_set) {
out += "eorigin " + (document.georeferenced ? std::string{"1"} : "0") + " " +
num(document.origin_x) + " " + num(document.origin_y) + "\n";
}
for (const auto& spec : document.niwelety) {
out += "eniw " + std::to_string(spec.straights.size()) + " " +
std::to_string(spec.fits.size()) + "\n";
out += "ename " + spec.name + "\n";
for (const auto& s : spec.straights) {
out += "estr " + num(s.x1) + " " + num(s.y1) + " " + num(s.x2) + " " +
num(s.y2) + " " + (s.hidden ? "1" : "0") + "\n";
}
for (std::size_t i = 0; i < spec.straights.size(); ++i) {
const auto& s = spec.straights[i];
if (s.rel_kind == 1) {
out += "epar " + std::to_string(i) + " " +
std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) +
" " + num(s.rel_offset) + "\n";
} else if (s.rel_kind == 2) {
out += "eskew " + std::to_string(i) + " " +
std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) +
" " + num(s.rel_cot) + " " + std::to_string(s.rel_side) + " " +
num(s.rel_length) + "\n";
}
}
for (const auto& f : spec.fits) {
// efit2: gap mode radius transition entry_t exit_t <n> [r len trans]*
// The variable arc list supersedes the legacy fixed two-arc 'efit'.
out += "efit2 " + std::to_string(f.gap) + " " +
std::to_string(f.mode) + " " + num(f.radius) + " " +
num(f.transition) + " " + num(f.entry_t) + " " + num(f.exit_t) +
" " + std::to_string(f.arcs.size());
for (const auto& a : f.arcs) {
out += " " + num(a.radius) + " " + num(a.length) + " " +
num(a.transition_to_next);
}
out += "\n";
}
}
// switches: through/station/side/facing/skew/branch, then the internal curve
// inline in the same shape as an efit2 tail (mode radius trans entry exit <n> …)
out += "ejuncs " + std::to_string(document.junctions.size()) + "\n";
for (const auto& j : document.junctions) {
out += "ejunc " + std::to_string(j.through) + " " + num(j.station) + " " +
std::to_string(j.side) + " " + (j.facing ? std::string{"1"} : "0") +
" " + num(j.crossing_n) + " " + std::to_string(j.branch) + " " +
std::to_string(j.curve.mode) + " " + num(j.curve.radius) + " " +
num(j.curve.transition) + " " + num(j.curve.entry_t) + " " +
num(j.curve.exit_t) + " " + std::to_string(j.curve.arcs.size());
for (const auto& a : j.curve.arcs) {
out += " " + num(a.radius) + " " + num(a.length) + " " +
num(a.transition_to_next);
}
// catalogue length trails the arcs, so files written before it still parse (length stays 0)
out += " " + num(j.length);
out += "\n";
}
return out;
}
EditorDocument deserialize_document(const std::string& text) {
std::vector<std::string> lines;
{
std::istringstream in(text);
std::string line;
while (std::getline(in, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
lines.push_back(line);
}
}
// Locate the editor section (the domain block before it is interop-only).
std::size_t cursor = lines.size();
for (std::size_t i = 0; i < lines.size(); ++i) {
const auto tokens = tokenize(lines[i]);
if (!tokens.empty() && tokens[0] == "editor") {
cursor = i;
break;
}
}
if (cursor >= lines.size()) {
throw std::runtime_error{"maj0sted: missing 'editor' section"};
}
const auto next = [&]() -> const std::string& {
while (cursor < lines.size()) {
const std::string& l = lines[cursor++];
bool only_ws = true;
for (const char c : l) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) return l;
}
throw std::runtime_error{"maj0sted: unexpected end of editor section"};
};
EditorDocument document;
const auto header = tokenize(next());
if (header.size() < 2 || header[0] != "editor") {
throw std::runtime_error{"maj0sted: expected 'editor <count>'"};
}
const long niweleta_count = to_long(header[1]);
// Optional camera/zoom line (absent in older files).
{
std::size_t peek = cursor;
while (peek < lines.size()) {
bool only_ws = true;
for (const char c : lines[peek]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) {
break;
}
++peek;
}
if (peek < lines.size()) {
const auto view_tok = tokenize(lines[peek]);
if (view_tok.size() >= 4 && view_tok[0] == "eview") {
next();
document.view_x = to_double(view_tok[1]);
document.view_y = to_double(view_tok[2]);
document.view_extent = to_double(view_tok[3]);
}
}
// Optional local-zero line (absent in older files). May follow eview.
peek = cursor;
while (peek < lines.size()) {
bool only_ws = true;
for (const char c : lines[peek]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) {
break;
}
++peek;
}
if (peek < lines.size()) {
const auto origin_tok = tokenize(lines[peek]);
if (origin_tok.size() >= 4 && origin_tok[0] == "eorigin") {
next();
document.origin_set = true;
document.georeferenced = to_long(origin_tok[1]) != 0;
document.origin_x = to_double(origin_tok[2]);
document.origin_y = to_double(origin_tok[3]);
}
}
}
for (long n = 0; n < niweleta_count; ++n) {
const auto eniw = tokenize(next());
if (eniw.size() < 3 || eniw[0] != "eniw") {
throw std::runtime_error{"maj0sted: expected 'eniw <straights> <fits>'"};
}
const long straight_count = to_long(eniw[1]);
const long fit_count = to_long(eniw[2]);
NiweletaSpec spec;
const std::string& name_line = next();
if (name_line.rfind("ename", 0) != 0) {
throw std::runtime_error{"maj0sted: expected 'ename'"};
}
spec.name = name_line.size() > 6 ? name_line.substr(6) : std::string{};
for (long i = 0; i < straight_count; ++i) {
const auto e = tokenize(next());
if (e.size() < 5 || e[0] != "estr") {
throw std::runtime_error{"maj0sted: bad 'estr' line"};
}
spec.straights.push_back(
StraightSpec{to_double(e[1]), to_double(e[2]), to_double(e[3]),
to_double(e[4]), e.size() >= 6 && to_long(e[5]) != 0});
}
// Optional parallel/skew lines sit between straights and fits; older files omit them.
auto apply_relation = [&](const std::vector<std::string>& e) -> bool {
if (e.empty()) return false;
if (e[0] == "epar" && e.size() >= 5) {
const long idx = to_long(e[1]);
if (idx < 0 || static_cast<std::size_t>(idx) >= spec.straights.size()) {
throw std::runtime_error{"maj0sted: bad 'epar' index"};
}
auto& s = spec.straights[static_cast<std::size_t>(idx)];
s.rel_kind = 1;
s.rel_niw = static_cast<int>(to_long(e[2]));
s.rel_str = static_cast<int>(to_long(e[3]));
s.rel_offset = to_double(e[4]);
return true;
}
if (e[0] == "eskew" && e.size() >= 7) {
const long idx = to_long(e[1]);
if (idx < 0 || static_cast<std::size_t>(idx) >= spec.straights.size()) {
throw std::runtime_error{"maj0sted: bad 'eskew' index"};
}
auto& s = spec.straights[static_cast<std::size_t>(idx)];
s.rel_kind = 2;
s.rel_niw = static_cast<int>(to_long(e[2]));
s.rel_str = static_cast<int>(to_long(e[3]));
s.rel_cot = to_double(e[4]);
s.rel_side = static_cast<int>(to_long(e[5]));
s.rel_length = to_double(e[6]);
return true;
}
return false;
};
auto peek_tokens = [&]() -> std::vector<std::string> {
std::size_t i = cursor;
while (i < lines.size()) {
bool only_ws = true;
for (const char c : lines[i]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) {
return tokenize(lines[i]);
}
++i;
}
return {};
};
while (apply_relation(peek_tokens())) {
next(); // consume the relation line
}
for (long i = 0; i < fit_count; ++i) {
const auto e = tokenize(next());
GapFit f;
if (!e.empty() && e[0] == "efit2") {
// New variable-arc format.
if (e.size() < 8) {
throw std::runtime_error{"maj0sted: bad 'efit2' line"};
}
f.gap = static_cast<int>(to_long(e[1]));
f.mode = static_cast<int>(to_long(e[2]));
f.radius = to_double(e[3]);
f.transition = to_double(e[4]);
f.entry_t = to_double(e[5]);
f.exit_t = to_double(e[6]);
const long arc_count = to_long(e[7]);
for (long a = 0; a < arc_count; ++a) {
const std::size_t base = 8 + static_cast<std::size_t>(a) * 3;
if (base + 2 >= e.size()) {
throw std::runtime_error{"maj0sted: truncated 'efit2' arcs"};
}
f.arcs.push_back(maj0sted::editor::CompoundArcSpec{
to_double(e[base]), to_double(e[base + 1]),
to_double(e[base + 2])});
}
} else if (!e.empty() && e[0] == "efit" && e.size() >= 11) {
// Legacy fixed two-arc format; arcs stay empty and to_request
// falls back to the r1/r2 scalar fields.
f.gap = static_cast<int>(to_long(e[1]));
f.mode = static_cast<int>(to_long(e[2]));
f.radius = to_double(e[3]);
f.transition = to_double(e[4]);
f.r1 = to_double(e[5]);
f.arc1_len = to_double(e[6]);
f.between = to_double(e[7]);
f.r2 = to_double(e[8]);
f.entry_t = to_double(e[9]);
f.exit_t = to_double(e[10]);
} else {
throw std::runtime_error{"maj0sted: bad 'efit'/'efit2' line"};
}
spec.fits.push_back(f);
}
document.niwelety.push_back(std::move(spec));
}
// Optional switches section (absent in older files); mirrors the eview/eorigin peek.
{
std::size_t peek = cursor;
while (peek < lines.size()) {
bool only_ws = true;
for (const char c : lines[peek]) {
if (!std::isspace(static_cast<unsigned char>(c))) {
only_ws = false;
break;
}
}
if (!only_ws) break;
++peek;
}
if (peek < lines.size()) {
const auto head = tokenize(lines[peek]);
if (head.size() >= 2 && head[0] == "ejuncs") {
next(); // consume the 'ejuncs <count>' header
const long junction_count = to_long(head[1]);
for (long j = 0; j < junction_count; ++j) {
const auto e = tokenize(next());
if (e.size() < 12 || e[0] != "ejunc") {
throw std::runtime_error{"maj0sted: bad 'ejunc' line"};
}
maj0sted::editor::Junction junction;
junction.through = static_cast<int>(to_long(e[1]));
junction.station = to_double(e[2]);
junction.side = static_cast<int>(to_long(e[3]));
junction.facing = to_long(e[4]) != 0;
junction.crossing_n = to_double(e[5]);
junction.branch = static_cast<int>(to_long(e[6]));
junction.curve.mode = static_cast<int>(to_long(e[7]));
junction.curve.radius = to_double(e[8]);
junction.curve.transition = to_double(e[9]);
junction.curve.entry_t = to_double(e[10]);
junction.curve.exit_t = to_double(e[11]);
const long arc_count = e.size() >= 13 ? to_long(e[12]) : 0;
for (long a = 0; a < arc_count; ++a) {
const std::size_t base = 13 + static_cast<std::size_t>(a) * 3;
if (base + 2 >= e.size()) {
throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"};
}
junction.curve.arcs.push_back(maj0sted::editor::CompoundArcSpec{
to_double(e[base]), to_double(e[base + 1]),
to_double(e[base + 2])});
}
// optional catalogue length after the arcs (absent in the earliest files)
const std::size_t after_arcs = 13 + static_cast<std::size_t>(arc_count) * 3;
if (e.size() > after_arcs) {
junction.length = to_double(e[after_arcs]);
}
document.junctions.push_back(std::move(junction));
}
}
}
}
return document;
}
std::string default_project_path() {
// Fallback only; the editor passes its own path (see plan_panel). Relative, so
// it lands under the working directory rather than at a filesystem root.
return "editor/project.m0s";
}
bool save_project(const EditorDocument& document, const std::string& path) {
try {
const std::filesystem::path file{path};
if (file.has_parent_path()) {
std::filesystem::create_directories(file.parent_path());
}
std::ofstream out(file, std::ios::binary | std::ios::trunc);
if (!out) return false;
out << serialize_document(document);
return static_cast<bool>(out);
} catch (...) {
return false;
}
}
std::optional<EditorDocument> load_project(const std::string& path) {
try {
const std::filesystem::path file{path};
if (!std::filesystem::exists(file)) return std::nullopt;
std::ifstream in(file, std::ios::binary);
if (!in) return std::nullopt;
std::ostringstream buffer;
buffer << in.rdbuf();
return deserialize_document(buffer.str());
} catch (...) {
return std::nullopt;
}
}
} // namespace maj0sted::app