Combining with Manifold (exact; closed shapes): faces come back whole, flat neighbours from the two shapes joined; the BSP still for open shapes; libraries pinned static (BUILD_SHARED_LIBS off before any); tests for touching, flush and itself

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-21 08:28:43 +02:00
commit b95e436d10
4 changed files with 141 additions and 1 deletions

View file

@ -38,6 +38,9 @@ else()
set(FRY_CONTENT_DIR ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
endif()
# Libraries are linked in, not .so files beside the program (SDL says for itself). Set before
# any of them, so one that has its own BUILD_SHARED_LIBS option (Manifold) can't turn it on.
set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build the libraries as shared ones" FORCE)
include(cmake/Dependencies.cmake)
include(cmake/Shaders.cmake)

View file

@ -1,8 +1,22 @@
# Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the
# tests use too.
# Manifold (github.com/elalish/manifold): exact, robust combining of closed shapes. Just the
# library: no threads (TBB), no 2D, no bindings.
include(FetchContent)
set(MANIFOLD_PAR OFF CACHE BOOL "" FORCE)
set(MANIFOLD_CROSS_SECTION OFF CACHE BOOL "" FORCE)
set(MANIFOLD_TEST OFF CACHE BOOL "" FORCE)
set(MANIFOLD_PYBIND OFF CACHE BOOL "" FORCE)
set(MANIFOLD_CBIND OFF CACHE BOOL "" FORCE)
set(MANIFOLD_JSBIND OFF CACHE BOOL "" FORCE)
set(MANIFOLD_EXPORT OFF CACHE BOOL "" FORCE)
FetchContent_Declare(manifold GIT_REPOSITORY https://github.com/elalish/manifold.git
GIT_TAG v3.0.1 GIT_SHALLOW ON SYSTEM)
FetchContent_MakeAvailable(manifold)
add_library(fry_core STATIC mesh.cpp bvh.cpp text.cpp import.cpp tools.cpp cut.cpp uv.cpp image.cpp sticker.cpp shapes.cpp scene.cpp package.cpp export.cpp print.cpp boolean.cpp modify.cpp sculpt.cpp)
target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(fry_core PUBLIC glm::glm libzstd_static PRIVATE stb cgltf)
target_link_libraries(fry_core PUBLIC glm::glm libzstd_static PRIVATE stb cgltf manifold)
target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL)
# Scripts (Lua) that build and change the scene: also without a window, for the tests.
add_library(fry_script STATIC script.cpp)

View file

@ -3,10 +3,14 @@
// shared, points that land on another piece's edge set into it (so it stays closed all round).
#include "mesh.h"
#include <manifold/manifold.h>
#include <algorithm>
#include <cmath>
#include <map>
#include <functional>
#include <memory>
#include <optional>
#include <set>
namespace fry {
@ -330,9 +334,109 @@ Mesh combineOnce(const Mesh& am, const Mesh& b, Combine op) {
return a;
}
// Manifold's way, for two closed shapes: exact, and doesn't go wrong where faces just touch.
// Each triangle carries the face it came from, so its faces come back whole (not triangles).
// Nothing if either isn't closed all round the way it needs (then the BSP's way).
std::optional<Mesh> combineManifold(const Mesh& a, const Mesh& b, Combine op) {
auto toManifold = [](const Mesh& m, uint32_t firstFace) -> std::optional<manifold::Manifold> {
manifold::MeshGL g;
g.numProp = 3;
g.vertProperties.reserve(m.verts.size() * 3);
for (const glm::vec3& v : m.verts) g.vertProperties.insert(g.vertProperties.end(), {v.x, v.y, v.z});
for (size_t f = 0; f < m.faces.size(); ++f) {
if (m.faces[f].size() < 3) return std::nullopt; // (a line: not a solid)
for (const auto& t : triangulate(m, int(f)))
g.triVerts.insert(g.triVerts.end(), {uint32_t(t[0]), uint32_t(t[1]), uint32_t(t[2])}), g.faceID.push_back(firstFace + uint32_t(f));
}
if (g.triVerts.empty()) return std::nullopt;
g.runOriginalID = {manifold::Manifold::ReserveIDs(1)}; // (so it keeps the faces given, not its own numbering)
g.runIndex = {0, uint32_t(g.triVerts.size())};
g.Merge(); // (points in the same place, kept apart by a seam: one)
manifold::Manifold out(g);
if (out.Status() != manifold::Manifold::Error::NoError) return std::nullopt;
return out;
};
const auto A = toManifold(a, 0), B = toManifold(b, uint32_t(a.faces.size()));
if (!A || !B) return std::nullopt;
const manifold::Manifold r = op == Combine::Add ? *A + *B : op == Combine::Subtract ? *A - *B : *A ^ *B;
if (r.Status() != manifold::Manifold::Error::NoError) return std::nullopt;
const manifold::MeshGL g = r.GetMeshGL();
Mesh out;
const size_t n = g.NumVert();
out.verts.reserve(n);
for (size_t i = 0; i < n; ++i) out.verts.push_back({g.vertProperties[i * g.numProp], g.vertProperties[i * g.numProp + 1], g.vertProperties[i * g.numProp + 2]});
// The triangles of each face, joined back into it: its outline, walked round (a face cut into
// two pieces, or with a hole in it: as it comes, triangles).
// A face of one shape lying flat against a face of the other, side by side (both cubes' tops
// in a row): one face. A shape's own faces stay as they were.
const size_t tris = g.triVerts.size() / 3;
const uint32_t split = uint32_t(a.faces.size());
std::vector<uint32_t> id(tris);
for (size_t t = 0; t < tris; ++t) id[t] = t < g.faceID.size() ? g.faceID[t] : uint32_t(0x80000000u + t);
std::map<uint32_t, uint32_t> parent;
std::function<uint32_t(uint32_t)> root = [&](uint32_t x) {
auto it = parent.find(x);
if (it == parent.end() || it->second == x) return x;
return it->second = root(it->second);
};
std::map<uint32_t, glm::vec4> plane; // (each face's: its normal and how far along it)
for (size_t t = 0; t < tris; ++t) {
if (plane.count(id[t])) continue;
const glm::vec3 p0 = out.verts[g.triVerts[t * 3]], p1 = out.verts[g.triVerts[t * 3 + 1]], p2 = out.verts[g.triVerts[t * 3 + 2]];
const glm::vec3 c = glm::cross(p1 - p0, p2 - p0);
if (glm::length(c) > 1e-12f) plane[id[t]] = glm::vec4(glm::normalize(c), glm::dot(glm::normalize(c), p0));
}
float size = 0;
for (const glm::vec3& v : out.verts) size = std::max(size, std::max(std::abs(v.x), std::max(std::abs(v.y), std::abs(v.z))));
std::map<std::pair<int, int>, size_t> edgeTri;
for (size_t t = 0; t < tris; ++t)
for (int k = 0; k < 3; ++k) edgeTri[{int(g.triVerts[t * 3 + size_t(k)]), int(g.triVerts[t * 3 + size_t((k + 1) % 3)])}] = t;
for (const auto& [e, t] : edgeTri) {
auto o = edgeTri.find({e.second, e.first});
if (o == edgeTri.end()) continue;
const uint32_t x = id[t], y = id[o->second];
if (x == y || (x < split) == (y < split) || !plane.count(x) || !plane.count(y)) continue;
const glm::vec4 px = plane[x], py = plane[y];
if (glm::dot(glm::vec3(px), glm::vec3(py)) > 1 - 1e-5f && std::abs(px.w - py.w) < 1e-5f * std::max(size, 1.0f)) {
const uint32_t rx = root(x), ry = root(y);
if (rx != ry) parent[rx] = ry, parent.emplace(ry, ry);
}
}
std::map<uint32_t, std::vector<std::array<int, 3>>> faces;
for (size_t t = 0; t < tris; ++t) faces[root(id[t])].push_back({int(g.triVerts[t * 3]), int(g.triVerts[t * 3 + 1]), int(g.triVerts[t * 3 + 2])});
for (const auto& [id, ts] : faces) {
std::set<std::pair<int, int>> inside;
for (const auto& t : ts)
for (int k = 0; k < 3; ++k) inside.insert({t[size_t(k)], t[size_t((k + 1) % 3)]});
std::map<int, int> next;
bool simple = true;
for (const auto& [p, q] : inside)
if (!inside.count({q, p})) simple &= next.emplace(p, q).second; // (an outline edge)
std::vector<int> loop;
if (simple && !next.empty()) {
int at = next.begin()->first;
do {
loop.push_back(at);
auto it = next.find(at);
if (it == next.end() || loop.size() > next.size()) break;
at = it->second;
} while (at != loop.front());
}
if (simple && loop.size() == next.size() && loop.size() >= 3) out.faces.push_back(loop);
else
for (const auto& t : ts) out.faces.push_back({t[0], t[1], t[2]});
}
return out;
}
} // namespace
bool combine(Mesh& a, const Mesh& b, Combine op) {
if (auto m = combineManifold(a, b, op)) {
a = std::move(*m);
return !a.faces.empty();
}
Mesh out = combineOnce(a, b, op);
// Closed going in but not coming out: the rounding went wrong somewhere. Again with the
// other shape moved a few µm (nothing that shows), and the first that comes out closed kept.

View file

@ -709,6 +709,25 @@ 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);
}
{ // combining where faces lie on each other (the BSP's weak spot): touching, flush, the same
Mesh a = makeCube(1.0f), b = makeCube(1.0f);
transform(b, glm::translate(glm::mat4(1.0f), glm::vec3(1, 0, 0)));
bool ok = combine(a, b, Combine::Add);
CHECK(ok && checkForPrint(a).closed() && std::abs(checkForPrint(a).volume - 2.0f) < 1e-4f && a.faces.size() == 6, "two cubes side by side: one 2 m3 block, 6 faces (%zu)", a.faces.size());
Mesh c = makeCube(1.0f), d = makeCube(0.5f);
transform(d, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, 0.25f))); // (its top flush with the cube's)
ok = combine(c, d, Combine::Subtract);
CHECK(ok && checkForPrint(c).closed() && std::abs(checkForPrint(c).volume - 0.875f) < 1e-4f, "a pocket flush with the top: 0.875 m3, closed (%.4f)", checkForPrint(c).volume);
Mesh e = makeCube(1.0f);
CHECK(!combine(e, makeCube(1.0f), Combine::Subtract), "a cube take itself: nothing left");
Mesh s1 = makeSphere(32, 16, 0.5f), s2 = makeSphere(32, 16, 0.5f);
transform(s2, glm::translate(glm::mat4(1.0f), glm::vec3(0.3f, 0.1f, 0.05f)));
const auto t0 = std::chrono::steady_clock::now();
ok = combine(s1, s2, Combine::Subtract);
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
CHECK(ok && checkForPrint(s1).closed(), "a sphere bitten by another: closed (%zu faces, %.0f ms)", s1.faces.size(), ms);
}
{ // editable cuts: a box with a bar through it, the bar still an object
Scene sc;
Object box, bar;