diff --git a/build_release_macos.sh b/build_release_macos.sh index cdc5dbbe3c..9eca2e7f40 100755 --- a/build_release_macos.sh +++ b/build_release_macos.sh @@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do u ) export BUILD_TARGET="universal" ;; - h ) echo "Usage: ./build_release_macos.sh [-d]" + h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]" echo " -d: Build deps only" echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)" echo " -s: Build slicer only" @@ -60,13 +60,33 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew" echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)" echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)" + echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds," + echo " e.g. -DDESTDIR= for the deps, -DCMAKE_PREFIX_PATH=/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh." + echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory." exit 0 ;; + : ) + echo "Option -${OPTARG} requires an argument, aborting." + exit 1 + ;; * ) + echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'." + exit 1 ;; esac done +# getopts stops at '--'; whatever follows is handed to cmake unchanged. +if [[ ${OPTIND} -le $# ]] ; then + separator_index=$((OPTIND-1)) + if [[ "${!separator_index}" != "--" ]] ; then + echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting." + exit 1 + fi +fi +shift $((OPTIND-1)) +EXTRA_CMAKE_ARGS=("$@") + # Set defaults if [ -z "$ARCH" ]; then @@ -211,7 +231,8 @@ function build_deps() { -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ "${CMAKE_CCACHE_ARGS[@]}" \ - ${CMAKE_POLICY_COMPAT} + ${CMAKE_POLICY_COMPAT} \ + "${EXTRA_CMAKE_ARGS[@]}" fi cmake --build . --config "$BUILD_CONFIG" --target deps ) @@ -314,7 +335,8 @@ function build_slicer() { -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ "${CMAKE_CCACHE_ARGS[@]}" \ - ${CMAKE_POLICY_COMPAT} + ${CMAKE_POLICY_COMPAT} \ + "${EXTRA_CMAKE_ARGS[@]}" fi cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET" ) diff --git a/deps/GMP/GMP.cmake b/deps/GMP/GMP.cmake index b3ba181753..4f2beedb4f 100644 --- a/deps/GMP/GMP.cmake +++ b/deps/GMP/GMP.cmake @@ -59,9 +59,13 @@ else () set(_cross_compile_arg --host=${TOOLCHAIN_PREFIX}) endif () + # 6.3.0 is the first release whose arm64 assembly avoids x18, which macOS + # reserves and zeroes on every return to user space; 6.2.1 crashes on Apple + # silicon whenever CGAL falls back to exact arithmetic. ExternalProject_Add(dep_GMP - URL https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2 - URL_HASH SHA256=eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c + URL https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz + https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.3.0/gmp-6.3.0.tar.xz + URL_HASH SHA256=a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898 DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/GMP PATCH_COMMAND git apply ${GMP_DIRECTORY_FLAG} --verbose ${CMAKE_CURRENT_LIST_DIR}/0001-GMP_GCC15.patch BUILD_IN_SOURCE ON diff --git a/docs/HLSD/polygon-clipping.md b/docs/HLSD/polygon-clipping.md index 60bf97d515..00a1e2863b 100644 --- a/docs/HLSD/polygon-clipping.md +++ b/docs/HLSD/polygon-clipping.md @@ -62,6 +62,23 @@ Clipper2 behaves differently by default, the wrapper adjusts it. clip do not leave slivers. - Open polylines are clipped with the non-zero rule and keep their direction. +### Tiled booleans + +The sweep slows down with the number of edges crossing a scan line, so a layer +cut into thousands of pieces makes every whole-layer boolean expensive. +`diff_ex_by_piece()` and `intersection_ex_by_piece()` take a subject of +non-overlapping `ExPolygons`, group them into tiles with +`ClipperUtils::tile_expolygons()`, and run each tile in parallel against only +the clip polygons near it, cut to the tile's box. Below 128 pieces there is a +single tile, and they are the plain `diff_ex()` / `intersection_ex()`. + +The result covers the same area as the plain call. Without the safety offset +the rings are the same. With it, each tile unites only the clip polygons near +it, so a clip edge that the whole-layer union splits where it crosses a distant +clip polygon stays whole, and a crossing with the subject can round 1 unit +differently. The tiles' results are concatenated in tile order, so the order of +the output `ExPolygons` differs from the plain call. + ### Offsets - Before offsetting, input vertices closer than diff --git a/resources/profiles/Flashforge.json b/resources/profiles/Flashforge.json index be6e64da1e..5625649e3b 100644 --- a/resources/profiles/Flashforge.json +++ b/resources/profiles/Flashforge.json @@ -1,7 +1,7 @@ { "name": "Flashforge", "url": "", - "version": "02.04.00.11", + "version": "02.04.00.12", "force_update": "0", "description": "Flashforge configurations", "machine_model_list": [ diff --git a/resources/profiles/Flashforge/process/0.20mm Standard @Flashforge AD5M 0.4 Nozzle.json b/resources/profiles/Flashforge/process/0.20mm Standard @Flashforge AD5M 0.4 Nozzle.json index 498f9c3064..849bc46c44 100644 --- a/resources/profiles/Flashforge/process/0.20mm Standard @Flashforge AD5M 0.4 Nozzle.json +++ b/resources/profiles/Flashforge/process/0.20mm Standard @Flashforge AD5M 0.4 Nozzle.json @@ -8,6 +8,7 @@ "compatible_printers": [ "Flashforge Adventurer 5M 0.4 Nozzle" ], + "exclude_object_tolerance": "0.2", "filename_format": "{input_filename_base}_{filament_type[initial_tool]}_{print_time}.gcode", "post_process": "", "only_one_wall_top": "0", diff --git a/resources/profiles/Flashforge/process/0.20mm Standard @Flashforge AD5M Pro 0.4 Nozzle.json b/resources/profiles/Flashforge/process/0.20mm Standard @Flashforge AD5M Pro 0.4 Nozzle.json index cf9f12b7f2..bb45f2a088 100644 --- a/resources/profiles/Flashforge/process/0.20mm Standard @Flashforge AD5M Pro 0.4 Nozzle.json +++ b/resources/profiles/Flashforge/process/0.20mm Standard @Flashforge AD5M Pro 0.4 Nozzle.json @@ -8,6 +8,7 @@ "compatible_printers": [ "Flashforge Adventurer 5M Pro 0.4 Nozzle" ], + "exclude_object_tolerance": "0.2", "filename_format": "{input_filename_base}_{filament_type[0]}_{print_time}.gcode", "post_process": "", "only_one_wall_top": "0", diff --git a/resources/profiles/Flashforge/process/0.30mm Standard @Flashforge AD5M 0.6 Nozzle.json b/resources/profiles/Flashforge/process/0.30mm Standard @Flashforge AD5M 0.6 Nozzle.json index 7540593b77..4ee2bd6afa 100644 --- a/resources/profiles/Flashforge/process/0.30mm Standard @Flashforge AD5M 0.6 Nozzle.json +++ b/resources/profiles/Flashforge/process/0.30mm Standard @Flashforge AD5M 0.6 Nozzle.json @@ -8,6 +8,7 @@ "compatible_printers": [ "Flashforge Adventurer 5M 0.6 Nozzle" ], + "exclude_object_tolerance": "0.2", "filename_format": "{input_filename_base}_{filament_type[initial_tool]}_{print_time}.gcode", "post_process": "", "only_one_wall_top": "0", diff --git a/resources/profiles/Flashforge/process/0.30mm Standard @Flashforge AD5M Pro 0.6 Nozzle.json b/resources/profiles/Flashforge/process/0.30mm Standard @Flashforge AD5M Pro 0.6 Nozzle.json index efc774872f..5fefdc83e7 100644 --- a/resources/profiles/Flashforge/process/0.30mm Standard @Flashforge AD5M Pro 0.6 Nozzle.json +++ b/resources/profiles/Flashforge/process/0.30mm Standard @Flashforge AD5M Pro 0.6 Nozzle.json @@ -8,6 +8,7 @@ "compatible_printers": [ "Flashforge Adventurer 5M Pro 0.6 Nozzle" ], + "exclude_object_tolerance": "0.2", "filename_format": "{input_filename_base}_{filament_type[initial_tool]}_{print_time}.gcode", "post_process": "", "only_one_wall_top": "0", diff --git a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml index cca417f735..27c9fcfdc2 100644 --- a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml +++ b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml @@ -226,10 +226,10 @@ modules: sha256: f353e7b99bd0cbfc27ac9082de51acf32a8bc0b3e21ff9661ecca6f205ec1d81 dest: external-packages/OpenVDB - # GMP 6.2.1 + # GMP 6.3.0 - type: file - url: https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2 - sha256: eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c + url: https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz + sha256: a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898 dest: external-packages/GMP # MPFR 4.2.2 diff --git a/src/libslic3r/ClipperUtils.cpp b/src/libslic3r/ClipperUtils.cpp index 8e9decf37a..0b4033ce8f 100644 --- a/src/libslic3r/ClipperUtils.cpp +++ b/src/libslic3r/ClipperUtils.cpp @@ -7,9 +7,13 @@ #include #include #include +#include #include +#include + #include "ClipperUtils.hpp" +#include "AABBTreeIndirect.hpp" #include "BoundingBox.hpp" #include "ExPolygon.hpp" #include "Point.hpp" @@ -276,6 +280,7 @@ Polylines c2_to_polylines(const C2::Paths64 &paths) return out; } +// Only sizes the reserve of c2_to_expolygons(): for a tree Clipper2 misnested (see below) the count is approximate. size_t c2_count_expolygons(const C2::PolyPath64 &outer) { size_t cnt = 1; @@ -285,17 +290,90 @@ size_t c2_count_expolygons(const C2::PolyPath64 &outer) return cnt; } -void c2_append_expolygons(const C2::PolyPath64 &outer, ExPolygons &out) +// Clipper2 sometimes puts a path at the wrong level of its PolyTree: a hole beside the outer path around it instead of +// under it, or an island at the level of holes. Taken as they are, a misplaced hole becomes a clockwise contour and its +// outer an expolygon that covers it; whole-layer booleans cancel the two, but anything handling expolygons one at a time +// sees the hole filled. So the orientation of a path decides its role: a clockwise path where a contour belongs is +// collected in stray_holes, for c2_attach_stray_holes() to give to the contour around it. +void c2_append_expolygons(const C2::PolyPath64 &outer, ExPolygons &out, std::vector &stray_holes) { - ExPolygon &expoly = out.emplace_back(); - expoly.contour.points = c2_to_points(outer.Polygon()); - expoly.holes.reserve(outer.Count()); - for (const auto &hole : outer) - expoly.holes.emplace_back().points = c2_to_points(hole->Polygon()); - // Islands inside the holes. - for (const auto &hole : outer) - for (const auto &island : *hole) - c2_append_expolygons(*island, out); + if (C2::Area(outer.Polygon()) < 0.) { + stray_holes.emplace_back(&outer.Polygon()); + for (const auto &island : outer) + c2_append_expolygons(*island, out, stray_holes); + return; + } + // Indexed, as the recursion below may reallocate out. + const size_t idx = out.size(); + out.emplace_back().contour.points = c2_to_points(outer.Polygon()); + out[idx].holes.reserve(outer.Count()); + for (const auto &child : outer) + if (C2::Area(child->Polygon()) > 0.) + c2_append_expolygons(*child, out, stray_holes); + else { + out[idx].holes.emplace_back().points = c2_to_points(child->Polygon()); + // Islands inside the hole. + for (const auto &island : *child) + c2_append_expolygons(*island, out, stray_holes); + } +} + +// Whether outer encloses inner, two paths of one Clipper2 result. Such paths don't cross, they only touch, and rounding +// both to the grid leaves a touching vertex up to about 1.5 units across the other path. So a vertex of inner that close +// to outer doesn't count, the first vertex clear of it decides, and a path touching at every vertex goes by a point +// strictly inside it: either lies on the same side of outer as the whole path. A sliver too thin to hold such a point is +// taken as not enclosed. A hole closely hugging an island inside it would pass, so callers skip any outer no larger than +// inner: a path inside another is always the smaller. +bool path_encloses(const Polygon &outer, const Polygon &inner) +{ + // The rounding of the two paths, and of the projection Polygon::on_boundary() measures to. + static constexpr double touching = 3.; + for (const Point &pt : inner.points) + if (! outer.on_boundary(pt, touching)) + return contains(outer, pt, true); + const std::optional pt = ClipperUtils::interior_point(inner); + return pt && contains(outer, *pt, true); +} + +// Gives each stray hole to the innermost of the contours out[first..] around it. Contours of a Clipper2 result don't +// cross, so those around a hole are nested and the innermost one is the smallest: candidates are tried smallest first, +// from the first one larger than the hole. +void c2_attach_stray_holes(ExPolygons &out, size_t first, const std::vector &stray_holes) +{ + if (stray_holes.empty()) + return; + using Tree = AABBTreeIndirect::Tree<2, coord_t>; + std::vector bboxes; + bboxes.reserve(out.size() - first); + for (size_t i = first; i < out.size(); ++ i) + bboxes.emplace_back(i, get_extents(out[i].contour)); + Tree tree; + tree.build_modify_input(bboxes); + // Contour areas, computed when a contour first becomes a candidate. + std::vector areas(out.size(), 0.); + std::vector candidates; + for (const C2::Path64 *path : stray_holes) { + Polygon hole; + hole.points = c2_to_points(*path); + const double hole_area = - C2::Area(*path); + const BoundingBox bbox = get_extents(hole); + const Tree::BoundingBox hole_bbox(bbox.min, bbox.max); + candidates.clear(); + AABBTreeIndirect::traverse(tree, + [&hole_bbox](const Tree::Node &node) { return node.bbox.contains(hole_bbox); }, + [&candidates](const Tree::Node &node) { candidates.emplace_back(node.idx); return true; }); + for (size_t idx : candidates) + if (areas[idx] == 0.) + areas[idx] = out[idx].contour.area(); + std::sort(candidates.begin(), candidates.end(), [&areas](size_t a, size_t b) { return areas[a] < areas[b]; }); + auto larger = std::upper_bound(candidates.begin(), candidates.end(), hole_area, [&areas](double area, size_t idx) { return area < areas[idx]; }); + if (auto it = std::find_if(larger, candidates.end(), [&out, &hole](size_t idx) { return path_encloses(out[idx].contour, hole); }); + it == candidates.end()) + // Nothing around it: keep the path as Clipper2 returned it. + out.emplace_back().contour = std::move(hole); + else + out[*it].holes.emplace_back(std::move(hole)); + } } ExPolygons c2_to_expolygons(const C2::PolyTree64 &tree) @@ -305,8 +383,10 @@ ExPolygons c2_to_expolygons(const C2::PolyTree64 &tree) cnt += c2_count_expolygons(*outer); ExPolygons out; out.reserve(cnt); + std::vector stray_holes; for (const auto &outer : tree) - c2_append_expolygons(*outer, out); + c2_append_expolygons(*outer, out, stray_holes); + c2_attach_stray_holes(out, 0, stray_holes); return out; } @@ -800,6 +880,114 @@ Slic3r::ExPolygons intersection_ex(const Slic3r::Surfaces &subject, const Slic3r { return _clipper_ex(ctIntersection, ClipperUtils::SurfacesProvider(subject), ClipperUtils::SurfacesProvider(clip), do_safety_offset); } Slic3r::ExPolygons intersection_ex(const Slic3r::SurfacesPtr &subject, const Slic3r::ExPolygons &clip, ApplySafetyOffset do_safety_offset) { return _clipper_ex(ctIntersection, ClipperUtils::SurfacesPtrProvider(subject), ClipperUtils::ExPolygonsProvider(clip), do_safety_offset); } + +namespace ClipperUtils { + std::vector tile_expolygons(const ExPolygons &expolygons, size_t per_tile) + { + BoundingBox extent; + std::vector bboxes; + bboxes.reserve(expolygons.size()); + for (const ExPolygon &expoly : expolygons) { + bboxes.emplace_back(get_extents(expoly)); + extent.merge(bboxes.back()); + } + if (! extent.defined) + return {}; + const int tiles = std::clamp(int(std::sqrt(double(expolygons.size()) / double(std::max(per_tile, 1)))), 1, 32); + const Point size = extent.size(); + const coord_t tile_w = std::max(1, size.x() / tiles + 1), tile_h = std::max(1, size.y() / tiles + 1); + std::vector out(size_t(tiles * tiles)); + for (size_t i = 0; i < expolygons.size(); ++ i) { + const Point c = bboxes[i].center(); + ExPolygonsTile &tile = out[size_t(std::clamp(int((c.y() - extent.min.y()) / tile_h), 0, tiles - 1) * tiles + + std::clamp(int((c.x() - extent.min.x()) / tile_w), 0, tiles - 1))]; + tile.members.emplace_back(i); + tile.bbox.merge(bboxes[i]); + } + out.erase(std::remove_if(out.begin(), out.end(), [](const ExPolygonsTile &tile) { return tile.members.empty(); }), out.end()); + return out; + } + + // Between two consecutive heights of its vertices the polygon is a set of trapezoids, and the line through the middle + // of that band meets no vertex and no horizontal edge: its crossings with the edges alternate into and out of the + // polygon, and there each trapezoid is at least half as wide as at its widest. So the bands are tried widest first, + // until the middle of the widest span on that line rounds to a point strictly inside. A band under two units has no + // grid line inside it. + std::optional interior_point(const Polygon &polygon) + { + std::vector heights; + heights.reserve(polygon.size()); + for (const Point &pt : polygon.points) + heights.emplace_back(pt.y()); + std::sort(heights.begin(), heights.end()); + heights.erase(std::unique(heights.begin(), heights.end()), heights.end()); + std::vector> bands; + for (size_t i = 1; i < heights.size(); ++ i) + if (heights[i] - heights[i - 1] >= 2) + bands.emplace_back(heights[i - 1], heights[i]); + std::sort(bands.begin(), bands.end(), [](const auto &a, const auto &b) { return a.second - a.first > b.second - b.first; }); + std::vector crossings; + for (const auto &[low, high] : bands) { + const coord_t y = low + (high - low) / 2; + crossings.clear(); + for (size_t i = 0, j = polygon.size() - 1; i < polygon.size(); j = i ++) { + const Point &a = polygon.points[j]; + const Point &b = polygon.points[i]; + if ((a.y() > y) != (b.y() > y)) + crossings.emplace_back(double(a.x()) + double(y - a.y()) * double(b.x() - a.x()) / double(b.y() - a.y())); + } + std::sort(crossings.begin(), crossings.end()); + // Inside from the first crossing to the second, from the third to the fourth... + double left = 0., right = 0.; + for (size_t i = 1; i < crossings.size(); i += 2) + if (crossings[i] - crossings[i - 1] > right - left) { + left = crossings[i - 1]; + right = crossings[i]; + } + if (const Point pt(coord_t(std::round(0.5 * (left + right))), y); right > left && contains(polygon, pt, false)) + return pt; + } + return std::nullopt; + } +} + +static Slic3r::ExPolygons clipper_ex_by_piece(ClipType clipType, const Slic3r::ExPolygons &subject, const Slic3r::Polygons &clip, ApplySafetyOffset do_safety_offset) +{ + // A few dozen subject ExPolygons to a tile, each tile one Clipper call with the clip cut to the tile's box. + const std::vector tiles = ClipperUtils::tile_expolygons(subject, 32); + // One tile is the plain call: cutting the clip would only cost time. + if (tiles.size() <= 1) + return _clipper_ex(clipType, ClipperUtils::ExPolygonsProvider(subject), ClipperUtils::PolygonsProvider(clip), do_safety_offset); + std::vector clip_bboxes; + clip_bboxes.reserve(clip.size()); + for (const Polygon &polygon : clip) + clip_bboxes.emplace_back(get_extents(polygon)); + + std::vector out_tiles(tiles.size()); + tbb::parallel_for(size_t(0), tiles.size(), [&](size_t tile_idx) { + const ClipperUtils::ExPolygonsTile &tile = tiles[tile_idx]; + Slic3r::ExPolygons local_subject; + local_subject.reserve(tile.members.size()); + for (size_t i : tile.members) + local_subject.emplace_back(subject[i]); + // Grown so that the cut edges of the clip stay clear of the subject, also after the safety offset. + const BoundingBox bbox = tile.bbox.inflated(SCALED_EPSILON); + Polygons local_clip; + for (size_t i = 0; i < clip.size(); ++i) + if (clip_bboxes[i].overlap(bbox)) + if (Polygon clipped = ClipperUtils::clip_clipper_polygon_with_subject_bbox(clip[i], bbox); ! clipped.empty()) + local_clip.emplace_back(std::move(clipped)); + out_tiles[tile_idx] = _clipper_ex(clipType, ClipperUtils::ExPolygonsProvider(local_subject), ClipperUtils::PolygonsProvider(local_clip), do_safety_offset); + }); + Slic3r::ExPolygons out; + for (Slic3r::ExPolygons &out_tile : out_tiles) + append(out, std::move(out_tile)); + return out; +} +Slic3r::ExPolygons diff_ex_by_piece(const Slic3r::ExPolygons &subject, const Slic3r::Polygons &clip, ApplySafetyOffset do_safety_offset) + { return clipper_ex_by_piece(ctDifference, subject, clip, do_safety_offset); } +Slic3r::ExPolygons intersection_ex_by_piece(const Slic3r::ExPolygons &subject, const Slic3r::Polygons &clip, ApplySafetyOffset do_safety_offset) + { return clipper_ex_by_piece(ctIntersection, subject, clip, do_safety_offset); } // May be used to "heal" unusual models (3DLabPrints etc.) by providing fill_type (pftEvenOdd, pftNonZero, pftPositive, pftNegative). Slic3r::ExPolygons union_ex(const Slic3r::Polygons &subject, PolyFillType fill_type) { return _clipper_ex(ctUnion, ClipperUtils::PolygonsProvider(subject), ClipperUtils::EmptyPathsProvider(), ApplySafetyOffset::No, fill_type); } @@ -1007,24 +1195,75 @@ Lines _clipper_ln(ClipType clipType, const Lines &subject, const Polygons &clip) return retval; } -static void traverse_pt_outside_in(const C2::PolyPath64 &parent, Polygons *retval) +// Chains the loops of one level of the tree, each followed by the loops under it, depth first. Clipper2 may put a loop +// beside the loop around it instead of under it (see c2_append_expolygons()), out of the nesting order the callers rely +// on: the spiral of FillSpiralInset continues only into a loop inside the last one. So a loop whose orientation doesn't +// fit its level (CCW under the top or a CW loop, CW under a CCW loop) goes under the smallest loop of the level around +// it, the one Clipper2 failed to put it under, and stays where it is if none is. +static void traverse_pt_outside_in(const std::vector &nodes, bool holes, Polygons *retval) { - // collect ordering points - Points ordering_points; - ordering_points.reserve(parent.Count()); - for (const auto &node : parent) - ordering_points.emplace_back(node->Polygon().front().x, node->Polygon().front().y); + struct Loop + { + const C2::PolyPath64 *node; + double area; + // The loops to traverse under this one: its children, then the misplaced loops it takes in. + std::vector next; + bool moved = false; + }; + std::vector loops; + std::vector fitting, misfits; + loops.reserve(nodes.size()); + for (const C2::PolyPath64 *node : nodes) { + Loop &loop = loops.emplace_back(Loop{ node, C2::Area(node->Polygon()), {} }); + loop.next.reserve(node->Count()); + for (const auto &child : *node) + loop.next.emplace_back(child.get()); + ((loop.area < 0.) == holes ? fitting : misfits).emplace_back(loops.size() - 1); + } + if (! misfits.empty()) { + // Candidates are tried smallest first, from the first one larger than the misplaced loop (see path_encloses()). + std::sort(fitting.begin(), fitting.end(), [&loops](size_t a, size_t b) { return std::abs(loops[a].area) < std::abs(loops[b].area); }); + std::vector bounds(loops.size()); + for (size_t i = 0; i < loops.size(); ++ i) + bounds[i] = C2::GetBounds(loops[i].node->Polygon()); + for (size_t misfit : misfits) { + Loop &loop = loops[misfit]; + const C2::Rect64 &box = bounds[misfit]; + Polygon inner; + inner.points = c2_to_points(loop.node->Polygon()); + for (size_t idx : fitting) { + // The slack of a vertex rounded across the candidate, as in c2_attach_stray_holes(). + const C2::Rect64 &around = bounds[idx]; + if (std::abs(loops[idx].area) <= std::abs(loop.area) || around.left - SCALED_EPSILON > box.left || + around.right + SCALED_EPSILON < box.right || around.top - SCALED_EPSILON > box.top || around.bottom + SCALED_EPSILON < box.bottom) + continue; + Polygon outer; + outer.points = c2_to_points(loops[idx].node->Polygon()); + if (path_encloses(outer, inner)) { + loops[idx].next.emplace_back(loop.node); + loop.moved = true; + break; + } + } + } + } - // Perform the ordering, push results recursively. + // Chain the loops staying on this level, push results recursively. + std::vector staying; + Points ordering_points; + for (size_t i = 0; i < loops.size(); ++ i) + if (! loops[i].moved) { + staying.emplace_back(i); + ordering_points.emplace_back(loops[i].node->Polygon().front().x, loops[i].node->Polygon().front().y); + } //FIXME pass the last point to chain_points? for (size_t idx : chain_points(ordering_points)) { - const C2::PolyPath64 &node = *parent.Child(idx); - retval->emplace_back().points = c2_to_points(node.Polygon()); - if (node.IsHole()) + const Loop &loop = loops[staying[idx]]; + retval->emplace_back().points = c2_to_points(loop.node->Polygon()); + if (loop.area < 0.) // Orient a hole, which is clockwise oriented, to CCW. retval->back().reverse(); - // traverse the next depth - traverse_pt_outside_in(node, retval); + traverse_pt_outside_in(loop.next, loop.area > 0., retval); } } @@ -1032,8 +1271,12 @@ Polygons union_pt_chained_outside_in(const Polygons &subject) { C2::PolyTree64 tree; c2_clip(C2::ClipType::Union, to_paths64(ClipperUtils::PolygonsProvider(subject)), {}, C2::FillRule::EvenOdd, tree); + std::vector top_level; + top_level.reserve(tree.Count()); + for (const auto &node : tree) + top_level.emplace_back(node.get()); Polygons retval; - traverse_pt_outside_in(tree, &retval); + traverse_pt_outside_in(top_level, false, &retval); return retval; } @@ -1070,6 +1313,8 @@ ExPolygons top_level_expolygons(const ExPolygons &expolygons, ExPolygons *nested c2_clip(C2::ClipType::Union, to_paths64(ClipperUtils::ExPolygonsProvider(expolygons)), {}, C2::FillRule::EvenOdd, tree); ExPolygons out; out.reserve(tree.Count()); + const size_t first_nested = nested ? nested->size() : 0; + std::vector stray_holes; for (const auto &outer : tree) { ExPolygon &expoly = out.emplace_back(); expoly.contour.points = c2_to_points(outer->Polygon()); @@ -1077,9 +1322,11 @@ ExPolygons top_level_expolygons(const ExPolygons &expolygons, ExPolygons *nested expoly.holes.emplace_back().points = c2_to_points(hole->Polygon()); if (nested) for (const auto &island : *hole) - c2_append_expolygons(*island, *nested); + c2_append_expolygons(*island, *nested, stray_holes); } } + if (nested) + c2_attach_stray_holes(*nested, first_nested, stray_holes); return out; } diff --git a/src/libslic3r/ClipperUtils.hpp b/src/libslic3r/ClipperUtils.hpp index 26585a1fef..43904ff4b4 100644 --- a/src/libslic3r/ClipperUtils.hpp +++ b/src/libslic3r/ClipperUtils.hpp @@ -5,12 +5,14 @@ #include "Polyline.hpp" #include "Line.hpp" #include "libslic3r.h" +#include "BoundingBox.hpp" #include "ExPolygon.hpp" #include "Polygon.hpp" #include "Surface.hpp" #include #include #include +#include #include namespace Slic3r { @@ -332,6 +334,19 @@ namespace ClipperUtils { [[nodiscard]] Polygons clip_clipper_polygons_with_subject_bbox(const ExPolygon &src, const BoundingBox &bbox, const bool get_entire_polygons = false); [[nodiscard]] Polygons clip_clipper_polygons_with_subject_bbox(const ExPolygons &src, const BoundingBox &bbox, const bool get_entire_polygons = false); + // Splits ExPolygons into tiles by the centres of their boxes, about `per_tile` of them to a tile, to run Clipper on a + // layer of many pieces tile by tile. Returns the non-empty tiles, each with the indices of its ExPolygons and their box. + struct ExPolygonsTile + { + BoundingBox bbox; + std::vector members; + }; + [[nodiscard]] std::vector tile_expolygons(const ExPolygons &expolygons, size_t per_tile); + + // A grid point strictly inside the polygon, or none if the polygon is at most a few units thick everywhere. + // Decides on which side of another path a path lies when every one of its vertices touches that path. + [[nodiscard]] std::optional interior_point(const Polygon &polygon); + } // offset Polygons @@ -527,6 +542,11 @@ Slic3r::ExPolygons intersection_ex(const Slic3r::Surfaces &subject, const Slic3r Slic3r::ExPolygons intersection_ex(const Slic3r::Surfaces &subject, const Slic3r::ExPolygons &clip, ApplySafetyOffset do_safety_offset = ApplySafetyOffset::No); Slic3r::ExPolygons intersection_ex(const Slic3r::Surfaces &subject, const Slic3r::Surfaces &clip, ApplySafetyOffset do_safety_offset = ApplySafetyOffset::No); Slic3r::ExPolygons intersection_ex(const Slic3r::SurfacesPtr &subject, const Slic3r::ExPolygons &clip, ApplySafetyOffset do_safety_offset = ApplySafetyOffset::No); +// diff_ex() / intersection_ex() of the subject split into tiles, each against only the part of the clip near it, the tiles in +// parallel. The same area as the operation on the whole subject when its ExPolygons do not overlap, and much faster for a +// subject of thousands of pieces spread over a layer: Clipper slows down with the number of edges crossing a scan line. +Slic3r::ExPolygons diff_ex_by_piece(const Slic3r::ExPolygons &subject, const Slic3r::Polygons &clip, ApplySafetyOffset do_safety_offset = ApplySafetyOffset::No); +Slic3r::ExPolygons intersection_ex_by_piece(const Slic3r::ExPolygons &subject, const Slic3r::Polygons &clip, ApplySafetyOffset do_safety_offset = ApplySafetyOffset::No); Slic3r::Polylines intersection_pl(const Slic3r::Polylines &subject, const Slic3r::Polygon &clip); Slic3r::Polylines intersection_pl(const Slic3r::Polyline &subject, const Slic3r::ExPolygon &clip); Slic3r::Polylines intersection_pl(const Slic3r::Polylines &subject, const Slic3r::ExPolygon &clip); diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index 9d76262ec6..9f70498499 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -377,7 +377,8 @@ public: virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0; virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector& restore_index, int stride) = 0; virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector& restore_index, int start, int len, bool skip_error = false) = 0; - virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector& diff_index, int stride) = 0; + // keep_nil: copy a nil column from rhs as well, for options whose nil means "disabled" rather than "inherit". + virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector& diff_index, int stride, bool keep_nil = false) = 0; virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector& dest_index, int stride) = 0; virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0; // Resize the vector of values, copy the newly added values from opt_default if provided. @@ -580,7 +581,7 @@ public: //set a item related with extruder variants when loading user config, only set the different value of some extruder //rhs: item from user config //diff_index: which index in this vector need to be set - virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector& diff_index, int stride) override + virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector& diff_index, int stride, bool keep_nil) override { if (rhs->type() == this->type()) { // Assign the first value of the rhs vector. @@ -592,7 +593,7 @@ public: for (size_t i = 0; i < diff_index.size(); i++) { if (diff_index[i] != -1) { for (size_t j = 0; j < stride; j++) { - if (!other->is_nil(diff_index[i] * stride)) + if (keep_nil || !other->is_nil(diff_index[i] * stride)) this->values[i * stride + j] = other->values[diff_index[i] * stride + j]; } } @@ -841,8 +842,12 @@ public: { UNUSED(append); std::istringstream iss(str); - iss >> this->value; - return !iss.fail(); + double value; + iss >> value; + if (iss.fail()) + return false; + this->value = value; + return true; } bool operator==(const ConfigOption &rhs) const override @@ -1033,8 +1038,12 @@ public: { UNUSED(append); std::istringstream iss(str); - iss >> this->value; - return !iss.fail(); + int value; + iss >> value; + if (iss.fail()) + return false; + this->value = value; + return true; } ConfigOptionInt& operator=(const ConfigOption *opt) @@ -1252,8 +1261,12 @@ public: UNUSED(append); // don't try to parse the trailing % since it's optional std::istringstream iss(str); - iss >> this->value; - return !iss.fail(); + double value; + iss >> value; + if (iss.fail()) + return false; + this->value = value; + return true; } private: @@ -1365,10 +1378,14 @@ public: bool deserialize(const std::string &str, bool append = false) override { UNUSED(append); - this->percent = str.find_first_of("%") != std::string::npos; std::istringstream iss(str); - iss >> this->value; - return !iss.fail(); + double value; + iss >> value; + if (iss.fail()) + return false; + this->value = value; + this->percent = str.find_first_of("%") != std::string::npos; + return true; } private: @@ -1538,8 +1555,12 @@ public: { UNUSED(append); char dummy; - return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 || - sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2; + Vec2d value; + if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 && + sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2) + return false; + this->value = value; + return true; } private: @@ -1658,8 +1679,12 @@ public: { UNUSED(append); char dummy; - return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 || - sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3; + Vec3d value; + if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 && + sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3) + return false; + this->value = value; + return true; } private: diff --git a/src/libslic3r/Fill/Fill.cpp b/src/libslic3r/Fill/Fill.cpp index 1e6eade821..d2d6bfb0f5 100644 --- a/src/libslic3r/Fill/Fill.cpp +++ b/src/libslic3r/Fill/Fill.cpp @@ -22,6 +22,8 @@ #include "../PrintConfig.hpp" #include "../Surface.hpp" +#include + #include "AABBTreeLines.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/ExPolygon.hpp" @@ -675,24 +677,28 @@ void split_solid_surface(size_t layer_id, const SurfaceFill &fill, ExPolygons &n if (!line_based_pattern) { const coord_t scaled_spacing = scaled(fill.params.spacing); - for (const ExPolygon &expolygon : fill.expolygons) { + // Each expolygon is split on its own, so they run in parallel and are collected in their original order. + std::vector> split_parts(fill.expolygons.size()); // normal, narrow + tbb::parallel_for(size_t(0), fill.expolygons.size(), [&](size_t idx) { + const ExPolygon &expolygon = fill.expolygons[idx]; Polygons filled_area = to_polygons(expolygon); // "Core" area: open (erode+dilate) to drop thin features, then clamp back to the original polygon. Polygons inner_area = intersection(filled_area, opening(filled_area, scaled_spacing, scaled_spacing)); if (inner_area.empty()) { - narrow_infill.emplace_back(expolygon); - continue; + split_parts[idx].second.emplace_back(expolygon); + return; } ExPolygons inner_ex = union_ex(inner_area); ExPolygons expolys{expolygon}; - ExPolygons narrow_ex = diff_ex(expolys, inner_ex); - ExPolygons normal_ex = intersection_ex(expolys, inner_ex); - - append(normal_infill, normal_ex); // normal infill area - append(narrow_infill, narrow_ex); // narrow infill area + split_parts[idx].second = diff_ex(expolys, inner_ex); // narrow infill area + split_parts[idx].first = intersection_ex(expolys, inner_ex); // normal infill area + }); + for (auto &[normal_ex, narrow_ex] : split_parts) { + append(normal_infill, std::move(normal_ex)); + append(narrow_infill, std::move(narrow_ex)); } return; @@ -714,7 +720,10 @@ void split_solid_surface(size_t layer_id, const SurfaceFill &fill, ExPolygons &n } const double aligning_angle = -base_angle + PI; - for (const ExPolygon &expolygon : fill.expolygons) { + // Each expolygon is reconstructed on its own, so they run in parallel and are collected in their original order. + std::vector split_reconstructed(fill.expolygons.size()); + tbb::parallel_for(size_t(0), fill.expolygons.size(), [&](size_t expolygon_idx) { + const ExPolygon &expolygon = fill.expolygons[expolygon_idx]; Polygons filled_area = to_polygons(expolygon); polygons_rotate(filled_area, aligning_angle); BoundingBox bb = get_extents(filled_area); @@ -845,8 +854,10 @@ void split_solid_surface(size_t layer_id, const SurfaceFill &fill, ExPolygons &n } } - polygons_append(normal_fill_areas, reconstructed_area); - } + split_reconstructed[expolygon_idx] = std::move(reconstructed_area); + }); + for (Polygons &reconstructed_area : split_reconstructed) + polygons_append(normal_fill_areas, std::move(reconstructed_area)); polygons_rotate(normal_fill_areas, -aligning_angle); @@ -1473,7 +1484,15 @@ void Layer::make_fills(const FillAdaptive::RegionOctrees* fill_octrees, FillLigh f->set_bounding_box(body >= 0 ? this->object()->separated_body_bboxes()[body] : bbox); f->adapt_fill_octree = octrees ? octrees->get(body) : nullptr; - f->no_overlap_expolygons = intersection_ex(surface_fill.no_overlap_expolygons, ExPolygons() = {expoly}, ApplySafetyOffset::Yes); + // Only the part of the layer-wide no-overlap area under this expolygon matters, so clip it to the + // expolygon's box first (padded past the safety offset, which grows the clip side). The result is + // identical; the cost is not: a layer split into many small fills, e.g. by colour painting, + // otherwise intersects every one of them with the whole layer. + BoundingBox no_overlap_bbox = get_extents(expoly); + no_overlap_bbox.offset(SCALED_EPSILON); + f->no_overlap_expolygons = intersection_ex( + ClipperUtils::clip_clipper_polygons_with_subject_bbox(surface_fill.no_overlap_expolygons, no_overlap_bbox), + ExPolygons() = {expoly}, ApplySafetyOffset::Yes); if (params.symmetric_infill_y_axis) { params.symmetric_y_axis = f->extended_object_bounding_box().center().x(); expoly.symmetric_y(params.symmetric_y_axis); diff --git a/src/libslic3r/Fill/FillBase.cpp b/src/libslic3r/Fill/FillBase.cpp index 63ebff49c2..7a2aee3f7f 100644 --- a/src/libslic3r/Fill/FillBase.cpp +++ b/src/libslic3r/Fill/FillBase.cpp @@ -2914,7 +2914,7 @@ void multiline_fill(Polylines& polylines, const FillParams& params, float spacin const float delta = float(scale_(t)); // Arc tolerance of 1/500 of the offset, Clipper2's default. - for (const Polygon &ring : offset(polylines, delta, jtRound, 0.002 * delta, etOpenRound)) + for (const Polygon &ring : offset(polylines, delta, jtMiter, 0.002 * delta, etOpenRound)) if (ring.size() >= 3) all_polylines.emplace_back(ring.split_at_first_point()); } diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 88fef3bb35..6508c8453f 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -3979,22 +3979,22 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato this->placeholder_parser().set("hold_chamber_temp_for_flat_print", new ConfigOptionBool(hold_chamber_temp_for_flat_print)); } - this->placeholder_parser().set("print_time_total_sec", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Total_Sec_Placeholder))); - this->placeholder_parser().set("print_time_day", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Day_Placeholder))); - this->placeholder_parser().set("print_time_hour", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Hour_Placeholder))); - this->placeholder_parser().set("print_time_minute", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Minute_Placeholder))); - this->placeholder_parser().set("print_time_sec", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Sec_Placeholder))); - this->placeholder_parser().set("used_filament_length", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder))); + this->placeholder_parser().set_deferred("print_time_total_sec", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Total_Sec_Placeholder)); + this->placeholder_parser().set_deferred("print_time_day", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Day_Placeholder)); + this->placeholder_parser().set_deferred("print_time_hour", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Hour_Placeholder)); + this->placeholder_parser().set_deferred("print_time_minute", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Minute_Placeholder)); + this->placeholder_parser().set_deferred("print_time_sec", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Print_Time_Sec_Placeholder)); + this->placeholder_parser().set_deferred("used_filament_length", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder)); // The final print statistics are known only after the whole G-code is generated, so they are filled in during post-processing too. // The running extruded_* totals are left alone; profiles use them as numbers. - this->placeholder_parser().set("used_filament", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder))); - this->placeholder_parser().set("print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder))); - this->placeholder_parser().set("normal_print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder))); - this->placeholder_parser().set("silent_print_time", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Silent_Print_Time_Placeholder))); - this->placeholder_parser().set("total_weight", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Weight_Placeholder))); - this->placeholder_parser().set("total_cost", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Cost_Placeholder))); - this->placeholder_parser().set("total_wipe_tower_cost", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Cost_Placeholder))); - this->placeholder_parser().set("total_wipe_tower_filament", new ConfigOptionString(GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Filament_Placeholder))); + this->placeholder_parser().set_deferred("used_filament", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Used_Filament_Length_Placeholder)); + this->placeholder_parser().set_deferred("print_time", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder)); + this->placeholder_parser().set_deferred("normal_print_time", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Normal_Print_Time_Placeholder)); + this->placeholder_parser().set_deferred("silent_print_time", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Silent_Print_Time_Placeholder)); + this->placeholder_parser().set_deferred("total_weight", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Weight_Placeholder)); + this->placeholder_parser().set_deferred("total_cost", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Cost_Placeholder)); + this->placeholder_parser().set_deferred("total_wipe_tower_cost", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Cost_Placeholder)); + this->placeholder_parser().set_deferred("total_wipe_tower_filament", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Total_Wipe_Tower_Filament_Placeholder)); // Sync variant-mapped params into placeholder_parser before processing start gcode update_placeholder_parser_with_variant_params(); diff --git a/src/libslic3r/GCode/OrderingStrategies.cpp b/src/libslic3r/GCode/OrderingStrategies.cpp index 73d916405d..fe856b3c17 100644 --- a/src/libslic3r/GCode/OrderingStrategies.cpp +++ b/src/libslic3r/GCode/OrderingStrategies.cpp @@ -4,6 +4,7 @@ #include "OrderingStrategies.hpp" #include "../Geometry.hpp" #include "../ShortestPath.hpp" +#include "libslic3r/BoundingBox.hpp" #include "libslic3r/KDTreeIndirect.hpp" #include "libslic3r/Point.hpp" #include "libslic3r/PrintConfig.hpp" @@ -153,43 +154,84 @@ bool tsp_remove_crossings(std::vector& path, const Points& centers) // TSP helpers (2-opt, closing-edge rotation) that operate on the full cycle. size_t n_edges = pn; - // Scan from edge `from` for the first pair of intersecting edges worth reversing; returns {i, j} or {npos, npos} if none. + constexpr size_t npos = std::numeric_limits::max(); + + // Whether edges i and j intersect and reversing path[i+1..j] is worth it. The reversal replaces edges (ai, bi) and + // (aj, bj) with (ai, aj) and (bi, bj). Edges that only touch or overlap collinearly, as at points sharing a position, + // may gain nothing from it, and reversing them would find the same pair again on every scan. A gain within + // SCALED_EPSILON, which nearly collinear crossings can have too, is not worth a reversal either. + auto worth_reversing = [&](size_t i, size_t j) { + // Skip the (0, pn-1) pair: edges (0,1) and (pn-1,0) share node 0. + if (i == 0 && j == pn - 1) + return false; + const Point& ai = centers[path[i]]; + const Point& bi = centers[path[(i + 1) % pn]]; + const Point& aj = centers[path[j]]; + const Point& bj = centers[path[(j + 1) % pn]]; + return bboxes_overlap(ai, bi, aj, bj) && Geometry::segments_intersect(ai, bi, aj, bj) && + ai.distance_to(bi) + aj.distance_to(bj) - ai.distance_to(aj) - bi.distance_to(bj) > SCALED_EPSILON; + }; + + // Scan from edge `from` for the first pair worth reversing, smallest i and then smallest j; returns {i, j} or + // {npos, npos} if none. auto find_crossing = [&](size_t from) -> std::pair { - for (size_t i = from; i < n_edges; ++i) { - const Point& ai = centers[path[i]]; - const Point& bi = centers[path[(i + 1) % pn]]; - - for (size_t j = i + 2; j < n_edges; ++j) { - // Skip the (0, pn-1) pair: edges (0,1) and (pn-1,0) share node 0. - if (i == 0 && j == pn - 1) continue; - - const Point& aj = centers[path[j]]; - const Point& bj = centers[path[(j + 1) % pn]]; - - if (!bboxes_overlap(ai, bi, aj, bj) || !Geometry::segments_intersect(ai, bi, aj, bj)) - continue; - // The reversal replaces edges (ai, bi) and (aj, bj) with (ai, aj) and (bi, bj). Edges that only touch - // or overlap collinearly, as at points sharing a position, may gain nothing from it, and reversing - // them would find the same pair again on every scan. A gain within SCALED_EPSILON, which nearly - // collinear crossings can have too, is not worth a reversal either. - if (ai.distance_to(bi) + aj.distance_to(bj) - ai.distance_to(aj) - bi.distance_to(bj) > SCALED_EPSILON) + for (size_t i = from; i < n_edges; ++i) + for (size_t j = i + 2; j < n_edges; ++j) + if (worth_reversing(i, j)) return {i, j}; - } + return {npos, npos}; + }; + + // For many islands, the same scan with the edges binned in a uniform grid over their boxes, so each edge is only + // tested against the edges sharing a cell with it - two edges whose boxes overlap always do. It returns the same + // pair as find_crossing(), so the result is unchanged. The all-pairs scan is quadratic in the edge count; rebuilding + // the grid costs more than it saves below the threshold. + constexpr size_t grid_min_size = 500; + BoundingBox extent; + for (size_t idx : path) + extent.merge(centers[idx]); + const int grid_n = std::clamp(int(std::sqrt(double(pn))), 1, 256); + const coord_t cell_w = std::max(1, (extent.max.x() - extent.min.x()) / grid_n + 1); + const coord_t cell_h = std::max(1, (extent.max.y() - extent.min.y()) / grid_n + 1); + const auto for_cells = [&](const Point& a, const Point& b, auto&& fn) { + const int x0 = int((std::min(a.x(), b.x()) - extent.min.x()) / cell_w), x1 = int((std::max(a.x(), b.x()) - extent.min.x()) / cell_w); + const int y0 = int((std::min(a.y(), b.y()) - extent.min.y()) / cell_h), y1 = int((std::max(a.y(), b.y()) - extent.min.y()) / cell_h); + for (int y = y0; y <= y1; ++y) + for (int x = x0; x <= x1; ++x) + fn(y * grid_n + x); + }; + std::vector> edge_cells(size_t(grid_n) * grid_n); + + auto find_crossing_grid = [&](size_t from) -> std::pair { + for (std::vector& cell : edge_cells) + cell.clear(); + for (size_t j = 0; j < n_edges; ++j) + for_cells(centers[path[j]], centers[path[(j + 1) % pn]], [&](int cell) { edge_cells[cell].emplace_back(j); }); + + for (size_t i = from; i < n_edges; ++i) { + size_t first_j = npos; + for_cells(centers[path[i]], centers[path[(i + 1) % pn]], [&](int cell) { + for (size_t j : edge_cells[cell]) + if (j >= i + 2 && j < first_j && worth_reversing(i, j)) + first_j = j; + }); + if (first_j != npos) + return {i, first_j}; } - return {std::numeric_limits::max(), std::numeric_limits::max()}; + return {npos, npos}; }; // Process crossings one at a time. Reversing path[ci+1..cj] replaces only edges ci and cj; the edges between // them are the same segments traversed backwards. The scan therefore resumes at edge ci rather than at edge 0, - // so a pass over the tour costs one quadratic scan however many reversals it makes. Pairs before ci that - // involve a replaced edge are new too, so a scan that reaches the end starts over from edge 0, and the loop - // ends when a scan from edge 0 finds nothing. + // so a pass over the tour costs one scan however many reversals it makes. Pairs before ci that involve a + // replaced edge are new too, so a scan that reaches the end starts over from edge 0, and the loop ends when a + // scan from edge 0 finds nothing. // Every reversal shortens the cycle, so no tour repeats and the loop ends; the cap is a safety net. size_t max_reversals = pn * pn; bool improved = false; for (size_t from = 0;;) { - auto [ci, cj] = find_crossing(from); - if (ci == std::numeric_limits::max()) { + auto [ci, cj] = pn >= grid_min_size ? find_crossing_grid(from) : find_crossing(from); + if (ci == npos) { if (from == 0) break; from = 0; diff --git a/src/libslic3r/GCode/SeamPlacer.cpp b/src/libslic3r/GCode/SeamPlacer.cpp index 932f8fb99e..c318f4f5d7 100644 --- a/src/libslic3r/GCode/SeamPlacer.cpp +++ b/src/libslic3r/GCode/SeamPlacer.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -1207,21 +1208,21 @@ std::optional> SeamPlacer::find_next_seam_in_layer( const size_t layer_idx, const float max_distance, const SeamPlacerImpl::SeamComparator &comparator) const { using namespace SeamPlacerImpl; - std::vector nearby_points_indices = find_nearby_points(*layers[layer_idx].points_tree, projected_position, - max_distance); - - if (nearby_points_indices.empty()) { - return {}; - } - - size_t best_nearby_point_index = nearby_points_indices[0]; - size_t nearest_point_index = nearby_points_indices[0]; - - // Now find best nearby point, nearest point, and corresponding indices - for (const size_t &nearby_point_index : nearby_points_indices) { + // Find the best nearby point and the nearest one. A layer of a fine relief has tens of thousands of candidates within + // the radius, so they are looked at as the search finds them rather than collected into a vector first. + constexpr size_t none = std::numeric_limits::max(); + size_t best_nearby_point_index = none; + size_t nearest_point_index = none; + visit_nearby_points(*layers[layer_idx].points_tree, projected_position, max_distance, + [&layers, &comparator, &projected_position, layer_idx, &best_nearby_point_index, &nearest_point_index] + (size_t nearby_point_index) { + if (best_nearby_point_index == none) { + // The first point found starts both, as the first of the collected ones did. + best_nearby_point_index = nearest_point_index = nearby_point_index; + } const SeamCandidate &point = layers[layer_idx].points[nearby_point_index]; if (point.perimeter.finalized) { - continue; // skip over finalized perimeters, try to find some that is not finalized + return; // skip over finalized perimeters, try to find some that is not finalized } if (comparator.is_first_better(point, layers[layer_idx].points[best_nearby_point_index], projected_position.head<2>()) @@ -1233,6 +1234,10 @@ std::optional> SeamPlacer::find_next_seam_in_layer( || layers[layer_idx].points[nearest_point_index].perimeter.finalized) { nearest_point_index = nearby_point_index; } + }); + + if (best_nearby_point_index == none) { + return {}; } const SeamCandidate &best_nearby_point = layers[layer_idx].points[best_nearby_point_index]; diff --git a/src/libslic3r/KDTreeIndirect.hpp b/src/libslic3r/KDTreeIndirect.hpp index d334a2ad64..3bf3d938e6 100644 --- a/src/libslic3r/KDTreeIndirect.hpp +++ b/src/libslic3r/KDTreeIndirect.hpp @@ -318,6 +318,36 @@ std::vector find_nearby_points(const KDTreeIndirectType &kdtree, const P return visitor.result; } +// Visits the points within max_distance of center, in the order find_nearby_points() would collect them, and hands +// each of them to `visitor_fn` instead of returning them all: a search over a dense set spends more on collecting the +// points into a vector than on the search itself, and its caller usually keeps only a few of them. +template +void visit_nearby_points(const KDTreeIndirectType &kdtree, const PointType ¢er, + const typename KDTreeIndirectType::CoordType &max_distance, VisitorFn visitor_fn) +{ + using CoordType = typename KDTreeIndirectType::CoordType; + + struct Visitor { + const KDTreeIndirectType &kdtree; + const PointType center; + const CoordType max_distance_squared; + VisitorFn visitor_fn; + + unsigned int operator()(size_t idx, size_t dimension) { + auto dist = CoordType(0); + for (size_t i = 0; i < KDTreeIndirectType::NumDimensions; ++i) { + CoordType d = center[i] - kdtree.coordinate(idx, i); + dist += d * d; + } + if (dist < max_distance_squared) + visitor_fn(idx); + return kdtree.descent_mask(center[dimension], max_distance_squared, idx, dimension); + } + } visitor { kdtree, center, max_distance * max_distance, visitor_fn }; + + kdtree.visit(visitor); +} + template std::vector find_nearby_points(const KDTreeIndirectType &kdtree, const PointType ¢er, const typename KDTreeIndirectType::CoordType& max_distance) diff --git a/src/libslic3r/LayerRegion.cpp b/src/libslic3r/LayerRegion.cpp index c20cebc641..eef3f90c87 100644 --- a/src/libslic3r/LayerRegion.cpp +++ b/src/libslic3r/LayerRegion.cpp @@ -99,10 +99,11 @@ void LayerRegion::slices_to_fill_surfaces_clipped() by_surface[size_t(surface.surface_type)].emplace_back(&surface); // Trim surfaces by the fill_boundaries. this->fill_surfaces.surfaces.clear(); + const Polygons fill_boundaries = to_polygons(this->fill_expolygons); for (size_t surface_type = 0; surface_type < size_t(stCount); ++ surface_type) { const SurfacesPtr &this_surfaces = by_surface[surface_type]; if (! this_surfaces.empty()) - this->fill_surfaces.append(intersection_ex(this_surfaces, this->fill_expolygons), SurfaceType(surface_type)); + this->fill_surfaces.append(intersection_ex_by_piece(to_expolygons(this_surfaces), fill_boundaries), SurfaceType(surface_type)); } } diff --git a/src/libslic3r/MultiMaterialSegmentation.cpp b/src/libslic3r/MultiMaterialSegmentation.cpp index dc3af43de4..507d997f1d 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -22,6 +22,7 @@ #include "Surface.hpp" #include "libslic3r.h" +#include #include #include #include @@ -1361,10 +1362,15 @@ static inline std::vector> segmentation_top_and_bottom_l } #endif // MM_SEGMENTATION_DEBUG_TOP_BOTTOM - // When the upper surface of an object is occluded, it should no longer be considered the upper surface + // When the upper surface of an object is occluded, it should no longer be considered the upper surface. + // Every (colour, layer) pair is trimmed on its own, so they all run at once: the painted faces of a finely + // textured part project hundreds of thousands of triangles onto one layer, which used to be trimmed serially. { - for (size_t extruder_idx = 0; extruder_idx < num_facets_states; ++extruder_idx) { - for (size_t layer_idx = 0; layer_idx < layers.size(); ++layer_idx) { + const size_t occluded_pairs = num_facets_states * layers.size(); + tbb::parallel_for(tbb::blocked_range(0, occluded_pairs), [&](const tbb::blocked_range &range) { + for (size_t pair_idx = range.begin(); pair_idx < range.end(); ++pair_idx) { + const size_t extruder_idx = pair_idx / layers.size(); + const size_t layer_idx = pair_idx % layers.size(); if (!top_raw[extruder_idx].empty() && !top_raw[extruder_idx][layer_idx].empty() && layer_idx + 1 < layers.size()) { top_raw[extruder_idx][layer_idx] = diff(top_raw[extruder_idx][layer_idx], input_expolygons[layer_idx + 1]); } @@ -1372,7 +1378,7 @@ static inline std::vector> segmentation_top_and_bottom_l bottom_raw[extruder_idx][layer_idx] = diff(bottom_raw[extruder_idx][layer_idx], input_expolygons[layer_idx - 1]); } } - } + }); } std::vector> triangles_by_color_bottom(num_facets_states); @@ -1430,11 +1436,58 @@ static inline std::vector> segmentation_top_and_bottom_l return out; }; + // Projects a painted top or bottom face `ex` of layer `layer_idx` onto the shell layers below or above it (in + // `shell_layers`, nearest first), one more perimeter in on each, stopping at the first layer where nothing is left. + // Only the slices within the deepest offset of `ex` (three times that with the miter joins) decide the result, so the + // work is done per tile of `ex`'s ExPolygons on the slices cut to the tile's box grown by that much: the same result, but + // each Clipper call stays the size of a tile rather than of a layer cut through a fine relief, and the tiles run in + // parallel. + const auto project_to_shells = [&input_expolygons](const ExPolygons &ex, size_t layer_idx, const std::vector &shell_layers, + const LayerColorStat &stat, ShellProjections &dst) { + std::vector offsets(shell_layers.size()); + float offset = 0.f; + for (size_t i = 0; i < shell_layers.size(); ++i) { + //BBS: offset width should be 2*spacing to avoid too narrow area which has overlap of wall line + offset -= (stat.extrusion_spacing + stat.extrusion_width); + offsets[i] = offset; + } + if (offsets.empty()) + return; + const coord_t reach = coord_t(std::ceil(DefaultMiterLimit * std::abs(offsets.back()))) + 10 * SCALED_EPSILON; + const std::vector tiles = ClipperUtils::tile_expolygons(ex, 16); + // [shell layer][tile] + std::vector> shells(shell_layers.size(), std::vector(tiles.size())); + tbb::parallel_for(size_t(0), tiles.size(), [&](size_t tile_idx) { + const ClipperUtils::ExPolygonsTile &tile = tiles[tile_idx]; + const BoundingBox bbox = tile.bbox.inflated(reach); + ExPolygons tile_ex; + tile_ex.reserve(tile.members.size()); + for (size_t i : tile.members) + tile_ex.emplace_back(ex[i]); + Polygons layer_slices_trimmed = ClipperUtils::clip_clipper_polygons_with_subject_bbox(input_expolygons[layer_idx], bbox); + for (size_t i = 0; i < shell_layers.size() && ! layer_slices_trimmed.empty(); ++i) { + const ExPolygons trimmed = intersection_ex(layer_slices_trimmed, ClipperUtils::clip_clipper_polygons_with_subject_bbox(input_expolygons[shell_layers[i]], bbox)); + shells[i][tile_idx] = opening_ex(intersection_ex(tile_ex, offset_ex(trimmed, offsets[i])), stat.small_region_threshold); + layer_slices_trimmed = to_polygons(trimmed); + } + }); + for (size_t i = 0; i < shell_layers.size(); ++i) { + ExPolygons shell; + for (ExPolygons &tile_shell : shells[i]) + append(shell, std::move(tile_shell)); + if (shell.empty()) + break; + dst.emplace_back(shell_layers[i], std::move(shell)); + } + }; + tbb::parallel_for(tbb::blocked_range(0, num_layers), [&num_layers, &num_facets_states, &layer_color_stat, &top_raw, &triangles_by_color_top, - &throw_on_cancel_callback, &input_expolygons, &bottom_raw, &triangles_by_color_bottom, + &throw_on_cancel_callback, &bottom_raw, &triangles_by_color_bottom, &project_to_shells, &shell_triangles_by_color_top, &shell_triangles_by_color_bottom](const tbb::blocked_range &range) { for (size_t layer_idx = range.begin(); layer_idx < range.end(); ++ layer_idx) { - for (size_t color_idx = 0; color_idx < num_facets_states; ++color_idx) { + // Each colour writes only its own vectors, so the colours run in parallel: a painted top or bottom face + // projects onto a single layer, which otherwise did all of its colours on one thread. + tbb::parallel_for(size_t(0), size_t(num_facets_states), [&](size_t color_idx) { throw_on_cancel_callback(); LayerColorStat stat = layer_color_stat(layer_idx, color_idx); if (std::vector &top = top_raw[color_idx]; ! top.empty() && ! top[layer_idx].empty()) @@ -1443,18 +1496,10 @@ static inline std::vector> segmentation_top_and_bottom_l top_ex = opening_ex(top_ex, stat.small_region_threshold); if (! top_ex.empty()) { append(triangles_by_color_top[color_idx][layer_idx], top_ex); - float offset = 0.f; - ExPolygons layer_slices_trimmed = input_expolygons[layer_idx]; - for (int last_idx = int(layer_idx) - 1; last_idx > std::max(int(layer_idx - stat.top_shell_layers), int(0)); --last_idx) { - //BBS: offset width should be 2*spacing to avoid too narrow area which has overlap of wall line - //offset -= stat.extrusion_width ; - offset -= (stat.extrusion_spacing + stat.extrusion_width); - layer_slices_trimmed = intersection_ex(layer_slices_trimmed, input_expolygons[last_idx]); - ExPolygons last = opening_ex(intersection_ex(top_ex, offset_ex(layer_slices_trimmed, offset)), stat.small_region_threshold); - if (last.empty()) - break; - shell_triangles_by_color_top[color_idx][layer_idx].emplace_back(size_t(last_idx), std::move(last)); - } + std::vector shell_layers; + for (int last_idx = int(layer_idx) - 1; last_idx > std::max(int(layer_idx - stat.top_shell_layers), int(0)); --last_idx) + shell_layers.emplace_back(size_t(last_idx)); + project_to_shells(top_ex, layer_idx, shell_layers, stat, shell_triangles_by_color_top[color_idx][layer_idx]); } } if (std::vector &bottom = bottom_raw[color_idx]; ! bottom.empty() && ! bottom[layer_idx].empty()) @@ -1463,21 +1508,13 @@ static inline std::vector> segmentation_top_and_bottom_l bottom_ex = opening_ex(bottom_ex, stat.small_region_threshold); if (! bottom_ex.empty()) { append(triangles_by_color_bottom[color_idx][layer_idx], bottom_ex); - float offset = 0.f; - ExPolygons layer_slices_trimmed = input_expolygons[layer_idx]; - for (size_t last_idx = layer_idx + 1; last_idx < std::min(layer_idx + stat.bottom_shell_layers, num_layers); ++last_idx) { - //BBS: offset width should be 2*spacing to avoid too narrow area which has overlap of wall line - //offset -= stat.extrusion_width; - offset -= (stat.extrusion_spacing + stat.extrusion_width); - layer_slices_trimmed = intersection_ex(layer_slices_trimmed, input_expolygons[last_idx]); - ExPolygons last = opening_ex(intersection_ex(bottom_ex, offset_ex(layer_slices_trimmed, offset)), stat.small_region_threshold); - if (last.empty()) - break; - shell_triangles_by_color_bottom[color_idx][layer_idx].emplace_back(last_idx, std::move(last)); - } + std::vector shell_layers; + for (size_t last_idx = layer_idx + 1; last_idx < std::min(layer_idx + stat.bottom_shell_layers, num_layers); ++last_idx) + shell_layers.emplace_back(last_idx); + project_to_shells(bottom_ex, layer_idx, shell_layers, stat, shell_triangles_by_color_bottom[color_idx][layer_idx]); } } - } + }); } }); @@ -1498,20 +1535,23 @@ static inline std::vector> segmentation_top_and_bottom_l &shell_top_by_layer, &shell_bottom_by_layer](const tbb::blocked_range &range) { for (size_t layer_idx = range.begin(); layer_idx < range.end(); ++ layer_idx) { throw_on_cancel_callback(); - ExPolygons painted_exploys; - for (size_t color_idx = 0; color_idx < triangles_by_color_merged.size(); ++color_idx) { + // The per-colour unions below are independent of each other, so they run in parallel (a painted top or + // bottom face puts all of its colours on one layer); whatever combines the colours stays in colour order. + const auto merge_colour_union = [&](size_t color_idx) { auto &self = triangles_by_color_merged[color_idx][layer_idx]; append(self, std::move(triangles_by_color_bottom[color_idx][layer_idx])); append(self, std::move(triangles_by_color_top[color_idx][layer_idx])); self = union_ex(self); + }; + tbb::parallel_for(size_t(0), triangles_by_color_merged.size(), merge_colour_union); - append(painted_exploys, self); - } - + ExPolygons painted_exploys; + for (size_t color_idx = 0; color_idx < triangles_by_color_merged.size(); ++color_idx) + append(painted_exploys, triangles_by_color_merged[color_idx][layer_idx]); painted_exploys = union_ex(painted_exploys); //BBS: merge the top and bottom shell layers - for (size_t color_idx = 0; color_idx < triangles_by_color_merged.size(); ++color_idx) { + tbb::parallel_for(size_t(0), triangles_by_color_merged.size(), [&](size_t color_idx) { auto &self = triangles_by_color_merged[color_idx][layer_idx]; auto top_area = diff_ex(union_ex(shell_top_by_layer[color_idx][layer_idx]), painted_exploys); @@ -1520,7 +1560,7 @@ static inline std::vector> segmentation_top_and_bottom_l append(self, top_area); append(self, bottom_area); self = union_ex(self); - } + }); // Trim one region by the other if some of the regions overlap. ExPolygons painted_regions; for (size_t color_idx = 1; color_idx < triangles_by_color_merged.size(); ++color_idx) { @@ -1887,7 +1927,69 @@ static void remove_multiple_edges_in_vertices(MMU_Graph &graph, const std::vecto } } -static std::vector> merge_segmented_layers(const std::vector> &segmented_regions, + +// Finds the islands (layer ExPolygons) a region piece overlaps. A top or bottom region is projected from the neighbouring +// layers and may reach past the island it belongs to, or over several islands. +class IslandLocator +{ +public: + explicit IslandLocator(const ExPolygons &islands) : m_islands(islands) + { + m_bboxes.reserve(islands.size()); + for (const ExPolygon &island : islands) { + m_bboxes.emplace_back(get_extents(island)); + m_extent.merge(m_bboxes.back()); + } + if (!m_extent.defined) + return; + const Point size = m_extent.size(); + m_cell_w = std::max(1, size.x() / GRID + 1); + m_cell_h = std::max(1, size.y() / GRID + 1); + m_grid.assign(GRID * GRID, {}); + for (size_t i = 0; i < m_bboxes.size(); ++i) + for_cells(m_bboxes[i], [&](int cell) { m_grid[cell].emplace_back(i); }); + } + + void find(const ExPolygon &piece, std::vector &out) const + { + out.clear(); + const BoundingBox bbox = get_extents(piece); + if (!m_extent.defined || !m_extent.overlap(bbox)) + return; + for_cells(bbox, [&](int cell) { + for (size_t i : m_grid[cell]) + if (m_bboxes[i].overlap(bbox)) + out.emplace_back(i); + }); + sort_remove_duplicates(out); + if (out.size() > 1) + out.erase(std::remove_if(out.begin(), out.end(), [&](size_t i) { + const BoundingBox common(m_bboxes[i].min.cwiseMax(bbox.min), m_bboxes[i].max.cwiseMin(bbox.max)); + return intersection(ClipperUtils::clip_clipper_polygons_with_subject_bbox(piece, common.inflated(SCALED_EPSILON)), + ClipperUtils::clip_clipper_polygons_with_subject_bbox(m_islands[i], common.inflated(SCALED_EPSILON))).empty(); + }), out.end()); + } + +private: + static constexpr int GRID = 64; + template void for_cells(const BoundingBox &bb, Fn &&fn) const + { + const int x0 = std::clamp(int((bb.min.x() - m_extent.min.x()) / m_cell_w), 0, GRID - 1), x1 = std::clamp(int((bb.max.x() - m_extent.min.x()) / m_cell_w), 0, GRID - 1); + const int y0 = std::clamp(int((bb.min.y() - m_extent.min.y()) / m_cell_h), 0, GRID - 1), y1 = std::clamp(int((bb.max.y() - m_extent.min.y()) / m_cell_h), 0, GRID - 1); + for (int y = y0; y <= y1; ++y) + for (int x = x0; x <= x1; ++x) + fn(y * GRID + x); + } + + const ExPolygons &m_islands; + std::vector m_bboxes; + BoundingBox m_extent; + coord_t m_cell_w = 1, m_cell_h = 1; + std::vector> m_grid; +}; + +static std::vector> merge_segmented_layers(const std::vector &input_expolygons, + const std::vector> &segmented_regions, std::vector> &&top_and_bottom_layers, const size_t num_facets_states, const std::function &throw_on_cancel_callback) @@ -1898,33 +2000,91 @@ static std::vector> merge_segmented_layers(const std::ve assert(!top_and_bottom_layers.size() || num_facets_states == top_and_bottom_layers.size()); BOOST_LOG_TRIVIAL(debug) << "Print object segmentation - Merging segmented layers in parallel - Begin"; - tbb::parallel_for(tbb::blocked_range(0, num_layers), [&segmented_regions, &top_and_bottom_layers, &segmented_regions_merged, &num_facets_states, &throw_on_cancel_callback](const tbb::blocked_range &range) { + // Every region of a layer is merged together with the regions of the islands it overlaps, and the islands are further + // apart than the dimple removal below reaches, so this gives the same result as merging the layer at once. On a layer + // cut through a fine relief every region shares thousands of hole contours with every other, and Clipper, splitting + // and re-linking one huge polygon over and over, took anything up to half an hour for a layer; per island each operation + // stays the size of the island, and the islands run in parallel. + tbb::parallel_for(tbb::blocked_range(0, num_layers), [&](const tbb::blocked_range &range) { for (size_t layer_idx = range.begin(); layer_idx < range.end(); ++layer_idx) { assert(segmented_regions[layer_idx].size() == num_facets_states); - // Zero is skipped because it is the default color of the volume + throw_on_cancel_callback(); + // Group the islands joined by a region overlapping several of them; the last group takes the regions lying + // outside every island. + const ExPolygons &islands = input_expolygons[layer_idx]; + const IslandLocator locator(islands); + std::vector parent(islands.size() + 1); + std::iota(parent.begin(), parent.end(), 0); + const auto root = [&parent](size_t i) { + while (parent[i] != i) + i = parent[i] = parent[parent[i]]; + return i; + }; + // Islands of every piece: side regions of colours 1.., then top/bottom regions of colours 0.. + std::vector pieces; + for (size_t extruder_id = 1; extruder_id < num_facets_states; ++extruder_id) + for (const ExPolygon &piece : segmented_regions[layer_idx][extruder_id]) + pieces.emplace_back(&piece); + if (!top_and_bottom_layers.empty()) + for (size_t color_idx = 0; color_idx < num_facets_states; ++color_idx) + for (const ExPolygon &piece : top_and_bottom_layers[color_idx][layer_idx]) + pieces.emplace_back(&piece); + std::vector> overlapped(pieces.size()); + tbb::parallel_for(size_t(0), pieces.size(), [&](size_t i) { locator.find(*pieces[i], overlapped[i]); }); + std::vector piece_island(pieces.size()); + for (size_t i = 0; i < pieces.size(); ++i) { + piece_island[i] = overlapped[i].empty() ? islands.size() : overlapped[i].front(); + for (size_t island : overlapped[i]) + parent[root(island)] = root(piece_island[i]); + } + std::vector bucket_of(parent.size(), size_t(-1)); + size_t num_buckets = 0; + for (size_t i = 0; i < parent.size(); ++i) + if (size_t &b = bucket_of[root(i)]; b == size_t(-1)) + b = num_buckets++; + + // [bucket][colour] + std::vector> sides(num_buckets, std::vector(num_facets_states)); + std::vector> tops(num_buckets, std::vector(num_facets_states)); + size_t piece_idx = 0; + for (size_t extruder_id = 1; extruder_id < num_facets_states; ++extruder_id) + for (const ExPolygon &piece : segmented_regions[layer_idx][extruder_id]) + sides[bucket_of[root(piece_island[piece_idx++])]][extruder_id].emplace_back(piece); + if (!top_and_bottom_layers.empty()) + for (size_t color_idx = 0; color_idx < num_facets_states; ++color_idx) + for (const ExPolygon &piece : top_and_bottom_layers[color_idx][layer_idx]) + tops[bucket_of[root(piece_island[piece_idx++])]][color_idx].emplace_back(piece); + + // Side regions minus the top/bottom regions of every colour. + std::vector> merged(num_buckets, std::vector(num_facets_states)); + tbb::parallel_for(size_t(0), num_buckets, [&](size_t bucket) { + Polygons tops_all; + for (const ExPolygons &t : tops[bucket]) + polygons_append(tops_all, t); + for (size_t extruder_id = 1; extruder_id < num_facets_states; ++extruder_id) + if (!sides[bucket][extruder_id].empty()) + merged[bucket][extruder_id] = tops_all.empty() ? std::move(sides[bucket][extruder_id]) : + diff_ex_by_piece(sides[bucket][extruder_id], tops_all); + }); + + // Then this colour's top/bottom regions, with the dimples removed (#7235) when the layer has side regions left. for (size_t extruder_id = 1; extruder_id < num_facets_states; ++extruder_id) { - throw_on_cancel_callback(); - if (!segmented_regions[layer_idx][extruder_id].empty()) { - ExPolygons segmented_regions_trimmed = segmented_regions[layer_idx][extruder_id]; - if (!top_and_bottom_layers.empty()) { - for (const std::vector &top_and_bottom_by_extruder : top_and_bottom_layers) { - if (!top_and_bottom_by_extruder[layer_idx].empty() && !segmented_regions_trimmed.empty()) { - segmented_regions_trimmed = diff_ex(segmented_regions_trimmed, top_and_bottom_by_extruder[layer_idx]); - } - } - } - - segmented_regions_merged[layer_idx][extruder_id - 1] = std::move(segmented_regions_trimmed); - } - - if (!top_and_bottom_layers.empty() && !top_and_bottom_layers[extruder_id][layer_idx].empty()) { - bool was_top_and_bottom_empty = segmented_regions_merged[layer_idx][extruder_id - 1].empty(); - append(segmented_regions_merged[layer_idx][extruder_id - 1], top_and_bottom_layers[extruder_id][layer_idx]); - - // Remove dimples (#7235) appearing after merging side segmentation of the model with tops and bottoms painted layers. - if (!was_top_and_bottom_empty) - segmented_regions_merged[layer_idx][extruder_id - 1] = offset2_ex(union_ex(segmented_regions_merged[layer_idx][extruder_id - 1]), float(SCALED_EPSILON), -float(SCALED_EPSILON)); + if (top_and_bottom_layers.empty() || top_and_bottom_layers[extruder_id][layer_idx].empty()) { + for (size_t bucket = 0; bucket < num_buckets; ++bucket) + append(segmented_regions_merged[layer_idx][extruder_id - 1], std::move(merged[bucket][extruder_id])); + continue; } + bool was_top_and_bottom_empty = true; + for (size_t bucket = 0; bucket < num_buckets && was_top_and_bottom_empty; ++bucket) + was_top_and_bottom_empty = merged[bucket][extruder_id].empty(); + tbb::parallel_for(size_t(0), num_buckets, [&](size_t bucket) { + ExPolygons ®ion = merged[bucket][extruder_id]; + append(region, tops[bucket][extruder_id]); + if (!was_top_and_bottom_empty && !region.empty()) + region = offset2_ex(union_ex(region), float(SCALED_EPSILON), -float(SCALED_EPSILON)); + }); + for (size_t bucket = 0; bucket < num_buckets; ++bucket) + append(segmented_regions_merged[layer_idx][extruder_id - 1], std::move(merged[bucket][extruder_id])); } } }); // end of parallel_for @@ -2212,16 +2372,56 @@ std::vector> segmentation_by_painting(const PrintObject assert(!color_poly.empty()); assert(!color_poly.front().empty()); - if (has_layer_only_one_color(color_poly)) { - // If the whole layer is painted using the same color, it is not needed to construct a Voronoi diagram for the segmentation of this layer. - segmented_regions[layer_idx][size_t(color_poly.front().front().color)] = input_expolygons[layer_idx]; - } else { - MMU_Graph graph = build_graph(layer_idx, color_poly); - remove_multiple_edges_in_vertices(graph, color_poly); - graph.remove_nodes_with_one_arc(); - segmented_regions[layer_idx] = extract_colored_segments(graph, num_facets_states); - //segmented_regions[layer_idx] = extract_colored_segments(color_poly, num_extruders, layer_idx); + // Each island (an ExPolygon with its holes) is segmented on its own. Any point of an island is closer to + // that island's contours than to any other island's - the way out crosses its own boundary first - so its + // Voronoi cells, and with them its colour regions, depend on nothing else. A layer cut through a fine relief + // has thousands of islands, and one Voronoi diagram over all of them degenerated into overlapping regions + // that every boolean afterwards had to untangle. Per island the diagrams stay small and the islands run in + // parallel; an island in a single colour needs no diagram at all. + const ExPolygons &islands = input_expolygons[layer_idx]; + std::vector> island_contours(islands.size()); // [first, last) into color_poly + { + // The same order EdgeGrid::Grid::create() lists the contours in, and so colorize_contours(). + size_t idx = 0; + for (size_t island_idx = 0; island_idx < islands.size(); ++island_idx) { + const size_t first = idx; + if (!islands[island_idx].contour.empty()) + ++idx; + for (const Polygon &hole : islands[island_idx].holes) + if (!hole.empty()) + ++idx; + island_contours[island_idx] = {first, idx}; + } + assert(idx == color_poly.size()); } + std::vector> island_regions(islands.size()); + tbb::parallel_for(size_t(0), islands.size(), [&](size_t island_idx) { + const auto [first, last] = island_contours[island_idx]; + if (first == last) + return; + const std::vector island_poly(color_poly.begin() + first, color_poly.begin() + last); + std::vector ®ions = island_regions[island_idx]; + if (has_layer_only_one_color(island_poly)) { + regions.assign(num_facets_states, ExPolygons()); + regions[size_t(island_poly.front().front().color)].emplace_back(islands[island_idx]); + } else { + MMU_Graph graph = build_graph(layer_idx, island_poly); + remove_multiple_edges_in_vertices(graph, island_poly); + graph.remove_nodes_with_one_arc(); + regions = extract_colored_segments(graph, num_facets_states); + // The faces of one colour tile it without overlapping; merged here, where an island is small, + // every later boolean gets a few regions instead of thousands of faces sharing their edges. An + // island with many holes keeps its faces: merged, each colour would be one region with thousands + // of holes, and subtracting from that is far slower than from the faces one at a time. + if (island_poly.size() <= 64) + for (ExPolygons &faces : regions) + if (faces.size() > 1) + faces = union_ex(faces); + } + }); + for (std::vector ®ions : island_regions) + for (size_t color_idx = 0; color_idx < regions.size(); ++color_idx) + append(segmented_regions[layer_idx][color_idx], std::move(regions[color_idx])); #ifdef MM_SEGMENTATION_DEBUG_REGIONS export_regions_to_svg(debug_out_path("3-mm-regions-sides-%d-%d.svg", layer_idx, iRun), segmented_regions[layer_idx], input_expolygons[layer_idx]); @@ -2244,7 +2444,7 @@ std::vector> segmentation_by_painting(const PrintObject throw_on_cancel_callback(); } - std::vector> segmented_regions_merged = merge_segmented_layers(segmented_regions, std::move(top_and_bottom_layers), num_facets_states, throw_on_cancel_callback); + std::vector> segmented_regions_merged = merge_segmented_layers(input_expolygons, segmented_regions, std::move(top_and_bottom_layers), num_facets_states, throw_on_cancel_callback); throw_on_cancel_callback(); #ifdef MM_SEGMENTATION_DEBUG_REGIONS diff --git a/src/libslic3r/MultiPoint.hpp b/src/libslic3r/MultiPoint.hpp index 82d6d5884b..772085b3be 100644 --- a/src/libslic3r/MultiPoint.hpp +++ b/src/libslic3r/MultiPoint.hpp @@ -23,11 +23,15 @@ public: MultiPoint() {} MultiPoint(const MultiPoint &other) : points(other.points) {} - MultiPoint(MultiPoint &&other) : points(std::move(other.points)) {} + MultiPoint(MultiPoint &&other) noexcept : points(std::move(other.points)) {} MultiPoint(std::initializer_list list) : points(list) {} explicit MultiPoint(const Points &_points) : points(_points) {} + // Without it, the derived classes' move constructors passing std::move(points) here copied them, which + // also means a moved-from Polygon or Polyline is now really empty where it used to silently keep its + // points: a use-after-move anywhere in the tree that happened to work before now sees nothing. + explicit MultiPoint(Points &&_points) noexcept : points(std::move(_points)) {} MultiPoint& operator=(const MultiPoint &other) { points = other.points; return *this; } - MultiPoint& operator=(MultiPoint &&other) { points = std::move(other.points); return *this; } + MultiPoint& operator=(MultiPoint &&other) noexcept { points = std::move(other.points); return *this; } virtual ~MultiPoint() = default; void scale(double factor); void scale(double factor_x, double factor_y); diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index eed1adcf78..219b6b2551 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -32,6 +32,8 @@ #include #include #include +#include +#include #include #include "libslic3r.h" #include @@ -2583,467 +2585,490 @@ void PerimeterGenerator::process_arachne() const bool only_one_wall_first_layer = this->config->only_one_wall_first_layer && has_bottom_shell_layers(*this->config); // we need to process each island separately because we might have different // extra perimeters for each one - for (const Surface& surface : all_surfaces) { - coord_t bead_width_0 = ext_perimeter_spacing; - // detect how many perimeters must be generated for this island - int loop_number = this->config->wall_loops + surface.extra_perimeters - 1; // 0-indexed loops - int sparse_infill_density = this->config->sparse_infill_density.value; - if (this->config->alternate_extra_wall && this->layer_id % 2 == 1 && !m_spiral_vase && sparse_infill_density > 0) // add alternating extra wall - loop_number++; + // Each island is independent up to its outputs, so they are generated in parallel - a layer split into thousands + // of islands (e.g. by colour painting) otherwise ran on one thread - and the outputs are then committed in the + // original island order, which is what the extra overhang perimeters (applied to the last island's loops and to + // all fill surfaces so far) depend on. + struct ArachneSurfaceResult + { + ExtrusionEntityCollection loops; + bool has_loops = false; + ExPolygons infill; + ExPolygons no_overlap; + }; + std::vector results(all_surfaces.size()); + tbb::parallel_for(tbb::blocked_range(0, all_surfaces.size()), [&](const tbb::blocked_range &range) { + for (size_t surface_idx = range.begin(); surface_idx < range.end(); ++surface_idx) { + const Surface &surface = all_surfaces[surface_idx]; + ArachneSurfaceResult &result = results[surface_idx]; + coord_t bead_width_0 = ext_perimeter_spacing; + // detect how many perimeters must be generated for this island + int loop_number = this->config->wall_loops + surface.extra_perimeters - 1; // 0-indexed loops + int sparse_infill_density = this->config->sparse_infill_density.value; + if (this->config->alternate_extra_wall && this->layer_id % 2 == 1 && !m_spiral_vase && sparse_infill_density > 0) // add alternating extra wall + loop_number++; - // Set the bottommost layer to be one wall - const bool is_bottom_layer = (this->layer_id == object_config->raft_layers) ? true : false; - if (is_bottom_layer && only_one_wall_first_layer) - loop_number = 0; + // Set the bottommost layer to be one wall + const bool is_bottom_layer = (this->layer_id == object_config->raft_layers) ? true : false; + if (is_bottom_layer && only_one_wall_first_layer) + loop_number = 0; - // Orca: set the topmost layer to be one wall according to the config - const bool is_topmost_layer = (this->upper_slices == nullptr) ? true : false; - if (is_topmost_layer && loop_number > 0 && only_one_wall_top) - loop_number = 0; + // Orca: set the topmost layer to be one wall according to the config + const bool is_topmost_layer = (this->upper_slices == nullptr) ? true : false; + if (is_topmost_layer && loop_number > 0 && only_one_wall_top) + loop_number = 0; - auto apply_precise_outer_wall = config->precise_outer_wall && config->wall_sequence == WallSequence::InnerOuter; - // Orca: properly adjust offset for the outer wall if precise_outer_wall is enabled. - ExPolygons last = offset_ex(surface.expolygon.simplify_p(surface_simplify_resolution), - apply_precise_outer_wall? -float(ext_perimeter_width - ext_perimeter_spacing ) - : -float(ext_perimeter_width / 2. - ext_perimeter_spacing / 2.)); + auto apply_precise_outer_wall = config->precise_outer_wall && config->wall_sequence == WallSequence::InnerOuter; + // Orca: properly adjust offset for the outer wall if precise_outer_wall is enabled. + ExPolygons last = offset_ex(surface.expolygon.simplify_p(surface_simplify_resolution), + apply_precise_outer_wall? -float(ext_perimeter_width - ext_perimeter_spacing ) + : -float(ext_perimeter_width / 2. - ext_perimeter_spacing / 2.)); - Arachne::WallToolPathsParams input_params = Arachne::make_paths_params(this->layer_id, *object_config, *print_config); - // Set params is_top_or_bottom_layer for adjusting short-wall removal sensitivity. - input_params.is_top_or_bottom_layer = (is_bottom_layer || is_topmost_layer) ? true : false; + Arachne::WallToolPathsParams input_params = Arachne::make_paths_params(this->layer_id, *object_config, *print_config); + // Set params is_top_or_bottom_layer for adjusting short-wall removal sensitivity. + input_params.is_top_or_bottom_layer = (is_bottom_layer || is_topmost_layer) ? true : false; - coord_t wall_0_inset = 0; - if (apply_precise_outer_wall) - wall_0_inset = -coord_t(ext_perimeter_width / 2 - ext_perimeter_spacing / 2); + coord_t wall_0_inset = 0; + if (apply_precise_outer_wall) + wall_0_inset = -coord_t(ext_perimeter_width / 2 - ext_perimeter_spacing / 2); - //PS: One wall top surface for Arachne - ExPolygons top_expolygons; - // Calculate how many inner loops remain when TopSurfaces is selected. - const int inner_loop_number = (only_one_wall_top && upper_slices != nullptr) ? loop_number - 1 : -1; + //PS: One wall top surface for Arachne + ExPolygons top_expolygons; + // Calculate how many inner loops remain when TopSurfaces is selected. + const int inner_loop_number = (only_one_wall_top && upper_slices != nullptr) ? loop_number - 1 : -1; - // Set one perimeter when TopSurfaces is selected. - if (only_one_wall_top && loop_number > 0) - loop_number = 0; + // Set one perimeter when TopSurfaces is selected. + if (only_one_wall_top && loop_number > 0) + loop_number = 0; - Arachne::WallToolPathsParams input_params_tmp = input_params; + Arachne::WallToolPathsParams input_params_tmp = input_params; - Polygons last_p = to_polygons(last); - Arachne::WallToolPaths wallToolPaths(last_p, bead_width_0, perimeter_spacing, coord_t(loop_number + 1), - wall_0_inset, layer_height, input_params_tmp); - std::vector perimeters = wallToolPaths.getToolPaths(); - ExPolygons infill_contour = union_ex(wallToolPaths.getInnerContour()); + Polygons last_p = to_polygons(last); + Arachne::WallToolPaths wallToolPaths(last_p, bead_width_0, perimeter_spacing, coord_t(loop_number + 1), + wall_0_inset, layer_height, input_params_tmp); + std::vector perimeters = wallToolPaths.getToolPaths(); + ExPolygons infill_contour = union_ex(wallToolPaths.getInnerContour()); - // Check if there are some remaining perimeters to generate (the number of perimeters - // is greater than one together with enabled the single perimeter on top surface feature). - if (inner_loop_number >= 0) { - assert(upper_slices != nullptr); + // Check if there are some remaining perimeters to generate (the number of perimeters + // is greater than one together with enabled the single perimeter on top surface feature). + if (inner_loop_number >= 0) { + assert(upper_slices != nullptr); - coord_t perimeter_width = this->perimeter_flow.scaled_width(); + coord_t perimeter_width = this->perimeter_flow.scaled_width(); - // Filter out areas that are too thin and expand top surface polygons a bit to hide the wall line. - // ORCA: skip if the top surface area is smaller than "min_width_top_surface" - const float top_surface_min_width = std::max(float(ext_perimeter_spacing) / 4.f + scaled(0.00001), float(scale_(config->min_width_top_surface.get_abs_value(unscale_(perimeter_width)))) / 4.f); + // Filter out areas that are too thin and expand top surface polygons a bit to hide the wall line. + // ORCA: skip if the top surface area is smaller than "min_width_top_surface" + const float top_surface_min_width = std::max(float(ext_perimeter_spacing) / 4.f + scaled(0.00001), float(scale_(config->min_width_top_surface.get_abs_value(unscale_(perimeter_width)))) / 4.f); - // Get top ExPolygons from the given contour. uncovered reports whether the upper layer leaves any of the - // contour uncovered, before bridges and too thin areas are filtered out. - auto get_top_expolygons = [&](const ExPolygons &contour, bool &uncovered) { - // Contour bounding box. - BoundingBox contour_bbox = get_extents(contour); - contour_bbox.offset(SCALED_EPSILON); + // Get top ExPolygons from the given contour. uncovered reports whether the upper layer leaves any of the + // contour uncovered, before bridges and too thin areas are filtered out. + auto get_top_expolygons = [&](const ExPolygons &contour, bool &uncovered) { + // Contour bounding box. + BoundingBox contour_bbox = get_extents(contour); + contour_bbox.offset(SCALED_EPSILON); - Polygons upper_slices_clipped; - if (object_config->interface_shells) { - auto upper_slicer_same_region = to_expolygons(this->upper_slices_same_region->surfaces); - upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(upper_slicer_same_region, contour_bbox); - } else - upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*upper_slices, contour_bbox); + Polygons upper_slices_clipped; + if (object_config->interface_shells) { + auto upper_slicer_same_region = to_expolygons(this->upper_slices_same_region->surfaces); + upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(upper_slicer_same_region, contour_bbox); + } else + upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*upper_slices, contour_bbox); - ExPolygons top = diff_ex(contour, upper_slices_clipped); - uncovered = !top.empty(); - if (top.empty()) - return top; + ExPolygons top = diff_ex(contour, upper_slices_clipped); + uncovered = !top.empty(); + if (top.empty()) + return top; - if (lower_slices != nullptr) { - const float bridge_offset = float(std::max(ext_perimeter_spacing, perimeter_width)); - const Polygons lower_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*lower_slices, contour_bbox); - const ExPolygons current_slices_bridges = offset_ex(diff_ex(top, lower_slices_clipped), bridge_offset); + if (lower_slices != nullptr) { + const float bridge_offset = float(std::max(ext_perimeter_spacing, perimeter_width)); + const Polygons lower_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*lower_slices, contour_bbox); + const ExPolygons current_slices_bridges = offset_ex(diff_ex(top, lower_slices_clipped), bridge_offset); - // Remove bridges from top surface polygons. - top = diff_ex(top, current_slices_bridges); + // Remove bridges from top surface polygons. + top = diff_ex(top, current_slices_bridges); + } + + // Shrink the polygon to remove the small areas, then expand it back out plus a maragin to hide the wall line a little. + // ORCA: Expand the polygon with half the perimeter width in addition to the contracted amount, + // not the full perimeter width as PS does, to enable thin lettering to print on the top surface without nozzle collisions + // due to thin lines being generated + top = offset2_ex(top, -top_surface_min_width, top_surface_min_width + float(perimeter_width * 0.85)); + + // Get final top ExPolygons (bridges were excluded above, so they stay walled). + return intersection_ex(top, contour); + }; + + // Walls with the full count, as generated when the single perimeter feature is disabled. Generated on first use. + std::vector full_perimeters; + Polygons full_inner_contour; + bool full_perimeters_generated = false; + auto generate_full_perimeters = [&]() { + if (full_perimeters_generated) + return; + Arachne::WallToolPaths full_tool_paths(last_p, bead_width_0, perimeter_spacing, coord_t(inner_loop_number + 2), wall_0_inset, layer_height, input_params_tmp); + full_perimeters = full_tool_paths.getToolPaths(); + full_inner_contour = full_tool_paths.getInnerContour(); + full_perimeters_generated = true; + }; + + // ORCA: the single wall pass allows Arachne 2 beads across a wall, so it fills a wall narrower than 3 outer wall + // widths by widening both, where the full pass adds a middle bead. Over the top surface that is the intent; + // anywhere else it leaves no room for the inner walls. When the single wall pass's outer walls run away from + // the full pass's outside the top surface, take the full pass's outer walls and the area inside them instead. + // Walls closer than outer_wall_tolerance count as the same wall: a widened bead's centerline moves by half + // the width added, and only beads widened by more than twice the tolerance are looked for. + const coord_t outer_wall_tolerance = bead_width_0 / 10; + if (widest_bead(perimeters) > bead_width_0 + 2 * outer_wall_tolerance) { + // The single wall pass's inner contour where it widens no bead: inside nominal width outer walls. + const ExPolygons nominal_infill_contour = offset_ex(last, -float(bead_width_0 + wall_0_inset)); + bool nominal_uncovered = false; + // Grown by an outer wall width to take in the outer walls bordering the top surface. + const ExPolygons top_zone = offset_ex(get_top_expolygons(nominal_infill_contour, nominal_uncovered), float(bead_width_0)); + if (nominal_uncovered) { + generate_full_perimeters(); + if (! full_perimeters.empty() && ! full_perimeters.front().empty() && + length_off_reference(perimeters, full_perimeters.front(), top_zone, outer_wall_tolerance) > double(perimeter_width)) { + perimeters = { full_perimeters.front() }; + infill_contour = diff_ex(nominal_infill_contour, walls_footprint(full_perimeters.front()), ApplySafetyOffset::Yes); + } + } } - // Shrink the polygon to remove the small areas, then expand it back out plus a maragin to hide the wall line a little. - // ORCA: Expand the polygon with half the perimeter width in addition to the contracted amount, - // not the full perimeter width as PS does, to enable thin lettering to print on the top surface without nozzle collisions - // due to thin lines being generated - top = offset2_ex(top, -top_surface_min_width, top_surface_min_width + float(perimeter_width * 0.85)); + bool uncovered = false; + top_expolygons = get_top_expolygons(infill_contour, uncovered); - // Get final top ExPolygons (bridges were excluded above, so they stay walled). - return intersection_ex(top, contour); - }; + if (uncovered) { + // ORCA: onion the real region (inside the outer wall) so the remaining walls follow the actual + // geometry, then cut away the parts over the top surface. Re-onioning the non-top complement + // instead - the fallback when there is no top fill - walls the top/non-top interface and rings + // top-surface islands with inner walls that don't exist when the feature is disabled. + const bool clip_walls_over_top = top_fill_replaces_inner_walls(*this->config); + const Polygons inner_region = to_polygons(offset_ex(clip_walls_over_top ? infill_contour + : diff_ex(infill_contour, top_expolygons), + wall_0_inset)); + Arachne::WallToolPaths inner_wall_tool_paths(inner_region, perimeter_spacing, perimeter_spacing, coord_t(inner_loop_number + 1), 0, layer_height, input_params_tmp); + std::vector inner_perimeters = inner_wall_tool_paths.getToolPaths(); - // Walls with the full count, as generated when the single perimeter feature is disabled. Generated on first use. - std::vector full_perimeters; - Polygons full_inner_contour; - bool full_perimeters_generated = false; - auto generate_full_perimeters = [&]() { - if (full_perimeters_generated) - return; - Arachne::WallToolPaths full_tool_paths(last_p, bead_width_0, perimeter_spacing, coord_t(inner_loop_number + 2), wall_0_inset, layer_height, input_params_tmp); - full_perimeters = full_tool_paths.getToolPaths(); - full_inner_contour = full_tool_paths.getInnerContour(); - full_perimeters_generated = true; - }; + if (clip_walls_over_top) { + Polygons kept_over_top; + clip_inner_walls_over_top(inner_perimeters, top_expolygons, perimeter_width, kept_over_top); + // Route the top fill around the walls kept despite grazing the top. + if (! kept_over_top.empty()) + top_expolygons = diff_ex(top_expolygons, kept_over_top); + } - // ORCA: the single wall pass allows Arachne 2 beads across a wall, so it fills a wall narrower than 3 outer wall - // widths by widening both, where the full pass adds a middle bead. Over the top surface that is the intent; - // anywhere else it leaves no room for the inner walls. When the single wall pass's outer walls run away from - // the full pass's outside the top surface, take the full pass's outer walls and the area inside them instead. - // Walls closer than outer_wall_tolerance count as the same wall: a widened bead's centerline moves by half - // the width added, and only beads widened by more than twice the tolerance are looked for. - const coord_t outer_wall_tolerance = bead_width_0 / 10; - if (widest_bead(perimeters) > bead_width_0 + 2 * outer_wall_tolerance) { - // The single wall pass's inner contour where it widens no bead: inside nominal width outer walls. - const ExPolygons nominal_infill_contour = offset_ex(last, -float(bead_width_0 + wall_0_inset)); - bool nominal_uncovered = false; - // Grown by an outer wall width to take in the outer walls bordering the top surface. - const ExPolygons top_zone = offset_ex(get_top_expolygons(nominal_infill_contour, nominal_uncovered), float(bead_width_0)); - if (nominal_uncovered) { + // Recalculate indexes of inner perimeters before merging them: they come after the single outer wall. + if (!perimeters.empty()) + for (Arachne::VariableWidthLines &inner_perimeter : inner_perimeters) + for (Arachne::ExtrusionLine &el : inner_perimeter) + ++el.inset_idx; + + perimeters.insert(perimeters.end(), inner_perimeters.begin(), inner_perimeters.end()); + infill_contour = union_ex(top_expolygons, inner_wall_tool_paths.getInnerContour()); + } else { + // There is no top surface ExPolygon, so use the walls generated like when the single perimeter + // feature is disabled. generate_full_perimeters(); - if (! full_perimeters.empty() && ! full_perimeters.front().empty() && - length_off_reference(perimeters, full_perimeters.front(), top_zone, outer_wall_tolerance) > double(perimeter_width)) { - perimeters = { full_perimeters.front() }; - infill_contour = diff_ex(nominal_infill_contour, walls_footprint(full_perimeters.front()), ApplySafetyOffset::Yes); - } + perimeters = std::move(full_perimeters); + infill_contour = union_ex(full_inner_contour); } } + //PS - bool uncovered = false; - top_expolygons = get_top_expolygons(infill_contour, uncovered); + loop_number = int(perimeters.size()) - 1; - if (uncovered) { - // ORCA: onion the real region (inside the outer wall) so the remaining walls follow the actual - // geometry, then cut away the parts over the top surface. Re-onioning the non-top complement - // instead - the fallback when there is no top fill - walls the top/non-top interface and rings - // top-surface islands with inner walls that don't exist when the feature is disabled. - const bool clip_walls_over_top = top_fill_replaces_inner_walls(*this->config); - const Polygons inner_region = to_polygons(offset_ex(clip_walls_over_top ? infill_contour - : diff_ex(infill_contour, top_expolygons), - wall_0_inset)); - Arachne::WallToolPaths inner_wall_tool_paths(inner_region, perimeter_spacing, perimeter_spacing, coord_t(inner_loop_number + 1), 0, layer_height, input_params_tmp); - std::vector inner_perimeters = inner_wall_tool_paths.getToolPaths(); - - if (clip_walls_over_top) { - Polygons kept_over_top; - clip_inner_walls_over_top(inner_perimeters, top_expolygons, perimeter_width, kept_over_top); - // Route the top fill around the walls kept despite grazing the top. - if (! kept_over_top.empty()) - top_expolygons = diff_ex(top_expolygons, kept_over_top); - } - - // Recalculate indexes of inner perimeters before merging them: they come after the single outer wall. - if (!perimeters.empty()) - for (Arachne::VariableWidthLines &inner_perimeter : inner_perimeters) - for (Arachne::ExtrusionLine &el : inner_perimeter) - ++el.inset_idx; - - perimeters.insert(perimeters.end(), inner_perimeters.begin(), inner_perimeters.end()); - infill_contour = union_ex(top_expolygons, inner_wall_tool_paths.getInnerContour()); - } else { - // There is no top surface ExPolygon, so use the walls generated like when the single perimeter - // feature is disabled. - generate_full_perimeters(); - perimeters = std::move(full_perimeters); - infill_contour = union_ex(full_inner_contour); + #ifdef ARACHNE_DEBUG + { + static int iRun = 0; + export_perimeters_to_svg(debug_out_path("arachne-perimeters-%d-%d.svg", layer_id, iRun++), to_polygons(last), perimeters, union_ex(wallToolPaths.getInnerContour())); } - } - //PS + #endif - loop_number = int(perimeters.size()) - 1; + // All closed ExtrusionLine should have the same the first and the last point. + // But in rare cases, Arachne produce ExtrusionLine marked as closed but without + // equal the first and the last point. + assert([&perimeters = std::as_const(perimeters)]() -> bool { + for (const Arachne::VariableWidthLines& perimeter : perimeters) + for (const Arachne::ExtrusionLine& el : perimeter) + if (el.is_closed && el.junctions.front().p != el.junctions.back().p) + return false; + return true; + }()); - #ifdef ARACHNE_DEBUG - { - static int iRun = 0; - export_perimeters_to_svg(debug_out_path("arachne-perimeters-%d-%d.svg", layer_id, iRun++), to_polygons(last), perimeters, union_ex(wallToolPaths.getInnerContour())); - } -#endif + int start_perimeter = int(perimeters.size()) - 1; + int end_perimeter = -1; + int direction = -1; - // All closed ExtrusionLine should have the same the first and the last point. - // But in rare cases, Arachne produce ExtrusionLine marked as closed but without - // equal the first and the last point. - assert([&perimeters = std::as_const(perimeters)]() -> bool { - for (const Arachne::VariableWidthLines& perimeter : perimeters) - for (const Arachne::ExtrusionLine& el : perimeter) - if (el.is_closed && el.junctions.front().p != el.junctions.back().p) - return false; - return true; - }()); - - int start_perimeter = int(perimeters.size()) - 1; - int end_perimeter = -1; - int direction = -1; - - bool is_outer_wall_first = - this->config->wall_sequence == WallSequence::OuterInner || - this->config->wall_sequence == WallSequence::InnerOuterInner; + bool is_outer_wall_first = + this->config->wall_sequence == WallSequence::OuterInner || + this->config->wall_sequence == WallSequence::InnerOuterInner; - if (layer_id == 0){ // disable inner outer inner algorithm after the first layer - is_outer_wall_first = - this->config->wall_sequence == WallSequence::OuterInner || this->outer_walls_first_for_brim(); - } - if (is_outer_wall_first) { - start_perimeter = 0; - end_perimeter = int(perimeters.size()); - direction = 1; - } - - std::vector all_extrusions; - for (int perimeter_idx = start_perimeter; perimeter_idx != end_perimeter; perimeter_idx += direction) { - if (perimeters[perimeter_idx].empty()) - continue; - for (Arachne::ExtrusionLine& wall : perimeters[perimeter_idx]) - all_extrusions.emplace_back(&wall); - } - - // Find topological order with constraints from extrusions_constrains. - std::vector blocked(all_extrusions.size(), 0); // Value indicating how many extrusions it is blocking (preceding extrusions) an extrusion. - std::vector> blocking(all_extrusions.size()); // Each extrusion contains a vector of extrusions that are blocked by this extrusion. - std::unordered_map map_extrusion_to_idx; - for (size_t idx = 0; idx < all_extrusions.size(); idx++) - map_extrusion_to_idx.emplace(all_extrusions[idx], idx); - - auto extrusions_constrains = Arachne::WallToolPaths::getRegionOrder(all_extrusions, is_outer_wall_first); - for (auto [before, after] : extrusions_constrains) { - auto after_it = map_extrusion_to_idx.find(after); - ++blocked[after_it->second]; - blocking[map_extrusion_to_idx.find(before)->second].emplace_back(after_it->second); - } - - std::vector processed(all_extrusions.size(), false); // Indicate that the extrusion was already processed. - Point current_position = all_extrusions.empty() ? Point::Zero() : all_extrusions.front()->junctions.front().p; // Some starting position. - std::vector ordered_extrusions; // To store our result in. At the end we'll std::swap. - ordered_extrusions.reserve(all_extrusions.size()); - - while (ordered_extrusions.size() < all_extrusions.size()) { - size_t best_candidate = 0; - double best_distance_sqr = std::numeric_limits::max(); - bool is_best_closed = false; - - std::vector available_candidates; - for (size_t candidate = 0; candidate < all_extrusions.size(); ++candidate) { - if (processed[candidate] || blocked[candidate]) - continue; // Not a valid candidate. - available_candidates.push_back(candidate); + if (layer_id == 0){ // disable inner outer inner algorithm after the first layer + is_outer_wall_first = + this->config->wall_sequence == WallSequence::OuterInner || this->outer_walls_first_for_brim(); + } + if (is_outer_wall_first) { + start_perimeter = 0; + end_perimeter = int(perimeters.size()); + direction = 1; } - std::sort(available_candidates.begin(), available_candidates.end(), [&all_extrusions](const size_t a_idx, const size_t b_idx) -> bool { - return all_extrusions[a_idx]->is_closed < all_extrusions[b_idx]->is_closed; - }); - - for (const size_t candidate_path_idx : available_candidates) { - auto& path = all_extrusions[candidate_path_idx]; - - if (path->junctions.empty()) { // No vertices in the path. Can't find the start position then or really plan it in. Put that at the end. - if (best_distance_sqr == std::numeric_limits::max()) { - best_candidate = candidate_path_idx; - is_best_closed = path->is_closed; - } + std::vector all_extrusions; + for (int perimeter_idx = start_perimeter; perimeter_idx != end_perimeter; perimeter_idx += direction) { + if (perimeters[perimeter_idx].empty()) continue; + for (Arachne::ExtrusionLine& wall : perimeters[perimeter_idx]) + all_extrusions.emplace_back(&wall); + } + + // Find topological order with constraints from extrusions_constrains. + std::vector blocked(all_extrusions.size(), 0); // Value indicating how many extrusions it is blocking (preceding extrusions) an extrusion. + std::vector> blocking(all_extrusions.size()); // Each extrusion contains a vector of extrusions that are blocked by this extrusion. + std::unordered_map map_extrusion_to_idx; + for (size_t idx = 0; idx < all_extrusions.size(); idx++) + map_extrusion_to_idx.emplace(all_extrusions[idx], idx); + + auto extrusions_constrains = Arachne::WallToolPaths::getRegionOrder(all_extrusions, is_outer_wall_first); + for (auto [before, after] : extrusions_constrains) { + auto after_it = map_extrusion_to_idx.find(after); + ++blocked[after_it->second]; + blocking[map_extrusion_to_idx.find(before)->second].emplace_back(after_it->second); + } + + std::vector processed(all_extrusions.size(), false); // Indicate that the extrusion was already processed. + Point current_position = all_extrusions.empty() ? Point::Zero() : all_extrusions.front()->junctions.front().p; // Some starting position. + std::vector ordered_extrusions; // To store our result in. At the end we'll std::swap. + ordered_extrusions.reserve(all_extrusions.size()); + + while (ordered_extrusions.size() < all_extrusions.size()) { + size_t best_candidate = 0; + double best_distance_sqr = std::numeric_limits::max(); + bool is_best_closed = false; + + std::vector available_candidates; + for (size_t candidate = 0; candidate < all_extrusions.size(); ++candidate) { + if (processed[candidate] || blocked[candidate]) + continue; // Not a valid candidate. + available_candidates.push_back(candidate); } - const Point candidate_position = path->junctions.front().p; - double distance_sqr = (current_position - candidate_position).cast().norm(); - if (distance_sqr < best_distance_sqr) { // Closer than the best candidate so far. - if (path->is_closed || (!path->is_closed && best_distance_sqr != std::numeric_limits::max()) || (!path->is_closed && !is_best_closed)) { - best_candidate = candidate_path_idx; - best_distance_sqr = distance_sqr; - is_best_closed = path->is_closed; + std::sort(available_candidates.begin(), available_candidates.end(), [&all_extrusions](const size_t a_idx, const size_t b_idx) -> bool { + return all_extrusions[a_idx]->is_closed < all_extrusions[b_idx]->is_closed; + }); + + for (const size_t candidate_path_idx : available_candidates) { + auto& path = all_extrusions[candidate_path_idx]; + + if (path->junctions.empty()) { // No vertices in the path. Can't find the start position then or really plan it in. Put that at the end. + if (best_distance_sqr == std::numeric_limits::max()) { + best_candidate = candidate_path_idx; + is_best_closed = path->is_closed; + } + continue; } + + const Point candidate_position = path->junctions.front().p; + double distance_sqr = (current_position - candidate_position).cast().norm(); + if (distance_sqr < best_distance_sqr) { // Closer than the best candidate so far. + if (path->is_closed || (!path->is_closed && best_distance_sqr != std::numeric_limits::max()) || (!path->is_closed && !is_best_closed)) { + best_candidate = candidate_path_idx; + best_distance_sqr = distance_sqr; + is_best_closed = path->is_closed; + } + } + } + + auto& best_path = all_extrusions[best_candidate]; + ordered_extrusions.push_back({ best_path, best_path->is_contour() }); + processed[best_candidate] = true; + for (size_t unlocked_idx : blocking[best_candidate]) + blocked[unlocked_idx]--; + + if (!best_path->junctions.empty()) { //If all paths were empty, the best path is still empty. We don't upate the current position then. + if (best_path->is_closed) + current_position = best_path->junctions[0].p; //We end where we started. + else + current_position = best_path->junctions.back().p; //Pick the other end from where we started. } } - auto& best_path = all_extrusions[best_candidate]; - ordered_extrusions.push_back({ best_path, best_path->is_contour() }); - processed[best_candidate] = true; - for (size_t unlocked_idx : blocking[best_candidate]) - blocked[unlocked_idx]--; + // printf("New Layer: Layer ID %d\n",layer_id); //debug - new layer + if (this->config->wall_sequence == WallSequence::InnerOuterInner && layer_id > 0) { // only enable inner outer inner algorithm after first layer + if (ordered_extrusions.size() > 2) { // 3 walls minimum needed to do inner outer inner ordering + int position = 0; // index to run the re-ordering for multiple external perimeters in a single island. + int arr_i, arr_j = 0; // indexes to run through the walls in the for loops + int outer, first_internal, second_internal, max_internal, current_perimeter; // allocate index values + + // To address any remaining scenarios where the outer perimeter contour is not first on the list as arachne sometimes reorders the perimeters when clustering + // for OI mode that is used the basis for IOI + bringContoursToFront(ordered_extrusions); + std::vector reordered_extrusions; + + // Debug statement to print spacing values: + //printf("External threshold - Ext perimeter: %d Ext spacing: %d Int perimeter: %d Int spacing: %d\n", this->ext_perimeter_flow.scaled_width(),this->ext_perimeter_flow.scaled_spacing(),this->perimeter_flow.scaled_width(), this->perimeter_flow.scaled_spacing()); - if (!best_path->junctions.empty()) { //If all paths were empty, the best path is still empty. We don't upate the current position then. - if (best_path->is_closed) - current_position = best_path->junctions[0].p; //We end where we started. - else - current_position = best_path->junctions.back().p; //Pick the other end from where we started. - } - } - - // printf("New Layer: Layer ID %d\n",layer_id); //debug - new layer - if (this->config->wall_sequence == WallSequence::InnerOuterInner && layer_id > 0) { // only enable inner outer inner algorithm after first layer - if (ordered_extrusions.size() > 2) { // 3 walls minimum needed to do inner outer inner ordering - int position = 0; // index to run the re-ordering for multiple external perimeters in a single island. - int arr_i, arr_j = 0; // indexes to run through the walls in the for loops - int outer, first_internal, second_internal, max_internal, current_perimeter; // allocate index values + // Get searching thresholds. For an external perimeter we take the external perimeter spacing/2 plus the internal perimeter spacing/2 and expand by the factor + // rounding errors. When precise wall is enabled, the external perimeter full spacing is used. + coord_t threshold_external = (apply_precise_outer_wall) + // Precise outer wall: use the full external spacing + ? ( this->ext_perimeter_flow.scaled_spacing() + + this->perimeter_flow.scaled_spacing()/2.0 ) + // Normal: half ext spacing plus half int spacing + : ( this->ext_perimeter_flow.scaled_spacing()/2.0 + + this->perimeter_flow.scaled_spacing()/2.0 ); - // To address any remaining scenarios where the outer perimeter contour is not first on the list as arachne sometimes reorders the perimeters when clustering - // for OI mode that is used the basis for IOI - bringContoursToFront(ordered_extrusions); - std::vector reordered_extrusions; + // For the intenal perimeter threshold, the distance is the internal perimeter spacing expanded by the factor to cover rounding errors. + coord_t threshold_internal = this->perimeter_flow.scaled_spacing(); - // Debug statement to print spacing values: - //printf("External threshold - Ext perimeter: %d Ext spacing: %d Int perimeter: %d Int spacing: %d\n", this->ext_perimeter_flow.scaled_width(),this->ext_perimeter_flow.scaled_spacing(),this->perimeter_flow.scaled_width(), this->perimeter_flow.scaled_spacing()); - - // Get searching thresholds. For an external perimeter we take the external perimeter spacing/2 plus the internal perimeter spacing/2 and expand by the factor - // rounding errors. When precise wall is enabled, the external perimeter full spacing is used. - coord_t threshold_external = (apply_precise_outer_wall) - // Precise outer wall ⇒ use “full external spacing” - ? ( this->ext_perimeter_flow.scaled_spacing() - + this->perimeter_flow.scaled_spacing()/2.0 ) - // Normal ⇒ half ext spacing + half int spacing - : ( this->ext_perimeter_flow.scaled_spacing()/2.0 - + this->perimeter_flow.scaled_spacing()/2.0 ); + // Re-order extrusions based on distance + // Alorithm will aggresively optimise for the appearance of the outermost perimeter + ordered_extrusions = reorderPerimetersByProximity(ordered_extrusions,threshold_external,threshold_internal ); + reordered_extrusions = ordered_extrusions; // copy them into the reordered extrusions vector to allow for IOI operations to be performed below without altering the base ordered extrusions list. - // For the intenal perimeter threshold, the distance is the internal perimeter spacing expanded by the factor to cover rounding errors. - coord_t threshold_internal = this->perimeter_flow.scaled_spacing(); - - // Re-order extrusions based on distance - // Alorithm will aggresively optimise for the appearance of the outermost perimeter - ordered_extrusions = reorderPerimetersByProximity(ordered_extrusions,threshold_external,threshold_internal ); - reordered_extrusions = ordered_extrusions; // copy them into the reordered extrusions vector to allow for IOI operations to be performed below without altering the base ordered extrusions list. - - // Now start the sandwich mode wall re-ordering using the reordered_extrusions as the basis - // scan to find the external perimeter, first internal, second internal and last perimeter in the island. - // We then advance the position index to move to the second island and continue until there are no more - // perimeters left. - while (position < reordered_extrusions.size()) { - outer = first_internal = second_internal = current_perimeter = -1; // initialise all index values to -1 - max_internal = reordered_extrusions.size()-1; // initialise the maximum internal perimeter to the last perimeter on the extrusion list - // run through the walls to get the index values that need re-ordering until the first one for each - // is found. Start at "position" index to enable the for loop to iterate for multiple external - // perimeters in a single island - // printf("Reorder Loop. Position %d, extrusion list size: %d, Outer index %d, inner index %d, second inner index %d\n", position, reordered_extrusions.size(),outer,first_internal,second_internal); - for (arr_i = position; arr_i < reordered_extrusions.size(); ++arr_i) { - // printf("Perimeter: extrusion inset index %d, ordered extrusions array position %d\n",reordered_extrusions[arr_i].extrusion->inset_idx, arr_i); - switch (reordered_extrusions[arr_i].extrusion->inset_idx) { - case 0: // external perimeter - if (outer == -1) - outer = arr_i; - break; - case 1: // first internal wall - if (first_internal==-1 && arr_i>outer && outer!=-1){ - first_internal = arr_i; - } - break; - case 2: // second internal wall - if (second_internal == -1 && arr_i > first_internal && outer!=-1){ - second_internal = arr_i; - } - break; - } - if(outer >-1 && first_internal>-1 && reordered_extrusions[arr_i].extrusion->inset_idx == 0){ // found a new external perimeter after we've found at least a first internal perimeter to re-order. - // This means we entered a new island. - arr_i=arr_i-1; //step back one perimeter - max_internal = arr_i; // new maximum internal perimeter is now this as we have found a new external perimeter, hence a new island. - break; // exit the for loop - } - } - - // printf("Layer ID %d, Outer index %d, inner index %d, second inner index %d, maximum internal perimeter %d \n",layer_id,outer,first_internal,second_internal, max_internal); - if (outer > -1 && first_internal > -1 && second_internal > -1) { // found all three perimeters to re-order? If not the perimeters will be processed outside in. - std::vector inner_outer_extrusions; // temporary array to hold extrusions for reordering - inner_outer_extrusions.resize(max_internal - position + 1); // reserve array containing the number of perimeters before a new island. Variables are array indexes hence need to add +1 to convert to position allocations - // printf("Allocated array size %d, max_internal index %d, start position index %d \n",max_internal-position+1,max_internal,position); - - for (arr_j = max_internal; arr_j >=position; --arr_j){ // go inside out towards the external perimeter (perimeters in reverse order) and store all internal perimeters until the first one identified with inset index 2 - if(arr_j >= second_internal){ - //printf("Inside out loop: Mapped perimeter index %d to array position %d\n", arr_j, max_internal-arr_j); - inner_outer_extrusions[max_internal-arr_j] = reordered_extrusions[arr_j]; - current_perimeter++; + // Now start the sandwich mode wall re-ordering using the reordered_extrusions as the basis + // scan to find the external perimeter, first internal, second internal and last perimeter in the island. + // We then advance the position index to move to the second island and continue until there are no more + // perimeters left. + while (position < reordered_extrusions.size()) { + outer = first_internal = second_internal = current_perimeter = -1; // initialise all index values to -1 + max_internal = reordered_extrusions.size()-1; // initialise the maximum internal perimeter to the last perimeter on the extrusion list + // run through the walls to get the index values that need re-ordering until the first one for each + // is found. Start at "position" index to enable the for loop to iterate for multiple external + // perimeters in a single island + // printf("Reorder Loop. Position %d, extrusion list size: %d, Outer index %d, inner index %d, second inner index %d\n", position, reordered_extrusions.size(),outer,first_internal,second_internal); + for (arr_i = position; arr_i < reordered_extrusions.size(); ++arr_i) { + // printf("Perimeter: extrusion inset index %d, ordered extrusions array position %d\n",reordered_extrusions[arr_i].extrusion->inset_idx, arr_i); + switch (reordered_extrusions[arr_i].extrusion->inset_idx) { + case 0: // external perimeter + if (outer == -1) + outer = arr_i; + break; + case 1: // first internal wall + if (first_internal==-1 && arr_i>outer && outer!=-1){ + first_internal = arr_i; + } + break; + case 2: // second internal wall + if (second_internal == -1 && arr_i > first_internal && outer!=-1){ + second_internal = arr_i; + } + break; + } + if(outer >-1 && first_internal>-1 && reordered_extrusions[arr_i].extrusion->inset_idx == 0){ // found a new external perimeter after we've found at least a first internal perimeter to re-order. + // This means we entered a new island. + arr_i=arr_i-1; //step back one perimeter + max_internal = arr_i; // new maximum internal perimeter is now this as we have found a new external perimeter, hence a new island. + break; // exit the for loop } } + + // printf("Layer ID %d, Outer index %d, inner index %d, second inner index %d, maximum internal perimeter %d \n",layer_id,outer,first_internal,second_internal, max_internal); + if (outer > -1 && first_internal > -1 && second_internal > -1) { // found all three perimeters to re-order? If not the perimeters will be processed outside in. + std::vector inner_outer_extrusions; // temporary array to hold extrusions for reordering + inner_outer_extrusions.resize(max_internal - position + 1); // reserve array containing the number of perimeters before a new island. Variables are array indexes hence need to add +1 to convert to position allocations + // printf("Allocated array size %d, max_internal index %d, start position index %d \n",max_internal-position+1,max_internal,position); - for (arr_j = position; arr_j < second_internal; ++arr_j){ // go outside in and map the remaining perimeters (external and first internal wall(s)) using the outside in wall order - // printf("Outside in loop: Mapped perimeter index %d to array position %d\n", arr_j, current_perimeter+1); - inner_outer_extrusions[++current_perimeter] = reordered_extrusions[arr_j]; + for (arr_j = max_internal; arr_j >=position; --arr_j){ // go inside out towards the external perimeter (perimeters in reverse order) and store all internal perimeters until the first one identified with inset index 2 + if(arr_j >= second_internal){ + //printf("Inside out loop: Mapped perimeter index %d to array position %d\n", arr_j, max_internal-arr_j); + inner_outer_extrusions[max_internal-arr_j] = reordered_extrusions[arr_j]; + current_perimeter++; + } + } + + for (arr_j = position; arr_j < second_internal; ++arr_j){ // go outside in and map the remaining perimeters (external and first internal wall(s)) using the outside in wall order + // printf("Outside in loop: Mapped perimeter index %d to array position %d\n", arr_j, current_perimeter+1); + inner_outer_extrusions[++current_perimeter] = reordered_extrusions[arr_j]; + } + + for(arr_j = position; arr_j <= max_internal; ++arr_j) // replace perimeter array with the new re-ordered array + ordered_extrusions[arr_j] = inner_outer_extrusions[arr_j-position]; } - - for(arr_j = position; arr_j <= max_internal; ++arr_j) // replace perimeter array with the new re-ordered array - ordered_extrusions[arr_j] = inner_outer_extrusions[arr_j-position]; + // go to the next perimeter from the current position to continue scanning for external walls in the same island + position = arr_i + 1; } - // go to the next perimeter from the current position to continue scanning for external walls in the same island - position = arr_i + 1; } } - } - bool steep_overhang_contour = false; - bool steep_overhang_hole = false; - if (!config->overhang_reverse) { - // Skip steep overhang detection no reverse is specified - steep_overhang_contour = true; - steep_overhang_hole = true; - } - if (ExtrusionEntityCollection extrusion_coll = traverse_extrusions(*this, ordered_extrusions, steep_overhang_contour, steep_overhang_hole); !extrusion_coll.empty()) { - if (config->overhang_reverse) { - reorient_perimeters(extrusion_coll, steep_overhang_contour, steep_overhang_hole, - this->config->overhang_reverse_internal_only); + bool steep_overhang_contour = false; + bool steep_overhang_hole = false; + if (!config->overhang_reverse) { + // Skip steep overhang detection no reverse is specified + steep_overhang_contour = true; + steep_overhang_hole = true; + } + if (ExtrusionEntityCollection extrusion_coll = traverse_extrusions(*this, ordered_extrusions, steep_overhang_contour, steep_overhang_hole); !extrusion_coll.empty()) { + if (config->overhang_reverse) { + reorient_perimeters(extrusion_coll, steep_overhang_contour, steep_overhang_hole, + this->config->overhang_reverse_internal_only); + } + defer_unsupported_loops(*this, extrusion_coll); + result.loops = std::move(extrusion_coll); + result.has_loops = true; } - defer_unsupported_loops(*this, extrusion_coll); - this->loops->append(extrusion_coll); - } - const coord_t spacing = (perimeters.size() == 1) ? ext_perimeter_spacing2 : perimeter_spacing; + const coord_t spacing = (perimeters.size() == 1) ? ext_perimeter_spacing2 : perimeter_spacing; - if (offset_ex(infill_contour, -float(spacing / 2.)).empty()) - infill_contour.clear(); // Infill region is too small, so let's filter it out. + if (offset_ex(infill_contour, -float(spacing / 2.)).empty()) + infill_contour.clear(); // Infill region is too small, so let's filter it out. - // create one more offset to be used as boundary for fill - // we offset by half the perimeter spacing (to get to the actual infill boundary) - // and then we offset back and forth by half the infill spacing to only consider the - // non-collapsing regions - coord_t inset = - (loop_number < 0) ? 0 : - (loop_number == 0) ? - // one loop - ext_perimeter_spacing : - // two or more loops? - perimeter_spacing; - coord_t top_inset = inset; + // create one more offset to be used as boundary for fill + // we offset by half the perimeter spacing (to get to the actual infill boundary) + // and then we offset back and forth by half the infill spacing to only consider the + // non-collapsing regions + coord_t inset = + (loop_number < 0) ? 0 : + (loop_number == 0) ? + // one loop + ext_perimeter_spacing : + // two or more loops? + perimeter_spacing; + coord_t top_inset = inset; - top_inset = coord_t(scale_(this->config->top_bottom_infill_wall_overlap.get_abs_value(unscale(inset)))); - if(is_topmost_layer || is_bottom_layer) - inset = coord_t(scale_(this->config->top_bottom_infill_wall_overlap.get_abs_value(unscale(inset)))); - else - inset = coord_t(scale_(this->config->infill_wall_overlap.get_abs_value(unscale(inset)))); + top_inset = coord_t(scale_(this->config->top_bottom_infill_wall_overlap.get_abs_value(unscale(inset)))); + if(is_topmost_layer || is_bottom_layer) + inset = coord_t(scale_(this->config->top_bottom_infill_wall_overlap.get_abs_value(unscale(inset)))); + else + inset = coord_t(scale_(this->config->infill_wall_overlap.get_abs_value(unscale(inset)))); - // simplify infill contours according to resolution - Polygons pp; - for (ExPolygon& ex : infill_contour) - ex.simplify_p(m_scaled_resolution, &pp); - ExPolygons not_filled_exp = union_ex(pp); - // collapse too narrow infill areas - const auto min_perimeter_infill_spacing = coord_t(solid_infill_spacing * (1. - INSET_OVERLAP_TOLERANCE)); + // simplify infill contours according to resolution + Polygons pp; + for (ExPolygon& ex : infill_contour) + ex.simplify_p(m_scaled_resolution, &pp); + ExPolygons not_filled_exp = union_ex(pp); + // collapse too narrow infill areas + const auto min_perimeter_infill_spacing = coord_t(solid_infill_spacing * (1. - INSET_OVERLAP_TOLERANCE)); - ExPolygons infill_exp = offset2_ex( - not_filled_exp, - float(-min_perimeter_infill_spacing / 2.), - float(inset + min_perimeter_infill_spacing / 2.)); - // append infill areas to fill_surfaces - if (!top_expolygons.empty()) { - infill_exp = union_ex(infill_exp, offset_ex(top_expolygons, double(top_inset))); - } - this->fill_surfaces->append(infill_exp, stInternal); - - apply_extra_perimeters(infill_exp); - - // BBS: get the no-overlap infill expolygons - { - ExPolygons polyWithoutOverlap; - polyWithoutOverlap = offset2_ex( + ExPolygons infill_exp = offset2_ex( not_filled_exp, float(-min_perimeter_infill_spacing / 2.), - float(+min_perimeter_infill_spacing / 2.)); - if (!top_expolygons.empty()) - polyWithoutOverlap = union_ex(polyWithoutOverlap, top_expolygons); - this->fill_no_overlap->insert(this->fill_no_overlap->end(), polyWithoutOverlap.begin(), polyWithoutOverlap.end()); - } + float(inset + min_perimeter_infill_spacing / 2.)); + // append infill areas to fill_surfaces + if (!top_expolygons.empty()) { + infill_exp = union_ex(infill_exp, offset_ex(top_expolygons, double(top_inset))); + } + result.infill = std::move(infill_exp); + + // BBS: get the no-overlap infill expolygons + { + ExPolygons polyWithoutOverlap; + polyWithoutOverlap = offset2_ex( + not_filled_exp, + float(-min_perimeter_infill_spacing / 2.), + float(+min_perimeter_infill_spacing / 2.)); + if (!top_expolygons.empty()) + polyWithoutOverlap = union_ex(polyWithoutOverlap, top_expolygons); + result.no_overlap = std::move(polyWithoutOverlap); + } + } + }); + for (ArachneSurfaceResult &result : results) { + if (result.has_loops) + // Moved, not copied: append(const ExtrusionEntity &) clones the whole wall tree of the island. + this->loops->append(std::move(result.loops)); + this->fill_surfaces->append(result.infill, stInternal); + apply_extra_perimeters(result.infill); + append(*this->fill_no_overlap, std::move(result.no_overlap)); } } diff --git a/src/libslic3r/PlaceholderParser.cpp b/src/libslic3r/PlaceholderParser.cpp index 5354e93480..a25cb4492b 100644 --- a/src/libslic3r/PlaceholderParser.cpp +++ b/src/libslic3r/PlaceholderParser.cpp @@ -243,7 +243,7 @@ namespace client struct expr { expr() {} - expr(const expr &rhs) : m_type(rhs.type()), it_range(rhs.it_range) + expr(const expr &rhs) : m_type(rhs.type()), it_range(rhs.it_range), m_deferred(rhs.m_deferred) { if (rhs.type() == TYPE_STRING) m_data.s = new std::string(*rhs.m_data.s); else m_data.set(rhs.m_data); } expr(expr &&rhs) : expr(std::move(rhs), rhs.it_range.begin(), rhs.it_range.end()) {} @@ -258,13 +258,16 @@ namespace client explicit expr(std::string &&s) : m_type(TYPE_STRING) { m_data.s = new std::string(std::move(s)); } explicit expr(const std::string &s, const Iterator &it_begin, const Iterator &it_end) : m_type(TYPE_STRING), it_range(it_begin, it_end) { m_data.s = new std::string(s); } - explicit expr(expr &&rhs, const Iterator &it_begin, const Iterator &it_end) : m_type(rhs.type()), it_range{ it_begin, it_end } + explicit expr(expr &&rhs, const Iterator &it_begin, const Iterator &it_end) : m_type(rhs.type()), it_range{ it_begin, it_end }, m_deferred(rhs.m_deferred) { m_data.set(rhs.m_data); - rhs.m_type = TYPE_EMPTY; + rhs.m_type = TYPE_EMPTY; + rhs.m_deferred = Deferred::None; } expr &operator=(const expr &rhs) { + // set_s() clears m_deferred, which is also rhs.m_deferred on a self-assignment. + const Deferred deferred = rhs.m_deferred; if (rhs.type() == TYPE_STRING) { this->set_s(rhs.s()); } else { @@ -272,6 +275,7 @@ namespace client m_data.set(rhs.m_data); } this->it_range = rhs.it_range; + m_deferred = deferred; return *this; } @@ -281,8 +285,10 @@ namespace client this->reset(); m_type = rhs.type(); this->it_range = rhs.it_range; + m_deferred = rhs.m_deferred; m_data.set(rhs.m_data); rhs.m_type = TYPE_EMPTY; + rhs.m_deferred = Deferred::None; } return *this; } @@ -291,7 +297,8 @@ namespace client { if (this->type() == TYPE_STRING) delete m_data.s; - m_type = TYPE_EMPTY; + m_type = TYPE_EMPTY; + m_deferred = Deferred::None; } ~expr() { reset(); } @@ -304,6 +311,10 @@ namespace client }; Type type() const { return m_type; } bool numeric_type() const { return m_type == TYPE_INT || m_type == TYPE_DOUBLE; } + // A deferred string is either one deferred value or text that contains at least one. + enum class Deferred : unsigned char { None, Value, Text }; + bool deferred() const { return m_deferred != Deferred::None; } + void set_deferred(Deferred deferred) { m_deferred = deferred; } bool& b() { return m_data.b; } bool b() const { return m_data.b; } @@ -323,18 +334,21 @@ namespace client std::string& s() { return *m_data.s; } const std::string& s() const { return *m_data.s; } void set_s(const std::string &s) { + m_deferred = Deferred::None; if (this->type() == TYPE_STRING) *m_data.s = s; else this->set_s_take_ownership(new std::string(s)); } void set_s(std::string &&s) { + m_deferred = Deferred::None; if (this->type() == TYPE_STRING) *m_data.s = std::move(s); else this->set_s_take_ownership(new std::string(std::move(s))); } void set_s(const char *s) { + m_deferred = Deferred::None; if (this->type() == TYPE_STRING) *m_data.s = s; else @@ -478,12 +492,21 @@ namespace client { if (this->type() == TYPE_EMPTY) { // Inside an if / else block to be skipped. + } else if (m_deferred == Deferred::Value && (rhs.type() != TYPE_STRING || rhs.m_deferred == Deferred::Value)) { + // A deferred value added to a number or to another deferred value is meant as a sum. + this->throw_if_deferred(); + } else if (rhs.m_deferred == Deferred::Value && this->type() != TYPE_STRING) { + rhs.throw_if_deferred(); } else if (this->type() == TYPE_STRING) { // Convert the right hand side to string and append. *m_data.s += rhs.to_string(); + if (this->deferred() || rhs.deferred()) + m_deferred = Deferred::Text; } else if (rhs.type() == TYPE_STRING) { // Conver the left hand side to string, append rhs. this->set_s(this->to_string() + rhs.s()); + if (rhs.deferred()) + m_deferred = Deferred::Text; } else { const char *err_msg = L("Cannot add non-numeric types."); this->throw_if_not_numeric(err_msg); @@ -602,6 +625,8 @@ namespace client if (lhs.type() == TYPE_EMPTY) // Inside an if / else block to be skipped return; + lhs.throw_if_deferred(); + rhs.throw_if_deferred(); bool value = false; if (lhs.numeric_type() && rhs.numeric_type()) { // Both types are numeric. @@ -731,6 +756,7 @@ namespace client if (lhs.type() == TYPE_EMPTY) // Inside an if / else block to be skipped return; + lhs.throw_if_deferred(); const std::string *subject = nullptr; if (lhs.type() == TYPE_STRING) { // One type is string, the other could be converted to string. @@ -761,6 +787,7 @@ namespace client if (subject.type() == TYPE_EMPTY) // Inside an if / else block to be skipped return; + subject.throw_if_deferred(); if (subject.type() != TYPE_STRING) subject.throw_exception(L("regex_replace() first parameter must be a string.")); try { @@ -785,6 +812,8 @@ namespace client return; } if (! out.b()) { + match.throw_if_deferred(); + pattern.throw_if_deferred(); if (match.type() != TYPE_STRING) match.throw_exception(L("one_of(): First parameter (the string to match against) has to be a string value")); if (pattern.type() != TYPE_STRING) @@ -818,6 +847,8 @@ namespace client if (lhs.type() == TYPE_EMPTY) // Inside an if / else block to be skipped return; + lhs.throw_if_deferred(); + rhs.throw_if_deferred(); bool value = false; if (lhs.type() == TYPE_BOOL && rhs.type() == TYPE_BOOL) { value = (op == '|') ? (lhs.b() || rhs.b()) : (lhs.b() && rhs.b()); @@ -830,8 +861,16 @@ namespace client static void logical_or (expr &lhs, expr &rhs) { logical_op(lhs, rhs, '|'); } static void logical_and(expr &lhs, expr &rhs) { logical_op(lhs, rhs, '&'); } + static const char* deferred_message() + { + return L("This value is known only after the G-code is generated. It can be printed, but not used in a calculation or a comparison."); + } + + // Reports the deferred message for a type error on a deferred value, since it holds a tag in place of the value. void throw_exception(const char *message) const { + if (this->deferred()) + message = deferred_message(); boost::throw_exception(qi::expectation_failure( this->it_range.begin(), this->it_range.end(), spirit::info(std::string("*") + message))); } @@ -842,11 +881,18 @@ namespace client this->throw_exception(message); } + void throw_if_deferred() const + { + if (this->deferred()) + this->throw_exception(deferred_message()); + } + private: // This object will take ownership of the parameter string object "s". void set_s_take_ownership(std::string* s) { assert(this->type() != TYPE_STRING); Data tmp; tmp.s = s; m_data.set(tmp); m_type = TYPE_STRING; } Type m_type = TYPE_EMPTY; + Deferred m_deferred = Deferred::None; union Data { bool b; @@ -895,6 +941,8 @@ namespace client std::string error_message; // Local variables declared in {if} branches that were not taken, see PlaceholderParser::check_inactive_branches. mutable std::set inactive_local_variables; + // Tags of PlaceholderParser::set_deferred(), matched by value so that a copy in a variable is deferred too. + const std::set *deferred_tags = nullptr; // Table to translate symbol tag to a human readable error message. static std::map tag_to_error_message; @@ -970,6 +1018,18 @@ namespace client } else this->config_local.set_key_value(opt_key, opt.release()); } + void mark_deferred(expr &output) const { + if (output.type() != expr::TYPE_STRING) + return; + expr::Deferred deferred = expr::Deferred::None; + if (deferred_tags != nullptr && output.s().find('@') != std::string::npos) + for (const std::string &tag : *deferred_tags) + if (output.s().find(tag) != std::string::npos) { + deferred = output.s() == tag ? expr::Deferred::Value : expr::Deferred::Text; + break; + } + output.set_deferred(deferred); + } static void legacy_variable_expansion(const MyContext *ctx, IteratorRange &opt_key, std::string &output) { @@ -1430,6 +1490,7 @@ namespace client vector_element_to_expr(ctx, opt, output); else scalar_variable_to_expr(ctx, opt, output); + ctx->mark_deferred(output); } output.it_range = opt.it_range; } @@ -1516,7 +1577,7 @@ namespace client if (ctx->skipping()) { } else if (lhs.opt) { if (lhs.opt->is_vector()) - rhs.throw_exception(L("Cannot assign a scalar value to a vector variable.")); + ctx->throw_exception(L("Cannot assign a scalar value to a vector variable."), rhs.it_range); OptWithPos lhs_opt{ lhs.opt, lhs.it_range, true }; scalar_variable_assign_scalar(ctx, lhs_opt, rhs); } else { @@ -1542,7 +1603,7 @@ namespace client if (ctx->skipping()) { } else if (lhs.opt) { if (lhs.opt->is_scalar()) - rhs_value.throw_exception(L("Cannot assign a vector value to a scalar variable.")); + ctx->throw_exception(L("Cannot assign a vector value to a scalar variable."), rhs_value.it_range); OptWithPos lhs_opt{ lhs.opt, lhs.it_range, true }; vector_variable_assign_expr_with_count(ctx, lhs_opt, rhs_count, rhs_value); } else { @@ -1568,7 +1629,7 @@ namespace client if (! ctx->skipping()) { check_writable(ctx, lhs); if (lhs.opt->is_scalar()) - rhs_value.throw_exception(L("Cannot assign a vector value to a scalar variable.")); + ctx->throw_exception(L("Cannot assign a vector value to a scalar variable."), rhs_value.it_range); vector_variable_assign_expr_with_count(ctx, lhs, rhs_count, rhs_value); } } @@ -1803,10 +1864,21 @@ namespace client static void filament_change(const MyContext* ctx, expr& param) { + if (param.type() == expr::TYPE_EMPTY) + // Inside an if / else block to be skipped + return; MyContext *context = const_cast(ctx); + param.throw_if_deferred(); context->current_extruder_id = param.as_i(); } + // The replacement may put a deferred tag into the result. + static void regex_replace(const MyContext *ctx, expr &subject, IteratorRange &pattern, expr &replacement) + { + expr::regex_replace(subject, pattern, replacement); + ctx->mark_deferred(subject); + } + static void throw_exception(const std::string &msg, const IteratorRange &it_range) { // An asterix is added to the start of the string to differentiate the boost::spirit::info::tag content @@ -2409,7 +2481,7 @@ namespace client | (kw["zdigits"] > '(' > conditional_expression(_r1) [_val = _1] > ',' > conditional_expression(_r1) > optional_parameter(_r1)) [ px::bind(&expr::digits, _val, _2, _3) ] | (kw["regex_replace"] > '(' > conditional_expression(_r1) [_val = _1] > ',' > regular_expression > ',' > conditional_expression(_r1) > ')') - [ px::bind(&expr::regex_replace, _val, _2, _3) ] + [ px::bind(&MyContext::regex_replace, _r1, _val, _2, _3) ] | (kw["int"] > '(' > conditional_expression(_r1) > ')') [ px::bind(&FactorActions::to_int, _1, _val) ] | (kw["round"] > '(' > conditional_expression(_r1) > ')') [ px::bind(&FactorActions::round, _1, _val) ] | (kw["ceil"] > '(' > conditional_expression(_r1) > ')') [ px::bind(&FactorActions::ceil, _1, _val) ] @@ -2614,6 +2686,7 @@ std::string PlaceholderParser::process(const std::string &templ, unsigned int cu context.config = &this->config(); context.config_override = config_override; context.config_outputs = config_outputs; + context.deferred_tags = &m_deferred_tags; context.current_extruder_id = current_extruder_id; context.context_data = context_data; return process_macro(templ, context); diff --git a/src/libslic3r/PlaceholderParser.hpp b/src/libslic3r/PlaceholderParser.hpp index 1ac758267b..c8094011c0 100644 --- a/src/libslic3r/PlaceholderParser.hpp +++ b/src/libslic3r/PlaceholderParser.hpp @@ -60,6 +60,9 @@ public: void set(const std::string &key, double value) { this->set(key, new ConfigOptionFloat(value)); } void set(const std::string &key, const std::vector &values) { this->set(key, new ConfigOptionStrings(values)); } void set(const std::string &key, ConfigOption *opt) { m_config.set_key_value(key, opt); } + // Orca: sets a value known only after the G-code is generated, as a tag that post-processing replaces. + // A template may print it or join it to text, and a calculation or a comparison on it throws. + void set_deferred(const std::string &key, const std::string &tag) { this->set(key, tag); m_deferred_tags.insert(tag); } DynamicConfig& config_writable() { return m_config; } const DynamicConfig& config() const { return m_config; } const ConfigOption* option(const std::string &key) const { return m_config.option(key); } @@ -88,6 +91,7 @@ private: // config has a higher priority than external_config when looking up a symbol. DynamicConfig m_config; const DynamicConfig *m_external_config; + std::set m_deferred_tags; }; } diff --git a/src/libslic3r/Polygon.hpp b/src/libslic3r/Polygon.hpp index 8e7ca2ed91..9c5801985e 100644 --- a/src/libslic3r/Polygon.hpp +++ b/src/libslic3r/Polygon.hpp @@ -34,7 +34,7 @@ public: explicit Polygon(const Points &points) : MultiPoint(points) {} Polygon(std::initializer_list points) : MultiPoint(points) {} Polygon(const Polygon &other) : MultiPoint(other.points) {} - Polygon(Polygon &&other) : MultiPoint(std::move(other.points)) {} + Polygon(Polygon &&other) noexcept : MultiPoint(std::move(other.points)) {} static Polygon new_scale(const std::vector &points) { Polygon pgn; pgn.points.reserve(points.size()); @@ -43,7 +43,7 @@ public: return pgn; } Polygon& operator=(const Polygon &other) { points = other.points; return *this; } - Polygon& operator=(Polygon &&other) { points = std::move(other.points); return *this; } + Polygon& operator=(Polygon &&other) noexcept { points = std::move(other.points); return *this; } Point& operator[](Points::size_type idx) { return this->points[idx]; } const Point& operator[](Points::size_type idx) const { return this->points[idx]; } diff --git a/src/libslic3r/Polyline.hpp b/src/libslic3r/Polyline.hpp index 29c795db33..3b59ab6c4c 100644 --- a/src/libslic3r/Polyline.hpp +++ b/src/libslic3r/Polyline.hpp @@ -26,7 +26,7 @@ class Polyline : public MultiPoint { public: Polyline() {}; Polyline(const Polyline& other) : MultiPoint(other.points), fitting_result(other.fitting_result) {} - Polyline(Polyline &&other) : MultiPoint(std::move(other.points)), fitting_result(std::move(other.fitting_result)) {} + Polyline(Polyline &&other) noexcept : MultiPoint(std::move(other.points)), fitting_result(std::move(other.fitting_result)) {} Polyline(std::initializer_list list) : MultiPoint(list) { fitting_result.clear(); } @@ -47,7 +47,7 @@ public: fitting_result = other.fitting_result; return *this; } - Polyline& operator=(Polyline&& other) { + Polyline& operator=(Polyline&& other) noexcept { points = std::move(other.points); fitting_result = std::move(other.fitting_result); return *this; diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index edd93390b4..81b78ec092 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config) { ConfigOption *opt_src = config.option(option); ConfigOption *opt_dst = temp_config.option(option, true); - if (opt_dst->is_scalar() || !(opt_dst->nullable())) + if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option)) opt_dst->set(opt_src); else { ConfigOptionVectorBase* opt_vec_src = static_cast(opt_src); @@ -2132,7 +2132,7 @@ Preset* PresetCollection::get_preset_differed_for_save(Preset& preset) { ConfigOption *opt_src = preset.config.option(option); ConfigOption *opt_dst = temp_config.option(option, true); - if (opt_dst->is_scalar() || !(opt_dst->nullable())) + if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option)) opt_dst->set(opt_src); else { ConfigOptionVectorBase* opt_vec_src = static_cast(opt_src); @@ -2199,9 +2199,12 @@ int PresetCollection::get_differed_values_to_update(Preset& preset, std::map 100%), silently // resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe. if (m_type == Preset::TYPE_FILAMENT) { - for (const std::string& opt_key : filament_extruder_override_keys) { - if (key_values.count(opt_key)) - continue; // already carried by the diff + for (const std::string& opt_key : preset.config.keys()) { + if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key)) + continue; // not an override, or already carried by the diff + const ConfigOptionDef *def = print_config_def.get(opt_key); + if (def == nullptr || !def->default_value || def->default_value->is_nil()) + continue; // the option's default is nil too, so an absent key cannot come back as a value const auto* opt_vec = dynamic_cast(preset.config.option(opt_key)); if (opt_vec == nullptr) continue; diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 56c3dc5af6..0876d7ebb8 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -119,12 +119,18 @@ const std::vector filament_extruder_override_keys = { }; // Some filament override parameters are generated from filament_extruder_override_keys, -// while filament_retract_length_nc is defined separately. Keep the generator list -// unchanged and use this helper for behavior checks that need the full override set. +// while filament_retract_length_nc and the ironing overrides are defined separately. +// Keep the generator list unchanged and use this helper for behavior checks that need +// the full override set. A nil in one of these keys means the override is disabled, so +// child presets store and load it verbatim instead of treating nil as "inherit". bool is_filament_extruder_override_key(const std::string &opt_key) { return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() || - opt_key == "filament_retract_length_nc"; + opt_key == "filament_retract_length_nc" || + opt_key == "filament_ironing_flow" || + opt_key == "filament_ironing_spacing" || + opt_key == "filament_ironing_inset" || + opt_key == "filament_ironing_speed"; } size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id) @@ -6453,7 +6459,7 @@ void PrintConfigDef::init_fff_params() def->label = L("Seam gap"); def->category = L("Quality"); def->tooltip = L("In order to reduce the visibility of the seam in a closed loop extrusion, the loop is interrupted and shortened by a specified amount.\n" - "This amount can be specified in millimeters or as a percentage of the current extruder diameter. The default value for this parameter is 10%."); + "This amount can be specified in millimeters or as a percentage of the nozzle diameter. The default value for this parameter is 10%."); def->sidetext = L("mm or %"); def->min = 0; def->mode = comAdvanced; @@ -12272,7 +12278,8 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig& opt_src->set(opt_target); } else - opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride); + // An override's nil means "disabled" and is the child's value; every other nil column inherits. + opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride, is_filament_extruder_override_key(opt)); } } } diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 150e987bcb..e48f8d71f1 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -76,6 +77,7 @@ #include #include +#include #include #include @@ -1930,7 +1932,9 @@ void PrintObject::detect_surfaces_type() bool interface_shells = ! spiral_mode && m_config.interface_shells.value; size_t num_layers = spiral_mode ? std::min(size_t(this->printing_region(0).config().bottom_shell_layers), m_layers.size()) : m_layers.size(); - for (size_t region_id = 0; region_id < this->num_printing_regions(); ++ region_id) { + // The regions of a layer do not see each other here, and a layer cut through a fine relief takes far longer than the + // others, so the regions run next to each other instead of one after another, each still over all layers. + tbb::parallel_for(size_t(0), this->num_printing_regions(), [&](size_t region_id) { BOOST_LOG_TRIVIAL(debug) << "Detecting solid surfaces for region " << region_id << " in parallel - start"; #ifdef SLIC3R_DEBUG_SLICE_PROCESSING for (Layer *layer : m_layers) @@ -1988,7 +1992,7 @@ void PrintObject::detect_surfaces_type() if (upper_layer) { ExPolygons upper_slices = interface_shells ? diff_ex(layerm_slices_surfaces, upper_layer->m_regions[region_id]->slices.surfaces, ApplySafetyOffset::Yes) : - diff_ex(layerm_slices_surfaces, upper_layer->lslices, ApplySafetyOffset::Yes); + diff_ex_by_piece(layerm_slices_surfaces, to_polygons(upper_layer->lslices), ApplySafetyOffset::Yes); surfaces_append(top, opening_ex(upper_slices, offset), stTop); } else { // if no upper layer, all surfaces of this one are solid @@ -2014,7 +2018,7 @@ void PrintObject::detect_surfaces_type() surfaces_append( bottom, opening_ex( - diff_ex(layerm_slices_surfaces, lower_layer->lslices, ApplySafetyOffset::Yes), + diff_ex_by_piece(layerm_slices_surfaces, to_polygons(lower_layer->lslices), ApplySafetyOffset::Yes), offset), surface_type_bottom_other); // if user requested internal shells, we need to identify surfaces @@ -2045,34 +2049,44 @@ void PrintObject::detect_surfaces_type() // and top surfaces; let's do an intersection to discover them and consider them // as bottom surfaces (to allow for bridge detection) if (! top.empty() && ! bottom.empty()) { - const auto cracks = intersection_ex(top, bottom); + const auto cracks = intersection_ex_by_piece(to_expolygons(top), to_polygons(bottom)); if (!cracks.empty()) { if (lower_layer) { // Only detect small cracks for non-first layer, because first layer should always be bottom const float small_crack_threshold = -layerm->flow(frExternalPerimeter).scaled_width() * 1.5; + // Only the bottom surfaces near a crack can take part: one that contains it must contain its box, + // and one whose box misses the grown crack is left unchanged by removing it. A layer cut through + // a fine relief has thousands of both, which made this loop quadratic. for (const auto& crack : cracks) { if (offset_ex(crack, small_crack_threshold).empty()) { // For small cracks, if it's part of a large bottom surface, then it should be added to bottom as well - if (std::any_of(bottom.begin(), bottom.end(), [&crack, small_crack_threshold](const Surface& s) { + const BoundingBox crack_bbox = get_extents(crack); + if (std::any_of(bottom.begin(), bottom.end(), [&crack, &crack_bbox, small_crack_threshold](const Surface& s) { const auto& se = s.expolygon; - return diff_ex(crack, se, ApplySafetyOffset::Yes).empty() + return get_extents(se).inflated(SCALED_EPSILON).contains(crack_bbox) + && diff_ex(crack, se, ApplySafetyOffset::Yes).empty() && se.area() > crack.area() * 2 && !offset_ex(diff_ex(se, crack), small_crack_threshold).empty(); })) continue; // Crack too small, leave it as part of the top surface, remove it from bottom surfaces + const ExPolygons grown_crack = offset_ex(crack, -small_crack_threshold); + const BoundingBox grown_bbox = get_extents(grown_crack); Surfaces bot_tmp; for (auto& b : bottom) { - surfaces_append(bot_tmp, diff_ex(b.expolygon, offset_ex(crack, -small_crack_threshold)), b.surface_type); + if (get_extents(b.expolygon).overlap(grown_bbox)) + surfaces_append(bot_tmp, diff_ex(b.expolygon, grown_crack), b.surface_type); + else + bot_tmp.emplace_back(std::move(b)); } bottom = std::move(bot_tmp); } } } - Polygons top_polygons = to_polygons(std::move(top)); + ExPolygons top_expolygons = to_expolygons(std::move(top)); top.clear(); - surfaces_append(top, diff_ex(top_polygons, bottom), stTop); + surfaces_append(top, diff_ex_by_piece(top_expolygons, to_polygons(bottom)), stTop); } } @@ -2163,7 +2177,7 @@ void PrintObject::detect_surfaces_type() { Polygons topbottom = to_polygons(top); polygons_append(topbottom, to_polygons(bottom)); - surfaces_append(surfaces_out, diff_ex(surfaces_prev_expolys, topbottom), stInternal); + surfaces_append(surfaces_out, diff_ex_by_piece(surfaces_prev_expolys, topbottom), stInternal); } surfaces_append(surfaces_out, std::move(top)); @@ -2340,29 +2354,31 @@ void PrintObject::detect_surfaces_type() } } ); - // ============================================================================================================== - // === ORCA: Interim workaround - for now the new stInternalAfterExternalBridge surfaace is re-classified ============== - // === back to a bottom bridge. As a starting point, this improves bridging reliability as it extrudes ========== - // === two external bridge layers. However, TODO: Implement a new surface type throughout the codebase ========== - // ============================================================================================================== - for (size_t region_id = 0; region_id < this->num_printing_regions(); ++region_id) { - tbb::parallel_for( tbb::blocked_range(0, m_layers.size()), [this, region_id](const tbb::blocked_range &range) { - for (size_t idx_layer = range.begin(); idx_layer < range.end(); ++idx_layer) { - Surfaces &surfs = m_layers[idx_layer]->m_regions[region_id]->slices.surfaces; - for (Surface &s : surfs) { - if (s.surface_type == stInternalAfterExternalBridge) { - s.surface_type = stBottomBridge; - } - } - } - } - ); - } } // ============================================================================================================== // === ORCA: End of second external bridge layer changes ======================================================= // ============================================================================================================== + }); // for each this->print->region_count + + // ============================================================================================================== + // === ORCA: Interim workaround - for now the new stInternalAfterExternalBridge surfaace is re-classified ============== + // === back to a bottom bridge. As a starting point, this improves bridging reliability as it extrudes ========== + // === two external bridge layers. However, TODO: Implement a new surface type throughout the codebase ========== + // ============================================================================================================== + // Once all the regions have their second bridge layer, and before their slices are trimmed into fill surfaces below. + if ((this->config().enable_extra_bridge_layer.value == eblApplyToAll) || (this->config().enable_extra_bridge_layer.value == eblExternalBridgeOnly)) { + tbb::parallel_for(tbb::blocked_range(0, m_layers.size()), [this](const tbb::blocked_range &range) { + for (size_t idx_layer = range.begin(); idx_layer < range.end(); ++idx_layer) + for (LayerRegion *layerm : m_layers[idx_layer]->regions()) + for (Surface &s : layerm->slices.surfaces) + if (s.surface_type == stInternalAfterExternalBridge) + s.surface_type = stBottomBridge; + }); + m_print->throw_if_canceled(); + } + + tbb::parallel_for(size_t(0), this->num_printing_regions(), [&](size_t region_id) { BOOST_LOG_TRIVIAL(debug) << "Detecting solid surfaces for region " << region_id << " - clipping in parallel - start"; // Fill in layerm->fill_surfaces by trimming the layerm->slices by the cummulative layerm->fill_surfaces. tbb::parallel_for( @@ -2379,7 +2395,7 @@ void PrintObject::detect_surfaces_type() }); m_print->throw_if_canceled(); BOOST_LOG_TRIVIAL(debug) << "Detecting solid surfaces for region " << region_id << " - clipping in parallel - end"; - } // for each this->print->region_count + }); // Mark the object to have the region slices classified (typed, which also means they are split based on whether they are supported, bridging, top layers etc.) m_typed_slices = true; @@ -2446,8 +2462,10 @@ void PrintObject::process_external_surfaces() BOOST_LOG_TRIVIAL(debug) << "Collecting surfaces covered with extrusions in parallel - end"; } - for (size_t region_id = 0; region_id < this->num_printing_regions(); ++region_id) { - BOOST_LOG_TRIVIAL(debug) << "Processing external surfaces for region " << region_id << " in parallel - start"; + BOOST_LOG_TRIVIAL(debug) << "Processing external surfaces in parallel - start"; + // The regions of a layer do not see each other here, and a layer cut through a fine relief takes far longer than the + // others, so the regions run next to each other instead of one after another, each still over all layers. + tbb::parallel_for(size_t(0), this->num_printing_regions(), [this, &surfaces_covered](size_t region_id) { tbb::parallel_for( tbb::blocked_range(0, m_layers.size()), [this, &surfaces_covered, region_id](const tbb::blocked_range& range) { @@ -2462,9 +2480,9 @@ void PrintObject::process_external_surfaces() } } ); - m_print->throw_if_canceled(); - BOOST_LOG_TRIVIAL(debug) << "Processing external surfaces for region " << region_id << " in parallel - end"; - } + }); + m_print->throw_if_canceled(); + BOOST_LOG_TRIVIAL(debug) << "Processing external surfaces in parallel - end"; } void PrintObject::discover_vertical_shells() @@ -2503,10 +2521,10 @@ void PrintObject::discover_vertical_shells() // The "ensure vertical wall thickness" feature is not applicable to any of the regions. Quit. return; BOOST_LOG_TRIVIAL(debug) << "Discovering vertical shells in parallel - start : cache top / bottom"; - //FIXME Improve the heuristics for a grain size. - size_t grain_size = std::max(num_layers / 16, size_t(1)); + // One layer per task: on a layer cut through a fine relief the unions below take far longer than elsewhere, and a + // few such layers next to each other must not end up in one task. tbb::parallel_for( - tbb::blocked_range(0, num_layers, grain_size), + tbb::blocked_range(0, num_layers, 1), [this, &cache_top_botom_regions](const tbb::blocked_range& range) { const std::initializer_list surfaces_bottom { stBottom, stBottomBridge }; const size_t num_regions = this->num_printing_regions(); @@ -2514,67 +2532,198 @@ void PrintObject::discover_vertical_shells() m_print->throw_if_canceled(); const Layer &layer = *m_layers[idx_layer]; DiscoverVerticalShellsCacheEntry &cache = cache_top_botom_regions[idx_layer]; - // Simulate single set of perimeters over all merged regions. - float perimeter_offset = 0.f; - float perimeter_min_spacing = FLT_MAX; + const auto top_bottom_expansion = [&layer](size_t region_id) { + return float(layer.m_regions[region_id]->flow(frSolidInfill).scaled_spacing()) * top_bottom_expansion_coeff; + }; #ifdef SLIC3R_DEBUG_SLICE_PROCESSING static size_t debug_idx = 0; ++ debug_idx; #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ - for (size_t region_id = 0; region_id < num_regions; ++ region_id) { - LayerRegion &layerm = *layer.m_regions[region_id]; - float top_bottom_expansion = float(layerm.flow(frSolidInfill).scaled_spacing()) * top_bottom_expansion_coeff; - // Top surfaces. - append(cache.top_surfaces, offset(layerm.slices.filter_by_type(stTop), top_bottom_expansion)); -// append(cache.top_surfaces, offset(layerm.fill_surfaces.filter_by_type(stTop), top_bottom_expansion)); - // Bottom surfaces. - append(cache.bottom_surfaces, offset(layerm.slices.filter_by_types(surfaces_bottom), top_bottom_expansion)); -// append(cache.bottom_surfaces, offset(layerm.fill_surfaces.filter_by_types(surfaces_bottom), top_bottom_expansion)); - // Calculate the maximum perimeter offset as if the slice was extruded with a single extruder only. - // First find the maxium number of perimeters per region slice. - unsigned int perimeters = 0; - for (Surface &s : layerm.slices.surfaces) - perimeters = std::max(perimeters, s.extra_perimeters); - perimeters += layerm.region().config().wall_loops.value; - // Then calculate the infill offset. - if (perimeters > 0) { - Flow extflow = layerm.flow(frExternalPerimeter); - Flow flow = layerm.flow(frPerimeter); - perimeter_offset = std::max(perimeter_offset, - 0.5f * float(extflow.scaled_width() + extflow.scaled_spacing()) + (float(perimeters) - 1.f) * flow.scaled_spacing()); - perimeter_min_spacing = std::min(perimeter_min_spacing, float(std::min(extflow.scaled_spacing(), flow.scaled_spacing()))); - } - polygons_append(cache.holes, to_polygons(layerm.fill_expolygons)); - } - // Save some computing time by reducing the number of polygons. - cache.top_surfaces = union_(cache.top_surfaces); - cache.bottom_surfaces = union_(cache.bottom_surfaces); - // For a multi-material print, simulate perimeter / infill split as if only a single extruder has been used for the whole print. - if (perimeter_offset > 0.) { - // The layer.lslices are forced to merge by expanding them first. - polygons_append(cache.holes, offset2(layer.lslices, 0.3f * perimeter_min_spacing, - perimeter_offset - 0.3f * perimeter_min_spacing)); + // The top surfaces, the bottom surfaces and the holes are independent of each other. + tbb::parallel_invoke( + [&]() { + for (size_t region_id = 0; region_id < num_regions; ++ region_id) + append(cache.top_surfaces, offset(layer.m_regions[region_id]->slices.filter_by_type(stTop), top_bottom_expansion(region_id))); +// append(cache.top_surfaces, offset(layerm.fill_surfaces.filter_by_type(stTop), top_bottom_expansion)); + // Save some computing time by reducing the number of polygons. + cache.top_surfaces = union_(cache.top_surfaces); + }, + [&]() { + for (size_t region_id = 0; region_id < num_regions; ++ region_id) + append(cache.bottom_surfaces, offset(layer.m_regions[region_id]->slices.filter_by_types(surfaces_bottom), top_bottom_expansion(region_id))); +// append(cache.bottom_surfaces, offset(layerm.fill_surfaces.filter_by_types(surfaces_bottom), top_bottom_expansion)); + cache.bottom_surfaces = union_(cache.bottom_surfaces); + }, + [&]() { + // Simulate single set of perimeters over all merged regions. + float perimeter_offset = 0.f; + float perimeter_min_spacing = FLT_MAX; + for (size_t region_id = 0; region_id < num_regions; ++ region_id) { + const LayerRegion &layerm = *layer.m_regions[region_id]; + // Calculate the maximum perimeter offset as if the slice was extruded with a single extruder only. + // First find the maxium number of perimeters per region slice. + unsigned int perimeters = 0; + for (const Surface &s : layerm.slices.surfaces) + perimeters = std::max(perimeters, s.extra_perimeters); + perimeters += layerm.region().config().wall_loops.value; + // Then calculate the infill offset. + if (perimeters > 0) { + Flow extflow = layerm.flow(frExternalPerimeter); + Flow flow = layerm.flow(frPerimeter); + perimeter_offset = std::max(perimeter_offset, + 0.5f * float(extflow.scaled_width() + extflow.scaled_spacing()) + (float(perimeters) - 1.f) * flow.scaled_spacing()); + perimeter_min_spacing = std::min(perimeter_min_spacing, float(std::min(extflow.scaled_spacing(), flow.scaled_spacing()))); + } + polygons_append(cache.holes, to_polygons(layerm.fill_expolygons)); + } + // For a multi-material print, simulate perimeter / infill split as if only a single extruder has been used for the whole print. + if (perimeter_offset > 0.) { + // The layer.lslices are forced to merge by expanding them first. + polygons_append(cache.holes, offset2(layer.lslices, 0.3f * perimeter_min_spacing, - perimeter_offset - 0.3f * perimeter_min_spacing)); #ifdef SLIC3R_DEBUG_SLICE_PROCESSING - { - Slic3r::SVG svg(debug_out_path("discover_vertical_shells-extra-holes-%d.svg", debug_idx), get_extents(layer.lslices)); - svg.draw(layer.lslices, "blue"); - svg.draw(union_ex(cache.holes), "red"); - svg.draw_outline(union_ex(cache.holes), "black", "blue", scale_(0.05)); - svg.Close(); - } + { + Slic3r::SVG svg(debug_out_path("discover_vertical_shells-extra-holes-%d.svg", debug_idx), get_extents(layer.lslices)); + svg.draw(layer.lslices, "blue"); + svg.draw(union_ex(cache.holes), "red"); + svg.draw_outline(union_ex(cache.holes), "black", "blue", scale_(0.05)); + svg.Close(); + } #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ - } - cache.holes = union_(cache.holes); + } + cache.holes = union_(cache.holes); + }); } }); m_print->throw_if_canceled(); BOOST_LOG_TRIVIAL(debug) << "Discovering vertical shells in parallel - end : cache top / bottom"; } - for (size_t region_id = 0; region_id < this->num_printing_regions(); ++ region_id) { + // With one top/bottom cache for all regions, the shell and hole accumulation in the loop below depends on nothing + // region-specific but the shell settings and the external perimeter spacing, so a region sharing them with an earlier + // one reuses its result instead of repeating it: that accumulation is a union over several layers of top/bottom + // surfaces, and a multi-material print has a region per filament. + using AccumulationKey = std::array; + struct ShellAccumulation + { + AccumulationKey key; + Polygons shell; + Polygons holes; + }; + const auto accumulation_key = [](const PrintRegionConfig ®ion_config, const LayerRegion *layerm) { + return AccumulationKey{ double(region_config.top_shell_layers.value), region_config.top_shell_thickness.value, + double(region_config.bottom_shell_layers.value), region_config.bottom_shell_thickness.value, + double(layerm->flow(frExternalPerimeter).scaled_spacing()) }; + }; + const auto accumulate_shell = [this, &cache_top_botom_regions](size_t idx_layer, const PrintRegionConfig ®ion_config, + const LayerRegion *layerm, Polygons &shell, Polygons &holes) { + const Layer *layer = m_layers[idx_layer]; + polygons_append(holes, cache_top_botom_regions[idx_layer].holes); + auto combine_holes = [&holes](const Polygons &holes2) { + if (holes.empty() || holes2.empty()) + holes.clear(); + else + holes = intersection(holes, holes2); + }; + auto combine_shells = [&shell](const Polygons &shells2) { + if (shell.empty()) + shell = std::move(shells2); + else if (! shells2.empty()) { + polygons_append(shell, shells2); + // Running the union_ using the Clipper library piece by piece is cheaper + // than running the union_ all at once. + shell = union_(shell); + } + }; + static constexpr const bool one_more_layer_below_top_bottom_surfaces = false; + if (int n_top_layers = region_config.top_shell_layers.value; n_top_layers > 0) { + // Gather top regions projected to this layer. + coordf_t print_z = layer->print_z; + int i = int(idx_layer) + 1; + int itop = int(idx_layer) + n_top_layers; + bool at_least_one_top_projected = false; + for (; i < int(cache_top_botom_regions.size()) && + (i < itop || m_layers[i]->print_z - print_z < region_config.top_shell_thickness - EPSILON); + ++ i) { + at_least_one_top_projected = true; + const DiscoverVerticalShellsCacheEntry &cache = cache_top_botom_regions[i]; + combine_holes(cache.holes); + combine_shells(cache.top_surfaces); + } + if (!at_least_one_top_projected && i < int(cache_top_botom_regions.size())) { + // Lets consider this a special case - with only 1 top solid and minimal shell thickness settings, the + // boundaries of solid layers are not anchored over/under perimeters, so lets fix it by adding at least one + // perimeter width of area + Polygons anchor_area = intersection(expand(cache_top_botom_regions[idx_layer].top_surfaces, + layerm->flow(frExternalPerimeter).scaled_spacing()), + to_polygons(m_layers[i]->lslices)); + combine_shells(anchor_area); + } + + if (one_more_layer_below_top_bottom_surfaces) + if (i < int(cache_top_botom_regions.size()) && + (i <= itop || m_layers[i]->bottom_z() - print_z < region_config.top_shell_thickness - EPSILON)) + combine_holes(cache_top_botom_regions[i].holes); + } + if (int n_bottom_layers = region_config.bottom_shell_layers.value; n_bottom_layers > 0) { + // Gather bottom regions projected to this layer. + coordf_t bottom_z = layer->bottom_z(); + int i = int(idx_layer) - 1; + int ibottom = int(idx_layer) - n_bottom_layers; + bool at_least_one_bottom_projected = false; + for (; i >= 0 && + (i > ibottom || bottom_z - m_layers[i]->bottom_z() < region_config.bottom_shell_thickness - EPSILON); + -- i) { + at_least_one_bottom_projected = true; + const DiscoverVerticalShellsCacheEntry &cache = cache_top_botom_regions[i]; + combine_holes(cache.holes); + combine_shells(cache.bottom_surfaces); + } + + if (!at_least_one_bottom_projected && i >= 0) { + Polygons anchor_area = intersection(expand(cache_top_botom_regions[idx_layer].bottom_surfaces, + layerm->flow(frExternalPerimeter).scaled_spacing()), + to_polygons(m_layers[i]->lslices)); + combine_shells(anchor_area); + } + + if (one_more_layer_below_top_bottom_surfaces) + if (i >= 0 && + (i > ibottom || bottom_z - m_layers[i]->print_z < region_config.bottom_shell_thickness - EPSILON)) + combine_holes(cache_top_botom_regions[i].holes); + } + }; + std::vector> shell_accumulations(top_bottom_surfaces_all_regions ? num_layers : 0); + if (! shell_accumulations.empty()) { + // Every (layer, key) pair is accumulated once, before the regions, so that nothing in the loop below is shared + // between them and they can run next to each other. + std::vector> todo; // layer, its slot, a region holding the key + for (size_t idx_layer = 0; idx_layer < num_layers; ++ idx_layer) { + std::vector &accumulations = shell_accumulations[idx_layer]; + for (size_t region_id = 0; region_id < this->num_printing_regions(); ++ region_id) { + if (this->printing_region(region_id).config().ensure_vertical_shell_thickness.value != evstAll) + continue; + const LayerRegion *layerm = m_layers[idx_layer]->m_regions[region_id]; + const AccumulationKey key = accumulation_key(layerm->region().config(), layerm); + if (std::none_of(accumulations.begin(), accumulations.end(), [&key](const ShellAccumulation &a) { return a.key == key; })) { + todo.push_back({ idx_layer, accumulations.size(), region_id }); + accumulations.push_back({ key, {}, {} }); + } + } + } + tbb::parallel_for(size_t(0), todo.size(), [this, &todo, &shell_accumulations, &accumulate_shell](size_t i) { + m_print->throw_if_canceled(); + const LayerRegion *layerm = m_layers[todo[i][0]]->m_regions[todo[i][2]]; + ShellAccumulation &out = shell_accumulations[todo[i][0]][todo[i][1]]; + accumulate_shell(todo[i][0], layerm->region().config(), layerm, out.shell, out.holes); + }); + m_print->throw_if_canceled(); + } + + const auto process_region = [&](size_t region_id) { const PrintRegion ®ion = this->printing_region(region_id); if (region.config().ensure_vertical_shell_thickness.value != evstAll ) // This region will be handled by discover_horizontal_shells(). - continue; + return; //FIXME Improve the heuristics for a grain size. size_t grain_size = std::max(num_layers / 16, size_t(1)); @@ -2614,7 +2763,7 @@ void PrintObject::discover_vertical_shells() grain_size = 1; tbb::parallel_for( tbb::blocked_range(0, num_layers, grain_size), - [this, region_id, &cache_top_botom_regions] + [this, region_id, &shell_accumulations, &accumulation_key, &accumulate_shell] (const tbb::blocked_range& range) { // printf("discover_vertical_shells from %d to %d\n", range.begin(), range.end()); for (size_t idx_layer = range.begin(); idx_layer < range.end(); ++ idx_layer) { @@ -2664,80 +2813,19 @@ void PrintObject::discover_vertical_shells() } } #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ - polygons_append(holes, cache_top_botom_regions[idx_layer].holes); - auto combine_holes = [&holes](const Polygons &holes2) { - if (holes.empty() || holes2.empty()) - holes.clear(); - else - holes = intersection(holes, holes2); - }; - auto combine_shells = [&shell](const Polygons &shells2) { - if (shell.empty()) - shell = std::move(shells2); - else if (! shells2.empty()) { - polygons_append(shell, shells2); - // Running the union_ using the Clipper library piece by piece is cheaper - // than running the union_ all at once. - shell = union_(shell); - } - }; - static constexpr const bool one_more_layer_below_top_bottom_surfaces = false; - if (int n_top_layers = region_config.top_shell_layers.value; n_top_layers > 0) { - // Gather top regions projected to this layer. - coordf_t print_z = layer->print_z; - int i = int(idx_layer) + 1; - int itop = int(idx_layer) + n_top_layers; - bool at_least_one_top_projected = false; - for (; i < int(cache_top_botom_regions.size()) && - (i < itop || m_layers[i]->print_z - print_z < region_config.top_shell_thickness - EPSILON); - ++ i) { - at_least_one_top_projected = true; - const DiscoverVerticalShellsCacheEntry &cache = cache_top_botom_regions[i]; - combine_holes(cache.holes); - combine_shells(cache.top_surfaces); - } - if (!at_least_one_top_projected && i < int(cache_top_botom_regions.size())) { - // Lets consider this a special case - with only 1 top solid and minimal shell thickness settings, the - // boundaries of solid layers are not anchored over/under perimeters, so lets fix it by adding at least one - // perimeter width of area - Polygons anchor_area = intersection(expand(cache_top_botom_regions[idx_layer].top_surfaces, - layerm->flow(frExternalPerimeter).scaled_spacing()), - to_polygons(m_layers[i]->lslices)); - combine_shells(anchor_area); - } - - if (one_more_layer_below_top_bottom_surfaces) - if (i < int(cache_top_botom_regions.size()) && - (i <= itop || m_layers[i]->bottom_z() - print_z < region_config.top_shell_thickness - EPSILON)) - combine_holes(cache_top_botom_regions[i].holes); - } - if (int n_bottom_layers = region_config.bottom_shell_layers.value; n_bottom_layers > 0) { - // Gather bottom regions projected to this layer. - coordf_t bottom_z = layer->bottom_z(); - int i = int(idx_layer) - 1; - int ibottom = int(idx_layer) - n_bottom_layers; - bool at_least_one_bottom_projected = false; - for (; i >= 0 && - (i > ibottom || bottom_z - m_layers[i]->bottom_z() < region_config.bottom_shell_thickness - EPSILON); - -- i) { - at_least_one_bottom_projected = true; - const DiscoverVerticalShellsCacheEntry &cache = cache_top_botom_regions[i]; - combine_holes(cache.holes); - combine_shells(cache.bottom_surfaces); - } - - if (!at_least_one_bottom_projected && i >= 0) { - Polygons anchor_area = intersection(expand(cache_top_botom_regions[idx_layer].bottom_surfaces, - layerm->flow(frExternalPerimeter).scaled_spacing()), - to_polygons(m_layers[i]->lslices)); - combine_shells(anchor_area); - } - - if (one_more_layer_below_top_bottom_surfaces) - if (i >= 0 && - (i > ibottom || bottom_z - m_layers[i]->print_z < region_config.bottom_shell_thickness - EPSILON)) - combine_holes(cache_top_botom_regions[i].holes); - } + const AccumulationKey key = accumulation_key(region_config, layerm); + const ShellAccumulation *reused = shell_accumulations.empty() ? nullptr : + [&]() -> const ShellAccumulation * { + for (const ShellAccumulation &a : shell_accumulations[idx_layer]) + if (a.key == key) + return &a; + return nullptr; + }(); + if (reused != nullptr) { + shell = reused->shell; + holes = reused->holes; + } else + accumulate_shell(idx_layer, region_config, layerm, shell, holes); #ifdef SLIC3R_DEBUG_SLICE_PROCESSING { Slic3r::SVG svg(debug_out_path("discover_vertical_shells-perimeters-before-union-%d.svg", debug_idx), get_extents(shell)); @@ -2831,11 +2919,8 @@ void PrintObject::discover_vertical_shells() Polygons object_volume; Polygons internal_volume; { - Polygons shrinked_bottom_slice = idx_layer > 0 ? to_polygons(m_layers[idx_layer - 1]->lslices) : Polygons{}; - Polygons shrinked_upper_slice = (idx_layer + 1) < m_layers.size() ? - to_polygons(m_layers[idx_layer + 1]->lslices) : - Polygons{}; - object_volume = intersection(shrinked_bottom_slice, shrinked_upper_slice); + if (idx_layer > 0 && idx_layer + 1 < m_layers.size()) + object_volume = to_polygons(intersection_ex_by_piece(m_layers[idx_layer - 1]->lslices, to_polygons(m_layers[idx_layer + 1]->lslices))); internal_volume = closing(polygonsInternal, SCALED_EPSILON); } @@ -2846,15 +2931,34 @@ void PrintObject::discover_vertical_shells() // the in-model condition is there due to small sloping surfaces, e.g. top of the hull of the benchy // 2. the area does not fully cover an internal polygon // This is there mainly for a very thin parts, where the solid layers would be missing if the part area is quite small + // Both tests below compare a small piece against the whole layer. Done literally, that is + // quadratic in the number of pieces, which is what a layer split up by colour painting has, + // so each is restricted to the part of the layer near the piece with an identical result: + // object_volume is clipped to the piece's box, and only the internal polygons whose box meets + // the expanded piece take part in the count, since the others pass through the difference + // unchanged and add the same number to both sides of it. + std::vector internal_bboxes; + internal_bboxes.reserve(internal_volume.size()); + for (const Polygon &poly : internal_volume) + internal_bboxes.emplace_back(get_extents(poly)); regularized_shell.erase(std::remove_if(regularized_shell.begin(), regularized_shell.end(), - [&internal_volume, &min_perimeter_infill_spacing, + [&internal_volume, &internal_bboxes, &min_perimeter_infill_spacing, &object_volume](const ExPolygon &p) { - return (p.area() < min_perimeter_infill_spacing * scaled(1.5) || - (p.area() < min_perimeter_infill_spacing * scaled(8.0) && - diff(to_polygons(p), object_volume).empty())) && - diff(internal_volume, - expand(to_polygons(p), min_perimeter_infill_spacing)) - .size() >= internal_volume.size(); + const bool small = p.area() < min_perimeter_infill_spacing * scaled(1.5) || + (p.area() < min_perimeter_infill_spacing * scaled(8.0) && + diff(to_polygons(p), + ClipperUtils::clip_clipper_polygons_with_subject_bbox( + object_volume, get_extents(p).inflated(SCALED_EPSILON))) + .empty()); + if (!small) + return false; + const Polygons expanded = expand(to_polygons(p), min_perimeter_infill_spacing); + const BoundingBox bbox = get_extents(expanded); + Polygons nearby; + for (size_t i = 0; i < internal_volume.size(); ++i) + if (internal_bboxes[i].overlap(bbox)) + nearby.emplace_back(internal_volume[i]); + return diff(nearby, expanded).size() >= nearby.size(); }), regularized_shell.end()); } @@ -2876,8 +2980,9 @@ void PrintObject::discover_vertical_shells() #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ // Trim the internal & internalvoid by the shell. - Slic3r::ExPolygons new_internal = diff_ex(layerm->fill_surfaces.filter_by_type(stInternal), regularized_shell); - Slic3r::ExPolygons new_internal_void = diff_ex(layerm->fill_surfaces.filter_by_type(stInternalVoid), regularized_shell); + const Polygons regularized_shell_polygons = to_polygons(regularized_shell); + Slic3r::ExPolygons new_internal = diff_ex_by_piece(to_expolygons(layerm->fill_surfaces.filter_by_type(stInternal)), regularized_shell_polygons); + Slic3r::ExPolygons new_internal_void = diff_ex_by_piece(to_expolygons(layerm->fill_surfaces.filter_by_type(stInternalVoid)), regularized_shell_polygons); #ifdef SLIC3R_DEBUG_SLICE_PROCESSING { @@ -2904,7 +3009,15 @@ void PrintObject::discover_vertical_shells() layerm->export_region_fill_surfaces_to_svg_debug("3_discover_vertical_shells-final"); } #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ - } // for each region + }; // for each region + if (top_bottom_surfaces_all_regions) + // Nothing is shared between the regions, and a layer cut through a fine relief takes far longer than the others, + // so they run next to each other instead of one after another. + tbb::parallel_for(size_t(0), this->num_printing_regions(), process_region); + else + // Here every region fills the one top/bottom cache with its own surfaces first. + for (size_t region_id = 0; region_id < this->num_printing_regions(); ++ region_id) + process_region(region_id); } // void PrintObject::discover_vertical_shells() // #define DEBUG_BRIDGE_OVER_INFILL @@ -3425,6 +3538,16 @@ void PrintObject::bridge_over_infill() vertical_lines[i].b = Point{x, y_max}; } + // The vertical lines only span the bridged area's x range, so anchors entirely outside it can never be + // hit. Leaving them out gives the same intersections without building a tree over the whole layer's + // boundary for every bridge. + const coord_t scan_x_min = bb_x.min.x(); + const coord_t scan_x_max = bb_x.min.x() + coord_t(n_vlines) * scan_spacing; + anchors.erase(std::remove_if(anchors.begin(), anchors.end(), + [scan_x_min, scan_x_max](const Line &l) { + return std::max(l.a.x(), l.b.x()) < scan_x_min || std::min(l.a.x(), l.b.x()) > scan_x_max; + }), + anchors.end()); auto anchors_and_walls_tree = AABBTreeLines::LinesDistancer{std::move(anchors)}; auto bridged_area_tree = AABBTreeLines::LinesDistancer{to_lines(bridged_area)}; @@ -3669,26 +3792,58 @@ void PrintObject::bridge_over_infill() std::vector expanded_surfaces; expanded_surfaces.reserve(surfaces_by_layer[lidx].size()); + // The expanded fill boundary depends only on the bridging flow, and total_fill_area is not + // modified below, so build it once per spacing rather than once per candidate. A layer split + // into many candidates (e.g. by colour painting) otherwise repeats a layer-wide offset for each. + std::map boundary_by_spacing; + // expansion_area is a clean, non-overlapping set, so cutting a bridge out of it only changes the + // polygons near that bridge. The rest are passed through untouched instead of being fed to Clipper + // with the whole layer again for every candidate. + // Not `near`/`far`: the Windows headers still define those as macros, and they expand to + // nothing, which turns the declaration below into an empty one. + const auto split_near = [](const Polygons &polys, const BoundingBox &bbox, Polygons &rest) { + Polygons nearby; + for (const Polygon &p : polys) + (get_extents(p).overlap(bbox) ? nearby : rest).emplace_back(p); + return nearby; + }; for (const CandidateSurface &candidate : surfaces_by_layer[lidx]) { const auto ®ion_config = candidate.region->region().config(); const bool turning_pattern = region_config.sparse_infill_pattern == ipHilbertCurve || region_config.sparse_infill_pattern == ipOctagramSpiral; const Flow &flow = candidate.region->bridging_flow(frSolidInfill, true); - ExPolygons bridge_components = intersection_ex(expand(candidate.new_polys, flow.scaled_spacing()), deep_infill_area); + const Polygons expanded_polys = expand(candidate.new_polys, flow.scaled_spacing()); + // deep_infill_area and internal_unsupported_area cover the whole layer; only their part under + // this candidate can change the results, so they are clipped to its box first. + ExPolygons bridge_components; + if (!expanded_polys.empty()) + bridge_components = intersection_ex(expanded_polys, + ClipperUtils::clip_clipper_polygons_with_subject_bbox( + deep_infill_area, get_extents(expanded_polys).inflated(SCALED_EPSILON))); // Orca: Filter whole bridge areas so their holes remain holes. bridge_components.erase(std::remove_if(bridge_components.begin(), bridge_components.end(), [&internal_unsupported_area](const ExPolygon &component) { - return intersection_ex(component, internal_unsupported_area).empty(); + return intersection_ex(component, ClipperUtils::clip_clipper_polygons_with_subject_bbox( + internal_unsupported_area, + get_extents(component).inflated(SCALED_EPSILON))) + .empty(); }), bridge_components.end()); Polygons area_to_be_bridge = to_polygons(std::move(bridge_components)); - Polygons limiting_area = union_(area_to_be_bridge, expansion_area); - if (area_to_be_bridge.empty()) continue; - Polylines boundary_plines = to_polylines(expand(total_fill_area, 1.3f * flow.scaled_spacing())); + // Not split like the cut of expansion_area below: the whole limiting area is grown by 30% of the spacing, + // which merges neighbouring polygons, and any of its boundary can anchor the bridge. + Polygons limiting_area = union_(area_to_be_bridge, expansion_area); + + auto boundary_it = boundary_by_spacing.find(flow.scaled_spacing()); + if (boundary_it == boundary_by_spacing.end()) + boundary_it = boundary_by_spacing + .emplace(flow.scaled_spacing(), to_polylines(expand(total_fill_area, 1.3f * flow.scaled_spacing()))) + .first; + Polylines boundary_plines = boundary_it->second; { Polylines limiting_plines = to_polylines(expand(limiting_area, 0.3f * flow.scaled_spacing())); boundary_plines.insert(boundary_plines.end(), limiting_plines.begin(), limiting_plines.end()); @@ -3764,9 +3919,12 @@ void PrintObject::bridge_over_infill() // Check collision with other expanded surfaces { bool reconstruct = false; - Polygons tmp_expanded_area = expand(bridging_area, 3.0f * flow.scaled_spacing()); + Polygons tmp_expanded_area = expand(bridging_area, 3.0 * flow.scaled_spacing()); + const BoundingBox tmp_expanded_bbox = get_extents(tmp_expanded_area); for (const CandidateSurface &s : expanded_surfaces) { - if (!intersection(s.new_polys, tmp_expanded_area).empty()) { + // Surfaces whose boxes miss each other cannot intersect, which is most pairs on a busy layer. + if (get_extents(s.new_polys).overlap(tmp_expanded_bbox) && + !intersection(s.new_polys, tmp_expanded_area).empty()) { bridging_angle = s.bridge_angle; reconstruct = true; break; @@ -3790,10 +3948,20 @@ void PrintObject::bridge_over_infill() bridging_area = union_(bridging_area, construct_anchored_polygon(bridging_area, to_lines(boundary_plines), flow, bridging_angle, scan_spacing, true)); } - bridging_area = intersection(bridging_area, limiting_area); - bridging_area = intersection(bridging_area, total_fill_area); - bridging_area = diff(bridging_area, total_top_area); - expansion_area = diff(expansion_area, bridging_area); + // Each of these meets one bridge with the whole layer, so the layer side is first cut down to the + // bridge's box (and expansion_area split as above); the result is the same. + if (!bridging_area.empty()) { + const BoundingBox bridging_bbox = get_extents(bridging_area).inflated(SCALED_EPSILON); + bridging_area = intersection(bridging_area, ClipperUtils::clip_clipper_polygons_with_subject_bbox(limiting_area, bridging_bbox)); + bridging_area = intersection(bridging_area, ClipperUtils::clip_clipper_polygons_with_subject_bbox(total_fill_area, bridging_bbox)); + bridging_area = diff(bridging_area, ClipperUtils::clip_clipper_polygons_with_subject_bbox(total_top_area, bridging_bbox)); + } + if (!bridging_area.empty()) { + Polygons kept; + const Polygons cut = split_near(expansion_area, get_extents(bridging_area).inflated(SCALED_EPSILON), kept); + append(kept, diff(cut, bridging_area)); + expansion_area = std::move(kept); + } #ifdef DEBUG_BRIDGE_OVER_INFILL debug_draw(std::to_string(lidx) + "_" + std::to_string(cluster_idx) + "_" + std::to_string(job_idx) + "_" + "_expanded_bridging" + std::to_string(r), diff --git a/src/libslic3r/Support/SupportMaterial.cpp b/src/libslic3r/Support/SupportMaterial.cpp index 813acecec4..8ac9172bfd 100644 --- a/src/libslic3r/Support/SupportMaterial.cpp +++ b/src/libslic3r/Support/SupportMaterial.cpp @@ -1004,9 +1004,9 @@ public: ::fread(&y, sizeof(coord_t), 1, file); poly.points.emplace_back(Point(x * scale, y * scale)); } + printf("Polygon %d, area: %lf\n", i, area(poly.points)); if (which == -1 || which == i) m_support_polygons_deserialized.emplace_back(std::move(poly)); - printf("Polygon %d, area: %lf\n", i, area(poly.points)); } ::fread(&n_polygons, 4, 1, file); m_trimming_polygons_deserialized.reserve(n_polygons); diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index 70ab0b06f1..1d3f0b29ea 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -996,16 +996,46 @@ void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) if (is_auto(stype) && config_detect_sharp_tails) { // BBS detect sharp tail + // On a belt, "below" has to include the belt itself and the + // shear-advanced lower layer, or every belt-contact island reads as + // a sharp tail -- which is what the empty-predecessor skip above was + // really masking. effective_lower is exactly that notion of below. + const ExPolygons &tail_lower = belt_ovh_active ? effective_lower : lower_polys; + // Each island is tested only against the lower islands whose box meets its own; overlaps() tries + // every pair, which is quadratic in the island counts of the two layers. + std::vector lower_bboxes; + lower_bboxes.reserve(tail_lower.size()); + for (const ExPolygon &lower : tail_lower) + lower_bboxes.emplace_back(get_extents(lower)); for (const ExPolygon& expoly : curr_polys) { bool is_sharp_tail = false; // 1. nothing below - // this is a sharp tail region if it's floating and non-ignorable. - // On a belt, "below" has to include the belt itself and the - // shear-advanced lower layer, or every belt-contact island reads as - // a sharp tail -- which is what the empty-predecessor skip above was - // really masking. effective_lower is exactly that notion of below. - const ExPolygons &tail_lower = belt_ovh_active ? effective_lower : lower_polys; - if (!overlaps(offset_ex(expoly, 0.1 * extrusion_width_scaled), tail_lower)) { + // this is a sharp tail region if it's floating and non-ignorable + const ExPolygons expanded = offset_ex(expoly, 0.1 * extrusion_width_scaled); + const BoundingBox bbox = get_extents(expanded); + ExPolygons lower_nearby; + for (size_t i = 0; i < tail_lower.size(); ++i) + if (lower_bboxes[i].overlap(bbox)) + lower_nearby.emplace_back(tail_lower[i]); + // As overlaps(expanded, lower_nearby), with each lower island cut to the island's box first: + // below a fine relief the lower layer is a few islands with thousands of holes, and the whole + // of that boundary would otherwise be intersected once per island above. + const auto overlaps_nearby = [&]() { + for (const ExPolygon &a : expanded) { + if (a.empty()) + continue; + const BoundingBox a_bbox = get_extents(a); + for (const ExPolygon &b : lower_nearby) { + if (b.empty() || !get_extents(b).overlap(a_bbox)) + continue; + const Polygons b_near = ClipperUtils::clip_clipper_polygons_with_subject_bbox(b, a_bbox.inflated(SCALED_EPSILON)); + if (!intersection_pl(to_polylines(b_near), a).empty() || b.contains(a.contour.points.front())) + return true; + } + } + return false; + }; + if (!overlaps_nearby()) { is_sharp_tail = !offset_ex(expoly, -0.1 * extrusion_width_scaled).empty(); } diff --git a/src/libslic3r/Surface.hpp b/src/libslic3r/Surface.hpp index 5e0c0461e4..4564ba0301 100644 --- a/src/libslic3r/Surface.hpp +++ b/src/libslic3r/Surface.hpp @@ -68,7 +68,7 @@ public: thickness(other.thickness), thickness_layers(other.thickness_layers), bridge_angle(other.bridge_angle), extra_perimeters(other.extra_perimeters) {}; - Surface(Surface &&rhs) + Surface(Surface &&rhs) noexcept : surface_type(rhs.surface_type), expolygon(std::move(rhs.expolygon)), thickness(rhs.thickness), thickness_layers(rhs.thickness_layers), bridge_angle(rhs.bridge_angle), extra_perimeters(rhs.extra_perimeters) @@ -94,7 +94,7 @@ public: return *this; } - Surface& operator=(Surface &&rhs) + Surface& operator=(Surface &&rhs) noexcept { surface_type = rhs.surface_type; expolygon = std::move(rhs.expolygon); diff --git a/src/libslic3r/libslic3r.h b/src/libslic3r/libslic3r.h index b0a5250532..e3662919e7 100644 --- a/src/libslic3r/libslic3r.h +++ b/src/libslic3r/libslic3r.h @@ -167,10 +167,10 @@ inline void append(std::vector &dest, std::vector &&src) { if (dest.empty()) dest = std::move(src); - else { - dest.reserve(dest.size() + src.size()); - std::move(std::begin(src), std::end(src), std::back_inserter(dest)); - } + else + // insert() grows the capacity geometrically; reserving exactly the new size reallocated on every call, which + // made appending piece by piece quadratic. + dest.insert(dest.end(), std::make_move_iterator(src.begin()), std::make_move_iterator(src.end())); src.clear(); src.shrink_to_fit(); } diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 12b3083d4d..a18083f4d4 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -2024,7 +2024,9 @@ static Semver installable_cache_version(const boost::filesystem::path& dir, cons const auto cache_ver = Semver::parse(VendorCacheFile::peek_version((dir / (vendor + ".opc")).string(), vendor)); if (! cache_ver) return Semver::invalid(); - const Semver profile_ver = get_version_from_json((dir / (vendor + ".json")).string()); + // Packaging prunes the profile of every vendor that ships a cache. + const boost::filesystem::path json = dir / (vendor + ".json"); + const Semver profile_ver = boost::filesystem::exists(json) ? get_version_from_json(json.string()) : Semver(); return cache_covers(*cache_ver, profile_ver) ? *cache_ver : Semver::invalid(); } diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 95944a8da5..3b3bfb35fd 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -5632,14 +5632,10 @@ void GLCanvas3D::do_move(const std::string& snapshot_type, bool force_volume_mov // Fixes sinking/flying instances (snaps object to buildplate) for (const std::pair& i : done) { ModelObject* mo = m_model->objects[i.first]; - - if (!mo->auto_drop) { - continue; - } const double shift_z = mo->get_instance_min_z(i.second); //BBS: don't call translate if the z is zero - if ((current_printer_technology() == ptSLA || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { + if (mo->auto_drop && (current_printer_technology() == ptSLA || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { const Vec3d shift(0.0, 0.0, -shift_z); m_selection.translate(i.first, i.second, shift); mo->translate_instance(i.second, shift); @@ -5763,14 +5759,10 @@ void GLCanvas3D::do_rotate(const std::string& snapshot_type) for (const std::pair &i : done) { ModelObject *mo = m_model->objects[i.first]; - if (!mo->auto_drop) { - continue; - } - // BBS: don't call translate if the z is zero const double shift_z = mo->get_instance_min_z(i.second); // leave sinking instances as sinking - if ((min_zs.find({i.first, i.second})->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { + if (mo->auto_drop && (min_zs.find({i.first, i.second})->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { const Vec3d shift(0.0, 0.0, -shift_z); m_selection.translate(i.first, i.second, shift); mo->translate_instance(i.second, shift); @@ -5865,14 +5857,10 @@ void GLCanvas3D::do_scale(const std::string& snapshot_type) for (const std::pair& i : done) { ModelObject* mo = m_model->objects[i.first]; - if (!mo->auto_drop) { - continue; - } - //BBS: don't call translate if the z is zero double shift_z = mo->get_instance_min_z(i.second); // leave sinking instances as sinking - if ((min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { + if (mo->auto_drop && (min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { Vec3d shift(0.0, 0.0, -shift_z); m_selection.translate(i.first, i.second, shift); mo->translate_instance(i.second, shift); @@ -5985,14 +5973,10 @@ void GLCanvas3D::do_mirror(const std::string& snapshot_type) for (const std::pair& i : done) { ModelObject* mo = m_model->objects[i.first]; - if (!mo->auto_drop) { - continue; - } - //BBS: don't call translate if the z is zero double shift_z = mo->get_instance_min_z(i.second); // leave sinking instances as sinking - if ((min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD)&&(shift_z != 0.0f)) { + if (mo->auto_drop && (min_zs.empty() || min_zs.find({ i.first, i.second })->second >= SINKING_Z_THRESHOLD || shift_z > SINKING_Z_THRESHOLD) && (shift_z != 0.0f)) { Vec3d shift(0.0, 0.0, -shift_z); m_selection.translate(i.first, i.second, shift); mo->translate_instance(i.second, shift); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index b9e119220c..47986c536a 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3002,7 +3002,8 @@ void GUI_App::init_plugin_gui_wiring() }; // why: a newly loaded plugin only adds a selectable agent - // refresh the dropdown and leave the live agent alone + // refresh the dropdown and leave the live agent alone, unless the selected preset was + // waiting for this agent: plugins load after the startup preset is applied auto refresh_printer_agent_dropdown_after_load = [](const std::string&) { if (!wxTheApp) @@ -3014,8 +3015,11 @@ void GUI_App::init_plugin_gui_wiring() app->CallAfter([app] { - if (!app->is_closing()) - app->refresh_printer_agent_dropdown(); + if (app->is_closing()) + return; + + app->switch_printer_agent_if_pending(); + app->refresh_printer_agent_dropdown(); }); }; @@ -4303,6 +4307,21 @@ void GUI_App::switch_printer_agent() select_machine(effective_agent_id); } +void GUI_App::switch_printer_agent_if_pending() +{ + if (!m_agent || !preset_bundle) + return; + + const std::string wanted_agent_id = resolve_printer_agent_id(preset_bundle->printers.get_edited_preset().config.opt_string("printer_agent")); + const auto live_agent = m_agent->get_printer_agent(); + if (live_agent && live_agent->get_agent_info().id == wanted_agent_id) + return; + if (!NetworkAgentFactory::get_printer_agent_info(wanted_agent_id)) + return; + + switch_printer_agent(); +} + void GUI_App::select_machine(const std::string& agent_id) { // Skip for BBL agent for now - uses its own device discovery/selection diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 33ac8f317f..30dbdf92d4 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -412,6 +412,9 @@ public: // Reconcile the live printer agent with the stored preset selection. void switch_printer_agent(); + // Install the selected preset's agent if it is registered but not live, e.g. when its plugin + // finishes loading after the startup preset was applied. Otherwise leave the live agent alone. + void switch_printer_agent_if_pending(); std::string resolve_printer_agent_id(const std::string& stored_id) const; // ORCA TODO: in the future, bbl presets should specify "bbl" printer agent id diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index e7d1041cb6..5961d5af47 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -1618,7 +1618,9 @@ void ObjectList::list_manipulation(const wxPoint& mouse_pos, bool evt_context_me get_selected_item_indexes(obj_idx, vol_idx, item); if (obj_idx != -1) { Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); - (*m_objects)[obj_idx]->ensure_on_bed(); + // Shift on request also when auto drop is off, which ensure_on_bed() skips. + ModelObject* mo = (*m_objects)[obj_idx]; + mo->translate_instances(-mo->min_z() * Vec3d::UnitZ()); cnv->reload_scene(true, true); update_info_items(obj_idx); notify_instance_updated(obj_idx); @@ -6734,7 +6736,9 @@ void ObjectList::OnEditingStarted(wxDataViewEvent &event) Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); int obj_idx, vol_idx; get_selected_item_indexes(obj_idx, vol_idx, item); - (*m_objects)[obj_idx]->ensure_on_bed(); + // Shift on request also when auto drop is off, which ensure_on_bed() skips. + ModelObject* mo = (*m_objects)[obj_idx]; + mo->translate_instances(-mo->min_z() * Vec3d::UnitZ()); cnv->reload_scene(true, true); update_info_items(obj_idx); notify_instance_updated(obj_idx); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 3b224725f6..99cc0de21b 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -2865,7 +2865,8 @@ Sidebar::Sidebar(Plater *parent) // add printer title scrolled_sizer->Add(p->m_panel_printer_title, 0, wxEXPAND | wxALL, 0); - p->m_panel_printer_title->Bind(wxEVT_LEFT_UP, [this] (auto & e) { + // ORCA Bind with LEFT_DOWN, since closing a dialog over the title bar can send it a stray LEFT_UP + auto toggle_printer_section = ([this] (wxMouseEvent & e) { if (!p || !p->combo_printer || !p->m_text_printer_settings || !p->m_panel_printer_content || !m_scrolled_sizer) return; // ORCA Show printer name on title when its folded to inform user without expanding it again @@ -2876,6 +2877,8 @@ Sidebar::Sidebar(Plater *parent) p->m_panel_printer_separator->Show(isShown); m_scrolled_sizer->Layout(); }); + p->m_panel_printer_title->Bind(wxEVT_LEFT_DOWN , toggle_printer_section); + p->m_panel_printer_title->Bind(wxEVT_LEFT_DCLICK, toggle_printer_section); // ORCA add bottom border for seperation wile sections folded p->m_panel_printer_separator = new wxPanel(p->scrolled, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(2))); // ORCA staticline class not works without string p->m_panel_printer_separator->SetBackgroundColour("#FFFFFF"); @@ -3248,7 +3251,8 @@ Sidebar::Sidebar(Plater *parent) p->m_panel_filament_title = new StaticBox(p->scrolled, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL | wxBORDER_NONE); p->m_panel_filament_title->SetBackgroundColor(title_bg); p->m_panel_filament_title->SetBackgroundColor2(0xF1F1F1); - p->m_panel_filament_title->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &e) { + // ORCA Bind with LEFT_DOWN, since closing a dialog over the title bar can send it a stray LEFT_UP + auto toggle_filament_section = ([this](wxMouseEvent &e) { if (!p || !p->m_filament_area_wrapper || !m_scrolled_sizer || !p->m_bpButton_set_filament || !p->m_purge_mode_btn || !p->m_flushing_volume_btn || !p->m_bpButton_add_filament || !ams_btn) return; // ORCA exclude area of del button from titlebar collapse/expand feature to fix undesired collapse when user spams del filament button @@ -3267,6 +3271,8 @@ Sidebar::Sidebar(Plater *parent) CallAfter([this]{update_filaments_counter(true);}); // call after all UI processing done }); + p->m_panel_filament_title->Bind(wxEVT_LEFT_DOWN , toggle_filament_section); + p->m_panel_filament_title->Bind(wxEVT_LEFT_DCLICK, toggle_filament_section); wxBoxSizer* bSizer39; bSizer39 = new wxBoxSizer( wxHORIZONTAL ); diff --git a/src/slic3r/GUI/Selection.cpp b/src/slic3r/GUI/Selection.cpp index 4d7f1591ee..e9c91bc337 100644 --- a/src/slic3r/GUI/Selection.cpp +++ b/src/slic3r/GUI/Selection.cpp @@ -579,29 +579,12 @@ void Selection::drop() wxGetApp().plater()->take_snapshot(L("Move object")); - this->move_to_center(Vec3d(0, 0, -min_z)); - - for (unsigned int i : m_list) { - GLVolume& volume = *(*m_volumes)[i]; - // Skip the wipe tower: its synthetic id (>= 1000) is not an index into m_model->objects. - if (volume.object_idx() >= 1000) - continue; - ModelObject* model_object = m_model->objects[volume.object_idx()]; - - if (model_object != nullptr) { - if (m_mode == Volume) { - ModelVolume* cur_mv = model_object->volumes[volume.volume_idx()]; - cur_mv->set_transformation(volume.get_volume_transformation()); - } else if (m_mode == Instance) { - model_object->instances[volume.instance_idx()]->set_transformation(volume.get_instance_transformation()); - } - - model_object->invalidate_bounding_box(); - this->notify_instance_update(volume.object_idx(), volume.instance_idx()); - } - } - - wxGetApp().plater()->get_view3D_canvas3D()->post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_MOVED)); + // do_move() writes every volume back to the model and refreshes the view. + setup_cache(); + TransformationType transformation_type; + transformation_type.set_relative(); + translate(Vec3d(0, 0, -min_z), transformation_type); + wxGetApp().plater()->get_view3D_canvas3D()->do_move(""); } void Selection::center_plate(const int plate_idx) { diff --git a/src/slic3r/GUI/Widgets/Label.cpp b/src/slic3r/GUI/Widgets/Label.cpp index da5e9b72af..0e5d9eab43 100644 --- a/src/slic3r/GUI/Widgets/Label.cpp +++ b/src/slic3r/GUI/Widgets/Label.cpp @@ -310,7 +310,7 @@ Label::Label(wxWindow *parent, wxFont const &font, wxString const &text, long st SetBackgroundColour(StaticBox::GetParentBackgroundColor(parent)); SetForegroundColour("#262E30"); if (style & LB_PROPAGATE_MOUSE_EVENT) { - for (auto evt : { wxEVT_LEFT_UP, wxEVT_LEFT_DOWN }) + for (auto evt : { wxEVT_LEFT_UP, wxEVT_LEFT_DOWN, wxEVT_LEFT_DCLICK}) // ORCA also propagete LEFT_DCLICK event that used on sidebar titlebars Bind(evt, [this] (auto & e) { GetParent()->GetEventHandler()->ProcessEventLocally(e); }); }; if (style & LB_AUTO_WRAP) { diff --git a/src/slic3r/Utils/QidiPrinterAgent.cpp b/src/slic3r/Utils/QidiPrinterAgent.cpp index c2dc4411a6..063f7721f0 100644 --- a/src/slic3r/Utils/QidiPrinterAgent.cpp +++ b/src/slic3r/Utils/QidiPrinterAgent.cpp @@ -73,7 +73,14 @@ bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode // 3. Fetch slot info and build AmsTrayData directly std::vector trays; int box_count = 0; - if (!fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error)) { + bool slots_ok = false; + try { + slots_ok = fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error); + } catch (const nlohmann::json::exception& e) { + // A slot value of an unexpected type fails the sync instead of escaping to the UI + error = e.what(); + } + if (!slots_ok) { BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error; return false; } @@ -91,7 +98,7 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url, int& box_count, std::string& error) { - std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables"); + std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables&multi_color_controller=box"); for (int i = 0; i < 16; ++i) { url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button"; } @@ -164,9 +171,12 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url, AmsTrayData tray; tray.slot_index = i; - // Read slot variables - const int color_index = variables.value("color_slot" + std::to_string(i), 1); - const int filament_type = variables.value("filament_slot" + std::to_string(i), 1); + // Read slot variables. Newer firmware stores a hex color list and a material id instead of dictionary + // indexes, and only in the slots it has rewritten, so each slot is checked for both forms. + const auto color_value = variables.value("color_slot" + std::to_string(i), nlohmann::json(1)); + const auto filament_value = variables.value("filament_slot" + std::to_string(i), nlohmann::json(1)); + const int color_index = color_value.is_number() ? color_value.get() : 0; + const int filament_type = filament_value.is_number() ? filament_value.get() : 0; const int vendor_type = variables.value("vendor_slot" + std::to_string(i), 0); // Check filament presence via runout sensor @@ -187,6 +197,13 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url, if (filament_it != dict.filaments.end()) { filament_name = filament_it->second; } + if (filament_value.is_string()) { + const nlohmann::json::json_pointer decoded_type("/multi_color_controller/box/box" + std::to_string(i / 4) + + "_filament_info_" + std::to_string(i % 4) + "_filament_type"); + if (status.contains(decoded_type) && status[decoded_type].is_string()) { + filament_name = status[decoded_type].get(); + } + } tray.tray_type = normalize_filament_type(filament_name); // Try Qidi-specific setting ID first; fall back to visible preset by type @@ -202,7 +219,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url, // Look up color from dictionary auto color_it = dict.colors.find(color_index); - if (color_it != dict.colors.end()) { + if (color_value.is_array() && !color_value.empty() && color_value[0].is_string()) { + tray.tray_color = color_value[0].get(); + } else if (color_it != dict.colors.end()) { tray.tray_color = color_it->second; } else { tray.tray_color = "FFFFFFFF"; diff --git a/src/slic3r/Utils/SimplyPrint.cpp b/src/slic3r/Utils/SimplyPrint.cpp index ff6860a244..98b1a7fa16 100644 --- a/src/slic3r/Utils/SimplyPrint.cpp +++ b/src/slic3r/Utils/SimplyPrint.cpp @@ -350,9 +350,13 @@ bool SimplyPrint::do_temp_upload(const boost::filesystem::path& file_path, if (should_open_in_external_browser()) { wxLaunchDefaultBrowser(url); } else { - const auto mainframe = GUI::wxGetApp().mainframe; - mainframe->request_select_tab(TAB_ID_MONITOR); - mainframe->load_printer_url(url); + // The tab switch queues a reload of the printer's home page, so switch synchronously + // first and queue the import page after it. + GUI::wxGetApp().CallAfter([url = wxString::FromUTF8(url)] { + const auto mainframe = GUI::wxGetApp().mainframe; + mainframe->select_tab(TAB_ID_MONITOR); + mainframe->load_printer_url(url); + }); } return true; diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index e913763891..2e51545512 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -130,6 +131,20 @@ static AuditEventCategory event_category(const std::string& event_name) return it == audit_event_categories.end() ? AuditEventCategory::None : it->second; } +// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole +// operation, so the raising frame bounds everything the operation does. Every other mapped event +// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly +// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2 +// are raised in iglob(), which returns its lazy generator before any directory is listed. +static const std::unordered_set operation_root_events{ + "urllib.Request", "http.client.connect", "os.walk", "os.fwalk", + "glob.glob", "glob.glob/2", "pathlib.Path.glob", "pathlib.Path.rglob", + "subprocess.Popen", "pty.spawn", "tempfile.mkstemp", "tempfile.mkdtemp", + "shutil.copyfile", "shutil.copymode", "shutil.copystat", "shutil.copytree", + "shutil.chown", "shutil.move", "shutil.rmtree", "shutil.make_archive", + "shutil.unpack_archive", +}; + // True for the categories whose targets are filesystem paths, as opposed to a network // address or a process command line -- the deny-path and allowed-root checks only make sense // against a path. @@ -146,6 +161,24 @@ static bool is_fs_category(AuditEventCategory category) } } +// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open". +static const char* category_name(AuditEventCategory category) +{ + switch (category) { + case AuditEventCategory::FsRead: return "FsRead"; + case AuditEventCategory::FsReadWrite: return "FsReadWrite"; + case AuditEventCategory::FsCreate: return "FsCreate"; + case AuditEventCategory::FsDelete: return "FsDelete"; + case AuditEventCategory::Http: return "Http"; + case AuditEventCategory::Socket: return "Socket"; + case AuditEventCategory::ProcessCreate: return "ProcessCreate"; + case AuditEventCategory::ProcessReplace: return "ProcessReplace"; + case AuditEventCategory::Threading: return "Threading"; + case AuditEventCategory::None: break; + } + return "None"; +} + // --------------------------------------------------------------------------- // Path safety // --------------------------------------------------------------------------- @@ -215,26 +248,50 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos thread_local std::string PluginAuditManager::m_current_plugin_key = ""; thread_local std::string PluginAuditManager::m_current_capability_name = ""; thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; +thread_local std::vector PluginAuditManager::m_root_frames; +thread_local int PluginAuditManager::m_releasing_frames = 0; thread_local bool PluginAuditManager::m_audit_denial_pending = false; thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local AuditViolation PluginAuditManager::m_last_violation; ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, const std::string& capability_name) - : m_previous_id(PluginAuditManager::instance().current_plugin()) + : m_plugin_key(plugin_key) + , m_capability(capability_name) + , m_previous_id(PluginAuditManager::instance().current_plugin()) , m_previous_capability(PluginAuditManager::instance().current_capability()) , m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) + , m_previous_root_frames(std::move(PluginAuditManager::m_root_frames)) { PluginAuditManager::instance().set_current_plugin(plugin_key); PluginAuditManager::instance().set_current_capability(capability_name); PluginAuditManager::m_scoped_allowed_roots.clear(); + PluginAuditManager::m_root_frames.clear(); } ScopedPluginAuditContext::~ScopedPluginAuditContext() { - PluginAuditManager::instance().set_current_plugin(m_previous_id); - PluginAuditManager::instance().set_current_capability(m_previous_capability); + PluginAuditManager& mgr = PluginAuditManager::instance(); + + // Let this scope's root frames go while it is still the current context, and as its own + // plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer + // must never be judged as the outer plugin, or bypass the audit when there is none. A + // finalizer may record a new root frame, so repeat until none is held. + if (!PluginAuditManager::m_root_frames.empty()) { + assert(PyGILState_Check()); + mgr.set_current_plugin(m_plugin_key); + mgr.set_current_capability(m_capability); + while (!PluginAuditManager::m_root_frames.empty()) { + std::vector frames = std::move(PluginAuditManager::m_root_frames); + PluginAuditManager::m_root_frames.clear(); + mgr.release_frames(frames); + } + } + + mgr.set_current_plugin(m_previous_id); + mgr.set_current_capability(m_previous_capability); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); + PluginAuditManager::m_root_frames = std::move(m_previous_root_frames); } // --------------------------------------------------------------------------- @@ -296,16 +353,18 @@ void PluginAuditManager::add_denied_filename(const std::string& filename) std::vector PluginAuditManager::default_denied_filenames() { // AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key - // on its mode, and is a non-static member we cannot call without an instance. Denying all + // on its mode, and is a non-static member we cannot call without an instance. Denying all // four names is cheaper and more robust than replicating that; the two unused names cost one - // string comparison each. Single-sourced here so install_hook() and the tests seed from the - // same list and cannot drift apart. + // string comparison each. Keep the plugin install-state sidecar here too: it controls which + // packages load on startup and must remain host-managed. Single-sourced so install_hook() and + // the tests seed from the same list and cannot drift apart. return { SLIC3R_APP_KEY ".conf", GCODEVIEWER_APP_KEY ".conf", SLIC3R_APP_KEY ".ini", GCODEVIEWER_APP_KEY ".ini", secret_constants::USER_SECRET_FILENAME, + ".install_state.json", }; } @@ -441,6 +500,12 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat candidate = absolute_candidate; } + // Importing Python modules reads their source and may inspect package directories. These + // reads are normal interpreter work, not plugin data access; keep the exemption read-only + // and exclude third-party packages from the stdlib roots. + if (!is_write && is_stdlib_path(candidate)) + return {true, ""}; + // A root that doesn't allow writes only matches a read-shaped request; a write/create/ // delete-shaped one falls through to "outside allowed root" for that root even though the // path is physically inside it. @@ -575,34 +640,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const return true; } -bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key, - const std::vector& call_site_ids) const -{ - if (plugin_key.empty() || call_site_ids.empty()) - return false; - - std::lock_guard lock(m_mutex); - auto it = m_approved_call_sites.find(plugin_key); - if (it == m_approved_call_sites.end()) - return false; - - for (const auto& id : call_site_ids) - if (it->second.count(id)) - return true; - return false; -} - -void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key, - const std::vector& call_site_ids) -{ - if (plugin_key.empty() || call_site_ids.empty()) - return; - - std::lock_guard lock(m_mutex); - auto& approved = m_approved_call_sites[plugin_key]; - approved.insert(call_site_ids.begin(), call_site_ids.end()); -} - // --------------------------------------------------------------------------- // The C-level audit hook // --------------------------------------------------------------------------- @@ -663,40 +700,88 @@ std::string python_str(PyObject* object) return result; } -std::vector call_site_identities(const std::string& plugin_root) -{ - std::vector ids; - if (plugin_root.empty()) - return ids; +// The interpreter's stdlib folders, and the site-packages folders inside them that hold +// third-party code, both resolved. Set by install_hook(); like the verdict cache below, only +// touched while the GIL is held. +static std::vector s_stdlib_dirs; +static std::vector s_site_package_dirs; +static std::unordered_map s_stdlib_file_verdicts; // co_filename -> is stdlib +void collect_stdlib_dirs() +{ + s_stdlib_dirs.clear(); + s_site_package_dirs.clear(); + s_stdlib_file_verdicts.clear(); + + PyObject* sysconfig = PyImport_ImportModule("sysconfig"); + PyObject* paths = sysconfig ? PyObject_CallMethod(sysconfig, "get_paths", nullptr) : nullptr; + if (paths && PyDict_Check(paths)) { + const auto collect = [paths](const char* key, std::vector& out) { + const std::string dir = python_unicode(PyDict_GetItemString(paths, key)); // borrowed + if (dir.empty()) + return; + boost::system::error_code ec; + const boost::filesystem::path resolved = boost::filesystem::weakly_canonical(dir, ec); + out.push_back(ec ? boost::filesystem::path(dir) : resolved); + }; + collect("stdlib", s_stdlib_dirs); + collect("platstdlib", s_stdlib_dirs); + collect("purelib", s_site_package_dirs); + collect("platlib", s_site_package_dirs); + } + Py_XDECREF(paths); + Py_XDECREF(sysconfig); + if (PyErr_Occurred()) { + PyErr_Clear(); + BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to read the stdlib folders from sysconfig"; + } +} + +bool is_stdlib_frame(PyObject* frame) +{ + PyCodeObject* code = PyFrame_GetCode(reinterpret_cast(frame)); // new reference + const std::string filename = python_unicode(code->co_filename); + Py_DECREF(code); + return PluginAuditManager::is_stdlib_file(filename); +} + +// The frames on this thread's Python stack, innermost first. The pointers stay valid while the +// caller runs, because a running activation keeps its frame object alive. +std::vector stack_frames() +{ + std::vector frames; PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference Py_XINCREF(frame); // normalize to an owned reference for the loop below - while (frame) { - PyCodeObject* code = PyFrame_GetCode(frame); // new reference - const std::string filename = python_unicode(reinterpret_cast(code->co_filename)); - const bool is_plugin_frame = !filename.empty() && boost::algorithm::starts_with(filename, plugin_root); - - std::string id; - if (!is_plugin_frame && !filename.empty()) { - const std::string funcname = python_unicode(reinterpret_cast(code->co_name)); - id = filename + ":" + funcname + ":" + std::to_string(code->co_firstlineno); - } - Py_DECREF(code); - + frames.push_back(reinterpret_cast(frame)); PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack Py_DECREF(frame); frame = back; - - if (is_plugin_frame) - break; - if (!id.empty()) - ids.push_back(std::move(id)); } + return frames; +} - Py_XDECREF(frame); // only holds a reference here if the loop exited via break +// Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended +// generator's frame (os.walk between two items) is off the stack, but its operation resumes, so +// it stays held until the scope ends. +void take_returned_frames(std::vector& held, std::vector& dropped) +{ + if (held.empty()) + return; - return ids; + const std::vector stack = stack_frames(); + const auto running = [&stack](PyObject* frame) { + if (std::find(stack.begin(), stack.end(), frame) != stack.end()) + return true; + // NULL once the generator has finished or been deleted: its frame then owns its own data. + PyObject* generator = PyFrame_GetGenerator(reinterpret_cast(frame)); // new reference + const bool suspended = generator != nullptr; + Py_XDECREF(generator); + return suspended; + }; + const auto returned = std::stable_partition(held.begin(), held.end(), running); + dropped.insert(dropped.end(), returned, held.end()); + held.erase(returned, held.end()); } static const std::unordered_set two_path_fs_events{ @@ -717,12 +802,15 @@ static const std::unordered_map> audit_targ {"socket.getnameinfo", {0}}, {"socket.getservbyname", {0}}, {"socket.getservbyport", {0}}, + {"socket.sendto", {1}}, + {"socket.sendmsg", {1}}, {"os.system", {0}}, {"subprocess.Popen", {1, 0}}, {"os.posix_spawn", {1, 0}}, {"os.spawn", {2, 1}}, {"os.startfile", {0}}, + {"os.startfile/2", {0}}, {"pty.spawn", {0}}, {"_winapi.CreateProcess", {1, 0}}, {"_posixsubprocess.fork_exec", {0}}, @@ -767,7 +855,11 @@ std::vector audit_targets(const std::string& event_name, AuditEvent const auto it = audit_target_arg_indices.find(event_name); if (it != audit_target_arg_indices.end()) { for (const Py_ssize_t index : it->second) { - std::string value = python_str(tuple_item(args, index)); + // None is an argument left out (sendmsg without an address), not a target named "None". + PyObject* const item = tuple_item(args, index); + if (item == Py_None) + continue; + std::string value = python_str(item); if (!value.empty()) { targets.push_back(std::move(value)); break; @@ -778,8 +870,14 @@ std::vector audit_targets(const std::string& event_name, AuditEvent return targets; } -std::vector* permission_list_for(AuditEventCategory category, PluginPermissions& permissions) +// The saved list that grants an event: its category's target list, or the event-grant list for +// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an +// os.fork grant is not an os.exec of a command named "os.fork"). +std::vector* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions) { + if (by_event) + return &permissions.events; + switch (category) { case AuditEventCategory::FsRead: return &permissions.fs_read; case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite; @@ -792,6 +890,13 @@ std::vector* permission_list_for(AuditEventCategory category, Plugi } } +// An event with no target is granted by its effective category and name, e.g. "FsRead:open" +// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open"). +std::string event_grant_key(AuditEventCategory category, const std::string& event_name) +{ + return std::string(category_name(category)) + ":" + event_name; +} + bool has_permission(const std::vector& granted, const std::string& target) { return std::find(granted.begin(), granted.end(), target) != granted.end(); @@ -902,22 +1007,41 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n // Records a grant in the plugin's sidecar so it is not asked again. Reads the install state // freshly because the async prompt outlives the caller's stack copy of it. -void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector& targets) +void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector& keys) { PluginInstallState state; if (!PluginManager::instance().get_install_state(plugin_key, state)) return; - std::vector* permission_list = permission_list_for(category, state.permissions); + std::vector* permission_list = permission_list_for(category, by_event, state.permissions); if (!permission_list) return; - for (const auto& target : targets) - persist_permission(plugin_key, state, *permission_list, target); + for (const auto& key : keys) + persist_permission(plugin_key, state, *permission_list, key); } -// Requests permission for an audited event, returning true when it is already granted or the user -// approves an inline prompt. +static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing; + +static const char* audit_category_name(AuditEventCategory category) +{ + switch (category) { + case AuditEventCategory::FsRead: return "FsRead"; + case AuditEventCategory::FsReadWrite: return "FsReadWrite"; + case AuditEventCategory::FsCreate: return "FsCreate"; + case AuditEventCategory::FsDelete: return "FsDelete"; + case AuditEventCategory::Http: return "Http"; + case AuditEventCategory::Socket: return "Socket"; + case AuditEventCategory::ProcessCreate: return "ProcessCreate"; + case AuditEventCategory::ProcessReplace: return "ProcessReplace"; + case AuditEventCategory::Threading: return "Threading"; + default: return "None"; + } +} + +// Asks the user to grant an audited event, returning true when they approve an inline prompt. +// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has +// no target (by_event), which the prompt does not show as a target. // // An audited event can fire on a thread the UI thread may itself be blocked waiting on: the // SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and @@ -929,56 +1053,227 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, c // prompt); the grant is persisted once the user accepts, so a later attempt succeeds without // re-prompting. bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name, - const std::string& event_name, const std::vector& unresolved) + const std::string& event_name, bool by_event, const std::vector& keys) { + const std::vector shown = by_event ? std::vector{} : keys; + std::string targets_text = by_event ? "" : ""; + for (const auto& target : shown) { + if (!targets_text.empty()) + targets_text += ", "; + targets_text += target; + } + + BOOST_LOG_TRIVIAL(info) << "[AUDIT PROMPT] plugin=" << plugin_key << " event=" << event_name + << " category=" << audit_category_name(category) << " targets=[" << targets_text << "]" + << (by_event && !keys.empty() ? " grant=" + keys.front() : std::string{}); + + if (s_permission_prompt_for_testing) + return s_permission_prompt_for_testing(event_name, shown); + if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) return false; if (wxIsMainThread()) - return prompt_for_targets(category, plugin_name, event_name, unresolved); + return prompt_for_targets(category, plugin_name, event_name, shown); - GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() { + GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() { if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) return; - if (prompt_for_targets(category, plugin_name, event_name, unresolved)) - persist_grant(plugin_key, category, unresolved); + if (prompt_for_targets(category, plugin_name, event_name, shown)) + persist_grant(plugin_key, category, by_event, keys); }); return false; } +// Decides an event that no allowed root or running operation covers: a saved grant allows it, +// otherwise the user is asked and a Yes is saved. int decide_audited_event(PluginAuditManager& mgr, PluginInstallState& state, const std::string& plugin_key, const std::string& plugin_name, const std::string& event_name, AuditEventCategory category, - const std::vector& targets, - std::vector* permission_list, - const std::vector& call_site_ids) + const std::vector& targets) { - std::vector unresolved = targets; + const bool by_event = targets.empty(); + const std::vector keys = by_event ? std::vector{event_grant_key(category, event_name)} : targets; + std::vector* permission_list = permission_list_for(category, by_event, state.permissions); + + std::vector unresolved = keys; if (permission_list) { unresolved.clear(); - for (const auto& target : targets) - if (!has_permission(*permission_list, target)) - unresolved.push_back(target); - if (!targets.empty() && unresolved.empty()) + for (const auto& key : keys) + if (!has_permission(*permission_list, key)) + unresolved.push_back(key); + if (unresolved.empty()) return 0; } - if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved)) + // Letting a root frame go runs finalizers inside a scope destructor or another event's hook, + // where no dialog may open: what they do must already be granted. + if (mgr.releasing_frames()) + return report_denied(mgr, event_name, {false, "audit permission required"}); + + if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved)) return report_denied(mgr, event_name, {false, "audit permission required"}); if (permission_list) - for (const auto& target : unresolved) - persist_permission(plugin_key, state, *permission_list, target); - - mgr.record_approved_call_sites(plugin_key, call_site_ids); + for (const auto& key : unresolved) + persist_permission(plugin_key, state, *permission_list, key); return 0; } +// Judges an audited event raised while a plugin context is active. +int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args) +{ + const bool fs_category = is_fs_category(event_type); + const std::vector targets = audit_targets(event_name, event_type, args); + + // A denied path (secrets, certificates, config files -- see is_denied_path) is an + // unconditional block: checked before the running-operation check below, so no granted + // operation can launder access to one, and before the allowed-root shortcut further down, + // so an allowed root (e.g. the read-only resources folder) cannot make a denied path + // underneath it reachable. + if (fs_category) { + for (const auto& target : targets) { + if (mgr.is_denied_path(boost::filesystem::path(target))) + return report_denied(mgr, event_name, {false, "denied path"}, target); + } + } + + // A nested event of an operation already granted, e.g. socket.connect inside an approved + // urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a + // redirect or a nested urlopen() inside a running request is not covered by it. + if (event_name != "urllib.Request" && mgr.covered_by_root_frame()) + return 0; + + // A filesystem target that resolves entirely inside a pre-determined allowed root -- the + // plugin system's own data_dir() tree (which holds each plugin's storage folder and the + // installed/system profile cache), the read-only bundled resources folder, or a per-call + // scoped root such as the current G-code folder -- is part of the plugin system's normal + // workflow and does not need a prompt. + if (fs_category && !targets.empty()) { + const bool is_write = event_type != AuditEventCategory::FsRead; + const bool all_inside_allowed_root = + std::all_of(targets.begin(), targets.end(), [&](const std::string& target) { + return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed; + }); + if (all_inside_allowed_root) + return 0; + } + + PluginInstallState state; + const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state); + if (!have_install_state) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin(); + } + + const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name; + + const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets); + + // A granted operation covers its nested events while it runs. A targetless grant names no + // operation, so it covers nothing else. + if (result == 0 && !targets.empty() && operation_root_events.count(event_name)) + mgr.record_root_frame(); + return result; +} + } // namespace PluginAuditDetail +void PluginAuditManager::record_root_frame() +{ + PyObject* frame = reinterpret_cast(PyEval_GetFrame()); // borrowed reference + if (!frame || !PluginAuditDetail::is_stdlib_frame(frame)) + return; + if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end()) + return; // glob.glob and glob.glob/2 share a frame + Py_INCREF(frame); + m_root_frames.push_back(frame); +} + +bool PluginAuditManager::covered_by_root_frame() const +{ + if (m_root_frames.empty()) + return false; + + // Only stdlib frames may sit between the event and the operation: plugin or library code + // the operation calls back into (an os.walk onerror, a urllib handler) acts on its own. + for (PyObject* frame : PluginAuditDetail::stack_frames()) { + if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end()) + return true; + if (!PluginAuditDetail::is_stdlib_frame(frame)) + return false; + } + return false; +} + +bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; } + +bool PluginAuditManager::is_stdlib_file(const std::string& filename) +{ + // Frozen stdlib modules (os, posixpath, codecs, ...) carry a pseudo filename. + if (boost::algorithm::starts_with(filename, "& dirs) { + return std::any_of(dirs.begin(), dirs.end(), + [&file](const boost::filesystem::path& dir) { return is_inside_allowed_root(file, dir); }); + }; + it->second = inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs); + } + return it->second; +} + +bool PluginAuditManager::is_stdlib_path(const boost::filesystem::path& path) +{ + if (path.empty()) + return false; + + const auto inside_any = [&path](const std::vector& dirs) { + return std::any_of(dirs.begin(), dirs.end(), + [&path](const boost::filesystem::path& dir) { return is_inside_allowed_root(path, dir); }); + }; + return inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs); +} + +void PluginAuditManager::release_frames(std::vector& frames) +{ + if (frames.empty()) + return; + + // A finalizer's own denial must neither replace the PermissionError of the event being + // decided nor leave a stale denial behind for the next call. + PyObject* const error = PyErr_GetRaisedException(); + const bool denial_pending = m_audit_denial_pending; + const bool has_violation = m_has_last_violation; + const AuditViolation violation = m_last_violation; + + std::vector released; + released.swap(frames); + ++m_releasing_frames; + for (PyObject* frame : released) + Py_DECREF(frame); + --m_releasing_frames; + + m_audit_denial_pending = denial_pending; + m_has_last_violation = has_violation; + m_last_violation = violation; + PyErr_SetRaisedException(error); +} + +void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt) +{ + PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt); +} + int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) { auto* mgr = static_cast(user_data); @@ -1006,62 +1301,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user if (event_type == AuditEventCategory::None) return 0; - const bool fs_category = is_fs_category(event_type); - const std::vector targets = PluginAuditDetail::audit_targets(event_name, event_type, args); - - // A denied path (secrets, certificates, config files -- see is_denied_path) is an - // unconditional block: checked before the ancestor-cascade check below, so a cascade - // approval recorded for an unrelated action can never launder access to one, and before - // the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources - // folder) cannot make a denied path underneath it reachable. - if (fs_category) { - for (const auto& target : targets) { - if (mgr->is_denied_path(boost::filesystem::path(target))) - return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target); - } - } - - PluginDescriptor plugin_descriptor; - PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor); - - // Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request - // was already approved, socket.connect won't trigger another permission dialog. - const std::vector call_site_ids = - PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root); - if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids)) - return 0; - - // A filesystem target that resolves entirely inside a pre-determined allowed root -- the - // plugin system's own data_dir() tree (which holds each plugin's storage folder and the - // installed/system profile cache), the read-only bundled resources folder, or a per-call - // scoped root such as the current G-code folder -- is part of the plugin system's normal - // workflow and does not need a prompt. - if (fs_category && !targets.empty()) { - const bool is_write = event_type != AuditEventCategory::FsRead; - const bool all_inside_allowed_root = - std::all_of(targets.begin(), targets.end(), [&](const std::string& target) { - return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed; - }); - if (all_inside_allowed_root) - return 0; - } - - PluginInstallState state; - const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state); - if (!have_install_state) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr->current_plugin(); - } - - const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name; - - std::vector* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions); - - return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name, - event_type, targets, permission_list, call_site_ids); + // Root frames whose operation has returned are let go once this event is decided, so that a + // finalizer they run re-enters a hook whose held frames are already consistent. + std::vector returned_frames; + PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames); + const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args); + mgr->release_frames(returned_frames); + return result; } void PluginAuditManager::install_hook() { + // Identifies stdlib frames for operation roots and stdlib paths for read-only access. + PluginAuditDetail::collect_stdlib_dirs(); + // data_dir() is the primary globally-allowed root during plugin execution: read+write. It // covers the plugin system's own workflow needs -- each plugin's storage folder // (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) -- @@ -1076,8 +1329,9 @@ void PluginAuditManager::install_hook() // "cert" denied-path keyword seeded below. add_global_allowed_root(resources_dir(), /*allow_write=*/false); - // The user's app config and cloud credentials live directly inside data_dir(), so the - // root just granted would otherwise expose them to any plugin. Deny them by name. + // The user's app config and cloud credentials live directly inside data_dir(), and plugin + // install state lives beneath it, so the root just granted would otherwise expose them to + // plugins. Deny them by name. // // Seeded here rather than by each secret's owner because install_hook() runs during lazy // interpreter init and therefore provably precedes any plugin bytecode, whereas diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 41d3dca1e8..af4172d7f1 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -4,10 +4,9 @@ // Via pybind11 so this file requests the same python3xx.lib as everything else. #include #include +#include #include #include -#include -#include #include #include @@ -144,15 +143,26 @@ public: void clear_last_violation(); bool last_violation(AuditViolation& violation) const; - // --- call-site cascade cache --- - // A single plugin action often fires several nested CPython audit events as it passes - // through stdlib layers (urllib.request calling http.client calling socket, for example). - // Once the user approves one event, every stdlib frame still on the stack for that call - // is recorded here by (filename, function, first line) identity. A later event whose own - // ancestor chain still contains one of those frames is the same logical action seen from - // a deeper layer, so it is auto-approved instead of prompting again. - bool has_approved_ancestor(const std::string& plugin_key, const std::vector& call_site_ids) const; - void record_approved_call_sites(const std::string& plugin_key, const std::vector& call_site_ids); + // --- operation-root frames --- + // One stdlib operation fires several nested audit events as it passes through lower layers: + // urlopen() raises urllib.Request, then http.client and socket events. When an operation's + // own root event is granted (by a saved grant or the user), the stdlib frame that raised it + // is held, and a later event whose stack reaches that frame through stdlib frames only is + // part of the same operation, so it is allowed without a prompt of its own. Plugin or + // library code the operation calls back into is judged on its own. A frame is let go once it + // returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may + // run finalizers of objects its locals held; they are judged as the plugin and never prompt. + void record_root_frame(); + bool covered_by_root_frame() const; + bool releasing_frames() const; + + // True when code from `filename` (a code object's co_filename) is the interpreter's own + // stdlib: the only code a root frame's coverage passes through. Valid after install_hook(). + static bool is_stdlib_file(const std::string& filename); + + // Answers permission prompts in place of the dialog, for tests. Production never sets it. + using PermissionPrompt = std::function& targets)>; + static void set_permission_prompt_for_testing(PermissionPrompt prompt); bool verbose_events = true; @@ -163,24 +173,37 @@ private: static int audit_hook(const char* event, PyObject* args, void* user_data); + // True for paths inside the interpreter's stdlib, excluding site-packages. Unlike + // is_stdlib_file(), this query is not cached because filesystem paths may resolve through + // symlinks that can change. + static bool is_stdlib_path(const boost::filesystem::path& path); + + // Drops the references in `frames`, keeping the caller's error and denial state intact. + void release_frames(std::vector& frames); + static thread_local std::string m_current_plugin_key; static thread_local std::string m_current_capability_name; static thread_local std::vector m_scoped_allowed_roots; + static thread_local std::vector m_root_frames; // strong references + static thread_local int m_releasing_frames; static thread_local bool m_audit_denial_pending; static thread_local bool m_has_last_violation; static thread_local AuditViolation m_last_violation; - // mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock. + // mutable: is_denied_filename() is a const query that must lock. mutable std::mutex m_mutex; std::vector m_global_allowed_roots; std::vector m_denied_filenames; std::vector m_denied_path_keywords; - std::unordered_map> m_approved_call_sites; // plugin_key -> call-site ids }; // RAII guard that sets the current plugin key and capability name, restoring the previous // pair on scope exit. `capability_name` may be empty for calls that are not scoped to a // single capability. +// +// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before +// restoring the outer context. That needs the GIL whenever a frame is still held, so construct +// the scope after the GIL guard, to be destroyed before it. class ScopedPluginAuditContext { public: @@ -194,9 +217,12 @@ public: ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete; private: + std::string m_plugin_key; + std::string m_capability; std::string m_previous_id; std::string m_previous_capability; std::vector m_previous_scoped_roots; + std::vector m_previous_root_frames; }; } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index 1dd8391ad7..00077d3f65 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -808,6 +808,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall read_string_list("network_socket", parsed.permissions.network_socket); read_string_list("process", parsed.permissions.process); read_string_list("threading", parsed.permissions.threading); + read_string_list("events", parsed.permissions.events); } if (state.contains("enabled") && state["enabled"].is_boolean()) @@ -852,6 +853,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin {"network_socket", state.permissions.network_socket}, {"process", state.permissions.process}, {"threading", state.permissions.threading}, + {"events", state.permissions.events}, }; nlohmann::json capabilities = nlohmann::json::array(); diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index 9c864ed73a..ee61d8c957 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -104,6 +104,8 @@ struct PluginPermissions std::vector network_socket; std::vector process; std::vector threading; + // Grants for audit events that expose no target, keyed ":" (e.g. "Socket:socket.__new__"). + std::vector events; }; struct PluginInstallState { diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index 915c578353..6d7ca9135c 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -102,9 +102,9 @@ public: // mistake), both fall back to the base's empty object rather than writing `"cap_config": null`. nlohmann::json get_default_config() const override { + pybind11::gil_scoped_acquire gil; ORCA_PY_AUDIT_SCOPE(); try { - pybind11::gil_scoped_acquire gil; pybind11::function override = pybind11::get_override(static_cast(this), "get_default_config"); if (!override) return Base::get_default_config(); diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp index 790f6e3ac6..812d69c072 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp @@ -282,11 +282,11 @@ public: int request_bind_ticket(std::string* ticket) override { try { - ORCA_PY_AUDIT_SCOPE(); ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); ::Slic3r::PythonGILState gil; if (!gil) throw std::runtime_error("Python interpreter is shutting down"); + ORCA_PY_AUDIT_SCOPE(); pybind11::function override = pybind11::get_override(static_cast(this), "request_bind_ticket"); if (!override) diff --git a/tests/fff_print/test_print.cpp b/tests/fff_print/test_print.cpp index 670c2aa7c1..a9654809c6 100644 --- a/tests/fff_print/test_print.cpp +++ b/tests/fff_print/test_print.cpp @@ -22,6 +22,7 @@ #include "libslic3r/TriangleMesh.hpp" #include #include +#include #include @@ -794,6 +795,20 @@ TEST_CASE("Print statistics in custom G-code match the final print statistics", } } +TEST_CASE("Print statistics in custom G-code cannot be used in a calculation", "[Print]") +{ + const std::string key = GENERATE(as{}, "total_weight", "used_filament", "total_cost", "print_time", "normal_print_time", + "silent_print_time", "total_wipe_tower_cost", "total_wipe_tower_filament", "print_time_total_sec", + "print_time_day", "print_time_hour", "print_time_minute", "print_time_sec", "used_filament_length"); + CAPTURE(key); + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ { "file_start_gcode", "; {" + key + " + 1}\n" } }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + CHECK_THROWS_WITH(Slic3r::Test::gcode(print), Catch::Matchers::ContainsSubstring("known only after the G-code is generated")); +} + TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]") { Print print; diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 6ab08f797f..44afdece7c 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -42,6 +42,7 @@ add_executable(${_TEST_NAME}_tests test_fill_plane_path.cpp test_adaptive_infill_field.cpp test_geometry.cpp + test_kdtree.cpp test_imex_arrange.cpp test_imex_helpers.cpp test_imex_zones.cpp diff --git a/tests/libslic3r/test_clipper_utils.cpp b/tests/libslic3r/test_clipper_utils.cpp index c9026a9ba2..4f8d872ae5 100644 --- a/tests/libslic3r/test_clipper_utils.cpp +++ b/tests/libslic3r/test_clipper_utils.cpp @@ -7,12 +7,18 @@ #include "libslic3r/libslic3r.h" #include #include +#include #include #include #include +#include +#include #include +#include #include +#include +#include #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/ExPolygon.hpp" #include "libslic3r/Point.hpp" @@ -300,3 +306,331 @@ TEST_CASE("Top level expolygons of an even-odd union", "[ClipperUtils]") { REQUIRE(area_sum == Catch::Approx(area(top_level) + area(nested))); REQUIRE(top_level_expolygons(reference).size() == 1); } + +// Rings flattened to x,y,x,y... and sorted, with each ring rotated to start at its lowest point: two +// encodings of the same geometry compare equal however the pieces came back or wherever a ring started. +static std::vector> canonical_rings(const ExPolygons &expolygons) +{ + std::vector> rings; + const auto add = [&rings](const Polygon &poly) { + if (poly.points.empty()) + return; + Points pts = poly.points; + std::rotate(pts.begin(), + std::min_element(pts.begin(), pts.end(), [](const Point &a, const Point &b) { + return std::make_pair(a.x(), a.y()) < std::make_pair(b.x(), b.y()); + }), + pts.end()); + std::vector flat; + flat.reserve(pts.size() * 2); + for (const Point &p : pts) { + flat.emplace_back(p.x()); + flat.emplace_back(p.y()); + } + rings.emplace_back(std::move(flat)); + }; + for (const ExPolygon &expoly : expolygons) { + add(expoly.contour); + for (const Polygon &hole : expoly.holes) + add(hole); + } + std::sort(rings.begin(), rings.end()); + return rings; +} + +// The same rings, every coordinate within `tolerance`. +static bool same_rings(const ExPolygons &a, const ExPolygons &b, coord_t tolerance) +{ + const std::vector> ra = canonical_rings(a), rb = canonical_rings(b); + return std::equal(ra.begin(), ra.end(), rb.begin(), rb.end(), [tolerance](const std::vector &x, const std::vector &y) { + return std::equal(x.begin(), x.end(), y.begin(), y.end(), [tolerance](coord_t u, coord_t v) { return std::abs(u - v) <= tolerance; }); + }); +} + +TEST_CASE("Tiled diff and intersection return the same polygons as the plain calls", "[ClipperUtils]") { + // A grid of disjoint framed squares, enough of them to be split into several tiles. + const int n = 40; + const coord_t cell = scaled(2.), side = scaled(1.5), frame = scaled(0.3); + ExPolygons subject; + for (int y = 0; y < n; ++ y) + for (int x = 0; x < n; ++ x) { + const Point o(x * cell, y * cell); + ExPolygon square(Polygon({ o, o + Point(side, 0), o + Point(side, side), o + Point(0, side) })); + Polygon hole({ o + Point(frame, frame), o + Point(frame, side - frame), o + Point(side - frame, side - frame), o + Point(side - frame, frame) }); + square.holes.emplace_back(std::move(hole)); + subject.emplace_back(std::move(square)); + } + // Clip polygons crossing many squares, one of them large with holes of its own. + Polygons clip; + const coord_t span = n * cell; + for (int i = 0; i < 8; ++ i) { + const coord_t y0 = coord_t(i) * span / 8, y1 = y0 + scaled(0.9); + clip.emplace_back(Polygon({ Point(- cell, y0), Point(span, y0 + cell * 3), Point(span, y1 + cell * 3), Point(- cell, y1) })); + } + ExPolygon big(Polygon({ Point(span / 4, span / 4), Point(3 * span / 4, span / 4), Point(3 * span / 4, 3 * span / 4), Point(span / 4, 3 * span / 4) })); + for (int i = 0; i < 4; ++ i) { + const Point o(span / 4 + scaled(3.1) + i * scaled(9.7), span / 4 + scaled(5.3)); + big.holes.emplace_back(Polygon({ o, o + Point(0, scaled(20.)), o + Point(scaled(5.), scaled(20.)), o + Point(scaled(5.), 0) })); + } + polygons_append(clip, to_polygons(big)); + + const ApplySafetyOffset safety = GENERATE(ApplySafetyOffset::No, ApplySafetyOffset::Yes); + + // The point of the fixture: below 128 pieces the helpers fall back to a single tile and the tiled + // path under test is never taken. + REQUIRE(ClipperUtils::tile_expolygons(subject, 32).size() > 1); + + // With the safety offset a tile unites fewer clip polygons, so Clipper2 can round a crossing 1 unit differently. + const coord_t tolerance = safety == ApplySafetyOffset::Yes ? 1 : 0; + + const ExPolygons diff_plain = diff_ex(subject, clip, safety); + const ExPolygons diff_tiled = diff_ex_by_piece(subject, clip, safety); + REQUIRE(area(diff_plain) > 0.); + CHECK(same_rings(diff_tiled, diff_plain, tolerance)); + + const ExPolygons intersection_plain = intersection_ex(subject, clip, safety); + const ExPolygons intersection_tiled = intersection_ex_by_piece(subject, clip, safety); + REQUIRE(area(intersection_plain) > 0.); + CHECK(same_rings(intersection_tiled, intersection_plain, tolerance)); +} + +// An outline with a small clockwise hole well inside it, cut along its edge by three clip polygons that never reach the +// hole. Clipper2 puts the hole at the top level of its PolyTree, beside the outline rather than under it. The coordinates +// come from a sliced layer reduced to the few paths that still do this; most changes to them don't. +static const Polygons misnesting_subject{ + Polygon({ { 23000000, 31000000 }, { 7500000, 25900000 }, { 200000, 26450004 }, { 0, 26450004 }, { 0, 26550004 }, + { -100000, 26550004 }, { -300000, 26600000 }, { 13000000, 0 } }), + Polygon({ { 10200000, 24250000 }, { 10500000, 24250000 }, { 9800000, 24000000 } }), + Polygon({ { 4500000, 22500000 }, { 5000000, 22000000 }, { 4000000, 22000000 } }), +}; +static const Polygons misnesting_clip{ + Polygon({ { 0, 26550004 }, { -200000, 26550004 }, { -200000, 26000000 } }), + Polygon({ { 0, 26450004 }, { 100000, 26450004 }, { 0, 26000000 } }), + Polygon({ { 10000000, 24000000 }, { 10199999, 24250000 }, { 10500000, 24250000 }, { 8000000, 26500000 } }), +}; + +// The case above inside a frame, a square outline with a square hole around the case, and with an island in its hole. +// Clipper2 then puts the hole beside the outline inside the frame's hole, where the frame and the outline both enclose +// it, and the island under the misplaced hole. +static Polygons misnesting_framed_subject() +{ + Polygons subject = misnesting_subject; + subject.emplace_back(Polygon({ { -2000000, -2000000 }, { 25000000, -2000000 }, { 25000000, 33000000 }, { -2000000, 33000000 } })); + subject.emplace_back(Polygon({ { -1000000, -1000000 }, { -1000000, 32000000 }, { 24000000, 32000000 }, { 24000000, -1000000 } })); + subject.emplace_back(Polygon({ { 4400000, 22100000 }, { 4600000, 22100000 }, { 4500000, 22300000 } })); + return subject; +} +// In the hole of the framed case, beside its island. +static const Point in_framed_hole(4500000, 22400000); +// In the island of the framed case. +static const Point in_framed_island(4500000, 22150000); + +static void check_orientation(const ExPolygons &expolygons) +{ + for (const ExPolygon &expoly : expolygons) { + CHECK(expoly.contour.is_counter_clockwise()); + for (const Polygon &hole : expoly.holes) + CHECK(hole.is_clockwise()); + } +} + +// How many of the expolygons contain the point, each on its own, as a per-piece operation sees them. +static size_t pieces_containing(const ExPolygons &expolygons, const Point &pt) +{ + return std::count_if(expolygons.begin(), expolygons.end(), [&pt](const ExPolygon &expoly) { return expoly.contains(pt); }); +} + +TEST_CASE("Every hole of a boolean result belongs to the expolygon around it", "[ClipperUtils]") { + const Polygon &hole = misnesting_subject.back(); + REQUIRE(hole.is_clockwise()); + const ExPolygons result = diff_ex(misnesting_subject, misnesting_clip); + check_orientation(result); + CHECK(pieces_containing(result, hole.centroid()) == 0); + CHECK_THAT(area(result), Catch::Matchers::WithinRel(area(diff(misnesting_subject, misnesting_clip)), 1e-9)); +} + +TEST_CASE("A misplaced hole goes to the innermost contour around it, an island in it stays filled", "[ClipperUtils]") { + const Polygons subject = misnesting_framed_subject(); + const ExPolygons result = diff_ex(subject, misnesting_clip); + check_orientation(result); + // Given to the frame instead of the outline, the hole would leave the outline covering it. + CHECK(pieces_containing(result, in_framed_hole) == 0); + CHECK(pieces_containing(result, in_framed_island) == 1); + CHECK_THAT(area(result), Catch::Matchers::WithinRel(area(diff(subject, misnesting_clip)), 1e-9)); +} + +TEST_CASE("A misplaced hole touching the contour around it goes to that contour", "[ClipperUtils]") { + // The first two vertices of the hole the subject's triangle cuts round a fraction of a unit outside the outline, the + // third is well inside it. Reduced from a sliced layer; Clipper2 puts the hole at the top level of its PolyTree. + const Polygons subject{ + Polygon({ { 1000000, 5000000 }, { 1000000, 4000000 }, { 6000000, 0 } }), + Polygon({ { 1024999, 4500000 }, { 1100011, 4574998 }, { 1100011, 4450000 } }), + }; + const Polygons clip{ + Polygon({ { 1500000, 5000000 }, { 1062508, 4612507 }, { 1212486, 4462504 } }), + Polygon({ { 1100000, 4430000 }, { 1024998, 4500001 }, { 2000000, 3000000 } }), + }; + const ExPolygons result = diff_ex(subject, clip); + check_orientation(result); + CHECK(pieces_containing(result, subject.back().centroid()) == 0); + CHECK_THAT(area(result), Catch::Matchers::WithinRel(area(diff(subject, clip)), 1e-9)); +} + +TEST_CASE("A misplaced island comes out as an expolygon of its own", "[ClipperUtils]") { + // Clipper2 puts the island of this result directly under the outer contour, at the level of its holes, instead of + // under the hole around it. Reduced from a sliced layer. + const Polygons subject{ + Polygon({ { 5200000, 700000 }, { 0, 5000000 }, { 0, 0 } }), + Polygon({ { 3100000, 825000 }, { 3250000, 900001 }, { 3250000, 1000000 }, { 4000000, 1500000 }, { 3000000, 500000 } }), + }; + const Polygons clip{ + Polygon({ { 4500000, 1200000 }, { 4000000, 1000000 }, { 5000000, 800000 } }), + Polygon({ { 3250000, 900001 }, { 3250000, 1000000 }, { 3000000, 1000000 }, { 3500000, 1100000 }, { 3100000, 1000000 }, { 3183343, 866672 } }), + Polygon({ { 4000000, 1000000 }, { 4000000, 1000000 }, { 4000000, 1000000 } }), + }; + const ExPolygons result = diff_ex(subject, clip); + check_orientation(result); + // In the island. + CHECK(pieces_containing(result, Point(3230000, 1020000)) == 1); + CHECK_THAT(area(result), Catch::Matchers::WithinRel(area(diff(subject, clip)), 1e-9)); +} + +TEST_CASE("Nested islands of an even-odd union keep their misplaced holes", "[ClipperUtils]") { + // The framed case as one even-odd union: the frame is the top level, everything in its hole is nested, and Clipper2 + // puts the case's hole beside the outline among the nested islands. + ExPolygons input; + for (const Polygon &polygon : misnesting_framed_subject()) + input.emplace_back(polygon); + for (const Polygon &polygon : misnesting_clip) + input.emplace_back(polygon); + ExPolygons nested; + const ExPolygons top_level = top_level_expolygons(input, &nested); + REQUIRE(top_level.size() == 1); + check_orientation(nested); + CHECK(pieces_containing(nested, in_framed_hole) == 0); + CHECK(pieces_containing(nested, in_framed_island) == 1); +} + +TEST_CASE("Loops chained outside in keep their nesting where Clipper2 misplaces one", "[ClipperUtils]") { + // An even-odd union of the case's paths leaves the hole at the top level of the PolyTree, among the outer loops. + Polygons loops = misnesting_subject; + polygons_append(loops, misnesting_clip); + const Polygons chained = union_pt_chained_outside_in(loops); + REQUIRE(chained.size() == 7); + for (const Polygon &loop : chained) + CHECK(loop.is_counter_clockwise()); + // The hole's loop comes right after the outline's, which has no other loop inside it. + const auto outline = std::max_element(chained.begin(), chained.end(), [](const Polygon &a, const Polygon &b) { return a.area() < b.area(); }); + REQUIRE(outline + 1 != chained.end()); + CHECK((outline + 1)->area() == Catch::Approx(- misnesting_subject.back().area())); +} + +TEST_CASE("A misplaced hole hugging an island inside it still goes to the outline", "[ClipperUtils]") { + // The case's hole with an island filling most of it, 20 units from it: the hole's vertices all lie close to the + // island and its middle inside it, yet the island is inside the hole, not around it. + Polygons subject = misnesting_subject; + subject.emplace_back(Polygon({ { 4000020, 22000010 }, { 4500000, 22150000 }, { 4999980, 22000010 }, + { 4650000, 22160000 }, { 4500000, 22499980 }, { 4350000, 22160000 } })); + const ExPolygons result = diff_ex(subject, misnesting_clip); + check_orientation(result); + // In the hole below the island, and in the island. + CHECK(pieces_containing(result, Point(4500000, 22050000)) == 0); + CHECK(pieces_containing(result, Point(4500000, 22300000)) == 1); + CHECK_THAT(area(result), Catch::Matchers::WithinRel(area(diff(subject, misnesting_clip)), 1e-9)); +} + +TEST_CASE("A concave misplaced hole touching the outline at every vertex goes to it", "[ClipperUtils]") { + // The case with the outline drawn in to two more vertices and the hole replaced by a concave pentagon, each of its + // vertices within 3 units of the outline; the middle of its bounding box is outside both. + Polygons subject = misnesting_subject; + subject.front().points.insert(subject.front().points.end(), { { 7000000, 20000000 }, { 20000000, 22000000 } }); + subject.back() = Polygon({ { 1500002, 22999999 }, { 20333332, 22999999 }, { 19999999, 22000001 }, { 6999998, 20000001 }, { 12999995, 13 } }); + const ExPolygons result = diff_ex(subject, misnesting_clip); + check_orientation(result); + CHECK(pieces_containing(result, Point(10000000, 22500000)) == 0); + CHECK_THAT(area(result), Catch::Matchers::WithinRel(area(diff(subject, misnesting_clip)), 1e-9)); +} + +TEST_CASE("A point strictly inside a polygon, where there is one", "[ClipperUtils]") { + // A notch with a horizontal edge at the middle of its bounding box, so a line through that middle runs along the edge; + // two lobes joined by a one unit neck and a square with a one unit spike, where the widest band between vertex heights + // runs through the thin part. + const Polygons wide{ + Polygon({ { 0, 0 }, { 2000000, 0 }, { 2000000, 5000000 }, { 8000000, 5000000 }, { 8000000, 0 }, { 10000000, 0 }, + { 10000000, 10000000 }, { 0, 10000000 } }), + Polygon({ { 0, 0 }, { 100, 0 }, { 100, 5 }, { 51, 5 }, { 51, 25 }, { 100, 25 }, { 100, 30 }, { 0, 30 }, { 0, 25 }, + { 50, 25 }, { 50, 5 }, { 0, 5 } }), + Polygon({ { 0, 0 }, { 1000000, 0 }, { 1000000, 1000000 }, { 500001, 1000000 }, { 500001, 5000000 }, { 500000, 5000000 }, + { 500000, 1000000 }, { 0, 1000000 } }), + }; + for (Polygon polygon : wide) + for (int winding = 0; winding < 2; ++ winding, polygon.reverse()) { + const std::optional pt = ClipperUtils::interior_point(polygon); + REQUIRE(pt); + CHECK(contains(polygon, *pt, false)); + } + // One unit thick everywhere, these hold no grid point strictly inside: a strip, and rows one unit high joined at the + // left. + CHECK_FALSE(ClipperUtils::interior_point(Polygon({ { 0, 0 }, { 1000, 0 }, { 1000, 1 }, { 0, 1 } }))); + Polygon rows({ { 0, 0 }, { 100, 0 } }); + for (coord_t y = 1; y < 100; y += 2) + rows.points.insert(rows.points.end(), { { 100, y }, { 1, y }, { 1, y + 1 }, { 100, y + 1 } }); + rows.points.emplace_back(0, 100); + CHECK_FALSE(ClipperUtils::interior_point(rows)); +} + +TEST_CASE("A correctly nested result converts in the order of its tree", "[ClipperUtils]") { + // Two squares, one with a hole holding an island, the other with two holes and a corner cut off. Clipper2 nests this + // correctly, so the expolygons follow its tree, each path as Clipper2 returned it: an outline with its holes in tree + // order, then the islands of those holes, outline by outline. + const Polygons subject{ + Polygon({ { 0, 0 }, { 10000000, 0 }, { 10000000, 10000000 }, { 0, 10000000 } }), + Polygon({ { 2000000, 2000000 }, { 2000000, 8000000 }, { 8000000, 8000000 }, { 8000000, 2000000 } }), + Polygon({ { 4000000, 4000000 }, { 6000000, 4000000 }, { 6000000, 6000000 }, { 4000000, 6000000 } }), + Polygon({ { 20000000, 0 }, { 32000000, 0 }, { 32000000, 12000000 }, { 20000000, 12000000 } }), + Polygon({ { 21000000, 1000000 }, { 21000000, 4000000 }, { 24000000, 4000000 }, { 24000000, 1000000 } }), + Polygon({ { 26000000, 6000000 }, { 26000000, 10000000 }, { 30000000, 10000000 }, { 30000000, 6000000 } }), + }; + const Polygons clip{ Polygon({ { 31000000, -1000000 }, { 33000000, -1000000 }, { 33000000, 1000000 }, { 31000000, 1000000 } }) }; + ExPolygons expected(3); + expected[0].contour = Polygon({ { 31000000, 1000000 }, { 32000000, 1000000 }, { 32000000, 12000000 }, { 20000000, 12000000 }, + { 20000000, 0 }, { 31000000, 0 } }); + expected[0].holes = { Polygon({ { 26000000, 10000000 }, { 30000000, 10000000 }, { 30000000, 6000000 }, { 26000000, 6000000 } }), + Polygon({ { 21000000, 4000000 }, { 24000000, 4000000 }, { 24000000, 1000000 }, { 21000000, 1000000 } }) }; + expected[1].contour = Polygon({ { 10000000, 10000000 }, { 0, 10000000 }, { 0, 0 }, { 10000000, 0 } }); + expected[1].holes = { Polygon({ { 2000000, 8000000 }, { 8000000, 8000000 }, { 8000000, 2000000 }, { 2000000, 2000000 } }) }; + expected[2].contour = Polygon({ { 6000000, 6000000 }, { 4000000, 6000000 }, { 4000000, 4000000 }, { 6000000, 4000000 } }); + CHECK(diff_ex(subject, clip) == expected); +} + +TEST_CASE("Loops of a correctly nested union chain in the order of its tree", "[ClipperUtils]") { + // Three nested squares and two more beside them, as concentric fill hands them over: chained outside in, depth first, + // each loop as Clipper2 returned it, the holes reversed to run counter-clockwise. + const Polygons loops{ + Polygon({ { 0, 0 }, { 10000000, 0 }, { 10000000, 10000000 }, { 0, 10000000 } }), + Polygon({ { 1000000, 1000000 }, { 9000000, 1000000 }, { 9000000, 9000000 }, { 1000000, 9000000 } }), + Polygon({ { 2000000, 2000000 }, { 8000000, 2000000 }, { 8000000, 8000000 }, { 2000000, 8000000 } }), + Polygon({ { 20000000, 0 }, { 32000000, 0 }, { 32000000, 12000000 }, { 20000000, 12000000 } }), + Polygon({ { 21000000, 1000000 }, { 31000000, 1000000 }, { 31000000, 11000000 }, { 21000000, 11000000 } }), + }; + const Polygons expected{ + Polygon({ { 10000000, 10000000 }, { 0, 10000000 }, { 0, 0 }, { 10000000, 0 } }), + Polygon({ { 9000000, 1000000 }, { 9000000, 9000000 }, { 1000000, 9000000 }, { 1000000, 1000000 } }), + Polygon({ { 8000000, 8000000 }, { 2000000, 8000000 }, { 2000000, 2000000 }, { 8000000, 2000000 } }), + Polygon({ { 32000000, 12000000 }, { 20000000, 12000000 }, { 20000000, 0 }, { 32000000, 0 } }), + Polygon({ { 31000000, 1000000 }, { 31000000, 11000000 }, { 21000000, 11000000 }, { 21000000, 1000000 } }), + }; + CHECK(union_pt_chained_outside_in(loops) == expected); +} + +TEST_CASE("A clockwise sliver with nothing around it comes back as Clipper2 returned it", "[ClipperUtils]") { + // Rounding leaves a clockwise sliver of half a square unit beside the result's one piece, outside it. Reduced from a + // sliced layer. It is no hole of anything, so it stays a contour of its own, neither dropped nor given to the piece. + const Polygons subject{ Polygon({ { 875000, 200000 }, { 900002, 175002 }, { 950001, 150004 } }) }; + const Polygons clip{ Polygon({ { 879409, 185299 }, { 950000, 150004 }, { 1000000, 200000 } }) }; + const ExPolygons result = diff_ex(subject, clip); + REQUIRE(result.size() == 2); + CHECK(std::count_if(result.begin(), result.end(), [](const ExPolygon &expoly) { return expoly.contour.is_clockwise() && expoly.holes.empty(); }) == 1); + CHECK(std::all_of(result.begin(), result.end(), [](const ExPolygon &expoly) { return expoly.holes.empty(); })); + CHECK_THAT(area(result), Catch::Matchers::WithinRel(area(diff(subject, clip)), 1e-9)); +} diff --git a/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index 8017c60dcf..dcf808ab92 100644 --- a/tests/libslic3r/test_config.cpp +++ b/tests/libslic3r/test_config.cpp @@ -1263,6 +1263,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") { REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys)); } +TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") { + using Catch::Matchers::WithinAbs; + + SECTION("float") { + ConfigOptionFloat opt(7.5); + REQUIRE_FALSE(opt.deserialize("abc")); + CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9)); + } + SECTION("int") { + ConfigOptionInt opt(3); + REQUIRE_FALSE(opt.deserialize("abc")); + CHECK(opt.value == 3); + } + SECTION("percent") { + ConfigOptionPercent opt(40.); + REQUIRE_FALSE(opt.deserialize("abc%")); + CHECK_THAT(opt.value, WithinAbs(40., 1e-9)); + } + SECTION("float or percent") { + ConfigOptionFloatOrPercent opt(0.4, false); + REQUIRE_FALSE(opt.deserialize("abc%")); + CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9)); + CHECK_FALSE(opt.percent); + } + SECTION("point") { + ConfigOptionPoint opt(Vec2d(1., 2.)); + REQUIRE_FALSE(opt.deserialize("5,abc")); + CHECK(opt.value == Vec2d(1., 2.)); + } + SECTION("point3") { + ConfigOptionPoint3 opt(Vec3d(1., 2., 3.)); + REQUIRE_FALSE(opt.deserialize("5,6,abc")); + CHECK(opt.value == Vec3d(1., 2., 3.)); + } +} + +TEST_CASE("A scalar option still takes a valid value", "[Config]") { + using Catch::Matchers::WithinAbs; + + ConfigOptionFloatOrPercent opt(0.4, false); + REQUIRE(opt.deserialize("50%")); + CHECK_THAT(opt.value, WithinAbs(50., 1e-9)); + CHECK(opt.percent); + + ConfigOptionPoint point; + REQUIRE(point.deserialize("3x4")); + CHECK(point.value == Vec2d(3., 4.)); +} + TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") { Slic3r::DynamicPrintConfig config; t_config_option_keys extra, keys; @@ -1678,3 +1727,75 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con } CHECK(enum_lists > 0); } + +TEST_CASE("A disabled filament override survives the merge with a parent that sets it", "[Config][Variant]") +{ + std::set no_keys; + Slic3r::DynamicPrintConfig parent; + parent.set_deserialize_strict("filament_retract_before_wipe", "100%"); + parent.set_deserialize_strict("filament_ironing_flow", "15%"); + Slic3r::DynamicPrintConfig child; + child.set_deserialize_strict("filament_retract_before_wipe", "nil"); + child.set_deserialize_strict("filament_ironing_flow", "nil"); + parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant", + Slic3r::filament_options_with_variant, no_keys); + CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil"); + CHECK(parent.opt_serialize("filament_ironing_flow") == "nil"); +} + +SCENARIO("A two-variant child's disabled override column is matched to the parent's variant by name", "[Config][Variant]") +{ + std::set no_keys; + GIVEN("A two-variant filament parent") { + Slic3r::DynamicPrintConfig parent; + parent.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow"); + parent.set_deserialize_strict("filament_retract_before_wipe", "100%,100%"); + parent.set_deserialize_strict("filament_flow_ratio", "1,1"); + WHEN("the child disables the override on one variant and leaves a plain option nil on it") { + Slic3r::DynamicPrintConfig child; + child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow"); + child.set_deserialize_strict("filament_retract_before_wipe", "nil,100%"); + child.set_deserialize_strict("filament_flow_ratio", "nil,1.1"); + parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant", + Slic3r::filament_options_with_variant, no_keys); + THEN("the override column is disabled while the plain option's nil column inherits") { + CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,100%"); + CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1"); + } + } + WHEN("the child lists the same variants in the other order") { + Slic3r::DynamicPrintConfig child; + child.set_deserialize_strict("filament_extruder_variant", "Direct Drive High Flow;Direct Drive Standard"); + child.set_deserialize_strict("filament_retract_before_wipe", "70%,nil"); + child.set_deserialize_strict("filament_flow_ratio", "1.1,nil"); + parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant", + Slic3r::filament_options_with_variant, no_keys); + THEN("columns are matched by variant name, the disabled one included") { + CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,70%"); + CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1"); + } + } + WHEN("the child was saved with a single variant") { + Slic3r::DynamicPrintConfig child; + child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard"); + child.set_deserialize_strict("filament_retract_before_wipe", "nil"); + parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant", + Slic3r::filament_options_with_variant, no_keys); + THEN("its one value applies to every variant of that extruder, as slicing does") { + CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,nil"); + } + } + } +} + +TEST_CASE("Every listed filament override is nullable", "[Config]") +{ + std::set overrides(Slic3r::filament_extruder_override_keys.begin(), Slic3r::filament_extruder_override_keys.end()); + overrides.insert({ "filament_retract_length_nc", "filament_ironing_flow", "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed" }); + for (const auto &[key, def] : Slic3r::print_config_def.options) { + INFO(key); + CHECK(Slic3r::is_filament_extruder_override_key(key) == (overrides.count(key) == 1)); + if (overrides.count(key) == 1) + CHECK(def.nullable); + } +} diff --git a/tests/libslic3r/test_kdtree.cpp b/tests/libslic3r/test_kdtree.cpp new file mode 100644 index 0000000000..fee8c0e98a --- /dev/null +++ b/tests/libslic3r/test_kdtree.cpp @@ -0,0 +1,72 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include "libslic3r/KDTreeIndirect.hpp" +#include "libslic3r/Point.hpp" + +using namespace Slic3r; + +TEST_CASE("Visiting the nearby points gives what collecting them gives", "[KDTree]") { + std::mt19937 rng(19937); + std::uniform_real_distribution coord(-50.f, 50.f); + // Points in a box, so that a radius search returns anything from none of them to all of them. + std::vector points(2000); + for (Vec3f &p : points) + p = Vec3f(coord(rng), coord(rng), coord(rng)); + + auto coordinate = [&points](size_t idx, size_t dimension) { return points[idx](int(dimension)); }; + KDTreeIndirect<3, float, decltype(coordinate)> tree(coordinate); + std::vector indices(points.size()); + std::iota(indices.begin(), indices.end(), 0); + tree.build(indices); + + const float radius = GENERATE(0.5f, 5.f, 25.f, 200.f); + for (int i = 0; i < 20; ++ i) { + const Vec3f center(coord(rng), coord(rng), coord(rng)); + + const std::vector collected = find_nearby_points(tree, center, radius); + std::vector visited; + visit_nearby_points(tree, center, radius, [&visited](size_t idx) { visited.emplace_back(idx); }); + + // Same points, and in the same order: a caller that keeps the first of several equally good ones + // must get the same answer either way. + REQUIRE(visited == collected); + } +} + +TEST_CASE("A radius search returns every point within the radius and no other", "[KDTree]") { + std::mt19937 rng(2024); + std::uniform_real_distribution coord(-20.f, 20.f); + std::vector points(500); + for (Vec3f &p : points) + p = Vec3f(coord(rng), coord(rng), coord(rng)); + + auto coordinate = [&points](size_t idx, size_t dimension) { return points[idx](int(dimension)); }; + KDTreeIndirect<3, float, decltype(coordinate)> tree(coordinate); + std::vector indices(points.size()); + std::iota(indices.begin(), indices.end(), 0); + tree.build(indices); + + const Vec3f center(1.f, -2.f, 3.f); + const float radius = 7.f; + + std::vector expected; + for (size_t i = 0; i < points.size(); ++ i) + if ((points[i] - center).squaredNorm() < radius * radius) + expected.emplace_back(i); + + std::vector visited; + visit_nearby_points(tree, center, radius, [&visited](size_t idx) { visited.emplace_back(idx); }); + std::sort(visited.begin(), visited.end()); + + REQUIRE(! expected.empty()); + REQUIRE(visited == expected); +} diff --git a/tests/libslic3r/test_placeholder_parser.cpp b/tests/libslic3r/test_placeholder_parser.cpp index 0a490a6bdd..5ec8b6d801 100644 --- a/tests/libslic3r/test_placeholder_parser.cpp +++ b/tests/libslic3r/test_placeholder_parser.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include "libslic3r/PlaceholderParser.hpp" @@ -398,3 +399,53 @@ SCENARIO("Placeholder parser names in branches that are not taken", "[Placeholde CHECK(PlaceholderParser::evaluate_boolean_expression("false ? no_such_var == 1 : true", config)); } } + +TEST_CASE("Placeholder parser deferred values can be printed but not computed with", "[PlaceholderParser]") { + PlaceholderParser parser; + parser.set_deferred("total_weight", "@TOTAL_WEIGHT@"); + parser.set_deferred("used_filament", "@USED_FILAMENT_LENGTH@"); + parser.set("label", "W"); + PlaceholderParser::ContextData context; + context.global_config = std::make_unique(); + auto process = [&parser, &context](const std::string &templ) { return parser.process(templ, 0, nullptr, nullptr, &context); }; + + SECTION("printed") { + CHECK(process("{total_weight}") == "@TOTAL_WEIGHT@"); + CHECK(process("[total_weight]") == "@TOTAL_WEIGHT@"); + CHECK(process("{label + \": \" + total_weight + \"g\"}") == "W: @TOTAL_WEIGHT@g"); + CHECK(process("{total_weight + \"g, \" + used_filament + \"m\"}") == "@TOTAL_WEIGHT@g, @USED_FILAMENT_LENGTH@m"); + CHECK(process("{\"W \" + total_weight + \"g for \" + 3 + \" parts\"}") == "W @TOTAL_WEIGHT@g for 3 parts"); + CHECK(process("{true ? total_weight : 0}") == "@TOTAL_WEIGHT@"); + CHECK(process("{regex_replace(\"x\", /x/, total_weight)}") == "@TOTAL_WEIGHT@"); + CHECK(process("{local w = total_weight}{w}") == "@TOTAL_WEIGHT@"); + } + SECTION("used in a calculation or a comparison") { + const auto deferred_error = Catch::Matchers::ContainsSubstring("known only after the G-code is generated"); + for (const std::string templ : { + "{total_weight + 1}", "{1 + total_weight}", "{total_weight * 2}", "{-total_weight}", "{int(total_weight)}", + "{min(total_weight, 5)}", "{digits(total_weight, 5)}", "{total_weight + used_filament}", + "{if total_weight > 100}heavy{endif}", "{if total_weight == \"1\"}one{endif}", "{total_weight and true}", + "{total_weight ? 1 : 0}", "{if total_weight =~ /1.*/}one{endif}", "{one_of(total_weight, \"1\")}", + "{regex_replace(total_weight, /1/, \"2\")}", "{(\"W\" + total_weight) == \"W1\"}", "{filament_change(total_weight)}", + "{regex_replace(\"x\", /x/, total_weight) * 2}", "{if regex_replace(\"x\", /x/, \"W\" + total_weight) > \"W1\"}heavy{endif}", + // A deferred value copied to a variable is still deferred. + "{local w = total_weight}{w > 1}", "{global g = \"W\" + total_weight}{if g == \"W1\"}one{endif}" }) { + CAPTURE(templ); + CHECK_THROWS_WITH(process(templ), deferred_error); + } + // A mistake unrelated to the value being deferred keeps its own message. + CHECK_THROWS_WITH(process("{global v = (1, 2)}{global v = \"W\" + total_weight}"), + Catch::Matchers::ContainsSubstring("Cannot assign a scalar value to a vector variable")); + } + SECTION("ignored in a branch that is not taken") { + CHECK(process("{if false}{total_weight * 2}{endif}ok") == "ok"); + struct Restore { ~Restore() { PlaceholderParser::check_inactive_branches = false; } } restore; + PlaceholderParser::check_inactive_branches = true; + CHECK(process("{if false}{total_weight * 2}{endif}ok") == "ok"); + } + SECTION("other strings keep their behavior") { + CHECK(process("{label + 1}") == "W1"); + CHECK(process("{if label > \"A\"}yes{endif}") == "yes"); + CHECK(process("{if regex_replace(label, /X/, total_weight) == \"W\"}yes{endif}") == "yes"); + } +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 1b4927f08c..9053fc5496 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -8417,3 +8417,58 @@ TEST_CASE("An empty choice list in a G-code's settings keeps the value it had be CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow"); CHECK(config.option("filament_multi_colour")->values.empty()); } + +TEST_CASE("A disabled filament override is saved and reloaded column by column", "[Preset][Bundle]") +{ + ScopedPresetDataDir data_dir; + PresetBundle bundle; + + Preset &parent = add_inmemory_preset(bundle.filaments, "Parent PLA @System"); + parent.config.option("filament_extruder_variant", true)->values = { "Direct Drive Standard", "Direct Drive High Flow" }; + parent.config.option("filament_retract_before_wipe", true)->values = { 100., 100. }; + parent.config.option("filament_flow_ratio", true)->values = { 1., 1. }; + + // The child turns the override off on the first variant and changes a plain option on the second. + const fs::path file = data_dir.tmp.path() / PRESET_FILAMENT_NAME / "Child PLA.json"; + Preset child(Preset::TYPE_FILAMENT, "Child PLA"); + child.file = file.string(); + child.version = *Semver::parse("1.0.0"); + child.config = parent.config; + child.config.option(BBL_JSON_KEY_INHERITS, true)->value = parent.name; + child.config.option("filament_retract_before_wipe")->set_at_to_nil(0); + child.config.option("filament_flow_ratio")->values = { 1., 1.1 }; + REQUIRE(child.save(&parent.config)); + + // On disk the override keeps every column, so its nil can only mean "off"; a plain + // option still stores nil for the columns that match the parent. + DynamicPrintConfig saved; + std::map key_values; + std::string reason; + saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason); + REQUIRE(reason.empty()); + CHECK(saved.opt_serialize("filament_retract_before_wipe") == "nil,100%"); + CHECK(saved.opt_serialize("filament_flow_ratio") == "nil,1.1"); + + PresetsConfigSubstitutions substitutions; + bundle.filaments.load_presets(data_dir.tmp.path().string(), PRESET_FILAMENT_NAME, substitutions, + ForwardCompatibilitySubstitutionRule::EnableSilent); + const Preset *loaded = bundle.filaments.find_preset("Child PLA"); + REQUIRE(loaded != nullptr); + CHECK(loaded->config.opt_serialize("filament_retract_before_wipe") == "nil,100%"); + CHECK(loaded->config.opt_serialize("filament_flow_ratio") == "1,1.1"); +} + +TEST_CASE("The cloud diff force-emits a disabled override only where the cloud would fill a value in", "[Preset][Bundle]") +{ + PresetBundle bundle; + add_inmemory_preset(bundle.filaments, "Parent PLA @System"); + Preset &child = add_inmemory_preset(bundle.filaments, "Child PLA", "Parent PLA @System"); + // Every override is nil in both parent and child, so none is in the plain diff. + std::map key_values; + REQUIRE(bundle.filaments.get_differed_values_to_update(child, key_values) >= 0); + // Retract Before Wipe defaults to 100%: an absent key would come back as 100%, so nil is sent. + CHECK(key_values.count("filament_retract_before_wipe") == 1); + CHECK(key_values["filament_retract_before_wipe"] == "nil"); + // An ironing override defaults to nil: an absent key stays nil, so it is not sent. + CHECK(key_values.count("filament_ironing_flow") == 0); +} diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index 267cdb4099..fc2c35c60c 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -1,22 +1,48 @@ #include #include +#include #include #include #include // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h) #include +#include +#include +#include +#include +#include +#include #include // secret_constants::USER_SECRET_FILENAME +#include #include +#include +#include #include "libslic3r_version.h" #include "plugin_test_utils.hpp" +#include "test_utils.hpp" #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include using namespace Slic3r; namespace fs = boost::filesystem; +namespace py = pybind11; namespace { @@ -43,7 +69,7 @@ void seed_denied_keywords() } // namespace -TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]") +TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]") { seed_denied_names(); const PluginAuditManager& mgr = PluginAuditManager::instance(); @@ -55,6 +81,7 @@ TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME))); + CHECK(mgr.is_denied_filename(fs::path(".install_state.json"))); } SECTION("companions holding the same secrets are denied by the prefix rule") @@ -110,6 +137,7 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]") const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf"); const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME; + const fs::path install_state = fs::path(data_dir()) / "plugins" / "example" / ".install_state.json"; SECTION("a non-denied file inside the allowed root is writable (root really grants writes)") { @@ -150,6 +178,17 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]") CHECK_FALSE(decision.allowed); } + SECTION("plugin install state is blocked for reads and writes despite the allowed root") + { + AuditDecision read_decision = mgr.check_open(install_state.string(), "r"); + CHECK_FALSE(read_decision.allowed); + CHECK(read_decision.reason == "denied filename"); + + AuditDecision write_decision = mgr.check_open(install_state.string(), "w"); + CHECK_FALSE(write_decision.allowed); + CHECK(write_decision.reason == "denied filename"); + } + SECTION("a traversal path resolving to the config is blocked") { const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf"); @@ -349,3 +388,781 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed); } } + +// --------------------------------------------------------------------------- +// The audit hook, run in the embedded interpreter +// --------------------------------------------------------------------------- +// +// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system +// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their +// cases instead of using SECTIONs, which would restart the interpreter for every section. + +namespace { + +// Discovered, never loaded: the hook only needs the plugin's root and its sidecar. +const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Audit Plugin" +# description = "Plugin audit hook fixture" +# author = "OrcaSlicer" +# version = "1.0" +# type = "script" +# /// +)PY"; + +// A third-party library, installed outside the stdlib and outside the plugin. +const char* const LIBRARY_SOURCE = R"PY( +import socket +import urllib.request + +def read_all(paths): + for path in paths: + with open(path) as f: + f.read() + +def connect_from_host(port): + socket.gethostname() + socket.create_connection(("127.0.0.1", port)).close() + +def fetch(url): + with urllib.request.urlopen(url) as response: + return response.read() + +def fetch_each(urls): + for url in urls: + yield fetch(url) + +class ReadingHandler(urllib.request.BaseHandler): + """Reads a file while each response is processed.""" + def __init__(self, path): + self.path = path + self.outcome = None + + def http_response(self, request, response): + try: + open(self.path).close() + self.outcome = "ok" + except PermissionError: + self.outcome = "denied" + return response +)PY"; + +// Test-side Python, run outside any plugin context: files outside every allowed root, a local +// HTTP server, the library, and every module the tests use imported up front, so that no import +// happens inside a plugin context. Code from exec() runs as "", which is not stdlib, so +// attempt() and fetch() stand in for plugin code. +const char* const AUDIT_SETUP = R"PY( +import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading +import urllib.request, zipfile +if os.name != "nt": + import pty + +APPROVED = os.path.join(OUTSIDE, "approved.txt") +PRIVATE = os.path.join(OUTSIDE, "private.txt") +MISSING = os.path.join(OUTSIDE, "missing") +TREE = os.path.join(OUTSIDE, "tree") +TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"] +for path in (APPROVED, PRIVATE): + with open(path, "w") as f: + f.write("x") +for sub in ("one", "two"): + os.makedirs(os.path.join(TREE, sub)) + with open(os.path.join(TREE, sub, "f.txt"), "w") as f: + f.write("x") + +LIBRARY = os.path.join(OUTSIDE, "library") +os.makedirs(LIBRARY) +with open(os.path.join(LIBRARY, "netlib.py"), "w") as f: + f.write(LIBRARY_SOURCE) +sys.path.insert(0, LIBRARY) +import netlib + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/redirect": + self.send_response(302) + self.send_header("Location", URL_B) + body = b"" + else: + self.send_response(200) + body = b"ok" + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +threading.Thread(target=server.serve_forever, daemon=True).start() +PORT = server.server_address[1] +BASE = "http://127.0.0.1:%d" % PORT +URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect" +urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({}))) + +def fetch(url): + with urllib.request.urlopen(url) as response: + return response.read() + +fetch(URL_A) # urllib imports some modules on its first request + +def attempt(action): + try: + action() + return "ok" + except PermissionError: + return "denied" + +def spawn_checked(argv): + parent = os.getpid() + try: + return pty.spawn(argv) + finally: + if os.getpid() != parent: + os._exit(1) # a child whose exec was refused must not go on running the tests + +finalizer_outcomes = [] + +class TrackedRequest(urllib.request.Request): + def __del__(self): + finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close())) +)PY"; + +struct Prompt +{ + std::string plugin; + std::string event; + std::vector targets; +}; + +// One line per prompt, "event: target ...", so a failed check shows every prompt. +std::string describe(const std::vector& prompts) +{ + std::string text; + for (const Prompt& prompt : prompts) { + text += prompt.event + ":"; + for (const std::string& target : prompt.targets) + text += " " + target; + text += "\n"; + } + return text; +} + +// The plugin system with the audit hook installed, and the permission dialog replaced by a +// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last. +struct AuditHarness +{ + // Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log. + // data_dir() is a global allowed root from then on, so the test files live in `outside`. + ScopedDataDir data{"plugin-audit-hook"}; + bool initialized = PluginManager::instance().initialize(); + ScopedTemporaryDir outside{"orca-plugin-audit-outside"}; + std::vector prompts; + std::function approve = [](const Prompt&) { return false; }; + + AuditHarness() + { + PluginAuditManager::set_permission_prompt_for_testing( + [this](const std::string& event, const std::vector& targets) { + prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets}); + return approve(prompts.back()); + }); + } + + ~AuditHarness() + { + PluginAuditManager::set_permission_prompt_for_testing(nullptr); + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); + } + + // Call before taking the GIL: discovery is not Python work. + void add_plugin(const std::string& key) + { + const fs::path root = data.plugins_dir() / key; + fs::create_directories(root); + std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE; + save_permissions(key, {}); + PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true); + } + + // Grants as if saved in an earlier session. + void save_permissions(const std::string& key, const PluginPermissions& permissions) + { + PluginInstallState state; + state.installed_from = "local"; + state.installed_version = "1.0"; + state.plugin_name = key; + state.permissions = permissions; + REQUIRE(write_install_state(data.plugins_dir() / key, state)); + } + + PluginPermissions saved_permissions(const std::string& key) const + { + PluginInstallState state; + read_install_state(data.plugins_dir() / key, state); + return state.permissions; + } +}; + +// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP +// server before the interpreter shuts down. +struct PythonSide +{ + py::dict globals; + + explicit PythonSide(const AuditHarness& harness) + { + globals["OUTSIDE"] = harness.outside.path().string(); + globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE; + exec(AUDIT_SETUP); + } + + // A Python error fails the test here, as text: an error_already_set left to propagate would + // outlive the interpreter the harness shuts down. + void exec(const char* code) + { + std::string error; + try { + py::exec(code, globals); + } catch (py::error_already_set& raised) { + error = raised.what(); + } + if (!error.empty()) + FAIL(error); + } + + ~PythonSide() + { + try { + globals["server"].attr("shutdown")(); + globals["server"].attr("server_close")(); + } catch (...) { + } + } + + std::string str(const char* name) const { return py::str(globals[name]).cast(); } + std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast(); } + std::vector strings(const char* name) const { return globals[name].cast>(); } + + // Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an + // audit scope of its own. + void run_as(const std::string& plugin_key, const char* code) + { + ScopedPluginAuditContext audit(plugin_key); + exec(code); + } +}; + +PluginPermissions http_grants(std::vector urls) +{ + PluginPermissions permissions; + permissions.network_http = std::move(urls); + return permissions; +} + +const char* const PLUGIN = "AuditPlugin"; + +} // namespace + +TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")})); + + python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))"); + + CHECK(python.str("outcome") == "ok"); + CHECK(describe(harness.prompts).empty()); +} + +TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")})); + + python.run_as(PLUGIN, R"PY( +outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)] +outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)] +outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B])))) +)PY"); + + // The redirect target is asked for inside the approved redirecting request, and a library's + // wrapper or generator asks for the second URL as plain code does. + CHECK(python.strings("outcomes") == std::vector{"ok", "denied", "denied", "ok", "denied", "denied"}); + const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n"; + CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b); +} + +TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + python.exec(R"PY( +CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy") +handler = netlib.ReadingHandler(PRIVATE) +handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler) +walk_outcomes = [] +copy_outcomes = [] + +def on_walk_error(error): + walk_outcomes.append(attempt(lambda: open(PRIVATE).close())) + +def copy_file(source, destination): + copy_outcomes.append(attempt(lambda: open(PRIVATE).close())) +)PY"); + PluginPermissions permissions = http_grants({python.str("URL_A")}); + permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")}; + permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")}; + harness.save_permissions(PLUGIN, permissions); + + // A library handler that urllib calls while the approved request runs, the plugin's own + // os.walk error callback and copytree copy function (called once per file), and a library + // loop over a saved file and an unsaved one. + python.run_as(PLUGIN, R"PY( +request_outcome = attempt(lambda: handler_opener.open(URL_A).read()) +for _ in os.walk(MISSING, onerror=on_walk_error): + pass +shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file) +loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE])) +)PY"); + + CHECK(python.str("request_outcome") == "ok"); + CHECK(python.eval("handler.outcome") == "denied"); + CHECK(python.strings("walk_outcomes") == std::vector{"denied"}); + CHECK(python.strings("copy_outcomes") == std::vector{"denied", "denied"}); + CHECK(python.str("loop_outcome") == "denied"); + const std::string private_file = "open: " + python.str("PRIVATE") + "\n"; + CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file); +} + +TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + PluginPermissions permissions; + permissions.fs_read = {python.str("TREE"), python.str("APPROVED")}; + harness.save_permissions(PLUGIN, permissions); + + // The walk lists the tree's two subdirectories on later resumes, after the plugin's own + // audited reads in between. Abandoned walks leave frames whose generator is gone, which the + // next audited event lets go. + python.run_as(PLUGIN, R"PY( +walked = [] +for root, dirs, files in os.walk(TREE): + walked.append(root) + open(APPROVED).close() +globbed = list(pathlib.Path(TREE).glob("**/*")) + +abandoned = os.walk(TREE) +next(abandoned) +del abandoned +open(APPROVED).close() +for _ in pathlib.Path(TREE).glob("*"): + break +open(APPROVED).close() +)PY"); + + CHECK(py::len(python.globals["walked"]) == 3); + CHECK(py::len(python.globals["globbed"]) == 4); + CHECK(describe(harness.prompts).empty()); +} + +TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + + struct Case + { + const char* prompts; + const char* code; + }; + // Each operation names targets no earlier one saved, so each is asked once. FsCreate and + // FsDelete grants are never saved, so those operations are asked every time. Only the + // expected prompts are approved, so a nested event left uncovered fails the operation. + std::vector cases = { + {"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"}, + {"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"}, + {"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"}, + {"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"}, + {"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"}, + {"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"}, + {"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"}, + {"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"}, + {"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"}, + {"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"}, + {"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"}, + // send() runs after connect() has returned, so its own grant is asked once. + {"http.client.connect\nhttp.client.send", + "connection = http.client.HTTPConnection('127.0.0.1', PORT)\n" + "connection.request('GET', '/a')\n" + "connection.getresponse().read()\n" + "connection.close()"}, + }; +#ifndef _WIN32 + cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"}); + // The forked child's exec is covered by the spawn too: had it prompted, the child's own + // denial would end it with status 1. + cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"}); +#endif + + for (const Case& operation : cases) { + INFO(operation.code); + const std::string expected = std::string(operation.prompts) + "\n"; + harness.approve = [&expected](const Prompt& prompt) { + return expected.find(prompt.event + "\n") != std::string::npos; + }; + harness.prompts.clear(); + python.run_as(PLUGIN, operation.code); + + std::string asked; + for (const Prompt& prompt : harness.prompts) + asked += (asked.empty() ? "" : "\n") + prompt.event; + CHECK(asked == operation.prompts); + } +#ifndef _WIN32 + CHECK(python.eval("spawn_status") == "0"); +#endif +} + +TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; }; + py::gil_scoped_acquire gil; + PythonSide python(harness); + + python.run_as(PLUGIN, R"PY( +socket.socket().close() +udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9))) +udp.close() +)PY"); + + // The socket is asked for once, without a target to show; the datagram's address is a target. + CHECK(python.str("sendto_outcome") == "denied"); + CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n"); + CHECK(harness.saved_permissions(PLUGIN).events == std::vector{"Socket:socket.__new__"}); + CHECK(harness.saved_permissions(PLUGIN).network_socket.empty()); + + harness.prompts.clear(); + python.run_as(PLUGIN, "socket.socket().close()"); + CHECK(describe(harness.prompts).empty()); + + // A grant for an event with no target covers nothing else: a library's approved + // gethostname() does not carry the connection it makes next. + harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; }; + python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))"); + CHECK(python.str("connect_outcome") == "denied"); + CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n"); + harness.prompts.clear(); + harness.approve = [](const Prompt&) { return true; }; + +#ifndef _WIN32 + // sendmsg() without an address names no target either. + python.run_as(PLUGIN, R"PY( +left, right = socket.socketpair() +left.sendmsg([b"x"]) +left.close() +right.close() +)PY"); + CHECK(describe(harness.prompts) == "socket.sendmsg:\n"); + CHECK(harness.saved_permissions(PLUGIN).events == + std::vector{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"}); +#endif +} + +TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; }; + py::gil_scoped_acquire gil; + PythonSide python(harness); + python.exec(R"PY( +READ_FD = os.open(APPROVED, os.O_RDONLY) +WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT) +)PY"); + + // A file descriptor has no path to show or save: a granted read of one is not a write. + python.run_as(PLUGIN, R"PY( +fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()), + attempt(lambda: open(WRITE_FD, "w", closefd=False).close())] +)PY"); + CHECK(python.strings("fd_outcomes") == std::vector{"ok", "denied"}); + CHECK(describe(harness.prompts) == "open:\nopen:\n"); + CHECK(harness.saved_permissions(PLUGIN).events == std::vector{"FsRead:open"}); + + // A saved os.fork event grant is not an os.system command named "os.fork". + PluginPermissions permissions; + permissions.events = {"FsRead:open", "ProcessCreate:os.fork"}; + harness.save_permissions(PLUGIN, permissions); + harness.prompts.clear(); + python.run_as(PLUGIN, R"PY( +reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close()) +system_outcome = attempt(lambda: os.system("os.fork")) +os.close(READ_FD) +os.close(WRITE_FD) +)PY"); + CHECK(python.str("reread_outcome") == "ok"); + CHECK(python.str("system_outcome") == "denied"); + REQUIRE(harness.prompts.size() == 1); + CHECK(harness.prompts.front().event == "os.system"); + CHECK(harness.saved_permissions(PLUGIN).process.empty()); +} + +TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + PluginPermissions permissions = http_grants({python.str("URL_A")}); + permissions.fs_read = {python.str("APPROVED")}; + harness.save_permissions(PLUGIN, permissions); + PluginAuditManager& mgr = PluginAuditManager::instance(); + + // Once the request returns, only its held frame keeps the request object alive, and the + // request's finalizer reads a file the plugin was never granted. + python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))"); + CHECK(python.strings("finalizer_outcomes") == std::vector{"denied"}); // at scope end + CHECK(describe(harness.prompts).empty()); + CHECK_FALSE(mgr.audit_denial_pending()); + + python.exec("finalizer_outcomes.clear()"); + python.run_as(PLUGIN, R"PY( +fetch(TrackedRequest(URL_A)) +open(APPROVED).close() # the next event lets the returned request's frame go +outcomes_before_scope_end = list(finalizer_outcomes) +)PY"); + CHECK(python.strings("outcomes_before_scope_end") == std::vector{"denied"}); + CHECK(describe(harness.prompts).empty()); + + // The finalizer's denial during another event's decision leaves that event's own denial intact. + python.exec("finalizer_outcomes.clear()"); + python.run_as(PLUGIN, R"PY( +fetch(TrackedRequest(URL_A)) +try: + os.listdir(OUTSIDE) + outer_error = "" +except PermissionError as error: + outer_error = str(error) +)PY"); + CHECK(python.strings("finalizer_outcomes") == std::vector{"denied"}); + CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir")); + CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n"); + AuditViolation violation; + REQUIRE(mgr.last_violation(violation)); + CHECK(violation.event_name == "os.listdir"); + CHECK(mgr.audit_denial_pending()); +} + +TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin("PluginA"); + harness.add_plugin("PluginB"); + py::gil_scoped_acquire gil; + PythonSide python(harness); + PluginPermissions permissions; + permissions.fs_read = {python.str("TREE")}; + harness.save_permissions("PluginA", permissions); + + { + // Two walks plugin A started and suspended after listing the tree itself. + ScopedPluginAuditContext plugin_a("PluginA"); + python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)"); + { + // Plugin B resuming one of them is asked for each subdirectory, as plugin B. + ScopedPluginAuditContext plugin_b("PluginB"); + python.exec("listed_by_b = len(list(first))"); + } + // Back in plugin A's scope, its walks are covered again. + python.exec("listed_by_a = len(list(second))"); + } + + CHECK(python.globals["listed_by_b"].cast() == 0); + CHECK(python.globals["listed_by_a"].cast() == 2); + REQUIRE(harness.prompts.size() == 2); + for (const Prompt& prompt : harness.prompts) { + CHECK(prompt.plugin == "PluginB"); + CHECK(prompt.event == "os.scandir"); + } +} + +TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; }; + py::gil_scoped_acquire gil; + PythonSide python(harness); + (void) PythonPluginBridge::instance(); // force the embedded module registration into the binary + python.globals["orca"] = py::module_::import("orca"); + + // walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker + // in its locals; the frame is let go when the capability call ends, which runs the marker's + // finalizer. + python.exec(R"PY( +class Marker: + def __init__(self, action=None): + self.action = action + def __del__(self): + finalized.append(self.action() if self.action else "ran") + +finalized = [] + +def walk_and_drop(marker): + walker = os.walk(TREE, onerror=marker) + next(walker) + del walker + +class ConfigCapability(orca.PythonPluginBase): + def get_name(self): + return "config_cap" + def get_default_config(self): + walk_and_drop(Marker()) + return {"finalized_during_call": len(finalized)} + def has_config_ui(self): + walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close()))) + next(walker) + attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key + del walker # the walk's frame is now held by the audit scope alone + raise RuntimeError("the capability fails after a denial") + +class TicketAgent(orca.printer_agent.PrinterAgentBase): + def get_name(self): + return "ticket_agent" + def request_bind_ticket(self): + walk_and_drop(Marker()) + return (0, "ticket") +)PY"); + + py::object config_instance = python.globals["ConfigCapability"](); + auto config_cap = config_instance.cast>(); + config_cap->set_audit_plugin_key("GilPlugin"); + config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script); + + py::object agent_instance = python.globals["TicketAgent"](); + auto agent_cap = agent_instance.cast>(); + agent_cap->set_audit_plugin_key("GilPlugin"); + agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection); + auto agent = std::dynamic_pointer_cast(agent_cap); + REQUIRE(agent); + + // A host call arrives without the GIL; both calls take it before their audit scope. + nlohmann::json config; + int ticket_result = -1; + std::string ticket; + { + py::gil_scoped_release released; + config = config_cap->get_default_config(); + ticket_result = agent->request_bind_ticket(&ticket); + } + CHECK(config == nlohmann::json{{"finalized_during_call", 0}}); + CHECK(ticket_result == 0); + CHECK(ticket == "ticket"); + CHECK(python.strings("finalized") == std::vector{"ran", "ran"}); + + // On the trampoline's denial path the frame is let go as the plugin, though the ambient key + // was cleared: the finalizer's read is audited and denied without a prompt. + python.exec("finalized.clear()"); + harness.prompts.clear(); + CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set); + CHECK(python.strings("finalized") == std::vector{"denied"}); + CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n"); +} + +TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + py::gil_scoped_acquire gil; + + const py::dict paths = py::module_::import("sysconfig").attr("get_paths")(); + const fs::path stdlib = py::str(paths["stdlib"]).cast(); + const fs::path purelib = py::str(paths["purelib"]).cast(); + const fs::path link = harness.outside.path() / "linked-stdlib"; + boost::system::error_code ec; + fs::create_directory_symlink(stdlib, link, ec); + if (ec) + SKIP("Cannot create a directory symlink here: " + ec.message()); + + CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string())); + CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string())); + CHECK(PluginAuditManager::is_stdlib_file("")); + CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string())); + CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string())); + CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string())); + CHECK_FALSE(PluginAuditManager::is_stdlib_file("")); +} + +TEST_CASE("A plugin may read the CPython stdlib without a prompt, but not site-packages or for writing", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + + const py::dict paths = py::module_::import("sysconfig").attr("get_paths")(); + const fs::path stdlib = py::str(paths["stdlib"]).cast(); + const fs::path purelib = py::str(paths["purelib"]).cast(); + // os.py ships with every CPython installation. A site-packages path is third-party code even + // when the directory is empty: the prompt must come before the missing file is noticed. + const fs::path stdlib_file = stdlib / "os.py"; + REQUIRE(fs::exists(stdlib_file)); + + python.globals["STDLIB_FILE"] = stdlib_file.string(); + python.globals["SITE_PACKAGE_FILE"] = (purelib / "regression_probe.py").string(); + python.run_as(PLUGIN, R"PY( +read_outcome = attempt(lambda: open(STDLIB_FILE).read()) +write_outcome = attempt(lambda: open(STDLIB_FILE, "r+").close()) +site_packages_outcome = attempt(lambda: open(SITE_PACKAGE_FILE).close()) +)PY"); + + CHECK(python.str("read_outcome") == "ok"); + CHECK(python.str("write_outcome") == "denied"); + CHECK(python.str("site_packages_outcome") == "denied"); + // Only the write and the site-packages read are asked about; the stdlib read is implicit. + const std::string write_prompt = "open: " + python.str("STDLIB_FILE") + "\n"; + const std::string site_packages_prompt = "open: " + python.str("SITE_PACKAGE_FILE") + "\n"; + CHECK(describe(harness.prompts) == write_prompt + site_packages_prompt); +}