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 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-21 00:51:43 +02:00
commit 1a09c17155
4 changed files with 131 additions and 1 deletions

View file

@ -586,6 +586,11 @@ public:
static keel::Cmd text("fry_text", [](const keel::Args& a) { 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(); if (a.size() > 1) std::snprintf(self->textBuf_, sizeof self->textBuf_, "%s", a[1].c_str()), self->addText();
}, "Fry3DX: add text as a shape"); }, "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 about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window");
static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) { static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) {
if (a.size() > 1 && a[1] == "reload") self->loadPlugins(); if (a.size() > 1 && a[1] == "reload") self->loadPlugins();
@ -715,6 +720,7 @@ public:
thickenWindow(); thickenWindow();
textWindow(); textWindow();
printSizeWindow(); printSizeWindow();
spinWindow();
pushWindow(); pushWindow();
saveQuestion(); saveQuestion();
recoverWindow(); recoverWindow();
@ -3805,6 +3811,60 @@ private:
if (glm::length(hi - lo) > 0) frameSelected(); 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 ── // ── Text as a shape ──
bool showText_ = false; bool showText_ = false;
char textBuf_[512] = "Fry3DX"; char textBuf_[512] = "Fry3DX";
@ -7763,7 +7823,7 @@ private:
} }
// ── (being made) ── // ── (being made) ──
bool showSpin_ = false, showPrintSize_ = false, liveMirror_ = false; bool showPrintSize_ = false, liveMirror_ = false;
void repairSelected() {} void repairSelected() {}
void startSlide() {} void startSlide() {}

View file

@ -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 // 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. // gets an inside (hollow, walls that thick). Negative: the new side outwards instead.
void thicken(Mesh& m, float thickness); 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 // 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. // overlap kept. The result goes in `a`; false if nothing's left.
enum class Combine { Add, Subtract, Overlap }; enum class Combine { Add, Subtract, Overlap };

View file

@ -1,6 +1,8 @@
// Whole-shape changes: flat faces joined up, and a surface made thick. // Whole-shape changes: flat faces joined up, and a surface made thick.
#include "mesh.h" #include "mesh.h"
#include <glm/gtc/matrix_transform.hpp>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <map> #include <map>
@ -104,4 +106,50 @@ void thicken(Mesh& m, float thickness) {
for (auto& f : m.faces) std::reverse(f.begin(), f.end()); 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<std::pair<int, int>, 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<int> 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<int>& f) { return f.size() < 3; });
removeUnused(out);
faceOutward(out);
return out;
}
} // namespace fry } // namespace fry

View file

@ -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, 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); "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. // 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); std::ifstream in(FRY_FONTS_DIR "/InstrumentSans-Bold.ttf", std::ios::binary);