From 3199f3796ed53569b0208c9503b59db8c2ab9b5b Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Fri, 2 Oct 2026 22:40:52 +0200 Subject: [PATCH] feat(editor): layers window, scenery save and include descriptions --- CMakeLists.txt | 9 + application/editormode.cpp | 67 +- application/editormode.h | 7 +- application/editoruilayer.cpp | 132 ++- application/editoruilayer.h | 11 +- application/editoruipanels.cpp | 579 +++++++++++++- application/editoruipanels.h | 51 ++ editor/editorIncludeInfo.cpp | 509 ++++++++++++ editor/editorIncludeInfo.hpp | 59 ++ scene/scenelayers.cpp | 510 +++++++++++- scene/scenelayers.h | 159 +++- scene/scenelayerssave.cpp | 972 +++++++++++++++++++++++ scene/scenenode.cpp | 1 + scene/scenenode.h | 2 + simulation/simulation.cpp | 1 + simulation/simulationstateserializer.cpp | 28 +- utilities/parser.cpp | 56 +- utilities/parser.h | 15 + 18 files changed, 3045 insertions(+), 123 deletions(-) create mode 100644 editor/editorIncludeInfo.cpp create mode 100644 editor/editorIncludeInfo.hpp create mode 100644 scene/scenelayerssave.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 13990261..0f13c2b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -168,6 +168,7 @@ set(SOURCES "input/editorkeyboardinput.cpp" "editor/editorSettings.cpp" "editor/editorModelSets.cpp" +"editor/editorIncludeInfo.cpp" "editor/editorTerrain.cpp" "editor/editorTerrainStreamer.cpp" "editor/editorOrthophoto.cpp" @@ -179,6 +180,7 @@ set(SOURCES "application/scenarioloaderuilayer.cpp" "scene/scenenodegroups.cpp" "scene/scenelayers.cpp" +"scene/scenelayerssave.cpp" "simulation/simulationenvironment.cpp" "simulation/simulationstateserializer.cpp" "rendering/precipitation.cpp" @@ -608,6 +610,13 @@ if (WITH_BETTER_RENDERER) target_link_libraries(${PROJECT_NAME} "betterRenderer") endif () +# yaml-cpp, for the descriptions of scenery templates edited in the scenery editor. +# the experimental renderer brings in the same copy on its own, when enabled +if (NOT TARGET yaml-cpp) + add_subdirectory("betterRenderer/thirdparty/yaml-cpp" "${CMAKE_CURRENT_BINARY_DIR}/yaml-cpp" EXCLUDE_FROM_ALL) +endif () +target_link_libraries(${PROJECT_NAME} yaml-cpp) + # Enable compiling with debug symbols if(GENERATE_PDB) diff --git a/application/editormode.cpp b/application/editormode.cpp index 2dd32555..9cc938dd 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -337,6 +337,7 @@ editor_mode::editor_mode() { // the area fill settings live in the node bank window, in the tab of the fill mode ui()->set_fill_options([this]() { render_area_fill(); }); ui()->set_gizmo_options([this]() { render_gizmo_options(); }); + ui()->set_file_actions([this]() { save(); }, [this]() { export_scenery(); }); // the orthophoto is fitted onto the same ground as the area fill uses, terrain tile models included m_orthophoto.ground_source([](glm::dvec2 const &Min, glm::dvec2 const &Max, std::vector &Out) { gather_ground_triangles(Min, Max, true, 50.0f, Out); @@ -606,6 +607,7 @@ void editor_mode::push_snapshot(scene::basic_node *node, EditorSnapshot::Action snap.node_name = node->name(); snap.position = node->location(); snap.uuid = node->uuid; + snap.layer = node->layer(); if (auto *model = dynamic_cast(node)) { @@ -679,11 +681,13 @@ void editor_mode::undo_last() redoSnap.serialized = snap.serialized; redoSnap.position = snap.position; redoSnap.node_ptr = nullptr; + redoSnap.layer = snap.layer; g_redo.push_back(std::move(redoSnap)); TAnimModel *created = simulation::State.create_model(snap.serialized, snap.node_name, snap.position); if (created) { + scene::Layers.move(created, snap.layer); // back to the layer it was deleted from, rather than the active one created->location(snap.position); created->Angles(snap.rotation); m_node = created; @@ -702,6 +706,7 @@ void editor_mode::undo_last() current.action = snap.action; current.node_name = snap.node_name; current.node_ptr = target; + current.layer = target->layer(); current.position = target->location(); if (auto *model = dynamic_cast(target)) { @@ -758,6 +763,7 @@ void editor_mode::redo_last() hist.serialized = snap.serialized; hist.position = snap.position; hist.uuid = snap.uuid; + hist.layer = snap.layer; m_history.push_back(std::move(hist)); scene::basic_node *target = simulation::Instances.find(snap.node_name); @@ -799,6 +805,7 @@ void editor_mode::redo_last() TAnimModel *created = simulation::State.create_model(snap.serialized, snap.node_name, snap.position); if (created) { + scene::Layers.move(created, snap.layer); created->location(snap.position); created->Angles(snap.rotation); created->Scale(snap.scale); @@ -893,7 +900,7 @@ bool editor_mode::update() update_camera(deltarealtime); // drop the selection if its layer was hidden or locked in the meantime - if (m_node != nullptr && false == scene::Layers.editable(m_node->layer())) + if (m_node != nullptr && false == scene::Layers.editable(m_node)) { m_node = nullptr; m_dragging = false; @@ -1497,6 +1504,15 @@ void editor_mode::create_chunked_terrain() } void editor_mode::save_scene_with_terrain() +{ + commit_terrain(); + + // export scenery; the exported .scm now carries an `editorterrain` directive (streamer is active) + export_scenery(); + WriteLog("Editor: saved scene + terrain", logtype::generic); +} + +void editor_mode::commit_terrain() { // commit authored terrain so the scenery streams it on load. if not already streaming, hand the // manual grid chunks over to the streamer (same as toggling Stream terrain on) @@ -1529,10 +1545,37 @@ void editor_mode::save_scene_with_terrain() } m_streamer.flush(); // save resident edited chunks to disk +} - // export scenery; the exported .scm now carries an `editorterrain` directive (streamer is active) - export_scenery(); - WriteLog("Editor: saved scene + terrain", logtype::generic); +void editor_mode::save() +{ + if (scene::Layers.empty()) + { + // without the sources tracked during the load there's no telling where the changes belong + ui()->set_status("The scenery wasn't opened for editing. Start the simulator with: -edit ", true); + return; + } + + // terrain made in the editor is kept in files of its own, the scenery only needs the directive which loads it + std::vector rootstatements; + if (m_streamer.active() || false == m_grid_chunks.empty()) + { + commit_terrain(); + if (false == scene::Layers.terrain_directive()) + { + rootstatements.emplace_back( + "editorterrain " + m_streamer.directory() + ' ' + std::to_string(m_streamer.cells()) + ' ' + to_string(m_streamer.cellsize(), 3) + ' ' + + std::to_string(m_streamer.radius()) + " endeditorterrain"); + } + } + + auto const result = scene::Layers.save(rootstatements); + if (result.success && false == rootstatements.empty()) + { + scene::Layers.terrain_directive(true); + } + ui()->set_status(result.message, false == result.success); + WriteLog("Editor: " + result.message, logtype::generic); } void editor_mode::export_scenery() @@ -2196,6 +2239,14 @@ void editor_mode::on_key(int const Key, int const Scancode, int const Action, in return; } + // save; checked ahead of the camera keys, which have S bound to moving back + if (Global.ctrlState && false == Global.shiftState && Key == GLFW_KEY_S) + { + if (is_press(Action)) + save(); + return; + } + // then internal input handling if (m_input.keyboard.key(Key, Action)) return; @@ -2364,9 +2415,13 @@ void editor_mode::on_mouse_button(int const Button, int const Action, int const m_node = nullptr; - // nodes of locked layers can't be selected - if (node && false == scene::Layers.editable(node->layer())) + // in a scenery opened for editing some nodes are off limits: the ones in locked layers, + // and the ones whose definitions can't be rewritten on save + if (std::string reason; node && false == scene::Layers.editable(node, &reason)) + { + ui()->set_status("\"" + (node->name().empty() ? std::string{"(unnamed node)"} : node->name()) + "\" can't be edited: " + reason, true); node = nullptr; + } // ignore picks that are beyond allowed placement distance if (node) { diff --git a/application/editormode.h b/application/editormode.h index 550d05a2..b39e3408 100644 --- a/application/editormode.h +++ b/application/editormode.h @@ -96,6 +96,7 @@ class editor_mode : public application_mode glm::vec3 rotation{0.0f, 0.0f, 0.0f}; glm::vec3 scale{1.0f, 1.0f, 1.0f}; UID uuid; // node UUID for reference, used as fallback lookup for deleted/recreated nodes + scene::layer_handle layer{null_handle}; // scenery layer of the node, so a recreated node returns to it }; void push_snapshot(scene::basic_node *node, EditorSnapshot::Action Action = EditorSnapshot::Action::Move, std::string const &Serialized = std::string()); @@ -160,8 +161,12 @@ class editor_mode : public application_mode void remove_grid_chunk(int Cx, int Cz); // handles a click in chunk-edit mode (add a neighbour, or Shift = delete the clicked chunk) void handle_chunk_edit_click(bool DeleteMode); - // commits authored terrain to disk, enables streaming, and exports the scenery (Ctrl+S) + // commits authored terrain to disk, enables streaming, and exports the scenery void save_scene_with_terrain(); + // commits authored terrain to disk and enables streaming + void commit_terrain(); + // writes the changes to the files of scenery opened for editing (Ctrl+S) + void save(); // exports the scenery in legacy (text) format, with layers hidden in the editor left out of the picture void export_scenery(); // raises/lowers terrain under the cursor while the left mouse button is held in sculpt mode diff --git a/application/editoruilayer.cpp b/application/editoruilayer.cpp index 99423d60..3ca3e1f1 100644 --- a/application/editoruilayer.cpp +++ b/application/editoruilayer.cpp @@ -15,6 +15,7 @@ http://mozilla.org/MPL/2.0/. #include "scene/scenenode.h" #include "scene/scenelayers.h" #include "rendering/renderer.h" +#include "utilities/translation.h" editor_ui::editor_ui() { @@ -23,6 +24,8 @@ editor_ui::editor_ui() // bind the panels with ui object. maybe not the best place for this but, eh add_external_panel(&m_nodebankpanel); + add_external_panel(&m_layerspanel); + add_external_panel(&m_includespanel); m_nodebankpanel.mode_options = [this](nodebank_panel::edit_mode const Mode) { render_mode_options(Mode); }; m_nodebankpanel.header_sections = [this]() { render_header_sections(); }; @@ -100,98 +103,55 @@ void editor_ui::render_header_sections() m_itempropertiespanel.render_body(); ImGui::Unindent(); } - if (ImGui::CollapsingHeader("Layers")) +} + +void editor_ui::render_menu_contents() +{ + if (ImGui::BeginMenu(STR_C("File"))) { - ImGui::Indent(); - render_layers(); - ImGui::Unindent(); + // changes go to the scenery files only if the scenery was loaded with its sources tracked + auto const editsession{false == scene::Layers.empty()}; + if (ImGui::MenuItem(STR_C("Save"), "Ctrl+S", false, editsession) && m_save) + { + m_save(); + } + if (false == editsession && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + { + ImGui::SetTooltip("%s", STR_C("Start the simulator with -edit to save changes to the scenery files")); + } + if (ImGui::MenuItem(STR_C("Export scenery dump"), "Ctrl+Shift+F11") && m_export) + { + m_export(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", STR_C("Writes the whole scene to _export files next to the scenario, the scenery files are left as they are")); + } + ImGui::EndMenu(); + } + + ui_layer::render_menu_contents(); + + if (ImGui::BeginMenu(STR_C("Mode windows"))) + { + ImGui::MenuItem(STR_C("Toolset"), nullptr, &m_nodebankpanel.is_open); + ImGui::MenuItem(STR_C("Layers"), nullptr, &m_layerspanel.is_open); + ImGui::MenuItem(STR_C("Include descriptions"), nullptr, &m_includespanel.is_open); + ImGui::EndMenu(); } } -void editor_ui::render_layers() +void editor_ui::set_file_actions(std::function Save, std::function Export) { - if (scene::Layers.empty()) - { - // layers are established only when the scenery is loaded for editing, regular load skips the bookkeeping - ImGui::TextDisabled("Scenery layers are available in an edit session.\nStart the simulator with: -edit "); - return; - } + m_save = Save; + m_export = std::move(Export); + m_layerspanel.save = std::move(Save); +} - ImGui::TextDisabled("visible, locked, layer file (click: make active)"); - - std::pair const itemlabels[] = {{"models", scene::layer_item::model}, - {"tracks", scene::layer_item::track}, - {"traction", scene::layer_item::traction}, - {"power sources", scene::layer_item::powersource}, - {"memory cells", scene::layer_item::memcell}, - {"event launchers", scene::layer_item::launcher}, - {"events", scene::layer_item::event}, - {"vehicles", scene::layer_item::vehicle}, - {"sounds", scene::layer_item::sound}, - {"terrain shapes", scene::layer_item::shape}, - {"lines", scene::layer_item::lines}}; - - for (std::size_t idx = 1; idx <= scene::Layers.size(); ++idx) - { - auto const handle{static_cast(idx)}; - auto const &layer{scene::Layers.layer(handle)}; - auto const isactive{handle == scene::Layers.active()}; - - ImGui::PushID(static_cast(idx)); - // active layer receives new nodes, so it can't be hidden nor locked - if (isactive) - { - ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * 0.5f); - } - auto visible{layer.visible}; - if (ImGui::Checkbox("##visible", &visible) && false == isactive) - { - scene::Layers.visible(handle, visible); - } - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("%s", isactive ? "Active layer is always visible" : "Show or hide models, tracks and traction of the layer"); - } - ImGui::SameLine(); - auto locked{layer.locked}; - if (ImGui::Checkbox("##locked", &locked) && false == isactive) - { - scene::Layers.locked(handle, locked); - } - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("%s", isactive ? "Active layer can't be locked" : "Nodes of locked layer can't be selected in the viewport"); - } - if (isactive) - { - ImGui::PopStyleVar(); - } - ImGui::SameLine(); - // layers are listed in the order the files were first opened, so indentation alone shows the include tree - auto const label{std::string(2 * scene::Layers.depth(handle), ' ') + Bezogonkow(layer.name) + " (" + (layer.binary ? "binary terrain" : std::to_string(layer.item_count())) + ")"}; - if (ImGui::Selectable(label.c_str(), isactive)) - { - scene::Layers.active(handle); - } - if (ImGui::IsItemHovered()) - { - std::string content{isactive ? "Active layer, nodes created in the editor are placed here" : "Click to make this the active layer"}; - if (layer.binary) - { - content += "\nThe file wasn't parsed, its content was loaded from binary terrain file"; - } - for (auto const &itemlabel : itemlabels) - { - auto const count{layer.items[static_cast(itemlabel.second)]}; - if (count > 0) - { - content += "\n" + std::string{itemlabel.first} + ": " + std::to_string(count); - } - } - ImGui::SetTooltip("%s", content.c_str()); - } - ImGui::PopID(); - } +void editor_ui::set_status(std::string const &Status, bool const Error) +{ + m_layerspanel.status = Status; + m_layerspanel.status_error = Error; } void editor_ui::render_rotation_controls() diff --git a/application/editoruilayer.h b/application/editoruilayer.h index 68808d2e..fb5444af 100644 --- a/application/editoruilayer.h +++ b/application/editoruilayer.h @@ -53,17 +53,24 @@ class editor_ui : public ui_layer { m_gizmooptions = std::move(Renderer); } + // actions of the file menu, performed by the editor mode + void set_file_actions(std::function Save, std::function Export); + // shows outcome of an operation in the layers window + void set_status(std::string const &Status, bool const Error = false); private: // methods void render_mode_options(nodebank_panel::edit_mode const Mode); void render_header_sections(); - // list of scenery layers: visibility, selection lock and choice of the layer receiving new nodes - void render_layers(); + void render_menu_contents() override; // members itemproperties_panel m_itempropertiespanel{"Node Properties", true}; // not a window of its own, drawn in the toolset window functions_panel m_functionspanel{"Functions", true}; // not a window of its own, its settings are drawn in the toolset tabs nodebank_panel m_nodebankpanel{"Toolset", true}; // main editor window: gizmo, node properties, edit modes and the node bank + layers_panel m_layerspanel{"Layers", true}; + includes_panel m_includespanel{"Include descriptions", false}; + std::function m_save; + std::function m_export; brush_object_list m_brushobjects; bool m_insertrandom{false}; // insert mode picks a random template from m_insertset model_set_ref m_insertset; diff --git a/application/editoruipanels.cpp b/application/editoruipanels.cpp index 41849a8a..2f22f8f0 100644 --- a/application/editoruipanels.cpp +++ b/application/editoruipanels.cpp @@ -1076,4 +1076,581 @@ void functions_panel::render_controls() // ImGui::Checkbox("Get rotation from last object", &rot_from_last); ImGui::SliderFloat("Rotation value", &rot_value, 0.0f, 360.0f, "%.1f deg"); } -} \ No newline at end of file +} + +namespace +{ + +std::pair const layer_itemlabels[] = {{"models", scene::layer_item::model}, + {"tracks", scene::layer_item::track}, + {"traction", scene::layer_item::traction}, + {"power sources", scene::layer_item::powersource}, + {"memory cells", scene::layer_item::memcell}, + {"event launchers", scene::layer_item::launcher}, + {"events", scene::layer_item::event}, + {"vehicles", scene::layer_item::vehicle}, + {"sounds", scene::layer_item::sound}, + {"terrain shapes", scene::layer_item::shape}, + {"lines", scene::layer_item::lines}}; + +// what the layer holds, a line per kind of item +std::string layer_content(scene::basic_layer const &Layer) +{ + std::string content; + for (auto const &itemlabel : layer_itemlabels) + { + auto const count{Layer.items[static_cast(itemlabel.second)]}; + if (count > 0) + { + content += (content.empty() ? "" : "\n") + std::string{itemlabel.first} + ": " + std::to_string(count); + } + } + return content; +} + +std::string layer_name(scene::layer_handle const Layer) +{ + return scene::Layers.valid(Layer) ? Bezogonkow(scene::Layers.layer(Layer).name) : "(none)"; +} + +// button which can be greyed out, with an explanation shown when it's hovered +bool action_button(char const *Label, bool const Enabled, std::string const &Reason = std::string()) +{ + if (false == Enabled) + { + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * 0.5f); + } + auto const clicked{ImGui::Button(Label) && Enabled}; + if (false == Enabled) + { + ImGui::PopStyleVar(); + if (false == Reason.empty() && ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", Reason.c_str()); + } + } + return clicked; +} + +// text field bound to a string +bool input_text(char const *Label, std::string &Value) +{ + std::array buffer{}; + Value.copy(buffer.data(), buffer.size() - 1); + if (false == ImGui::InputText(Label, buffer.data(), buffer.size())) + { + return false; + } + Value = buffer.data(); + return true; +} + +ImVec4 const status_errorcolor{1.0f, 0.45f, 0.4f, 1.0f}; + +} // namespace + +void layers_panel::report(std::string const &Status, bool const Error) +{ + status = Status; + status_error = Error; +} + +void layers_panel::render() +{ + if (false == is_open) + { + return; + } + + // initially next to the toolset window + ImGui::SetNextWindowPos(ImVec2S(520, 60), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2S(440, 340), ImGuiCond_FirstUseEver); + auto const panelname{(title.empty() ? m_name : title) + "###" + m_name}; + if (ImGui::Begin(panelname.c_str(), &is_open, ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoCollapse)) + { + if (scene::Layers.empty()) + { + // layers are established only when the scenery is loaded for editing, regular load skips the bookkeeping + ImGui::TextDisabled("Scenery layers are available in an edit session.\nStart the simulator with: -edit "); + } + else + { + if (false == scene::Layers.listed(m_selected)) + { + m_selected = null_handle; + } + auto const selected{m_selected != null_handle}; + auto const removed{selected && scene::Layers.layer(m_selected).removed}; + std::string reason{"Select a layer first"}; + + if (ImGui::Button("New...")) + { + m_newname[0] = '\0'; + m_newparent = (selected && false == removed && false == scene::Layers.layer(m_selected).binary ? m_selected : scene::layer_handle{1}); + m_popuperror.clear(); + ImGui::OpenPopup("New layer"); + } + ImGui::SameLine(); + if (removed) + { + if (ImGui::Button("Restore")) + { + scene::Layers.restore(m_selected); + report("Layer \"" + layer_name(m_selected) + "\" restored"); + } + } + else if (action_button("Remove...", selected && scene::Layers.can_remove(m_selected, reason), reason)) + { + ImGui::OpenPopup("Remove layer"); + } + ImGui::SameLine(); + // the scenario file is what holds the scenery together, it stays where it is + auto const mergeable{selected && false == removed && (scene::Layers.layer(m_selected).created || false == scene::Layers.layer(m_selected).sites.empty())}; + if (action_button("Merge into...", mergeable, selected && false == removed ? "The scenario file can't be merged into another layer" : "Select a layer first")) + { + // the layer which includes the selected one is the most likely target + m_mergetarget = scene::Layers.resolve(scene::Layers.layer(m_selected).parent); + if (false == scene::Layers.can_merge(m_selected, m_mergetarget, reason)) + { + m_mergetarget = null_handle; + } + ImGui::OpenPopup("Merge layer"); + } + ImGui::SameLine(); + if (ImGui::Button("Save") && save) + { + save(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Writes changed nodes and layers to the scenery files (Ctrl+S)"); + } + ImGui::Separator(); + + render_list(); + + if (false == status.empty()) + { + ImGui::PushTextWrapPos(0.0f); + if (status_error) + { + ImGui::TextColored(status_errorcolor, "%s", status.c_str()); + } + else + { + ImGui::TextUnformatted(status.c_str()); + } + ImGui::PopTextWrapPos(); + } + + render_popups(); + } + } + ImGui::End(); +} + +void layers_panel::render_list() +{ + // the list takes the window except for the room left for the status + ImGui::BeginChild("##layers", ImVec2(0.0f, -ImGui::GetTextLineHeightWithSpacing() * 2.5f)); + ImGui::TextDisabled("visible, locked, active, layer file"); + + for (auto const handle : scene::Layers.tree()) + { + // NOTE: copies of the flags, as the calls below can change the layer + auto const &layer{scene::Layers.layer(handle)}; + auto const isactive{handle == scene::Layers.active()}; + auto const isremoved{layer.removed}; + std::string readonly; + auto const iswritable{scene::Layers.writable(handle, &readonly)}; + + ImGui::PushID(static_cast(handle)); + if (isremoved) + { + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * 0.5f); + } + // active layer receives new nodes, so it can't be hidden nor locked. removed layer is out of the picture altogether + auto const fixed{isactive || isremoved}; + auto visible{layer.visible}; + if (ImGui::Checkbox("##visible", &visible) && false == fixed) + { + scene::Layers.visible(handle, visible); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", isactive ? "Active layer is always visible" : "Show or hide models, tracks and traction of the layer"); + } + ImGui::SameLine(); + auto locked{layer.locked}; + if (ImGui::Checkbox("##locked", &locked) && false == fixed) + { + scene::Layers.locked(handle, locked); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", isactive ? "Active layer can't be locked" : "Nodes of locked layer can't be selected in the viewport"); + } + ImGui::SameLine(); + if (ImGui::RadioButton("##active", isactive) && false == isactive) + { + if (isremoved || false == iswritable) + { + report("Layer \"" + layer_name(handle) + "\" can't take new nodes" + (isremoved ? "" : ": " + readonly), true); + } + else + { + scene::Layers.active(handle); + } + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Active layer: nodes created in the editor are placed in it"); + } + ImGui::SameLine(); + auto label{std::string(2 * scene::Layers.depth(handle), ' ') + layer_name(handle) + " (" + (layer.binary ? "binary terrain" : std::to_string(layer.item_count())) + ")"}; + if (layer.created) + { + label += " [new]"; + } + if (isremoved) + { + label += " [removed]"; + } + else if (false == iswritable) + { + label += " [read-only]"; + } + if (ImGui::Selectable(label.c_str(), handle == m_selected)) + { + m_selected = handle; + } + if (ImGui::IsItemHovered()) + { + std::string content{layer.created ? "New layer, its file is created on save" : isremoved ? "Dropped from the scenery on save" : "Scenery file"}; + if (scene::Layers.valid(layer.parent)) + { + content += "\nincluded by: " + layer_name(scene::Layers.resolve(layer.parent)); + } + if (false == iswritable) + { + content += "\nread-only: " + readonly; + } + if (auto const items{layer_content(layer)}; false == items.empty()) + { + content += "\n" + items; + } + ImGui::SetTooltip("%s", content.c_str()); + } + if (isremoved) + { + ImGui::PopStyleVar(); + } + ImGui::PopID(); + } + ImGui::EndChild(); +} + +void layers_panel::render_popups() +{ + if (ImGui::BeginPopupModal("New layer", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::TextUnformatted("File of the new layer, in the scenery directory:"); + ImGui::InputTextWithHint("##name", "name.scm", m_newname, IM_ARRAYSIZE(m_newname)); + if (ImGui::BeginCombo("Included by", layer_name(m_newparent).c_str())) + { + for (std::size_t idx = 1; idx <= scene::Layers.size(); ++idx) + { + auto const handle{static_cast(idx)}; + if (false == scene::Layers.listed(handle) || scene::Layers.layer(handle).removed || scene::Layers.layer(handle).binary) + { + continue; + } + if (ImGui::Selectable((layer_name(handle) + "##" + std::to_string(idx)).c_str(), handle == m_newparent)) + { + m_newparent = handle; + } + } + ImGui::EndCombo(); + } + if (false == m_popuperror.empty()) + { + ImGui::TextColored(status_errorcolor, "%s", m_popuperror.c_str()); + } + if (ImGui::Button("Create")) + { + auto const created{scene::Layers.create(m_newname, m_newparent, m_popuperror)}; + if (created != null_handle) + { + m_selected = created; + report("Layer \"" + layer_name(created) + "\" created and made active. Its file is written on save."); + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + if (ImGui::BeginPopupModal("Remove layer", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + if (false == scene::Layers.listed(m_selected)) + { + ImGui::CloseCurrentPopup(); + } + else + { + auto const &layer{scene::Layers.layer(m_selected)}; + auto included{0}; + for (std::size_t idx = 1; idx <= scene::Layers.size(); ++idx) + { + auto const handle{static_cast(idx)}; + if (handle != m_selected && scene::Layers.listed(handle) && scene::Layers.is_descendant(handle, m_selected)) + { + ++included; + } + } + ImGui::Text("Remove layer \"%s\" from the scenery?", layer_name(m_selected).c_str()); + ImGui::TextUnformatted(layer.created ? "The layer wasn't saved yet, so it leaves no file behind." : "Its include directive is erased on save. The file itself stays on the disk."); + if (included > 0) + { + ImGui::Text("%d layer(s) it includes are removed with it.", included); + } + if (auto const items{layer_content(layer)}; false == items.empty()) + { + ImGui::Separator(); + ImGui::TextUnformatted("The layer holds:"); + ImGui::TextUnformatted(items.c_str()); + ImGui::TextDisabled("Make sure the rest of the scenery doesn't refer to its tracks, events or memory cells."); + } + if (ImGui::Button("Remove")) + { + auto const name{layer_name(m_selected)}; + if (scene::Layers.remove(m_selected)) + { + report("Layer \"" + name + "\" removed. It can be restored until the scenery is saved."); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + { + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + + if (ImGui::BeginPopupModal("Merge layer", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + if (false == scene::Layers.listed(m_selected)) + { + ImGui::CloseCurrentPopup(); + } + else + { + ImGui::Text("Move content of layer \"%s\" to:", layer_name(m_selected).c_str()); + std::string reason; + auto targets{0}; + if (ImGui::BeginCombo("##target", layer_name(m_mergetarget).c_str())) + { + for (std::size_t idx = 1; idx <= scene::Layers.size(); ++idx) + { + auto const handle{static_cast(idx)}; + if (false == scene::Layers.listed(handle) || false == scene::Layers.can_merge(m_selected, handle, reason)) + { + continue; + } + ++targets; + if (ImGui::Selectable((layer_name(handle) + "##" + std::to_string(idx)).c_str(), handle == m_mergetarget)) + { + m_mergetarget = handle; + } + } + if (targets == 0) + { + ImGui::TextDisabled("no layer can take it"); + } + ImGui::EndCombo(); + } + ImGui::TextDisabled("The text of the file is moved on save, the emptied file stays on the disk.\n" + "Merged into the layer which includes it, the content stays where the include was.\n" + "Merged into any other layer it's placed at its end, which changes the load order."); + auto const possible{m_mergetarget != null_handle && scene::Layers.can_merge(m_selected, m_mergetarget, reason)}; + if (action_button("Merge", possible, m_mergetarget != null_handle ? reason : "Pick the target layer")) + { + auto const name{layer_name(m_selected)}; + auto const target{m_mergetarget}; + if (scene::Layers.merge(m_selected, target)) + { + report("Layer \"" + name + "\" merged into \"" + layer_name(target) + "\". The files are changed on save."); + m_selected = target; + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + { + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } +} + +void includes_panel::open(std::string const &File) +{ + m_file = File; + m_statuserror = false; + std::string error; + m_loaded = editor_includes::load(File, m_info, error); + if (false == m_loaded) + { + m_status = error; + m_statuserror = true; + return; + } + // every parameter the template uses gets an entry, described or not + auto const count{editor_includes::parameter_count(File)}; + for (auto id = 1; id <= count; ++id) + { + m_info.parameter(id); + } + m_status = "The template uses " + std::to_string(count) + " parameter(s)"; +} + +void includes_panel::render() +{ + if (false == is_open) + { + return; + } + + ImGui::SetNextWindowPos(ImVec2S(560, 110), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2S(520, 440), ImGuiCond_FirstUseEver); + auto const panelname{(title.empty() ? m_name : title) + "###" + m_name}; + if (ImGui::Begin(panelname.c_str(), &is_open, ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoCollapse)) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + ImGui::TextWrapped("Describes .inc templates and their parameters. The description is stored in the template file, as //$e comment lines."); + ImGui::PopStyleColor(); + // templates the loaded scenery makes use of; known only for scenery opened for editing + auto const &templates{scene::Layers.templates()}; + if (false == templates.empty()) + { + ImGui::BeginChild("##templates", ImVec2(0.0f, ImGui::GetTextLineHeightWithSpacing() * 6.0f), true); + for (auto const &file : templates) + { + if (ImGui::Selectable(Bezogonkow(file).c_str(), file == m_file)) + { + open(file); + } + } + ImGui::EndChild(); + } + ImGui::PushItemWidth(-ImGui::CalcTextSize(" Open ").x - ImGui::GetStyle().ItemSpacing.x * 2.0f); + auto const entered{ImGui::InputTextWithHint("##file", "path of an .inc file in the scenery directory", m_filename, IM_ARRAYSIZE(m_filename), ImGuiInputTextFlags_EnterReturnsTrue)}; + ImGui::PopItemWidth(); + ImGui::SameLine(); + if ((ImGui::Button("Open") || entered) && m_filename[0] != '\0') + { + std::string file{ToLower(m_filename)}; + replace_slashes(file); + open(file); + } + + if (m_loaded) + { + ImGui::Separator(); + ImGui::Text("%s", Bezogonkow(m_file).c_str()); + input_text("Name", m_info.name); + input_text("Category", m_info.category); + input_text("Description", m_info.description); + + ImGui::Columns(4, "##parameters", false); + ImGui::SetColumnWidth(0, ImGui::CalcTextSize("(p000) ").x); + ImGui::SetColumnWidth(1, ImGui::CalcTextSize("texture ").x + ImGui::GetFrameHeight() * 2.0f); + ImGui::TextDisabled("param"); + ImGui::NextColumn(); + ImGui::TextDisabled("role"); + ImGui::NextColumn(); + ImGui::TextDisabled("label"); + ImGui::NextColumn(); + ImGui::TextDisabled("default"); + ImGui::NextColumn(); + for (auto ¶meter : m_info.parameters) + { + ImGui::PushID(parameter.id); + ImGui::AlignTextToFramePadding(); + ImGui::Text("(p%d)", parameter.id); + ImGui::NextColumn(); + ImGui::PushItemWidth(-1.0f); + if (ImGui::BeginCombo("##role", parameter.role.c_str())) + { + for (auto const &role : editor_includes::roles) + { + if (ImGui::Selectable(role.c_str(), role == parameter.role)) + { + parameter.role = role; + } + } + ImGui::EndCombo(); + } + ImGui::PopItemWidth(); + ImGui::NextColumn(); + ImGui::PushItemWidth(-1.0f); + input_text("##label", parameter.label); + ImGui::PopItemWidth(); + ImGui::NextColumn(); + ImGui::PushItemWidth(-1.0f); + input_text("##default", parameter.value); + ImGui::PopItemWidth(); + ImGui::NextColumn(); + ImGui::PopID(); + } + ImGui::Columns(1); + + if (ImGui::Button("Suggest roles")) + { + editor_includes::suggest(m_file, m_info); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Sets roles of the parameters still marked as free, where the way the template uses them tells what they are"); + } + ImGui::SameLine(); + if (ImGui::Button("Add parameter")) + { + m_info.parameter(m_info.parameters.empty() ? 1 : m_info.parameters.back().id + 1); + } + ImGui::SameLine(); + if (ImGui::Button("Save to file")) + { + std::string error; + m_statuserror = false == editor_includes::save(m_file, m_info, error); + m_status = m_statuserror ? error : "Description saved in \"" + m_file + "\""; + } + ImGui::SameLine(); + if (ImGui::Button("Reload")) + { + open(m_file); + } + } + if (false == m_status.empty()) + { + ImGui::PushTextWrapPos(0.0f); + if (m_statuserror) + { + ImGui::TextColored(status_errorcolor, "%s", Bezogonkow(m_status).c_str()); + } + else + { + ImGui::TextUnformatted(Bezogonkow(m_status).c_str()); + } + ImGui::PopTextWrapPos(); + } + } + ImGui::End(); +} diff --git a/application/editoruipanels.h b/application/editoruipanels.h index 55a0ca1a..53cfe938 100644 --- a/application/editoruipanels.h +++ b/application/editoruipanels.h @@ -11,6 +11,7 @@ http://mozilla.org/MPL/2.0/. #include "application/uilayer.h" #include "utilities/Classes.h" +#include "editor/editorIncludeInfo.hpp" #include /* @@ -148,6 +149,56 @@ class nodebank_panel : public ui_panel model_set_ref m_setsgroup{model_set_ref::source::nodebank, 0}; // node bank group to append to the edited set }; +// scenery layers of a scenery opened for editing: their visibility, selection lock, the layer receiving new nodes, +// and layer management. the changes are applied to the scenery files on save +class layers_panel : public ui_panel +{ + + public: + layers_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) {} + + void render() override; + + // saves the scenery; provided by the editor mode + std::function save; + // outcome of the most recent operation, shown at the bottom of the window + std::string status; + bool status_error{false}; + + private: + // methods + void render_list(); + void render_popups(); + void report(std::string const &Status, bool const Error = false); + // members + scene::layer_handle m_selected{null_handle}; // layer the buttons act on + char m_newname[128]{}; // file name of the layer being created + scene::layer_handle m_newparent{null_handle}; // layer which is going to include it + scene::layer_handle m_mergetarget{null_handle}; + std::string m_popuperror; +}; + +// editor of the descriptions of *.inc scenery templates, see editorIncludeInfo.hpp +class includes_panel : public ui_panel +{ + + public: + includes_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) {} + + void render() override; + + private: + // methods + void open(std::string const &File); + // members + std::string m_file; // template being edited, relative to the scenery directory + char m_filename[256]{}; // name of a template typed in by hand + include_info m_info; + bool m_loaded{false}; + std::string m_status; + bool m_statuserror{false}; +}; + class functions_panel : public ui_panel { diff --git a/editor/editorIncludeInfo.cpp b/editor/editorIncludeInfo.cpp new file mode 100644 index 00000000..f9f09920 --- /dev/null +++ b/editor/editorIncludeInfo.cpp @@ -0,0 +1,509 @@ +/* +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 "editor/editorIncludeInfo.hpp" + +#include "utilities/Globals.h" +#include "utilities/utilities.h" + +#include + +#include +#include +#include + +namespace +{ + +std::string const linemark{"//$e"}; + +// template file split into the description and the rest +struct template_text +{ + std::string content; // whole file + std::string yaml; // description, with the line marks taken off; utf-8 + std::vector> lines; // ranges of the content taken by the description lines, line ends included + std::string eol{"\r\n"}; + std::size_t start{0}; // location of the first character past the byte order mark + bool utf8{false}; // the file is utf-8 encoded, rather than windows-1250 like most of the scenery files +}; + +// true if specified text is valid utf-8. HasExtended: set if the text has any characters outside of ascii +bool is_utf8(std::string const &Text, bool &HasExtended) +{ + HasExtended = false; + for (std::size_t idx = 0; idx < Text.size();) + { + auto const lead{static_cast(Text[idx])}; + if (lead < 0x80) + { + ++idx; + continue; + } + HasExtended = true; + auto const length{(lead & 0xE0) == 0xC0 ? 2 : (lead & 0xF0) == 0xE0 ? 3 : (lead & 0xF8) == 0xF0 ? 4 : 0}; + if (length == 0 || idx + length > Text.size()) + { + return false; + } + for (auto offset = 1; offset < length; ++offset) + { + if ((static_cast(Text[idx + offset]) & 0xC0) != 0x80) + { + return false; + } + } + idx += length; + } + return true; +} + +// counterpart of win1250_to_utf8(). characters outside of the supported set are replaced with question marks +std::string utf8_to_win1250(std::string const &Text) +{ + // polish letters, as utf-8 sequences; the same set win1250_to_utf8() handles + static std::unordered_map const charmap{{"\xC4\x84", '\xA5'}, {"\xC4\x86", '\xC6'}, {"\xC4\x98", '\xCA'}, {"\xC5\x81", '\xA3'}, {"\xC5\x83", '\xD1'}, {"\xC3\x93", '\xD3'}, + {"\xC5\x9A", '\x8C'}, {"\xC5\xB9", '\x8F'}, {"\xC5\xBB", '\xAF'}, {"\xC4\x85", '\xB9'}, {"\xC4\x87", '\xE6'}, {"\xC4\x99", '\xEA'}, + {"\xC5\x82", '\xB3'}, {"\xC5\x84", '\xF1'}, {"\xC3\xB3", '\xF3'}, {"\xC5\x9B", '\x9C'}, {"\xC5\xBA", '\x9F'}, {"\xC5\xBC", '\xBF'}}; + std::string output; + for (std::size_t idx = 0; idx < Text.size();) + { + auto const lead{static_cast(Text[idx])}; + if (lead < 0x80) + { + output += Text[idx++]; + continue; + } + auto const length{std::min((lead & 0xE0) == 0xC0 ? 2 : (lead & 0xF0) == 0xE0 ? 3 : (lead & 0xF8) == 0xF0 ? 4 : 1, Text.size() - idx)}; + auto const lookup{charmap.find(Text.substr(idx, length))}; + output += (lookup != charmap.end() ? lookup->second : '?'); + idx += length; + } + return output; +} + +bool read_template(std::string const &File, template_text &Text, std::string &Error) +{ + std::ifstream input{Global.asCurrentSceneryPath + File, std::ios_base::binary}; + if (false == input.is_open()) + { + Error = "can't open file \"" + File + "\""; + return false; + } + auto &content{Text.content}; + content.assign(std::istreambuf_iterator(input), std::istreambuf_iterator()); + + auto const bom{content.compare(0, 3, "\xEF\xBB\xBF") == 0}; + Text.start = bom ? 3 : 0; + bool extended; + Text.utf8 = bom || (is_utf8(content, extended) && extended); + if (auto const linebreak{content.find('\n')}; linebreak != std::string::npos) + { + Text.eol = (linebreak > 0 && content[linebreak - 1] == '\r') ? "\r\n" : "\n"; + } + + for (auto linebegin{Text.start}; linebegin < content.size();) + { + auto const linebreak{content.find('\n', linebegin)}; + auto const next{linebreak == std::string::npos ? content.size() : linebreak + 1}; + if (content.compare(linebegin, linemark.size(), linemark) == 0) + { + auto line{content.substr(linebegin + linemark.size(), next - linebegin - linemark.size())}; + line.erase(line.find_last_not_of("\r\n") + 1); + // a single space separates the mark from the document; the rest is indentation, which carries meaning + if (false == line.empty() && line.front() == ' ') + { + line.erase(0, 1); + } + Text.yaml += line + '\n'; + Text.lines.emplace_back(linebegin, next); + } + linebegin = next; + } + if (false == is_utf8(Text.yaml, extended)) + { + Text.yaml = win1250_to_utf8(Text.yaml); + } + return true; +} + +// number of the parameter if specified text holds (pN) placeholder at specified location, 0 otherwise. Length: size of the placeholder +int placeholder(std::string const &Text, std::size_t const Location, std::size_t &Length) +{ + if (Text.compare(Location, 2, "(p") != 0) + { + return 0; + } + auto idx{Location + 2}; + auto id{0}; + while (idx < Text.size() && std::isdigit(static_cast(Text[idx])) && id < 1000) + { + id = id * 10 + (Text[idx] - '0'); + ++idx; + } + if (idx == Location + 2 || idx >= Text.size() || Text[idx] != ')') + { + return 0; + } + Length = idx + 1 - Location; + return id; +} + +// number of the parameter if the token is nothing but its placeholder, 0 otherwise +int exact_placeholder(std::string const &Token) +{ + std::size_t length{0}; + auto const id{placeholder(Token, 0, length)}; + return length == Token.size() ? id : 0; +} + +// number of the first parameter whose placeholder is a part of the token, 0 if there's none +int first_placeholder(std::string const &Token) +{ + std::size_t length{0}; + for (auto location{Token.find("(p")}; location != std::string::npos; location = Token.find("(p", location + 1)) + { + if (auto const id{placeholder(Token, location, length)}; id != 0) + { + return id; + } + } + return 0; +} + +// tokens of scenery text, in lower case and with the comments left out +std::vector tokenize(std::string const &Text) +{ + std::vector tokens; + std::string token; + auto const flush = [&]() { + if (false == token.empty()) + { + tokens.emplace_back(ToLower(token)); + token.clear(); + } + }; + for (std::size_t idx = 0; idx < Text.size(); ++idx) + { + if (Text.compare(idx, 2, "//") == 0) + { + flush(); + idx = Text.find('\n', idx); + if (idx == std::string::npos) + { + break; + } + } + else if (Text.compare(idx, 2, "/*") == 0) + { + flush(); + idx = Text.find("*/", idx + 2); + if (idx == std::string::npos) + { + break; + } + ++idx; + } + else if (Text[idx] == ' ' || Text[idx] == '\t' || Text[idx] == '\n' || Text[idx] == '\r' || Text[idx] == ';') + { + flush(); + } + else + { + token += Text[idx]; + } + } + flush(); + return tokens; +} + +} // namespace + +include_parameter &include_info::parameter(int const Id) +{ + auto lookup{std::find_if(std::begin(parameters), std::end(parameters), [=](include_parameter const &Parameter) { return Parameter.id >= Id; })}; + if (lookup == std::end(parameters) || lookup->id != Id) + { + lookup = parameters.emplace(lookup); + lookup->id = Id; + } + return *lookup; +} + +namespace editor_includes +{ + +std::vector const roles{"free", "name", "pos.x", "pos.y", "pos.z", "rot.x", "rot.y", "rot.z", "track", "memcell", "event", "model", "texture", "number", "text"}; + +bool load(std::string const &File, include_info &Info, std::string &Error) +{ + Info = include_info(); + template_text text; + if (false == read_template(File, text, Error)) + { + return false; + } + try + { + auto const document{YAML::Load(text.yaml)}; + if (document.IsNull()) + { + return true; + } + if (false == document.IsMap()) + { + Error = "the description isn't a list of named values"; + return false; + } + Info.name = document["name"].as(""); + Info.category = document["category"].as(""); + Info.description = document["description"].as(""); + if (auto const parameters{document["params"]}; parameters.IsDefined() && parameters.IsSequence()) + { + for (auto const &entry : parameters) + { + auto const id{entry.IsMap() ? entry["id"].as(0) : 0}; + if (id <= 0) + { + continue; + } + auto ¶meter{Info.parameter(id)}; + parameter.role = entry["role"].as("free"); + parameter.label = entry["label"].as(""); + parameter.value = entry["default"].as(""); + } + } + } + catch (YAML::Exception const &exception) + { + Error = std::string{"the description is malformed: "} + exception.what(); + return false; + } + return true; +} + +bool save(std::string const &File, include_info const &Info, std::string &Error) +{ + template_text text; + if (false == read_template(File, text, Error)) + { + return false; + } + std::string yaml; + try + { + // start with the existing description, to keep the entries we don't know + auto document{YAML::Load(text.yaml)}; + if (false == document.IsNull() && false == document.IsMap()) + { + Error = "the existing description isn't a list of named values"; + return false; + } + if (false == document["version"].IsDefined()) + { + document["version"] = 1; + } + auto const set = [&](char const *Key, std::string const &Value) { + if (false == Value.empty()) + { + document[Key] = Value; + } + else + { + document.remove(Key); + } + }; + set("name", Info.name); + set("category", Info.category); + set("description", Info.description); + + YAML::Node parameters{YAML::NodeType::Sequence}; + for (auto const ¶meter : Info.parameters) + { + YAML::Node entry{YAML::NodeType::Map}; + if (auto const existing{document["params"]}; existing.IsDefined() && existing.IsSequence()) + { + for (auto const &candidate : existing) + { + if (candidate.IsMap() && candidate["id"].as(0) == parameter.id) + { + entry = YAML::Clone(candidate); + break; + } + } + } + entry["id"] = parameter.id; + entry["role"] = parameter.role.empty() ? std::string{"free"} : parameter.role; + if (false == parameter.label.empty()) + { + entry["label"] = parameter.label; + } + else + { + entry.remove("label"); + } + if (false == parameter.value.empty()) + { + entry["default"] = parameter.value; + } + else + { + entry.remove("default"); + } + entry.SetStyle(YAML::EmitterStyle::Flow); + parameters.push_back(entry); + } + if (parameters.size() > 0) + { + document["params"] = parameters; + } + else + { + document.remove("params"); + } + + YAML::Emitter emitter; + emitter << document; + yaml = emitter.c_str(); + } + catch (YAML::Exception const &exception) + { + Error = std::string{"the existing description is malformed, fix or remove it first: "} + exception.what(); + return false; + } + if (false == text.utf8) + { + yaml = utf8_to_win1250(yaml); + } + + std::string block; + std::istringstream lines{yaml}; + for (std::string line; std::getline(lines, line);) + { + block += linemark + (line.empty() ? "" : " " + line) + text.eol; + } + // the description replaces the old one, or goes to the top of the file if there was none + auto content{text.content}; + auto location{text.lines.empty() ? text.start : text.lines.front().first}; + for (auto line{text.lines.rbegin()}; line != text.lines.rend(); ++line) + { + content.erase(line->first, line->second - line->first); + } + content.insert(location, block); + + auto const filepath{Global.asCurrentSceneryPath + File}; + std::ofstream output{filepath + ".tmp", std::ios_base::binary | std::ios_base::trunc}; + output << content; + output.close(); + std::error_code error; + if (output.fail()) + { + std::filesystem::remove(filepath + ".tmp", error); + Error = "can't write file \"" + File + "\""; + return false; + } + // the first save of a file leaves its original content next to it + if (false == std::filesystem::exists(filepath + ".bak", error)) + { + std::filesystem::copy_file(filepath, filepath + ".bak", error); + } + std::filesystem::rename(filepath + ".tmp", filepath, error); + if (error) + { + Error = "can't replace file \"" + File + "\": " + error.message(); + return false; + } + return true; +} + +int parameter_count(std::string const &File) +{ + template_text text; + std::string error; + if (false == read_template(File, text, error)) + { + return 0; + } + // the description lines may mention parameters the template doesn't use + auto content{text.content}; + for (auto line{text.lines.rbegin()}; line != text.lines.rend(); ++line) + { + content.erase(line->first, line->second - line->first); + } + auto count{0}; + std::size_t length{0}; + for (auto location{content.find("(p")}; location != std::string::npos; location = content.find("(p", location + 1)) + { + count = std::max(count, placeholder(content, location, length)); + } + return count; +} + +void suggest(std::string const &File, include_info &Info) +{ + template_text text; + std::string error; + if (false == read_template(File, text, error)) + { + return; + } + auto const tokens{tokenize(text.content)}; + auto const assign = [&](std::size_t const Index, char const *Role, bool const Exact = true) { + if (Index >= tokens.size()) + { + return; + } + auto const id{Exact ? exact_placeholder(tokens[Index]) : first_placeholder(tokens[Index])}; + if (id != 0 && Info.parameter(id).role == "free") + { + Info.parameter(id).role = Role; + } + }; + for (std::size_t idx = 0; idx < tokens.size(); ++idx) + { + auto const &token{tokens[idx]}; + if (token == "origin") + { + assign(idx + 1, "pos.x"); + assign(idx + 2, "pos.y"); + assign(idx + 3, "pos.z"); + } + else if (token == "rotate") + { + assign(idx + 1, "rot.x"); + assign(idx + 2, "rot.y"); + assign(idx + 3, "rot.z"); + } + else if (token == "node" && idx + 4 < tokens.size()) + { + // node ... + assign(idx + 3, "name", false); + auto const &type{tokens[idx + 4]}; + if (type == "model" || type == "memcell" || type == "eventlauncher" || type == "sound") + { + assign(idx + 5, "pos.x"); + assign(idx + 6, "pos.y"); + assign(idx + 7, "pos.z"); + } + if (type == "model") + { + assign(idx + 8, "rot.y"); + assign(idx + 9, "model"); + assign(idx + 10, "texture"); + } + if (type == "memcell") + { + assign(idx + 11, "track"); + } + } + } +} + +} // namespace editor_includes diff --git a/editor/editorIncludeInfo.hpp b/editor/editorIncludeInfo.hpp new file mode 100644 index 00000000..9aa47af2 --- /dev/null +++ b/editor/editorIncludeInfo.hpp @@ -0,0 +1,59 @@ +/* +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/. +*/ + +#pragma once + +#include +#include + +// description of an *.inc scenery template: what the template is, and what its (pN) parameters mean. +// it's kept in the template file itself, as a YAML document made of the comment lines starting with "//$e", e.g. +// //$e version: 1 +// //$e name: Semaphore, 5 lights +// //$e params: +// //$e - {id: 1, role: name, label: Name} +// being a comment it's invisible to the scenery parser and to the older tools +struct include_parameter +{ + int id{0}; // number of the (pN) placeholder + std::string role{"free"}; // what the value is; one of editor_includes::roles + std::string label; // name to show to the user + std::string value; // default value +}; + +struct include_info +{ + std::string name; + std::string category; + std::string description; + std::vector parameters; + + // description of specified parameter, created if there's none + include_parameter ¶meter(int Id); +}; + +namespace editor_includes +{ + +// recognized parameter roles. "free" is a value typed in by the user, with no meaning to the editor; +// the placement roles (pos.*, rot.*) and "name" are the ones the editor can fill in on its own +extern std::vector const roles; + +// reads description of specified template, located in the scenery directory. a template without the description +// yields an empty one. returns: false if the file can't be read or its description isn't a valid YAML document +bool load(std::string const &File, include_info &Info, std::string &Error); +// stores provided description in specified template. entries of the existing description which aren't +// a part of include_info are preserved. returns: false if the file can't be updated +bool save(std::string const &File, include_info const &Info, std::string &Error); +// highest number of (pN) placeholder used by specified template, 0 if it has none +int parameter_count(std::string const &File); +// sets roles of the parameters whose use in the template makes their meaning clear. roles already set are left alone +void suggest(std::string const &File, include_info &Info); + +} // namespace editor_includes diff --git a/scene/scenelayers.cpp b/scene/scenelayers.cpp index 3acb0154..abbd56d6 100644 --- a/scene/scenelayers.cpp +++ b/scene/scenelayers.cpp @@ -14,22 +14,44 @@ http://mozilla.org/MPL/2.0/. #include "model/AnimModel.h" #include "world/Track.h" #include "world/Traction.h" +#include "utilities/Globals.h" +#include "utilities/utilities.h" + +#include namespace scene { node_layers Layers; +// converts world space location to the one which produces it when loaded with this context in effect +glm::dvec3 layer_context::to_local(glm::dvec3 Location) const +{ + // reverse of the transformation done by the scenery loader: rotation around the vertical axis, then scale, then offset + Location -= offset; + Location /= glm::dvec3{scale}; + if (rotation != glm::vec3{0.f}) + { + Location = glm::rotateY(Location, -glm::radians(static_cast(rotation.y))); + } + return Location; +} + +bool layer_context::matches(layer_context const &Other) const +{ + return glm::all(glm::epsilonEqual(offset, Other.offset, 1e-4)) && glm::all(glm::epsilonEqual(rotation, Other.rotation, 1e-4f)) && glm::all(glm::epsilonEqual(scale, Other.scale, 1e-5f)); +} + std::size_t basic_layer::item_count() const { return std::accumulate(std::begin(items), std::end(items), std::size_t{0}); } -// indicates start of content of specified scenery file. returns: handle to the layer of the file +// indicates start of content of the scenario file. returns: handle to its layer layer_handle node_layers::open(std::string const &Name) { // the same file can be included more than once, all its content goes to a single layer then - auto const lookup{std::find_if(std::begin(m_layers), std::end(m_layers), [&](basic_layer const &Layer) { return Layer.name == Name; })}; + auto const lookup{std::find_if(std::begin(m_layers), std::end(m_layers), [&](basic_layer const &Layer) { return Layer.name == Name && false == Layer.dead; })}; auto layerhandle{static_cast(std::distance(std::begin(m_layers), lookup) + 1)}; if (lookup == std::end(m_layers)) { @@ -38,6 +60,7 @@ layer_handle node_layers::open(std::string const &Name) m_layers.emplace_back(); m_layers.back().name = Name; m_layers.back().parent = handle(); + stat(layerhandle); } else { @@ -45,18 +68,39 @@ layer_handle node_layers::open(std::string const &Name) layerhandle = handle(); } } + if (valid(layerhandle) && layerhandle != handle()) + { + layer(layerhandle).context_begin = m_context; + } // NOTE: the stack receives an entry even if the layer wasn't created, to keep it in sync with close() calls m_stack.emplace_back(layerhandle); return layerhandle; } +// indicates start of content of specified scenery file, included by specified directive. returns: handle to the layer of the file +layer_handle node_layers::open(std::string const &Name, include_site Site) +{ + Site.parent = handle(); + auto const layerhandle{open(Name)}; + if (valid(layerhandle) && layerhandle != Site.parent) + { + layer(layerhandle).sites.emplace_back(Site); + } + return layerhandle; +} + // indicates end of content of the current scenery file. returns: handle to the parent layer, or null_handle if the stack is empty layer_handle node_layers::close() { if (false == m_stack.empty()) { + auto const closing{m_stack.back()}; m_stack.pop_back(); + if (valid(closing) && closing != handle()) + { + layer(closing).context_end = m_context; + } } return handle(); } @@ -66,22 +110,59 @@ void node_layers::clear() m_layers.clear(); m_stack.clear(); m_active = null_handle; + m_context = layer_context(); + m_sources.clear(); + m_erased.clear(); + m_templates.clear(); + m_terraindirective = false; +} + +// follows layer merges. returns: handle of the layer which currently holds content of specified layer +layer_handle node_layers::resolve(layer_handle Layer) const +{ + // NOTE: merge() rules out cycles, the counter is only a safeguard + for (auto hops = 0; hops < 64 && valid(Layer) && layer(Layer).merged != null_handle; ++hops) + { + Layer = layer(Layer).merged; + } + return Layer; } int node_layers::depth(layer_handle Layer) const { auto depth{0}; - // NOTE: parent is always created before its children, which rules out cycles - while (valid(Layer) && layer(Layer).parent != null_handle && layer(Layer).parent < Layer) + Layer = resolve(Layer); + while (valid(Layer) && layer(Layer).parent != null_handle && depth < 64) { - Layer = layer(Layer).parent; + Layer = resolve(layer(Layer).parent); ++depth; } return depth; } -void node_layers::count(layer_handle const Layer, layer_item const Item, int const Change) +std::vector node_layers::tree() const { + std::vector order; + order.reserve(m_layers.size()); + // NOTE: the layers are few, plain scans do + std::function const add = [&](layer_handle const Parent) { + for (std::size_t idx = 0; idx < m_layers.size(); ++idx) + { + auto const candidate{static_cast(idx + 1)}; + if (listed(candidate) && resolve(layer(candidate).parent) == Parent && order.size() < m_layers.size()) + { + order.emplace_back(candidate); + add(candidate); + } + } + }; + add(null_handle); + return order; +} + +void node_layers::count(layer_handle Layer, layer_item const Item, int const Change) +{ + Layer = resolve(Layer); if (false == valid(Layer)) { return; @@ -97,9 +178,10 @@ void node_layers::count(layer_handle const Layer, layer_item const Item, int con } } -void node_layers::active(layer_handle const Layer) +void node_layers::active(layer_handle Layer) { - if (false == valid(Layer)) + Layer = resolve(Layer); + if (false == valid(Layer) || layer(Layer).removed || false == writable(Layer)) { return; } @@ -110,16 +192,17 @@ void node_layers::active(layer_handle const Layer) } // shows or hides nodes of specified layer. visibility defined in the scenery is preserved, hidden layer only overrides it -void node_layers::visible(layer_handle const Layer, bool const Visible) +void node_layers::visible(layer_handle Layer, bool const Visible) { + Layer = resolve(Layer); if (false == valid(Layer) || layer(Layer).visible == Visible) { return; } layer(Layer).visible = Visible; - auto const update_node = [Layer, Visible](basic_node *Node) { - if (Node == nullptr || Node->layer() != Layer) + auto const update_node = [this, Layer, Visible](basic_node *Node) { + if (Node == nullptr || resolve(Node->layer()) != Layer) { return; } @@ -153,19 +236,101 @@ void node_layers::visible(layer_handle const Layer, bool const Visible) } } -void node_layers::locked(layer_handle const Layer, bool const Locked) +void node_layers::locked(layer_handle Layer, bool const Locked) { + Layer = resolve(Layer); if (valid(Layer)) { layer(Layer).locked = Locked; } } -// true if nodes of specified layer can be selected and modified in the editor -bool node_layers::editable(layer_handle const Layer) const +// true if the file of specified layer can be rewritten with changed nodes. optionally explains why it can't +bool node_layers::writable(layer_handle Layer, std::string *Reason) const { + Layer = resolve(Layer); + if (false == valid(Layer)) + { + return false; + } + auto const &target{layer(Layer)}; + char const *reason{nullptr}; + if (target.binary) + { + reason = "the file is loaded from binary terrain cache"; + } + else if (target.sites.size() > 1) + { + // one definition yields several nodes, there's no telling which of them should be written back + reason = "the file is included more than once"; + } + else if (false == target.sites.empty() && target.sites.front().parameters) + { + reason = "the file is included with parameters"; + } + if (reason != nullptr && Reason != nullptr) + { + *Reason = reason; + } + return reason == nullptr; +} + +// true if nodes of specified layer can be selected and modified in the editor +bool node_layers::editable(layer_handle Layer) const +{ + Layer = resolve(Layer); // nodes without a layer (regular load, or created by the simulation) aren't restricted - return false == valid(Layer) || (layer(Layer).visible && false == layer(Layer).locked); + return false == valid(Layer) || (layer(Layer).visible && false == layer(Layer).locked && false == layer(Layer).removed && writable(Layer)); +} + +// true if specified node can be selected and modified in the editor. optionally explains why it can't +bool node_layers::editable(basic_node const *Node, std::string *Reason) const +{ + if (Node == nullptr) + { + return false; + } + auto const nodelayer{resolve(Node->layer())}; + if (false == valid(nodelayer)) + { + return true; + } + std::string reason; + if (Node->m_template) + { + reason = "it's a part of an include template (.inc)"; + } + else if (false == writable(nodelayer, &reason)) + { + // reason was filled by the call + } + else if (layer(nodelayer).locked) + { + reason = "its layer is locked"; + } + else if (false == layer(nodelayer).visible || layer(nodelayer).removed) + { + reason = "its layer is hidden"; + } + if (false == reason.empty() && Reason != nullptr) + { + *Reason = reason; + } + return reason.empty(); +} + +// moves specified node to specified layer +void node_layers::move(basic_node *Node, layer_handle Layer) +{ + Layer = resolve(Layer); + if (Node == nullptr || false == valid(Layer) || resolve(Node->layer()) == Layer) + { + return; + } + // NOTE: the only nodes the editor creates and moves between layers are model instances + count(Node->layer(), layer_item::model, -1); + Node->layer(Layer); + count(Layer, layer_item::model, 1); } std::vector node_layers::hidden() const @@ -185,10 +350,323 @@ void node_layers::show_all() { for (auto const hiddenlayer : hidden()) { - visible(hiddenlayer, true); + // layers dropped from the scenery stay out of sight + if (false == layer(hiddenlayer).removed) + { + visible(hiddenlayer, true); + } } } +// stores origin of a node defined directly in a layer file, to allow rewriting its definition on save +void node_layers::track(basic_node const *Node, source_span const &Span, glm::vec3 const &Angles, glm::vec3 const &Scale) +{ + if (Node == nullptr || Node->m_template || false == valid(Node->layer()) || false == Span.valid()) + { + return; + } + m_sources[Node] = {Node->layer(), Span, m_context, Node->location(), Angles, Scale}; +} + +// indicates specified node was removed from the scene +void node_layers::forget(basic_node const *Node) +{ + auto const lookup{m_sources.find(Node)}; + if (lookup == m_sources.end()) + { + return; + } + m_erased.emplace_back(lookup->second.layer, lookup->second.span); + m_sources.erase(lookup); +} + +bool node_layers::is_descendant(layer_handle Layer, layer_handle const Ancestor) const +{ + // NOTE: content of a merged layer, includes of other files among it, belongs to the layer it was merged into + for (auto hops = 0; hops < 64 && valid(Layer); ++hops) + { + Layer = resolve(layer(Layer).parent); + if (Layer == Ancestor) + { + return true; + } + } + return false; +} + +std::string node_layers::path(layer_handle const Layer) const +{ + return Global.asCurrentSceneryPath + layer(Layer).name; +} + +// stores current state of the layer file, to detect later whether it was changed behind our back +void node_layers::stat(layer_handle const Layer) +{ + std::error_code error; + auto const filepath{std::filesystem::path(path(Layer))}; + auto const filesize{std::filesystem::file_size(filepath, error)}; + layer(Layer).filesize = error ? 0 : filesize; + auto const filetime{std::filesystem::last_write_time(filepath, error)}; + layer(Layer).filetime = error ? std::filesystem::file_time_type{} : filetime; +} + +// creates empty layer, included from specified one. returns: handle to the new layer, or null_handle with the explanation in Reason +layer_handle node_layers::create(std::string Name, layer_handle Parent, std::string &Reason) +{ + Parent = resolve(Parent); + if (false == valid(Parent) || layer(Parent).removed || layer(Parent).binary) + { + Reason = "the parent layer can't include other files"; + return null_handle; + } + // file names in the scenery are processed in lower case, with forward slashes + Name = ToLower(Name); + replace_slashes(Name); + Name.erase(0, Name.find_first_not_of(" \t")); + Name.erase(Name.find_last_not_of(" \t") + 1); + if (Name.empty()) + { + Reason = "the layer needs a file name"; + return null_handle; + } + if (Name.find_first_of(" \t\";:*?<>|") != std::string::npos || Name.front() == '/' || Name.back() == '/' || contains(Name, "..")) + { + Reason = "the file name has to be a plain path inside the scenery directory, without spaces"; + return null_handle; + } + if (std::filesystem::path(Name).extension().empty()) + { + Name += ".scm"; + } + if (Name.ends_with(".inc")) + { + Reason = ".inc files are include templates, not layers"; + return null_handle; + } + if (m_layers.size() >= static_cast(std::numeric_limits::max())) + { + Reason = "too many layers"; + return null_handle; + } + auto const taken{std::any_of(std::begin(m_layers), std::end(m_layers), [&](basic_layer const &Layer) { return Layer.name == Name && false == Layer.dead; })}; + if (taken || FileExists(Global.asCurrentSceneryPath + Name)) + { + Reason = "the file \"" + Name + "\" already exists"; + return null_handle; + } + + // the include directive goes after the last file included by the parent, so the scenery files stay ahead of + // what may follow them in a scenario, like the vehicles + auto anchor{null_handle}; + std::streamoff anchorend{-1}; + for (std::size_t idx = 0; idx < m_layers.size(); ++idx) + { + auto const &sibling{m_layers[idx]}; + if (sibling.dead || sibling.created || sibling.sites.size() != 1) + { + continue; + } + auto const &site{sibling.sites.front()}; + if (site.parent == Parent && false == site.fixed && site.span.end > anchorend) + { + anchor = static_cast(idx + 1); + anchorend = site.span.end; + } + } + + m_layers.emplace_back(); + auto const layerhandle{static_cast(m_layers.size())}; + auto &created{m_layers.back()}; + created.name = Name; + created.parent = Parent; + created.created = true; + created.editormade = true; + created.anchor = anchor; + // placement the new file is going to be loaded with + created.context_begin = valid(anchor) ? layer(anchor).context_end : layer(Parent).context_end; + created.context_end = created.context_begin; + + active(layerhandle); + + return layerhandle; +} + +bool node_layers::can_remove(layer_handle Layer, std::string &Reason) const +{ + if (false == listed(Layer) || layer(Layer).removed) + { + Reason = "no such layer"; + return false; + } + auto const &target{layer(Layer)}; + if (target.created) + { + return true; + } + if (target.sites.empty()) + { + Reason = "the scenario file can't be removed"; + return false; + } + if (target.binary) + { + Reason = "the file is loaded from binary terrain cache"; + return false; + } + if (target.sites.size() > 1) + { + Reason = "the file is included more than once"; + return false; + } + if (target.sites.front().fixed) + { + Reason = "the file is included by an include template (.inc)"; + return false; + } + return true; +} + +// marks specified layer and the layers it includes as dropped from the scenery. their files are left on the disk +bool node_layers::remove(layer_handle Layer) +{ + std::string reason; + if (false == can_remove(Layer, reason)) + { + return false; + } + for (std::size_t idx = 0; idx < m_layers.size(); ++idx) + { + auto const candidate{static_cast(idx + 1)}; + // layers merged elsewhere keep going, their content is no longer a part of the removed file + if (false == listed(candidate) || (candidate != Layer && false == is_descendant(candidate, Layer))) + { + continue; + } + visible(candidate, false); + layer(candidate).removed = true; + if (m_active == candidate) + { + m_active = null_handle; + } + } + if (m_active == null_handle) + { + // fall back on the scenario file, which is always there + active(1); + } + return true; +} + +// takes back remove() which wasn't saved yet +void node_layers::restore(layer_handle Layer) +{ + if (false == valid(Layer) || false == layer(Layer).removed || layer(Layer).dead) + { + return; + } + // a layer can't be brought back without the file which includes it + auto const parent{resolve(layer(Layer).parent)}; + if (valid(parent) && layer(parent).removed) + { + restore(parent); + } + for (std::size_t idx = 0; idx < m_layers.size(); ++idx) + { + auto const candidate{static_cast(idx + 1)}; + if (false == listed(candidate) || (candidate != Layer && false == is_descendant(candidate, Layer))) + { + continue; + } + layer(candidate).removed = false; + visible(candidate, true); + } +} + +bool node_layers::can_merge(layer_handle Source, layer_handle Target, std::string &Reason) const +{ + if (false == listed(Source) || false == listed(Target) || layer(Source).removed || layer(Target).removed) + { + Reason = "no such layer"; + return false; + } + if (Source == Target) + { + Reason = "pick another layer to merge into"; + return false; + } + auto const &source{layer(Source)}; + auto const &target{layer(Target)}; + if (false == writable(Target, &Reason)) + { + return false; + } + if (is_descendant(Target, Source)) + { + // the merged text would include the file it was put in + Reason = "the target layer is included by the merged one"; + return false; + } + if (source.created) + { + // nothing but the nodes made in the editor to move + return true; + } + if (source.sites.empty()) + { + Reason = "the scenario file can't be merged into another layer"; + return false; + } + if (false == writable(Source, &Reason)) + { + return false; + } + if (source.sites.front().fixed) + { + Reason = "the file is included by an include template (.inc)"; + return false; + } + if (resolve(source.sites.front().parent) != Target) + { + // the text is moved to the end of another file. its statements have to keep their meaning there, + // and can't leave anything behind for what used to follow it + if (false == source.context_begin.matches(target.context_end) || false == source.context_end.matches(source.context_begin)) + { + Reason = "the layers are loaded with different origin, rotation or scale"; + return false; + } + } + return true; +} + +// moves content of the source layer to the target layer +bool node_layers::merge(layer_handle Source, layer_handle Target) +{ + std::string reason; + if (false == can_merge(Source, Target, reason)) + { + return false; + } + // start with the same visibility on both sides, as it's kept per node + visible(Source, true); + visible(Target, true); + for (std::size_t idx = 0; idx < layer(Source).items.size(); ++idx) + { + layer(Target).items[idx] += layer(Source).items[idx]; + } + layer(Source).merged = Target; + if (m_active == Source) + { + active(Target); + } + return true; +} + +// true if there are layer changes awaiting save. NOTE: doesn't account for modified nodes +bool node_layers::pending() const +{ + return std::any_of(std::begin(m_layers), std::end(m_layers), [](basic_layer const &Layer) { return false == Layer.dead && (Layer.created || Layer.removed || Layer.merged != null_handle); }); +} + } // namespace scene //--------------------------------------------------------------------------- diff --git a/scene/scenelayers.h b/scene/scenelayers.h index 71df3726..394f607d 100644 --- a/scene/scenelayers.h +++ b/scene/scenelayers.h @@ -11,6 +11,9 @@ http://mozilla.org/MPL/2.0/. #include "scene/scenenode.h" +#include +#include + namespace scene { @@ -31,6 +34,39 @@ enum class layer_item count_ }; +// placement applied to scenery statements by the origin, rotate and scale directives in effect +struct layer_context +{ + glm::dvec3 offset{0.0}; + glm::vec3 rotation{0.f}; + glm::vec3 scale{1.f}; + + // converts world space location to the one which produces it when loaded with this context in effect + glm::dvec3 to_local(glm::dvec3 Location) const; + bool matches(layer_context const &Other) const; +}; + +// location of a piece of text in a scenery file, as byte offsets +struct source_span +{ + std::streamoff begin{-1}; + std::streamoff end{-1}; + + bool valid() const + { + return begin >= 0 && end > begin; + } +}; + +// include directive bringing a scenery file into another one +struct include_site +{ + layer_handle parent{null_handle}; // layer of the file holding the directive + source_span span; // location of the whole directive in that file + bool parameters{false}; // the file is included with parameters + bool fixed{false}; // the directive can't be rewritten: it's a part of an *.inc template, or its location is unknown +}; + // scenery layer: content of a single scenery file (.scn, .scm, .ctr...) other than an .inc node template struct basic_layer { @@ -40,18 +76,51 @@ struct basic_layer bool locked{false}; // items of locked layer can't be selected in the editor bool binary{false}; // the file wasn't parsed, its content came from binary terrain file std::array(layer_item::count_)> items{}; // amount of items of each kind + // source data, used by the scenery save + std::vector sites; // include directives bringing the file in; empty for the scenario file + layer_context context_begin; // placement in effect at the start of the file... + layer_context context_end; // ...and right after its end + std::uintmax_t filesize{0}; // state of the file when it was loaded or saved the last time + std::filesystem::file_time_type filetime{}; + // pending changes, applied by the scenery save + bool created{false}; // layer was created in the editor, its file doesn't exist yet + bool editormade{false}; // the file was made by the editor during this session, there's no original content to keep a backup of + layer_handle anchor{null_handle}; // for created layer: sibling its include directive goes after + bool removed{false}; // layer is to be dropped from the scenery + layer_handle merged{null_handle}; // layer which took over the content of this one + bool dead{false}; // removal or merge of the layer was saved, it's no longer a part of the scenery std::size_t item_count() const; }; +// origin of a node defined in a scenery layer file, with the placement it was loaded with +struct node_source +{ + layer_handle layer{null_handle}; // layer of the file holding the node definition + source_span span; // location of the definition in that file + layer_context context; // placement in effect at the definition + glm::dvec3 location{0.0}; + glm::vec3 angles{0.f}; + glm::vec3 scale{1.f}; +}; + +struct save_result +{ + bool success{false}; + std::vector files; // names of saved files + std::string message; // summary, or cause of the failure +}; + // holds scenery layers, established during load of scenery opened for editing (-edit) // NOTE: regular load doesn't create any layers, all nodes are left with null_handle class node_layers { public: // methods - // indicates start of content of specified scenery file. returns: handle to the layer of the file + // indicates start of content of the scenario file. returns: handle to its layer layer_handle open(std::string const &Name); + // indicates start of content of specified scenery file, included by specified directive. returns: handle to the layer of the file + layer_handle open(std::string const &Name, include_site Site); // indicates end of content of the current scenery file. returns: handle to the parent layer, or null_handle if the stack is empty layer_handle close(); // returns layer of the scenery file being loaded, or null_handle if there's none @@ -82,32 +151,110 @@ class node_layers { return m_layers[Layer - 1]; } + // follows layer merges. returns: handle of the layer which currently holds content of specified layer + layer_handle resolve(layer_handle Layer) const; + // true if the layer is a part of the scenery and should be presented to the user + bool listed(layer_handle const Layer) const + { + return valid(Layer) && false == layer(Layer).dead && layer(Layer).merged == null_handle; + } // number of layers between specified layer and the scenario file int depth(layer_handle Layer) const; + // handles of the layers to present to the user, each followed by the layers it includes + std::vector tree() const; // adjusts item count of specified layer - void count(layer_handle const Layer, layer_item const Item, int const Change = 1); + void count(layer_handle Layer, layer_item const Item, int const Change = 1); // layer receiving the items created in the editor, or null_handle if there's none layer_handle active() const { return m_active; } // NOTE: active layer is made visible and unlocked - void active(layer_handle const Layer); + void active(layer_handle Layer); // shows or hides nodes of specified layer. visibility defined in the scenery is preserved, hidden layer only overrides it - void visible(layer_handle const Layer, bool const Visible); - void locked(layer_handle const Layer, bool const Locked); + void visible(layer_handle Layer, bool const Visible); + void locked(layer_handle Layer, bool const Locked); + // true if the file of specified layer can be rewritten with changed nodes. optionally explains why it can't + bool writable(layer_handle Layer, std::string *Reason = nullptr) const; // true if nodes of specified layer can be selected and modified in the editor - bool editable(layer_handle const Layer) const; + bool editable(layer_handle Layer) const; + // true if specified node can be selected and modified in the editor. optionally explains why it can't + bool editable(basic_node const *Node, std::string *Reason = nullptr) const; + // moves specified node to specified layer + void move(basic_node *Node, layer_handle Layer); // handles of layers which are currently hidden std::vector hidden() const; // restores visibility of all layers void show_all(); + // scenery load bookkeeping + // registers *.inc template used by the scenery + void template_used(std::string const &Name) + { + m_templates.emplace(Name); + } + std::set const &templates() const + { + return m_templates; + } + // updates placement in effect for the statements which follow + void context(layer_context const &Context) + { + m_context = Context; + } + // stores origin of a node defined directly in a layer file, to allow rewriting its definition on save + void track(basic_node const *Node, source_span const &Span, glm::vec3 const &Angles = glm::vec3{0.f}, glm::vec3 const &Scale = glm::vec3{1.f}); + // indicates specified node was removed from the scene + void forget(basic_node const *Node); + // notes the scenery already holds the directive setting up terrain made in the editor + void terrain_directive(bool const Present) + { + m_terraindirective = Present; + } + bool terrain_directive() const + { + return m_terraindirective; + } + + // layer management. changes are applied to the scenery files by save() + // creates empty layer, included from specified one. returns: handle to the new layer, or null_handle with the explanation in Reason + layer_handle create(std::string Name, layer_handle Parent, std::string &Reason); + bool can_remove(layer_handle Layer, std::string &Reason) const; + // marks specified layer and the layers it includes as dropped from the scenery. their files are left on the disk + bool remove(layer_handle Layer); + // takes back remove() which wasn't saved yet + void restore(layer_handle Layer); + bool can_merge(layer_handle Source, layer_handle Target, std::string &Reason) const; + // moves content of the source layer to the target layer + bool merge(layer_handle Source, layer_handle Target); + // true if there are layer changes awaiting save. NOTE: doesn't account for modified nodes + bool pending() const; + // true if specified layer is included, directly or not, by the other one + bool is_descendant(layer_handle Layer, layer_handle const Ancestor) const; + + // writes changes made in the editor to the scenery files: changed, created and deleted model instances and + // memory cells, and layer changes. the files are left intact unless the whole operation can be performed. + // Rootstatements: additional statements to place at the end of the scenario file + save_result save(std::vector const &Rootstatements = {}); + private: + // types + struct file_patch; + struct composition; + struct save_state; + // methods + std::string path(layer_handle const Layer) const; + void stat(layer_handle const Layer); + bool compose(save_state &State, layer_handle const Layer, composition &Output, int const Depth) const; // members std::vector m_layers; // layer handle is index in the vector + 1 std::vector m_stack; // helper, layers of scenery files being loaded layer_handle m_active{null_handle}; + layer_context m_context; // placement in effect at the current point of scenery load + std::unordered_map m_sources; // origins of nodes which can be rewritten on save + std::vector> m_erased; // definitions of nodes deleted since the load or the last save + std::set m_templates; // *.inc templates used by the scenery + bool m_terraindirective{false}; }; extern node_layers Layers; diff --git a/scene/scenelayerssave.cpp b/scene/scenelayerssave.cpp new file mode 100644 index 00000000..b76a9f2b --- /dev/null +++ b/scene/scenelayerssave.cpp @@ -0,0 +1,972 @@ +/* +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 "simulation/simulation.h" +#include "model/AnimModel.h" +#include "world/MemCell.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 +}; + +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.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, glm::dvec3 const &Location, 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; + } + 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 + 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; + }; + std::string text; + std::vector nodes; + std::vector sites; + + // adds another piece of text at the end + void append(composition const &Other) + { + auto const offset{static_cast(text.size())}; + text += Other.text; + for (auto place : Other.nodes) + { + place.span.begin += offset; + place.span.end += offset; + nodes.emplace_back(place); + } + for (auto place : Other.sites) + { + place.span.begin += offset; + place.span.end += offset; + sites.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::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; + } + for (auto &edit : edits) + { + if (edit.begin < 0) + { + edit.begin = edit.end = basesize; + } + } + 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}); + } + 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)}); + } + } + return true; +} + +// writes changes made in the editor to the scenery files +save_result node_layers::save(std::vector const &Rootstatements) +{ + 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; + } + 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))}; + if (false == (moved || rotated || scaled)) + { + 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))}; + 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; + } + patched = patch_model(text, placement, error); + } + else + { + patched = patch_memcell(text, source.context.to_local(location), error); + } + if (false == patched) + { + state.error = error + " of \"" + Node->name() + "\" in file \"" + layer(source.layer).name + "\""; + return false; + } + 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->m_template) + { + // created in the editor. the definition is placed at the end of the layer file, with the placement in effect there + 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_end}; + 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); + } + } + 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)); + } + + // 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 + { + edit.begin = edit.end = -1; + if (false == layer(parent).created) + { + if (false == load(parent)) + { + return fail(state.error); + } + auto const &content{state.content[parent]}; + if (false == content.empty() && content.back() != '\n') + { + 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 || + (candidate == root && false == Rootstatements.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); + } + + 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); + 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(text.text, eol); + text.append(mergedtext); + } + } + if (auto const lookup{created.find(output)}; lookup != created.end()) + { + for (auto const &definition : lookup->second) + { + ensure_newline(text.text, eol); + auto const offset{static_cast(text.text.size())}; + text.nodes.push_back({definition.first, {offset, offset + static_cast(definition.second.size())}, true}); + text.text += definition.second + eol; + } + } + if (output == root) + { + for (auto const &statement : Rootstatements) + { + ensure_newline(text.text, eol); + text.text += statement + eol; + } + } + } + + // 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); + } + 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) + { + return fail("can't replace file \"" + layer(output).name + "\": " + error.message()); + } + result.files.emplace_back(layer(output).name); + WriteLog("Scenery save: file \"" + layer(output).name + "\" updated"); + } + + // the scenery files now match the scene, update the bookkeeping to reflect it + 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; + if (isnew) + { + source.context = layer(output).context_end; + } + 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].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; + } + } + 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); + } + m_erased.clear(); + 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() ? ", " : ""); + } + } + return result; +} + +} // namespace scene + +//--------------------------------------------------------------------------- diff --git a/scene/scenenode.cpp b/scene/scenenode.cpp index 41b035bd..788d0717 100644 --- a/scene/scenenode.cpp +++ b/scene/scenenode.cpp @@ -718,6 +718,7 @@ memory_node::deserialize( cParser &Input, node_data const &Nodedata ) { basic_node::basic_node( scene::node_data const &Nodedata ) : m_name( Nodedata.name ), + m_template( Nodedata.is_template ), m_layer( Nodedata.layer ) { uuid = UID::random(); diff --git a/scene/scenenode.h b/scene/scenenode.h index 533ed41a..549eb0b4 100644 --- a/scene/scenenode.h +++ b/scene/scenenode.h @@ -71,6 +71,7 @@ struct node_data { std::string name; std::string type; layer_handle layer { null_handle }; // scenery layer the node is defined in, if any + bool is_template { false }; // the node is defined by an *.inc template rather than directly by a scenery layer file }; // holds unique piece of geometry, covered with single material @@ -381,6 +382,7 @@ public: std::string m_name; bool m_dirty { false }; bool m_layerhidden { false }; // the node was visible until its scenery layer was hidden + bool m_template { false }; // the node is defined by an *.inc template, which the editor can't rewrite. set only for scenery opened for editing scene::layer_handle m_layer { null_handle }; // scenery layer this node belongs to, if any UID uuid; diff --git a/simulation/simulation.cpp b/simulation/simulation.cpp index e10b0843..ed381572 100644 --- a/simulation/simulation.cpp +++ b/simulation/simulation.cpp @@ -495,6 +495,7 @@ TEventLauncher * state_manager::create_eventlauncher(const std::string &src, con void state_manager::delete_model(TAnimModel *model) { scene::Layers.count(model->layer(), scene::layer_item::model, -1); + scene::Layers.forget(model); Region->erase(model); Instances.purge(model); } diff --git a/simulation/simulationstateserializer.cpp b/simulation/simulationstateserializer.cpp index f7429b1d..d1d44963 100644 --- a/simulation/simulationstateserializer.cpp +++ b/simulation/simulationstateserializer.cpp @@ -437,8 +437,11 @@ void state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratchpad ) { auto const inputline = Input.Line(); // cache in case we need to report error + auto const sourcebegin = Input.TokenBegin(); // location of the node definition, for scenery opened for editing scene::node_data nodedata; + nodedata.layer = scene::Layers.handle(); + nodedata.is_template = ( nodedata.layer != null_handle ) && ( false == Input.InLayerFile() ); // common data and node type indicator Input.getTokens( 4 ); Input @@ -447,7 +450,6 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch >> nodedata.name >> nodedata.type; if( nodedata.name == "none" ) { nodedata.name.clear(); } - nodedata.layer = scene::Layers.handle(); // type-based deserialization. not elegant but it'll do if( nodedata.type == "dynamic" ) { @@ -571,6 +573,7 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch } scene::Groups.insert( scene::Groups.handle(), instance ); simulation::Region->insert( instance ); + scene::Layers.track( instance, { sourcebegin, Input.TokenEnd() }, instance->Angles(), instance->Scale() ); scene::basic_node *hierarchy_node = instance; if (hierarchy_node) { scene::Hierarchy[hierarchy_node->uuid.to_string()] = hierarchy_node; @@ -626,6 +629,7 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch } scene::Groups.insert( scene::Groups.handle(), memorycell ); simulation::Region->insert( memorycell ); + scene::Layers.track( memorycell, { sourcebegin, Input.TokenEnd() } ); } else if( nodedata.type == "eventlauncher" ) { @@ -685,6 +689,22 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch } } +namespace { + +// passes the placement in effect to the layer bookkeeping of scenery opened for editing +void +sync_layer_context( scene::scratch_data const &Scratchpad ) { + + if( true == scene::Layers.empty() ) { return; } + + scene::Layers.context( { + ( Scratchpad.location.offset.empty() ? glm::dvec3{ 0.0 } : Scratchpad.location.offset.top() ), + Scratchpad.location.rotation, + ( Scratchpad.location.scale.empty() ? glm::vec3{ 1.f } : Scratchpad.location.scale.top() ) } ); +} + +} // namespace + void state_serializer::deserialize_origin( cParser &Input, scene::scratch_data &Scratchpad ) { @@ -700,6 +720,7 @@ state_serializer::deserialize_origin( cParser &Input, scene::scratch_data &Scrat Scratchpad.location.offset.empty() ? glm::dvec3() : Scratchpad.location.offset.top() ) ); + sync_layer_context( Scratchpad ); } void @@ -711,6 +732,7 @@ state_serializer::deserialize_endorigin( cParser &Input, scene::scratch_data &Sc else { ErrorLog( "Bad origin: endorigin instruction with empty origin stack in file \"" + Input.Name() + "\" (line " + std::to_string( Input.Line() - 1 ) + ")" ); } + sync_layer_context( Scratchpad ); } void @@ -734,6 +756,7 @@ state_serializer::deserialize_scale( cParser &Input, scene::scratch_data &Scratc // scales compose component-wise, mirroring how origin offsets compose additively. glm::vec3 const parent = Scratchpad.location.scale.empty() ? glm::vec3(1.0f) : Scratchpad.location.scale.top(); Scratchpad.location.scale.emplace( factor * parent ); + sync_layer_context( Scratchpad ); } void @@ -745,6 +768,7 @@ state_serializer::deserialize_endscale( cParser &Input, scene::scratch_data &Scr else { ErrorLog( "Bad scale: endscale instruction with empty scale stack in file \"" + Input.Name() + "\" (line " + std::to_string( Input.Line() - 1 ) + ")" ); } + sync_layer_context( Scratchpad ); } void @@ -755,6 +779,7 @@ state_serializer::deserialize_rotate( cParser &Input, scene::scratch_data &Scrat >> Scratchpad.location.rotation.x >> Scratchpad.location.rotation.y >> Scratchpad.location.rotation.z; + sync_layer_context( Scratchpad ); } void @@ -860,6 +885,7 @@ state_serializer::deserialize_editorterrain(cParser &Input, scene::scratch_data Input.getTokens(4); Input >> folder >> cells >> cellsize >> radius; skip_until(Input, "endeditorterrain"); + scene::Layers.terrain_directive(true); if (!folder.empty() && cells > 0 && cellsize > 0.0f) { diff --git a/utilities/parser.cpp b/utilities/parser.cpp index 4219765c..36592d96 100644 --- a/utilities/parser.cpp +++ b/utilities/parser.cpp @@ -237,11 +237,13 @@ std::string cParser::readTokenFromStream(bool ToLower, const char *Break) const auto breakTable = makeBreakTable(Break); char c = 0; + bool separated = false; // the token was ended by a separator, which isn't a part of it while (token.empty() && mStream->peek() != EOF) { while (mStream->peek() != EOF) { // idk why but with mStream->get(c) not all cars are loaded c = static_cast(mStream->get()); + ++mPosition; if (c == '\n') { ++mLine; } @@ -249,11 +251,16 @@ std::string cParser::readTokenFromStream(bool ToLower, const char *Break) const unsigned char uc = static_cast(c); if (breakTable[uc]) { // separator ends token (or continues skipping if token empty) - if (!token.empty()) + if (!token.empty()) { + separated = true; break; + } continue; } + if (token.empty()) { + mTokenBegin = mPosition - 1; + } if (ToLower) c = toLowerChar(c); token.push_back(c); @@ -265,6 +272,8 @@ std::string cParser::readTokenFromStream(bool ToLower, const char *Break) } } } + // NOTE: comment glued to the end of a token is counted as a part of it + mTokenEnd = separated ? mPosition - 1 : mPosition; return token; } @@ -275,6 +284,7 @@ void cParser::stripFirstTokenBOM(std::string& token, bool ToLower, const char* B if (startsWithBOM(token)) { token.erase(0, 3); + mTokenBegin += 3; } // if first "token" was standalone BOM, read the next real token (avoid recursion) @@ -345,7 +355,7 @@ void cParser::startIncludeFromParser(cParser& srcParser, bool ToLower, std::stri if (sceneryLayers) { // the file is still a part of the scenery, even though its content comes from the binary terrain file - scene::Layers.layer(scene::Layers.open(includefile)).binary = true; + scene::Layers.layer(scene::Layers.open(includefile, include_site())).binary = true; scene::Layers.close(); } return; @@ -373,17 +383,37 @@ void cParser::startIncludeFromParser(cParser& srcParser, bool ToLower, std::stri mIncludeParser->sceneryLayers = true; // content of an included scenery file forms a layer of its own. *.inc files are node templates // reused all over the scenery, so their content stays in the layer of the file which includes them - if (false == mIncludeParser->mIncFile && mIncludeParser->mSize > 0) + if (mIncludeParser->mIncFile) { - scene::Layers.open(includefile); + scene::Layers.template_used(includefile); + } + else if (mIncludeParser->mSize > 0) + { + auto site{include_site()}; + site.parameters = false == mIncludeParser->parameters.empty(); + scene::Layers.open(includefile, site); mIncludeParser->mLayerFile = true; } } } +// describes the include directive being processed, for the scenery layer bookkeeping +scene::include_site cParser::include_site() const +{ + scene::include_site site; + // the directive can be rewritten on scenery save only if we know where it is, and it sits in a layer file + site.fixed = mIncludeBegin < 0 || mFile.empty() || mIncFile; + if (false == site.fixed) + { + site.span = {mIncludeBegin, mTokenEnd}; + } + return site; +} + bool cParser::handleIncludeIfPresent(std::string& token, bool ToLower, const char* Break) { // token-mode include: token == "include" if (expandIncludes && token == "include") { + mIncludeBegin = mTokenBegin; std::string includefile; if (allowRandomIncludes) includefile = deserialize_random_set(*this); @@ -399,6 +429,7 @@ bool cParser::handleIncludeIfPresent(std::string& token, bool ToLower, const cha // line-mode HACK: Break == "\n\r" and line begins with "include" if (std::strcmp(Break, "\n\r") == 0 && token.compare(0, 7, "include") == 0) { + mIncludeBegin = -1; // the directive is parsed out of a line of text, its exact location isn't known cParser includeparser(token.substr(7)); std::string includefile; if (allowRandomIncludes) @@ -459,6 +490,7 @@ std::string cParser::readQuotes(char const Quote) bool escaped = false; while (mStream->get(c)) { // get all chars until the quote mark + ++mPosition; if (escaped) { escaped = false; @@ -489,6 +521,7 @@ void cParser::skipComment(std::string const &Endmark) auto const endmarksize = Endmark.size(); while (mStream->get(c)) { + ++mPosition; if (c == '\n') { // update line counter @@ -626,3 +659,18 @@ int cParser::LineMain() const { return mIncludeParser ? -1 : mLine; } + +std::streamoff cParser::TokenBegin() const +{ + return mIncludeParser ? mIncludeParser->TokenBegin() : mTokenBegin; +} + +std::streamoff cParser::TokenEnd() const +{ + return mIncludeParser ? mIncludeParser->TokenEnd() : mTokenEnd; +} + +bool cParser::InLayerFile() const +{ + return mIncludeParser ? mIncludeParser->InLayerFile() : (false == mFile.empty() && false == mIncFile); +} diff --git a/utilities/parser.h b/utilities/parser.h index 1a806582..c64ed131 100644 --- a/utilities/parser.h +++ b/utilities/parser.h @@ -15,6 +15,11 @@ http://mozilla.org/MPL/2.0/. #include #include +namespace scene +{ +struct include_site; +} + ///////////////////////////////////////////////////////////////////////////////////////////////////// // cParser -- generic class for parsing text data, either from file or provided string @@ -94,6 +99,11 @@ class cParser //: public std::stringstream std::size_t Line() const; // returns number of currently processed line in main file, -1 if inside include int LineMain() const; + // location of the most recently read token in its source file, as byte offsets + std::streamoff TokenBegin() const; + std::streamoff TokenEnd() const; + // true if the most recently read token came straight from a scenery file which isn't an *.inc node template + bool InLayerFile() const; bool expandIncludes = true; bool allowRandomIncludes = false; bool skipComments = true; @@ -101,6 +111,7 @@ class cParser //: public std::stringstream private: void startIncludeFromParser(cParser &srcParser, bool ToLower, std::string includefile); + scene::include_site include_site() const; bool handleIncludeIfPresent(std::string &token, bool ToLower, const char *Break); // methods: void readToken(std::string& out, bool ToLower = true, const char *Break = "\n\r\t ;"); @@ -120,6 +131,10 @@ class cParser //: public std::stringstream std::size_t mLine { 0 }; // currently processed line bool mIncFile { false }; // the parser is processing an *.inc file bool mLayerFile { false }; // the parser is processing a file registered as scenery layer + std::streamoff mPosition { 0 }; // amount of bytes read from the stream so far + std::streamoff mTokenBegin { 0 }; // location of the most recently read token + std::streamoff mTokenEnd { 0 }; + std::streamoff mIncludeBegin { -1 }; // location of the include directive being processed, -1 if unknown bool mFirstToken { true }; // processing first token in the current file; helper used when checking for utf bom typedef std::map commentmap; commentmap mComments {