From f9deee5cfdd63db40ace5c555fdec13cf61cb24e Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Thu, 1 Oct 2026 02:03:49 +0200 Subject: [PATCH] fix(editor): stuck brush after clicks on UI; add polygon area fill (#191) --- application/editormode.cpp | 619 +++++++++++++++++++++++++++++++-- application/editormode.h | 26 +- application/editoruilayer.cpp | 10 + application/editoruilayer.h | 2 + application/editoruipanels.cpp | 43 ++- application/editoruipanels.h | 7 +- 6 files changed, 674 insertions(+), 33 deletions(-) diff --git a/application/editormode.cpp b/application/editormode.cpp index 46a8edad..94b1424d 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -24,6 +24,8 @@ http://mozilla.org/MPL/2.0/. #include "model/Model3d.h" #include "utilities/Float3d.h" #include "scene/scene.h" +#include "utilities/parser.h" +#include "utilities/utilities.h" #include "imgui/imgui.h" @@ -58,6 +60,14 @@ namespace return state == GLFW_PRESS; } + // true when the most recent left button press landed in the viewport rather than on an ImGui window. + // valid once ImGui has processed the press (i.e. from mode update onwards), so it's also used to + // discard deferred pick callbacks of a press that leaked past the application's UI filter + inline bool viewport_click() + { + return false == ImGui::GetIO().MouseDownOwned[0]; + } + // tests whether the vertical line through (Px,Pz) passes over triangle abc; if so returns the // surface height at that point through OutY. used by the "snap to ground" (END) feature. inline bool triangle_height_at(glm::dvec3 const &a, glm::dvec3 const &b, glm::dvec3 const &c, @@ -117,6 +127,158 @@ namespace } } + // world transform of a model instance, matching the renderer: translate * rotateY * rotateX * rotateZ * scale + glm::dmat4 instance_matrix(TAnimModel const &Model) + { + glm::dmat4 rootm(1.0); + rootm = glm::translate(rootm, Model.location()); + glm::vec3 const angles = Model.Angles(); + if (angles.y != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast(angles.y)), glm::dvec3(0.0, 1.0, 0.0)); + if (angles.x != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast(angles.x)), glm::dvec3(1.0, 0.0, 0.0)); + if (angles.z != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast(angles.z)), glm::dvec3(0.0, 0.0, 1.0)); + return glm::scale(rootm, glm::dvec3(Model.Scale())); + } + + // even-odd test of a point against a polygon outline projected onto the XZ plane + bool point_in_polygon_xz(std::vector const &Polygon, double const X, double const Z) + { + bool inside = false; + for (std::size_t i = 0, j = Polygon.size() - 1; i < Polygon.size(); j = i++) + { + auto const &a = Polygon[i]; + auto const &b = Polygon[j]; + if ((a.z > Z) != (b.z > Z) && X < (b.x - a.x) * (Z - a.z) / (b.z - a.z) + a.x) + inside = !inside; + } + return inside; + } + + // area of a polygon outline projected onto the XZ plane (shoelace formula) + double polygon_area_xz(std::vector const &Polygon) + { + if (Polygon.size() < 3) + return 0.0; + double sum = 0.0; + for (std::size_t i = 0, j = Polygon.size() - 1; i < Polygon.size(); j = i++) + sum += Polygon[j].x * Polygon[i].z - Polygon[i].x * Polygon[j].z; + return std::abs(sum) * 0.5; + } + + // short readable name of a node template (model file and texture), like the node bank shows it + std::string template_label(std::string const &Template) + { + cParser tokenizer(Template); + tokenizer.getTokens(9, false); // node, ranges, name, type, position, rotation + auto model{tokenizer.getToken(false)}; + auto texture{tokenizer.getToken(false)}; + replace_slashes(model); + erase_extension(model); + replace_slashes(texture); + return texture == "none" || texture.empty() ? model : model + " (" + texture + ")"; + } + + // world triangles bucketed on a uniform XZ grid, for many ground height queries over one area + class triangle_grid + { + public: + triangle_grid(std::vector Triangles, glm::dvec2 const &Min, glm::dvec2 const &Max) : m_triangles(std::move(Triangles)), m_min(Min) + { + glm::dvec2 const extent = glm::max(Max - Min, glm::dvec2(1.0)); + // ~8 m cells, coarser for big areas so large terrain triangles don't flood the grid + m_cellsize = std::max(8.0, std::max(extent.x, extent.y) / 128.0); + m_columns = static_cast(std::ceil(extent.x / m_cellsize)) + 1; + m_rows = static_cast(std::ceil(extent.y / m_cellsize)) + 1; + m_cells.resize(static_cast(m_columns) * m_rows); + + for (std::uint32_t idx = 0; idx < m_triangles.size(); ++idx) + { + auto const &t = m_triangles[idx]; + int const c0 = column(std::min({t[0].x, t[1].x, t[2].x})); + int const c1 = column(std::max({t[0].x, t[1].x, t[2].x})); + int const r0 = row(std::min({t[0].z, t[1].z, t[2].z})); + int const r1 = row(std::max({t[0].z, t[1].z, t[2].z})); + for (int r = r0; r <= r1; ++r) + for (int c = c0; c <= c1; ++c) + m_cells[static_cast(r) * m_columns + c].push_back(idx); + } + } + + // highest surface at (X,Z) that isn't above Ceiling. returns: true if there is one + bool height_at(double const X, double const Z, double const Ceiling, double &OutY) const + { + bool found = false; + for (auto const idx : m_cells[static_cast(row(Z)) * m_columns + column(X)]) + { + auto const &t = m_triangles[idx]; + double y; + if (triangle_height_at(t[0], t[1], t[2], X, Z, y) && y <= Ceiling && (!found || y > OutY)) + { + OutY = y; + found = true; + } + } + return found; + } + + private: + int column(double const X) const + { + return std::clamp(static_cast(std::floor((X - m_min.x) / m_cellsize)), 0, m_columns - 1); + } + int row(double const Z) const + { + return std::clamp(static_cast(std::floor((Z - m_min.y) / m_cellsize)), 0, m_rows - 1); + } + + std::vector m_triangles; + std::vector> m_cells; + glm::dvec2 m_min; + double m_cellsize{8.0}; + int m_columns{1}; + int m_rows{1}; + }; + + // appends triangles of a shape node which overlap specified XZ rectangle + void gather_shape_triangles(scene::shape_node const &Shape, glm::dvec2 const &Min, glm::dvec2 const &Max, std::vector &Out) + { + auto const &data = Shape.data(); + if (data.area.radius >= 0.0f) + { + double const r = data.area.radius; + if (data.area.center.x + r < Min.x || data.area.center.x - r > Max.x || data.area.center.z + r < Min.y || data.area.center.z - r > Max.y) + return; + } + + auto const add = [&](glm::dvec3 const &a, glm::dvec3 const &b, glm::dvec3 const &c) { + if (std::max({a.x, b.x, c.x}) < Min.x || std::min({a.x, b.x, c.x}) > Max.x || std::max({a.z, b.z, c.z}) < Min.y || std::min({a.z, b.z, c.z}) > Max.y) + return; + Out.push_back({a, b, c}); + }; + + if (false == data.vertices.empty()) + { + for (std::size_t i = 0; i + 2 < data.vertices.size(); i += 3) + add(data.vertices[i].position, data.vertices[i + 1].position, data.vertices[i + 2].position); + return; + } + // once the shape is uploaded its world-space source is released; the renderer keeps an origin-relative copy + if (data.geometry.bank == 0 && data.geometry.chunk == 0) + return; + auto const &verts = GfxRenderer->Vertices(data.geometry); + auto const &indices = GfxRenderer->Indices(data.geometry); + auto const to_world = [&](gfx::basic_vertex const &v) { return data.origin + glm::dvec3(v.position); }; + if (false == indices.empty()) + { + for (std::size_t i = 0; i + 2 < indices.size(); i += 3) + add(to_world(verts[indices[i]]), to_world(verts[indices[i + 1]]), to_world(verts[indices[i + 2]])); + } + else + { + for (std::size_t i = 0; i + 2 < verts.size(); i += 3) + add(to_world(verts[i]), to_world(verts[i + 1]), to_world(verts[i + 2])); + } + } + } bool editor_mode::editormode_input::init() @@ -306,32 +468,38 @@ void editor_mode::handle_brush_mouse_hold(int Action, int Button) GfxRenderer->Pick_Node_Callback( [this, mode, rotation_mode, fixed_rotation_value, Action, Button](scene::basic_node * /*node*/) { + // picks are resolved a few frames later; drop the ones queued before the button went up, + // or for a press that turned out to belong to the UI + if (!mouseHold || !viewport_click()) + return; + const std::string *src = ui()->get_active_node_template(); if (!src) return; std::string name = "editor_"; - glm::dvec3 newPos = clamp_mouse_offset_to_max(GfxRenderer->Mouse_Position()); - double distance = glm::distance(newPos, oldPos); - if (distance < ui()->getSpacing()) + // spacing is measured in world space: the mouse offset is camera-relative and the camera may move mid-stroke + glm::dvec3 const newPos = Camera.Pos + clamp_mouse_offset_to_max(GfxRenderer->Mouse_Position()); + if (m_brush_has_last && glm::distance(newPos, oldPos) < ui()->getSpacing()) return; - TAnimModel *cloned = simulation::State.create_model(*src, name, Camera.Pos + newPos); + TAnimModel *cloned = simulation::State.create_model(*src, name, newPos); oldPos = newPos; + m_brush_has_last = true; if (!cloned) return; std::string new_name = "editor_" + cloned->uuid.to_string(); cloned->m_name = new_name; - + apply_rotation_for_new_node(cloned, rotation_mode, fixed_rotation_value); + std::string as_text; cloned->export_as_text(as_text); push_snapshot(cloned, EditorSnapshot::Action::Add, as_text); m_node = cloned; - apply_rotation_for_new_node(m_node, rotation_mode, fixed_rotation_value); ui()->set_node(m_node); }); } @@ -439,6 +607,9 @@ void editor_mode::nullify_history_pointers(scene::basic_node *node) if (s.node_ptr == node) s.node_ptr = nullptr; } + + // deleted nodes also drop out of the "undo last fill" set + m_fill_last.erase(std::remove(m_fill_last.begin(), m_fill_last.end(), node), m_fill_last.end()); } void editor_mode::undo_last() @@ -626,6 +797,19 @@ bool editor_mode::update() Application.set_cursor(GLFW_CURSOR_NORMAL); } + // same for the left button (brush / sculpt / click placement). the UI filter in the application + // uses io.WantCaptureMouse from the previous frame, so a press on a panel the cursor has just + // moved onto can leak into the viewport, and its release is then swallowed by the UI; the brush + // would keep painting with the button up. by now ImGui has processed this frame's input, so + // MouseDown is the real button state and MouseDownOwned tells whether the press hit the UI. + // (pick callbacks queued by such a press are discarded separately, see viewport_click()) + if (mouseHold && (!ImGui::GetIO().MouseDown[0] || !viewport_click())) + { + mouseHold = false; + m_dragging = false; + m_input.mouse.button(GLFW_MOUSE_BUTTON_LEFT, GLFW_RELEASE); + } + // fixed step render time routines (50 Hz) fTime50Hz += deltarealtime; // accumulate even when paused to keep frame reads stable while (fTime50Hz >= 1.0 / 50.0) @@ -708,6 +892,13 @@ bool editor_mode::update() // --- ImGuizmo: in-viewport transform gizmo for the selected node --- render_gizmo(); + // --- area fill: outline overlay and settings, while the mode is active --- + if (ui()->mode() == nodebank_panel::FILL) + { + draw_area_fill_outline(); + render_area_fill(); + } + // --- ImGui: Editor Settings & History windows --- if(m_settings_open) render_settings(); @@ -1097,18 +1288,8 @@ void editor_mode::capture_terrain() return; } - // instance world transform, matching the renderer: translate * rotateY * rotateX * rotateZ * scale - glm::dmat4 rootm(1.0); - rootm = glm::translate(rootm, model->location()); - glm::vec3 const angles = model->Angles(); - if (angles.y != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast(angles.y)), glm::dvec3(0.0, 1.0, 0.0)); - if (angles.x != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast(angles.x)), glm::dvec3(1.0, 0.0, 0.0)); - if (angles.z != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast(angles.z)), glm::dvec3(0.0, 0.0, 1.0)); - glm::vec3 const scale = model->Scale(); - rootm = glm::scale(rootm, glm::dvec3(scale)); - std::vector tris; - gather_submodel_triangles(model->pModel->Root, rootm, tris); + gather_submodel_triangles(model->pModel->Root, instance_matrix(*model), tris); if (tris.empty()) { WriteLog("Editor: selected model has no readable geometry to capture", logtype::generic); @@ -1174,6 +1355,366 @@ void editor_mode::capture_terrain() simulation::State.delete_model(model); } +void editor_mode::add_area_fill_point() +{ + // like the other placement tools, the point comes from the depth buffer under the cursor + // (ground geometry only), relative to the camera + glm::dvec3 const offset = GfxRenderer->Mouse_Position(); + if (glm::length(offset) < 1e-3) + return; // no ground under the cursor (yet) + + m_fill_points.push_back(Camera.Pos + offset); +} + +void editor_mode::undo_last_area_fill() +{ + // nullify_history_pointers() removes deleted nodes from m_fill_last, so work on a copy + auto const created = std::move(m_fill_last); + m_fill_last.clear(); + + for (TAnimModel *model : created) + { + if (model == nullptr) + continue; + if (m_node == model) + { + m_node = nullptr; + ui()->set_node(nullptr); + } + nullify_history_pointers(model); + remove_from_hierarchy(model); + simulation::State.delete_model(model); + } + m_fill_status = "Removed " + std::to_string(created.size()) + " objects of the last fill"; +} + +void editor_mode::run_area_fill() +{ + // limits keeping a single click from freezing the editor for too long + std::size_t constexpr max_objects = 20000; + // large model instances (e.g. terrain tiles) are treated as ground, smaller ones (houses, trees) are not + float constexpr terrain_model_radius = 50.0f; + // ground is searched up to this far above the height interpolated from the outline points, + // so hills inside the outline are found but bridges or roofs high above it are not + double constexpr height_tolerance = 50.0; + + if (m_fill_points.size() < 3) + { + m_fill_status = "Outline needs at least 3 points"; + return; + } + + // model package + std::vector package; + if (m_fill_package == 0) + package = m_fill_custom; + else + package = ui()->nodebank_group_templates(static_cast(m_fill_package - 1)); + package.erase(std::remove_if(package.begin(), package.end(), [](std::string const &Template) { return Template.empty(); }), package.end()); + if (package.empty()) + { + m_fill_status = "Selected model package is empty"; + return; + } + + // area bounds + glm::dvec2 bmin(std::numeric_limits::max()); + glm::dvec2 bmax(-std::numeric_limits::max()); + for (auto const &p : m_fill_points) + { + bmin = glm::min(bmin, glm::dvec2(p.x, p.z)); + bmax = glm::max(bmax, glm::dvec2(p.x, p.z)); + } + double const area = polygon_area_xz(m_fill_points); + auto const target = static_cast(std::min(std::round(area / 10000.0 * std::max(0.0f, m_fill_density)), max_objects)); + if (target == 0) + { + m_fill_status = "Density too low for this area, nothing to place"; + return; + } + + // scatter points: random samples inside the outline, rejecting the ones closer than the minimal spacing + // to an already accepted point (checked on a hashed grid with cell size == spacing) + double const spacing = std::max(0.0f, m_fill_min_spacing); + std::unordered_map> occupancy; + auto const cell_key = [](std::int64_t const X, std::int64_t const Z) { return (X << 32) ^ (Z & 0xffffffff); }; + std::vector points; + points.reserve(target); + for (std::size_t attempt = 0; attempt < target * 30 && points.size() < target; ++attempt) + { + glm::dvec2 const p(LocalRandom(bmin.x, bmax.x), LocalRandom(bmin.y, bmax.y)); + if (false == point_in_polygon_xz(m_fill_points, p.x, p.y)) + continue; + if (spacing > 0.0) + { + auto const cx = static_cast(std::floor(p.x / spacing)); + auto const cz = static_cast(std::floor(p.y / spacing)); + bool free = true; + for (std::int64_t dz = -1; dz <= 1 && free; ++dz) + for (std::int64_t dx = -1; dx <= 1 && free; ++dx) + { + auto const lookup = occupancy.find(cell_key(cx + dx, cz + dz)); + if (lookup == occupancy.end()) + continue; + for (auto const idx : lookup->second) + if (glm::distance(points[idx], p) < spacing) + { + free = false; + break; + } + } + if (!free) + continue; + occupancy[cell_key(cx, cz)].push_back(points.size()); + } + points.push_back(p); + } + + // ground geometry within the area. shapes hold the (legacy) terrain, large instances the terrain tiles + std::vector triangles; + glm::dvec2 const center = (bmin + bmax) * 0.5; + float const radius = static_cast(glm::length(bmax - bmin) * 0.5); + auto const sections = simulation::Region->sections(glm::dvec3(center.x, 0.0, center.y), radius); // copy, the result is a scratchpad + for (auto *section : sections) + { + for (auto const &shape : section->m_shapes) + gather_shape_triangles(shape, bmin, bmax, triangles); + for (auto &cell : section->m_cells) + { + double const r = cell.m_area.radius; + if (cell.m_area.center.x + r < bmin.x || cell.m_area.center.x - r > bmax.x || cell.m_area.center.z + r < bmin.y || cell.m_area.center.z - r > bmax.y) + continue; + for (auto const &shape : cell.m_shapesopaque) + gather_shape_triangles(shape, bmin, bmax, triangles); + if (false == m_fill_models_as_ground) + continue; + for (auto *instance : cell.m_instancesopaque) + { + if (instance == nullptr || instance->Model() == nullptr || instance->radius() < terrain_model_radius) + continue; + std::vector modeltriangles; + gather_submodel_triangles(instance->Model()->Root, instance_matrix(*instance), modeltriangles); + for (auto const &t : modeltriangles) + if (std::max({t[0].x, t[1].x, t[2].x}) >= bmin.x && std::min({t[0].x, t[1].x, t[2].x}) <= bmax.x && std::max({t[0].z, t[1].z, t[2].z}) >= bmin.y && + std::min({t[0].z, t[1].z, t[2].z}) <= bmax.y) + triangles.push_back(t); + } + } + } + triangle_grid const ground(std::move(triangles), bmin, bmax); + auto const terrains = active_terrains(); + + auto const rotation_mode = m_fill_random_rotation ? functions_panel::RANDOM : ui()->rot_mode(); + auto const fixed_rotation_value = ui()->rot_val(); + float const scalemin = std::max(0.01f, std::min(m_fill_scale_min, m_fill_scale_max)); + float const scalemax = std::max(0.01f, std::max(m_fill_scale_min, m_fill_scale_max)); + + std::vector created; + created.reserve(points.size()); + std::size_t unsupported = 0; + for (auto const &p : points) + { + // reference height: the outline points, weighted by inverse squared distance + double weights = 0.0, reference = 0.0; + for (auto const &v : m_fill_points) + { + double const w = 1.0 / std::max(1e-6, glm::length2(glm::dvec2(v.x, v.z) - p)); + weights += w; + reference += w * v.y; + } + reference /= weights; + double const ceiling = reference + height_tolerance; + + double y = -std::numeric_limits::max(); + bool found = ground.height_at(p.x, p.y, ceiling, y); + for (editor_terrain *terrain : terrains) + { + if (!terrain->contains(p.x, p.y)) + continue; + double const h = terrain->height_at(p.x, p.y); + if (h <= ceiling && (!found || h > y)) + { + y = h; + found = true; + } + } + if (!found) + { + y = reference; + ++unsupported; + } + + glm::dvec3 const location(p.x, y, p.y); + if (!simulation::Region->point_inside(location)) + continue; + + auto const &src = package[package.size() > 1 ? std::min(package.size() - 1, static_cast(LocalRandom(0.0, static_cast(package.size())))) : 0]; + TAnimModel *cloned = simulation::State.create_model(src, "editor_", location); + if (!cloned) + continue; + cloned->m_name = "editor_" + cloned->uuid.to_string(); + apply_rotation_for_new_node(cloned, rotation_mode, fixed_rotation_value); + if (scalemin != 1.0f || scalemax != 1.0f) + cloned->Scale(static_cast(LocalRandom(scalemin, scalemax))); + created.push_back(cloned); + } + + // a new fill replaces the "last fill" undo set; the previous objects stay in the scene + m_fill_last = std::move(created); + + m_fill_status = "Placed " + std::to_string(m_fill_last.size()) + " of " + std::to_string(target) + " objects"; + if (points.size() < target) + m_fill_status += " (limited by min. spacing)"; + if (unsupported > 0) + m_fill_status += ", " + std::to_string(unsupported) + " without ground found (height interpolated)"; + WriteLog("Editor: area fill - " + m_fill_status, logtype::generic); +} + +void editor_mode::draw_area_fill_outline() const +{ + if (m_fill_points.empty()) + return; + + // same camera-relative view and clean perspective the transform gizmo uses + ImGuiIO const &io = ImGui::GetIO(); + glm::mat4 const view = GfxRenderer->Camera_View_Matrix(); + glm::dvec3 const camerapos = GfxRenderer->Camera_Position(); + float const fovy = glm::radians(Global.FieldOfView / Global.ZoomFactor); + float const aspect = io.DisplaySize.y > 0.0f ? io.DisplaySize.x / io.DisplaySize.y : 1.0f; + glm::mat4 const viewprojection = glm::perspective(fovy, aspect, 0.1f, 10000.0f) * view; + + auto const to_clip = [&](glm::dvec3 const &Point) { return viewprojection * glm::vec4(glm::vec3(Point - camerapos), 1.0f); }; + auto const to_screen = [&](glm::vec4 const &Clip) { + return ImVec2((Clip.x / Clip.w * 0.5f + 0.5f) * io.DisplaySize.x, (0.5f - Clip.y / Clip.w * 0.5f) * io.DisplaySize.y); + }; + float constexpr nearw = 0.1f; + + ImDrawList *drawlist = ImGui::GetBackgroundDrawList(); + ImU32 const edgecolor = IM_COL32(255, 200, 40, 230); + ImU32 const closingcolor = IM_COL32(255, 200, 40, 110); + + std::size_t const count = m_fill_points.size(); + for (std::size_t i = 0; i < count; ++i) + { + bool const closing = (i + 1 == count); + if (closing && count < 3) + break; + glm::vec4 a = to_clip(m_fill_points[i]); + glm::vec4 b = to_clip(m_fill_points[(i + 1) % count]); + // clip the edge against the near plane, so points behind the camera don't flip across the screen + if (a.w < nearw && b.w < nearw) + continue; + if (a.w < nearw) + a = glm::mix(a, b, (nearw - a.w) / (b.w - a.w)); + else if (b.w < nearw) + b = glm::mix(b, a, (nearw - b.w) / (a.w - b.w)); + drawlist->AddLine(to_screen(a), to_screen(b), closing ? closingcolor : edgecolor, 2.0f); + } + for (std::size_t i = 0; i < count; ++i) + { + glm::vec4 const c = to_clip(m_fill_points[i]); + if (c.w < nearw) + continue; + drawlist->AddCircleFilled(to_screen(c), i == 0 ? 6.0f : 4.0f, i == 0 ? IM_COL32(255, 90, 40, 255) : edgecolor); + } +} + +void editor_mode::render_area_fill() +{ + ImGui::Begin("Area fill", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoFocusOnAppearing); + + ImGui::TextUnformatted("LMB: add outline point, Backspace: remove last point"); + double const area = polygon_area_xz(m_fill_points); + ImGui::Text("Points: %zu Area: %.0f m2 (%.2f ha)", m_fill_points.size(), area, area / 10000.0); + if (ImGui::Button("Remove last point") && !m_fill_points.empty()) + m_fill_points.pop_back(); + ImGui::SameLine(); + if (ImGui::Button("Clear outline")) + m_fill_points.clear(); + + ImGui::Separator(); + + // model package: a node bank group, or a custom set assembled from node bank templates + auto const groups = ui()->nodebank_groups(); + m_fill_package = std::clamp(m_fill_package, 0, static_cast(groups.size())); + std::string const current = m_fill_package == 0 ? std::string("Custom set") : groups[m_fill_package - 1]; + ImGui::SetNextItemWidth(250.0f); + if (ImGui::BeginCombo("Model package", current.c_str())) + { + if (ImGui::Selectable("Custom set", m_fill_package == 0)) + m_fill_package = 0; + for (int i = 0; i < static_cast(groups.size()); ++i) + { + auto const label = groups[i] + "##fillgroup" + std::to_string(i); + if (ImGui::Selectable(label.c_str(), m_fill_package == i + 1)) + m_fill_package = i + 1; + } + ImGui::EndCombo(); + } + + if (m_fill_package == 0) + { + ImGui::BeginChild("fill_custom_set", ImVec2(350.0f, 110.0f), true); + for (int i = 0; i < static_cast(m_fill_custom.size()); ++i) + { + auto const label = template_label(m_fill_custom[i]) + "##fillcustom" + std::to_string(i); + if (ImGui::Selectable(label.c_str(), m_fill_custom_idx == i)) + m_fill_custom_idx = i; + } + ImGui::EndChild(); + if (ImGui::Button("Add selected template")) + { + // the node bank selection, not a random pick from the brush set + auto const *src = ui()->get_active_node_template(true); + if (src && !src->empty()) + m_fill_custom.push_back(*src); + } + ImGui::SameLine(); + if (ImGui::Button("Remove") && m_fill_custom_idx >= 0 && m_fill_custom_idx < static_cast(m_fill_custom.size())) + { + m_fill_custom.erase(m_fill_custom.begin() + m_fill_custom_idx); + m_fill_custom_idx = -1; + } + ImGui::SameLine(); + if (ImGui::Button("Clear set")) + { + m_fill_custom.clear(); + m_fill_custom_idx = -1; + } + } + else + { + ImGui::Text("Templates in group: %zu", ui()->nodebank_group_templates(static_cast(m_fill_package - 1)).size()); + } + + ImGui::Separator(); + + ImGui::SetNextItemWidth(120.0f); + ImGui::InputFloat("Density (objects/ha)", &m_fill_density, 10.0f, 100.0f, "%.1f"); + m_fill_density = std::max(0.0f, m_fill_density); + ImGui::SetNextItemWidth(120.0f); + ImGui::InputFloat("Min. spacing (m)", &m_fill_min_spacing, 0.5f, 2.0f, "%.1f"); + m_fill_min_spacing = std::max(0.0f, m_fill_min_spacing); + ImGui::SetNextItemWidth(200.0f); + ImGui::DragFloatRange2("Scale", &m_fill_scale_min, &m_fill_scale_max, 0.01f, 0.1f, 5.0f, "min %.2f", "max %.2f"); + ImGui::Checkbox("Random rotation (off: Functions panel setting)", &m_fill_random_rotation); + ImGui::Checkbox("Large models count as ground (terrain tiles)", &m_fill_models_as_ground); + + ImGui::Text("Estimated objects: %.0f", std::round(area / 10000.0 * m_fill_density)); + + if (ImGui::Button("Fill area")) + run_area_fill(); + ImGui::SameLine(); + std::string const undolabel = "Undo last fill (" + std::to_string(m_fill_last.size()) + ")"; + if (ImGui::Button(undolabel.c_str()) && !m_fill_last.empty()) + undo_last_area_fill(); + + if (!m_fill_status.empty()) + ImGui::TextUnformatted(m_fill_status.c_str()); + + ImGui::End(); +} + void editor_mode::render_gizmo() { // the transform gizmo is suppressed while editing terrain, so the brush/chunk tool owns the mouse @@ -1381,6 +1922,7 @@ void editor_mode::exit() g_redo.clear(); m_history.clear(); + m_fill_last.clear(); // drop selection so a stale/dangling node pointer isn't used on the next editor session m_node = nullptr; @@ -1496,6 +2038,12 @@ void editor_mode::on_key(int const Key, int const Scancode, int const Action, in } break; + case GLFW_KEY_BACKSPACE: + // area fill: remove the last outline point + if (is_press(Action) && ui()->mode() == nodebank_panel::FILL && !m_fill_points.empty()) + m_fill_points.pop_back(); + break; + case GLFW_KEY_F: if (is_press(Action)) { @@ -1565,11 +2113,30 @@ void editor_mode::on_mouse_button(int const Button, int const Action, int const if (is_press(Action)) { mouseHold = true; - m_node = nullptr; + m_brush_has_last = false; // every brush stroke starts with a placement - // delegate node picking behaviour depending on current panel mode + // in area fill mode the left button only adds outline points, it doesn't touch the selection + if (mode == nodebank_panel::FILL) + { + GfxRenderer->Pick_Node_Callback([this](scene::basic_node * /*node*/) { + if (viewport_click()) + add_area_fill_point(); + }); + m_input.mouse.button(Button, Action); + return; + } + + // delegate node picking behaviour depending on current panel mode. + // NOTE: the pick is resolved a few frames later, often after the button was already released, + // so the callback mustn't depend on the button still being held GfxRenderer->Pick_Node_Callback( [this, mode, rotation_mode, fixed_rotation_value](scene::basic_node *node) { + // the press turned out to be meant for the UI + if (!viewport_click()) + return; + + m_node = nullptr; + // ignore picks that are beyond allowed placement distance if (node) { double const dist = glm::distance(node->location(), glm::dvec3{Global.pCamera.Pos}); @@ -1578,9 +2145,6 @@ void editor_mode::on_mouse_button(int const Button, int const Action, int const } if (mode == nodebank_panel::MODIFY) { - if (!m_dragging) - return; - m_node = node; ui()->set_node(m_node); } @@ -1607,19 +2171,16 @@ void editor_mode::on_mouse_button(int const Button, int const Action, int const if (!cloned) return; - // record addition for undo - std::string as_text; std::string new_name = "editor_" + cloned->uuid.to_string(); - cloned->m_name = new_name; + apply_rotation_for_new_node(cloned, rotation_mode, fixed_rotation_value); + + // record addition for undo (after rotation, so a redo recreates the same thing) + std::string as_text; cloned->export_as_text(as_text); push_snapshot(cloned, EditorSnapshot::Action::Add, as_text); - if (!m_dragging) - return; - m_node = cloned; - apply_rotation_for_new_node(m_node, rotation_mode, fixed_rotation_value); ui()->set_node(m_node); } }); diff --git a/application/editormode.h b/application/editormode.h index 29b1ad69..413b4225 100644 --- a/application/editormode.h +++ b/application/editormode.h @@ -19,6 +19,10 @@ http://mozilla.org/MPL/2.0/. #include "editor/editorTerrainStreamer.hpp" #include +#include +#include + +class TAnimModel; class editor_mode : public application_mode { @@ -122,7 +126,8 @@ class editor_mode : public application_mode scene::basic_node *m_node{nullptr}; // currently selected scene node bool m_takesnapshot{true}; // helper, hints whether snapshot of selected node(s) should be taken before modification bool m_dragging = false; - glm::dvec3 oldPos; + glm::dvec3 oldPos; // world position of the last brush placement + bool m_brush_has_last{false}; // false at the start of a brush stroke, so the first placement ignores spacing bool mouseHold{false}; float kMaxPlacementDistance = 200.0f; static bool m_change_history; @@ -196,6 +201,25 @@ class editor_mode : public application_mode void render_change_history(); void render_settings(); + // area fill: scatters models from a package over a polygon outlined in the viewport + void render_area_fill(); + void draw_area_fill_outline() const; + void add_area_fill_point(); + void run_area_fill(); + void undo_last_area_fill(); + std::vector m_fill_points; // outline vertices (world space) in click order + std::vector m_fill_last; // instances created by the most recent fill, for "Undo last fill" + std::vector m_fill_custom; // user-assembled package of node templates + int m_fill_custom_idx{-1}; + int m_fill_package{0}; // 0 = custom set, N = nodebank group N-1 + float m_fill_density{200.0f}; // objects per hectare + float m_fill_min_spacing{2.0f}; // minimal distance between placed objects (m) + bool m_fill_random_rotation{true}; // random yaw; otherwise the Functions panel rotation settings apply + float m_fill_scale_min{1.0f}; + float m_fill_scale_max{1.0f}; + bool m_fill_models_as_ground{true}; // large model instances (terrain tiles) count as ground + std::string m_fill_status; // result of the last fill, shown in the panel + // ImGuizmo-based transform gizmo for the selected node enum class gizmo_operation { translate, rotate, scale }; void render_gizmo(); diff --git a/application/editoruilayer.cpp b/application/editoruilayer.cpp index d87dd8fa..b00b1e51 100644 --- a/application/editoruilayer.cpp +++ b/application/editoruilayer.cpp @@ -55,6 +55,16 @@ void editor_ui::toggleBrushSettings(bool isVisible) m_brushobjects.is_open = isVisible; } +std::vector editor_ui::nodebank_groups() const +{ + return m_nodebankpanel.group_names(); +} + +std::vector editor_ui::nodebank_group_templates(std::size_t const Group) const +{ + return m_nodebankpanel.group_templates(Group); +} + void editor_ui::set_node(scene::basic_node *Node) { m_node = Node; diff --git a/application/editoruilayer.h b/application/editoruilayer.h index ad3d5447..57d75466 100644 --- a/application/editoruilayer.h +++ b/application/editoruilayer.h @@ -37,6 +37,8 @@ class editor_ui : public ui_layer nodebank_panel::edit_mode mode(); float getSpacing(); void toggleBrushSettings(bool isVisible); + std::vector nodebank_groups() const; + std::vector nodebank_group_templates(std::size_t const Group) const; private: // members diff --git a/application/editoruipanels.cpp b/application/editoruipanels.cpp index cbfe0ae7..80eec0ba 100644 --- a/application/editoruipanels.cpp +++ b/application/editoruipanels.cpp @@ -434,13 +434,13 @@ void brush_object_list::render() { Objects.erase(Objects.begin() + idx); } - if (ImGui::Button("Remove all") && idx >= 0 && idx < Objects.size()) + if (ImGui::Button("Remove all")) { Objects.clear(); } } - ImGui::End(); } + ImGui::End(); } nodebank_panel::nodebank_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) @@ -535,6 +535,8 @@ void nodebank_panel::render() ImGui::SameLine(); ImGui::RadioButton("Copy to bank", (int *)&mode, COPY); ImGui::SameLine(); + ImGui::RadioButton("Area fill", (int *)&mode, FILL); + ImGui::SameLine(); if (ImGui::Button("Reload Nodebank")) { nodebank_reload(); @@ -598,6 +600,43 @@ const std::string *nodebank_panel::get_active_template() return m_selectedtemplate.get(); } +std::vector nodebank_panel::group_names() const +{ + std::vector names; + for (auto const &entry : m_nodebank) + { + if (entry.second->empty()) + names.emplace_back(entry.first); + else if (names.empty()) + names.emplace_back("(ungrouped)"); // templates listed before the first header + } + return names; +} + +std::vector nodebank_panel::group_templates(std::size_t const Group) const +{ + std::vector templates; + std::size_t group{0}; + bool started{false}; + for (auto const &entry : m_nodebank) + { + if (entry.second->empty()) + { + // header: opens the next group, unless it's the very first entry + if (started) + ++group; + started = true; + continue; + } + started = true; + if (group == Group) + templates.emplace_back(*entry.second); + else if (group > Group) + break; + } + return templates; +} + std::string nodebank_panel::generate_node_label(std::string Input) const { diff --git a/application/editoruipanels.h b/application/editoruipanels.h index 41da7173..50e8bf99 100644 --- a/application/editoruipanels.h +++ b/application/editoruipanels.h @@ -81,7 +81,8 @@ class nodebank_panel : public ui_panel MODIFY, COPY, ADD, - BRUSH + BRUSH, + FILL }; edit_mode mode = MODIFY; @@ -90,6 +91,10 @@ class nodebank_panel : public ui_panel void render() override; void add_template(const std::string &desc); const std::string *get_active_template(); + // nodebank groups (collapsing headers), in file order; entries listed before the first header form an unnamed group + std::vector group_names() const; + // node templates of the group with specified index (as returned by group_names()) + std::vector group_templates(std::size_t const Group) const; private: // methods: