From b95e436d10f4a4cd04bdca987202422a1c6f3633 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Wed, 21 Oct 2026 08:28:43 +0200 Subject: [PATCH] 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 --- CMakeLists.txt | 3 ++ src/CMakeLists.txt | 16 ++++++- src/boolean.cpp | 104 ++++++++++++++++++++++++++++++++++++++++++++ tests/mesh_test.cpp | 19 ++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a8d67bb..dbd60f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 711154d..3c3d0ca 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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) diff --git a/src/boolean.cpp b/src/boolean.cpp index b2e8510..45f3ecc 100644 --- a/src/boolean.cpp +++ b/src/boolean.cpp @@ -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 + #include #include #include +#include #include +#include #include 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 combineManifold(const Mesh& a, const Mesh& b, Combine op) { + auto toManifold = [](const Mesh& m, uint32_t firstFace) -> std::optional { + 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 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 parent; + std::function 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 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, 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>> 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> 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 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 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. diff --git a/tests/mesh_test.cpp b/tests/mesh_test.cpp index 1a48cd4..f89c60a 100644 --- a/tests/mesh_test.cpp +++ b/tests/mesh_test.cpp @@ -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(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;