From 1a09c1715575969b21719b830113d821290be4ba Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Wed, 21 Oct 2026 00:51:43 +0200 Subject: [PATCH] Spin / screw: an outline or open surface turned round X, Y or Z (its own origin or the world's), any angle, rising for a screw; tests (Pappus, cone, screw) Co-Authored-By: Claude Opus 5.5 --- src/app.cpp | 62 ++++++++++++++++++++++++++++++++++++++++++++- src/mesh.h | 5 ++++ src/modify.cpp | 48 +++++++++++++++++++++++++++++++++++ tests/mesh_test.cpp | 17 +++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/app.cpp b/src/app.cpp index a069678..1b67a40 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -586,6 +586,11 @@ public: static keel::Cmd text("fry_text", [](const keel::Args& a) { if (a.size() > 1) std::snprintf(self->textBuf_, sizeof self->textBuf_, "%s", a[1].c_str()), self->addText(); }, "Fry3DX: add text as a shape"); + static keel::Cmd spinCmd("fry_spin", [](const keel::Args& a) { // fry_spin degrees steps [rise_mm] [world] + if (a.size() > 2) self->spinDegrees_ = std::stof(a[1]), self->spinSteps_ = std::stoi(a[2]); + self->spinRise_ = a.size() > 3 ? std::stof(a[3]) / 1000 : 0.0f, self->spinWorld_ = a.size() > 4 && a[4] == "world"; + self->spinSelected(); + }, "Fry3DX: spin the picked shape round Z: degrees steps [rise mm] [world]"); static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window"); static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) { if (a.size() > 1 && a[1] == "reload") self->loadPlugins(); @@ -715,6 +720,7 @@ public: thickenWindow(); textWindow(); printSizeWindow(); + spinWindow(); pushWindow(); saveQuestion(); recoverWindow(); @@ -3805,6 +3811,60 @@ private: if (glm::length(hi - lo) > 0) frameSelected(); } + // ── Spin: an outline turned round an axis (a vase, a wheel, a ring), or a screw ── + bool showSpin_ = false, spinWorld_ = false, spinKeep_ = false; + int spinAxis_ = 2, spinSteps_ = 48; + float spinDegrees_ = 360.0f, spinRise_ = 0.0f; + void spinWindow() { + if (!showSpin_) return; + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (!ImGui::Begin("Spin", &showSpin_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); + const float w = 300; + ImGui::PushTextWrapPos(w); + ImGui::TextUnformatted("The picked shape turned round an axis: draw half a vase's outline beside it, spin it all the way round. Its open edges become the walls."); + ImGui::PopTextWrapPos(); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##axis", &spinAxis_, "Round X (across)\0Round Y (deep)\0Round Z (up)\0"); + ImGui::SetNextItemWidth(w); + int where = spinWorld_; + if (ImGui::Combo("##where", &where, "Through the shape's own origin\0Through the middle of the world\0")) spinWorld_ = where; + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##deg", &spinDegrees_, 1.0f, -3600, 3600, "%.0f degrees"); + ImGui::SetNextItemWidth(w); + ImGui::DragInt("##steps", &spinSteps_, 0.5f, 3, 512, "%d steps"); + float mm = spinRise_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##rise", &mm, 0.5f, -1e5f, 1e5f, mm == 0 ? "no rise (a spin)" : "rises %.1f mm (a screw)")) spinRise_ = mm / 1000; + ImGui::Checkbox("Keep the outline too", &spinKeep_); + pushAccent(); + if (ImGui::Button("Spin it", ImVec2(w, 0))) spinSelected(); + popAccent(); + ImGui::End(); + } + void spinSelected() { + Object* o = find(active_); + if (!o || !selected_.count(o->id)) return say("Pick the shape to spin first"); + if (o->locked) return say(o->name + " is locked"); + if (checkForPrint(shownMesh(*o)).openEdges == 0) + return say("Spin turns an outline or an open surface (a drawn shape, half a vase's side): this one is closed all round"); + if (mode_ == Mode::Edit) leaveEdit(); + pushUndo(); + Mesh world = shownMesh(*o); + transform(world, objectMatrix(*o)); + glm::vec3 axis(0.0f); + axis[spinAxis_] = 1.0f; + Mesh m = fry::spin(world, spinWorld_ ? glm::vec3(0.0f) : o->position, axis, spinDegrees_, spinSteps_, spinRise_); + if (m.faces.empty()) return takeBack(), say("Nothing to spin"); + transform(m, glm::translate(glm::mat4(1.0f), -o->position)); + Object& made = spinKeep_ ? addObject(o->name + " spun", Mesh{}) : *o; + if (spinKeep_) made.position = find(active_)->position, made.color = find(active_)->color; + made.mesh = std::move(m), made.rotation = glm::quat(1, 0, 0, 0), made.scale = glm::vec3(1.0f); + made.mirror = 0, made.round = 0, made.smoothLevels = 0; // (spun as it showed: its finish is in the shape now) + handMade(made), touched(made.id); + selected_ = {made.id}, active_ = made.id; + say(std::format("Spun {:.0f} degrees{}: {} faces", spinDegrees_, spinRise_ != 0 ? std::format(", rising {:.1f} mm", spinRise_ * 1000) : "", made.mesh.faces.size())); + } + // ── Text as a shape ── bool showText_ = false; char textBuf_[512] = "Fry3DX"; @@ -7763,7 +7823,7 @@ private: } // ── (being made) ── - bool showSpin_ = false, showPrintSize_ = false, liveMirror_ = false; + bool showPrintSize_ = false, liveMirror_ = false; void repairSelected() {} void startSlide() {} diff --git a/src/mesh.h b/src/mesh.h index 7aa58c9..a0fe19a 100644 --- a/src/mesh.h +++ b/src/mesh.h @@ -150,6 +150,11 @@ int mergeFlatFaces(Mesh& m, float angle = 0.5f); // Made `thickness` thick: a surface gets a back and walls round its open edges; a closed shape // gets an inside (hollow, walls that thick). Negative: the new side outwards instead. void thicken(Mesh& m, float thickness); +// Turned round an axis (through `origin`, along `axis`) by `degrees`, in `steps` pieces, rising +// `rise` along the axis as it goes (a screw; 0: a plain spin): what it sweeps through. Its open +// 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); // 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 0ee9be9..014d8bd 100644 --- a/src/modify.cpp +++ b/src/modify.cpp @@ -1,6 +1,8 @@ // Whole-shape changes: flat faces joined up, and a surface made thick. #include "mesh.h" +#include + #include #include #include @@ -104,4 +106,50 @@ void thicken(Mesh& m, float thickness) { for (auto& f : m.faces) std::reverse(f.begin(), f.end()); } +Mesh spin(const Mesh& m, const glm::vec3& origin, const glm::vec3& axis, float degrees, int steps, float rise) { + if (m.verts.empty() || glm::length(axis) < 1e-9f) return m; + const glm::vec3 ax = glm::normalize(axis); + steps = std::clamp(steps, 1, 4096); + const bool round = std::abs(std::abs(degrees) - 360.0f) < 1e-3f && std::abs(rise) < 1e-9f; // (back where it started: no ends) + const int copies = round ? steps : steps + 1; + const int n = int(m.verts.size()); + Mesh out; + out.verts.reserve(size_t(n * copies)); + for (int k = 0; k < copies; ++k) { + const float t = float(k) / float(steps); + const glm::mat4 turn = glm::translate(glm::mat4(1.0f), origin + ax * (rise * t)) * glm::rotate(glm::mat4(1.0f), glm::radians(degrees * t), ax) * glm::translate(glm::mat4(1.0f), -origin); + for (const glm::vec3& v : m.verts) out.verts.push_back(glm::vec3(turn * glm::vec4(v, 1.0f))); + } + auto at = [&](int k, int v) { return (k % copies) * n + v; }; + // What sweeps: edges with one face (a surface's rim, a flat shape's outline), each once. + std::map, int> count; + for (const auto& f : m.faces) + 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()])}]; + for (const auto& f : m.faces) + for (size_t i = 0; i < f.size(); ++i) { + const int a = f[i], b = f[(i + 1) % f.size()]; + if (count[{std::min(a, b), std::max(a, b)}] != 1) continue; + for (int k = 0; k < steps; ++k) out.faces.push_back({at(k, b), at(k, a), at(k + 1, a), at(k + 1, b)}); + } + // A closed shape turned: its own faces at each end too (it sweeps a solid). A surface: its two ends. + if (!round) + for (const auto& f : m.faces) { + std::vector first, last; + for (int v : f) first.push_back(at(0, v)), last.push_back(at(steps, v)); + out.faces.push_back(first); + std::reverse(last.begin(), last.end()); + out.faces.push_back(last); + } + // Points on the axis come to the same place each step: one point, and faces left with fewer than three gone. + mergeByDistance(out, 1e-6f); + for (auto& f : out.faces) { + f.erase(std::unique(f.begin(), f.end()), f.end()); + while (f.size() > 1 && f.front() == f.back()) f.pop_back(); + } + std::erase_if(out.faces, [](const std::vector& f) { return f.size() < 3; }); + removeUnused(out); + faceOutward(out); + return out; +} + } // namespace fry diff --git a/tests/mesh_test.cpp b/tests/mesh_test.cpp index afd79f5..d46373a 100644 --- a/tests/mesh_test.cpp +++ b/tests/mesh_test.cpp @@ -365,6 +365,23 @@ int main() { CHECK(combine(a, drill, Combine::Subtract) && checkForPrint(a).closed() && checkForPrint(a).volume < 1.0f && checkForPrint(a).volume > 0.85f, "a cylinder drilled through a cube: closed, %g m3", checkForPrint(a).volume); } + // Spin: a square beside the axis turned all the way round is a ring (Pappus: its area times + // how far its middle goes); a quarter turn has ends; a screw rises. + { + const float kPi = 3.14159265f; + Mesh sq{{{0.5f, 0, 0}, {1.0f, 0, 0}, {1.0f, 0, 0.5f}, {0.5f, 0, 0.5f}}, {{0, 1, 2, 3}}}; + const Mesh ring = spin(sq, glm::vec3(0), glm::vec3(0, 0, 1), 360, 96); + const float want = 0.25f * 2 * kPi * 0.75f * std::sin(kPi / 96) / (kPi / 96); + CHECK(checkForPrint(ring).closed() && std::abs(checkForPrint(ring).volume - want) < want * 2e-3f, "a square spun round: a ring (%.4f m3, want %.4f)", checkForPrint(ring).volume, want); + const Mesh quarter = spin(sq, glm::vec3(0), glm::vec3(0, 0, 1), 90, 24); + CHECK(checkForPrint(quarter).closed() && checkForPrint(quarter).volume > 0 && std::abs(checkForPrint(quarter).volume - want / 4) < want * 0.01f, "a quarter turn: closed, its ends on (%.4f)", checkForPrint(quarter).volume); + const Mesh screw = spin(sq, glm::vec3(0), glm::vec3(0, 0, 1), 720, 96, 2.0f); + CHECK(checkForPrint(screw).closed() && std::abs(boundsMax(screw).z - 2.5f) < 1e-4f, "a screw: two turns up 2 m, closed (top %.3f)", boundsMax(screw).z); + Mesh tri{{{0, 0, 0}, {0.5f, 0, 0}, {0, 0, 1}}, {{0, 1, 2}}}; // (a side on the axis: a cone) + const Mesh cone = spin(tri, glm::vec3(0), glm::vec3(0, 0, 1), 360, 64); + const float coneWant = kPi * 0.25f / 3 * std::sin(2 * kPi / 64) / (2 * kPi / 64); // (a 64-sided cone's) + CHECK(checkForPrint(cone).closed() && std::abs(checkForPrint(cone).volume - coneWant) < coneWant * 1e-3f, "a triangle on the axis spun: a closed cone (%.4f, want %.4f)", checkForPrint(cone).volume, coneWant); + } // Text as a shape: letters with holes (O, A, B) come out closed, as tall as asked. { std::ifstream in(FRY_FONTS_DIR "/InstrumentSans-Bold.ttf", std::ios::binary);