diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index b1955126da..e49e157358 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -460,10 +460,6 @@ jobs: - name: Build slicer Win if: runner.os == 'Windows' working-directory: ${{ github.workspace }} - # Orca: Removed Netfabb STL fixing service support in favor of CGAL. - # env: - # WindowsSdkDir: 'C:\Program Files (x86)\Windows Kits\10\' - # WindowsSDKVersion: '10.0.26100.0\' # --tests builds the unit tests too; the unit_tests_windows_* jobs run them. run: | # cache-path is the install directory inside the deps build directory. diff --git a/CMakeLists.txt b/CMakeLists.txt index a208c5e7a2..7f1586f222 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -497,47 +497,6 @@ endif() # Once we get rid of Perl and libslic3r is linked statically, we can get rid of -fPIC set(CMAKE_POSITION_INDEPENDENT_CODE ON) -# WIN10SDK_PATH is used to point CMake to the WIN10 SDK installation directory. -# We pick it from environment if it is not defined in another way -# ORCA: Removed Netfabb STL fixing service support in favor of CGAL. -# if(WIN32) -# if(NOT DEFINED WIN10SDK_PATH) -# if(DEFINED ENV{WIN10SDK_PATH}) -# set(WIN10SDK_PATH "$ENV{WIN10SDK_PATH}") -# endif() -# endif() -# if(DEFINED WIN10SDK_PATH) -# #BBS: modify win10sdk_path -# if (EXISTS "${WIN10SDK_PATH}/winrt/windows.graphics.printing3d.h") -# set(WIN10SDK_INCLUDE_PATH "${WIN10SDK_PATH}") -# else() -# message("WIN10SDK_PATH is invalid: ${WIN10SDK_PATH}") -# message("${WIN10SDK_PATH}/winrt/windows.graphics.printing3d.h was not found") -# message("STL fixing by the Netfabb service will not be compiled") -# unset(WIN10SDK_PATH) -# endif() -# else() -# # Try to use the default Windows 10 SDK path. -# if (DEFINED ENV{WindowsSdkDir} AND DEFINED ENV{WindowsSDKVersion}) -# set(WIN10SDK_INCLUDE_PATH "$ENV{WindowsSdkDir}/Include/$ENV{WindowsSDKVersion}") -# else () -# set(WIN10SDK_INCLUDE_PATH "C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0") -# endif () -# if (NOT EXISTS "${WIN10SDK_INCLUDE_PATH}/winrt/windows.graphics.printing3d.h") -# message("${WIN10SDK_INCLUDE_PATH}/winrt/windows.graphics.printing3d.h was not found") -# message("STL fixing by the Netfabb service will not be compiled") -# unset(WIN10SDK_INCLUDE_PATH) -# endif() -# endif() -# if(WIN10SDK_INCLUDE_PATH) -# message("Building with Win10 Netfabb STL fixing service support") -# add_definitions(-DHAS_WIN10SDK) -# include_directories(SYSTEM "${WIN10SDK_INCLUDE_PATH}") -# else() -# message("Building without Win10 Netfabb STL fixing service support") -# endif() -# endif() - if (APPLE) message("OS X SDK Path: ${CMAKE_OSX_SYSROOT}") if (CMAKE_OSX_DEPLOYMENT_TARGET) diff --git a/docs/HLSD/mesh-repair.md b/docs/HLSD/mesh-repair.md new file mode 100644 index 0000000000..145c904769 --- /dev/null +++ b/docs/HLSD/mesh-repair.md @@ -0,0 +1,135 @@ +# Mesh repair — High Level Design + +## Purpose and scope + +Mesh repair turns a broken triangle mesh into closed, outward oriented shells that bound a solid, so that the +mesh slices predictably and the other mesh tools (booleans, cut, painting) get valid input. It runs on demand: +the *Fix Model* command of the context menu of an object or part and of the warning icon in the object list, *Fix +All* and *Fix All Plates* in the context menu of a plate, the prompt the Cut tool shows when a cut leaves open edges, +the *Repair and import* choice of texture import, and the `--repair` option of the command line. It is built on CGAL +and is the only repair the application has, on every platform. + +`repair_mesh()` in `src/libslic3r/MeshRepair.cpp` does the work on one `TriangleMesh`, and `ModelVolume::repair()` +applies it to a part: it replaces the mesh only when the repair changed it, and remaps or drops the painting of a +replaced mesh. The GUI side, `fix_model_with_cgal_gui()` in `src/slic3r/Utils/FixModelByCgal.cpp`, runs it on a +worker thread for each volume of an object, or for one volume, behind a progress dialog. `repair_mesh()` reports its +progress between stages through a callback, which cancels the repair by throwing. + +## What the repaired mesh is + +The repair keeps what the slicer would print from the input. The slicer fills a layer where the winding number of +the input surface is not zero (`MeshSlicingParams::SlicingMode::Regular` uses Clipper's nonzero fill rule), so: + +- Shells crossing each other print as their union. The repair keeps them as separate shells, as the parts of an + object are kept, and makes each of them an outward oriented solid. +- A shell inside a solid that is oriented like it adds nothing, and the repair removes it. +- A shell inside a solid that is oriented inward is a cavity, and stays one. +- A whole mesh turned inside out prints as a solid, and the repair turns it outward. + +On top of that, it closes holes and cracks, gives inconsistently oriented triangles the orientation of their +neighbours, drops degenerate, duplicate and stray flat geometry, and removes small self-intersections inside a shell, +such as slivers and folds. Where none of this applies, it reports the mesh as unmodified and the caller keeps the +original mesh, its painting and its object id. + +A volume is repaired in place and is never split into several parts: splitting by connectivity would turn a cavity +into a separate solid part and fill it. Each solid of the result is a shell of its own, so *Split to parts* still +separates them. Open pieces of the input whose borders meet exactly, which are pieces of one surface rather than +solids, come out as the one shell they form. + +## Pipeline + +The input is treated as a triangle soup. Every stage after the first only runs on what the previous ones could +not settle, so a valid mesh passes through quickly. + +### Valid parts first + +Parts of the input that are connected by vertex indices and are already closed, consistently oriented 2-manifolds +skip the soup stages and go straight to the shell stages. Merging their points with coincident points of other +parts would only make non-manifold junctions out of solids that touch, for example parts of an assembly exported +together or voxels written as separate cubes. + +### Soup clean-up + +The rest of the soup has its coincident points merged. Triangles with repeated corners are dropped. Coincident +triangles of opposite orientation are a wall between two solids touching face to face, so they cancel in pairs; +of the remaining copies of a triangle one is kept. A connected region that would vanish entirely through +cancelling is a two-sided export instead (every face written in both orientations), and keeps one copy of each +triangle. + +Open edges then get their vertices welded within a tolerance of 1e-4 of the bounding box diagonal, which closes the +cracks some exporters leave between triangles that should share corners. A vertex is never welded further than a +quarter of its shortest edge, so welding cannot collapse fine detail. A soup that stays mostly open after welding has +cracks wider than that tolerance, and is welded again with ten and then a hundred times the tolerance. + +### Work units + +The soup is cut into units repaired independently and in parallel: its connected parts, except that open parts whose +bounding boxes touch share a unit, so that cracks and T-junctions between separately exported patches still close. + +### Building the mesh + +A unit whose edges are each shared by at most two triangles, running in opposite directions, and whose vertices each +have a single fan of triangles, becomes a `Surface_mesh` directly. Any other unit is oriented first. Triangles are +flipped so that neighbours across an edge shared by two of them agree, each part keeping the orientation the +majority of its area had, which keeps inward oriented cavities inward. Edges where that is impossible or that more +than two triangles share are cut, and each fan of triangles around a vertex gets its own copy of the vertex. If that +still leaves an invalid mesh, CGAL's `orient_polygon_soup()` orients the unit instead, and the parts it flipped +against their majority are reversed back. The linear pass exists because `orient_polygon_soup()` is several times +slower on large soups that need only a few fixes. + +### Closing + +Compatible borders are stitched, parts that can be stitched once reversed are merged, and borders still open are +snapped onto each other within the weld tolerance, which closes T-junctions. Exactly degenerate faces are removed. + +Holes are filled last. Planar border loops that face each other in one plane, one inside the other, bound a single +region, such as a missing annulus at the end of a tube or a cap with features standing on it. Such a group is +triangulated at once with a 2D constrained Delaunay triangulation that fills only the faces at odd nesting depth, +so the openings inside the region stay open. The patch is used only if it closes every loop with the orientation +of the triangles around it. Every other loop is filled on its own with `triangulate_hole()`, which uses a 2D +constrained Delaunay triangulation when the loop is planar. Otherwise it searches the best triangulation among all +triangles over the loop for loops of up to 100 edges, and among the triangles of a 3D Delaunay triangulation for +longer ones, as the full search is cubic in the length of the loop. Beyond 200 edges the full search is not even +used as a fallback, and a fan around the loop's centroid closes a loop nothing else could. + +The closed unit is split into its shells. The largest stays in place and the others are copied out, as converting +the whole mesh again costs seconds on large models. + +### Shells + +The shells of all units, together with the valid parts set aside at the start, are handled independently and in +parallel. Shells enclosing no volume, which hole filling makes out of stray flat patches, are dropped. + +Self-intersections inside a shell are removed locally with `experimental::remove_self_intersections()`, which +remeshes the region around them. It is used only for local defects: a shell whose intersections involve more than +5 % of its faces is left as it is, and a result that changes the enclosed volume by more than 0.1 % is undone. On +heavily overlapping ornamental models the remeshing otherwise cuts parts down to slivers, while on genuine defects +it changes the volume by thousandths of a percent. Overlaps left in place slice correctly. Remeshing can cut +small pieces off a shell, so a shell it changed is split again and its pieces enclosing no volume are dropped. + +Shells crossing others are found through their bounding boxes and an AABB tree per shell, and are made outward +oriented solids. For every other shell the winding number just outside it is summed from the orientation of the +shells containing it, tested from a point on its largest face. The shell bounds the solid only if the winding +number is zero on exactly one of its sides; it is then oriented so that the solid is inside, or dropped otherwise. + +Crossing shells are not united. Slicing unites them anyway, uniting them exactly costs tens of seconds on models +made of many overlapping pieces, and it would fuse parts that *Split to parts* should keep apart. + +## Robustness + +The repair avoids `experimental::autorefine_and_remove_self_intersections()` and corefinement, which can crash on +messy input, and uses only steps whose failures it can detect. A step that fails keeps its input. CGAL precondition +failures surface as `repair_mesh()` returning false with the message as the error. The output is written in float, +as every mesh in the application is. + +The result does not depend on thread scheduling. `libslic3r_cgal` is built with `CGAL_LINKED_WITH_TBB`. Units and +shells are processed in parallel, but each on its own data, and their results are gathered in a fixed order; +sorts compare complete keys, and the detected self-intersections are put in order before they are used. Snapping +stays sequential, as its parallel version gives different results from run to run. Holes are filled with the full +search wherever it is affordable, because the search among Delaunay triangles can choose between equally good fills +differently from run to run, so only non-planar holes of more than 100 edges may still vary. On some inputs +`experimental::remove_self_intersections()` itself places a few vertices differently from run to run. + +## Verification + +`tests/libslic3r/test_mesh_repair.cpp` covers each kind of defect above on small meshes with known volumes. diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 0d0aea0954..2a158f4b12 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -99,6 +99,7 @@ #include "libslic3r/Preset.hpp" #include "libslic3r/Geometry.hpp" #include "libslic3r/GCode.hpp" +#include "libslic3r/MeshRepair.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/ModelArrange.hpp" #include "libslic3r/Platform.hpp" @@ -5130,9 +5131,19 @@ int CLI::run(int argc, char **argv) } } } else if (opt_key == "repair") { - // Models are repaired by default. - //for (auto &model : m_models) - // model.repair(); + if (m_config.opt_bool(opt_key)) + for (Model &model : m_models) + for (ModelObject *object : model.objects) { + for (ModelVolume *volume : object->volumes) { + MeshRepairStats stats; + std::string error; + if (!volume->repair(true, &stats, &error)) + BOOST_LOG_TRIVIAL(warning) << "repair of " << object->name << " / " << volume->name << " failed: " << error; + else if (stats.modified) + BOOST_LOG_TRIVIAL(info) << "repaired " << object->name << " / " << volume->name; + } + object->invalidate_bounding_box(); + } } else { boost::nowide::cerr << "error: option not implemented yet: " << opt_key << std::endl; record_exit_reson(outfile_dir, CLI_UNSUPPORTED_OPERATION, 0, cli_errors[CLI_UNSUPPORTED_OPERATION], sliced_info); diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 1a7080d5c7..afa3b61bc1 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -625,6 +625,7 @@ add_library(libslic3r_cgal STATIC CutSurface.hpp CutSurface.cpp IntersectionPoints.hpp IntersectionPoints.cpp MeshBoolean.hpp MeshBoolean.cpp + MeshRepair.hpp MeshRepair.cpp TryCatchSignal.hpp TryCatchSignal.cpp Triangulation.hpp Triangulation.cpp ) @@ -652,7 +653,9 @@ if (IS_CLANG_CL) target_compile_options(libslic3r_cgal PRIVATE -Wno-overriding-option) endif () -target_link_libraries(libslic3r_cgal PRIVATE ${_cgal_tgt} admesh libigl mcut boost_libs) +# Lets CGAL algorithms given CGAL::Parallel_if_available_tag run on TBB. +target_compile_definitions(libslic3r_cgal PRIVATE CGAL_LINKED_WITH_TBB) +target_link_libraries(libslic3r_cgal PRIVATE ${_cgal_tgt} admesh libigl mcut boost_libs TBB::tbb) if (MSVC AND "${CMAKE_SIZEOF_VOID_P}" STREQUAL "4") # 32 bit MSVC workaround target_compile_definitions(libslic3r_cgal PRIVATE CGAL_DO_NOT_USE_MPZF) diff --git a/src/libslic3r/Format/STEP.cpp b/src/libslic3r/Format/STEP.cpp index 962c4cffb9..4f40093c73 100644 --- a/src/libslic3r/Format/STEP.cpp +++ b/src/libslic3r/Format/STEP.cpp @@ -236,19 +236,29 @@ static void getNamedSolids(const TopLoc_Location& location, int i = 0; switch (shape_type) { case TopAbs_COMPOUND: - if (!isSplitCompound) { - namedSolids.emplace_back(TopoDS::Compound(transform.Shape()), fullName); - break; - } case TopAbs_COMPSOLID: if (!isSplitCompound) { - namedSolids.emplace_back(TopoDS::CompSolid(transform.Shape()), fullName); + namedSolids.emplace_back(transform.Shape(), fullName); } else { for (explorer.Init(transform.Shape(), TopAbs_SOLID); explorer.More(); explorer.Next()) { i++; const TopoDS_Shape& currentShape = explorer.Current(); namedSolids.emplace_back(TopoDS::Solid(currentShape), fullName + "-SOLID-" + std::to_string(i)); } + // Surface models have no solids: each shell outside a solid is a part, and loose faces stay together. + i = 0; + for (explorer.Init(transform.Shape(), TopAbs_SHELL, TopAbs_SOLID); explorer.More(); explorer.Next()) + namedSolids.emplace_back(explorer.Current(), fullName + "-SHELL-" + std::to_string(++i)); + TopoDS_Compound loose_faces; + TopoDS_Builder builder; + builder.MakeCompound(loose_faces); + bool has_loose_faces = false; + for (explorer.Init(transform.Shape(), TopAbs_FACE, TopAbs_SHELL); explorer.More(); explorer.Next()) { + builder.Add(loose_faces, explorer.Current()); + has_loose_faces = true; + } + if (has_loose_faces) + namedSolids.emplace_back(loose_faces, fullName); } break; case TopAbs_SOLID: diff --git a/src/libslic3r/MeshBoolean.cpp b/src/libslic3r/MeshBoolean.cpp index 37e3c84b3f..074eff3121 100644 --- a/src/libslic3r/MeshBoolean.cpp +++ b/src/libslic3r/MeshBoolean.cpp @@ -29,8 +29,6 @@ #include #include #include -#include -#include #include #include #include @@ -50,7 +48,6 @@ #include #include #include -#include #include // BBS: for segment #include @@ -658,91 +655,6 @@ bool empty(const CGALMesh &mesh) return mesh.m.is_empty(); } -bool repair(TriangleMesh& mesh, RepairedMeshErrors* repaired_errors, std::string* error) -{ - namespace PMP = CGAL::Polygon_mesh_processing; - - if (mesh.empty()) - return true; - - try { - // 1) Convert to polygon soup - std::vector<_EpicMesh::Point> points; - std::vector> polygons; - - points.reserve(mesh.its.vertices.size()); - polygons.reserve(mesh.its.indices.size()); - - for (const auto& v : mesh.its.vertices) - points.emplace_back(v.x(), v.y(), v.z()); - - for (const auto& f : mesh.its.indices) - polygons.push_back({size_t(f[0]), size_t(f[1]), size_t(f[2])}); - - // 2) Aggressive soup cleanup - PMP::repair_polygon_soup(points, polygons); - - // 3) Convert soup → mesh - _EpicMesh cgal_mesh; - PMP::polygon_soup_to_polygon_mesh(points, polygons, cgal_mesh); - - // 4) Remove degenerate geometry - PMP::remove_degenerate_faces(cgal_mesh); - PMP::remove_isolated_vertices(cgal_mesh); - - // 5) Fix remaining non-manifold vertices - PMP::duplicate_non_manifold_vertices(cgal_mesh); - - // 6) Boolean union (keeps only outer shell) - _EpicMesh tmp; - if (PMP::corefine_and_compute_union(cgal_mesh, cgal_mesh, tmp)) { - cgal_mesh = std::move(tmp); - } - // If it fails, continue anyway with previous mesh - - // 7) Fill holes - if (!CGAL::is_closed(cgal_mesh)) { - using halfedge_descriptor = boost::graph_traits<_EpicMesh>::halfedge_descriptor; - - std::vector borders; - CGAL::extract_boundary_cycles(cgal_mesh, std::back_inserter(borders)); - - for (halfedge_descriptor h : borders) { - PMP::triangulate_and_refine_hole(cgal_mesh, h); - } - } - - // 8) Final validity check - if (!CGAL::is_closed(cgal_mesh)) { - if (error) - *error = "Repair failed: mesh still open after hole filling."; - return false; - } - - // 9) Ensure outward orientation - if (!PMP::does_bound_a_volume(cgal_mesh)) - PMP::orient_to_bound_a_volume(cgal_mesh); - - // 10) Convert back - indexed_triangle_set its = cgal_to_indexed_triangle_set(cgal_mesh); - - RepairedMeshErrors errs{}; - errs.facets_removed = 0; - errs.edges_fixed = 0; - - mesh = TriangleMesh(std::move(its), errs); - - if (repaired_errors) - *repaired_errors = errs; - - return true; - } catch (const std::exception& e) { - if (error) - *error = e.what(); - return false; - } -} - CGALMeshPtr clone(const CGALMesh &m) { return CGALMeshPtr{new CGALMesh{m}}; diff --git a/src/libslic3r/MeshBoolean.hpp b/src/libslic3r/MeshBoolean.hpp index 6ced9b4646..b229a4e724 100644 --- a/src/libslic3r/MeshBoolean.hpp +++ b/src/libslic3r/MeshBoolean.hpp @@ -77,9 +77,6 @@ TriangleMesh merge(std::vector meshes); bool does_bound_a_volume(const CGALMesh &mesh); bool empty(const CGALMesh &mesh); -// Repair a mesh using CGAL. Returns true on success. Optionally returns a summary of repairs and an error string. -bool repair(TriangleMesh &mesh, RepairedMeshErrors *repaired_errors = nullptr, std::string *error = nullptr); - // Real UV unwrap of an open mesh patch via CGAL's LSCM (Least Squares Conformal Maps) surface // parameterization. Returns one UV coordinate per input vertex (same indexing as `mesh.vertices`), // or nullopt if `mesh` isn't a single topological disk -- LSCM needs exactly one connected diff --git a/src/libslic3r/MeshRepair.cpp b/src/libslic3r/MeshRepair.cpp new file mode 100644 index 0000000000..df76836109 --- /dev/null +++ b/src/libslic3r/MeshRepair.cpp @@ -0,0 +1,1215 @@ +#include "MeshRepair.hpp" + +#include "I18N.hpp" +#include "TriangleMesh.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +namespace { + +namespace PMP = CGAL::Polygon_mesh_processing; + +using Kernel = CGAL::Exact_predicates_inexact_constructions_kernel; +using Point = Kernel::Point_3; +using Vector = Kernel::Vector_3; +using Mesh = CGAL::Surface_mesh; +using Vertex = Mesh::Vertex_index; +using Halfedge = Mesh::Halfedge_index; +using Face = Mesh::Face_index; +using Triangle = std::array; +using Tree = CGAL::AABB_tree>>; +using Box = CGAL::Box_intersection_d::Box_with_info_d; + +constexpr std::size_t NONE = std::size_t(-1); + +class UnionFind +{ +public: + explicit UnionFind(std::size_t n) : m_parent(n) { std::iota(m_parent.begin(), m_parent.end(), std::size_t(0)); } + + std::size_t find(std::size_t i) + { + while (m_parent[i] != i) + i = m_parent[i] = m_parent[m_parent[i]]; + return i; + } + + // The smaller index becomes the root, so results do not depend on the order of unions. + void unite(std::size_t a, std::size_t b) + { + a = find(a); + b = find(b); + if (a != b) + m_parent[std::max(a, b)] = std::min(a, b); + } + +private: + std::vector m_parent; +}; + +template void erase_marked(Container &items, const Mask &marked) +{ + std::size_t out = 0; + for (std::size_t i = 0; i < items.size(); ++i) + if (!marked[i]) + items[out++] = std::move(items[i]); + items.resize(out); +} + +template void parallel_for_each(std::size_t n, Fn &&fn) +{ + tbb::parallel_for(tbb::blocked_range(0, n, 1), [&fn](const tbb::blocked_range &range) { + for (std::size_t i = range.begin(); i < range.end(); ++i) + fn(i); + }); +} + +// Triangles sharing a vertex belong to one part. +UnionFind connect_vertices(std::size_t num_points, const std::vector &triangles) +{ + UnionFind parts(num_points); + for (const Triangle &t : triangles) { + parts.unite(t[0], t[1]); + parts.unite(t[0], t[2]); + } + return parts; +} + +bool bbox_contains(const CGAL::Bbox_3 &outer, const CGAL::Bbox_3 &inner, double tolerance = 0.) +{ + return outer.xmin() - tolerance <= inner.xmin() && outer.ymin() - tolerance <= inner.ymin() && outer.zmin() - tolerance <= inner.zmin() && + outer.xmax() + tolerance >= inner.xmax() && outer.ymax() + tolerance >= inner.ymax() && outer.zmax() + tolerance >= inner.zmax(); +} + +std::vector> overlapping_boxes(std::vector &boxes) +{ + std::vector> pairs; + CGAL::box_self_intersection_d(boxes.begin(), boxes.end(), [&pairs](const Box &a, const Box &b) { + pairs.emplace_back(std::min(a.info(), b.info()), std::max(a.info(), b.info())); + }, std::ptrdiff_t(10), CGAL::Box_intersection_d::CLOSED); + std::sort(pairs.begin(), pairs.end()); + return pairs; +} + +double bbox_diagonal(const std::vector &points) +{ + CGAL::Bbox_3 bb; + for (const Point &p : points) + bb += p.bbox(); + return points.empty() ? 0. : std::sqrt(CGAL::square(bb.x_span()) + CGAL::square(bb.y_span()) + CGAL::square(bb.z_span())); +} + +// One use of an edge by a triangle: a <= b are its vertices, corner = 3 * triangle + position of the edge's first vertex. +struct EdgeUse +{ + std::size_t a, b, corner; + bool operator<(const EdgeUse &rhs) const { return std::tie(a, b, corner) < std::tie(rhs.a, rhs.b, rhs.corner); } +}; + +std::vector sorted_edge_uses(const std::vector &triangles) +{ + std::vector uses; + uses.reserve(3 * triangles.size()); + for (std::size_t f = 0; f < triangles.size(); ++f) + for (std::size_t k = 0; k < 3; ++k) { + const auto [a, b] = std::minmax(triangles[f][k], triangles[f][(k + 1) % 3]); + uses.push_back({a, b, 3 * f + k}); + } + tbb::parallel_sort(uses.begin(), uses.end()); + return uses; +} + +// Calls fn(first, last) with the range of uses of each edge. +template void for_each_edge(const std::vector &uses, Fn &&fn) +{ + for (std::size_t i = 0; i < uses.size();) { + std::size_t j = i + 1; + while (j < uses.size() && uses[j].a == uses[i].a && uses[j].b == uses[i].b) + ++j; + fn(i, j); + i = j; + } +} + +bool same_direction(const std::vector &triangles, const EdgeUse &e1, const EdgeUse &e2) +{ + return triangles[e1.corner / 3][e1.corner % 3] == triangles[e2.corner / 3][e2.corner % 3]; +} + +struct SoupTopology +{ + std::vector open_face; // on an edge used by one triangle + std::vector bad_face; // on an edge used by more than two triangles, or twice in one direction + std::vector pinched; // vertex whose triangles form several fans + std::vector border; // vertices of the edges used by one triangle + double open_fraction = 0.; +}; + +// The fans around each vertex are found by joining the corners of triangles across their shared edges. +SoupTopology analyze_topology(std::size_t num_points, const std::vector &triangles) +{ + SoupTopology topology{std::vector(triangles.size(), 0), std::vector(triangles.size(), 0), std::vector(num_points, 0)}; + const std::vector uses = sorted_edge_uses(triangles); + UnionFind fans(3 * triangles.size()); + std::size_t num_edges = 0; + for_each_edge(uses, [&](std::size_t i, std::size_t j) { + const EdgeUse &e = uses[i]; + ++num_edges; + if (j - i == 1 && e.a != e.b) { + topology.open_face[e.corner / 3] = 1; + topology.border.push_back(e.a); + topology.border.push_back(e.b); + } else if (j - i != 2 || e.a == e.b || same_direction(triangles, e, uses[i + 1])) { + for (std::size_t k = i; k < j; ++k) + topology.bad_face[uses[k].corner / 3] = 1; + } else { + const std::size_t c = e.corner, n = uses[i + 1].corner; + fans.unite(c, n - n % 3 + (n + 1) % 3); + fans.unite(c - c % 3 + (c + 1) % 3, n); + } + }); + topology.open_fraction = num_edges == 0 ? 0. : double(topology.border.size() / 2) / double(num_edges); + std::sort(topology.border.begin(), topology.border.end()); + topology.border.erase(std::unique(topology.border.begin(), topology.border.end()), topology.border.end()); + + std::vector fan_of(num_points, NONE); + for (std::size_t c = 0; c < 3 * triangles.size(); ++c) { + const std::size_t v = triangles[c / 3][c % 3], root = fans.find(c); + if (fan_of[v] == NONE) + fan_of[v] = root; + else if (fan_of[v] != root) + topology.pinched[v] = 1; + } + return topology; +} + +// orient_polygon_soup() needs resizable polygons. +void orient_with_cgal(std::vector &points, std::vector &triangles) +{ + std::vector> polygons(triangles.size()); + for (std::size_t i = 0; i < triangles.size(); ++i) + polygons[i].assign(triangles[i].begin(), triangles[i].end()); + PMP::orient_polygon_soup(points, polygons); + for (std::size_t i = 0; i < triangles.size(); ++i) + std::copy(polygons[i].begin(), polygons[i].end(), triangles[i].begin()); +} + +// The triangles of a group renumbered over the vertices they use. +void extract_group(const std::vector &points, const std::vector &triangles, const std::vector &group, + std::vector &group_points, std::vector &group_triangles) +{ + std::vector vertices; + vertices.reserve(3 * group.size()); + for (std::size_t f : group) + vertices.insert(vertices.end(), triangles[f].begin(), triangles[f].end()); + std::sort(vertices.begin(), vertices.end()); + vertices.erase(std::unique(vertices.begin(), vertices.end()), vertices.end()); + group_points.clear(); + for (std::size_t v : vertices) + group_points.push_back(points[v]); + group_triangles.clear(); + for (std::size_t f : group) { + Triangle &t = group_triangles.emplace_back(); + for (std::size_t k = 0; k < 3; ++k) + t[k] = std::lower_bound(vertices.begin(), vertices.end(), triangles[f][k]) - vertices.begin(); + } +} + +// Groups not known to be valid polygon meshes are made into ones first. +std::vector soup_to_meshes(const std::vector &points, const std::vector &triangles, + const std::vector> &groups, bool known_valid) +{ + std::vector meshes(groups.size()); + parallel_for_each(groups.size(), [&](std::size_t i) { + std::vector group_points; + std::vector group_triangles; + extract_group(points, triangles, groups[i], group_points, group_triangles); + if (!known_valid && !PMP::is_polygon_soup_a_polygon_mesh(group_triangles)) + orient_with_cgal(group_points, group_triangles); + PMP::polygon_soup_to_polygon_mesh(group_points, group_triangles, meshes[i]); + }); + return meshes; +} + +std::vector split_shells(Mesh &mesh) +{ + mesh.collect_garbage(); + std::vector face_part(mesh.number_of_faces()); + const std::size_t num_parts = PMP::connected_components(mesh, CGAL::make_property_map(face_part)); + std::vector part_size(num_parts, 0); + for (std::size_t part : face_part) + ++part_size[part]; + const std::size_t largest = std::max_element(part_size.begin(), part_size.end()) - part_size.begin(); + + // The small shells are copied out and the largest one stays in place. + std::vector shells; + if (num_parts > 1) { + std::vector points; + for (Vertex v : mesh.vertices()) + points.push_back(mesh.point(v)); + std::vector triangles; + std::vector> groups(num_parts); + for (Face f : mesh.faces()) + if (face_part[f] != largest) { + Triangle &t = triangles.emplace_back(); + std::size_t k = 0; + for (Vertex v : CGAL::vertices_around_face(mesh.halfedge(f), mesh)) + t[k++] = v; + groups[face_part[f]].push_back(triangles.size() - 1); + } + groups.erase(groups.begin() + largest); + std::vector nonmanifold; + PMP::non_manifold_vertices(mesh, std::back_inserter(nonmanifold)); + shells = soup_to_meshes(points, triangles, groups, nonmanifold.empty()); + PMP::keep_connected_components(mesh, std::vector{largest}, CGAL::make_property_map(face_part)); + mesh.collect_garbage(); + } + shells.insert(shells.begin(), std::move(mesh)); + return shells; +} + +// Valid closed parts skip the soup repair, which would weld them to coincident parts into non-manifold junctions. +std::vector take_closed_shells(const std::vector &points, std::vector &triangles) +{ + const SoupTopology topology = analyze_topology(points.size(), triangles); + UnionFind parts = connect_vertices(points.size(), triangles); + std::vector part_ok(points.size(), 1); + for (std::size_t f = 0; f < triangles.size(); ++f) + for (std::size_t v : triangles[f]) + if (topology.open_face[f] || topology.bad_face[f] || topology.pinched[v]) + part_ok[parts.find(v)] = 0; + + std::vector group_of(points.size(), NONE); + std::vector> groups; + std::vector taken(triangles.size(), 0); + for (std::size_t f = 0; f < triangles.size(); ++f) + if (const std::size_t r = parts.find(triangles[f][0]); part_ok[r]) { + if (group_of[r] == NONE) { + group_of[r] = groups.size(); + groups.emplace_back(); + } + groups[group_of[r]].push_back(f); + taken[f] = 1; + } + std::vector shells = soup_to_meshes(points, triangles, groups, true); + erase_marked(triangles, taken); + return shells; +} + +// Opposite coincident triangles cancel in pairs, unless that empties a whole region (a two-sided export). +void remove_duplicate_triangles(std::size_t num_points, std::vector &triangles) +{ + struct Entry + { + std::array key; + bool odd; + std::size_t idx; + }; + std::vector entries; + for (std::size_t i = 0; i < triangles.size(); ++i) { + const Triangle &t = triangles[i]; + const std::size_t r = std::min_element(t.begin(), t.end()) - t.begin(); + const std::size_t b = t[(r + 1) % 3], c = t[(r + 2) % 3]; + entries.push_back({{t[r], std::min(b, c), std::max(b, c)}, b > c, i}); + } + tbb::parallel_sort(entries.begin(), entries.end(), [](const Entry &l, const Entry &r) { return std::tie(l.key, l.idx) < std::tie(r.key, r.idx); }); + + std::vector remove(triangles.size(), 0); + std::vector cancelled; + for (std::size_t i = 0; i < entries.size();) { + std::size_t j = i + 1; + while (j < entries.size() && entries[j].key == entries[i].key) + ++j; + if (j - i > 1) { + std::size_t num_odd = 0; + for (std::size_t k = i; k < j; ++k) + num_odd += entries[k].odd; + const bool odd_wins = 2 * num_odd > j - i; + bool kept = 2 * num_odd == j - i; + if (kept) + cancelled.push_back(entries[i].idx); + for (std::size_t k = i; k < j; ++k) { + if (!kept && entries[k].odd == odd_wins) + kept = true; + else + remove[entries[k].idx] = 1; + } + } + i = j; + } + if (!cancelled.empty()) { + UnionFind regions = connect_vertices(num_points, triangles); + std::vector survives(num_points, 0); + for (std::size_t i = 0; i < triangles.size(); ++i) + if (!remove[i]) + survives[regions.find(triangles[i][0])] = 1; + for (std::size_t i : cancelled) + if (!survives[regions.find(triangles[i][0])]) + remove[i] = 0; + } + erase_marked(triangles, remove); +} + +void clean_soup(std::vector &points, std::vector &triangles) +{ + std::vector order(points.size()); + std::iota(order.begin(), order.end(), std::size_t(0)); + tbb::parallel_sort(order.begin(), order.end(), [&points](std::size_t a, std::size_t b) { + return std::make_tuple(points[a].x(), points[a].y(), points[a].z(), a) < std::make_tuple(points[b].x(), points[b].y(), points[b].z(), b); + }); + std::vector merged(points.size()); + for (std::size_t i = 0; i < order.size(); ++i) + merged[order[i]] = i > 0 && points[order[i]] == points[order[i - 1]] ? merged[order[i - 1]] : order[i]; + std::vector degenerate(triangles.size(), 0); + for (std::size_t f = 0; f < triangles.size(); ++f) { + Triangle &t = triangles[f]; + for (std::size_t &v : t) + v = merged[v]; + degenerate[f] = t[0] == t[1] || t[1] == t[2] || t[0] == t[2]; + } + erase_marked(triangles, degenerate); + remove_duplicate_triangles(points.size(), triangles); + + std::vector compact(points.size(), NONE); + std::vector used; + for (Triangle &t : triangles) + for (std::size_t &v : t) { + if (compact[v] == NONE) { + compact[v] = used.size(); + used.push_back(points[v]); + } + v = compact[v]; + } + points = std::move(used); +} + +// Closes cracks between open edges; no vertex moves by more than a quarter of its shortest edge. +std::size_t weld_border_vertices(const std::vector &points, std::vector &triangles, const std::vector &border, + double tolerance) +{ + if (border.empty()) + return 0; + std::vector radius(points.size(), tolerance); + for (const Triangle &t : triangles) + for (std::size_t k = 0; k < 3; ++k) { + const double r = 0.25 * std::sqrt(CGAL::squared_distance(points[t[k]], points[t[(k + 1) % 3]])); + radius[t[k]] = std::min(radius[t[k]], r); + radius[t[(k + 1) % 3]] = std::min(radius[t[(k + 1) % 3]], r); + } + + using Cell = std::array; + auto cell_of = [tolerance](const Point &p) { + return Cell{int64_t(std::floor(p.x() / tolerance)), int64_t(std::floor(p.y() / tolerance)), int64_t(std::floor(p.z() / tolerance))}; + }; + std::vector> grid; + for (std::size_t v : border) + grid.emplace_back(cell_of(points[v]), v); + tbb::parallel_sort(grid.begin(), grid.end()); + + UnionFind uf(points.size()); + for (const auto &[c, v] : grid) + for (int64_t dx = -1; dx <= 1; ++dx) + for (int64_t dy = -1; dy <= 1; ++dy) + for (int64_t dz = -1; dz <= 1; ++dz) { + const Cell n{c[0] + dx, c[1] + dy, c[2] + dz}; + for (auto it = std::lower_bound(grid.begin(), grid.end(), std::make_pair(n, std::size_t(0))); it != grid.end() && it->first == n; ++it) + if (const std::size_t u = it->second; + u < v && CGAL::squared_distance(points[u], points[v]) <= CGAL::square(std::min(radius[u], radius[v]))) + uf.unite(u, v); + } + std::size_t welded = 0; + for (std::size_t v : border) + welded += uf.find(v) != v; + if (welded > 0) + for (Triangle &t : triangles) + for (std::size_t &v : t) + v = uf.find(v); + return welded; +} + +// Orients each part as most of its area was and gives each fan its own vertex; false if the mesh is still invalid. +bool orient_soup(std::vector &points, std::vector &triangles, std::size_t &reversed_parts) +{ + const std::size_t num_faces = triangles.size(); + const std::vector uses = sorted_edge_uses(triangles); + std::vector twin(3 * num_faces, NONE); + std::vector> cut; // edges used by more than two triangles + for_each_edge(uses, [&](std::size_t i, std::size_t j) { + if (j - i == 2) { + twin[uses[i].corner] = uses[i + 1].corner; + twin[uses[i + 1].corner] = uses[i].corner; + } else if (j - i > 2) + cut.emplace_back(i, j); + }); + + std::vector flip(num_faces, -1); + std::vector part_of(num_faces); + std::vector vote; + std::vector conflict(3 * num_faces, 0); + std::vector stack; + for (std::size_t seed = 0; seed < num_faces; ++seed) { + if (flip[seed] >= 0) + continue; + const std::size_t part = vote.size(); + vote.push_back(0.); + flip[seed] = 0; + stack.push_back(seed); + while (!stack.empty()) { + const std::size_t f = stack.back(); + stack.pop_back(); + part_of[f] = part; + const Triangle &t = triangles[f]; + const double area = std::sqrt(CGAL::squared_area(points[t[0]], points[t[1]], points[t[2]])); + vote[part] += flip[f] ? -area : area; + for (std::size_t k = 0; k < 3; ++k) { + const std::size_t c = 3 * f + k, n = twin[c]; + if (n == NONE) + continue; + const std::size_t g = n / 3; + // Triangles agree when they run along their shared edge in opposite directions. + const signed char want = flip[f] ^ (t[k] == triangles[g][n % 3]); + if (flip[g] < 0) { + flip[g] = want; + stack.push_back(g); + } else if (flip[g] != want) + conflict[c] = conflict[n] = 1; + } + } + } + + // Corners keep their input vertex in original, through the flips below. + std::vector original(3 * num_faces); + std::vector part_reversed(vote.size(), 0); + for (std::size_t f = 0; f < num_faces; ++f) { + if (flip[f] != (vote[part_of[f]] < 0.)) { + std::swap(triangles[f][1], triangles[f][2]); + part_reversed[part_of[f]] = 1; + } + for (std::size_t k = 0; k < 3; ++k) + original[3 * f + k] = triangles[f][k]; + } + reversed_parts = std::count(part_reversed.begin(), part_reversed.end(), 1); + + auto corner_of = [&original](std::size_t f, std::size_t v) { return 3 * f + (original[3 * f] == v ? 0 : original[3 * f + 1] == v ? 1 : 2); }; + UnionFind fans(3 * num_faces); + for (const EdgeUse &e : uses) + if (const std::size_t n = twin[e.corner]; n != NONE && e.corner < n && !conflict[e.corner]) { + fans.unite(corner_of(e.corner / 3, e.a), corner_of(n / 3, e.a)); + fans.unite(corner_of(e.corner / 3, e.b), corner_of(n / 3, e.b)); + } + std::vector fan_vertex(3 * num_faces, NONE); + std::vector used(points.size(), 0); + for (std::size_t c = 0; c < 3 * num_faces; ++c) { + std::size_t © = fan_vertex[fans.find(c)]; + if (copy == NONE) { + const std::size_t v = original[c]; + copy = used[v] ? points.size() : v; + if (used[v]) + points.push_back(points[v]); + used[v] = 1; + } + triangles[c / 3][c % 3] = copy; + } + + // Only edges that were cut can still be shared wrongly: by more than two triangles, or twice in one direction. + for (std::size_t k = 0; k < uses.size(); ++k) + if (conflict[uses[k].corner] && uses[k].corner < twin[uses[k].corner]) + cut.emplace_back(k, k + 2); + for (const auto &[i, j] : cut) { + // Each use as its copied endpoints and whether it runs from a to b. + std::vector> copies; + for (std::size_t k = i; k < j; ++k) { + const std::size_t f = uses[k].corner / 3, ka = corner_of(f, uses[k].a) % 3, kb = corner_of(f, uses[k].b) % 3; + const auto [ca, cb] = std::minmax(triangles[f][ka], triangles[f][kb]); + copies.push_back({ca, cb, std::size_t((ka + 1) % 3 == kb)}); + } + std::sort(copies.begin(), copies.end()); + for (std::size_t k = 0; k + 1 < copies.size(); ++k) + if (copies[k][0] == copies[k + 1][0] && copies[k][1] == copies[k + 1][1] && + (copies[k][2] == copies[k + 1][2] || (k + 2 < copies.size() && copies[k + 2][0] == copies[k][0] && copies[k + 2][1] == copies[k][1]))) + return false; + } + return true; +} + +// orient_polygon_soup() orients each part arbitrarily; give it back the orientation most of its input had. +std::size_t keep_input_orientation(Mesh &mesh, const std::vector &points_before, const std::vector &triangles_before, + const std::vector &points, const std::vector &triangles) +{ + if (mesh.number_of_faces() != triangles.size()) + return 0; + auto fcc = mesh.add_property_map("f:repair_part").first; + const std::size_t num_parts = PMP::connected_components(mesh, fcc); + std::vector vote(num_parts, 0.); + std::size_t i = 0; + for (Face f : mesh.faces()) { + const Triangle &a = triangles_before[i]; + const Triangle &b = triangles[i++]; + bool same = false; + for (std::size_t r = 0; r < 3 && !same; ++r) { + same = true; + for (std::size_t k = 0; k < 3 && same; ++k) + same = points_before[a[k]] == points[b[(k + r) % 3]]; + } + vote[fcc[f]] += same ? PMP::face_area(f, mesh) : -PMP::face_area(f, mesh); + } + std::vector to_reverse; + for (Face f : mesh.faces()) + if (vote[fcc[f]] < 0.) + to_reverse.push_back(f); + mesh.remove_property_map(fcc); + if (!to_reverse.empty()) + PMP::reverse_face_orientations(to_reverse, mesh); + return std::count_if(vote.begin(), vote.end(), [](double v) { return v < 0.; }); +} + +void close_cracks(Mesh &mesh, double tolerance) +{ + PMP::merge_reversible_connected_components(mesh); + PMP::stitch_borders(mesh); + if (CGAL::is_closed(mesh)) + return; + // T-junctions, which welding cannot close. + PMP::experimental::snap_borders(mesh, CGAL::Constant_property_map(tolerance)); + PMP::stitch_borders(mesh); +} + +struct BorderLoop +{ + Halfedge halfedge; + std::vector halfedges; + Vector normal; // unit normal a face closing the loop would have + double offset = 0.; + bool planar = false; + CGAL::Bbox_3 bbox; +}; + +struct CdtFaceInfo +{ + int nesting = -1; +}; +using CdtVb = CGAL::Triangulation_vertex_base_with_info_2; +using CdtFb = CGAL::Constrained_triangulation_face_base_2>; +using Cdt = CGAL::Constrained_Delaunay_triangulation_2, CGAL::No_constraint_intersection_tag>; + +// Number of constrained edges crossed to reach each face from the outside. +void mark_nesting(Cdt &cdt) +{ + std::vector> seeds{{cdt.infinite_face(), 0}}; + while (!seeds.empty()) { + auto [seed, level] = seeds.back(); + seeds.pop_back(); + if (seed->info().nesting != -1) + continue; + seed->info().nesting = level; + std::vector queue{seed}; + while (!queue.empty()) { + Cdt::Face_handle f = queue.back(); + queue.pop_back(); + for (int i = 0; i < 3; ++i) { + Cdt::Face_handle n = f->neighbor(i); + if (n->info().nesting != -1) + continue; + if (cdt.is_constrained({f, i})) + seeds.emplace_back(n, level + 1); + else { + n->info().nesting = level; + queue.push_back(n); + } + } + } + } +} + +// Fills a planar region bounded by nested loops, such as a missing annulus, leaving its openings open. +bool fill_planar_region(Mesh &mesh, const std::vector &loops) +{ + const Vector &n = loops.front()->normal; + const Vector u = std::abs(n.x()) < 0.9 ? CGAL::cross_product(n, Vector(1, 0, 0)) : CGAL::cross_product(n, Vector(0, 1, 0)); + const Vector e1 = u / std::sqrt(u.squared_length()); + const Vector e2 = CGAL::cross_product(n, e1); + + Cdt cdt; + std::size_t num_vertices = 0; + std::set> border_edges; + try { + for (const BorderLoop *loop : loops) { + std::vector vh; + for (Halfedge h : loop->halfedges) { + const Vector p = mesh.point(mesh.source(h)) - CGAL::ORIGIN; + vh.push_back(cdt.insert(Kernel::Point_2(p * e1, p * e2))); + vh.back()->info() = mesh.source(h); + border_edges.emplace(mesh.source(h), mesh.target(h)); + } + num_vertices += vh.size(); + for (std::size_t k = 0; k < vh.size(); ++k) + cdt.insert_constraint(vh[k], vh[(k + 1) % vh.size()]); + } + } catch (const std::exception &) { + return false; + } + // Loop vertices projecting onto the same point were merged by the triangulation. + if (cdt.number_of_vertices() != num_vertices) + return false; + mark_nesting(cdt); + + std::vector> patch; + std::set> patch_edges; + for (Cdt::Face_handle f : cdt.finite_face_handles()) + if (f->info().nesting % 2 == 1) { + const std::array &t = patch.emplace_back(std::array{f->vertex(0)->info(), f->vertex(1)->info(), f->vertex(2)->info()}); + for (int k = 0; k < 3; ++k) + patch_edges.emplace(t[k], t[(k + 1) % 3]); + } + // The patch has to close every loop with the orientation of the triangles around it. + for (const auto &e : border_edges) + if (patch_edges.count(e) == 0) + return false; + + std::vector added; + for (const auto &t : patch) { + const Face f = mesh.add_face(t[0], t[1], t[2]); + if (f == Mesh::null_face()) { + for (Face a : added) + mesh.remove_face(a); + return false; + } + added.push_back(f); + } + return true; +} + +// Last resort for a loop the hole filling could not close: a fan around its centroid. +void fill_with_fan(Mesh &mesh, const BorderLoop &loop) +{ + Vector sum(0, 0, 0); + for (Halfedge h : loop.halfedges) + sum = sum + (mesh.point(mesh.source(h)) - CGAL::ORIGIN); + const Vertex c = mesh.add_vertex(CGAL::ORIGIN + sum / double(loop.halfedges.size())); + for (Halfedge h : loop.halfedges) + mesh.add_face(mesh.source(h), mesh.target(h), c); +} + +std::size_t fill_holes(Mesh &mesh, double tolerance) +{ + std::vector borders; + CGAL::extract_boundary_cycles(mesh, std::back_inserter(borders)); + std::vector loops(borders.size()); + for (std::size_t i = 0; i < borders.size(); ++i) { + BorderLoop &loop = loops[i]; + loop.halfedge = borders[i]; + Vector newell(0, 0, 0); + for (Halfedge h : CGAL::halfedges_around_face(borders[i], mesh)) { + loop.halfedges.push_back(h); + newell = newell + CGAL::cross_product(mesh.point(mesh.source(h)) - CGAL::ORIGIN, mesh.point(mesh.target(h)) - CGAL::ORIGIN); + loop.bbox += mesh.point(mesh.source(h)).bbox(); + } + if (const double len = std::sqrt(newell.squared_length()); len > 0.) { + loop.normal = newell / len; + loop.offset = (mesh.point(mesh.source(loop.halfedge)) - CGAL::ORIGIN) * loop.normal; + loop.planar = std::all_of(loop.halfedges.begin(), loop.halfedges.end(), [&](Halfedge h) { + return std::abs((mesh.point(mesh.source(h)) - CGAL::ORIGIN) * loop.normal - loop.offset) <= tolerance; + }); + } + } + + // Planar loops facing each other in the same plane, one inside the other, bound one region. + UnionFind regions(loops.size()); + std::vector grouped(loops.size(), 0); + for (std::size_t a = 0; a < loops.size(); ++a) + for (std::size_t b = 0; b < loops.size(); ++b) { + const BorderLoop &la = loops[a], &lb = loops[b]; + if (a == b || !la.planar || !lb.planar || la.normal * lb.normal > -0.99 || !bbox_contains(la.bbox, lb.bbox, tolerance)) + continue; + if (std::all_of(lb.halfedges.begin(), lb.halfedges.end(), [&](Halfedge h) { + return std::abs((mesh.point(mesh.source(h)) - CGAL::ORIGIN) * la.normal - la.offset) <= tolerance; + })) { + regions.unite(a, b); + grouped[a] = grouped[b] = 1; + } + } + std::map> groups; + for (std::size_t i = 0; i < loops.size(); ++i) + if (grouped[i]) + groups[regions.find(i)].push_back(i); + + std::size_t filled = 0; + std::vector done(loops.size(), 0); + for (auto &[root, members] : groups) { + // The outermost loop sets the orientation of the region. + auto extent = [&loops](std::size_t i) { return loops[i].bbox.x_span() + loops[i].bbox.y_span() + loops[i].bbox.z_span(); }; + std::sort(members.begin(), members.end(), [&extent](std::size_t l, std::size_t r) { return extent(l) > extent(r); }); + std::vector group; + for (std::size_t i : members) + group.push_back(&loops[i]); + if (fill_planar_region(mesh, group)) { + filled += members.size(); + for (std::size_t i : members) + done[i] = 1; + } + } + + for (std::size_t i = 0; i < loops.size(); ++i) { + if (done[i]) + continue; + ++filled; + std::vector patch; + // Searching all triangles is cubic in the loop length; the search among Delaunay ones varies from run to run. + const std::size_t size = loops[i].halfedges.size(); + PMP::triangulate_hole(mesh, loops[i].halfedge, + CGAL::parameters::use_2d_constrained_delaunay_triangulation(true) + .use_delaunay_triangulation(size > 100) + .do_not_use_cubic_algorithm(size > 200) + .face_output_iterator(std::back_inserter(patch))); + if (patch.empty() && CGAL::is_border(loops[i].halfedge, mesh)) + fill_with_fan(mesh, loops[i]); + } + return filled; +} + +double signed_volume(const Mesh &mesh) +{ + double vol = 0.; + for (Face f : mesh.faces()) { + const Halfedge h = mesh.halfedge(f); + vol += CGAL::determinant(mesh.point(mesh.source(h)) - CGAL::ORIGIN, mesh.point(mesh.target(h)) - CGAL::ORIGIN, + mesh.point(mesh.target(mesh.next(h))) - CGAL::ORIGIN); + } + return vol / 6.; +} + +// Returns the pairs removed. Widespread overlaps are left to slicing: remeshing them can cut a part down to a sliver. +std::size_t remove_local_self_intersections(Mesh &shell) +{ + std::vector> pairs; + PMP::self_intersections(shell, std::back_inserter(pairs)); + if (pairs.empty()) + return 0; + std::set faces; + for (const auto &[a, b] : pairs) { + faces.insert(a); + faces.insert(b); + } + if (20 * faces.size() > shell.number_of_faces()) + return 0; + const double volume = signed_volume(shell); + Mesh backup = shell; + try { + PMP::experimental::remove_self_intersections(faces, shell, CGAL::parameters::preserve_genus(false)); + shell.collect_garbage(); + if (CGAL::is_valid_polygon_mesh(shell) && CGAL::is_closed(shell) && std::abs(signed_volume(shell) - volume) <= 1e-3 * std::abs(volume)) + return pairs.size(); + } catch (const std::exception &) { + } + shell = std::move(backup); + return 0; +} + +bool surfaces_intersect(const Mesh &mesh, const Tree &other) +{ + for (Face f : mesh.faces()) { + const Halfedge h = mesh.halfedge(f); + if (other.do_intersect(Kernel::Triangle_3(mesh.point(mesh.source(h)), mesh.point(mesh.target(h)), mesh.point(mesh.target(mesh.next(h)))))) + return true; + } + return false; +} + +Point inner_sample(const Mesh &shell) +{ + Face best; + double best_area = -1.; + for (Face f : shell.faces()) + if (const double a = PMP::face_area(f, shell); a > best_area) { + best_area = a; + best = f; + } + const Halfedge h = shell.halfedge(best); + return CGAL::centroid(shell.point(shell.source(h)), shell.point(shell.target(h)), shell.point(shell.target(shell.next(h)))); +} + +// Orients and filters the shells by the nonzero winding rule of slicing. Crossing shells stay apart, as parts do. +void resolve_shells(std::vector &shells, MeshRepairStats &stats, const std::function &progress) +{ + const std::size_t n = shells.size(); + std::vector bbox(n); + std::vector sign(n); + std::vector boxes; + for (std::size_t i = 0; i < n; ++i) { + bbox[i] = PMP::bbox(shells[i]); + sign[i] = signed_volume(shells[i]) < 0. ? -1 : 1; + boxes.emplace_back(bbox[i], i); + } + const std::vector> overlaps = overlapping_boxes(boxes); + + std::vector> enclosing(n); + std::vector needs_tree(n, 0); + for (const auto &[a, b] : overlaps) { + needs_tree[shells[a].number_of_faces() < shells[b].number_of_faces() ? b : a] = 1; + if (bbox_contains(bbox[a], bbox[b])) + enclosing[b].push_back(a); + if (bbox_contains(bbox[b], bbox[a])) + enclosing[a].push_back(b); + } + for (const std::vector &outer : enclosing) + for (std::size_t k : outer) + needs_tree[k] = 1; + // Built before any query: an AABB tree finishes its construction lazily on the first one. + std::vector> trees(n); + parallel_for_each(n, [&](std::size_t i) { + if (needs_tree[i]) { + trees[i] = std::make_unique(shells[i].faces().begin(), shells[i].faces().end(), shells[i]); + trees[i]->build(); + } + }); + + std::vector hit(overlaps.size(), 0); + parallel_for_each(overlaps.size(), [&](std::size_t i) { + const auto [a, b] = overlaps[i]; + const bool a_smaller = shells[a].number_of_faces() < shells[b].number_of_faces(); + hit[i] = surfaces_intersect(shells[a_smaller ? a : b], *trees[a_smaller ? b : a]); + }); + std::vector crossing(n, 0); + for (std::size_t i = 0; i < overlaps.size(); ++i) + if (hit[i]) + crossing[overlaps[i].first] = crossing[overlaps[i].second] = 1; + progress(95); + + // Crossing shells are solids; any other shell must separate zero from nonzero winding. + std::vector drop(n, 0); + std::vector target(n, 1); + parallel_for_each(n, [&](std::size_t j) { + if (crossing[j]) + return; + const Point sample = inner_sample(shells[j]); + int winding = 0; + for (std::size_t k : enclosing[j]) + if (CGAL::Side_of_triangle_mesh(*trees[k])(sample) == CGAL::ON_BOUNDED_SIDE) + winding += crossing[k] ? 1 : sign[k]; + const bool solid_outside = winding != 0; + const bool solid_inside = winding + sign[j] != 0; + drop[j] = solid_outside == solid_inside; + target[j] = solid_inside ? 1 : -1; + }); + trees.clear(); + + for (std::size_t i = 0; i < n; ++i) + if (drop[i]) + ++stats.removed_shells; + else if (target[i] != sign[i]) { + PMP::reverse_face_orientations(shells[i]); + ++stats.reversed_shells; + } + erase_marked(shells, drop); +} + +// Connected parts of the soup; open parts with touching bounding boxes share a unit, so cracks between them close. +std::vector> soup_units(const std::vector &points, const std::vector &triangles, + const SoupTopology &topology, double tolerance) +{ + UnionFind parts = connect_vertices(points.size(), triangles); + std::vector part_index(points.size(), NONE); + std::vector part_of_face(triangles.size()); + std::vector part_bbox; + std::vector part_open; + for (std::size_t f = 0; f < triangles.size(); ++f) { + std::size_t &index = part_index[parts.find(triangles[f][0])]; + if (index == NONE) { + index = part_bbox.size(); + part_bbox.emplace_back(); + part_open.push_back(0); + } + part_of_face[f] = index; + for (std::size_t v : triangles[f]) + part_bbox[index] += points[v].bbox(); + part_open[index] = part_open[index] || topology.open_face[f]; + } + + std::vector boxes; + for (std::size_t i = 0; i < part_bbox.size(); ++i) + if (part_open[i]) { + const CGAL::Bbox_3 &b = part_bbox[i]; + boxes.emplace_back(CGAL::Bbox_3(b.xmin() - tolerance, b.ymin() - tolerance, b.zmin() - tolerance, b.xmax() + tolerance, + b.ymax() + tolerance, b.zmax() + tolerance), i); + } + UnionFind units(part_bbox.size()); + for (const auto &[a, b] : overlapping_boxes(boxes)) + units.unite(a, b); + + std::vector unit_index(part_bbox.size(), NONE); + std::vector> out; + for (std::size_t f = 0; f < triangles.size(); ++f) { + std::size_t &index = unit_index[units.find(part_of_face[f])]; + if (index == NONE) { + index = out.size(); + out.emplace_back(); + } + out[index].push_back(f); + } + return out; +} + +struct UnitResult +{ + std::vector shells; + std::size_t reversed = 0; + std::size_t filled = 0; + bool closed = true; +}; + +UnitResult repair_unit(const std::vector &points, const std::vector &triangles, const std::vector &faces, + const SoupTopology &topology, double tolerance) +{ + std::vector unit_points; + std::vector unit_triangles; + extract_group(points, triangles, faces, unit_points, unit_triangles); + bool oriented_manifold = true; + for (std::size_t f : faces) { + oriented_manifold = oriented_manifold && !topology.bad_face[f]; + for (std::size_t v : triangles[f]) + oriented_manifold = oriented_manifold && !topology.pinched[v]; + } + + UnitResult result; + Mesh mesh; + if (oriented_manifold) + PMP::polygon_soup_to_polygon_mesh(unit_points, unit_triangles, mesh); + else { + std::vector oriented_points = unit_points; + std::vector oriented_triangles = unit_triangles; + if (orient_soup(oriented_points, oriented_triangles, result.reversed)) + PMP::polygon_soup_to_polygon_mesh(oriented_points, oriented_triangles, mesh); + else { + const std::vector points_before = unit_points; + const std::vector triangles_before = unit_triangles; + orient_with_cgal(unit_points, unit_triangles); + PMP::polygon_soup_to_polygon_mesh(unit_points, unit_triangles, mesh); + result.reversed = keep_input_orientation(mesh, points_before, triangles_before, unit_points, unit_triangles); + } + } + + if (!CGAL::is_closed(mesh)) + close_cracks(mesh, tolerance); + PMP::remove_degenerate_faces(mesh); + if (!CGAL::is_closed(mesh)) { + // Rejoin vertices the orientation split where holes break their fan, so each gap is one loop. + PMP::merge_duplicated_vertices_in_boundary_cycles(mesh); + result.filled = fill_holes(mesh, tolerance); + PMP::remove_degenerate_faces(mesh); + } + PMP::remove_isolated_vertices(mesh); + mesh.collect_garbage(); + result.closed = CGAL::is_closed(mesh); + if (result.closed && !mesh.is_empty()) + result.shells = split_shells(mesh); + return result; +} + +// Returns false when no closed shell was left. +bool repair_soup(std::vector &points, std::vector &triangles, std::vector &shells, MeshRepairStats &stats, + const std::function &progress) +{ + const double tolerance = 1e-4 * bbox_diagonal(points); + shells = take_closed_shells(points, triangles); + stats.modified = !triangles.empty(); + progress(10); + + if (!triangles.empty()) { + const std::size_t num_triangles = triangles.size(); + clean_soup(points, triangles); + SoupTopology topology = analyze_topology(points.size(), triangles); + // A soup that stays mostly open has cracks wider than the tolerance, so try wider ones. + for (double t = tolerance; t > 0. && t <= 100. * tolerance; t *= 10.) { + if (const std::size_t welded = weld_border_vertices(points, triangles, topology.border, t); welded > 0) { + stats.welded_vertices += welded; + clean_soup(points, triangles); + topology = analyze_topology(points.size(), triangles); + } + if (topology.open_fraction < 0.5) + break; + } + stats.removed_triangles = num_triangles - triangles.size(); + progress(25); + + // Independent parts of the soup are repaired in parallel. + const std::vector> units = soup_units(points, triangles, topology, tolerance); + std::vector results(units.size()); + parallel_for_each(units.size(), [&](std::size_t i) { results[i] = repair_unit(points, triangles, units[i], topology, tolerance); }); + for (UnitResult &result : results) { + if (!result.closed) + return false; + stats.reversed_shells += result.reversed; + stats.filled_holes += result.filled; + for (Mesh &shell : result.shells) + shells.emplace_back(std::move(shell)); + } + progress(60); + } + + // Shells are independent from here on. Hole filling and remeshing leave some that enclose nothing. + auto flat = [tolerance](const Mesh &shell) { + // Fewer than four faces are two triangles back to back, whose volumes need not cancel exactly. + return shell.number_of_faces() < 4 || std::abs(signed_volume(shell)) <= 0.1 * tolerance * PMP::area(shell); + }; + std::vector> pieces(shells.size()); + std::vector pairs(shells.size(), 0), removed(shells.size(), 0); + parallel_for_each(shells.size(), [&](std::size_t i) { + if (flat(shells[i])) + removed[i] = 1; + else if ((pairs[i] = remove_local_self_intersections(shells[i])) == 0) + pieces[i].emplace_back(std::move(shells[i])); + else + for (Mesh &piece : split_shells(shells[i])) + if (flat(piece)) + ++removed[i]; + else + pieces[i].emplace_back(std::move(piece)); + }); + stats.removed_shells += std::accumulate(removed.begin(), removed.end(), std::size_t(0)); + stats.self_intersections += std::accumulate(pairs.begin(), pairs.end(), std::size_t(0)); + shells.clear(); + for (std::vector &shell_pieces : pieces) + std::move(shell_pieces.begin(), shell_pieces.end(), std::back_inserter(shells)); + progress(90); + resolve_shells(shells, stats, progress); + + stats.modified = stats.modified || stats.removed_shells > 0 || stats.reversed_shells > 0 || stats.self_intersections > 0; + return !shells.empty(); +} + +// Repairs its into result, which is left empty when the input needs no change. +bool repair_its(const indexed_triangle_set &its, indexed_triangle_set &result, MeshRepairStats &stats, const std::function &progress) +{ + std::vector points; + points.reserve(its.vertices.size()); + for (const stl_vertex &v : its.vertices) + points.emplace_back(v.x(), v.y(), v.z()); + std::vector triangles; + triangles.reserve(its.indices.size()); + for (const stl_triangle_vertex_indices &f : its.indices) + if (its.vertices[f[0]].allFinite() && its.vertices[f[1]].allFinite() && its.vertices[f[2]].allFinite()) + triangles.push_back({std::size_t(f[0]), std::size_t(f[1]), std::size_t(f[2])}); + const bool dropped = triangles.size() != its.indices.size(); + + std::vector shells; + const bool ok = repair_soup(points, triangles, shells, stats, progress); + stats.modified = stats.modified || dropped; + if (!ok || !stats.modified) + return ok; + + for (Mesh &shell : shells) { + shell.collect_garbage(); + const int offset = int(result.vertices.size()); + for (Vertex v : shell.vertices()) { + const Point &p = shell.point(v); + result.vertices.emplace_back(float(p.x()), float(p.y()), float(p.z())); + } + for (Face f : shell.faces()) { + const Halfedge h = shell.halfedge(f); + result.indices.emplace_back(offset + int(shell.source(h)), offset + int(shell.target(h)), offset + int(shell.target(shell.next(h)))); + } + } + return true; +} + +} // namespace + +bool repair_mesh(TriangleMesh &mesh, MeshRepairStats *stats_out, std::string *error, const std::function &progress) +{ + if (mesh.its.indices.empty()) + return true; + + MeshRepairStats stats; + indexed_triangle_set result; + bool ok = false; + try { + ok = repair_its(mesh.its, result, stats, progress ? progress : [](int) {}); + } catch (const CGAL::Failure_exception &ex) { + if (error) + *error = ex.what(); + return false; + } + if (stats_out) + *stats_out = stats; + if (!ok) { + if (error) + *error = _u8L("The mesh does not enclose any volume."); + return false; + } + BOOST_LOG_TRIVIAL(info) << "repair_mesh: welded " << stats.welded_vertices << " vertices, removed " << stats.removed_triangles + << " triangles, filled " << stats.filled_holes << " holes, reversed " << stats.reversed_shells << " and removed " + << stats.removed_shells << " shells, fixed " << stats.self_intersections << " self-intersections"; + if (stats.modified) + mesh = TriangleMesh(std::move(result)); + return true; +} + +} // namespace Slic3r diff --git a/src/libslic3r/MeshRepair.hpp b/src/libslic3r/MeshRepair.hpp new file mode 100644 index 0000000000..0bbb6e94a5 --- /dev/null +++ b/src/libslic3r/MeshRepair.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +namespace Slic3r { + +class TriangleMesh; + +struct MeshRepairStats +{ + std::size_t welded_vertices = 0; + std::size_t removed_triangles = 0; + std::size_t reversed_shells = 0; + std::size_t filled_holes = 0; + std::size_t removed_shells = 0; + std::size_t self_intersections = 0; + // False when the input already was closed, consistently oriented and free of self-intersections. + bool modified = false; +}; + +// Returns false, leaving the mesh untouched, if no solid can be built. progress gets the percentage done and may +// throw to cancel. See docs/HLSD/mesh-repair.md. +bool repair_mesh(TriangleMesh &mesh, MeshRepairStats *stats = nullptr, std::string *error = nullptr, + const std::function &progress = {}); + +} // namespace Slic3r diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 6cca66e44b..d096bd2158 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -29,6 +29,7 @@ #include "TriangleMeshSlicer.hpp" #include "TriangleSelector.hpp" #include "MaterialType.hpp" +#include "MeshRepair.hpp" #include "Format/AMF.hpp" #include "Format/svg.hpp" @@ -2946,6 +2947,18 @@ size_t ModelVolume::split(unsigned int max_extruders, bool remap_paint) if (text_configuration.has_value()) text_configuration.reset(); + // Parts without a convex hull enclose no volume; drop them here, as deleting volumes later could delete this one. + std::vector> parts; + for (TriangleMesh &mesh : meshes) + if (!mesh.empty()) { + std::unique_ptr part(new ModelVolume(object, *this, std::move(mesh))); + if (const std::shared_ptr &hull = part->get_convex_hull_shared_ptr(); + hull != nullptr && !hull->its.vertices.empty() && !hull->its.indices.empty()) + parts.emplace_back(std::move(part)); + } + if (parts.empty()) + return 1; + std::optional saved_painting = remap_paint ? save_painting() : std::optional{}; @@ -2956,26 +2969,22 @@ size_t ModelVolume::split(unsigned int max_extruders, bool remap_paint) unsigned int extruder_counter = 0; const Vec3d offset = this->get_offset(); - for (TriangleMesh &mesh : meshes) { - if (mesh.empty()) - // Repair may have removed unconnected triangles, thus emptying the mesh. - continue; - + for (std::unique_ptr &part : parts) { if (idx == 0) { - this->set_mesh(std::move(mesh)); - this->calculate_convex_hull(); + this->set_mesh(part->m_mesh); + m_convex_hull = part->m_convex_hull; this->invalidate_convex_hull_2d(); // Assign a new unique ID, so that a new GLVolume will be generated. this->set_new_unique_id(); - // reset the source to disable reload from disk - this->source = ModelVolume::Source(); // BBS: reset facet annotations this->reset_extra_facets(); } else - this->object->volumes.insert(this->object->volumes.begin() + (++ivolume), new ModelVolume(object, *this, std::move(mesh))); + this->object->volumes.insert(this->object->volumes.begin() + (++ivolume), part.release()); + // reset the source to disable reload from disk + this->object->volumes[ivolume]->source = ModelVolume::Source(); this->object->volumes[ivolume]->set_offset(Vec3d::Zero()); this->object->volumes[ivolume]->center_geometry_after_creation(); this->object->volumes[ivolume]->translate(offset); @@ -2989,21 +2998,29 @@ size_t ModelVolume::split(unsigned int max_extruders, bool remap_paint) ++ idx; } - // discard volumes for which the convex hull was not generated or is degenerate - size_t i = 0; - while (i < this->object->volumes.size()) { - const std::shared_ptr &hull = this->object->volumes[i]->get_convex_hull_shared_ptr(); - if (hull == nullptr || hull->its.vertices.empty() || hull->its.indices.empty()) { - this->object->delete_volume(i); - --idx; - --i; - } - ++i; - } - return idx; } +bool ModelVolume::repair(bool keep_painting, MeshRepairStats *stats_out, std::string *error, const std::function &progress) +{ + TriangleMesh mesh = this->mesh(); + MeshRepairStats stats; + const bool ok = repair_mesh(mesh, &stats, error, progress); + if (stats_out) + *stats_out = stats; + if (!ok || !stats.modified) + return ok; + + const std::optional saved_painting = keep_painting ? save_painting() : std::nullopt; + this->set_mesh(std::move(mesh)); + this->calculate_convex_hull(); + this->invalidate_convex_hull_2d(); + // Assign a new unique ID, so that a new GLVolume will be generated. + this->set_new_unique_id(); + this->restore_painting(saved_painting); + return true; +} + void ModelVolume::translate(const Vec3d& displacement) { set_offset(get_offset() + displacement); diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 4bb1794b46..dcaea5ee97 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -77,6 +77,7 @@ class Preset; class BBLProject; class KeyStore; +struct MeshRepairStats; namespace UndoRedo { class StackImpl; @@ -887,13 +888,13 @@ public: // The triangular model. const TriangleMesh& mesh() const { return *m_mesh.get(); } std::shared_ptr mesh_ptr() const { return m_mesh; } - void set_mesh(const TriangleMesh &mesh) { m_mesh = std::make_shared(mesh); } - void set_mesh(TriangleMesh &&mesh) { m_mesh = std::make_shared(std::move(mesh)); } - void set_mesh(const indexed_triangle_set &mesh) { m_mesh = std::make_shared(mesh); } - void set_mesh(indexed_triangle_set &&mesh) { m_mesh = std::make_shared(std::move(mesh)); } - void set_mesh(std::shared_ptr &mesh) { m_mesh = mesh; } - void set_mesh(std::unique_ptr &&mesh) { m_mesh = std::move(mesh); } - void reset_mesh() { m_mesh = std::make_shared(); } + void set_mesh(const TriangleMesh &mesh) { m_mesh = std::make_shared(mesh); m_is_splittable = -1; } + void set_mesh(TriangleMesh &&mesh) { m_mesh = std::make_shared(std::move(mesh)); m_is_splittable = -1; } + void set_mesh(const indexed_triangle_set &mesh) { m_mesh = std::make_shared(mesh); m_is_splittable = -1; } + void set_mesh(indexed_triangle_set &&mesh) { m_mesh = std::make_shared(std::move(mesh)); m_is_splittable = -1; } + void set_mesh(std::shared_ptr &mesh) { m_mesh = mesh; m_is_splittable = -1; } + void set_mesh(std::unique_ptr &&mesh) { m_mesh = std::move(mesh); m_is_splittable = -1; } + void reset_mesh() { m_mesh = std::make_shared(); m_is_splittable = -1; } const std::shared_ptr& get_mesh_shared_ptr() const { return m_mesh; } // Configuration parameters specific to an object model geometry or a modifier volume, // overriding the global Slic3r settings and the ModelObject settings. @@ -1043,6 +1044,9 @@ public: // Return the number of volumes created from this one. // This is useful to assign different materials to different volumes of an object. size_t split(unsigned int max_extruders, bool remap_paint); + // Replaces the mesh by its repair_mesh() result if that changed it. False if no solid can be built. + bool repair(bool keep_painting, MeshRepairStats *stats = nullptr, std::string *error = nullptr, + const std::function &progress = {}); void translate(double x, double y, double z) { translate(Vec3d(x, y, z)); } void translate(const Vec3d& displacement); void scale(const Vec3d& scaling_factors); diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index b081f14248..ac584e5b77 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -12866,10 +12866,11 @@ CLITransformConfigDef::CLITransformConfigDef() //def->cli = "orient|o"; def->set_default_value(new ConfigOptionInt(0)); - /*def = this->add("repair", coBool); + def = this->add("repair", coBool); def->label = L("Repair"); - def->tooltip = L("Repair the model's meshes if it is non-manifold mesh."); - def->set_default_value(new ConfigOptionBool(false));*/ + def->tooltip = L("Repair the meshes of the models as Fix Model does: close holes and cracks, orient them outward and " + "remove self-intersections."); + def->set_default_value(new ConfigOptionBool(false)); def = this->add("rotate", coFloat); def->label = L("Rotate"); diff --git a/src/libslic3r/TextureToColor/TextureToColor.cpp b/src/libslic3r/TextureToColor/TextureToColor.cpp index 15a2a641af..e016a549a2 100644 --- a/src/libslic3r/TextureToColor/TextureToColor.cpp +++ b/src/libslic3r/TextureToColor/TextureToColor.cpp @@ -673,14 +673,14 @@ static bool repair_cluster_smooth( [&]() { return cancelled(); }, &repair_error); if (repaired) { if (cancelled()) return false; - BOOST_LOG_TRIVIAL(info) << log_prefix << ": Windows 3D mesh repair finished."; + BOOST_LOG_TRIVIAL(info) << log_prefix << ": mesh repair finished."; if (!resample_face_colors(TriMesh(std::move(repaired_its)))) return false; } else { - BOOST_LOG_TRIVIAL(warning) << log_prefix << ": Windows 3D mesh repair failed: " << repair_error; + BOOST_LOG_TRIVIAL(warning) << log_prefix << ": mesh repair failed: " << repair_error; } } else { - BOOST_LOG_TRIVIAL(info) << log_prefix << ": importing mesh without Windows 3D repair."; + BOOST_LOG_TRIVIAL(info) << log_prefix << ": importing mesh without repair."; } } } diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index 5cbda924b5..f4508f47ac 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -1952,6 +1952,16 @@ void MenuFactory::create_plate_menu() }, "", nullptr, []() { return !plater()->get_partplate_list().get_selected_plate()->get_objects().empty(); }, m_parent); + // fix objects on current plate + append_menu_item(menu, wxID_ANY, _L("Fix All"), _L("Fix all models on the current plate"), + [](wxCommandEvent&) { obj_list()->fix_all_through_cgal(false); }, "", nullptr, + []() { return !plater()->get_partplate_list().get_selected_plate()->empty(); }, m_parent); + + // fix objects on all plates + append_menu_item(menu, wxID_ANY, _L("Fix All Plates"), _L("Fix all models on all plates"), + [](wxCommandEvent&) { obj_list()->fix_all_through_cgal(true); }, "", nullptr, + []() { return !plater()->model().objects.empty(); }, m_parent); + // orient objects on current plate append_menu_item(menu, wxID_ANY, _L("Auto Rotate"), _L("Auto rotate current plate"), [](wxCommandEvent&) { diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 4a733d3342..40ba495349 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -6297,30 +6297,44 @@ void ObjectList::rename_item() } void ObjectList::fix_through_cgal() +{ + std::vector obj_idxs, vol_idxs; + get_selection_indexes(obj_idxs, vol_idxs); + fix_through_cgal(obj_idxs, vol_idxs); +} + +void ObjectList::fix_all_through_cgal(bool all_plates) +{ + std::vector obj_idxs; + if (all_plates) { + for (int obj_idx = 0; obj_idx < int(m_objects->size()); ++obj_idx) + obj_idxs.push_back(obj_idx); + } else { + for (const ModelObject *mo : wxGetApp().plater()->get_partplate_list().get_selected_plate()->get_objects_on_this_plate()) + obj_idxs.push_back(int(std::find(m_objects->begin(), m_objects->end(), mo) - m_objects->begin())); + std::sort(obj_idxs.begin(), obj_idxs.end()); + obj_idxs.erase(std::unique(obj_idxs.begin(), obj_idxs.end()), obj_idxs.end()); + } + fix_through_cgal(obj_idxs, {}); +} + +void ObjectList::fix_through_cgal(const std::vector &obj_idxs, const std::vector &vol_idxs) { // Do not fix anything when a gizmo is open. There might be issues with updates // and what is worse, the snapshot time would refer to the internal stack. - if (!wxGetApp().plater()->get_view3D_canvas3D()->get_gizmos_manager().check_gizmos_closed_except(GLGizmosManager::Undefined)) + if (obj_idxs.empty() || !wxGetApp().plater()->get_view3D_canvas3D()->get_gizmos_manager().check_gizmos_closed_except(GLGizmosManager::Undefined)) return; // model_name std::vector succes_models; // model_name failing reason std::vector> failed_models; - - std::vector obj_idxs, vol_idxs; - get_selection_indexes(obj_idxs, vol_idxs); + size_t unchanged_models = 0; + bool painting_removed = false; std::vector model_names; - // clear selections from the non-broken models if any exists - // and than fill names of models to repairing if (vol_idxs.empty()) { -#if !FIX_THROUGH_CGAL_ALWAYS - for (int i = int(obj_idxs.size())-1; i >= 0; --i) - if (object(obj_idxs[i])->get_repaired_errors_count() == 0) - obj_idxs.erase(obj_idxs.begin()+i); -#endif // FIX_THROUGH_CGAL_ALWAYS for (int obj_idx : obj_idxs) if (object(obj_idx)) model_names.push_back(object(obj_idx)->name); @@ -6328,31 +6342,26 @@ void ObjectList::fix_through_cgal() else { ModelObject* obj = object(obj_idxs.front()); if (obj) { -#if !FIX_THROUGH_CGAL_ALWAYS - for (int i = int(vol_idxs.size()) - 1; i >= 0; --i) - if (obj->get_repaired_errors_count(vol_idxs[i]) == 0) - vol_idxs.erase(vol_idxs.begin() + i); -#endif // FIX_THROUGH_CGAL_ALWAYS for (int vol_idx : vol_idxs) model_names.push_back(obj->volumes[vol_idx]->name); } } auto plater = wxGetApp().plater(); + const bool keep_painting = GUI::wxGetApp().app_config->get_bool("keep_painting"); - auto fix_and_update_progress = [this, plater, model_names](const int obj_idx, const int vol_idx, - int model_idx, - ProgressDialog& progress_dlg, - std::vector& succes_models, - std::vector>& failed_models) + auto fix_and_update_progress = [&](const int obj_idx, const int vol_idx, int model_idx, ProgressDialog& progress_dlg) { - if (!object(obj_idx)) + ModelObject *mo = object(obj_idx); + if (!mo) return false; const std::string& model_name = model_names[model_idx]; wxString msg = _L("Repairing model object"); if (model_names.size() == 1) msg += ": " + from_u8(model_name) + "\n"; + else if (model_names.size() > 10) + msg += ": " + from_u8(model_name) + wxString::Format(" (%d/%d)", model_idx + 1, int(model_names.size())) + "\n"; else { msg += ":\n"; for (int i = 0; i < int(model_names.size()); ++i) @@ -6360,33 +6369,29 @@ void ObjectList::fix_through_cgal() msg += "\n"; } - const bool keep_painting = GUI::wxGetApp().app_config->get_bool("keep_painting"); - if (!keep_painting) { - plater->clear_before_change_mesh(obj_idx); - } - const size_t volumes_before = object(obj_idx)->volumes.size(); + auto count_painted = [mo]() { return std::count_if(mo->volumes.begin(), mo->volumes.end(), [](const ModelVolume *mv) { return mv->is_any_painted(); }); }; + const auto painted_before = count_painted(); std::string res; - if (!fix_model_with_cgal_gui(*(object(obj_idx)), vol_idx, progress_dlg, msg, res, keep_painting)) + bool modified = false; + if (!fix_model_with_cgal_gui(*mo, vol_idx, progress_dlg, msg, res, keep_painting, &modified)) return false; - //wxGetApp().plater()->changed_mesh(obj_idx); - object(obj_idx)->ensure_on_bed(); - plater->changed_mesh(obj_idx); - const size_t volumes_after = object(obj_idx)->volumes.size(); - if (volumes_after != volumes_before) - add_volumes_to_object_in_list(obj_idx); + if (modified) { + painting_removed = painting_removed || count_painted() < painted_before; + mo->ensure_on_bed(); + plater->changed_mesh(obj_idx); + plater->get_partplate_list().notify_instance_update(obj_idx, 0); + plater->sidebar().obj_list()->update_plate_values_for_items(); + update_item_error_icon(obj_idx, vol_idx); + update_info_items(obj_idx); + } - plater->get_partplate_list().notify_instance_update(obj_idx, 0); - plater->sidebar().obj_list()->update_plate_values_for_items(); - - if (res.empty()) + if (!res.empty()) + failed_models.push_back({ model_name, res }); + else if (modified) succes_models.push_back(model_name); else - failed_models.push_back({ model_name, res }); - - update_item_error_icon(obj_idx, vol_idx); - update_info_items(obj_idx); - + ++unchanged_models; return true; }; @@ -6399,11 +6404,7 @@ void ObjectList::fix_through_cgal() if (vol_idxs.empty()) { int vol_idx{ -1 }; for (int obj_idx : obj_idxs) { -#if !FIX_THROUGH_CGAL_ALWAYS - if (object(obj_idx)->get_repaired_errors_count(vol_idx) == 0) - continue; -#endif // FIX_THROUGH_CGAL_ALWAYS - if (!fix_and_update_progress(obj_idx, vol_idx, model_idx, progress_dlg, succes_models, failed_models)) + if (!fix_and_update_progress(obj_idx, vol_idx, model_idx, progress_dlg)) break; model_idx++; } @@ -6411,7 +6412,7 @@ void ObjectList::fix_through_cgal() else { int obj_idx{ obj_idxs.front() }; for (int vol_idx : vol_idxs) { - if (!fix_and_update_progress(obj_idx, vol_idx, model_idx, progress_dlg, succes_models, failed_models)) + if (!fix_and_update_progress(obj_idx, vol_idx, model_idx, progress_dlg)) break; model_idx++; } @@ -6419,20 +6420,29 @@ void ObjectList::fix_through_cgal() // Close the progress dialog progress_dlg.Update(100, ""); + if (painting_removed) + plater->notify_painting_removed(); + // Show info notification - wxString msg; + std::vector blocks; wxString bullet_suf = "\n - "; if (!succes_models.empty()) { - msg = _L_PLURAL("The following model object has been repaired", "The following model objects have been repaired", succes_models.size()) + ":"; + wxString block = _L_PLURAL("The following model object has been repaired", "The following model objects have been repaired", succes_models.size()) + ":"; for (auto& model : succes_models) - msg += bullet_suf + from_u8(model); - msg += "\n\n"; + block += bullet_suf + from_u8(model); + blocks.push_back(block); } if (!failed_models.empty()) { - msg += _L_PLURAL("Failed to repair the following model object", "Failed to repair the following model objects", failed_models.size()) + ":\n"; + wxString block = _L_PLURAL("Failed to repair the following model object", "Failed to repair the following model objects", failed_models.size()) + ":"; for (auto& model : failed_models) - msg += bullet_suf + from_u8(model.first) + ": " + _(model.second); + block += bullet_suf + from_u8(model.first) + ": " + _(model.second); + blocks.push_back(block); } + if (unchanged_models > 0) + blocks.push_back(format_wxstr(_L_PLURAL("%1$d model object needed no repair.", "%1$d model objects needed no repair.", unchanged_models), unchanged_models)); + wxString msg; + for (const wxString &block : blocks) + msg += (msg.IsEmpty() ? "" : "\n\n") + block; if (msg.IsEmpty()) msg = _L("Repairing was canceled"); plater->get_notification_manager()->push_notification(NotificationType::CgalFinished, NotificationManager::NotificationLevel::PrintInfoShortNotificationLevel, into_u8(msg)); diff --git a/src/slic3r/GUI/GUI_ObjectList.hpp b/src/slic3r/GUI/GUI_ObjectList.hpp index 03bffb6cdc..85dc3943f9 100644 --- a/src/slic3r/GUI/GUI_ObjectList.hpp +++ b/src/slic3r/GUI/GUI_ObjectList.hpp @@ -47,9 +47,6 @@ typedef double coordf_t; typedef std::pair t_layer_height_range; typedef std::map t_layer_config_ranges; -// Manifold mesh may contain self-intersections, so we want to always allow fixing the mesh. -#define FIX_THROUGH_CGAL_ALWAYS 1 - namespace GUI { enum class Shortcut : uint8_t; @@ -463,6 +460,9 @@ public: void split_instances(); void rename_item(); void fix_through_cgal(); + // Fixes every object on the selected plate, or on all plates. + void fix_all_through_cgal(bool all_plates); + void fix_through_cgal(const std::vector &obj_idxs, const std::vector &vol_idxs); void simplify(); void smooth_mesh(); void update_item_error_icon(const int obj_idx, int vol_idx) const ; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 8339e4d9d9..dd2d0e60d3 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -14984,24 +14984,8 @@ bool Plater::priv::can_fix_through_cgal() const std::vector obj_idxs, vol_idxs; sidebar->obj_list()->get_selection_indexes(obj_idxs, vol_idxs); -#if FIX_THROUGH_CGAL_ALWAYS - // Fixing always. + // A closed mesh may still self-intersect, so fixing is always allowed. return ! obj_idxs.empty() || ! vol_idxs.empty(); -#else // FIX_THROUGH_CGAL_ALWAYS - // Fixing only if the model is not manifold. - if (vol_idxs.empty()) { - for (auto obj_idx : obj_idxs) - if (model.objects[obj_idx]->get_repaired_errors_count() > 0) - return true; - return false; - } - - int obj_idx = obj_idxs.front(); - for (auto vol_idx : vol_idxs) - if (model.objects[obj_idx]->get_repaired_errors_count(vol_idx) > 0) - return true; - return false; -#endif // FIX_THROUGH_CGAL_ALWAYS } bool Plater::priv::can_simplify() const @@ -22041,13 +22025,16 @@ void Plater::clear_before_change_mesh(int obj_idx) mv->mmu_segmentation_facets.reset(); mv->fuzzy_skin_facets.reset(); } - if (paint_removed) { - // snapshot_time is captured by copy so the lambda knows where to undo/redo to. - get_notification_manager()->push_notification( - NotificationType::CustomSupportsAndSeamRemovedAfterRepair, - NotificationManager::NotificationLevel::PrintInfoNotificationLevel, - _u8L("Custom supports and color painting were removed before repairing.")); - } + if (paint_removed) + notify_painting_removed(); +} + +void Plater::notify_painting_removed() +{ + get_notification_manager()->push_notification( + NotificationType::CustomSupportsAndSeamRemovedAfterRepair, + NotificationManager::NotificationLevel::PrintInfoNotificationLevel, + _u8L("Custom supports and color painting were removed before repairing.")); } void Plater::changed_mesh(int obj_idx) diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 0770e7e8d9..b3a2954b33 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -599,6 +599,7 @@ public: void reslice_SLA_until_step(SLAPrintObjectStep step, const ModelObject &object, bool postpone_error_messages = false); void clear_before_change_mesh(int obj_idx); + void notify_painting_removed(); void changed_mesh(int obj_idx); void changed_object(ModelObject &object); diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp index bc50cabc8c..a785847670 100644 --- a/src/slic3r/GUI/TextureImportDialog.cpp +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -14,7 +14,7 @@ #include "Widgets/StateColor.hpp" #include "Widgets/StaticLine.hpp" #include "libslic3r/ColorDecomposeRecipe.hpp" -#include "libslic3r/MeshBoolean.hpp" +#include "libslic3r/MeshRepair.hpp" #include "libslic3r/TriangleSelector.hpp" #include @@ -2436,25 +2436,25 @@ void TextureImportDialog::start_computation(bool auto_color, bool initial) settings.target_colors_num = auto_color ? 0 : (size_t)m_param_color_count; settings.smooth_weight = m_param_smooth / 10.0; settings.mesh_repair_decision = m_mesh_repair_decision; - // BBS repairs the mesh through the Windows 3D SDK, which is only available on Windows - // builds that ship the SDK. Orca's CGAL-based repair (MeshBoolean::cgal::repair) works - // on all three platforms, so use that instead. settings.mesh_repair_callback = [](const indexed_triangle_set& mesh, indexed_triangle_set& repaired_mesh, std::function progress_callback, std::function cancel_callback, std::string* error_message) -> bool { - if (cancel_callback && cancel_callback()) - return false; if (progress_callback) progress_callback(_u8L("Repairing mesh").c_str(), 0); + struct Canceled {}; TriangleMesh tm(mesh); - if (!MeshBoolean::cgal::repair(tm, nullptr, error_message)) - return false; - - if (cancel_callback && cancel_callback()) + try { + if (!repair_mesh(tm, nullptr, error_message, [&cancel_callback](int) { + if (cancel_callback && cancel_callback()) + throw Canceled(); + })) + return false; + } catch (const Canceled &) { return false; + } repaired_mesh = tm.its; if (progress_callback) progress_callback(_u8L("Repairing mesh").c_str(), 100); @@ -2683,12 +2683,12 @@ void TextureImportDialog::on_mesh_repair_decision_required(wxCommandEvent&) m_progress_dlg = nullptr; } -#ifdef HAS_WIN10SDK Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, - _L("The mesh has non-manifold geometry or open boundaries. You can import it as-is or repair it with Windows 3D repair service before importing."), - _L("Mesh repair"), wxYES_NO | wxICON_WARNING | wxYES_DEFAULT); + _L("The mesh has non-manifold geometry or open boundaries. You can import it as-is or repair it before importing."), + _L("Mesh repair"), wxYES_NO | wxCANCEL | wxICON_WARNING | wxYES_DEFAULT); dlg.SetButtonLabel(wxID_YES, _L("Import without repair")); dlg.SetButtonLabel(wxID_NO, _L("Repair and import"), true); + dlg.SetButtonLabel(wxID_CANCEL, _L("Cancel")); // "Repair and import" is the recommended action here, so the accent moves off the default YES // button onto NO. MsgDialog::add_button already styled both as ButtonType::Choice, so restyling // with the same type swaps only the palette and leaves the geometry alone. @@ -2704,17 +2704,7 @@ void TextureImportDialog::on_mesh_repair_decision_required(wxCommandEvent&) dlg.Fit(); dlg.CenterOnParent(); int ret = dlg.ShowModal(); - m_mesh_repair_decision = (ret == wxID_NO) - ? Slic3r::TexturePaintingSettings::MeshRepairDecision::RepairAndImport - : Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; -#else - Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, - _L("Please note that the mesh has non-manifold geometry or open boundaries."), - _L("Mesh issue"), wxOK | wxCANCEL | wxICON_WARNING | wxOK_DEFAULT); - dlg.SetButtonLabel(wxID_OK, _L("Continue"), true); - dlg.SetButtonLabel(wxID_CANCEL, _L("Cancel")); - int ret = dlg.ShowModal(); - if (ret != wxID_OK) { + if (ret == wxID_CANCEL) { m_cancel_flag = true; if (initial) { m_initial_computation_cancelled = true; @@ -2727,8 +2717,9 @@ void TextureImportDialog::on_mesh_repair_decision_required(wxCommandEvent&) } return; } - m_mesh_repair_decision = Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; -#endif + m_mesh_repair_decision = (ret == wxID_NO) + ? Slic3r::TexturePaintingSettings::MeshRepairDecision::RepairAndImport + : Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; start_computation(auto_color, initial); } diff --git a/src/slic3r/Utils/FixModelByCgal.cpp b/src/slic3r/Utils/FixModelByCgal.cpp index 09dad529fe..081d9dcf0d 100644 --- a/src/slic3r/Utils/FixModelByCgal.cpp +++ b/src/slic3r/Utils/FixModelByCgal.cpp @@ -3,240 +3,115 @@ #include #include #include -#include #include -#include "libslic3r/TriangleMesh.hpp" -#include "libslic3r/BoundingBox.hpp" -#include "libslic3r/Point.hpp" -#include "libslic3r/libslic3r.h" -#include #include -#include "libslic3r/Exception.hpp" -#include +#include #include -#include "slic3r/GUI/Widgets/ProgressDialog.hpp" -#include #include #include -#include #include + #include -#include "libslic3r/MeshBoolean.hpp" -#include "libslic3r/Model.hpp" #include "libslic3r/Format/bbs_3mf.hpp" -#include "libslic3r/format.hpp" +#include "libslic3r/MeshRepair.hpp" +#include "libslic3r/Model.hpp" #include "libslic3r/Thread.hpp" -#include "../GUI/I18N.hpp" -#include "libslic3r/TriangleSelector.hpp" - -// Orca: This file provides utilities for repairing 3D model meshes using the CGAL library, handling mesh splitting, merging, and boolean operations. +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/Widgets/ProgressDialog.hpp" namespace Slic3r { namespace { -// Orca: Helper functions for analyzing mesh properties and transformations. - -bool is_not_3dimensional_part(const TriangleMesh &mesh) +class RepairCanceledException : public std::exception { - // Orca: Determines if a mesh is degenerate or represents a non-3dimensional part by checking volume and bounding box dimensions. - if (mesh.its.indices.empty()) - return true; - - indexed_triangle_set tmp = mesh.its; - its_remove_degenerate_faces(tmp, true); - if (tmp.indices.empty()) - return true; - - const BoundingBoxf3 bbox = mesh.bounding_box(); - const Vec3d size = bbox.size(); - const double min_dim = std::min(size.x(), std::min(size.y(), size.z())); - const double max_dim = std::max(size.x(), std::max(size.y(), size.z())); - if (min_dim <= EPSILON) - return true; - - const double volume = std::abs(its_volume(mesh.its)); - const double bbox_volume = size.x() * size.y() * size.z(); - if (volume <= EPSILON) - return true; - - const double min_relative_thickness = 1e-6; - const double min_volume_ratio = 1e-6; - if (min_dim / max_dim <= min_relative_thickness) - return true; - if (bbox_volume > 0.0 && volume / bbox_volume <= min_volume_ratio) - return true; - - return false; -} +public: + const char *what() const noexcept override { return "Model repair has been canceled"; } +}; } // namespace -// Orca: Exception class for handling user-initiated cancellation of model repair operations. -class RepairCanceledException : public std::exception { -public: - const char* what() const noexcept override { return "Model repair has been canceled"; } -}; - -// Orca: Main function to repair model objects using CGAL, with progress dialog and cancellation support. -// Returns false if fixing was canceled. fix_result contains error message if failed. -bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::ProgressDialog &progress_dialog, const wxString &msg_header, std::string &fix_result, bool keep_painting) +bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::ProgressDialog &progress_dialog, const wxString &msg_header, std::string &fix_result, bool keep_painting, + bool *modified) { - // Hold SaveObjectGaurd to prevent backup manager from racing concurrent mesh mutations (use-after-free). + // Keeps the backup manager from reading a mesh while the worker replaces it. SaveObjectGaurd backup_gaurd(model_object); - // Orca: Synchronization primitives for progress updates between worker thread and GUI. - std::mutex mtx; - std::condition_variable condition; - struct Progress { - std::string message; - int percent = 0; - bool updated = false; - } progress; + std::vector volumes; + if (volume_idx < 0) + volumes = model_object.volumes; + else if (std::size_t(volume_idx) < model_object.volumes.size()) + volumes.push_back(model_object.volumes[volume_idx]); + std::mutex mtx; + std::condition_variable condition; + struct Progress + { + std::string message; + int percent = 0; + bool updated = false; + } progress; std::atomic canceled = false; std::atomic finished = false; + bool any_modified = false; + std::string errors; - bool success = false; - size_t ivolume = 0; - - // Orca: Lambda for updating progress from worker thread. - auto on_progress = [&mtx, &condition, &ivolume, &model_object, &progress](const std::string &msg, unsigned prcnt) { - std::unique_lock lock(mtx); + auto on_progress = [&mtx, &condition, &progress](const std::string &msg, int percent) { + std::lock_guard lock(mtx); progress.message = msg; - const size_t total = std::max(1, model_object.volumes.size()); - progress.percent = int(std::floor((float(prcnt) + float(ivolume) * 100.f) / float(total))); + progress.percent = percent; progress.updated = true; condition.notify_all(); }; - // Orca: Worker thread that performs the actual model repair operations. - auto worker_thread = std::thread([&model_object, volume_idx, &ivolume, on_progress, &success, &canceled, &finished, &fix_result, keep_painting]() { + auto worker_thread = std::thread([&]() { + set_current_thread_name("cgal_fix_model"); try { - set_current_thread_name("cgal_fix_model"); + for (std::size_t i = 0; i < volumes.size(); ++i) { + ModelVolume *volume = volumes[i]; + const std::string name = volumes.size() > 1 ? volume->name : std::string(); + const auto report = [&](int percent) { + if (canceled) + throw RepairCanceledException(); + on_progress(name, int((100 * i + std::size_t(percent)) / volumes.size())); + }; + report(0); - size_t start_volume = volume_idx == -1 ? 0 : size_t(volume_idx); - size_t end_volume = volume_idx == -1 ? std::numeric_limits::max() : size_t(volume_idx); - - for (ivolume = start_volume; ivolume < model_object.volumes.size(); ++ivolume) { - if (volume_idx != -1 && ivolume > end_volume) - break; - if (canceled) - throw RepairCanceledException(); - - on_progress(_u8L("Repairing model object"), 10); - - ModelVolume *volume = model_object.volumes[ivolume]; - - // Orca: Split splittable volumes into parts for individual processing. - size_t parts_count = 1; - if (volume->is_splittable()) { - parts_count = volume->split(1, keep_painting); - if (parts_count > 1) { - const std::string msg = Slic3r::format(L("Split into %1% parts"), parts_count); - on_progress(msg, 10); - } - } - - size_t part_end = std::min(ivolume + parts_count - 1, model_object.volumes.size() - 1); - if (volume_idx != -1) - end_volume = part_end; - - size_t removed_parts = 0; - for (size_t idx = part_end + 1; idx > ivolume; --idx) { - const size_t part_idx = idx - 1; - const ModelVolume *part_volume = model_object.volumes[part_idx]; - if (!is_not_3dimensional_part(part_volume->mesh())) - continue; - - model_object.delete_volume(part_idx); - ++removed_parts; - if (part_end > 0) - --part_end; - else - part_end = 0; - if (volume_idx != -1) - end_volume = part_end; - } - - if (removed_parts >= parts_count) { - ivolume = part_end; - on_progress(_u8L("Repair finished"), 100); - continue; - } - - for (size_t part_idx = ivolume; part_idx <= part_end && part_idx < model_object.volumes.size(); ++part_idx) { - ModelVolume *part_volume = model_object.volumes[part_idx]; - TriangleMesh mesh = part_volume->mesh(); - if (its_num_open_edges(mesh.its) != 0) { - - // Save painting for later remap - const std::optional saved_painting = keep_painting ? - part_volume->save_painting() : - std::optional{}; - - std::string error; - if (!MeshBoolean::cgal::repair(mesh, nullptr, &error)) - throw Slic3r::RuntimeError(error.empty() ? _u8L("Repair failed") : error); - - part_volume->set_mesh(std::move(mesh)); - part_volume->calculate_convex_hull(); - part_volume->invalidate_convex_hull_2d(); - part_volume->set_new_unique_id(); - - // Remap paint back - part_volume->restore_painting(saved_painting); - } - } - - ivolume = part_end; - - on_progress(_u8L("Repair finished"), 100); + MeshRepairStats stats; + std::string error; + if (!volume->repair(keep_painting, &stats, &error, report)) + errors += (errors.empty() ? "" : "\n") + (volumes.size() > 1 ? volume->name + ": " : std::string()) + error; + any_modified = any_modified || stats.modified; } - - model_object.invalidate_bounding_box(); - - if (ivolume > 0) - --ivolume; - on_progress(_u8L("Repair finished"), 100); - success = true; - finished = true; - } catch (RepairCanceledException &) { + } catch (const RepairCanceledException &) { canceled = true; - finished = true; - on_progress(_u8L("Repair canceled"), 100); - } catch (std::exception &ex) { - success = false; - finished = true; - fix_result = ex.what(); - on_progress(ex.what(), 100); + } catch (const std::exception &ex) { + errors += (errors.empty() ? "" : "\n") + std::string(ex.what()); } + // Volumes repaired before a cancel or an error keep their new mesh. + model_object.invalidate_bounding_box(); + finished = true; + on_progress(_u8L("Repair finished"), 100); }); - // Orca: Main GUI loop to update progress dialog and handle cancellation. while (!finished) { std::unique_lock lock(mtx); - condition.wait_for(lock, std::chrono::milliseconds(250), [&progress]{ return progress.updated; }); - - // Decrease progress percent slightly to avoid auto-closing. - if (!progress_dialog.Update(progress.percent - 1, msg_header + _(progress.message))) + condition.wait_for(lock, std::chrono::milliseconds(250), [&progress] { return progress.updated; }); + // One percent short of the maximum, so that the dialog does not close itself. + if (!progress_dialog.Update(std::max(0, progress.percent - 1), msg_header + wxString::FromUTF8(progress.message))) canceled = true; else progress_dialog.Fit(); - progress.updated = false; } - if (canceled) { - // Nothing to show. - } else if (success) { - fix_result.clear(); - } - if (worker_thread.joinable()) worker_thread.join(); + fix_result = canceled ? std::string() : errors; + if (modified) + *modified = any_modified; return !canceled; } diff --git a/src/slic3r/Utils/FixModelByCgal.hpp b/src/slic3r/Utils/FixModelByCgal.hpp index b3f96169f6..e59e767223 100644 --- a/src/slic3r/Utils/FixModelByCgal.hpp +++ b/src/slic3r/Utils/FixModelByCgal.hpp @@ -12,8 +12,9 @@ class Model; class ModelObject; class Print; -// Return false if fixing was canceled. fix_result is empty on success. -extern bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::ProgressDialog &progress_dlg, const wxString &msg_header, std::string &fix_result, bool keep_painting); +// Return false if fixing was canceled. fix_result is empty on success; modified tells whether any mesh changed. +extern bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::ProgressDialog &progress_dlg, const wxString &msg_header, std::string &fix_result, bool keep_painting, + bool *modified = nullptr); } // namespace Slic3r diff --git a/tests/data/surface_model_geometric_set.step b/tests/data/surface_model_geometric_set.step new file mode 100644 index 0000000000..ec11fde62d --- /dev/null +++ b/tests/data/surface_model_geometric_set.step @@ -0,0 +1,35 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION((''),'2;1'); +FILE_NAME('surface_model_geometric_set','2026-10-12T00:00:00',(''),(''),'','',''); +FILE_SCHEMA(('CONFIG_CONTROL_DESIGN')); +ENDSEC; +DATA; +#1=APPLICATION_CONTEXT('configuration controlled 3d designs of mechanical parts and assemblies'); +#2=APPLICATION_PROTOCOL_DEFINITION('international standard','config_control_design',1994,#1); +#3=MECHANICAL_CONTEXT('',#1,'mechanical'); +#4=PRODUCT('surfaces','surfaces','',(#3)); +#5=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('','',#4,.NOT_KNOWN.); +#6=DESIGN_CONTEXT('',#1,'design'); +#7=PRODUCT_DEFINITION('','',#5,#6); +#8=PRODUCT_DEFINITION_SHAPE('','',#7); +#9=SHAPE_DEFINITION_REPRESENTATION(#8,#20); +#10=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)); +#11=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.)); +#12=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT()); +#13=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#10,'distance_accuracy_value',''); +#14=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#13))GLOBAL_UNIT_ASSIGNED_CONTEXT((#10,#11,#12))REPRESENTATION_CONTEXT('','')); +#15=CARTESIAN_POINT('',(0.,0.,0.)); +#16=DIRECTION('',(0.,0.,1.)); +#17=DIRECTION('',(1.,0.,0.)); +#18=AXIS2_PLACEMENT_3D('',#15,#16,#17); +#19=GEOMETRIC_SET('',(#30,#40)); +#20=GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION('',(#18,#19),#14); +#30=RECTANGULAR_TRIMMED_SURFACE('',#31,0.,10.,0.,10.,.T.,.T.); +#31=PLANE('',#18); +#40=RECTANGULAR_TRIMMED_SURFACE('',#41,0.,10.,0.,10.,.T.,.T.); +#41=PLANE('',#42); +#42=AXIS2_PLACEMENT_3D('',#43,#16,#17); +#43=CARTESIAN_POINT('',(0.,0.,10.)); +ENDSEC; +END-ISO-10303-21; diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 9e1e7c44f8..1f72f83478 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -53,6 +53,7 @@ add_executable(${_TEST_NAME}_tests test_stl.cpp test_triangle_selector.cpp test_meshboolean.cpp + test_mesh_repair.cpp test_trianglemesh_slicer.cpp test_marchingsquares.cpp test_lay_on_face.cpp diff --git a/tests/libslic3r/test_mesh_repair.cpp b/tests/libslic3r/test_mesh_repair.cpp new file mode 100644 index 0000000000..3390a65c77 --- /dev/null +++ b/tests/libslic3r/test_mesh_repair.cpp @@ -0,0 +1,255 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace Slic3r; +using Catch::Matchers::WithinRel; + +namespace { + +indexed_triangle_set cube(double size, const Vec3d &offset = Vec3d::Zero()) +{ + TriangleMesh mesh = make_cube(size, size, size); + mesh.translate(offset.cast()); + return mesh.its; +} + +indexed_triangle_set merged(const std::vector &parts) +{ + indexed_triangle_set out; + for (const indexed_triangle_set &part : parts) + its_merge(out, part); + return out; +} + +// Every triangle with its own three vertices, as some exporters write meshes. +indexed_triangle_set soup(const indexed_triangle_set &its) +{ + indexed_triangle_set out; + for (const stl_triangle_vertex_indices &f : its.indices) { + const int first = int(out.vertices.size()); + for (int k = 0; k < 3; ++k) + out.vertices.push_back(its.vertices[f[k]]); + out.indices.emplace_back(first, first + 1, first + 2); + } + return out; +} + +double regular_polygon_area(int sides, double radius) { return 0.5 * sides * radius * radius * std::sin(2. * PI / sides); } + +// Closed by vertex indices, outward oriented and free of self-intersections. +void check_solid(const TriangleMesh &mesh, double volume) +{ + CHECK(its_num_open_edges(mesh.its) == 0); + CHECK_THAT(its_volume(mesh.its), WithinRel(volume, 1e-4)); + CHECK_FALSE(MeshBoolean::cgal::does_self_intersect(mesh)); +} + +} // namespace + +TEST_CASE("A triangle soup repairs into the solid it describes", "[MeshRepair]") +{ + TriangleMesh mesh(soup(cube(20.))); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.modified); + check_solid(mesh, 20. * 20. * 20.); + CHECK(mesh.its.vertices.size() == 8); +} + +TEST_CASE("Flipped triangles take the orientation of their neighbours", "[MeshRepair]") +{ + indexed_triangle_set its = cube(20.); + std::swap(its.indices[1][1], its.indices[1][2]); + std::swap(its.indices[5][1], its.indices[5][2]); + TriangleMesh mesh(std::move(its)); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("An inside-out mesh is turned outward", "[MeshRepair]") +{ + indexed_triangle_set its = cube(20.); + its_flip_triangles(its); + TriangleMesh mesh(std::move(its)); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("Holes are filled", "[MeshRepair]") +{ + indexed_triangle_set its = cube(20.); + its.indices.erase(its.indices.begin(), its.indices.begin() + 3); + TriangleMesh mesh(std::move(its)); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.filled_holes > 0); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("Cracks between triangles that should share corners are closed", "[MeshRepair]") +{ + indexed_triangle_set its = soup(cube(20.)); + // Shift the corners of two triangles by far less than a micron, as float rounding in an exporter would. + for (std::size_t v = 0; v < 6; ++v) + its.vertices[v] += Vec3f(1e-5f, -1e-5f, 1e-5f); + TriangleMesh mesh(std::move(its)); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.welded_vertices > 0); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("A T-junction is closed", "[MeshRepair]") +{ + // The top of the cube is split at the middle of its front edge, which the front side does not share. + indexed_triangle_set its; + its.vertices = {{0, 0, 0}, {20, 0, 0}, {20, 20, 0}, {0, 20, 0}, {0, 0, 20}, {20, 0, 20}, {20, 20, 20}, {0, 20, 20}, {10, 0, 20}}; + its.indices = {{0, 2, 1}, {0, 3, 2}, {4, 8, 7}, {8, 6, 7}, {8, 5, 6}, {0, 1, 5}, {0, 5, 4}, + {1, 2, 6}, {1, 6, 5}, {2, 3, 7}, {2, 7, 6}, {3, 0, 4}, {3, 4, 7}}; + TriangleMesh mesh(std::move(its)); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("Crossing shells stay separate parts and are made solid", "[MeshRepair]") +{ + indexed_triangle_set inside_out = cube(20., {10., 10., 10.}); + its_flip_triangles(inside_out); + TriangleMesh mesh(merged({cube(20.), inside_out})); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.reversed_shells == 1); + CHECK(its_num_open_edges(mesh.its) == 0); + CHECK_THAT(its_volume(mesh.its), WithinRel(2 * 8000., 1e-4)); + CHECK(mesh.split().size() == 2); +} + +TEST_CASE("Solids touching face to face in a soup lose the wall between them", "[MeshRepair]") +{ + // The mirrored copy shares the wall at x = 10 with exactly the same triangles, reversed. + TriangleMesh right = make_cube(10., 10., 10.); + right.mirror(X); + right.translate(20.f, 0.f, 0.f); + TriangleMesh mesh(soup(merged({cube(10.), right.its}))); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 2 * 1000.); + CHECK(mesh.split().size() == 1); +} + +TEST_CASE("A shell inside a solid follows the nonzero winding rule of slicing", "[MeshRepair]") +{ + SECTION("Oriented like the outer shell, it adds nothing and is removed") + { + TriangleMesh mesh(merged({cube(20.), cube(10., {5., 5., 5.})})); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 8000.); + CHECK(mesh.its.indices.size() == 12); + } + SECTION("Oriented inward, it is a cavity and stays") + { + indexed_triangle_set cavity = cube(10., {5., 5., 5.}); + its_flip_triangles(cavity); + TriangleMesh mesh(merged({cube(20.), cavity})); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK_FALSE(stats.modified); + CHECK_THAT(its_volume(mesh.its), WithinRel(8000. - 1000., 1e-4)); + } +} + +TEST_CASE("A pipe missing its end caps gets annular caps", "[MeshRepair]") +{ + const int n = 64; + const double h = 20., r_out = 10., r_in = 7.; + indexed_triangle_set its; + for (const double r : {r_out, r_in}) + for (int k = 0; k < n; ++k) { + const double a = 2. * PI * k / n; + its.vertices.emplace_back(float(r * std::cos(a)), float(r * std::sin(a)), 0.f); + its.vertices.emplace_back(float(r * std::cos(a)), float(r * std::sin(a)), float(h)); + } + for (int k = 0; k < n; ++k) { + const int a0 = 2 * k, b0 = a0 + 1, a1 = 2 * ((k + 1) % n), b1 = a1 + 1; + its.indices.emplace_back(a0, a1, b1); + its.indices.emplace_back(a0, b1, b0); + its.indices.emplace_back(2 * n + a0, 2 * n + b1, 2 * n + a1); + its.indices.emplace_back(2 * n + a0, 2 * n + b0, 2 * n + b1); + } + TriangleMesh mesh(std::move(its)); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, h * (regular_polygon_area(n, r_out) - regular_polygon_area(n, r_in))); +} + +TEST_CASE("A stray flat triangle is dropped", "[MeshRepair]") +{ + indexed_triangle_set its = cube(20.); + its.vertices.insert(its.vertices.end(), {{30, 0, 0}, {40, 0, 0}, {30, 10, 0}}); + its.indices.emplace_back(8, 9, 10); + TriangleMesh mesh(std::move(its)); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.removed_shells == 1); + check_solid(mesh, 8000.); +} + +TEST_CASE("A two-sided export keeps one side of each triangle", "[MeshRepair]") +{ + indexed_triangle_set back = cube(20.); + its_flip_triangles(back); + TriangleMesh mesh(soup(merged({cube(20.), back}))); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 8000.); +} + +TEST_CASE("A valid mesh is left untouched", "[MeshRepair]") +{ + TriangleMesh mesh = make_sphere(10., 2. * PI / 36.); + const TriangleMesh original = mesh; + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK_FALSE(stats.modified); + CHECK(mesh.its.vertices == original.its.vertices); + CHECK(mesh.its.indices == original.its.indices); +} + +TEST_CASE("A mesh enclosing no volume is reported and left untouched", "[MeshRepair]") +{ + indexed_triangle_set its; + its.vertices = {{0, 0, 0}, {10, 0, 0}, {10, 10, 0}, {0, 10, 0}}; + its.indices = {{0, 1, 2}, {0, 2, 3}}; + TriangleMesh mesh(its); + std::string error; + CHECK_FALSE(repair_mesh(mesh, nullptr, &error)); + CHECK_FALSE(error.empty()); + CHECK(mesh.its.indices.size() == 2); +} + +TEST_CASE("Progress is reported in order and throwing from it cancels the repair", "[MeshRepair]") +{ + TriangleMesh mesh(soup(cube(20.))); + std::vector reported; + REQUIRE(repair_mesh(mesh, nullptr, nullptr, [&reported](int percent) { reported.push_back(percent); })); + CHECK_FALSE(reported.empty()); + CHECK(std::is_sorted(reported.begin(), reported.end())); + + struct Canceled {}; + TriangleMesh canceled(soup(cube(20.))); + const TriangleMesh original = canceled; + CHECK_THROWS_AS(repair_mesh(canceled, nullptr, nullptr, [](int) { throw Canceled(); }), Canceled); + CHECK(canceled.its.vertices == original.its.vertices); + CHECK(canceled.its.indices == original.its.indices); +} diff --git a/tests/libslic3r/test_model.cpp b/tests/libslic3r/test_model.cpp index 5fc72a2b9b..abdaabd801 100644 --- a/tests/libslic3r/test_model.cpp +++ b/tests/libslic3r/test_model.cpp @@ -12,7 +12,9 @@ #include #include #include +#include "libslic3r/MeshRepair.hpp" #include "libslic3r/Model.hpp" +#include "libslic3r/ObjectID.hpp" #include "libslic3r/Geometry.hpp" #include "libslic3r/libslic3r.h" @@ -97,3 +99,104 @@ TEST_CASE("An instance added from another's scale, rotation and mirror matches i original->get_rotation(), original->get_mirror()); CHECK(copy->get_matrix().linear().isApprox(original->get_matrix().linear(), 1e-9)); } + +namespace { + +// A flat triangle first, then a cube for each offset, all in one mesh. +TriangleMesh flat_triangle_then_cubes(const std::vector &cube_offsets) +{ + indexed_triangle_set its; + its.vertices = {{-20.f, 0.f, 0.f}, {-10.f, 0.f, 0.f}, {-15.f, 5.f, 0.f}}; + its.indices = {{0, 1, 2}}; + for (double offset : cube_offsets) { + TriangleMesh cube = make_cube(10, 10, 10); + cube.translate(float(offset), 0.f, 0.f); + its_merge(its, cube.its); + } + return TriangleMesh(its); +} + +} // namespace + +TEST_CASE("Splitting a part drops pieces enclosing no volume and keeps the part itself", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + ModelVolume *volume = object->add_volume(flat_triangle_then_cubes({0., 30.}), ModelVolumeType::MODEL_PART, false); + REQUIRE(volume->is_splittable()); + + CHECK(volume->split(1, false) == 2); + REQUIRE(object->volumes.size() == 2); + CHECK(std::find(object->volumes.begin(), object->volumes.end(), volume) != object->volumes.end()); + for (const ModelVolume *part : object->volumes) { + CHECK(part->mesh().facets_count() == 12); + CHECK_FALSE(part->is_splittable()); + } +} + +TEST_CASE("Splitting a part with a single solid piece keeps just that piece", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + ModelVolume *volume = object->add_volume(flat_triangle_then_cubes({0.}), ModelVolumeType::MODEL_PART, false); + + CHECK(volume->split(1, false) == 1); + REQUIRE(object->volumes.size() == 1); + CHECK(object->volumes.front() == volume); + CHECK(volume->mesh().facets_count() == 12); +} + +TEST_CASE("Splitting a part made only of pieces enclosing no volume leaves it whole", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + indexed_triangle_set its; + its.vertices = {{0.f, 0.f, 0.f}, {10.f, 0.f, 0.f}, {5.f, 5.f, 0.f}, {20.f, 0.f, 0.f}, {30.f, 0.f, 0.f}, {25.f, 5.f, 0.f}}; + its.indices = {{0, 1, 2}, {3, 4, 5}}; + ModelVolume *volume = object->add_volume(TriangleMesh(its), ModelVolumeType::MODEL_PART, false); + + CHECK(volume->split(1, false) == 1); + REQUIRE(object->volumes.size() == 1); + CHECK(object->volumes.front() == volume); + CHECK(volume->mesh().facets_count() == 2); +} + +TEST_CASE("Replacing a part's mesh updates whether it can be split", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + TriangleMesh two_cubes = make_cube(10, 10, 10); + TriangleMesh second = make_cube(10, 10, 10); + second.translate(30.f, 0.f, 0.f); + two_cubes.merge(second); + ModelVolume *volume = object->add_volume(std::move(two_cubes), ModelVolumeType::MODEL_PART, false); + REQUIRE(volume->is_splittable()); + + volume->set_mesh(make_cube(10, 10, 10)); + CHECK_FALSE(volume->is_splittable()); +} + +TEST_CASE("Repairing a part replaces its mesh only when the repair changes it", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + TriangleMesh flipped = make_cube(10, 10, 10); + std::swap(flipped.its.indices.front()[1], flipped.its.indices.front()[2]); + ModelVolume *broken = object->add_volume(std::move(flipped), ModelVolumeType::MODEL_PART, false); + TriangleMesh cube = make_cube(10, 10, 10); + cube.translate(30.f, 0.f, 0.f); + ModelVolume *valid = object->add_volume(std::move(cube), ModelVolumeType::MODEL_PART, false); + const ObjectID broken_id = broken->id(); + const ObjectID valid_id = valid->id(); + + MeshRepairStats stats; + REQUIRE(broken->repair(false, &stats)); + CHECK(stats.modified); + CHECK(broken->id() != broken_id); + CHECK_THAT(its_volume(broken->mesh().its), Catch::Matchers::WithinRel(1000., 1e-6)); + CHECK_FALSE(broken->get_convex_hull().empty()); + + REQUIRE(valid->repair(false, &stats)); + CHECK_FALSE(stats.modified); + CHECK(valid->id() == valid_id); +} diff --git a/tests/libslic3r/test_step.cpp b/tests/libslic3r/test_step.cpp index 5d843499fa..ff8ce1dcba 100644 --- a/tests/libslic3r/test_step.cpp +++ b/tests/libslic3r/test_step.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include "libslic3r/Model.hpp" @@ -91,6 +92,26 @@ TEST_CASE("A cone with a slightly tilted seam imports as a closed mesh", "[Step] CHECK(its_num_open_edges(model.objects.front()->volumes.front()->mesh().its) == 0); } +// The fixture holds two trimmed planes in a geometric set and no shells or solids, as some CAD exporters write +// surface models, so OCCT reads it as a compound of loose faces. +TEST_CASE("A surface model imports whether or not compounds are split", "[Step]") +{ + const std::string path = std::string(TEST_DATA_DIR) + PATH_SEPARATOR "surface_model_geometric_set.step"; + const bool split = GENERATE(false, true); + CAPTURE(split); + + Model model; + bool cancel = false; + Step step(path); + + REQUIRE(step.load() == Step::Step_Status::LOAD_SUCCESS); + REQUIRE(step.mesh(&model, cancel, split) == Step::Step_Status::MESH_SUCCESS); + + REQUIRE(model.objects.size() == 1); + REQUIRE(model.objects.front()->volumes.size() == 1); + CHECK(model.objects.front()->volumes.front()->mesh().facets_count() > 0); +} + TEST_CASE("isUtf8 recognises two, three and four byte sequences", "[Step]") { CHECK(StepPreProcessor::isUtf8("\xC3\xA9")); // U+00E9