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 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-21 07:34:47 +02:00
commit f5d1b79756
5 changed files with 159 additions and 7 deletions

View file

@ -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<Object*> 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<std::string> 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/"; }

View file

@ -4,6 +4,7 @@
#include <cmath>
#include <map>
#include <numeric>
#include <unordered_map>
namespace fry {
@ -376,13 +377,24 @@ bool makeFace(Mesh& m, const std::vector<int>& verts, const glm::vec3& towards)
int mergeByDistance(Mesh& m, float distance) {
std::vector<int> to(m.verts.size(), -1);
std::vector<glm::vec3> 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<uint64_t, std::vector<int>> 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);

View file

@ -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 ──

View file

@ -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<int>& 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<int>, std::vector<size_t>> same;
for (size_t i = 0; i < m.faces.size(); ++i) {
std::vector<int> key = m.faces[i];
std::sort(key.begin(), key.end());
same[key].push_back(i);
}
std::vector<char> drop(m.faces.size(), 0);
auto turn = [](const std::vector<int>& f) { // (the face started at its lowest point: two that turn the same way match)
std::vector<int> 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<int> 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

View file

@ -14,6 +14,7 @@
#include <glm/gtc/matrix_transform.hpp>
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <memory>
#include <fstream>
@ -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<double, std::milli>(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<int> 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.
{