17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-11-03 17:50:21 +01:00

Vendor the maj0sted track layout library and link it into the editor

Drop-in copy of C:\Users\maj00r\Projects\maj0sted-master minus its web/
directory - the WASM frontend is not being ported, the editor drives the
same C++ API natively. What is left is plain C++20 with no dependencies:
domain model (niweleta, plan and profile geometry, fitting, parallelism),
project serialization, rail sampling and the editor document.

src/web/ribbon.cpp was missing from the library source list; added, since
without it the ribbon symbols never make it into the archive.
This commit is contained in:
maj00r
2026-07-31 18:27:02 +02:00
committed by maj00r
parent 9a0b63a97c
commit a083269867
72 changed files with 6344 additions and 0 deletions

View File

@@ -0,0 +1,323 @@
#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::web::GapFit;
using maj0sted::web::NiweletaSpec;
using maj0sted::web::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";
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 (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";
}
}
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]);
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});
}
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::web::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));
}
return document;
}
std::string default_project_path() {
// Under Emscripten the host mounts IDBFS at /data (see web/index.html), so
// this path is persistent across reloads once FS.syncfs has flushed.
return "/data/maj0sted/project.m0s";
}
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