From f5d1b7975641d36dcdd69c92dfb84f2c23328d6a Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Wed, 21 Oct 2026 07:34:47 +0200 Subject: [PATCH] Repair for printing (joins loose corners, drops empty and doubled faces and inside walls, turns faces out, closes holes): a button in Print and a command; joining points by a grid (an imported STL's 100k corners: milliseconds, not minutes); tests Co-Authored-By: Claude Opus 5.5 --- src/app.cpp | 37 +++++++++++++++++++++++++++++- src/mesh.cpp | 24 ++++++++++++++----- src/mesh.h | 8 +++++++ src/print.cpp | 56 +++++++++++++++++++++++++++++++++++++++++++++ tests/mesh_test.cpp | 41 +++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 7 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 1b67a40..9eeb4a7 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -3334,6 +3334,12 @@ private: } ImGui::SeparatorText("Ready to print?"); + if (!all.closed() || all.insideOut || all.tinyFaces) { + pushAccent(); + if (ImGui::Button("Repair it (all at once)", ImVec2(w, 0))) repairSelected(); + popAccent(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Joins loose corners, takes away empty and doubled faces, turns faces outwards, closes holes"); + } if (all.openEdges) { bad(std::format("Not closed: {} edges have a face on one side only (holes). A slicer may fill them wrongly or not print it.", all.openEdges)); if (ImGui::Button("Close the holes", ImVec2(w, 0))) { @@ -7824,7 +7830,36 @@ private: // ── (being made) ── bool showPrintSize_ = false, liveMirror_ = false; - void repairSelected() {} + // Each picked object (or all, in the Print tab with none picked) made ready to print. + void repairSelected() { + std::vector targets; + if (tab_ == Tab::Print) targets = printTargets(); + else + for (Object& o : scene_.objects) + if (selected_.count(o.id)) targets.push_back(&o); + std::erase_if(targets, [](const Object* o) { return o->locked; }); + if (targets.empty()) return say("Pick what to repair first"); + if (mode_ == Mode::Edit) leaveEdit(); + pushUndo(); + fry::Repair all; + int still = 0; + for (Object* o : targets) { + const fry::Repair r = fry::repairForPrint(o->mesh); + all.joinedPoints += r.joinedPoints, all.emptyFaces += r.emptyFaces, all.doubledFaces += r.doubledFaces, all.turnedFaces += r.turnedFaces, all.closedHoles += r.closedHoles; + if (r.total()) handMade(*o), touched(o->id); + still += !checkForPrint(o->mesh).closed(); + } + if (!all.total()) return takeBack(), say(still ? "Nothing it can repair by itself: look at what the Print tab says" : "Nothing to repair: ready to print"); + std::vector did; + if (all.joinedPoints) did.push_back(std::format("{} points joined", all.joinedPoints)); + if (all.emptyFaces) did.push_back(std::format("{} empty faces gone", all.emptyFaces)); + if (all.doubledFaces) did.push_back(std::format("{} doubled faces gone", all.doubledFaces)); + if (all.turnedFaces) did.push_back(std::format("{} faces turned out", all.turnedFaces)); + if (all.closedHoles) did.push_back(std::format("{} holes closed", all.closedHoles)); + std::string text; + for (const std::string& d : did) text += (text.empty() ? "" : ", ") + d; + say("Repaired: " + text + (still ? std::format(". {} still not closed (see the Print tab)", still) : ". Ready to print")); + } void startSlide() {} std::string workspaceDir() const { return keel::prefPath() + "workspaces/"; } diff --git a/src/mesh.cpp b/src/mesh.cpp index 3c845c7..6738a64 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace fry { @@ -376,13 +377,24 @@ bool makeFace(Mesh& m, const std::vector& verts, const glm::vec3& towards) int mergeByDistance(Mesh& m, float distance) { std::vector to(m.verts.size(), -1); std::vector kept; + // (kept points in a grid of cubes `distance` big: one is looked for in its own and the 26 + // round it, not among all of them; an imported STL has hundreds of thousands) + const float cell = std::max(distance, 1e-9f); + auto cellOf = [&](const glm::vec3& p) { return glm::ivec3(glm::floor(p / cell)); }; + auto key = [](glm::ivec3 c) { return (uint64_t(uint32_t(c.x) & 0x1FFFFF) << 42) | (uint64_t(uint32_t(c.y) & 0x1FFFFF) << 21) | uint64_t(uint32_t(c.z) & 0x1FFFFF); }; + std::unordered_map> grid; + grid.reserve(m.verts.size()); for (size_t v = 0; v < m.verts.size(); ++v) { - for (size_t k = 0; k < kept.size(); ++k) - if (glm::distance(kept[k], m.verts[v]) <= distance) { - to[v] = int(k); - break; - } - if (to[v] < 0) to[v] = int(kept.size()), kept.push_back(m.verts[v]); + const glm::ivec3 c = cellOf(m.verts[v]); + int best = -1; + for (int dx = -1; dx <= 1; ++dx) + for (int dy = -1; dy <= 1; ++dy) + for (int dz = -1; dz <= 1; ++dz) + if (auto it = grid.find(key(c + glm::ivec3(dx, dy, dz))); it != grid.end()) + for (int k : it->second) + if ((best < 0 || k < best) && glm::distance(kept[size_t(k)], m.verts[v]) <= distance) best = k; // (the first kept, as before) + if (best < 0) best = int(kept.size()), kept.push_back(m.verts[v]), grid[key(c)].push_back(best); + to[v] = best; } const int gone = int(m.verts.size() - kept.size()); remapFaces(m, to); diff --git a/src/mesh.h b/src/mesh.h index a0fe19a..294c529 100644 --- a/src/mesh.h +++ b/src/mesh.h @@ -178,6 +178,14 @@ bool overhangs(const Mesh& m, int face, float overhang, float bedZ); int closeHoles(Mesh& m); // Turns faces so each piece's all face the same way, outwards; how many were turned. int faceOutward(Mesh& m); +// Made ready to print, as far as it can be without guessing: points in the same place joined (an +// STL's corners come apart), faces with no area gone, faces there twice once (back to back: both +// gone, an inside wall), faces turned the same way, holes closed. What it did. +struct Repair { + int joinedPoints = 0, emptyFaces = 0, doubledFaces = 0, turnedFaces = 0, closedHoles = 0; + int total() const { return joinedPoints + emptyFaces + doubledFaces + turnedFaces + closedHoles; } +}; +Repair repairForPrint(Mesh& m); // ── Its picture's layout ── diff --git a/src/print.cpp b/src/print.cpp index a8dd086..7173fb5 100644 --- a/src/print.cpp +++ b/src/print.cpp @@ -119,4 +119,60 @@ int faceOutward(Mesh& m) { return turned; } +Repair repairForPrint(Mesh& m) { + Repair r; + if (m.verts.empty()) return r; + // (how near is "the same place": a millionth of its size, a micron at least) + const float size = glm::length(boundsMax(m) - boundsMin(m)); + const size_t before = m.verts.size(), faces = m.faces.size(); + mergeByDistance(m, std::max(size * 1e-6f, 1e-6f)); // (it drops faces left with fewer than three points: counted below) + r.joinedPoints = int(before - m.verts.size()); + // Faces with a point twice in a row, fewer than three points, or no area. + const float tiny = std::max(size * size * 1e-12f, 1e-16f); + for (auto& f : m.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(m.faces, [&](const std::vector& f) { return f.size() < 3; }); + for (size_t i = m.faces.size(); i-- > 0;) + if (faceArea(m, int(i)) <= tiny) m.faces.erase(m.faces.begin() + std::ptrdiff_t(i)); + r.emptyFaces = int(faces - m.faces.size()); + // The same face twice: once. Back to back (the same points, the other way round): neither. + std::map, std::vector> same; + for (size_t i = 0; i < m.faces.size(); ++i) { + std::vector key = m.faces[i]; + std::sort(key.begin(), key.end()); + same[key].push_back(i); + } + std::vector drop(m.faces.size(), 0); + auto turn = [](const std::vector& f) { // (the face started at its lowest point: two that turn the same way match) + std::vector g = f; + std::rotate(g.begin(), std::min_element(g.begin(), g.end()), g.end()); + return g; + }; + for (const auto& [key, list] : same) { + if (list.size() < 2) continue; + int ways = 0, other = 0; + const std::vector first = turn(m.faces[list[0]]); + for (size_t i : list) (turn(m.faces[i]) == first ? ways : other)++; + if (ways == other) // (back to back: an inside wall) + for (size_t i : list) drop[i] = 1; + else + for (size_t k = 1; k < list.size(); ++k) drop[list[k]] = 1; + } + size_t at = 0; + for (size_t i = 0; i < m.faces.size(); ++i) + if (!drop[i]) { + if (at != i) m.faces[at] = std::move(m.faces[i]); // (not onto itself: that empties it) + ++at; + } + r.doubledFaces = int(m.faces.size() - at); + m.faces.resize(at); + removeUnused(m); + r.turnedFaces = faceOutward(m); + r.closedHoles = closeHoles(m); + if (r.closedHoles) r.turnedFaces += faceOutward(m); + return r; +} + } // namespace fry diff --git a/tests/mesh_test.cpp b/tests/mesh_test.cpp index d46373a..174bec7 100644 --- a/tests/mesh_test.cpp +++ b/tests/mesh_test.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -365,6 +366,46 @@ 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); } + // Joining points is quick on a big mesh: a sphere in loose triangles, as an STL brings it. + { + const Mesh sphere = makeSphere(200, 100, 1.0f); + Mesh soup; + for (size_t f = 0; f < sphere.faces.size(); ++f) + for (const auto& t : triangulate(sphere, int(f))) { + soup.faces.push_back({int(soup.verts.size()), int(soup.verts.size()) + 1, int(soup.verts.size()) + 2}); + for (int v : t) soup.verts.push_back(sphere.verts[size_t(v)]); + } + const size_t loose = soup.verts.size(); + const auto t0 = std::chrono::steady_clock::now(); + mergeByDistance(soup, 1e-6f); + const double ms = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + CHECK(soup.verts.size() == sphere.verts.size() && ms < 3000, "%zu loose points joined to %zu in %.0f ms", loose, soup.verts.size(), ms); + } + // Repair: a cube as an STL brings it (each face its own corners), one face missing, one + // turned in, one there twice, an inside wall: closed and the right way out after. + { + const Mesh good = makeCube(1.0f); + Mesh bad; + for (size_t f = 0; f < good.faces.size(); ++f) { + if (f == 2) continue; // (missing) + std::vector face; + for (int v : good.faces[f]) face.push_back(int(bad.verts.size())), bad.verts.push_back(good.verts[size_t(v)] + glm::vec3(f == 4 ? 1e-8f : 0.0f)); + if (f == 3) std::reverse(face.begin(), face.end()); // (turned in) + bad.faces.push_back(face); + if (f == 5) bad.faces.push_back(face); // (twice) + } + bad.faces.push_back({0, 1, 1}); // (no area) + const int a = int(bad.verts.size()); + bad.verts.insert(bad.verts.end(), {{-0.5f, 0, -0.5f}, {0.5f, 0, -0.5f}, {0.5f, 0, 0.5f}, {-0.5f, 0, 0.5f}}); + bad.faces.push_back({a, a + 1, a + 2, a + 3}), bad.faces.push_back({a + 3, a + 2, a + 1, a}); // (an inside wall, back to back) + CHECK(!checkForPrint(bad).closed(), "the broken cube is broken"); + const Repair r = repairForPrint(bad); + const PrintCheck c = checkForPrint(bad); + CHECK(c.closed() && !c.insideOut && std::abs(c.volume - 1.0f) < 1e-4f && r.closedHoles == 1 && r.doubledFaces == 3 && r.emptyFaces == 1 && r.joinedPoints > 0, + "repair: closed, outwards, 1 m3 (joined %d, empty %d, doubled %d, turned %d, holes %d)", r.joinedPoints, r.emptyFaces, r.doubledFaces, r.turnedFaces, r.closedHoles); + Mesh fine = makeCube(1.0f); + CHECK(repairForPrint(fine).total() == 0 && fine.faces == good.faces, "a good cube: nothing to repair"); + } // 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. {