/* 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/. */ #include "stdafx.h" #include "scene/scenelayers.h" #include "world/Track.h" #include "world/Road.h" #include "world/Sweep.h" #include "world/RoadPoint.h" #include "simulation/simulation.h" #include "model/AnimModel.h" #include "scene/scenenodegroups.h" #include "world/MemCell.h" #include "world/Traction.h" #include "world/EvLaunch.h" #include "world/Event.h" #include "utilities/Globals.h" #include "utilities/Logs.h" #include "utilities/utilities.h" #include #include #include // scenery save for sceneries opened for editing. // the scenery files are patched rather than generated: text of a file is carried over as it is, except for the // definitions of nodes changed in the editor and the include directives affected by layer changes. this keeps // everything the editor doesn't understand or didn't touch, comments and formatting included. namespace scene { namespace { struct text_token { std::size_t begin; std::size_t end; std::string text; // lower case }; struct text_change { std::size_t begin; std::size_t end; std::string text; }; bool is_separator(char const Char) { return Char == ' ' || Char == '\t' || Char == '\n' || Char == '\r' || Char == ';'; } // splits scenery text into tokens the same way the scenery parser does: comments are skipped, quoted text is kept together std::vector tokenize(std::string const &Text) { std::vector tokens; auto const size{Text.size()}; std::size_t idx{0}; auto const starts = [&](char const *Mark) { return Text.compare(idx, 2, Mark) == 0; }; while (idx < size) { if (is_separator(Text[idx])) { ++idx; continue; } if (starts("//")) { idx = Text.find('\n', idx); idx = (idx == std::string::npos ? size : idx + 1); continue; } if (starts("/*")) { idx = Text.find("*/", idx + 2); idx = (idx == std::string::npos ? size : idx + 2); continue; } auto const begin{idx}; while (idx < size && false == is_separator(Text[idx]) && false == starts("//") && false == starts("/*")) { if (Text[idx] == '\"') { // quoted part runs up to the closing quote, whatever is inside ++idx; while (idx < size && Text[idx] != '\"') { idx += (Text[idx] == '\\' ? 2 : 1); } } ++idx; } idx = std::min(idx, size); tokens.push_back({begin, idx, ToLower(Text.substr(begin, idx - begin))}); } return tokens; } std::string number(double const Value) { std::ostringstream converter; converter.imbue(std::locale::classic()); converter << std::fixed << std::setprecision(3) << Value; auto text{converter.str()}; return text == "-0.000" ? "0.000" : text; } std::string numbers(glm::dvec3 const &Values) { return number(Values.x) + ' ' + number(Values.y) + ' ' + number(Values.z); } void apply(std::string &Text, std::vector Changes) { // from the end, so the locations of pending changes stay valid std::sort(std::begin(Changes), std::end(Changes), [](text_change const &Left, text_change const &Right) { return Left.begin > Right.begin; }); for (auto const &change : Changes) { Text.replace(change.begin, change.end - change.begin, change.text); } } // placement to write into definition of a model instance. empty parts are left as they are struct model_placement { std::optional location; // in the coordinates used by the definition std::optional angles; // complete rotation of the instance glm::vec3 rotation{0.f}; // rotation the loader adds to the angle specified in the definition std::optional scale; // scale factor to put in the definition std::optional name; // name of the node }; bool about_equal(float const Left, float const Right) { return std::abs(Left - Right) < 1e-3f; } // definition layout: node model [options] endmodel bool patch_model(std::string &Text, model_placement const &Placement, std::string &Error) { auto const tokens{tokenize(Text)}; if (tokens.size() < 12 || tokens[0].text != "node" || tokens[4].text != "model" || tokens.back().text != "endmodel") { Error = "unexpected layout of model definition"; return false; } std::vector changes; auto const replace = [&](std::size_t const Index, std::string const &Value) { changes.push_back({tokens[Index].begin, tokens[Index].end, Value}); }; // optional blocks of three values, located past the texture auto const last{tokens.size() - 1}; auto const find_block = [&](char const *Keyword) { for (std::size_t idx = 11; idx + 3 < last; ++idx) { if (tokens[idx].text == Keyword) { return idx; } } return std::size_t{0}; }; std::string additions; // blocks to insert ahead of the closing keyword if (Placement.name) { replace(3, *Placement.name); } if (Placement.location) { replace(5, number(Placement.location->x)); replace(6, number(Placement.location->y)); replace(7, number(Placement.location->z)); } if (Placement.angles) { auto const &angles{*Placement.angles}; auto const block{find_block("angles")}; if (block != 0) { // the block specifies complete rotation of the instance, regardless of anything else replace(block + 1, number(angles.x)); replace(block + 2, number(angles.y)); replace(block + 3, number(angles.z)); } else if (about_equal(angles.x, Placement.rotation.x) && about_equal(angles.z, Placement.rotation.z)) { // rotation around the vertical axis alone fits in the basic angle parameter replace(8, number(angles.y - Placement.rotation.y)); } else { additions += "angles " + numbers(angles) + ' '; } } if (Placement.scale) { auto const &scale{*Placement.scale}; auto const unit{about_equal(scale.x, 1.f) && about_equal(scale.y, 1.f) && about_equal(scale.z, 1.f)}; auto const block{find_block("scale")}; if (block != 0) { if (unit) { // drop the block along with the whitespace separating it from the next token changes.push_back({tokens[block].begin, tokens[block + 4].begin, ""}); } else { replace(block + 1, number(scale.x)); replace(block + 2, number(scale.y)); replace(block + 3, number(scale.z)); } } else if (false == unit) { additions += "scale " + numbers(scale) + ' '; } } if (false == additions.empty()) { changes.push_back({tokens[last].begin, tokens[last].begin, additions}); } apply(Text, changes); return true; } // definition layout: node memcell endmemcell bool patch_memcell(std::string &Text, std::optional const &Location, std::string const &Track, std::string &Error) { auto const tokens{tokenize(Text)}; if (tokens.size() < 13 || tokens[0].text != "node" || tokens[4].text != "memcell" || tokens.back().text != "endmemcell") { Error = "unexpected layout of memory cell definition"; return false; } std::vector changes; if (Location) changes.insert(changes.end(), {{tokens[5].begin, tokens[5].end, number(Location->x)}, {tokens[6].begin, tokens[6].end, number(Location->y)}, {tokens[7].begin, tokens[7].end, number(Location->z)}}); auto const &track{tokens[tokens.size() - 2]}; if (false == Track.empty() && track.text != Track) changes.push_back({track.begin, track.end, Track}); apply(Text, changes); return true; } // definition layout: node traction // [parallel ] endtraction bool patch_traction(std::string &Text, TTraction const &Traction, glm::dvec3 const &Offset, std::string &Error) { auto const tokens{tokenize(Text)}; if (tokens.size() < 30 || tokens[0].text != "node" || tokens[4].text != "traction" || tokens.back().text != "endtraction") { Error = "unexpected layout of traction definition"; return false; } std::vector changes; std::size_t index{12}; for (auto const &point : {Traction.pPoint1, Traction.pPoint2, Traction.pPoint3, Traction.pPoint4}) { auto const local{point - Offset}; for (auto const value : {local.x, local.y, local.z}) { changes.push_back({tokens[index].begin, tokens[index].end, number(value)}); ++index; } } auto const &p1{Traction.pPoint1}; auto const &p2{Traction.pPoint2}; auto const &p3{Traction.pPoint3}; auto const &p4{Traction.pPoint4}; changes.push_back({tokens[24].begin, tokens[24].end, number((p3.y - p1.y + p4.y - p2.y) * 0.5 - Traction.fHeightDifference)}); if (Traction.iNumSections > 0) { // the load truncates length / segment length, the margin keeps the rounding of the number from losing a section changes.push_back({tokens[25].begin, tokens[25].end, number(glm::length(p1 - p2) / (Traction.iNumSections + 0.001))}); } apply(Text, changes); return true; } // definition layout: node eventlauncher ... end bool patch_launcher(std::string &Text, glm::dvec3 const &Location, std::string &Error) { auto const tokens{tokenize(Text)}; if (tokens.size() < 9 || tokens[0].text != "node" || tokens[4].text != "eventlauncher") { Error = "unexpected layout of event launcher definition"; return false; } apply(Text, {{tokens[5].begin, tokens[5].end, number(Location.x)}, {tokens[6].begin, tokens[6].end, number(Location.y)}, {tokens[7].begin, tokens[7].end, number(Location.z)}}); return true; } std::string detect_eol(std::string const &Text, std::string const &Fallback) { auto const linebreak{Text.find('\n')}; if (linebreak == std::string::npos) { return Fallback; } return (linebreak > 0 && Text[linebreak - 1] == '\r') ? "\r\n" : "\n"; } void ensure_newline(std::string &Text, std::string const &Eol) { if (false == Text.empty() && Text.back() != '\n') { Text += Eol; } } bool read_file(std::string const &Path, std::string &Content) { std::ifstream input{Path, std::ios_base::binary}; if (false == input.is_open()) { return false; } Content.assign(std::istreambuf_iterator(input), std::istreambuf_iterator()); return true; } } // namespace // changes to make in the text of a single scenery file struct node_layers::file_patch { struct edit { std::streamoff begin{0}; // replaced range of the original text. empty range inserts, negative location stands for the end of the text std::streamoff end{0}; std::string text; // replacement std::size_t lead{0}; // size of the part of replacement preceding the definition it carries std::size_t length{0}; // size of that definition basic_node const *node{nullptr}; // node defined by the replacement layer_handle site{null_handle}; // layer included by the directive in the replacement instance_handle instance{0}; // include of a template made by the directive in the replacement layer_handle inlined{null_handle}; // layer whose content replaces the range }; std::vector edits; }; // text of a scenery file after the changes, with the new locations of what it defines struct node_layers::composition { struct node_place { basic_node const *node; source_span span; bool rewritten; }; struct site_place { layer_handle layer; source_span span; }; struct instance_place { instance_handle instance; source_span span; }; std::string text; std::vector nodes; std::vector sites; std::vector instances; source_span init; // location of what performs the scenario initialization, see basic_layer::init // adds another piece of text at the end void append(composition const &Other) { insert(static_cast(text.size()), Other); } // adds another piece of text at specified location void insert(std::streamoff const Location, composition const &Other) { auto const size{static_cast(Other.text.size())}; auto const move = [=](source_span &Span) { if (Span.begin >= Location) { Span.begin += size; Span.end += size; } }; for (auto &place : nodes) { move(place.span); } for (auto &place : sites) { move(place.span); } for (auto &place : instances) { move(place.span); } if (init.valid()) { move(init); } else if (Other.init.valid()) { init = {Other.init.begin + Location, Other.init.end + Location}; } text.insert(static_cast(Location), Other.text); for (auto place : Other.nodes) { place.span.begin += Location; place.span.end += Location; nodes.emplace_back(place); } for (auto place : Other.sites) { place.span.begin += Location; place.span.end += Location; sites.emplace_back(place); } for (auto place : Other.instances) { place.span.begin += Location; place.span.end += Location; instances.emplace_back(place); } } }; struct node_layers::save_state { std::map content; // original text of the files std::map patches; std::map> nodes; // unchanged nodes defined in each file std::map> includes; // unchanged include directives in each file, by included layer std::map> instances; // unchanged includes of templates in each file std::string eol{"\r\n"}; std::string error; }; // produces text of specified file with the changes applied bool node_layers::compose(save_state &State, layer_handle const Layer, composition &Output, int const Depth) const { if (Depth > 32) { State.error = "layers include each other in a loop"; return false; } static std::string const empty; auto const contentlookup{State.content.find(Layer)}; auto const &base{contentlookup != State.content.end() ? contentlookup->second : empty}; auto const basesize{static_cast(base.size())}; auto const eol{detect_eol(base, State.eol)}; std::vector edits; if (auto const lookup{State.patches.find(Layer)}; lookup != State.patches.end()) { edits = lookup->second.edits; } // what's added to the file goes ahead of the scenario initialization, if the file takes a part in it auto const &init{layer(Layer).init}; auto const hasinit{init.valid() && init.end <= basesize}; auto addition{basesize}; if (hasinit) { addition = init.begin; // on a line of its own, where the initialization has one auto linebegin{addition}; while (linebegin > 0 && (base[linebegin - 1] == ' ' || base[linebegin - 1] == '\t')) { --linebegin; } if (linebegin == 0 || base[linebegin - 1] == '\n') { addition = linebegin; } } auto initedited{false}; // the directive standing for the initialization is replaced or removed for (auto &edit : edits) { if (edit.begin < 0) { edit.begin = edit.end = addition; } initedited |= hasinit && edit.begin == init.begin && edit.end == init.end; } std::stable_sort(std::begin(edits), std::end(edits), [](file_patch::edit const &Left, file_patch::edit const &Right) { return Left.begin < Right.begin; }); std::streamoff cursor{0}; for (auto const &edit : edits) { if (edit.begin < cursor || edit.end < edit.begin || edit.end > basesize) { State.error = "conflicting changes in file \"" + layer(Layer).name + "\""; return false; } cursor = edit.end; } // locations in the original text past which everything is shifted by given amount std::vector> shifts; cursor = 0; for (std::size_t idx = 0; idx < edits.size(); ++idx) { auto const &edit{edits[idx]}; auto begin{edit.begin}; auto end{edit.end}; if (edit.text.empty() && edit.inlined == null_handle && end > begin) { // a definition is removed. if it had a line for itself take the whole line out, instead of leaving it blank auto const limit{idx + 1 < edits.size() ? edits[idx + 1].begin : basesize}; auto linebegin{begin}; while (linebegin > cursor && (base[linebegin - 1] == ' ' || base[linebegin - 1] == '\t')) { --linebegin; } auto lineend{end}; while (lineend < limit && (base[lineend] == ' ' || base[lineend] == '\t')) { ++lineend; } auto const startsline{linebegin == 0 || base[linebegin - 1] == '\n'}; auto endsline{lineend == basesize}; if (lineend + 1 < limit && base[lineend] == '\r' && base[lineend + 1] == '\n') { lineend += 2; endsline = true; } else if (lineend < limit && base[lineend] == '\n') { lineend += 1; endsline = true; } if (startsline && endsline) { begin = linebegin; end = lineend; } } Output.text.append(base, static_cast(cursor), static_cast(begin - cursor)); if (edit.inlined != null_handle) { // content of the included file takes place of the include directive composition included; if (false == compose(State, edit.inlined, included, Depth + 1)) { return false; } if (end < basesize && (base[end] == '\r' || base[end] == '\n')) { // the line break which followed the directive ends the included text now, a second one would leave a blank line if (false == included.text.empty() && included.text.back() == '\n') { included.text.pop_back(); if (false == included.text.empty() && included.text.back() == '\r') { included.text.pop_back(); } } } else { ensure_newline(included.text, eol); } Output.append(included); } else { // the line break leading the replacement isn't needed if the text ahead happens to end with one auto const lead{(Output.text.empty() || Output.text.back() == '\n') ? std::size_t{0} : edit.lead}; auto const offset{static_cast(Output.text.size() + lead)}; auto const span{source_span{offset, offset + static_cast(edit.length)}}; if (edit.node != nullptr) { Output.nodes.push_back({edit.node, span, true}); } if (edit.site != null_handle) { Output.sites.push_back({edit.site, span}); } if (edit.instance != 0) { Output.instances.push_back({edit.instance, span}); } Output.text.append(edit.text, edit.lead - lead, std::string::npos); } cursor = end; shifts.emplace_back(end, static_cast(Output.text.size()) - end); } Output.text.append(base, static_cast(cursor), std::string::npos); // what the changes didn't touch only moves along with the text around it auto const shift = [&](source_span Span) { auto const lookup{std::upper_bound(std::begin(shifts), std::end(shifts), Span.begin, [](std::streamoff const Location, std::pair const &Shift) { return Location < Shift.first; })}; auto const offset{lookup == std::begin(shifts) ? 0 : std::prev(lookup)->second}; Span.begin += offset; Span.end += offset; return Span; }; if (auto const lookup{State.nodes.find(Layer)}; lookup != State.nodes.end()) { for (auto const *node : lookup->second) { Output.nodes.push_back({node, shift(m_sources.at(node).span), false}); } } if (auto const lookup{State.includes.find(Layer)}; lookup != State.includes.end()) { for (auto const included : lookup->second) { Output.sites.push_back({included, shift(layer(included).sites.front().span)}); } } if (auto const lookup{State.instances.find(Layer)}; lookup != State.instances.end()) { for (auto const included : lookup->second) { Output.instances.push_back({included, shift(instance(included).span)}); } } // NOTE: with the file holding the initialization inlined, its own mark was carried over along with its text if (hasinit && false == initedited && false == Output.init.valid()) { Output.init = shift(init); } return true; } // writes changes made in the editor to the scenery files save_result node_layers::save(std::vector const &Rootstatements, std::vector const &Trailingstatements) { save_result result; if (m_layers.empty()) { result.message = "The scenery wasn't opened for editing, there are no layers to save."; return result; } auto const root{layer_handle{1}}; save_state state; auto const fail = [&](std::string const &Message) { result.message = "Scenery not saved: " + Message; ErrorLog(result.message); return result; }; // loads text of specified layer file, if it wasn't loaded yet auto const load = [&](layer_handle const Layer) { if (layer(Layer).created || state.content.count(Layer) != 0) { return true; } std::error_code error; auto const filepath{std::filesystem::path(path(Layer))}; auto const filesize{std::filesystem::file_size(filepath, error)}; auto const filetime{error ? std::filesystem::file_time_type{} : std::filesystem::last_write_time(filepath, error)}; std::string content; if (error || false == read_file(path(Layer), content)) { state.error = "can't read file \"" + layer(Layer).name + "\""; return false; } if (filesize != layer(Layer).filesize || filetime != layer(Layer).filetime || content.size() != filesize) { // locations of the definitions are no longer valid state.error = "file \"" + layer(Layer).name + "\" was changed outside of the editor since the scenery was loaded"; return false; } state.content.emplace(Layer, std::move(content)); return true; }; // a layer is saved if it's still a part of the scenery, as a file of its own auto const is_output = [&](layer_handle const Layer) { return listed(Layer) && false == layer(Layer).removed && false == layer(Layer).binary; }; if (false == load(root)) { return fail(state.error); } state.eol = detect_eol(state.content[root], "\r\n"); // changed and deleted nodes std::set rewritten; std::map>> created; // new definitions, by target layer auto const process = [&](basic_node const *Node, TAnimModel const *Model) { auto const nodelayer{resolve(Node->layer())}; if (false == is_output(nodelayer)) { return true; } if (Model != nullptr && Groups.relocating(Node)) { // written anew along with its group, below return true; } auto const location{Node->location()}; auto const angles{Model != nullptr ? Model->Angles() : glm::vec3{0.f}}; auto const scale{Model != nullptr ? Model->Scale() : glm::vec3{1.f}}; auto const lookup{m_sources.find(Node)}; std::string error; if (lookup != m_sources.end()) { // defined in a scenery file. rewrite the placement in its definition if it's no longer the one it was loaded with auto const &source{lookup->second}; if (false == writable(source.layer)) { // the editor doesn't let these be changed, as one definition stands for more than one node return true; } auto const moved{glm::distance(location, source.location) > 1e-4}; auto const rotated{false == glm::all(glm::epsilonEqual(angles, source.angles, 1e-3f))}; auto const scaled{false == glm::all(glm::epsilonEqual(scale, source.scale, 1e-4f))}; auto const renamed{Model != nullptr && m_renamed.find(Node) != m_renamed.end()}; auto const *cell{Model == nullptr ? static_cast(Node) : nullptr}; auto const track{cell != nullptr && cell->Track != nullptr ? cell->Track->name() : std::string{}}; if (false == (moved || rotated || scaled || renamed || (false == track.empty() && Node->dirty()))) // NOSONAR { return true; } if (false == load(source.layer)) { return false; } auto const &content{state.content[source.layer]}; if (source.span.end > static_cast(content.size())) { state.error = "definition of \"" + Node->name() + "\" is out of bounds of file \"" + layer(source.layer).name + "\""; // NOSONAR return false; } auto text{content.substr(static_cast(source.span.begin), static_cast(source.span.end - source.span.begin))}; auto patched{false}; if (Model != nullptr) { model_placement placement; placement.rotation = source.context.rotation; if (moved) { placement.location = source.context.to_local(location); } if (rotated) { placement.angles = angles; } if (scaled) { placement.scale = scale / source.context.scale; } if (renamed) { placement.name = (Node->name().empty() ? "none" : Node->name()); } patched = patch_model(text, placement, error); } else { patched = patch_memcell(text, moved ? std::optional{source.context.to_local(location)} : std::nullopt, track, error); } if (false == patched) { state.error = error + " of \"" + Node->name() + "\" in file \"" + layer(source.layer).name + "\""; // NOSONAR return false; } if (text.size() == static_cast(source.span.end - source.span.begin) && content.compare(static_cast(source.span.begin), text.size(), text) == 0) { return true; } file_patch::edit edit; edit.begin = source.span.begin; edit.end = source.span.end; edit.length = text.size(); edit.text = std::move(text); edit.node = Node; state.patches[source.layer].edits.emplace_back(std::move(edit)); rewritten.emplace(Node); } else if (Model != nullptr && false == Node->from_template()) { // created in the editor. the definition is added to the layer file, with the placement in effect at the place it goes to std::string text; Node->export_as_text(text); text.erase(text.find_last_not_of(" \t\r\n") + 1); auto const &context{layer(nodelayer).context_insert()}; if (false == context.matches(layer_context())) { model_placement placement; placement.rotation = context.rotation; placement.location = context.to_local(location); placement.angles = angles; placement.scale = scale / context.scale; if (false == patch_model(text, placement, error)) { state.error = error + " of \"" + Node->name() + "\""; return false; } } created[nodelayer].emplace_back(Node, std::move(text)); } return true; }; for (auto const *instance : simulation::Instances.sequence()) { if (instance != nullptr && false == process(instance, instance)) { return fail(state.error); } } for (auto const *memorycell : simulation::Memory.sequence()) { if (memorycell != nullptr && false == process(memorycell, nullptr)) { return fail(state.error); } } // models grouped or ungrouped in the editor: their definitions are taken out of where they are and written anew, a group // as one block between group and endgroup in the layer of its first model, a model of a dissolved group on its own std::set relocated; auto const relocate = [&](TAnimModel const *Model, layer_handle const Target, std::vector> &Output) { if (auto const lookup{m_sources.find(Model)}; lookup != m_sources.end()) { auto const &source{lookup->second}; if (false == writable(source.layer)) { // one definition stands for more than one node, it stays where it is return true; } if (false == load(source.layer)) { return false; } file_patch::edit edit; edit.begin = source.span.begin; edit.end = source.span.end; state.patches[source.layer].edits.emplace_back(std::move(edit)); rewritten.emplace(Model); } std::string text; Model->export_as_text(text); text.erase(text.find_last_not_of(" \t\r\n") + 1); auto const &context{layer(Target).context_insert()}; if (false == context.matches(layer_context())) { model_placement placement; placement.rotation = context.rotation; placement.location = context.to_local(Model->location()); placement.angles = Model->Angles(); placement.scale = Model->Scale() / context.scale; std::string error; if (false == patch_model(text, placement, error)) { state.error = error + " of \"" + Model->name() + "\""; return false; } } Output.emplace_back(Model, std::move(text)); relocated.emplace(Model); return true; }; for (auto *node : Groups.loose()) { auto const *model{dynamic_cast(node)}; if (model == nullptr || model->from_template() || false == is_output(resolve(node->layer()))) { continue; } if (false == relocate(model, resolve(node->layer()), created[resolve(node->layer())])) { return fail(state.error); } } for (auto const group : Groups.rewritten()) { auto const &nodes{Groups.group(group).nodes}; if (nodes.empty() || false == is_output(resolve(nodes.front()->layer()))) { continue; } auto const target{resolve(nodes.front()->layer())}; auto &output{created[target]}; // NOTE: lines with no node are written as they are, without a place in the bookkeeping output.emplace_back(nullptr, "group"); for (auto const *node : nodes) { auto const *model{dynamic_cast(node)}; if (model == nullptr || model->from_template()) { continue; } if (false == relocate(model, target, output)) { return fail(state.error); } } output.emplace_back(nullptr, "endgroup"); } auto const push_edit = [&](layer_handle const Layer, source_span const &Span, std::string Text, basic_node const *Node) { file_patch::edit edit; edit.begin = Span.begin; edit.end = Span.end; edit.length = Text.size(); edit.text = std::move(Text); edit.node = Node; state.patches[Layer].edits.emplace_back(std::move(edit)); }; // changes which can't be written, as their definition stands for more than one node std::vector unwritten; // traction and event launchers are rewritten when the editor moved them along with the track they belong to std::vector patchednodes; auto const patch_node = [&](basic_node *Node, auto const &Patch) { auto const lookup{m_sources.find(Node)}; if (lookup == m_sources.end() || false == Node->dirty()) { return true; } auto const &source{lookup->second}; if (false == is_output(resolve(source.layer))) { return true; } if (false == writable(source.layer)) { unwritten.push_back(Node->name()); return true; } if (false == load(source.layer)) { return false; } auto const &content{state.content[source.layer]}; if (source.span.end > static_cast(content.size())) { state.error = "definition of \"" + Node->name() + "\" is out of bounds of file \"" + layer(source.layer).name + "\""; return false; } auto text{content.substr(static_cast(source.span.begin), static_cast(source.span.end - source.span.begin))}; std::string error; if (false == Patch(text, source, error)) { state.error = error + " of \"" + Node->name() + "\" in file \"" + layer(source.layer).name + "\""; return false; } push_edit(source.layer, source.span, std::move(text), Node); rewritten.emplace(Node); patchednodes.push_back(Node); return true; }; for (auto *traction : simulation::Traction.sequence()) { if (traction != nullptr && false == patch_node(traction, [&](std::string &Text, node_source const &Source, std::string &Error) { return patch_traction(Text, *traction, Source.context.offset, Error); })) { return fail(state.error); } } for (auto *launcher : simulation::Events.launchers()) { if (launcher != nullptr && false == patch_node(launcher, [&](std::string &Text, node_source const &Source, std::string &Error) { return patch_launcher(Text, Source.context.to_local(launcher->location()), Error); })) { return fail(state.error); } } std::vector savedpaths; std::vector droppedpaths; auto const path_text = [](TTrack &Path, glm::dvec3 const &Offset) { auto const paths{Path.m_paths}; for (auto &path : Path.m_paths) path.translate(-Offset); std::string text; Path.export_as_text(text); Path.m_paths = paths; text.erase(text.find_last_not_of(" \t\r\n") + 1); return text; }; for (auto *path : simulation::Paths.sequence()) { if (path == nullptr || (path->iCategoryFlag & 0x80) != 0 || path->m_road != nullptr || path->from_template()) { continue; } auto const nodelayer{resolve(path->layer())}; if (false == is_output(nodelayer)) { continue; } auto const lookup{m_sources.find(path)}; if (lookup == m_sources.end()) { if (path->m_editorremoved || false == path->dirty()) { continue; } // a file included more than once would get a copy of the new path at every inclusion auto const target{writable(nodelayer) ? nodelayer : root}; created[target].emplace_back(path, path_text(*path, layer(target).context_insert().offset)); savedpaths.push_back(path); continue; } auto const &source{lookup->second}; if (false == path->m_editorremoved && false == path->dirty()) { continue; } if (false == writable(source.layer)) { unwritten.push_back(path->name().empty() ? std::string{"(noname)"} : path->name()); continue; } if (false == load(source.layer)) { return fail(state.error); } if (path->m_editorremoved) { push_edit(source.layer, source.span, {}, nullptr); droppedpaths.push_back(path); } else { push_edit(source.layer, source.span, path_text(*path, source.context.offset), path); savedpaths.push_back(path); } rewritten.emplace(path); } // roads. the lanes aren't saved, they're generated from the definition of the road std::vector savedroads; std::vector droppedroads; auto const road_text = [](road_node &Road, glm::dvec3 const &Offset) { auto const definition{Road.definition()}; auto local{definition}; local.axis.points[segment_data::point::start] -= Offset; local.axis.points[segment_data::point::end] -= Offset; Road.define(local); std::string text; Road.export_as_text(text); Road.define(definition); text.erase(text.find_last_not_of(" \t\r\n") + 1); return text; }; for (auto *road : simulation::Roads.sequence()) { if (road == nullptr || road->from_template()) { continue; } auto const nodelayer{resolve(road->layer())}; if (false == is_output(nodelayer)) { continue; } auto const lookup{m_sources.find(road)}; if (lookup == m_sources.end()) { if (road->m_editorremoved || false == road->dirty()) { continue; } created[nodelayer].emplace_back(road, road_text(*road, layer(nodelayer).context_insert().offset)); savedroads.push_back(road); continue; } auto const &source{lookup->second}; if ((false == road->m_editorremoved && false == road->dirty()) || false == writable(source.layer)) { continue; } if (false == load(source.layer)) { return fail(state.error); } file_patch::edit edit; edit.begin = source.span.begin; edit.end = source.span.end; if (road->m_editorremoved) { droppedroads.push_back(road); } else { edit.text = road_text(*road, source.context.offset); edit.length = edit.text.size(); edit.node = road; savedroads.push_back(road); } state.patches[source.layer].edits.emplace_back(std::move(edit)); rewritten.emplace(road); } // models laid along curves, likewise std::vector savedsweeps; std::vector droppedsweeps; auto const sweep_text = [](sweep_node &Sweep, glm::dvec3 const &Offset) { auto const definition{Sweep.definition()}; auto local{definition}; for (auto &piece : local.pieces) { piece.points[segment_data::point::start] -= Offset; piece.points[segment_data::point::end] -= Offset; } Sweep.define(local); std::string text; Sweep.export_as_text(text); Sweep.define(definition); text.erase(text.find_last_not_of(" \t\r\n") + 1); return text; }; for (auto *sweep : simulation::Sweeps.sequence()) { if (sweep == nullptr || sweep->from_template()) { continue; } auto const nodelayer{resolve(sweep->layer())}; if (false == is_output(nodelayer)) { continue; } auto const lookup{m_sources.find(sweep)}; if (lookup == m_sources.end()) { if (sweep->m_editorremoved || false == sweep->dirty()) { continue; } created[nodelayer].emplace_back(sweep, sweep_text(*sweep, layer(nodelayer).context_insert().offset)); savedsweeps.push_back(sweep); continue; } auto const &source{lookup->second}; if ((false == sweep->m_editorremoved && false == sweep->dirty()) || false == writable(source.layer)) { continue; } if (false == load(source.layer)) { return fail(state.error); } file_patch::edit edit; edit.begin = source.span.begin; edit.end = source.span.end; if (sweep->m_editorremoved) { droppedsweeps.push_back(sweep); } else { edit.text = sweep_text(*sweep, source.context.offset); edit.length = edit.text.size(); edit.node = sweep; savedsweeps.push_back(sweep); } state.patches[source.layer].edits.emplace_back(std::move(edit)); rewritten.emplace(sweep); } // road junctions, likewise std::vector savedjunctions; std::vector droppedjunctions; auto const junction_text = [](junction_node &Junction, glm::dvec3 const &Offset) { auto const definition{Junction.definition()}; auto local{definition}; local.centre -= Offset; for (auto &arm : local.arms) { arm.position -= Offset; } Junction.define(local); std::string text; Junction.export_as_text(text); Junction.define(definition); text.erase(text.find_last_not_of(" \t\r\n") + 1); return text; }; for (auto *junction : simulation::Junctions.sequence()) { if (junction == nullptr || junction->from_template()) { continue; } auto const nodelayer{resolve(junction->layer())}; if (false == is_output(nodelayer)) { continue; } auto const lookup{m_sources.find(junction)}; if (lookup == m_sources.end()) { if (junction->m_editorremoved || false == junction->dirty()) { continue; } created[nodelayer].emplace_back(junction, junction_text(*junction, layer(nodelayer).context_insert().offset)); savedjunctions.push_back(junction); continue; } auto const &source{lookup->second}; if ((false == junction->m_editorremoved && false == junction->dirty()) || false == writable(source.layer)) { continue; } if (false == load(source.layer)) { return fail(state.error); } file_patch::edit edit; edit.begin = source.span.begin; edit.end = source.span.end; if (junction->m_editorremoved) { droppedjunctions.push_back(junction); } else { edit.text = junction_text(*junction, source.context.offset); edit.length = edit.text.size(); edit.node = junction; savedjunctions.push_back(junction); } state.patches[source.layer].edits.emplace_back(std::move(edit)); rewritten.emplace(junction); } // level crossings and traffic points of the roads std::vector savedroadpoints; std::vector droppedroadpoints; auto const roadpoint_text = [](roadpoint_node &Point, glm::dvec3 const &Offset) { auto const definition{Point.definition()}; auto local{definition}; local.position -= Offset; Point.define(local); std::string text; Point.export_as_text(text); Point.define(definition); text.erase(text.find_last_not_of(" \t\r\n") + 1); return text; }; for (auto *point : simulation::Roadpoints.sequence()) { if (point == nullptr || point->from_template()) { continue; } auto const nodelayer{resolve(point->layer())}; if (false == is_output(nodelayer)) { continue; } auto const lookup{m_sources.find(point)}; if (lookup == m_sources.end()) { if (point->m_editorremoved || false == point->dirty()) { continue; } created[nodelayer].emplace_back(point, roadpoint_text(*point, layer(nodelayer).context_insert().offset)); savedroadpoints.push_back(point); continue; } auto const &source{lookup->second}; if ((false == point->m_editorremoved && false == point->dirty()) || false == writable(source.layer)) { continue; } if (false == load(source.layer)) { return fail(state.error); } file_patch::edit edit; edit.begin = source.span.begin; edit.end = source.span.end; if (point->m_editorremoved) { droppedroadpoints.push_back(point); } else { edit.text = roadpoint_text(*point, source.context.offset); edit.length = edit.text.size(); edit.node = point; savedroadpoints.push_back(point); } state.patches[source.layer].edits.emplace_back(std::move(edit)); rewritten.emplace(point); } for (auto const &erased : m_erased) { file_patch::edit edit; edit.begin = erased.second.begin; edit.end = erased.second.end; state.patches[erased.first].edits.emplace_back(std::move(edit)); } // editor data in the comment lines: the old lines go, the new ones are added to the file like new definitions std::map> markedtext; for (auto const &marking : m_marking) { auto const markedlayer{marking.first.first}; auto const &mark{marking.first.second}; if (auto const lookup{m_marked.find(markedlayer)}; lookup != m_marked.end()) { for (auto const &line : lookup->second) { if (line.mark == mark) { push_edit(markedlayer, line.span, {}, nullptr); } } } auto const target{resolve(markedlayer)}; if (marking.second.empty() || false == is_output(target)) { continue; } for (auto const &text : marking.second) { markedtext[target].push_back(mark + ' ' + text); } } // includes of templates std::map> placed; // directives made in the editor, by target layer for (std::size_t idx = 0; idx < m_instances.size(); ++idx) { auto const &included{m_instances[idx]}; if (included.dead) { continue; } if (included.span.valid()) { if (included.removed) { file_patch::edit edit; edit.begin = included.span.begin; edit.end = included.span.end; state.patches[included.layer].edits.emplace_back(std::move(edit)); } else if (false == included.directive.empty()) { // changed in the editor, the new directive takes the place of the one in the file file_patch::edit edit; edit.begin = included.span.begin; edit.end = included.span.end; edit.length = included.directive.size(); edit.text = included.directive; edit.instance = static_cast(idx + 1); state.patches[included.layer].edits.emplace_back(std::move(edit)); } } else if (false == included.removed && false == included.directive.empty()) { auto const target{resolve(included.layer)}; if (false == is_output(target)) { continue; } if (false == included.context.matches(layer(target).context_insert())) { // shouldn't happen, merge of layers is refused when it'd lead to this return fail("include \"" + *included.file + "\" was placed for different origin, rotation or scale than its layer file receives it with"); } placed[target].emplace_back(static_cast(idx + 1)); } } // layer changes std::map> appended; // merged layers placed at the end of their target std::set absorbed; // merged layers which cease to exist as files std::set detached; // layers whose include directives are replaced for (std::size_t idx = 0; idx < m_layers.size(); ++idx) { auto const candidate{static_cast(idx + 1)}; auto const &source{layer(candidate)}; if (source.dead) { continue; } auto const hassite{false == source.created && source.sites.size() == 1 && false == source.sites.front().fixed && source.sites.front().span.valid()}; if (source.merged != null_handle) { auto const target{resolve(candidate)}; absorbed.emplace(candidate); if (hassite) { file_patch::edit edit; edit.begin = source.sites.front().span.begin; edit.end = source.sites.front().span.end; if (resolve(source.sites.front().parent) == target) { edit.inlined = candidate; } else { appended[target].emplace_back(candidate); } state.patches[source.sites.front().parent].edits.emplace_back(std::move(edit)); detached.emplace(candidate); } else { appended[target].emplace_back(candidate); } } else if (source.removed) { if (hassite) { file_patch::edit edit; edit.begin = source.sites.front().span.begin; edit.end = source.sites.front().span.end; state.patches[source.sites.front().parent].edits.emplace_back(std::move(edit)); detached.emplace(candidate); } } else if (source.created) { // include directive for the new file, on a line of its own auto const parent{source.parent}; auto const directive{"include " + source.name + " end"}; file_patch::edit edit; edit.site = candidate; edit.length = directive.size(); if (valid(source.anchor) && layer(source.anchor).sites.size() == 1 && layer(source.anchor).sites.front().parent == parent && layer(source.anchor).sites.front().span.valid()) { edit.begin = edit.end = layer(source.anchor).sites.front().span.end; edit.text = state.eol + directive; edit.lead = state.eol.size(); } else { // NOTE: negative location stands for the place the file receives additions at edit.begin = edit.end = -1; if (false == layer(parent).created) { if (false == load(parent)) { return fail(state.error); } // dropped by compose() if the text ahead ends with a line break already edit.text = state.eol; edit.lead = state.eol.size(); } edit.text += directive + state.eol; } state.patches[parent].edits.emplace_back(std::move(edit)); } } // files to write std::vector outputs; for (std::size_t idx = 0; idx < m_layers.size(); ++idx) { auto const candidate{static_cast(idx + 1)}; if (false == is_output(candidate)) { continue; } if (layer(candidate).created || state.patches.count(candidate) != 0 || appended.count(candidate) != 0 || created.count(candidate) != 0 || placed.count(candidate) != 0 || markedtext.count(candidate) != 0 || (candidate == root && (false == Rootstatements.empty() || false == Trailingstatements.empty()))) { outputs.emplace_back(candidate); } } // text of the merged layers travels with the file they end up in, but changes made to it are still theirs std::vector composed{outputs}; composed.insert(std::end(composed), std::begin(absorbed), std::end(absorbed)); for (auto const candidate : composed) { if (false == load(candidate)) { return fail(state.error); } } // what is defined in these files and isn't rewritten moves along with the text for (auto const &source : m_sources) { if (state.content.count(source.second.layer) != 0 && rewritten.count(source.first) == 0) { state.nodes[source.second.layer].emplace_back(source.first); } } for (std::size_t idx = 0; idx < m_layers.size(); ++idx) { auto const candidate{static_cast(idx + 1)}; auto const &included{layer(candidate)}; if (included.dead || included.created || detached.count(candidate) != 0 || included.sites.size() != 1 || included.sites.front().fixed || false == included.sites.front().span.valid()) { continue; } state.includes[included.sites.front().parent].emplace_back(candidate); } for (std::size_t idx = 0; idx < m_instances.size(); ++idx) { auto const &included{m_instances[idx]}; if (false == included.dead && false == included.removed && included.directive.empty() && included.span.valid() && state.content.count(included.layer) != 0) { state.instances[included.layer].emplace_back(static_cast(idx + 1)); } } std::map texts; for (auto const output : outputs) { auto &text{texts[output]}; auto const eol{detect_eol(state.content.count(output) != 0 ? state.content[output] : std::string{}, state.eol)}; if (layer(output).created) { // NOTE: the parser treats an empty file as a missing one text.text = "// scenery layer created in the editor" + eol; } composition body; if (false == compose(state, output, body, 0)) { return fail(state.error); } text.append(body); // what the editor adds to the file composition added; if (auto const lookup{appended.find(output)}; lookup != appended.end()) { for (auto const merged : lookup->second) { composition mergedtext; if (false == compose(state, merged, mergedtext, 0)) { return fail(state.error); } ensure_newline(added.text, eol); added.append(mergedtext); } } if (auto const lookup{created.find(output)}; lookup != created.end()) { for (auto const &definition : lookup->second) { ensure_newline(added.text, eol); auto const offset{static_cast(added.text.size())}; if (definition.first != nullptr) { added.nodes.push_back({definition.first, {offset, offset + static_cast(definition.second.size())}, true}); } added.text += definition.second + eol; } } if (auto const lookup{placed.find(output)}; lookup != placed.end()) { for (auto const included : lookup->second) { auto const &directive{instance(included).directive}; ensure_newline(added.text, eol); auto const offset{static_cast(added.text.size())}; added.instances.push_back({included, {offset, offset + static_cast(directive.size())}}); added.text += directive + eol; } } if (auto const lookup{markedtext.find(output)}; lookup != markedtext.end()) { for (auto const &line : lookup->second) { ensure_newline(added.text, eol); added.text += line + eol; } } if (output == root) { for (auto const &statement : Rootstatements) { ensure_newline(added.text, eol); added.text += statement + eol; } } if (output == root && false == Trailingstatements.empty()) { composition trailing; for (auto const &statement : Trailingstatements) trailing.text += statement + eol; ensure_newline(text.text, eol); text.append(trailing); } if (added.text.empty()) { continue; } if (false == text.init.valid()) { ensure_newline(text.text, eol); text.append(added); continue; } // the scenario initialization is meant to be followed by nothing but the vehicles, so the additions go ahead // of it. they take whole lines, with the initialization left at the start of a line the way it was ensure_newline(added.text, eol); auto location{text.init.begin}; while (location > 0 && (text.text[location - 1] == ' ' || text.text[location - 1] == '\t')) { --location; } if (location == 0 || text.text[location - 1] == '\n') { text.insert(location, added); } else { composition separated; separated.text = eol; separated.append(added); text.insert(text.init.begin, separated); } } // with everything worked out it's safe to touch the files. new content goes to temporary files first, // so a failure half way through doesn't leave the scenery with only a part of the changes std::error_code error; std::vector written; for (auto const output : outputs) { auto const lookup{state.content.find(output)}; if (lookup != state.content.end() && lookup->second == texts[output].text) { continue; } auto const filepath{std::filesystem::path(path(output))}; if (filepath.has_parent_path()) { std::filesystem::create_directories(filepath.parent_path(), error); } std::ofstream file{path(output) + ".tmp", std::ios_base::binary | std::ios_base::trunc}; file << texts[output].text; file.close(); if (file.fail()) { for (auto const leftover : written) { std::filesystem::remove(path(leftover) + ".tmp", error); } std::filesystem::remove(path(output) + ".tmp", error); return fail("can't write file \"" + layer(output).name + "\""); } written.emplace_back(output); } // a file which loses its ground to the terrain of the editor keeps a copy of itself each time, whatever was saved before. // without the copy nothing is replaced { auto const now{std::time(nullptr)}; char stamp[32]; std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", std::localtime(&now)); for (auto const output : written) { auto const filepath{path(output)}; bool const losesground{std::any_of(std::begin(m_shapes), std::end(m_shapes), [&](shape_source const &Shape) { return Shape.erased && Shape.layer == output; })}; if (false == losesground || false == std::filesystem::exists(filepath, error)) { continue; } auto const backup{filepath + ".bak-" + stamp + "-przed-terenem-edytora"}; std::error_code copyerror; if (false == std::filesystem::copy_file(filepath, backup, std::filesystem::copy_options::overwrite_existing, copyerror)) { for (auto const leftover : written) { std::filesystem::remove(path(leftover) + ".tmp", error); } return fail("can't make a backup of file \"" + layer(output).name + "\": " + copyerror.message()); } WriteLog("Scenery save: the triangles of the ground go from \"" + layer(output).name + "\", its previous content is kept in \"" + backup + "\""); result.backups.emplace_back(backup); } } std::vector replaced; for (auto const output : written) { auto const filepath{path(output)}; // the first save of a file leaves its original content next to it if (false == layer(output).editormade && std::filesystem::exists(filepath, error) && false == std::filesystem::exists(filepath + ".bak", error)) { std::filesystem::copy_file(filepath, filepath + ".bak", error); } std::filesystem::rename(filepath + ".tmp", filepath, error); if (error) { // e.g. the file is held open by another program. take back what was done so far: the scenery files // have to stay in step with each other, and with what we know about them auto const cause{error.message()}; for (auto const restored : replaced) { auto const lookup{state.content.find(restored)}; if (lookup != state.content.end()) { std::ofstream{path(restored), std::ios_base::binary | std::ios_base::trunc} << lookup->second; stat(restored); } else { std::filesystem::remove(path(restored), error); } } for (auto const leftover : written) { std::filesystem::remove(path(leftover) + ".tmp", error); } result.files.clear(); return fail("can't replace file \"" + layer(output).name + "\": " + cause); } replaced.emplace_back(output); result.files.emplace_back(layer(output).name); } for (auto const &file : result.files) { WriteLog("Scenery save: file \"" + file + "\" updated"); } // the scenery files now match the scene, update the bookkeeping to reflect it Groups.saved(); auto removedterrain{false}; for (auto const output : outputs) { for (auto const &place : texts[output].nodes) { auto &source{m_sources[place.node]}; auto const isnew{source.layer == null_handle}; source.layer = output; source.span = place.span; // a definition written anew elsewhere is in the placement of the place it went to, as a new one is if (isnew || relocated.count(place.node) != 0) { source.context = layer(output).context_insert(); } if (isnew || place.rewritten) { auto const *model{dynamic_cast(place.node)}; source.location = place.node->location(); source.angles = (model != nullptr ? model->Angles() : glm::vec3{0.f}); source.scale = (model != nullptr ? model->Scale() : glm::vec3{1.f}); } } for (auto const &place : texts[output].instances) { auto &included{m_instances[place.instance - 1]}; included.layer = output; included.span = place.span; included.directive.clear(); } for (auto const &place : texts[output].sites) { auto &included{layer(place.layer)}; if (included.sites.empty()) { // directive of a layer created in the editor included.sites.emplace_back(); } included.sites.front().parent = output; included.sites.front().span = place.span; included.parent = output; } // the mark of the scenario initialization moved along with the text around it, or came with the text of a merged layer auto &saved{layer(output)}; if (false == saved.init.valid() && texts[output].init.valid()) { for (std::size_t idx = 0; idx < m_layers.size(); ++idx) { if (m_layers[idx].init.valid() && m_layers[idx].merged != null_handle && resolve(static_cast(idx + 1)) == output) { saved.context_init = m_layers[idx].context_init; } } } saved.init = texts[output].init; } for (std::size_t idx = 0; idx < m_layers.size(); ++idx) { auto const candidate{static_cast(idx + 1)}; auto &saved{layer(candidate)}; if (saved.dead) { continue; } if (saved.merged != null_handle || saved.removed) { removedterrain |= saved.removed && (saved.items[static_cast(layer_item::shape)] + saved.items[static_cast(layer_item::lines)] > 0); saved.dead = true; saved.sites.clear(); } saved.created = false; saved.anchor = null_handle; } for (auto const output : written) { stat(output); scan(output); } for (auto const &marking : m_marking) { if (resolve(marking.first.first) != marking.first.first) { // the lines of the merged layer are now in the file of its target m_marked.erase(marking.first.first); } } m_marking.clear(); for (auto &included : m_instances) { // removed includes are gone from the files now included.dead = included.dead || included.removed; } m_erased.clear(); m_renamed.clear(); // the shapes of the rewritten files are somewhere else in them now m_shapes.erase(std::remove_if(std::begin(m_shapes), std::end(m_shapes), [&](shape_source const &Shape) { return std::find(std::begin(composed), std::end(composed), Shape.layer) != std::end(composed); }), std::end(m_shapes)); for (auto *path : savedpaths) { path->m_dirty = false; } for (auto *node : patchednodes) { node->m_dirty = false; } for (auto const *path : droppedpaths) { m_sources.erase(path); } for (auto *road : savedroads) { road->m_dirty = false; } for (auto const *road : droppedroads) { m_sources.erase(road); } for (auto *sweep : savedsweeps) { sweep->m_dirty = false; } for (auto const *sweep : droppedsweeps) { m_sources.erase(sweep); } for (auto *junction : savedjunctions) { junction->m_dirty = false; } for (auto const *junction : droppedjunctions) { m_sources.erase(junction); } for (auto *point : savedroadpoints) { point->m_dirty = false; } for (auto const *point : droppedroadpoints) { m_sources.erase(point); } removedterrain |= m_shapeserased; m_shapeserased = false; if (removedterrain) { // binary terrain file still holds geometry of the dropped layer, have it rebuilt on the next load auto terrainfile{layer(root).name}; while (false == terrainfile.empty() && terrainfile.front() == '$') { terrainfile.erase(0, 1); } erase_extension(terrainfile); if (std::filesystem::remove(Global.asCurrentSceneryPath + terrainfile + ".sbt", error)) { WriteLog("Scenery save: binary terrain file removed, it'll be generated again on the next load"); } } result.success = true; if (result.files.empty()) { result.message = "No changes to save."; } else { result.message = "Saved: "; for (auto const &file : result.files) { result.message += file + (&file != &result.files.back() ? ", " : ""); } for (auto const &backup : result.backups) { result.message += ". Backup before the ground was taken out: " + std::filesystem::path(backup).filename().string(); } } if (false == unwritten.empty()) { std::string names; for (std::size_t i = 0; i < std::min(unwritten.size(), 5); ++i) { names += (i > 0 ? ", " : "") + unwritten[i]; } if (unwritten.size() > 5) { names += ", ..."; } auto const warning{std::to_string(unwritten.size()) + " changed nodes not saved, their files are included with parameters or more than once: " + names}; WriteLog("Scenery save: " + warning, logtype::generic); result.message = result.files.empty() ? warning : result.message + ". " + warning; } return result; } } // namespace scene //---------------------------------------------------------------------------