From f9ba30ab3d4c1ee1e278a734a144085e1b110dcb Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Wed, 21 Oct 2026 08:14:16 +0200 Subject: [PATCH] Make copies: along a path (the last picked: a drawn shape's outline, a line, a ring), spread evenly by length, turned as it turns (or not); fry::pathOf / alongPath; tests Co-Authored-By: Claude Opus 5.5 --- src/app.cpp | 56 +++++++++++++++++++++------ src/mesh.h | 11 ++++++ src/modify.cpp | 92 +++++++++++++++++++++++++++++++++++++++++++++ tests/mesh_test.cpp | 23 ++++++++++++ 4 files changed, 171 insertions(+), 11 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index a0e8d51..34d4e4f 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -421,7 +421,8 @@ public: static keel::Cmd copiesCmd("fry_copies", [](const keel::Args& a) { // fry_copies n row dx dy dz | n round angle [joined] if (a.size() < 3) return; auto& c = self->copies_; - c.count = std::stoi(a[1]), c.round = a[2] == "round"; + c.count = std::stoi(a[1]), c.round = a[2] == "round", c.path = a[2] == "path"; + c.follow = std::find(a.begin(), a.end(), "still") == a.end(); if (c.round && a.size() > 3) c.angle = std::stof(a[3]), c.centre = glm::vec3(0.0f); if (!c.round && a.size() > 5) c.step = {std::stof(a[3]), std::stof(a[4]), std::stof(a[5])}; c.joined = a.back() == "joined"; @@ -940,7 +941,7 @@ private: std::string captureUri_; // (a picture of the view going into a picked file) struct CopiesSetting { int count = 3, axis = 2; - bool round = false, joined = false; + bool round = false, joined = false, path = false, follow = true; // (path: along the last picked object) glm::vec3 step{0.3f, 0.0f, 0.0f}, centre{0.0f}; float angle = 360.0f; } copies_; @@ -3842,21 +3843,45 @@ private: void makeCopies() { if (mode_ == Mode::Edit) leaveEdit(); if (selected_.empty()) return say("Pick what to copy first"); - pushUndo(); const CopiesSetting& c = copies_; + // Along a path: the last one picked is the path (its outline, or its line), the others go along it. + std::vector along; + int pathSkip = 0; + if (c.path) { + const Object* path = find(active_); + if (!path || selected_.size() < 2) return say("Pick what to copy, then (Shift) the path last: a drawn shape, a line, a ring..."); + bool loop = false; + Mesh m = path->mesh; + transform(m, objectMatrix(*path)); + const std::vector line = fry::pathOf(m, loop); + along = fry::alongPath(line, loop, c.count); + if (along.empty()) return say("The path has no length to go along"); + // (one already on the path's start: that's the first, the copies after it) + const float near = glm::distance(boundsMin(m), boundsMax(m)) * 1e-3f + 1e-6f; + for (uint32_t id : selected_) + if (const Object* o = find(id); o && id != active_ && glm::distance(o->position, along[0].at) < near) { + along = fry::alongPath(line, loop, c.count + 1); + pathSkip = 1; + break; + } + } + pushUndo(); std::set made = selected_; const std::vector originals = [&] { std::vector v; for (const Object& o : scene_.objects) - if (selected_.count(o.id)) v.push_back(o); + if (selected_.count(o.id) && !(c.path && o.id == active_)) v.push_back(o); return v; }(); const glm::vec3 axis = c.axis == 0 ? glm::vec3(1, 0, 0) : c.axis == 1 ? glm::vec3(0, 1, 0) : glm::vec3(0, 0, 1); for (const Object& o : originals) { Object* joinInto = c.joined ? find(o.id) : nullptr; - for (int k = 1; k <= c.count; ++k) { + for (int k = c.path ? pathSkip : 1; k <= (c.path ? int(along.size()) - 1 : c.count); ++k) { glm::mat4 T(1.0f); - if (c.round) { + if (c.path) { // (its middle onto the point; turned as the path turns from its start) + const glm::quat turn = c.follow ? glm::rotation(along[0].along, along[size_t(k)].along) : glm::quat(1, 0, 0, 0); + T = glm::translate(glm::mat4(1.0f), along[size_t(k)].at) * glm::mat4_cast(turn) * glm::translate(glm::mat4(1.0f), -o.position); + } else if (c.round) { const float a = glm::radians(c.angle) * float(k) / float(c.angle >= 359.99f ? c.count + 1 : c.count); T = glm::translate(glm::mat4(1.0f), c.centre) * glm::rotate(glm::mat4(1.0f), a, axis) * glm::translate(glm::mat4(1.0f), -c.centre); } else @@ -3872,14 +3897,16 @@ private: const std::string name = n.name; n = o, n.id = id, n.name = name, n.locked = false; n.position = glm::vec3(T * glm::vec4(o.position, 1.0f)); - if (c.round) n.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); + if (c.round || c.path) n.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); made.insert(id); } if (joinInto) changedShape(*joinInto); } selected_ = made; touchAll(); - say(std::format("{} cop{} of each made{}", c.count, c.count == 1 ? "y" : "ies", c.joined ? ", joined into it" : "")); + if (c.path) selected_.erase(active_), active_ = selected_.empty() ? 0 : *selected_.rbegin(); // (the path isn't one of them) + const size_t n = c.path ? along.size() - size_t(pathSkip) : size_t(c.count); + say(std::format("{} cop{} of each made{}{}", n, n == 1 ? "y" : "ies", c.path ? " along the path" : "", c.joined ? ", joined into it" : "")); } void copiesWindow() { if (!showCopies_) return; @@ -3889,10 +3916,17 @@ private: const float w = 280; ImGui::SetNextItemWidth(w); ImGui::SliderInt("##count", &c.count, 1, 100, "%d copies"); - int kind = c.round ? 1 : 0; + int kind = c.path ? 2 : c.round ? 1 : 0; ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##kind", &kind, "In a row\0Round in a circle\0")) c.round = kind == 1; - if (!c.round) { + if (ImGui::Combo("##kind", &kind, "In a row\0Round in a circle\0Along a path\0")) c.round = kind == 1, c.path = kind == 2; + if (c.path) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + w); + ImGui::TextDisabled("Pick what to copy, then the path last (Shift+click): a drawn shape's outline, a line, a ring. The copies are spread evenly along it."); + ImGui::PopTextWrapPos(); + ImGui::Checkbox("Turn them as the path turns", &c.follow); + if (const Object* p = selected_.size() >= 2 ? find(active_) : nullptr) ImGui::TextColored(accentText(), "Path: %s", p->name.c_str()); + else ImGui::TextColored(roleColor(keel::ui::Role::DangerText), "No path picked yet"); + } else if (!c.round) { glm::vec3 mm = c.step * 1000.0f; ImGui::SetNextItemWidth(w); if (ImGui::DragFloat3("##step", &mm.x, 1.0f, -1e6f, 1e6f, "%.1f mm")) c.step = mm / 1000.0f; diff --git a/src/mesh.h b/src/mesh.h index c204d06..a0f90dc 100644 --- a/src/mesh.h +++ b/src/mesh.h @@ -156,6 +156,17 @@ void thicken(Mesh& m, float thickness); // edges (or a flat shape's outline) sweep into the walls; a flat shape is its two ends, when the // turn doesn't come back round on itself. Mesh spin(const Mesh& m, const glm::vec3& origin, const glm::vec3& axis, float degrees, int steps, float rise = 0.0f); + +// A shape as a path to put things along: its open edges chained up (the longest run; a flat +// shape's outline, a line's points), or, a closed solid, its biggest face's outline. `closed`: +// it comes back round to its start. +std::vector pathOf(const Mesh& m, bool& closed); +struct PathPoint { + glm::vec3 at, along; // (the way the path goes there, unit length) +}; +// `count` points evenly spaced along it by length (ends included on an open one; on a closed one +// the last isn't on top of the first). +std::vector alongPath(const std::vector& path, bool closed, int count); // Two shapes (in the same space) made one: `b` added to `a`, taken out of it, or only where they // overlap kept. The result goes in `a`; false if nothing's left. enum class Combine { Add, Subtract, Overlap }; diff --git a/src/modify.cpp b/src/modify.cpp index 014d8bd..a574d91 100644 --- a/src/modify.cpp +++ b/src/modify.cpp @@ -153,3 +153,95 @@ Mesh spin(const Mesh& m, const glm::vec3& origin, const glm::vec3& axis, float d } } // namespace fry + +namespace fry { + +std::vector pathOf(const Mesh& m, bool& closed) { + closed = false; + std::map, int> count; + for (const auto& f : m.faces) + if (f.size() == 2) count[{std::min(f[0], f[1]), std::max(f[0], f[1])}] = 1; // (a line's piece) + else + for (size_t i = 0; i < f.size(); ++i) ++count[{std::min(f[i], f[(i + 1) % f.size()]), std::max(f[i], f[(i + 1) % f.size()])}]; + std::map> next; + for (const auto& [e, n] : count) + if (n == 1 && e.first != e.second) next[e.first].push_back(e.second), next[e.second].push_back(e.first); + if (next.empty()) { // (closed all round: the biggest face's outline) + int best = -1; + float area = 0; + for (size_t f = 0; f < m.faces.size(); ++f) { + if (m.faces[f].size() < 3) continue; + glm::vec3 n(0.0f); + const auto& fv = m.faces[f]; + for (size_t i = 0; i < fv.size(); ++i) n += glm::cross(m.verts[size_t(fv[i])], m.verts[size_t(fv[(i + 1) % fv.size()])]); + if (glm::length(n) > area) area = glm::length(n), best = int(f); + } + std::vector out; + if (best >= 0) + for (int v : m.faces[size_t(best)]) out.push_back(m.verts[size_t(v)]); + closed = out.size() >= 3; + return out; + } + // Each run of edges, walked from an end (or anywhere on a loop); the longest kept. + std::set> used; + std::vector best; + bool bestClosed = false; + float bestLength = -1; + auto walk = [&](int from) { + std::vector chain{from}; + int at = from, prev = -1; + bool loop = false; + for (;;) { + int go = -1; + for (int n : next[at]) + if (n != prev && !used.count({std::min(at, n), std::max(at, n)})) { + go = n; + break; + } + if (go < 0) break; + used.insert({std::min(at, go), std::max(at, go)}); + if (go == from) { + loop = true; + break; + } + chain.push_back(go), prev = at, at = go; + } + float length = 0; + for (size_t i = 1; i < chain.size(); ++i) length += glm::distance(m.verts[size_t(chain[i - 1])], m.verts[size_t(chain[i])]); + if (loop) length += glm::distance(m.verts[size_t(chain.back())], m.verts[size_t(from)]); + if (chain.size() >= 2 && length > bestLength) { + bestLength = length, bestClosed = loop, best.clear(); + for (int v : chain) best.push_back(m.verts[size_t(v)]); + } + }; + for (const auto& [v, ns] : next) + if (ns.size() == 1) walk(v); // (ends first: open runs from their start) + for (const auto& [v, ns] : next) + for (int n : ns) + if (!used.count({std::min(v, n), std::max(v, n)})) walk(v); + closed = bestClosed; + return best; +} + +std::vector alongPath(const std::vector& path, bool closed, int count) { + std::vector out; + if (path.size() < 2 || count < 1) return out; + std::vector p = path; + if (closed) p.push_back(path.front()); + std::vector at(p.size(), 0.0f); // (the length to each point) + for (size_t i = 1; i < p.size(); ++i) at[i] = at[i - 1] + glm::distance(p[i - 1], p[i]); + const float total = at.back(); + if (total < 1e-12f) return out; + for (int k = 0; k < count; ++k) { + const float want = closed ? total * float(k) / float(count) : count == 1 ? 0.0f : total * float(k) / float(count - 1); + size_t i = size_t(std::upper_bound(at.begin(), at.end(), want) - at.begin()); + i = std::clamp(i, 1, p.size() - 1); + while (i < p.size() - 1 && at[i] - at[i - 1] < 1e-12f) ++i; // (no way along a piece with no length) + const float piece = std::max(at[i] - at[i - 1], 1e-12f), t = std::clamp((want - at[i - 1]) / piece, 0.0f, 1.0f); + const glm::vec3 d = p[i] - p[i - 1]; + out.push_back({glm::mix(p[i - 1], p[i], t), glm::length(d) > 1e-12f ? glm::normalize(d) : glm::vec3(1, 0, 0)}); + } + return out; +} + +} // namespace fry diff --git a/tests/mesh_test.cpp b/tests/mesh_test.cpp index 40d8419..e7ea081 100644 --- a/tests/mesh_test.cpp +++ b/tests/mesh_test.cpp @@ -709,6 +709,29 @@ int main() { CHECK(!held.empty() && std::abs(boundsMax(g).z - 0.8f) < 0.01f && closed(g), "grab pulls the middle up with it (%.3f)", boundsMax(g).z); } + { // copies along a path: a square's outline, a line, a cube's biggest face + Mesh sq; + sq.verts = {{0, 0, 0}, {2, 0, 0}, {2, 2, 0}, {0, 2, 0}}; + sq.faces = {{0, 1, 2, 3}}; + bool loop = false; + const auto p = pathOf(sq, loop); + const auto pts = alongPath(p, loop, 8); + bool even = pts.size() == 8; + for (size_t i = 0; i < pts.size() && even; ++i) even = glm::distance(pts[i].at, pts[(i + 1) % pts.size()].at) > 0.99f && glm::distance(pts[i].at, pts[(i + 1) % pts.size()].at) < 1.42f; + CHECK(loop && p.size() == 4 && even, "a square's outline is a closed path, 8 points 1 apart round it"); + Mesh line; + line.verts = {{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {1, 1, 0}, {3, 1, 0}}; + line.faces = {{0, 1}, {1, 2}, {3, 4}, {2, 3}}; + const auto lp = pathOf(line, loop); + const auto lpts = alongPath(lp, loop, 5); + CHECK(!loop && lp.size() == 5 && lpts.size() == 5 && glm::distance(lpts.front().at, glm::vec3(0)) < 1e-5f && glm::distance(lpts.back().at, glm::vec3(3, 1, 0)) < 1e-5f && + std::abs(lpts[2].at.x - 1) < 1e-5f && std::abs(lpts[2].at.y - 1) < 1e-5f && lpts[4].along == glm::vec3(1, 0, 0), + "a line (a piece with no length in it): open, both ends, the middle at its corner, going its way"); + const auto cp = pathOf(makeCube(1.0f), loop); + CHECK(loop && cp.size() == 4, "a closed box: its biggest face's outline"); + CHECK(alongPath({}, false, 4).empty() && alongPath({glm::vec3(1), glm::vec3(1)}, false, 3).empty(), "no path, or one with no length: nothing"); + } + std::printf("%s (%d failure%s)\n", g_failures ? "FAILED" : "passed", g_failures, g_failures == 1 ? "" : "s"); return g_failures ? 1 : 0; }