From 6d34d83e788021dcd45a3d7dcccf6f67e7e922dc Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 21 Sep 2026 14:55:39 +0200 Subject: [PATCH 01/37] Faster slicing of colour-painted layers A layer split into ~1000 colour fragments (a colour texture baked over a large top face) made several per-fragment loops redo whole-layer ClipperLib work, so slicing took ~33 min; it now takes ~3 min with the same output. - make_fills: clip the layer's no-overlap area to each expolygon's box before intersecting - discover_vertical_shells: small-piece filter compares only against the nearby part of the layer - bridge_over_infill: whole-layer union/diff/intersections restricted to the candidate's neighbourhood; fill boundary expanded once per spacing; anchor tree built only from lines crossing the scan range; bbox pre-check in the collision test; limiting outline taken directly instead of through expand(..., 0.3 * flow.spacing()), which offsets by 0.135 scaled units (flow.spacing() is in mm) and only cost a whole-layer pass per candidate --- src/libslic3r/Fill/Fill.cpp | 10 ++- src/libslic3r/PrintObject.cpp | 115 ++++++++++++++++++++++++++++------ 2 files changed, 104 insertions(+), 21 deletions(-) diff --git a/src/libslic3r/Fill/Fill.cpp b/src/libslic3r/Fill/Fill.cpp index a09c69fc5e..54986e66e1 100644 --- a/src/libslic3r/Fill/Fill.cpp +++ b/src/libslic3r/Fill/Fill.cpp @@ -1409,7 +1409,15 @@ void Layer::make_fills(FillAdaptive::Octree* adaptive_fill_octree, FillAdaptive: // Orca: Reuse the body origin used for bridge anchoring, resetting it for each surface. f->set_bounding_box(infill_bounding_box(*this, surface_fill, expoly, bbox)); - 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/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 699bbb99b2..bb581d28a8 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -2579,15 +2579,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()); } @@ -3158,6 +3177,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)}; @@ -3402,28 +3431,61 @@ 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 uniting it with a bridge or cutting a bridge + // out of it only changes the polygons near that bridge. The rest are passed through untouched + // instead of being fed to ClipperLib with the whole layer again for every candidate. + const auto split_near = [](const Polygons &polys, const BoundingBox &bbox, Polygons &far) { + Polygons near; + for (const Polygon &p : polys) + (get_extents(p).overlap(bbox) ? near : far).emplace_back(p); + return near; + }; 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); Polygons area_to_be_bridge = expand(candidate.new_polys, flow.scaled_spacing()); - area_to_be_bridge = intersection(area_to_be_bridge, deep_infill_area); + // 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. + if (!area_to_be_bridge.empty()) + area_to_be_bridge = intersection(area_to_be_bridge, + ClipperUtils::clip_clipper_polygons_with_subject_bbox( + deep_infill_area, get_extents(area_to_be_bridge).inflated(SCALED_EPSILON))); area_to_be_bridge.erase(std::remove_if(area_to_be_bridge.begin(), area_to_be_bridge.end(), - [internal_unsupported_area](const Polygon &p) { - return intersection({p}, internal_unsupported_area).empty(); + [&internal_unsupported_area](const Polygon &p) { + return intersection({p}, ClipperUtils::clip_clipper_polygons_with_subject_bbox( + internal_unsupported_area, + get_extents(p).inflated(SCALED_EPSILON))) + .empty(); }), area_to_be_bridge.end()); - 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.3 * flow.scaled_spacing())); + Polygons limiting_area; + const Polygons near_expansion = split_near(expansion_area, get_extents(area_to_be_bridge).inflated(SCALED_EPSILON), + limiting_area); + append(limiting_area, union_(area_to_be_bridge, near_expansion)); + + 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.3 * flow.scaled_spacing()))) + .first; + Polylines boundary_plines = boundary_it->second; { - Polylines limiting_plines = to_polylines(expand(limiting_area, 0.3*flow.spacing())); + // No offset here: flow.spacing() is in mm, so the expand(limiting_area, 0.3 * flow.spacing()) + // this used to be moved the outline by 0.135 scaled units - nothing beyond rounding - while + // costing a whole-layer ClipperLib pass for every candidate. limiting_area is already a clean + // union, so its own outline is the same boundary. + Polylines limiting_plines = to_polylines(limiting_area); boundary_plines.insert(boundary_plines.end(), limiting_plines.begin(), limiting_plines.end()); } @@ -3497,9 +3559,12 @@ void PrintObject::bridge_over_infill() // Check collision with other expanded surfaces { bool reconstruct = false; - Polygons tmp_expanded_area = expand(bridging_area, 3.0 * 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; @@ -3523,10 +3588,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), From 3167c3665a02790c88cab994ebc2f70a3cce6d4a Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 21 Sep 2026 15:22:36 +0200 Subject: [PATCH 02/37] Run colour segmentation and vertical shells in parallel Same output, ~2.6 min for the LOTR map plate, was ~2.9. --- src/libslic3r/MultiMaterialSegmentation.cpp | 65 ++++++++++++--------- src/libslic3r/PrintObject.cpp | 33 ++++++++++- 2 files changed, 70 insertions(+), 28 deletions(-) diff --git a/src/libslic3r/MultiMaterialSegmentation.cpp b/src/libslic3r/MultiMaterialSegmentation.cpp index 464740768d..1edc91e1e0 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -1393,11 +1393,38 @@ 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. + // The per-layer ClipperLib work is independent once the trimmed slices and the offsets have been walked in order, + // so it runs in parallel; the offsets are accumulated exactly as the sequential walk did. + 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 trimmed(shell_layers.size()); + std::vector offsets(shell_layers.size()); + ExPolygons layer_slices_trimmed = input_expolygons[layer_idx]; + 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; + layer_slices_trimmed = intersection_ex(layer_slices_trimmed, input_expolygons[shell_layers[i]]); + trimmed[i] = layer_slices_trimmed; + } + std::vector shells(shell_layers.size()); + tbb::parallel_for(size_t(0), shell_layers.size(), [&](size_t i) { + shells[i] = opening_ex(intersection_ex(ex, offset_ex(trimmed[i], offsets[i])), stat.small_region_threshold); + }); + for (size_t i = 0; i < shell_layers.size() && !shells[i].empty(); ++i) + dst.emplace_back(shell_layers[i], std::move(shells[i])); + }; + 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()) @@ -1406,18 +1433,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()) @@ -1426,21 +1445,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]); } } - } + }); } }); diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index bb581d28a8..0ab801c643 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -2303,6 +2304,18 @@ void PrintObject::discover_vertical_shells() BOOST_LOG_TRIVIAL(debug) << "Discovering vertical shells in parallel - end : cache top / bottom"; } + // 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. + struct ShellAccumulation + { + std::array key; + Polygons shell; + Polygons holes; + }; + std::vector> shell_accumulations(top_bottom_surfaces_all_regions ? num_layers : 0); + for (size_t region_id = 0; region_id < this->num_printing_regions(); ++ region_id) { const PrintRegion ®ion = this->printing_region(region_id); if (region.config().ensure_vertical_shell_thickness.value != evstAll ) @@ -2347,7 +2360,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, &cache_top_botom_regions, &shell_accumulations] (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) { @@ -2397,6 +2410,21 @@ void PrintObject::discover_vertical_shells() } } #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ + const std::array accumulation_key{ 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()) }; + std::vector *accumulations = shell_accumulations.empty() ? nullptr : &shell_accumulations[idx_layer]; + const auto reused = accumulations == nullptr ? nullptr : + [&]() -> const ShellAccumulation * { + for (const ShellAccumulation &a : *accumulations) + if (a.key == accumulation_key) + return &a; + return nullptr; + }(); + if (reused != nullptr) { + shell = reused->shell; + holes = reused->holes; + } else { polygons_append(holes, cache_top_botom_regions[idx_layer].holes); auto combine_holes = [&holes](const Polygons &holes2) { if (holes.empty() || holes2.empty()) @@ -2471,6 +2499,9 @@ void PrintObject::discover_vertical_shells() (i > ibottom || bottom_z - m_layers[i]->print_z < region_config.bottom_shell_thickness - EPSILON)) combine_holes(cache_top_botom_regions[i].holes); } + if (accumulations != nullptr) + accumulations->push_back({ accumulation_key, 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)); From 1fc153308f73b70684ef855d68af3f2bb6fc0510 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 21 Sep 2026 16:40:22 +0200 Subject: [PATCH 03/37] Generate walls and split solid infill in parallel Same output, ~2.2 min for the LOTR map plate, was ~2.6. --- src/libslic3r/Fill/Fill.cpp | 33 +- src/libslic3r/MultiMaterialSegmentation.cpp | 17 +- src/libslic3r/PerimeterGenerator.cpp | 746 ++++++++++---------- 3 files changed, 417 insertions(+), 379 deletions(-) diff --git a/src/libslic3r/Fill/Fill.cpp b/src/libslic3r/Fill/Fill.cpp index 54986e66e1..444fa15ddb 100644 --- a/src/libslic3r/Fill/Fill.cpp +++ b/src/libslic3r/Fill/Fill.cpp @@ -9,6 +9,8 @@ #include "../PrintConfig.hpp" #include "../Surface.hpp" +#include + #include "AABBTreeLines.hpp" #include "ExtrusionEntity.hpp" #include "Fill.hpp" @@ -630,24 +632,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; @@ -669,7 +675,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); @@ -800,8 +809,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); diff --git a/src/libslic3r/MultiMaterialSegmentation.cpp b/src/libslic3r/MultiMaterialSegmentation.cpp index 1edc91e1e0..de0b60c229 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -1472,20 +1472,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); @@ -1494,7 +1497,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) { diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index 2f8e29c237..a5bf29fcb4 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -18,6 +18,8 @@ #include #include #include +#include +#include #include "libslic3r/AABBTreeLines.hpp" #include "Print.hpp" static const int overhang_sampling_number = 6; @@ -2473,421 +2475,443 @@ 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); - // Infill contour bounding box. - BoundingBox infill_contour_bbox = get_extents(infill_contour); - infill_contour_bbox.offset(SCALED_EPSILON); + // Infill contour bounding box. + BoundingBox infill_contour_bbox = get_extents(infill_contour); + infill_contour_bbox.offset(SCALED_EPSILON); - coord_t perimeter_width = this->perimeter_flow.scaled_width(); + coord_t perimeter_width = this->perimeter_flow.scaled_width(); - // Get top ExPolygons from current infill contour. - 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, infill_contour_bbox); - } else - upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*upper_slices, infill_contour_bbox); + // Get top ExPolygons from current infill contour. + 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, infill_contour_bbox); + } else + upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*upper_slices, infill_contour_bbox); - top_expolygons = diff_ex(infill_contour, upper_slices_clipped); + top_expolygons = diff_ex(infill_contour, upper_slices_clipped); - if (!top_expolygons.empty()) { - 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, infill_contour_bbox); - const ExPolygons current_slices_bridges = offset_ex(diff_ex(top_expolygons, lower_slices_clipped), bridge_offset); + if (!top_expolygons.empty()) { + 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, infill_contour_bbox); + const ExPolygons current_slices_bridges = offset_ex(diff_ex(top_expolygons, lower_slices_clipped), bridge_offset); - // Remove bridges from top surface polygons. - top_expolygons = diff_ex(top_expolygons, current_slices_bridges); + // Remove bridges from top surface polygons. + top_expolygons = diff_ex(top_expolygons, current_slices_bridges); + } + + // 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); + // 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_expolygons = offset2_ex(top_expolygons, -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). + top_expolygons = intersection_ex(top_expolygons, infill_contour); + + // 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 we call Arachne again with parameters + // like when the single perimeter feature is disabled. + Arachne::WallToolPaths no_single_perimeter_tool_paths(last_p, bead_width_0, perimeter_spacing, coord_t(inner_loop_number + 2), wall_0_inset, layer_height, input_params_tmp); + perimeters = no_single_perimeter_tool_paths.getToolPaths(); + infill_contour = union_ex(no_single_perimeter_tool_paths.getInnerContour()); } - - // 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); - // 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_expolygons = offset2_ex(top_expolygons, -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). - top_expolygons = intersection_ex(top_expolygons, infill_contour); - - // 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 we call Arachne again with parameters - // like when the single perimeter feature is disabled. - Arachne::WallToolPaths no_single_perimeter_tool_paths(last_p, bead_width_0, perimeter_spacing, coord_t(inner_loop_number + 2), wall_0_inset, layer_height, input_params_tmp); - perimeters = no_single_perimeter_tool_paths.getToolPaths(); - infill_contour = union_ex(no_single_perimeter_tool_paths.getInnerContour()); } - } - //PS + //PS - loop_number = int(perimeters.size()) - 1; + loop_number = int(perimeters.size()) - 1; - #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 + #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 - // 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; - }()); + // 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; + 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; - } - 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; + } + 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 “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 ); - // 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) + this->loops->append(result.loops); + this->fill_surfaces->append(result.infill, stInternal); + apply_extra_perimeters(result.infill); + this->fill_no_overlap->insert(this->fill_no_overlap->end(), result.no_overlap.begin(), result.no_overlap.end()); } } From 84ec518f268e5fe302600886219b47151f680d2c Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Tue, 22 Sep 2026 13:06:44 +0200 Subject: [PATCH 04/37] Slice fine texture relief without stalling A colour texture baked at 0.1 mm / 2000k made the top layers thousands of islands and slicing never finished. Colour segmentation runs per island, the merge subtracts piece by piece, the support check tests only nearby islands, and the travel ordering finds crossings through a grid. --- src/libslic3r/GCode/OrderingStrategies.cpp | 67 +++++++++- src/libslic3r/MultiMaterialSegmentation.cpp | 130 +++++++++++++++++--- src/libslic3r/Support/TreeSupport.cpp | 32 ++++- 3 files changed, 207 insertions(+), 22 deletions(-) diff --git a/src/libslic3r/GCode/OrderingStrategies.cpp b/src/libslic3r/GCode/OrderingStrategies.cpp index 2ba9ae5490..d52a326e2e 100644 --- a/src/libslic3r/GCode/OrderingStrategies.cpp +++ b/src/libslic3r/GCode/OrderingStrategies.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -134,15 +135,79 @@ bool tsp_remove_crossings(std::vector& path, const Points& centers) return {std::numeric_limits::max(), std::numeric_limits::max()}; }; + // 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 crossing as the all-pairs scan + // (smallest i, then smallest j), so the result is unchanged; with thousands of islands on a layer the all-pairs + // scan, repeated after every reversal, never finished. Rebuilding the grid costs more than it saves on small inputs. + 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 = [&]() -> 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 = 0; i < n_edges; ++i) { + const Point& ai = centers[path[i]]; + const Point& bi = centers[path[(i + 1) % pn]]; + + size_t first_j = std::numeric_limits::max(); + for_cells(ai, bi, [&](int cell) { + for (size_t j : edge_cells[cell]) { + if (j < i + 2 || j >= first_j) continue; + // 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)) continue; + if (Geometry::segments_intersect(ai, bi, aj, bj)) + first_j = j; + } + }); + if (first_j != std::numeric_limits::max()) + return {i, first_j}; + } + return {std::numeric_limits::max(), std::numeric_limits::max()}; + }; + // Process crossings one at a time: find first, reverse it, restart scan. // Cap iterations to prevent infinite loops on collinear/overlapping segments. int max_iters = static_cast(pn * pn); bool improved = false; + // Reversing between two segments that only touch or overlap along a line need not remove the intersection, and on + // islands laid out on a regular grid (a tiled texture, an array of parts) the loop cycled through the same orderings + // until the pn * pn cap - effectively forever. Stop as soon as an ordering repeats: until then this is the same loop. + std::unordered_set seen_paths; + const auto path_hash = [&path]() { + uint64_t h = 1469598103934665603ull; // FNV-1a + for (size_t idx : path) + h = (h ^ uint64_t(idx)) * 1099511628211ull; + return h; + }; + seen_paths.insert(path_hash()); while (max_iters-- > 0) { - auto [ci, cj] = find_crossing(); + auto [ci, cj] = pn >= grid_min_size ? find_crossing_grid() : find_crossing(); if (ci == std::numeric_limits::max()) break; improved = true; std::reverse(path.begin() + ci + 1, path.begin() + cj + 1); + if (!seen_paths.insert(path_hash()).second) + break; } return improved; } diff --git a/src/libslic3r/MultiMaterialSegmentation.cpp b/src/libslic3r/MultiMaterialSegmentation.cpp index de0b60c229..ac5b7d213e 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -1324,10 +1324,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]); } @@ -1335,7 +1340,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); @@ -1864,6 +1869,54 @@ static void remove_multiple_edges_in_vertices(MMU_Graph &graph, const std::vecto } } +// diff_ex(subject, clip), one subject ExPolygon at a time against the part of `clip` in its box. The pieces are disjoint, +// so the result is the same; but ClipperLib never gets a whole finely painted layer at once, where re-linking the holes +// of its PolyTree (FixupFirstLefts) is quadratic in the thousands of regions. +static ExPolygons diff_ex_by_piece(const ExPolygons &subject, const ExPolygons &clip) +{ + // A coarse grid over the clip's boxes, so each piece only looks at the clip regions near it. + std::vector clip_bboxes; + clip_bboxes.reserve(clip.size()); + BoundingBox extent; + for (const ExPolygon &expoly : clip) { + clip_bboxes.emplace_back(get_extents(expoly)); + extent.merge(clip_bboxes.back()); + } + constexpr int GRID = 64; + const Point size = extent.defined ? extent.size() : Point(1, 1); + const coord_t cell_w = std::max(1, size.x() / GRID + 1), cell_h = std::max(1, size.y() / GRID + 1); + const auto cells = [&](const BoundingBox &bb, auto &&fn) { + const int x0 = std::clamp(int((bb.min.x() - extent.min.x()) / cell_w), 0, GRID - 1), x1 = std::clamp(int((bb.max.x() - extent.min.x()) / cell_w), 0, GRID - 1); + const int y0 = std::clamp(int((bb.min.y() - extent.min.y()) / cell_h), 0, GRID - 1), y1 = std::clamp(int((bb.max.y() - extent.min.y()) / cell_h), 0, GRID - 1); + for (int y = y0; y <= y1; ++y) + for (int x = x0; x <= x1; ++x) + fn(y * GRID + x); + }; + std::vector> grid(GRID * GRID); + if (extent.defined) + for (size_t i = 0; i < clip.size(); ++i) + cells(clip_bboxes[i], [&](int cell) { grid[cell].emplace_back(i); }); + + std::vector pieces(subject.size()); + tbb::parallel_for(size_t(0), subject.size(), [&](size_t idx) { + const BoundingBox bbox = get_extents(subject[idx]).inflated(SCALED_EPSILON); + std::vector near; + if (extent.defined && bbox.overlap(extent)) { + cells(bbox, [&](int cell) { append(near, grid[cell]); }); + sort_remove_duplicates(near); + } + Polygons nearby_clip; + for (size_t i : near) + if (clip_bboxes[i].overlap(bbox)) + polygons_append(nearby_clip, ClipperUtils::clip_clipper_polygons_with_subject_bbox(clip[i], bbox)); + pieces[idx] = nearby_clip.empty() ? ExPolygons{ subject[idx] } : diff_ex(subject[idx], nearby_clip); + }); + ExPolygons out; + for (ExPolygons &piece : pieces) + append(out, std::move(piece)); + return out; +} + static std::vector> merge_segmented_layers(const std::vector> &segmented_regions, std::vector> &&top_and_bottom_layers, const size_t num_facets_states, @@ -1878,18 +1931,18 @@ static std::vector> merge_segmented_layers(const std::ve 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) { for (size_t layer_idx = range.begin(); layer_idx < range.end(); ++layer_idx) { assert(segmented_regions[layer_idx].size() == num_facets_states); + ExPolygons top_and_bottom_all_colours; + for (const std::vector &top_and_bottom_by_extruder : top_and_bottom_layers) + append(top_and_bottom_all_colours, top_and_bottom_by_extruder[layer_idx]); // Zero is skipped because it is the default color of the volume 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]); - } - } - } + // All colours at once: taking them one after another made every piece go through ClipperLib once per + // colour, and it is the same area either way. + if (!top_and_bottom_all_colours.empty() && !segmented_regions_trimmed.empty()) + segmented_regions_trimmed = diff_ex_by_piece(segmented_regions_trimmed, top_and_bottom_all_colours); segmented_regions_merged[layer_idx][extruder_id - 1] = std::move(segmented_regions_trimmed); } @@ -2188,16 +2241,53 @@ 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. + 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]); diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index a50b328dbe..e666cb4789 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -854,11 +854,41 @@ void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) if (is_auto(stype) && config_detect_sharp_tails) { // BBS detect sharp tail + // Each island is tested only against the lower islands whose box meets its own: overlaps() tries every + // pair, which on a layer cut through a fine relief (thousands of islands above thousands) never ends. + std::vector lower_bboxes; + lower_bboxes.reserve(lower_polys.size()); + for (const ExPolygon &lower : lower_polys) + 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 - if (!overlaps(offset_ex(expoly, 0.1 * extrusion_width_scaled), lower_polys)) { + 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 < lower_polys.size(); ++i) + if (lower_bboxes[i].overlap(bbox)) + lower_nearby.emplace_back(lower_polys[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, whose whole boundary + // was otherwise intersected again for every 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(); } From d905f1a39bc824a88339f1ec6e6d09ea38bd2a5c Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Tue, 22 Sep 2026 16:43:53 +0200 Subject: [PATCH 05/37] Merge colour and top/bottom regions per island The merge took anything from 3 to 38 minutes at 0.1 mm / 2000k, now ~2.5. Every region is grouped with the islands it overlaps, so the result is the same. --- src/libslic3r/MultiMaterialSegmentation.cpp | 181 ++++++++++++++++---- src/libslic3r/PrintObject.cpp | 16 +- 2 files changed, 165 insertions(+), 32 deletions(-) diff --git a/src/libslic3r/MultiMaterialSegmentation.cpp b/src/libslic3r/MultiMaterialSegmentation.cpp index ac5b7d213e..b4565ac232 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -8,6 +8,7 @@ #include "MutablePolygon.hpp" #include "format.hpp" +#include #include #include @@ -1917,7 +1918,68 @@ static ExPolygons diff_ex_by_piece(const ExPolygons &subject, const ExPolygons & return out; } -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) @@ -1928,33 +1990,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 ClipperLib, 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); - ExPolygons top_and_bottom_all_colours; - for (const std::vector &top_and_bottom_by_extruder : top_and_bottom_layers) - append(top_and_bottom_all_colours, top_and_bottom_by_extruder[layer_idx]); - // 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) { + ExPolygons tops_all; + for (const ExPolygons &t : tops[bucket]) + 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]; - // All colours at once: taking them one after another made every piece go through ClipperLib once per - // colour, and it is the same area either way. - if (!top_and_bottom_all_colours.empty() && !segmented_regions_trimmed.empty()) - segmented_regions_trimmed = diff_ex_by_piece(segmented_regions_trimmed, top_and_bottom_all_colours); - - 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 @@ -2279,10 +2399,13 @@ std::vector> segmentation_by_painting(const PrintObject 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. - for (ExPolygons &faces : regions) - if (faces.size() > 1) - faces = union_ex(faces); + // 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) @@ -2310,7 +2433,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/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 0ab801c643..d4905d0b0d 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -1784,20 +1784,30 @@ void PrintObject::detect_surfaces_type() 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); } From 9a86d79038bd669cbc6e55e2445749ee2ec5c60d Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Tue, 22 Sep 2026 18:05:13 +0200 Subject: [PATCH 06/37] Tile the booleans on layers of many pieces ClipperLib slows down with the number of edges on a scan line, and a layer cut through a fine relief has tens of thousands of pieces. detect_surfaces_type ~50 s at 0.1 mm / 2000k, was ~145. --- src/libslic3r/ClipperUtils.cpp | 59 +++++++++ src/libslic3r/ClipperUtils.hpp | 5 + src/libslic3r/LayerRegion.cpp | 3 +- src/libslic3r/MultiMaterialSegmentation.cpp | 51 +------- src/libslic3r/PrintObject.cpp | 129 +++++++++++--------- tests/libslic3r/test_clipper_utils.cpp | 44 +++++++ 6 files changed, 181 insertions(+), 110 deletions(-) diff --git a/src/libslic3r/ClipperUtils.cpp b/src/libslic3r/ClipperUtils.cpp index 88be185f8c..27ebb1a2b9 100644 --- a/src/libslic3r/ClipperUtils.cpp +++ b/src/libslic3r/ClipperUtils.cpp @@ -4,6 +4,8 @@ #include #include +#include + #include "ClipperUtils.hpp" #include "Geometry.hpp" #include "ShortestPath.hpp" @@ -785,6 +787,63 @@ 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); } + +static Slic3r::ExPolygons clipper_ex_by_piece(ClipType clipType, const Slic3r::ExPolygons &subject, const Slic3r::Polygons &clip, ApplySafetyOffset do_safety_offset) +{ + // The subject ExPolygons are split into tiles by the centres of their boxes, a few dozen per tile, and each tile is one + // ClipperLib call with the clip cut to the box of the tile's ExPolygons. + BoundingBox extent; + std::vector bboxes; + bboxes.reserve(subject.size()); + for (const ExPolygon &expoly : subject) { + bboxes.emplace_back(get_extents(expoly)); + extent.merge(bboxes.back()); + } + const int tiles = std::clamp(int(std::sqrt(double(subject.size()) / 32.)), 1, 32); + std::vector> members(size_t(tiles * tiles)); + std::vector tile_bboxes(members.size()); + if (extent.defined) { + 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); + for (size_t i = 0; i < subject.size(); ++i) { + const Point c = bboxes[i].center(); + const size_t tile = 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)); + members[tile].emplace_back(i); + tile_bboxes[tile].merge(bboxes[i]); + } + } + 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(members.size()); + tbb::parallel_for(size_t(0), members.size(), [&](size_t tile) { + if (members[tile].empty()) + return; + Slic3r::ExPolygons local_subject; + local_subject.reserve(members[tile].size()); + for (size_t i : members[tile]) + 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_bboxes[tile].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] = _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); } diff --git a/src/libslic3r/ClipperUtils.hpp b/src/libslic3r/ClipperUtils.hpp index 5f05c8e3c4..a8e8fa5cf4 100644 --- a/src/libslic3r/ClipperUtils.hpp +++ b/src/libslic3r/ClipperUtils.hpp @@ -525,6 +525,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: ClipperLib 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/LayerRegion.cpp b/src/libslic3r/LayerRegion.cpp index ab2096184b..7305b6f8b8 100644 --- a/src/libslic3r/LayerRegion.cpp +++ b/src/libslic3r/LayerRegion.cpp @@ -72,10 +72,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 b4565ac232..37d6258cd8 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -1870,53 +1870,6 @@ static void remove_multiple_edges_in_vertices(MMU_Graph &graph, const std::vecto } } -// diff_ex(subject, clip), one subject ExPolygon at a time against the part of `clip` in its box. The pieces are disjoint, -// so the result is the same; but ClipperLib never gets a whole finely painted layer at once, where re-linking the holes -// of its PolyTree (FixupFirstLefts) is quadratic in the thousands of regions. -static ExPolygons diff_ex_by_piece(const ExPolygons &subject, const ExPolygons &clip) -{ - // A coarse grid over the clip's boxes, so each piece only looks at the clip regions near it. - std::vector clip_bboxes; - clip_bboxes.reserve(clip.size()); - BoundingBox extent; - for (const ExPolygon &expoly : clip) { - clip_bboxes.emplace_back(get_extents(expoly)); - extent.merge(clip_bboxes.back()); - } - constexpr int GRID = 64; - const Point size = extent.defined ? extent.size() : Point(1, 1); - const coord_t cell_w = std::max(1, size.x() / GRID + 1), cell_h = std::max(1, size.y() / GRID + 1); - const auto cells = [&](const BoundingBox &bb, auto &&fn) { - const int x0 = std::clamp(int((bb.min.x() - extent.min.x()) / cell_w), 0, GRID - 1), x1 = std::clamp(int((bb.max.x() - extent.min.x()) / cell_w), 0, GRID - 1); - const int y0 = std::clamp(int((bb.min.y() - extent.min.y()) / cell_h), 0, GRID - 1), y1 = std::clamp(int((bb.max.y() - extent.min.y()) / cell_h), 0, GRID - 1); - for (int y = y0; y <= y1; ++y) - for (int x = x0; x <= x1; ++x) - fn(y * GRID + x); - }; - std::vector> grid(GRID * GRID); - if (extent.defined) - for (size_t i = 0; i < clip.size(); ++i) - cells(clip_bboxes[i], [&](int cell) { grid[cell].emplace_back(i); }); - - std::vector pieces(subject.size()); - tbb::parallel_for(size_t(0), subject.size(), [&](size_t idx) { - const BoundingBox bbox = get_extents(subject[idx]).inflated(SCALED_EPSILON); - std::vector near; - if (extent.defined && bbox.overlap(extent)) { - cells(bbox, [&](int cell) { append(near, grid[cell]); }); - sort_remove_duplicates(near); - } - Polygons nearby_clip; - for (size_t i : near) - if (clip_bboxes[i].overlap(bbox)) - polygons_append(nearby_clip, ClipperUtils::clip_clipper_polygons_with_subject_bbox(clip[i], bbox)); - pieces[idx] = nearby_clip.empty() ? ExPolygons{ subject[idx] } : diff_ex(subject[idx], nearby_clip); - }); - ExPolygons out; - for (ExPolygons &piece : pieces) - append(out, std::move(piece)); - return out; -} // 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. @@ -2048,9 +2001,9 @@ static std::vector> merge_segmented_layers(const std::ve // 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) { - ExPolygons tops_all; + Polygons tops_all; for (const ExPolygons &t : tops[bucket]) - append(tops_all, t); + 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 --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index d4905d0b0d..76f5b6e6ec 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -42,6 +42,7 @@ #include #include +#include #include #include @@ -1722,7 +1723,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 @@ -1748,7 +1749,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 @@ -1779,7 +1780,7 @@ 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; @@ -1814,9 +1815,9 @@ void PrintObject::detect_surfaces_type() } } - 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); } } @@ -1907,7 +1908,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)); @@ -2247,10 +2248,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(); @@ -2258,56 +2259,66 @@ 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(); @@ -2605,11 +2616,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); } @@ -2669,8 +2677,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 { diff --git a/tests/libslic3r/test_clipper_utils.cpp b/tests/libslic3r/test_clipper_utils.cpp index ddec07089b..bab7932e34 100644 --- a/tests/libslic3r/test_clipper_utils.cpp +++ b/tests/libslic3r/test_clipper_utils.cpp @@ -291,3 +291,47 @@ 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); } + +TEST_CASE("Tiled diff and intersection cover the same area 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 auto xor_area = [](const ExPolygons &a, const ExPolygons &b) { return area(diff_ex(a, b)) + area(diff_ex(b, a)); }; + const ApplySafetyOffset safety = GENERATE(ApplySafetyOffset::No, ApplySafetyOffset::Yes); + const double tolerance = double(scaled(0.001)) * double(span); + + 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_THAT(area(diff_tiled), Catch::Matchers::WithinRel(area(diff_plain), 1e-9)); + CHECK(xor_area(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_THAT(area(intersection_tiled), Catch::Matchers::WithinRel(area(intersection_plain), 1e-9)); + CHECK(xor_area(intersection_tiled, intersection_plain) < tolerance); +} From 14751a8b06e4e5f1d40ac1f5b4fbea52df9dfebe Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Tue, 22 Sep 2026 19:19:54 +0200 Subject: [PATCH 07/37] Project painted faces onto the shell layers per tile Only the slices within the deepest shell offset decide the result, so the work is done per tile of the face. Top and bottom segmentation ~130 s at 0.1 mm / 2000k, was ~180. --- src/libslic3r/ClipperUtils.cpp | 64 +++++++++++---------- src/libslic3r/ClipperUtils.hpp | 10 ++++ src/libslic3r/MultiMaterialSegmentation.cpp | 48 +++++++++++----- 3 files changed, 79 insertions(+), 43 deletions(-) diff --git a/src/libslic3r/ClipperUtils.cpp b/src/libslic3r/ClipperUtils.cpp index 27ebb1a2b9..46bd0e725a 100644 --- a/src/libslic3r/ClipperUtils.cpp +++ b/src/libslic3r/ClipperUtils.cpp @@ -788,52 +788,58 @@ Slic3r::ExPolygons intersection_ex(const Slic3r::Surfaces &subject, const Slic3r 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); } -static Slic3r::ExPolygons clipper_ex_by_piece(ClipType clipType, const Slic3r::ExPolygons &subject, const Slic3r::Polygons &clip, ApplySafetyOffset do_safety_offset) -{ - // The subject ExPolygons are split into tiles by the centres of their boxes, a few dozen per tile, and each tile is one - // ClipperLib call with the clip cut to the box of the tile's ExPolygons. - BoundingBox extent; - std::vector bboxes; - bboxes.reserve(subject.size()); - for (const ExPolygon &expoly : subject) { - bboxes.emplace_back(get_extents(expoly)); - extent.merge(bboxes.back()); - } - const int tiles = std::clamp(int(std::sqrt(double(subject.size()) / 32.)), 1, 32); - std::vector> members(size_t(tiles * tiles)); - std::vector tile_bboxes(members.size()); - if (extent.defined) { +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); - for (size_t i = 0; i < subject.size(); ++i) { - const Point c = bboxes[i].center(); - const size_t tile = 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)); - members[tile].emplace_back(i); - tile_bboxes[tile].merge(bboxes[i]); + 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; } +} + +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 ClipperLib call with the clip cut to the tile's box. + const std::vector tiles = ClipperUtils::tile_expolygons(subject, 32); 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(members.size()); - tbb::parallel_for(size_t(0), members.size(), [&](size_t tile) { - if (members[tile].empty()) - return; + 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(members[tile].size()); - for (size_t i : members[tile]) + 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_bboxes[tile].inflated(SCALED_EPSILON); + 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] = _clipper_ex(clipType, ClipperUtils::ExPolygonsProvider(local_subject), ClipperUtils::PolygonsProvider(local_clip), do_safety_offset); + 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) diff --git a/src/libslic3r/ClipperUtils.hpp b/src/libslic3r/ClipperUtils.hpp index a8e8fa5cf4..44497eb2db 100644 --- a/src/libslic3r/ClipperUtils.hpp +++ b/src/libslic3r/ClipperUtils.hpp @@ -2,6 +2,7 @@ #define slic3r_ClipperUtils_hpp_ #include "libslic3r.h" +#include "BoundingBox.hpp" #include "ExPolygon.hpp" #include "Polygon.hpp" #include "Surface.hpp" @@ -330,6 +331,15 @@ 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 ClipperLib 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); + } // offset Polygons diff --git a/src/libslic3r/MultiMaterialSegmentation.cpp b/src/libslic3r/MultiMaterialSegmentation.cpp index 37d6258cd8..9bce9a6145 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -1401,27 +1401,47 @@ static inline std::vector> segmentation_top_and_bottom_l // 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. - // The per-layer ClipperLib work is independent once the trimmed slices and the offsets have been walked in order, - // so it runs in parallel; the offsets are accumulated exactly as the sequential walk did. + // 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 ClipperLib 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 trimmed(shell_layers.size()); - std::vector offsets(shell_layers.size()); - ExPolygons layer_slices_trimmed = input_expolygons[layer_idx]; - float offset = 0.f; + 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; - layer_slices_trimmed = intersection_ex(layer_slices_trimmed, input_expolygons[shell_layers[i]]); - trimmed[i] = layer_slices_trimmed; + offsets[i] = offset; } - std::vector shells(shell_layers.size()); - tbb::parallel_for(size_t(0), shell_layers.size(), [&](size_t i) { - shells[i] = opening_ex(intersection_ex(ex, offset_ex(trimmed[i], offsets[i])), stat.small_region_threshold); + 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() && !shells[i].empty(); ++i) - dst.emplace_back(shell_layers[i], std::move(shells[i])); + 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, From b1f0d6c6f665da02f8bc994fe7460913f2bc5c6e Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Tue, 22 Sep 2026 19:19:54 +0200 Subject: [PATCH 08/37] Move polygons instead of copying them on move MultiPoint had no rvalue constructor, so the derived move constructors bound to the const reference and copied; append reserved exactly, so collecting pieces one by one was quadratic. Colour segmentation ~3 s at 0.1 mm / 2000k, was ~40, and ordinary prints gain too. --- src/libslic3r/MultiPoint.hpp | 6 ++++-- src/libslic3r/Polygon.hpp | 4 ++-- src/libslic3r/Polyline.hpp | 4 ++-- src/libslic3r/Support/SupportMaterial.cpp | 2 +- src/libslic3r/Surface.hpp | 4 ++-- src/libslic3r/libslic3r.h | 8 ++++---- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/libslic3r/MultiPoint.hpp b/src/libslic3r/MultiPoint.hpp index 096230d22c..b4f8af0871 100644 --- a/src/libslic3r/MultiPoint.hpp +++ b/src/libslic3r/MultiPoint.hpp @@ -19,11 +19,13 @@ 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. + 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/Polygon.hpp b/src/libslic3r/Polygon.hpp index 146491e281..1e12c77c18 100644 --- a/src/libslic3r/Polygon.hpp +++ b/src/libslic3r/Polygon.hpp @@ -27,7 +27,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()); @@ -36,7 +36,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 f52c3c9bbd..aa9282970e 100644 --- a/src/libslic3r/Polyline.hpp +++ b/src/libslic3r/Polyline.hpp @@ -20,7 +20,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(); } @@ -41,7 +41,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/Support/SupportMaterial.cpp b/src/libslic3r/Support/SupportMaterial.cpp index 2850160d91..d9f21752ec 100644 --- a/src/libslic3r/Support/SupportMaterial.cpp +++ b/src/libslic3r/Support/SupportMaterial.cpp @@ -929,9 +929,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/Surface.hpp b/src/libslic3r/Surface.hpp index b63c283251..f9737f8cf5 100644 --- a/src/libslic3r/Surface.hpp +++ b/src/libslic3r/Surface.hpp @@ -61,7 +61,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) @@ -87,7 +87,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 c339da566a..06fce9796d 100644 --- a/src/libslic3r/libslic3r.h +++ b/src/libslic3r/libslic3r.h @@ -162,10 +162,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(); } From 2b4bdead739a661366af767a013e4081c7a9edb4 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Tue, 22 Sep 2026 22:14:39 +0200 Subject: [PATCH 09/37] Run a layer's regions in parallel where they are independent detect_surfaces_type, process_external_surfaces and the vertical shells each waited on their own heaviest layer in turn. The LOTR map plate slices in ~10.5 min at 0.1 mm / 2000k, was ~11.5; ~87 s at normal settings, was ~97. --- src/libslic3r/PrintObject.cpp | 278 ++++++++++++++++++++-------------- 1 file changed, 161 insertions(+), 117 deletions(-) diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 76f5b6e6ec..469e8e1c5b 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -1665,7 +1665,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) @@ -2085,29 +2087,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( @@ -2124,7 +2128,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; @@ -2191,8 +2195,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) { @@ -2207,9 +2213,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() @@ -2329,19 +2335,128 @@ void PrintObject::discover_vertical_shells() // 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 { - std::array key; - Polygons shell; - Polygons holes; + 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(); + } - for (size_t region_id = 0; region_id < this->num_printing_regions(); ++ region_id) { + 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)); @@ -2381,7 +2496,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, &shell_accumulations] + [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) { @@ -2431,98 +2546,19 @@ void PrintObject::discover_vertical_shells() } } #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ - const std::array accumulation_key{ 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()) }; - std::vector *accumulations = shell_accumulations.empty() ? nullptr : &shell_accumulations[idx_layer]; - const auto reused = accumulations == nullptr ? nullptr : + const AccumulationKey key = accumulation_key(region_config, layerm); + const ShellAccumulation *reused = shell_accumulations.empty() ? nullptr : [&]() -> const ShellAccumulation * { - for (const ShellAccumulation &a : *accumulations) - if (a.key == accumulation_key) + 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 { - 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); - } - if (accumulations != nullptr) - accumulations->push_back({ accumulation_key, shell, 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)); @@ -2706,7 +2742,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 From fb03d1a1cb5d43686d36124f471932511f31cf37 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Wed, 23 Sep 2026 02:03:02 +0200 Subject: [PATCH 10/37] Visit seam candidates as the search finds them Collecting every candidate within the radius into a vector cost more than the search itself. Same order, so the seams are unchanged; align_seam_points ~19.6 s at 0.1 mm / 2000k, was ~21. --- src/libslic3r/GCode/SeamPlacer.cpp | 30 ++++++++------ src/libslic3r/KDTreeIndirect.hpp | 30 ++++++++++++++ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_kdtree.cpp | 66 ++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 tests/libslic3r/test_kdtree.cpp diff --git a/src/libslic3r/GCode/SeamPlacer.cpp b/src/libslic3r/GCode/SeamPlacer.cpp index d527ec6aa9..82da25aa8e 100644 --- a/src/libslic3r/GCode/SeamPlacer.cpp +++ b/src/libslic3r/GCode/SeamPlacer.cpp @@ -1178,21 +1178,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>()) @@ -1204,6 +1204,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 37c10827b1..8b48ffc818 100644 --- a/src/libslic3r/KDTreeIndirect.hpp +++ b/src/libslic3r/KDTreeIndirect.hpp @@ -313,6 +313,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/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index b935519177..22553a4754 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable(${_TEST_NAME}_tests test_filament_mixer.cpp test_fill_plane_path.cpp test_geometry.cpp + test_kdtree.cpp test_multimaterial_segmentation.cpp test_placeholder_parser.cpp test_polygon.cpp diff --git a/tests/libslic3r/test_kdtree.cpp b/tests/libslic3r/test_kdtree.cpp new file mode 100644 index 0000000000..dbcf86674a --- /dev/null +++ b/tests/libslic3r/test_kdtree.cpp @@ -0,0 +1,66 @@ +#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); +} From 82f37ddb3a2fd854dbcbae223ae1f9a78457969d Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Wed, 23 Sep 2026 14:51:07 +0200 Subject: [PATCH 11/37] Fix the Windows build: near and far are macros there bridge_over_infill's helper for splitting polygons by proximity named its locals near and far. The Windows headers define both as macros that expand to nothing, so "Polygons near;" declared nothing and the uses of it did not compile. Renamed; no behaviour change. --- src/libslic3r/PrintObject.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 469e8e1c5b..f530bb13c0 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -3532,11 +3532,13 @@ void PrintObject::bridge_over_infill() // expansion_area is a clean, non-overlapping set, so uniting it with a bridge or cutting a bridge // out of it only changes the polygons near that bridge. The rest are passed through untouched // instead of being fed to ClipperLib with the whole layer again for every candidate. - const auto split_near = [](const Polygons &polys, const BoundingBox &bbox, Polygons &far) { - Polygons near; + // 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) ? near : far).emplace_back(p); - return near; + (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(); From 11a5971cefef69c2ae21bef3aa962abb2d75c83e Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Wed, 23 Sep 2026 16:16:46 +0200 Subject: [PATCH 12/37] Include what the new code uses The Windows build stopped on test_kdtree.cpp: it calls std::iota without including , which libstdc++ happens to pull in anyway. Added there, and the same for and / where the recent changes rely on them being included by something else. --- src/libslic3r/ClipperUtils.cpp | 1 + src/libslic3r/GCode/SeamPlacer.cpp | 1 + tests/libslic3r/test_kdtree.cpp | 1 + 3 files changed, 3 insertions(+) diff --git a/src/libslic3r/ClipperUtils.cpp b/src/libslic3r/ClipperUtils.cpp index 46bd0e725a..1893e192c2 100644 --- a/src/libslic3r/ClipperUtils.cpp +++ b/src/libslic3r/ClipperUtils.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include diff --git a/src/libslic3r/GCode/SeamPlacer.cpp b/src/libslic3r/GCode/SeamPlacer.cpp index 82da25aa8e..04d2360f0e 100644 --- a/src/libslic3r/GCode/SeamPlacer.cpp +++ b/src/libslic3r/GCode/SeamPlacer.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include diff --git a/tests/libslic3r/test_kdtree.cpp b/tests/libslic3r/test_kdtree.cpp index dbcf86674a..679a1465a7 100644 --- a/tests/libslic3r/test_kdtree.cpp +++ b/tests/libslic3r/test_kdtree.cpp @@ -1,5 +1,6 @@ #include +#include #include #include From de1dfd06111ac50604fb23302d0f07e9df8ad26a Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Wed, 30 Sep 2026 08:49:34 +0200 Subject: [PATCH 13/37] Hand the island's walls over instead of copying them append(const ExtrusionEntity &) clones; the collection each island produced was deep-copied into the layer's loops and then thrown away. The no-overlap areas are moved as well. --- src/libslic3r/PerimeterGenerator.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index a5bf29fcb4..4bf320df73 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -2908,10 +2908,11 @@ void PerimeterGenerator::process_arachne() }); for (ArachneSurfaceResult &result : results) { if (result.has_loops) - this->loops->append(result.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); - this->fill_no_overlap->insert(this->fill_no_overlap->end(), result.no_overlap.begin(), result.no_overlap.end()); + append(*this->fill_no_overlap, std::move(result.no_overlap)); } } From 4a63a7d916d17d1d0bad706bfb8d6e4f5fcfca78 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Wed, 30 Sep 2026 08:49:34 +0200 Subject: [PATCH 14/37] Compare the tiled booleans polygon by polygon Area alone would pass on a result whose pieces were merged across tiles or which kept the cut edges of the clip. The rings are compared after rotating each to its lowest point and sorting, so only the ordering is free. The fixture now also asserts it really is split into more than one tile, which is the path being tested. --- tests/libslic3r/test_clipper_utils.cpp | 47 ++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/tests/libslic3r/test_clipper_utils.cpp b/tests/libslic3r/test_clipper_utils.cpp index bab7932e34..7090628057 100644 --- a/tests/libslic3r/test_clipper_utils.cpp +++ b/tests/libslic3r/test_clipper_utils.cpp @@ -1,7 +1,9 @@ #include +#include #include #include +#include #include #include "libslic3r/ClipperUtils.hpp" @@ -292,7 +294,38 @@ TEST_CASE("Top level expolygons of an even-odd union", "[ClipperUtils]") { REQUIRE(top_level_expolygons(reference).size() == 1); } -TEST_CASE("Tiled diff and intersection cover the same area as the plain calls", "[ClipperUtils]") { +// 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; +} + +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); @@ -319,19 +352,19 @@ TEST_CASE("Tiled diff and intersection cover the same area as the plain calls", } polygons_append(clip, to_polygons(big)); - const auto xor_area = [](const ExPolygons &a, const ExPolygons &b) { return area(diff_ex(a, b)) + area(diff_ex(b, a)); }; const ApplySafetyOffset safety = GENERATE(ApplySafetyOffset::No, ApplySafetyOffset::Yes); - const double tolerance = double(scaled(0.001)) * double(span); + + // 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); 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_THAT(area(diff_tiled), Catch::Matchers::WithinRel(area(diff_plain), 1e-9)); - CHECK(xor_area(diff_tiled, diff_plain) < tolerance); + CHECK(canonical_rings(diff_tiled) == canonical_rings(diff_plain)); 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_THAT(area(intersection_tiled), Catch::Matchers::WithinRel(area(intersection_plain), 1e-9)); - CHECK(xor_area(intersection_tiled, intersection_plain) < tolerance); + CHECK(canonical_rings(intersection_tiled) == canonical_rings(intersection_plain)); } From d80c69341cc82f3b84ed8cf1cdd2bf1c72b83e43 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Wed, 30 Sep 2026 08:49:34 +0200 Subject: [PATCH 15/37] Say what the code does, not what it replaced The timings and the runs that never finished belong in the commit messages, where they can be read against the change; a reader of the code cannot check them. Kept the cost that still explains the design. MultiPoint also spells out the consequence: a moved-from Polygon or Polyline is now really empty where it used to silently keep its points. The two wall spacing comments the parallel loop reindented are plain ASCII now, so the whole file is. --- src/libslic3r/GCode/OrderingStrategies.cpp | 10 +++++----- src/libslic3r/MultiPoint.hpp | 4 +++- src/libslic3r/PerimeterGenerator.cpp | 4 ++-- src/libslic3r/Support/TreeSupport.cpp | 10 +++++----- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/libslic3r/GCode/OrderingStrategies.cpp b/src/libslic3r/GCode/OrderingStrategies.cpp index d52a326e2e..69a19954c3 100644 --- a/src/libslic3r/GCode/OrderingStrategies.cpp +++ b/src/libslic3r/GCode/OrderingStrategies.cpp @@ -137,8 +137,8 @@ bool tsp_remove_crossings(std::vector& path, const Points& centers) // 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 crossing as the all-pairs scan - // (smallest i, then smallest j), so the result is unchanged; with thousands of islands on a layer the all-pairs - // scan, repeated after every reversal, never finished. Rebuilding the grid costs more than it saves on small inputs. + // (smallest i, then smallest j), so the result is unchanged. The all-pairs scan is quadratic in the edge count and + // runs again after every reversal; 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) @@ -190,9 +190,9 @@ bool tsp_remove_crossings(std::vector& path, const Points& centers) // Cap iterations to prevent infinite loops on collinear/overlapping segments. int max_iters = static_cast(pn * pn); bool improved = false; - // Reversing between two segments that only touch or overlap along a line need not remove the intersection, and on - // islands laid out on a regular grid (a tiled texture, an array of parts) the loop cycled through the same orderings - // until the pn * pn cap - effectively forever. Stop as soon as an ordering repeats: until then this is the same loop. + // Reversing between two segments that only touch or overlap along a line need not remove the intersection, so on + // islands laid out on a regular grid (a tiled texture, an array of parts) the loop can cycle through the same + // orderings until the pn * pn cap. Stop as soon as an ordering repeats; up to that point this is the same loop. std::unordered_set seen_paths; const auto path_hash = [&path]() { uint64_t h = 1469598103934665603ull; // FNV-1a diff --git a/src/libslic3r/MultiPoint.hpp b/src/libslic3r/MultiPoint.hpp index b4f8af0871..19b55ceb5a 100644 --- a/src/libslic3r/MultiPoint.hpp +++ b/src/libslic3r/MultiPoint.hpp @@ -22,7 +22,9 @@ public: 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. + // 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) noexcept { points = std::move(other.points); return *this; } diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index 4bf320df73..dc6b77f970 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -2754,10 +2754,10 @@ void PerimeterGenerator::process_arachne() // 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” + // 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 + half int spacing + // Normal: half ext spacing plus half int spacing : ( this->ext_perimeter_flow.scaled_spacing()/2.0 + this->perimeter_flow.scaled_spacing()/2.0 ); diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index e666cb4789..8fcd2b4fbc 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -854,8 +854,8 @@ void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) if (is_auto(stype) && config_detect_sharp_tails) { // BBS detect sharp tail - // Each island is tested only against the lower islands whose box meets its own: overlaps() tries every - // pair, which on a layer cut through a fine relief (thousands of islands above thousands) never ends. + // 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(lower_polys.size()); for (const ExPolygon &lower : lower_polys) @@ -870,9 +870,9 @@ void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) for (size_t i = 0; i < lower_polys.size(); ++i) if (lower_bboxes[i].overlap(bbox)) lower_nearby.emplace_back(lower_polys[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, whose whole boundary - // was otherwise intersected again for every island above. + // 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()) From 7b0e2f3ce5f007be695ed3df82d2be142f719a79 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Fri, 2 Oct 2026 20:19:05 -0300 Subject: [PATCH 16/37] Keep the G-code identical to main on Clipper2 Since main moved to Clipper2, parts of this branch no longer gave the same G-code as main: - bridge_over_infill dropped expand(limiting_area, 0.3 * flow.spacing()) as a no-op. The offset is below one unit, but Clipper2 still unites its result, which splits and merges touching polygons and so changes the anchor lines. Running it on the polygons next to the bridge gives main's anchors without the whole-layer pass. - tsp_remove_crossings stopped at the first repeated ordering, where main runs on to its pn * pn cap. The loop is periodic from that point, so it now takes only the steps to the ordering main stops on. - With a single tile, the tiled booleans now make the plain call instead of cutting the clip to the tile first. The tiled boolean test compared rings exactly. With the safety offset a tile unites only the clip polygons near it, and Clipper2 can then round a crossing 1 unit differently, so that case allows 1 unit. Comments that named ClipperLib now say Clipper, and docs/HLSD/polygon-clipping.md describes the tiled booleans. G-code of the five handy models in four configurations and of a baked texture relief is byte-identical to main. Colour-painted models still differ: segmenting each island on its own splits a colour's region into different pieces than one diagram over the layer, which on one model also changes the first layer's tool order. --- docs/HLSD/polygon-clipping.md | 17 ++++++++++++++ src/libslic3r/ClipperUtils.cpp | 5 ++++- src/libslic3r/ClipperUtils.hpp | 4 ++-- src/libslic3r/GCode/OrderingStrategies.cpp | 25 +++++++++++++-------- src/libslic3r/MultiMaterialSegmentation.cpp | 4 ++-- src/libslic3r/PrintObject.cpp | 11 +++++---- tests/libslic3r/test_clipper_utils.cpp | 16 +++++++++++-- 7 files changed, 60 insertions(+), 22 deletions(-) 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/src/libslic3r/ClipperUtils.cpp b/src/libslic3r/ClipperUtils.cpp index 1893e192c2..8770603784 100644 --- a/src/libslic3r/ClipperUtils.cpp +++ b/src/libslic3r/ClipperUtils.cpp @@ -819,8 +819,11 @@ namespace ClipperUtils { 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 ClipperLib call with the clip cut to the tile's box. + // 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) diff --git a/src/libslic3r/ClipperUtils.hpp b/src/libslic3r/ClipperUtils.hpp index 44497eb2db..52e0abab9c 100644 --- a/src/libslic3r/ClipperUtils.hpp +++ b/src/libslic3r/ClipperUtils.hpp @@ -331,7 +331,7 @@ 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 ClipperLib on a + // 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 { @@ -537,7 +537,7 @@ Slic3r::ExPolygons intersection_ex(const Slic3r::Surfaces &subject, const Slic3r 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: ClipperLib slows down with the number of edges crossing a scan line. +// 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); diff --git a/src/libslic3r/GCode/OrderingStrategies.cpp b/src/libslic3r/GCode/OrderingStrategies.cpp index 69a19954c3..ba299dd76b 100644 --- a/src/libslic3r/GCode/OrderingStrategies.cpp +++ b/src/libslic3r/GCode/OrderingStrategies.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include @@ -188,26 +187,34 @@ bool tsp_remove_crossings(std::vector& path, const Points& centers) // Process crossings one at a time: find first, reverse it, restart scan. // Cap iterations to prevent infinite loops on collinear/overlapping segments. - int max_iters = static_cast(pn * pn); + const int max_iters = static_cast(pn * pn); bool improved = false; // Reversing between two segments that only touch or overlap along a line need not remove the intersection, so on // islands laid out on a regular grid (a tiled texture, an array of parts) the loop can cycle through the same - // orderings until the pn * pn cap. Stop as soon as an ordering repeats; up to that point this is the same loop. - std::unordered_set seen_paths; + // orderings until the pn * pn cap. Once an ordering repeats the rest of the loop is periodic, so only the steps + // to the ordering the capped loop would have stopped on are taken. + std::unordered_map seen_paths; // path hash -> reversals done when it was reached const auto path_hash = [&path]() { uint64_t h = 1469598103934665603ull; // FNV-1a for (size_t idx : path) h = (h ^ uint64_t(idx)) * 1099511628211ull; return h; }; - seen_paths.insert(path_hash()); - while (max_iters-- > 0) { + const auto reverse_first_crossing = [&]() { auto [ci, cj] = pn >= grid_min_size ? find_crossing_grid() : find_crossing(); - if (ci == std::numeric_limits::max()) break; - improved = true; + if (ci == std::numeric_limits::max()) + return false; std::reverse(path.begin() + ci + 1, path.begin() + cj + 1); - if (!seen_paths.insert(path_hash()).second) + return true; + }; + seen_paths.emplace(path_hash(), 0); + for (int iter = 1; iter <= max_iters && reverse_first_crossing(); ++iter) { + improved = true; + if (auto [it, inserted] = seen_paths.emplace(path_hash(), iter); !inserted) { + for (int steps = (max_iters - iter) % (iter - it->second); steps > 0; --steps) + reverse_first_crossing(); break; + } } return improved; } diff --git a/src/libslic3r/MultiMaterialSegmentation.cpp b/src/libslic3r/MultiMaterialSegmentation.cpp index 9bce9a6145..7eba251df6 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -1403,7 +1403,7 @@ static inline std::vector> segmentation_top_and_bottom_l // `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 ClipperLib call stays the size of a tile rather than of a layer cut through a fine relief, and the tiles run in + // 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) { @@ -1965,7 +1965,7 @@ static std::vector> merge_segmented_layers(const std::ve BOOST_LOG_TRIVIAL(debug) << "Print object segmentation - Merging segmented layers in parallel - Begin"; // 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 ClipperLib, splitting + // 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) { diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index f530bb13c0..1b1b44afcd 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -3531,7 +3531,7 @@ void PrintObject::bridge_over_infill() std::map boundary_by_spacing; // expansion_area is a clean, non-overlapping set, so uniting it with a bridge or cutting a bridge // out of it only changes the polygons near that bridge. The rest are passed through untouched - // instead of being fed to ClipperLib with the whole layer again for every candidate. + // 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) { @@ -3568,6 +3568,7 @@ void PrintObject::bridge_over_infill() Polygons limiting_area; const Polygons near_expansion = split_near(expansion_area, get_extents(area_to_be_bridge).inflated(SCALED_EPSILON), limiting_area); + const size_t num_far = limiting_area.size(); append(limiting_area, union_(area_to_be_bridge, near_expansion)); auto boundary_it = boundary_by_spacing.find(flow.scaled_spacing()); @@ -3577,11 +3578,9 @@ void PrintObject::bridge_over_infill() .first; Polylines boundary_plines = boundary_it->second; { - // No offset here: flow.spacing() is in mm, so the expand(limiting_area, 0.3 * flow.spacing()) - // this used to be moved the outline by 0.135 scaled units - nothing beyond rounding - while - // costing a whole-layer ClipperLib pass for every candidate. limiting_area is already a clean - // union, so its own outline is the same boundary. - Polylines limiting_plines = to_polylines(limiting_area); + // The sub-unit offset (spacing is in mm) still re-unites touching polygons by the bridge, which the anchors depend on. + Polylines limiting_plines = to_polylines(Polygons(limiting_area.begin(), limiting_area.begin() + num_far)); + append(limiting_plines, to_polylines(expand(Polygons(limiting_area.begin() + num_far, limiting_area.end()), 0.3 * flow.spacing()))); boundary_plines.insert(boundary_plines.end(), limiting_plines.begin(), limiting_plines.end()); } diff --git a/tests/libslic3r/test_clipper_utils.cpp b/tests/libslic3r/test_clipper_utils.cpp index 7090628057..889258f7e4 100644 --- a/tests/libslic3r/test_clipper_utils.cpp +++ b/tests/libslic3r/test_clipper_utils.cpp @@ -325,6 +325,15 @@ static std::vector> canonical_rings(const ExPolygons &expol 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; @@ -358,13 +367,16 @@ TEST_CASE("Tiled diff and intersection return the same polygons as the plain cal // 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(canonical_rings(diff_tiled) == canonical_rings(diff_plain)); + 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(canonical_rings(intersection_tiled) == canonical_rings(intersection_plain)); + CHECK(same_rings(intersection_tiled, intersection_plain, tolerance)); } From 7f554fde046b341239d2ff896be2cb73b195e4d7 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Fri, 9 Oct 2026 22:11:47 +0800 Subject: [PATCH 17/37] Restore the internal bridge expansion from #16056 lost in the main merge --- src/libslic3r/PrintObject.cpp | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 8989b7c052..32edf1c388 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -3807,9 +3807,9 @@ void PrintObject::bridge_over_infill() // 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 uniting it with a bridge or 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. + // 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) { @@ -3845,22 +3845,18 @@ void PrintObject::bridge_over_infill() if (area_to_be_bridge.empty()) continue; - Polygons limiting_area; - const Polygons near_expansion = split_near(expansion_area, get_extents(area_to_be_bridge).inflated(SCALED_EPSILON), - limiting_area); - const size_t num_far = limiting_area.size(); - append(limiting_area, union_(area_to_be_bridge, near_expansion)); + // 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.3 * flow.scaled_spacing()))) + .emplace(flow.scaled_spacing(), to_polylines(expand(total_fill_area, 1.3f * flow.scaled_spacing()))) .first; Polylines boundary_plines = boundary_it->second; { - // The sub-unit offset (spacing is in mm) still re-unites touching polygons by the bridge, which the anchors depend on. - Polylines limiting_plines = to_polylines(Polygons(limiting_area.begin(), limiting_area.begin() + num_far)); - append(limiting_plines, to_polylines(expand(Polygons(limiting_area.begin() + num_far, limiting_area.end()), 0.3 * flow.spacing()))); + 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()); } From ac8ac299a5c1bf1eb835c4f36c69f8af378cb166 Mon Sep 17 00:00:00 2001 From: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:36:16 -0300 Subject: [PATCH 18/37] add missing headers Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> --- src/libslic3r/GCode/OrderingStrategies.cpp | 1 + tests/libslic3r/test_clipper_utils.cpp | 2 ++ tests/libslic3r/test_kdtree.cpp | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/src/libslic3r/GCode/OrderingStrategies.cpp b/src/libslic3r/GCode/OrderingStrategies.cpp index 1e39aafa3b..31b80e2697 100644 --- a/src/libslic3r/GCode/OrderingStrategies.cpp +++ b/src/libslic3r/GCode/OrderingStrategies.cpp @@ -6,6 +6,7 @@ #include "../ShortestPath.hpp" #include "libslic3r/Point.hpp" #include "libslic3r/PrintConfig.hpp" +#include "libslic3r/libslic3r.h" #include #include diff --git a/tests/libslic3r/test_clipper_utils.cpp b/tests/libslic3r/test_clipper_utils.cpp index c15b499a3d..2d0e1fab1b 100644 --- a/tests/libslic3r/test_clipper_utils.cpp +++ b/tests/libslic3r/test_clipper_utils.cpp @@ -11,8 +11,10 @@ #include #include #include +#include #include +#include #include #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/ExPolygon.hpp" diff --git a/tests/libslic3r/test_kdtree.cpp b/tests/libslic3r/test_kdtree.cpp index 679a1465a7..fee8c0e98a 100644 --- a/tests/libslic3r/test_kdtree.cpp +++ b/tests/libslic3r/test_kdtree.cpp @@ -3,6 +3,11 @@ #include #include #include +#include +#include + +#include +#include #include "libslic3r/KDTreeIndirect.hpp" #include "libslic3r/Point.hpp" From 0d5b3a79d73e22f7eda4dc8e326425c30e514b7d Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:02:15 +0800 Subject: [PATCH 19/37] Keep a Scalar Config Option's Value When Deserializing Fails (#16641) A failed stream extraction writes 0 into its target, so Float, Int, Percent and FloatOrPercent options were overwritten before deserialize reported the error, and Point/Point3 kept a partially scanned value. Parse into a temporary and assign only on success; FloatOrPercent sets its percent flag only then too. --- src/libslic3r/Config.hpp | 50 ++++++++++++++++++++++++--------- tests/libslic3r/test_config.cpp | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index 9d76262ec6..fd1745d7cc 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -841,8 +841,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 +1037,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 +1260,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 +1377,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 +1554,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 +1678,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/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index 8017c60dcf..3fac2f284e 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; From 74d4392dae0d9644de60c6795b743b7edcff8b49 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:38:20 +0800 Subject: [PATCH 20/37] Keep a Disabled Filament Override Off After a Restart (Key List Variant) (#16650) Keep a Disabled Filament Override Off After a Restart A child preset stores "nil" both for an override the user switched off and for a per-variant column it inherits from its parent, and the loader filled every nil from the parent. Whenever the parent preset sets Retract Before Wipe or another override, switching it off lasted only until the next restart, project open or newer cloud copy. Treat the keys in is_filament_extruder_override_key, now including the ironing overrides, as "nil means off": Preset::save and the project-embedded preset diff write such an option with all its columns, and the loader keeps its nil columns while still mapping them onto the parent's variants by name. Every other nullable option keeps nil as "inherit this column". The cloud diff only force-emits a disabled override whose default is a real value. --- src/libslic3r/Config.hpp | 7 +- src/libslic3r/Preset.cpp | 13 ++-- src/libslic3r/PrintConfig.cpp | 15 ++-- tests/libslic3r/test_config.cpp | 72 +++++++++++++++++++ .../libslic3r/test_preset_bundle_loading.cpp | 55 ++++++++++++++ 5 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index fd1745d7cc..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]; } } 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..212d8d8cb5 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) @@ -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/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index 3fac2f284e..dcf808ab92 100644 --- a/tests/libslic3r/test_config.cpp +++ b/tests/libslic3r/test_config.cpp @@ -1727,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_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); +} From 6626377abc177587c7e60da487d69d86b4cc7f62 Mon Sep 17 00:00:00 2001 From: SoftFever <103989404+SoftFever@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:38:44 +0800 Subject: [PATCH 21/37] Stop re-asking for plugin permissions granted in an earlier session (#16658) --- src/slic3r/plugin/PluginAuditManager.cpp | 473 ++++++++--- src/slic3r/plugin/PluginAuditManager.hpp | 47 +- src/slic3r/plugin/PluginFsUtils.cpp | 2 + src/slic3r/plugin/PluginFsUtils.hpp | 2 + src/slic3r/plugin/PyPluginTrampoline.hpp | 2 +- ...PrinterAgentPluginCapabilityTrampoline.hpp | 2 +- tests/slic3rutils/test_plugin_audit.cpp | 770 ++++++++++++++++++ 7 files changed, 1150 insertions(+), 148 deletions(-) diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index e913763891..9cf010d803 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); } // --------------------------------------------------------------------------- @@ -575,34 +632,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 +692,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 +794,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 +847,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 +862,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 +882,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 +999,25 @@ 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; + +// 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 +1029,205 @@ 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; + + 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; +} + +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 +1255,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() { + // Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame). + 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) -- diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 41d3dca1e8..ac6d99a4da 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,32 @@ private: static int audit_hook(const char* event, PyObject* args, void* user_data); + // 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 +212,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/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index 267cdb4099..debf5784b1 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 { @@ -349,3 +375,747 @@ 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("")); +} From 069bd402d984ba5e8ed5fca56ce8cbbb96395289 Mon Sep 17 00:00:00 2001 From: SoftFever <103989404+SoftFever@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:06:33 +0800 Subject: [PATCH 22/37] Forward CMake arguments after -- in the macOS build script (#16683) Unknown options and missing option values now stop the script instead of being ignored. --- build_release_macos.sh | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) 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" ) From e7d6cc32a7c8c3a29a7114dc1a26a81f93ea84fb Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 20 Oct 2026 18:04:42 +0800 Subject: [PATCH 23/37] fix: deny plugin from modifying plugin sidecar (#16634) --- src/slic3r/plugin/PluginAuditManager.cpp | 13 ++++++++----- tests/slic3rutils/test_plugin_audit.cpp | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 9cf010d803..7464a8a41f 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -353,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", }; } @@ -1283,8 +1285,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/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index debf5784b1..de0c1cf87e 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -69,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(); @@ -81,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") @@ -136,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)") { @@ -176,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"); From cdb7eda54f416d242409047148077d1cf8633914 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:52:44 +0800 Subject: [PATCH 24/37] Update GMP to 6.3.0 to Avoid the x18 Register on Apple Silicon (#16686) * Update GMP to 6.3.0 to Avoid the x18 Register on Apple Silicon GMP 6.2.1's arm64 assembly keeps loop counters in x18, which Apple reserves and macOS zeroes on every return to user space (syscalls, page faults, preemption). When that happens inside one of those loops the counter underflows and the routine walks the stack until the guard page: EXC_BAD_ACCESS / SIGBUS inside __gmpn_* whenever CGAL falls back to exact arithmetic (texture import boundary smoothing, text emboss cuts, mesh repair, mesh boolean). 6.3.0 moves those counters to ordinary temporaries (gmp-6.2 changeset f4ff6ff711ed); no x18 use remains under mpn/arm64. The GCC 15 patch applies unchanged apart from a line offset. The tarball comes from the GNU mirror, like MPFR. Windows is unaffected: MSVC copies the prebuilt libgmp-10 and the arm64 job stages MSYS2's GMP. Built with the deps recipe flags and ran make check on Linux x86_64 (clang 18, 200/200) and macOS arm64 (Apple clang, 199/199). * Add gmplib.org as a Fallback Download for GMP Same two-source pattern as MPFR: the GNU mirror first, the project's own server second, one hash for both. * Use the OrcaSlicer_deps Mirror as the GMP Fallback Download SoftFever published gmp-6.3.0.tar.xz in SoftFever/OrcaSlicer_deps, the same mirror 6.2.1 came from. Both sources serve the .tar.xz release file, byte-identical (one SHA-256): the GNU server first, the mirror as fallback. * Stage GMP 6.3.0 for the Flatpak Build The Flatpak sandbox has no network, so the manifest pre-downloads every dependency tarball into external-packages/. Point the GMP entry at the 6.3.0 .tar.xz the deps recipe now expects. --- deps/GMP/GMP.cmake | 8 ++++++-- scripts/flatpak/com.orcaslicer.OrcaSlicer.yml | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) 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/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 From 37dcc65752a24b66dabaa9be627eb0204bd09274 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 20 Oct 2026 21:31:25 +0800 Subject: [PATCH 25/37] fix: Allow plugins to read the embedded Python stdlib (#16669) * Stop re-asking for plugin permissions granted in an earlier session * fix: Allow plugins to read the embedded Python stdlib --------- Co-authored-by: SoftFever --- src/slic3r/plugin/PluginAuditManager.cpp | 46 +++++++++++++++++++++++- src/slic3r/plugin/PluginAuditManager.hpp | 5 +++ tests/slic3rutils/test_plugin_audit.cpp | 34 ++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 7464a8a41f..2e51545512 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -500,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. @@ -1017,6 +1023,22 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, b 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. @@ -1034,6 +1056,16 @@ bool request_permission(AuditEventCategory category, const std::string& plugin_k 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); @@ -1200,6 +1232,18 @@ bool PluginAuditManager::is_stdlib_file(const std::string& filename) 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()) @@ -1268,7 +1312,7 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user void PluginAuditManager::install_hook() { - // Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame). + // 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 diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index ac6d99a4da..af4172d7f1 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -173,6 +173,11 @@ 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); diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index de0c1cf87e..fc2c35c60c 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -1132,3 +1132,37 @@ TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][ 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); +} From 651eed392ca10774735f5a212c7addbc4fd82b86 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 21 Oct 2026 01:13:02 +0800 Subject: [PATCH 26/37] Attach holes Clipper2 misplaces to the expolygon around them --- src/libslic3r/ClipperUtils.cpp | 229 ++++++++++++++++++++--- src/libslic3r/ClipperUtils.hpp | 5 + tests/libslic3r/test_clipper_utils.cpp | 244 +++++++++++++++++++++++++ 3 files changed, 453 insertions(+), 25 deletions(-) diff --git a/src/libslic3r/ClipperUtils.cpp b/src/libslic3r/ClipperUtils.cpp index 1f8cf8a756..0b4033ce8f 100644 --- a/src/libslic3r/ClipperUtils.cpp +++ b/src/libslic3r/ClipperUtils.cpp @@ -7,11 +7,13 @@ #include #include #include +#include #include #include #include "ClipperUtils.hpp" +#include "AABBTreeIndirect.hpp" #include "BoundingBox.hpp" #include "ExPolygon.hpp" #include "Point.hpp" @@ -278,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; @@ -287,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) @@ -307,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; } @@ -829,6 +907,48 @@ namespace ClipperUtils { 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) @@ -1075,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); } } @@ -1100,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; } @@ -1138,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()); @@ -1145,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 216815aa65..43904ff4b4 100644 --- a/src/libslic3r/ClipperUtils.hpp +++ b/src/libslic3r/ClipperUtils.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include namespace Slic3r { @@ -342,6 +343,10 @@ namespace ClipperUtils { }; [[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 diff --git a/tests/libslic3r/test_clipper_utils.cpp b/tests/libslic3r/test_clipper_utils.cpp index 2d0e1fab1b..4f8d872ae5 100644 --- a/tests/libslic3r/test_clipper_utils.cpp +++ b/tests/libslic3r/test_clipper_utils.cpp @@ -12,10 +12,13 @@ #include #include #include +#include #include #include #include +#include +#include #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/ExPolygon.hpp" #include "libslic3r/Point.hpp" @@ -390,3 +393,244 @@ TEST_CASE("Tiled diff and intersection return the same polygons as the plain cal 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)); +} From c081aa97f783d2180fc1d816e0f86edb85558787 Mon Sep 17 00:00:00 2001 From: Javad Shafique Date: Tue, 20 Oct 2026 20:28:36 +0200 Subject: [PATCH 27/37] fix: SimplyPrint device tab ignores the uploaded file import URL (#16555) The tab switch queued a reload of the host's home page, which ran after the import URL and replaced it. Switch tabs synchronously first, then queue the import URL so it is the last page loaded. --- src/slic3r/Utils/SimplyPrint.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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; From 2457173764b4f3a02f488585642219d8a6098012 Mon Sep 17 00:00:00 2001 From: yw4z Date: Tue, 20 Oct 2026 22:40:49 +0300 Subject: [PATCH 28/37] Fix: Instance position not syncs after drop and Object list sink icon/button not appears or non functional (#16501) * sync instances on drop * fix sinking object not works while auto drop is off --- src/slic3r/GUI/GLCanvas3D.cpp | 24 ++++-------------------- src/slic3r/GUI/GUI_ObjectList.cpp | 8 ++++++-- src/slic3r/GUI/Selection.cpp | 29 ++++++----------------------- 3 files changed, 16 insertions(+), 45 deletions(-) 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_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/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) { From 25fd549d171f4d2ea175065a6b3deb6d3a72725a Mon Sep 17 00:00:00 2001 From: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:28:08 -0300 Subject: [PATCH 29/37] jtround -> jtmiter corners multiline infill (#16707) --- src/libslic3r/Fill/FillBase.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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()); } From 07da005fab23486ad8dbc4dff10bd2041b607396 Mon Sep 17 00:00:00 2001 From: TheLegendTubaGuy <95944177+thelegendtubaguy@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:16:54 -0500 Subject: [PATCH 30/37] Fix current gen Qidi printer AMS sync (#16679) Newer Qidi firmware for the Q2, Q2C, Plus 5 and Max 4 stores a slot's color as a hex list and its filament as a material id, and filament sync crashed on it. Each slot is now read in either form, the filament type comes from the box object, and any other JSON type error fails the sync instead of closing Orca. --- src/slic3r/Utils/QidiPrinterAgent.cpp | 31 +++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) 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"; From 283d40949c07b16ad37e9c06f438a1cde18bbd43 Mon Sep 17 00:00:00 2001 From: yw4z Date: Wed, 21 Oct 2026 03:37:38 +0300 Subject: [PATCH 31/37] Fix: Filament / Printer areas collapses after closing selection dialogs (#16600) The Printer and Filament title bars now toggle on mouse down instead of mouse up. Closing a dialog over a title bar could send it a stray mouse up, which folded the section. A double click is also bound so it still toggles twice, and Label passes double clicks to its parent. --- src/slic3r/GUI/Plater.cpp | 10 ++++++++-- src/slic3r/GUI/Widgets/Label.cpp | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) 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/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) { From 3d0cb3654462dfb4f07146a006fe20bc18beb086 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 20 Oct 2026 20:48:02 -0500 Subject: [PATCH 32/37] fix: install a plugin printer agent once it registers for selected preset (#16708) * fix: install a plugin printer agent once it registers for the selected preset * fix: check for the pending printer agent after every plugin load --- src/slic3r/GUI/GUI_App.cpp | 25 ++++++++++++++++++++++--- src/slic3r/GUI/GUI_App.hpp | 3 +++ 2 files changed, 25 insertions(+), 3 deletions(-) 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 From 8a14a0383f7a0fc9f9de67ab201da06d8a4bd99f Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 20 Oct 2026 21:01:50 -0500 Subject: [PATCH 33/37] fix: error on calculations with post-processed placeholders (#16597) Placeholders that GCodeProcessor fills in after the G-code is generated (print_time_total_sec and the other print_time_* parts, used_filament_length, and the print statistics such as total_weight) hold a tag such as @TOTAL_WEIGHT@ while the template is evaluated. Multiplying one already failed with a type error. Adding a number joined the tag as text, so {total_weight + 5} printed 123.455 for a 123.45 g print, and a comparison compared the tag as text, so {if total_weight > 100} was always true. Neither gave an error. PlaceholderParser::set_deferred() now registers such a value. Printing it and joining it to text work as before. A calculation, a comparison, a regex or a condition on it fails with an error that says the value is known only after the G-code is generated, also after it was copied to a variable or through regex_replace(). Adding a number or another deferred value to one is treated as a sum and fails too. Text that already contains one joins a number as text, like any other string. filament_change() inside an {if} branch that is not taken now does nothing. It used to set the current extruder from an uninitialized value. --- src/libslic3r/GCode.cpp | 28 +++---- src/libslic3r/PlaceholderParser.cpp | 89 +++++++++++++++++++-- src/libslic3r/PlaceholderParser.hpp | 4 + tests/fff_print/test_print.cpp | 15 ++++ tests/libslic3r/test_placeholder_parser.cpp | 51 ++++++++++++ 5 files changed, 165 insertions(+), 22 deletions(-) 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/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/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/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"); + } +} From 8f3b1ccafb358b1faaa784a915b6d1f4e048f18c Mon Sep 17 00:00:00 2001 From: Sergei <119299827+ghzserg@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:05:56 +0500 Subject: [PATCH 34/37] Flashforge: set exclude_object_tolerance to 0.2 for AD5M/Pro, AD5X, Creator 5 (#16687) Sets the exclude-object outline tolerance to 0.2 mm in the four AD5M and AD5M Pro base process profiles. Every AD5M, AD5M Pro, AD5X, Creator 5, Creator 5 Pro and Adventurer A5 process inherits it, and so do some Guider 4 processes. Fewer outline points let these Klipper printers fit more objects on a plate without the "Timer too close" error. Also bumps the Flashforge profile version. --- resources/profiles/Flashforge.json | 2 +- .../process/0.20mm Standard @Flashforge AD5M 0.4 Nozzle.json | 1 + .../0.20mm Standard @Flashforge AD5M Pro 0.4 Nozzle.json | 1 + .../process/0.30mm Standard @Flashforge AD5M 0.6 Nozzle.json | 1 + .../0.30mm Standard @Flashforge AD5M Pro 0.6 Nozzle.json | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) 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", From 0132d97d0f784f5bee42cf13d44b714dc86df38a Mon Sep 17 00:00:00 2001 From: Error404JoyNotFound Date: Wed, 21 Oct 2026 07:55:16 +0530 Subject: [PATCH 35/37] fix(ui): clarify seam gap tooltip to use "nozzle diameter" (#10147) (#15983) ref : seam gap tooltip --- src/libslic3r/PrintConfig.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 212d8d8cb5..0876d7ebb8 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -6459,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; From f31a931f2eb59fa9547d5f128973b64299214dfb Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 20 Oct 2026 21:40:39 -0500 Subject: [PATCH 36/37] Stop logging parse errors for vendor profiles removed by packaging (#16710) Don't log a parse error for a vendor profile pruned beside its cache Packaging deletes .json for every vendor that ships a .opc cache, but installable_cache_version still read it to compare versions. Every packaged build logged a JSON parse error per installed vendor at startup. Check that the file exists first, as installed_vendor_version already does. The returned version does not change. --- src/libslic3r/utils.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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(); } From a06ed2d182bcb26c277a8b38f542fd2c33bc51df Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Tue, 20 Oct 2026 13:01:48 +0800 Subject: [PATCH 37/37] Unify the below-plate view across the painting gizmos The painting gizmos clip the selected instance at the plate, so a sinking object stopped at the bed with nothing to show how it continues below it, and the plate disappeared along with the bed mesh. Give every paint tool the same view of that region: - Draw the part of the selected instance below the plate at 50% opacity with depth writes off, so it reads as the object going through the plate. - Keep the plate and its grid while the bed mesh is hidden, instead of losing every reference plane in the scene. - Mark the plate intersection with a 0.4 mm outline rather than an opaque filled cap, which covered the model and hid the ghost from above. - Gate the bed mesh and the below-plate pass on the same predicate. Texture displacement derives from GLGizmoPainterBase like the other painters but was missing from the gizmo type list in is_bed_visible(), so it kept its bed. - Add GLVolume::simple_render()'s ban_color_volume so the ghost keeps its alpha for volumes with MMU segmentation facets, whose sub-models carry their own opaque filament colours, and stop gating the ghost on is_active: the colour painter marks the whole instance inactive and draws it in its own pass. --- src/slic3r/GUI/3DScene.cpp | 4 +- src/slic3r/GUI/3DScene.hpp | 2 +- src/slic3r/GUI/GLCanvas3D.cpp | 90 ++++++++++++++++++++++-- src/slic3r/GUI/GLCanvas3D.hpp | 2 + src/slic3r/GUI/Gizmos/GLGizmosCommon.cpp | 9 ++- 5 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index acd2e4f41c..3895275a33 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -700,7 +700,7 @@ void GLVolume::render_with_outline(const GUI::Size& cnv_size) } //BBS add render for simple case -void GLVolume::simple_render(GLShaderProgram* shader, ModelObjectPtrs& model_objects, std::vector& extruder_colors, bool ban_light) +void GLVolume::simple_render(GLShaderProgram* shader, ModelObjectPtrs& model_objects, std::vector& extruder_colors, bool ban_light, bool ban_color_volume) { if (this->is_left_handed()) glFrontFace(GL_CW); @@ -734,7 +734,7 @@ void GLVolume::simple_render(GLShaderProgram* shader, ModelObjectPtrs& model_obj } } while (0); - if (color_volume && !picking) { + if (color_volume && !picking && !ban_color_volume) { const bool brighten_selected = selected && !disabled && !force_native_color && !force_neutral_color; // when force_transparent, we need to keep the alpha diff --git a/src/slic3r/GUI/3DScene.hpp b/src/slic3r/GUI/3DScene.hpp index 7a19b20a24..d7ff4168c8 100644 --- a/src/slic3r/GUI/3DScene.hpp +++ b/src/slic3r/GUI/3DScene.hpp @@ -370,7 +370,7 @@ public: virtual void render_with_outline(const GUI::Size& cnv_size); //BBS: add simple render function for thumbnail - void simple_render(GLShaderProgram* shader, ModelObjectPtrs& model_objects, std::vector& extruder_colors, bool ban_light =false); + void simple_render(GLShaderProgram* shader, ModelObjectPtrs& model_objects, std::vector& extruder_colors, bool ban_light =false, bool ban_color_volume = false); void set_bounding_boxes_as_dirty() { m_transformed_bounding_box.reset(); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 3b3bfb35fd..c6a7f8f6a6 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2804,7 +2804,9 @@ void GLCanvas3D::_render_scene(const Camera& camera, const Size& cnv_size) _render_bed(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), show_axes); } m_frame_profiler.mark("bed"); - if (show_bed) //BBS: add outline logic + // Exception: the painting gizmos hide the bed mesh on purpose and draw the plate instead, + // so there the plate list is the only bed reference in the scene. + if (show_bed || _is_below_plate_view_active()) //BBS: add outline logic _render_platelist(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), only_current, only_body, hover_id, true, show_grid); if (m_design_canvas && show_bed) // Design tab: replace the plate's corner-origin grid with the origin-centred CAD grid. @@ -2816,6 +2818,7 @@ void GLCanvas3D::_render_scene(const Camera& camera, const Size& cnv_size) _render_shadows(camera.get_view_matrix(), camera.get_projection_matrix()); m_frame_profiler.mark("shadows"); _render_objects(GLVolumeCollection::ERenderType::Opaque, !m_gizmos.is_running()); + _render_below_plate_volumes(); m_frame_profiler.mark("objects"); _render_sla_slices(); _render_selection(); @@ -8824,6 +8827,84 @@ void GLCanvas3D::_render_bed(const Transform3d& view_matrix, const Transform3d& m_bed.render(*this, view_matrix, projection_matrix, bottom, scale_factor, show_axes); } +bool GLCanvas3D::_is_below_plate_view_active() const +{ + return dynamic_cast(m_gizmos.get_current()) != nullptr && m_gizmos.is_hiding_instances(); +} + +// The painting gizmo clips the selected object at the plate, so the part under the plate is not drawn. +// This pass draws that part of the selected instance again at 50% opacity, depth writes off. +void GLCanvas3D::_render_below_plate_volumes() +{ + if (!_is_below_plate_view_active() || m_volumes.empty()) + return; + + GLShaderProgram* shader = wxGetApp().get_shader("gouraud"); + if (shader == nullptr) + return; + + const Camera& camera = wxGetApp().plater()->get_camera(); + const Transform3d& view_matrix = camera.get_view_matrix(); + const Transform3d& proj_matrix = camera.get_projection_matrix(); + const int object_idx = m_selection.get_object_idx(); + const int instance_idx = m_selection.get_instance_idx(); + + // Depth test stays on so whatever is already in the depth buffer hides the ghost; depth writes + // are off and back faces are culled, so each pixel blends only the near surface once. + const GLboolean cull_face = ::glIsEnabled(GL_CULL_FACE); + glsafe(::glEnable(GL_DEPTH_TEST)); + glsafe(::glEnable(GL_CULL_FACE)); + glsafe(::glCullFace(GL_BACK)); + glsafe(::glDepthMask(GL_FALSE)); + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + + shader->start_using(); + // Keep z <= SINKING_Z_THRESHOLD, the complement of the clip the painting gizmo applies. + shader->set_uniform("z_range", std::array{ -FLT_MAX, float(SINKING_Z_THRESHOLD) }); + shader->set_uniform("clipping_plane", m_volumes.get_clipping_plane()); + shader->set_uniform("use_color_clip_plane", false); + shader->set_uniform("is_outline", false); + shader->set_uniform("slope.actived", false); + shader->set_uniform("print_volume.type", -1); + shader->set_uniform("extruder_printable_heights", std::array{ 0.0f, 0.0f, 0.0f }); + shader->set_uniform("shadow_intensity", 0.0f); + + // simple_render() needs these to reach the plain surface; with ban_color_volume it ignores both. + ModelObjectPtrs& model_objects = wxGetApp().model().objects; + std::vector extruder_colors = wxGetApp().plater()->get_extruders_colors(); + + for (GLVolume* volume : m_volumes.volumes) { + // is_active cannot gate this pass: the color painter marks the whole instance inactive and + // draws it in its own gizmo pass. The instance and print-part checks below are what pick + // this ghost out of the scene. + if (volume->object_idx() != object_idx || volume->instance_idx() != instance_idx || + volume->is_modifier || volume->is_wipe_tower || volume->volume_idx() < 0) + continue; + + volume->set_render_color(); + ColorRGBA color = volume->render_color; + color.a(0.5f); + volume->model.set_color(color); + + const Transform3d model_matrix = volume->world_matrix(); + shader->set_uniform("view_model_matrix", view_matrix * model_matrix); + shader->set_uniform("projection_matrix", proj_matrix); + shader->set_uniform("volume_world_matrix", model_matrix); + const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3) * model_matrix.matrix().block(0, 0, 3, 3).inverse().transpose(); + shader->set_uniform("view_normal_matrix", view_normal_matrix); + // Painted facets would send render() through the MMU segmentation sub-models, which carry + // their own opaque filament colours; the ghost draws the plain surface so the alpha holds. + volume->simple_render(shader, model_objects, extruder_colors, false, true); + } + shader->stop_using(); + + glsafe(::glDisable(GL_BLEND)); + glsafe(::glDepthMask(GL_TRUE)); + if (cull_face == GL_FALSE) + glsafe(::glDisable(GL_CULL_FACE)); +} + bool GLCanvas3D::_hides_inactive_plates() const { return m_canvas_type == ECanvasType::CanvasView3D && !m_design_canvas && wxGetApp().focus_mode(); @@ -11952,9 +12033,10 @@ bool GLCanvas3D::is_bed_visible() const if (!m_main_toolbar.is_enabled()) return true; - const auto type = m_gizmos.get_current_type(); - return type != GLGizmosManager::FdmSupports && type != GLGizmosManager::Seam && - type != GLGizmosManager::MmSegmentation && type != GLGizmosManager::FuzzySkin; + // The painting gizmos clip the selected instance at the plate (see InstancesHider), so the bed + // mesh would sit in front of the part below it. They draw the plate and its grid instead (see + // _render_scene) and the below-plate part as a translucent ghost. + return !_is_below_plate_view_active(); } Vec3d GLCanvas3D::get_camera_pan_anchor(Camera& camera, ECameraNavigationType navigation_type, diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index e483859824..5acbba39a1 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -1552,6 +1552,8 @@ private: void _render_imex_ghost_tooltip(); void _render_section_view_caps(); void _render_wireframe_overlay(); + bool _is_below_plate_view_active() const; + void _render_below_plate_volumes(); bool _is_xray_view_active() const; void _render_xray_volumes(); //BBS: GUI refactor: add canvas size as parameters diff --git a/src/slic3r/GUI/Gizmos/GLGizmosCommon.cpp b/src/slic3r/GUI/Gizmos/GLGizmosCommon.cpp index e391022d6c..9cabc2e3fe 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosCommon.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosCommon.cpp @@ -7,7 +7,6 @@ #include -#include #include "slic3r/GUI/Selection.hpp" #include #include "libslic3r/Model.hpp" @@ -174,6 +173,9 @@ void InstancesHider::on_update() m_clippers.emplace_back(new MeshClipper); m_clippers.back()->set_plane(ClippingPlane(-Vec3d::UnitZ(), z_min)); m_clippers.back()->set_mesh(mesh->its); + // Outline only: a filled cap would read as a surface the object does not have, and it + // covers the model below it. 0.4 mm matches the cut gizmo's contour width. + m_clippers.back()->set_behaviour(false, 0.4); } m_old_meshes = meshes; } @@ -214,10 +216,7 @@ void InstancesHider::render_cut() const else clipper->set_limiting_plane(ClippingPlane::ClipsNothing()); - glsafe(::glPushAttrib(GL_DEPTH_TEST)); - glsafe(::glDisable(GL_DEPTH_TEST)); - clipper->render_cut(mv->is_model_part() ? ColorRGBA(0.8f, 0.3f, 0.0f, 1.0f) : color_from_model_volume(*mv)); - glsafe(::glPopAttrib()); + clipper->render_contour(mv->is_model_part() ? ColorRGBA(0.8f, 0.3f, 0.0f, 1.0f) : color_from_model_volume(*mv)); ++clipper_id; }