mirror of
https://github.com/MaSzyna-EU07/maszyna.git
synced 2026-11-06 18:38:36 +01:00
sonar fix, registration to sbt.sources improved
This commit is contained in:
@@ -28,6 +28,7 @@ add_library(maj0sted STATIC
|
||||
src/editor/solution.cpp
|
||||
src/editor/chain.cpp
|
||||
src/editor/sketch.cpp
|
||||
src/editor/join.cpp
|
||||
src/editor/layout.cpp
|
||||
src/editor/render.cpp
|
||||
src/editor/tile_cache.cpp
|
||||
|
||||
@@ -189,6 +189,11 @@ struct Document {
|
||||
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};
|
||||
|
||||
@@ -80,6 +80,13 @@ 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};
|
||||
domain::geometry::Pose begins{};
|
||||
};
|
||||
|
||||
/// Pulls @p track's skeleton out of @p solved.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
#include <ostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "maj0sted/editor/model.hpp"
|
||||
#include "maj0sted/editor/solution.hpp"
|
||||
|
||||
namespace maj0sted::io {
|
||||
@@ -11,24 +13,59 @@ struct ScnExportOptions {
|
||||
double origin_east{0.0};
|
||||
double origin_north{0.0};
|
||||
double rail_y{0.2};
|
||||
double max_arc_angle{1.5707963267948966};
|
||||
/// 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};
|
||||
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.
|
||||
/// 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 editor::Solution& solution,
|
||||
[[nodiscard]] ScnExportResult export_scn(const editor::Document& document,
|
||||
const editor::Solution& solution,
|
||||
const ScnExportOptions& options,
|
||||
std::ostream& out);
|
||||
|
||||
|
||||
@@ -377,6 +377,8 @@ bool skeleton_of(const Track& track, const SolvedTrack& solved, Skeleton& out,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -392,7 +394,7 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::vector<Support>& lines = skeleton.lines;
|
||||
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.
|
||||
@@ -401,6 +403,25 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why
|
||||
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);
|
||||
|
||||
@@ -454,14 +475,7 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why
|
||||
|
||||
if (std::holds_alternative<AtPose>(track.anchor)) {
|
||||
track.anchor = AtPose{begins.x, begins.y, std::atan2(begins.hx, begins.hy)};
|
||||
} else if (has_head) {
|
||||
// A branch pinned to a turnout's port and starting mid-curve: its first
|
||||
// straight is wherever the frog and that curve put it, so there is nothing
|
||||
// here that may move.
|
||||
why = "tor jest przypięty do rozjazdu i zaczyna się łukiem — nie ma czego dopasować "
|
||||
"bez odrywania go od krzyżownicy";
|
||||
return false;
|
||||
} else if (std::hypot(begins.x - skeleton.start_x, begins.y - skeleton.start_y) > 1e-6) {
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ namespace {
|
||||
|
||||
using namespace maj0sted::editor;
|
||||
|
||||
constexpr int kVersion = 6;
|
||||
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
|
||||
@@ -244,6 +245,8 @@ std::string serialize(const Document& document) {
|
||||
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) {
|
||||
@@ -309,8 +312,8 @@ std::optional<Document> deserialize(const std::string& text) {
|
||||
{
|
||||
const std::optional<Line> header = reader.next("m0s", 2);
|
||||
if (!header || !to_int(header->tokens[1], version) ||
|
||||
(version != kVersion && version != kStraightOnly && version != kNoseless &&
|
||||
version != kLegacyTurnoutVersion)) {
|
||||
(version != kVersion && version != kNoExportPath && version != kStraightOnly &&
|
||||
version != kNoseless && version != kLegacyTurnoutVersion)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
@@ -337,6 +340,15 @@ std::optional<Document> deserialize(const std::string& text) {
|
||||
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);
|
||||
|
||||
@@ -2,116 +2,161 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <numbers>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace maj0sted::io {
|
||||
namespace {
|
||||
|
||||
using maj0sted::editor::Document;
|
||||
using maj0sted::editor::Kind;
|
||||
using maj0sted::editor::PlanPoint;
|
||||
using maj0sted::editor::SolvedElement;
|
||||
using maj0sted::editor::SolvedTrack;
|
||||
using maj0sted::editor::SolvedTurnout;
|
||||
using maj0sted::editor::TurnoutPlacement;
|
||||
|
||||
struct AxisElement {
|
||||
int kind{0};
|
||||
std::uint32_t element_id{0};
|
||||
double length{0.0};
|
||||
double radius_start{0.0};
|
||||
double radius_end{0.0};
|
||||
std::vector<PlanPoint> points;
|
||||
};
|
||||
/// 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 WorldXYZ {
|
||||
struct World {
|
||||
double x{0.0};
|
||||
double y{0.0};
|
||||
double z{0.0};
|
||||
};
|
||||
|
||||
/// The solved elements, in the shape the emitter below wants. Nothing is
|
||||
/// recovered or averaged: the solution already holds the axis.
|
||||
std::vector<AxisElement> axis_of(const SolvedTrack& track) {
|
||||
const auto radius_of = [](double k) {
|
||||
return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0;
|
||||
};
|
||||
std::vector<AxisElement> out;
|
||||
out.reserve(track.elements.size());
|
||||
for (const SolvedElement& element : track.elements) {
|
||||
AxisElement axis;
|
||||
axis.kind = element.kind == maj0sted::editor::Kind::Line ? 0
|
||||
: element.kind == maj0sted::editor::Kind::Arc ? 1
|
||||
: 2;
|
||||
axis.element_id = static_cast<std::uint32_t>(element.id);
|
||||
axis.length = element.length;
|
||||
axis.radius_start = radius_of(element.k0);
|
||||
axis.radius_end = radius_of(element.k1);
|
||||
axis.points = element.points;
|
||||
out.push_back(std::move(axis));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/// 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};
|
||||
};
|
||||
|
||||
WorldXYZ to_scn(double east, double north, double height, double ox, double oy) {
|
||||
return {east - ox, height, -(north - oy)};
|
||||
}
|
||||
/// 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};
|
||||
};
|
||||
|
||||
void plan_tangent_to_scn(double de, double dn, double& out_x, double& out_z) {
|
||||
const double len = std::hypot(de, dn);
|
||||
if (len < 1e-12) {
|
||||
out_x = 0.0;
|
||||
out_z = 1.0;
|
||||
return;
|
||||
}
|
||||
out_x = de / len;
|
||||
out_z = -dn / len;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sampled axes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool axis_pose_at(const std::vector<PlanPoint>& pts, double along, PlanPoint& out_p,
|
||||
double& out_tx, double& out_ty) {
|
||||
if (pts.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
double a = 0.0;
|
||||
for (std::size_t i = 1; i < pts.size(); ++i) {
|
||||
const double dx = pts[i].x - pts[i - 1].x;
|
||||
const double dy = pts[i].y - pts[i - 1].y;
|
||||
const double seg = std::hypot(dx, dy);
|
||||
if (a + seg >= along || i + 1 == pts.size()) {
|
||||
const double t = seg > 1e-12 ? std::clamp((along - a) / seg, 0.0, 1.0) : 0.0;
|
||||
out_p = {pts[i - 1].x + dx * t, pts[i - 1].y + dy * t};
|
||||
if (seg > 1e-12) {
|
||||
out_tx = dx / seg;
|
||||
out_ty = dy / seg;
|
||||
} else {
|
||||
out_tx = 1.0;
|
||||
out_ty = 0.0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
a += seg;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
double axis_total_length(const std::vector<PlanPoint>& pts) {
|
||||
double poly_length(const std::vector<PlanPoint>& points) {
|
||||
double along = 0.0;
|
||||
for (std::size_t i = 1; i < pts.size(); ++i) {
|
||||
along += std::hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
||||
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;
|
||||
}
|
||||
|
||||
double circular_bezier_handle(double radius, double phi) {
|
||||
const double p = std::clamp(phi, 1e-6, std::numbers::pi);
|
||||
return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(p * 0.25);
|
||||
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;
|
||||
}
|
||||
|
||||
bool fit_clothoid_bezier_handles(const std::vector<PlanPoint>& pts, double s0,
|
||||
double s1, const PlanPoint& p0, const PlanPoint& p3,
|
||||
double t0x, double t0y, double t1x, double t1y,
|
||||
double& out_l0, double& out_l1) {
|
||||
const double span = s1 - s0;
|
||||
/// 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;
|
||||
}
|
||||
@@ -119,22 +164,19 @@ bool fit_clothoid_bezier_handles(const std::vector<PlanPoint>& pts, double s0,
|
||||
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);
|
||||
PlanPoint q{};
|
||||
double tx = 1.0, ty = 0.0;
|
||||
if (!axis_pose_at(pts, s0 + span * t, q, tx, ty)) {
|
||||
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 = q.x - ax;
|
||||
const double ry = q.y - ay;
|
||||
const double a0x = c0 * t0x, a0y = c0 * t0y;
|
||||
const double a1x = -c1 * t1x, a1y = -c1 * t1y;
|
||||
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;
|
||||
@@ -145,55 +187,292 @@ bool fit_clothoid_bezier_handles(const std::vector<PlanPoint>& pts, double s0,
|
||||
if (std::abs(det) < 1e-12) {
|
||||
return false;
|
||||
}
|
||||
const double l0 = (m11 * b0 - m01 * b1) / det;
|
||||
const double l1 = (-m01 * b0 + m00 * b1) / det;
|
||||
if (l0 < 0.05 || l1 < 0.05) {
|
||||
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_l0 = std::clamp(l0, 0.05, span);
|
||||
out_l1 = std::clamp(l1, 0.05, span);
|
||||
out_first = std::clamp(first, 0.05, span);
|
||||
out_last = std::clamp(last, 0.05, span);
|
||||
return true;
|
||||
}
|
||||
|
||||
void clothoid_handles_from_radii(double len, double phi, double r0, double r1,
|
||||
double& out_l0, double& out_l1) {
|
||||
auto handle = [&](double r) {
|
||||
if (r < 1.0) {
|
||||
return len / 3.0;
|
||||
}
|
||||
return circular_bezier_handle(r, phi);
|
||||
};
|
||||
out_l0 = handle(r0);
|
||||
out_l1 = handle(r1);
|
||||
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};
|
||||
}
|
||||
|
||||
void write_scn_track(std::ostream& out, const std::string& name, const WorldXYZ& p1,
|
||||
const WorldXYZ& cv1, const WorldXYZ& p2, const WorldXYZ& cv2,
|
||||
double length, double radius) {
|
||||
out << "node -1 0 " << name << " track normal " << length
|
||||
<< " 1.435 0.15 25.0 20 0 flat vis\n";
|
||||
out << " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n";
|
||||
auto fmt = [](double v) {
|
||||
std::ostringstream s;
|
||||
s.setf(std::ios::fixed);
|
||||
s.precision(4);
|
||||
s << v;
|
||||
return s.str();
|
||||
};
|
||||
out << fmt(p1.x) << ' ' << fmt(p1.y) << ' ' << fmt(p1.z) << " 0\n";
|
||||
out << fmt(cv1.x) << ' ' << fmt(cv1.y) << ' ' << fmt(cv1.z) << '\n';
|
||||
out << fmt(cv2.x) << ' ' << fmt(cv2.y) << ' ' << fmt(cv2.z) << '\n';
|
||||
out << fmt(p2.x) << ' ' << fmt(p2.y) << ' ' << fmt(p2.z) << " 0\n";
|
||||
out << fmt(radius) << '\n';
|
||||
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 editor::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 domain::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 maj0sted::editor::Solution& solution,
|
||||
ScnExportOptions options) {
|
||||
if (std::abs(options.origin_east) >= 1.0 ||
|
||||
std::abs(options.origin_north) >= 1.0) {
|
||||
if (std::abs(options.origin_east) >= 1.0 || std::abs(options.origin_north) >= 1.0) {
|
||||
return options;
|
||||
}
|
||||
for (const auto& track : solution.tracks) {
|
||||
@@ -210,127 +489,266 @@ ScnExportOptions resolve_scn_origin(const maj0sted::editor::Solution& solution,
|
||||
return options;
|
||||
}
|
||||
|
||||
ScnExportResult export_scn(const maj0sted::editor::Solution& solution,
|
||||
ScnExportResult export_scn(const Document& document,
|
||||
const maj0sted::editor::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;
|
||||
|
||||
out << "//$n plan\n";
|
||||
out << "//$d origin " << opt.origin_east << ' ' << opt.origin_north << "\n\n";
|
||||
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;
|
||||
|
||||
const double kRailY = opt.rail_y;
|
||||
const double kMaxArcAngle = opt.max_arc_angle;
|
||||
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 track_id = static_cast<std::uint32_t>(track.id);
|
||||
const auto axis = axis_of(track);
|
||||
for (std::size_t ei = 0; ei < axis.size(); ++ei) {
|
||||
const auto& el = axis[ei];
|
||||
if (el.points.size() < 2) {
|
||||
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 double total =
|
||||
el.length > 1e-6 ? el.length : axis_total_length(el.points);
|
||||
if (total < 0.05) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
auto curvature_at = [&](double along) {
|
||||
const double k0 = el.radius_start > 1.0 ? 1.0 / el.radius_start : 0.0;
|
||||
const double k1 = el.radius_end > 1.0 ? 1.0 / el.radius_end : 0.0;
|
||||
const double t =
|
||||
total > 1e-9 ? std::clamp(along / total, 0.0, 1.0) : 0.0;
|
||||
return k0 + (k1 - k0) * t;
|
||||
};
|
||||
auto radius_from_k = [](double k) {
|
||||
return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0;
|
||||
};
|
||||
|
||||
auto emit_piece = [&](double s0, double s1, double piece_len,
|
||||
double radius, const char* tag) {
|
||||
PlanPoint p0{}, p1{};
|
||||
double t0x = 1.0, t0y = 0.0, t1x = 1.0, t1y = 0.0;
|
||||
if (!axis_pose_at(el.points, s0, p0, t0x, t0y) ||
|
||||
!axis_pose_at(el.points, s1, p1, t1x, t1y)) {
|
||||
return;
|
||||
// ---- 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 auto w0 = to_scn(p0.x, p0.y, kRailY, opt.origin_east,
|
||||
opt.origin_north);
|
||||
const auto w1 = to_scn(p1.x, p1.y, kRailY, opt.origin_east,
|
||||
opt.origin_north);
|
||||
double tx0 = 0.0, tz0 = 0.0, tx1 = 0.0, tz1 = 0.0;
|
||||
plan_tangent_to_scn(t0x, t0y, tx0, tz0);
|
||||
plan_tangent_to_scn(t1x, t1y, tx1, tz1);
|
||||
|
||||
WorldXYZ cv1{}, cv2{};
|
||||
double radius_out = 0.0;
|
||||
if (el.kind == 1 && radius >= 1.0) {
|
||||
const double phi = std::clamp(piece_len / std::max(radius, 1.0),
|
||||
1e-6, std::numbers::pi);
|
||||
const double handle = circular_bezier_handle(radius, phi);
|
||||
cv1 = {tx0 * handle, 0.0, tz0 * handle};
|
||||
cv2 = {-tx1 * handle, 0.0, -tz1 * handle};
|
||||
radius_out = radius;
|
||||
} else if (el.kind == 2) {
|
||||
const double cross = t0x * t1y - t0y * t1x;
|
||||
const double dot = t0x * t1x + t0y * t1y;
|
||||
const double phi = std::clamp(std::abs(std::atan2(cross, dot)),
|
||||
1e-6, std::numbers::pi);
|
||||
const double r0 = radius_from_k(curvature_at(s0));
|
||||
const double r1 = radius_from_k(curvature_at(s1));
|
||||
double l0 = piece_len / 3.0;
|
||||
double l1 = piece_len / 3.0;
|
||||
if (!fit_clothoid_bezier_handles(el.points, s0, s1, p0, p1, t0x,
|
||||
t0y, t1x, t1y, l0, l1)) {
|
||||
clothoid_handles_from_radii(piece_len, phi, r0, r1, l0, l1);
|
||||
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;
|
||||
}
|
||||
cv1 = {tx0 * l0, 0.0, tz0 * l0};
|
||||
cv2 = {-tx1 * l1, 0.0, -tz1 * l1};
|
||||
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;
|
||||
}
|
||||
|
||||
std::ostringstream name;
|
||||
name << "plan_t" << track_id << "_e" << el.element_id << '_' << tag
|
||||
<< '_' << result.tracks;
|
||||
const auto track_name = name.str();
|
||||
if (result.first_track_name.empty()) {
|
||||
result.first_track_name = track_name;
|
||||
}
|
||||
write_scn_track(out, track_name, w0, cv1, w1, cv2, piece_len,
|
||||
radius_out);
|
||||
++result.tracks;
|
||||
};
|
||||
|
||||
if (el.kind == 0 || (el.kind == 1 && el.radius_start < 1.0)) {
|
||||
emit_piece(0.0, total, total, 0.0, "str");
|
||||
continue;
|
||||
}
|
||||
if (el.kind == 2) {
|
||||
emit_piece(0.0, total, total, 0.0, "kp");
|
||||
continue;
|
||||
};
|
||||
|
||||
// ---- 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;
|
||||
domain::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");
|
||||
}
|
||||
|
||||
const double r = std::max(el.radius_start, el.radius_end);
|
||||
const double phi = total / std::max(r, 1.0);
|
||||
const int pieces =
|
||||
std::max(1, static_cast<int>(std::ceil(phi / kMaxArcAngle)));
|
||||
const double piece_len = total / static_cast<double>(pieces);
|
||||
for (int p = 0; p < pieces; ++p) {
|
||||
const double s0 = piece_len * static_cast<double>(p);
|
||||
const double s1 =
|
||||
p + 1 == pieces ? total : piece_len * static_cast<double>(p + 1);
|
||||
emit_piece(s0, s1, s1 - s0, r, "arc");
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out << "FirstInit\n\n";
|
||||
if (!result.first_track_name.empty()) {
|
||||
out << "trainset none " << result.first_track_name << " 5 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";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@ target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME turnout COMMAND maj0sted_test_turnout)
|
||||
|
||||
add_executable(maj0sted_test_scn test_scn.cpp)
|
||||
target_link_libraries(maj0sted_test_scn PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_scn PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME scn COMMAND maj0sted_test_scn)
|
||||
|
||||
add_executable(maj0sted_test_join test_join.cpp)
|
||||
target_link_libraries(maj0sted_test_join PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_join PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME join COMMAND maj0sted_test_join)
|
||||
|
||||
add_executable(maj0sted_test_tile_cache test_tile_cache.cpp)
|
||||
target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted)
|
||||
target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
@@ -74,6 +74,8 @@ Document sample() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -167,6 +169,8 @@ void every_field_survives() {
|
||||
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);
|
||||
}
|
||||
@@ -183,7 +187,7 @@ void an_older_file_is_refused() {
|
||||
"tracks 0\n";
|
||||
CHECK(!maj0sted::io::deserialize(version_2).has_value());
|
||||
CHECK(!maj0sted::io::deserialize("").has_value());
|
||||
CHECK(!maj0sted::io::deserialize("m0s 7\n").has_value());
|
||||
CHECK(!maj0sted::io::deserialize("m0s 8\n").has_value());
|
||||
}
|
||||
|
||||
void a_file_from_before_lukowanie_still_opens() {
|
||||
@@ -202,7 +206,24 @@ void a_file_from_before_lukowanie_still_opens() {
|
||||
CHECK(document->turnouts[0].station == 100.0);
|
||||
CHECK(document->turnouts[0].bend_from_track);
|
||||
CHECK(document->turnouts[0].bend == 0.0);
|
||||
CHECK(maj0sted::io::serialize(*document).find("m0s 6") != std::string::npos);
|
||||
CHECK(maj0sted::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 = maj0sted::io::deserialize(version_6);
|
||||
CHECK(document.has_value());
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
CHECK(document->scn_path.empty());
|
||||
CHECK(document->tracks.size() == 1);
|
||||
CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos);
|
||||
}
|
||||
|
||||
void a_file_from_before_the_nose_still_opens() {
|
||||
@@ -225,7 +246,7 @@ void a_file_from_before_the_nose_still_opens() {
|
||||
CHECK(document->turnout_types[0].pieces.size() == 1);
|
||||
|
||||
// and it is written back in the new form, with a place for the nose
|
||||
CHECK(maj0sted::io::serialize(*document).find("m0s 6") != std::string::npos);
|
||||
CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos);
|
||||
}
|
||||
|
||||
void an_old_file_upgrades_to_the_piece_list() {
|
||||
|
||||
@@ -471,6 +471,92 @@ void a_chain_starting_on_a_curve_carries_its_head_along() {
|
||||
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
|
||||
@@ -543,6 +629,7 @@ int main() {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user