From 6d35ff6b9a75a0a1fd6e06da29d0e882892c6226 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Mon, 12 Oct 2026 21:59:17 -0300 Subject: [PATCH] Rewrite Fix Model on CGAL 6.2, fix Split to parts, remove Win10 SDK leftovers (#16366) * Add CGAL-based mesh repair, drop Netfabb remnants Add repair_mesh() in MeshRepair.cpp, a CGAL-based repair that replaces MeshBoolean::cgal::repair(). It welds cracks, cancels coincident triangles, fixes orientation, fills holes (including planar annular regions), removes local self-intersections, and unites crossing shells with libigl's exact boolean while following the nonzero winding rule used by slicing. FixModelByCgal now repairs each volume in place without splitting it into parts, and reports whether the mesh was modified so unchanged meshes keep their painting and ids. The texture import repair prompt no longer depends on the Windows SDK, and the FIX_THROUGH_CGAL_ALWAYS conditionals are removed. The commented-out Netfabb/WIN10SDK build code is deleted. libslic3r_cgal now builds with CGAL_LINKED_WITH_TBB and links TBB. Add docs/HLSD/mesh-repair.md and unit tests in test_mesh_repair.cpp. * Parallelize repair while preserving separate shells Repair connected mesh units and shells independently in parallel, keep crossing shells separate, and limit self-intersection remeshing to local defects. Update mesh-repair documentation and tests for the revised behavior. * Make mesh repair deterministic and report its progress Flags written from parallel loops were std::vector, whose neighbouring bits share a word, so shells could be kept or dropped at random; they are now std::vector. Holes of up to 100 edges are filled with the search over all triangles: the search restricted to a 3D Delaunay triangulation picks between equally good fills differently from run to run. Shells of fewer than four faces are dropped, as the volumes of two triangles back to back need not cancel exactly, and a shell that self-intersection removal changed is split again so the pieces it cut off are dropped too. repair_mesh() takes a progress callback in place of the cancel callback, and Fix Model reports each stage. The object's bounding box is now invalidated even when a cancel or an error stops the repair after some volumes were replaced. The edge-use sort, union-find and box-overlap code shared by several stages is factored out, the topology analysed for welding is reused to build the work units, the orientation pass checks only the edges it cut, and the output is written from the shells without joining them first. * Fix Split to parts crashing on flat pieces and staying offered after a repair ModelVolume::split() added every piece to the object and then deleted the pieces without a convex hull. When the first piece was flat, as often in a triangle soup, that deleted the volume being split while split() and its caller kept using it. A part made only of flat pieces lost all its volumes, and a soup added thousands of pieces, saving a backup of the object for each one it deleted. Pieces without a convex hull are now dropped before any joins the object, the volume being split keeps the first remaining piece, and a part with no solid piece is left whole. Other parts of the object without a convex hull are no longer deleted as a side effect. ModelVolume caches whether its mesh can be split, and set_mesh() kept the cached value, so after Fix Model, Simplify or Emboss replaced a mesh, Split to parts could still be offered for a single piece. set_mesh() and reset_mesh() now clear the cache. * Import STEP surface models with Split compound enabled Splitting a compound kept only its solids, so a surface model, which OCCT reads as a compound of loose faces, gave nothing to mesh and the import failed with "Meshing of a model file failed or no valid shape". Each shell outside a solid now becomes a part too, and loose faces stay together as one part, as they do without splitting. * Add Fix All for plates and re-enable --repair on the command line ModelVolume::repair() applies repair_mesh() to a part and replaces the mesh only when the repair changed it. Fix Model, the new Fix All and Fix All Plates items of the plate context menu, and the command line's --repair option, which had been commented out since the Win10 SDK repair was dropped, all use it. With Keep painted feature after mesh change off, Fix Model cleared the painting of every part of an object before repairing. Now only a part whose mesh changes loses its painting, so fixing a whole project does not wipe it. The notification counts the models that needed no repair instead of listing them as repaired, and objects that did not change are not refreshed. --- .github/workflows/build_orca.yml | 4 - CMakeLists.txt | 41 - docs/HLSD/mesh-repair.md | 135 ++ src/OrcaSlicer.cpp | 17 +- src/libslic3r/CMakeLists.txt | 5 +- src/libslic3r/Format/STEP.cpp | 20 +- src/libslic3r/MeshBoolean.cpp | 88 -- src/libslic3r/MeshBoolean.hpp | 3 - src/libslic3r/MeshRepair.cpp | 1215 +++++++++++++++++ src/libslic3r/MeshRepair.hpp | 28 + src/libslic3r/Model.cpp | 61 +- src/libslic3r/Model.hpp | 18 +- src/libslic3r/PrintConfig.cpp | 7 +- .../TextureToColor/TextureToColor.cpp | 6 +- src/slic3r/GUI/GUI_Factories.cpp | 10 + src/slic3r/GUI/GUI_ObjectList.cpp | 120 +- src/slic3r/GUI/GUI_ObjectList.hpp | 6 +- src/slic3r/GUI/Plater.cpp | 35 +- src/slic3r/GUI/Plater.hpp | 1 + src/slic3r/GUI/TextureImportDialog.cpp | 43 +- src/slic3r/Utils/FixModelByCgal.cpp | 245 +--- src/slic3r/Utils/FixModelByCgal.hpp | 5 +- tests/data/surface_model_geometric_set.step | 35 + tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_mesh_repair.cpp | 255 ++++ tests/libslic3r/test_model.cpp | 103 ++ tests/libslic3r/test_step.cpp | 21 + 27 files changed, 2053 insertions(+), 475 deletions(-) create mode 100644 docs/HLSD/mesh-repair.md create mode 100644 src/libslic3r/MeshRepair.cpp create mode 100644 src/libslic3r/MeshRepair.hpp create mode 100644 tests/data/surface_model_geometric_set.step create mode 100644 tests/libslic3r/test_mesh_repair.cpp 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