17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 02:49:19 +02:00

fix(editor): stuck brush after clicks on UI; add polygon area fill

This commit is contained in:
2026-10-01 01:17:21 +02:00
parent b67bbb19e6
commit 39dd1c057f
6 changed files with 674 additions and 33 deletions

View File

@@ -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<double>(angles.y)), glm::dvec3(0.0, 1.0, 0.0));
if (angles.x != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast<double>(angles.x)), glm::dvec3(1.0, 0.0, 0.0));
if (angles.z != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast<double>(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<glm::dvec3> 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<glm::dvec3> 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<std::string>(false)};
auto texture{tokenizer.getToken<std::string>(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<world_triangle> 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<int>(std::ceil(extent.x / m_cellsize)) + 1;
m_rows = static_cast<int>(std::ceil(extent.y / m_cellsize)) + 1;
m_cells.resize(static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(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<int>(std::floor((X - m_min.x) / m_cellsize)), 0, m_columns - 1);
}
int row(double const Z) const
{
return std::clamp(static_cast<int>(std::floor((Z - m_min.y) / m_cellsize)), 0, m_rows - 1);
}
std::vector<world_triangle> m_triangles;
std::vector<std::vector<std::uint32_t>> 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<world_triangle> &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<double>(angles.y)), glm::dvec3(0.0, 1.0, 0.0));
if (angles.x != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast<double>(angles.x)), glm::dvec3(1.0, 0.0, 0.0));
if (angles.z != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast<double>(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<world_triangle> 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<std::string> package;
if (m_fill_package == 0)
package = m_fill_custom;
else
package = ui()->nodebank_group_templates(static_cast<std::size_t>(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<double>::max());
glm::dvec2 bmax(-std::numeric_limits<double>::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::size_t>(std::min<double>(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<std::int64_t, std::vector<std::size_t>> occupancy;
auto const cell_key = [](std::int64_t const X, std::int64_t const Z) { return (X << 32) ^ (Z & 0xffffffff); };
std::vector<glm::dvec2> 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::int64_t>(std::floor(p.x / spacing));
auto const cz = static_cast<std::int64_t>(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<world_triangle> triangles;
glm::dvec2 const center = (bmin + bmax) * 0.5;
float const radius = static_cast<float>(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<world_triangle> 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<TAnimModel *> 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<double>::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<std::size_t>(LocalRandom(0.0, static_cast<double>(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<float>(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<int>(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<int>(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<int>(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<int>(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<std::size_t>(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);
}
});

View File

@@ -19,6 +19,10 @@ http://mozilla.org/MPL/2.0/.
#include "editor/editorTerrainStreamer.hpp"
#include <memory>
#include <string>
#include <vector>
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<glm::dvec3> m_fill_points; // outline vertices (world space) in click order
std::vector<TAnimModel *> m_fill_last; // instances created by the most recent fill, for "Undo last fill"
std::vector<std::string> 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();

View File

@@ -55,6 +55,16 @@ void editor_ui::toggleBrushSettings(bool isVisible)
m_brushobjects.is_open = isVisible;
}
std::vector<std::string> editor_ui::nodebank_groups() const
{
return m_nodebankpanel.group_names();
}
std::vector<std::string> 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;

View File

@@ -37,6 +37,8 @@ class editor_ui : public ui_layer
nodebank_panel::edit_mode mode();
float getSpacing();
void toggleBrushSettings(bool isVisible);
std::vector<std::string> nodebank_groups() const;
std::vector<std::string> nodebank_group_templates(std::size_t const Group) const;
private:
// members

View File

@@ -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<std::string> nodebank_panel::group_names() const
{
std::vector<std::string> 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<std::string> nodebank_panel::group_templates(std::size_t const Group) const
{
std::vector<std::string> 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
{

View File

@@ -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<std::string> group_names() const;
// node templates of the group with specified index (as returned by group_names())
std::vector<std::string> group_templates(std::size_t const Group) const;
private:
// methods: