diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index 1c785c3184..ad4d615807 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -360,6 +360,14 @@ static ClipperLib_Z::Paths clip_extrusion(const ClipperLib_Z::Path& subject, con return clipped_paths; } +static double clipper_z_path_length(const ClipperLib_Z::Path &path) +{ + double len = 0.; + for (size_t i = 1; i < path.size(); ++ i) + len += (Vec2d(double(path[i].x()), double(path[i].y())) - Vec2d(double(path[i - 1].x()), double(path[i - 1].y()))).norm(); + return len; +} + struct PerimeterGeneratorArachneExtrusion { Arachne::ExtrusionLine* extrusion = nullptr; @@ -571,17 +579,156 @@ static ExtrusionEntityCollection traverse_extrusions(const PerimeterGenerator& p return extrusion_coll; } -// ORCA: only_one_wall_top detects the top as "slice − upper", so a feature rising from the middle of a -// top surface becomes an enclosed hole that gets ringed with extra inner walls. Fill those holes back -// into the top. Only holes that are both covered by the upper layer (excludes bridges) and backed by -// solid material (excludes voids) are filled. -static ExPolygons fill_enclosed_top_feature_holes(const ExPolygons &top, const Polygons &covered_by_upper, const ExPolygons &solid) +// ORCA: only_one_wall_top acts on top surfaces, so without a top shell there is nothing for it to act on: zero top +// shell layers retype the top surfaces as internal, see LayerRegion::prepare_fill_surfaces(). A 0% top surface +// density does leave a top surface - just an unfilled one - so it does not disable the feature. +// ConfigManipulation::toggle_print_fff_options() hides the option under the same condition, so a profile that left +// it enabled does not act behind a hidden checkbox. +static bool has_top_shell_layers(const PrintRegionConfig &config) { - ExPolygons filled = top; - for (ExPolygon &ex : filled) - ex.holes.clear(); - const ExPolygons feature_holes = intersection_ex(intersection_ex(diff_ex(filled, top), covered_by_upper), solid); - return feature_holes.empty() ? top : union_ex(top, feature_holes); + return config.top_shell_layers.value > 0; +} + +// ORCA: only_one_wall_first_layer thins the first layer to a single wall, the bottom counterpart of the above and +// gated the same way: zero bottom shell layers retype the bottom surfaces as internal, so that wall would ring +// sparse infill on the bed. The bottom surface density plays no part - an unfilled bottom surface is still a bottom +// surface, exactly as for the top - and it cannot reach zero anyway, being capped at a 10% minimum. +static bool has_bottom_shell_layers(const PrintRegionConfig &config) +{ + return config.bottom_shell_layers.value > 0; +} + +// ORCA: the inner walls are only given up when a top fill takes their space, and it has to actually reach it - +// a 0% top surface density leaves no fill at all, and without top_surface_expansion the fill never grows over +// them. Either way the original generation is kept (re-onion the not-top region), which is what users of +// only_one_wall_top alone have always got. +static bool top_fill_replaces_inner_walls(const PrintRegionConfig &config) +{ + return has_top_shell_layers(config) && config.top_surface_density.value > 0 && config.top_surface_expansion.value > 0; +} + +// ORCA: only_one_wall_top - cheap per-vertex classification of a wall against the top surface. Only Partial +// needs the geometry clipped or measured; a segment crossing the top with no vertex inside is rare enough to ignore. +enum class TopOverlap { None, Partial, Full }; + +static bool point_over_top(const Point &p, const ExPolygons &top_region, const BoundingBox &top_region_bbox) +{ + if (! top_region_bbox.contains(p)) + return false; + for (const ExPolygon &ex : top_region) + if (ex.contains(p, false)) + return true; + return false; +} + +static TopOverlap classify_over_top(const Points &pts, const ExPolygons &top_region, const BoundingBox &top_region_bbox) +{ + size_t inside = 0; + for (const Point &p : pts) + if (point_over_top(p, top_region, top_region_bbox)) + ++ inside; + return inside == 0 ? TopOverlap::None : inside == pts.size() ? TopOverlap::Full : TopOverlap::Partial; +} + +static TopOverlap classify_over_top(const Arachne::ExtrusionLine &el, const ExPolygons &top_region, const BoundingBox &top_region_bbox) +{ + size_t inside = 0; + for (const Arachne::ExtrusionJunction &j : el.junctions) + if (point_over_top(j.p, top_region, top_region_bbox)) + ++ inside; + return inside == 0 ? TopOverlap::None : inside == el.junctions.size() ? TopOverlap::Full : TopOverlap::Partial; +} + +// ORCA: only_one_wall_top for Arachne - cut out of the already generated inner walls the parts running over the top +// surface, so geometry that continues upward keeps its walls. A wall too short over the top to be worth slitting open +// is left whole, its footprint reported in kept_over_top for the caller to withhold from the top fill. +static void clip_inner_walls_over_top(std::vector &inner_perimeters, const ExPolygons &top_region, coord_t perimeter_width, Polygons &kept_over_top) +{ + const BoundingBox top_region_bbox = get_extents(top_region).inflated(SCALED_EPSILON); + auto covered_by = [](const Arachne::ExtrusionLine &el) { + Polyline centerline; + centerline.points.reserve(el.junctions.size()); + coord_t width = 0; + for (const Arachne::ExtrusionJunction &j : el.junctions) { + centerline.points.emplace_back(j.p); + width = std::max(width, j.w); + } + return offset(centerline, float(width) / 2.f); + }; + // Pull the cut back by half a wall width: the clip severs the centerline, but the bead's rounded end + // extends half a width past its endpoint and would otherwise overlap the top fill. + ClipperLib_Z::Paths top_paths_z; + for (const Polygon &poly : to_polygons(offset_ex(top_region, float(perimeter_width) / 2.f))) { + top_paths_z.emplace_back(); + ClipperLib_Z::Path &out = top_paths_z.back(); + out.reserve(poly.points.size()); + for (const Point &pt : poly.points) + out.emplace_back(pt.x(), pt.y(), 0); + } + for (Arachne::VariableWidthLines &inner_perimeter : inner_perimeters) { + Arachne::VariableWidthLines kept; + kept.reserve(inner_perimeter.size()); + for (Arachne::ExtrusionLine &el : inner_perimeter) { + if (el.empty()) + continue; + const TopOverlap overlap = classify_over_top(el, top_region, top_region_bbox); + if (overlap == TopOverlap::None) { + kept.emplace_back(std::move(el)); + continue; + } + if (overlap == TopOverlap::Full) + continue; // the clip below would return nothing anyway + ClipperLib_Z::Path subject; + subject.reserve(el.size()); + for (const Arachne::ExtrusionJunction &j : el.junctions) + subject.emplace_back(j.p.x(), j.p.y(), j.w); + ClipperLib_Z::Paths pieces = clip_extrusion(subject, top_paths_z, ClipperLib_Z::ctDifference); + + // Clipper treats the subject as an open polyline, so it also cuts a closed loop at its (arbitrary) + // start vertex and may reverse pieces. Stitch pieces sharing an endpoint back together. + auto same_pt = [](const ClipperLib_Z::IntPoint &p, const ClipperLib_Z::IntPoint &q) { + return std::abs(p.x() - q.x()) <= SCALED_EPSILON && std::abs(p.y() - q.y()) <= SCALED_EPSILON; + }; + for (size_t i = 0; i < pieces.size(); ++ i) { + for (size_t j = i + 1; j < pieces.size();) { + ClipperLib_Z::Path &a = pieces[i]; + ClipperLib_Z::Path &b = pieces[j]; + if (same_pt(a.front(), b.front()) || same_pt(a.front(), b.back())) + std::reverse(a.begin(), a.end()); + if (same_pt(a.back(), b.back())) + std::reverse(b.begin(), b.end()); + if (same_pt(a.back(), b.front())) { + a.insert(a.end(), b.begin() + 1, b.end()); + pieces.erase(pieces.begin() + j); + j = i + 1; // the merged path has new endpoints, restart the scan + } else + ++ j; + } + } + + // If the clip removed next to nothing, keep the loop untouched instead of slitting it open. The + // half-width pull-back above already costs about one width per crossing, hence two widths. + double kept_length = 0.; + for (const ClipperLib_Z::Path &path : pieces) + kept_length += clipper_z_path_length(path); + if (clipper_z_path_length(subject) - kept_length < 2. * double(perimeter_width)) { + append(kept_over_top, covered_by(el)); + kept.emplace_back(std::move(el)); + continue; + } + + for (const ClipperLib_Z::Path &path : pieces) { + Arachne::ExtrusionLine clipped(el.inset_idx, el.is_odd); + clipped.junctions.reserve(path.size()); + for (const ClipperLib_Z::IntPoint &pt : path) + clipped.junctions.emplace_back(Point(pt.x(), pt.y()), coord_t(pt.z()), el.inset_idx); + // Discard tiny leftovers that would print as zits. + if (clipped.size() >= 2 && clipped.getLength() >= perimeter_width) + kept.emplace_back(std::move(clipped)); + } + } + inner_perimeter = std::move(kept); + } } void PerimeterGenerator::split_top_surfaces(const ExPolygons &orig_polygons, ExPolygons &top_fills, @@ -649,7 +796,6 @@ void PerimeterGenerator::split_top_surfaces(const ExPolygons &orig_polygons, ExP ExPolygons delete_bridge = diff_ex(orig_polygons, bridge_checker, ApplySafetyOffset::Yes); ExPolygons top_polygons = diff_ex(delete_bridge, upper_polygons_series_clipped, ApplySafetyOffset::Yes); - top_polygons = fill_enclosed_top_feature_holes(top_polygons, upper_polygons_series_clipped, orig_polygons); // get the not-top surface, from the "real top" but enlarged by external_infill_margin (and the // min_width_top_surface we removed a bit before) @@ -1234,6 +1380,11 @@ void PerimeterGenerator::process_classic() for (const Surface &surface : all_surfaces) surface_exp.push_back(surface.expolygon); std::vector surface_order = chain_expolygons(surface_exp); + // ORCA: neither one-wall option has a surface to act on without the shell behind it, see + // has_top_shell_layers() / has_bottom_shell_layers(). Gated here so every use below - including the + // topmost and first layers - sees the same answer. + const bool only_one_wall_top = this->config->only_one_wall_top && has_top_shell_layers(*this->config); + const bool only_one_wall_first_layer = this->config->only_one_wall_first_layer && has_bottom_shell_layers(*this->config); for (size_t order_idx = 0; order_idx < surface_order.size(); order_idx++) { const Surface &surface = all_surfaces[surface_order[order_idx]]; // detect how many perimeters must be generated for this island @@ -1241,16 +1392,23 @@ void PerimeterGenerator::process_classic() 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++; - if (this->layer_id == object_config->raft_layers && this->config->only_one_wall_first_layer) + if (this->layer_id == object_config->raft_layers && only_one_wall_first_layer) loop_number = 0; // Set the topmost layer to be one wall - if (loop_number > 0 && config->only_one_wall_top && this->upper_slices == nullptr) + if (loop_number > 0 && only_one_wall_top && this->upper_slices == nullptr) loop_number = 0; ExPolygons last = union_ex(surface.expolygon.simplify_p(surface_simplify_resolution)); ExPolygons gaps; ExPolygons top_fills; ExPolygons fill_clip; + // ORCA: only_one_wall_top, all empty unless this island has a top surface on this layer. See the + // post-onion reduction below: the region to keep clear of inner walls, the space freed by the dropped + // walls (goes to infill, not left as a void) and the space held by the kept ones (withheld from the fill). + ExPolygons one_wall_top_region; + ExPolygons one_wall_top_reclaimed; + Polygons one_wall_top_kept_bands; + bool apply_one_wall_top = false; if (loop_number >= 0) { // In case no perimeters are to be generated, loop_number will equal to -1. std::vector contours(loop_number+1); // depth => loops @@ -1389,8 +1547,19 @@ void PerimeterGenerator::process_classic() //BBS: refer to superslicer //store surface for top infill if only_one_wall_top - if (i == 0 && i!=loop_number && config->only_one_wall_top && !surface.is_bridge() && this->upper_slices != NULL) { - this->split_top_surfaces(last, top_fills, last, fill_clip); + if (i == 0 && i!=loop_number && only_one_wall_top && !surface.is_bridge() && this->upper_slices != NULL) { + if (top_fill_replaces_inner_walls(*this->config)) { + // ORCA: take the top fill and the keep-out region but leave `last` as the real geometry, + // so the onion follows it and the walls over the top are reduced in one step below. + ExPolygons non_top_polygons; + this->split_top_surfaces(last, top_fills, non_top_polygons, fill_clip); + apply_one_wall_top = !top_fills.empty(); + if (apply_one_wall_top) + one_wall_top_region = diff_ex(last, non_top_polygons); + } else { + // Onion the not-top region only, so the remaining walls stop at the top boundary. + this->split_top_surfaces(last, top_fills, last, fill_clip); + } } if (i == loop_number && (! has_gap_fill || this->config->sparse_infill_density.value == 0)) { @@ -1400,6 +1569,46 @@ void PerimeterGenerator::process_classic() } } + // ORCA: only_one_wall_top reduction - drop the inner walls (depth > 0) running over the top surface and + // take that space back from the gaps, leaving the top with the outer wall and the top infill. Classic + // perimeters are closed loops, so a wall can only be kept or dropped whole; one that merely grazes the + // top (same tolerance as the Arachne clip) is kept and withheld from the top fill instead. + if (apply_one_wall_top) { + const BoundingBox top_region_bbox = get_extents(one_wall_top_region).inflated(SCALED_EPSILON); + const double grazing_tolerance = 2. * double(perimeter_width); + // The band a wall covers, taken around its centerline so the orientation of holes does not matter. + auto wall_band = [perimeter_spacing](const Polygon &poly) { + Polygon centerline = poly; + centerline.make_counter_clockwise(); + return diff(offset(centerline, float(perimeter_spacing) / 2.f), + offset(centerline, -float(perimeter_spacing) / 2.f)); + }; + Polygons dropped_wall_bands; + auto reduce_over_top = [&](PerimeterGeneratorLoops &loops) { + loops.erase(std::remove_if(loops.begin(), loops.end(), [&](const PerimeterGeneratorLoop &loop) { + const TopOverlap overlap = classify_over_top(loop.polygon.points, one_wall_top_region, top_region_bbox); + if (overlap == TopOverlap::None) + return false; + // Only a wall straddling the boundary is worth measuring; a wall wholly over the top goes. + if (overlap == TopOverlap::Partial && + total_length(intersection_pl(Polylines{ loop.polygon.split_at_first_point() }, one_wall_top_region)) < grazing_tolerance) { + append(one_wall_top_kept_bands, wall_band(loop.polygon)); + return false; + } + append(dropped_wall_bands, wall_band(loop.polygon)); + return true; + }), loops.end()); + }; + for (int d = 1; d <= loop_number; ++ d) { + reduce_over_top(contours[d]); + reduce_over_top(holes[d]); + } + if (! gaps.empty()) + gaps = diff_ex(gaps, one_wall_top_region); + if (! dropped_wall_bands.empty()) + one_wall_top_reclaimed = diff_ex(dropped_wall_bands, one_wall_top_region); + } + // nest loops: holes first for (int d = 0; d <= loop_number; ++ d) { PerimeterGeneratorLoops &holes_d = holes[d]; @@ -1634,7 +1843,10 @@ void PerimeterGenerator::process_classic() and use zigzag). */ //FIXME Vojtech: This grows by a rounded extrusion width, not by line spacing, // therefore it may cover the area, but no the volume. - last = diff_ex(last, gap_fill.polygons_covered_by_width(10.f)); + Polygons gap_fill_covered = gap_fill.polygons_covered_by_width(10.f); + last = diff_ex(last, gap_fill_covered); + if (! one_wall_top_reclaimed.empty()) + one_wall_top_reclaimed = diff_ex(one_wall_top_reclaimed, gap_fill_covered); this->gap_fill->append(std::move(gap_fill.entities)); } @@ -1679,9 +1891,15 @@ void PerimeterGenerator::process_classic() // append infill areas to fill_surfaces //if any top_fills, grow them by ext_perimeter_spacing/2 to have the real un-anchored fill ExPolygons top_infill_exp = intersection_ex(fill_clip, offset_ex(top_fills, double(ext_perimeter_spacing / 2))); + // ORCA: only_one_wall_top - route the top fill around the walls kept despite grazing the top. + if (!one_wall_top_kept_bands.empty()) + top_infill_exp = diff_ex(top_infill_exp, one_wall_top_kept_bands); if (!top_fills.empty()) { infill_exp = union_ex(infill_exp, offset_ex(top_infill_exp, double(top_infill_peri_overlap))); } + // ORCA: only_one_wall_top - what the top fill does not cover of the dropped walls goes to infill. + if (!one_wall_top_reclaimed.empty()) + infill_exp = union_ex(infill_exp, one_wall_top_reclaimed); this->fill_surfaces->append(infill_exp, stInternal); apply_extra_perimeters(infill_exp); @@ -1700,6 +1918,8 @@ void PerimeterGenerator::process_classic() double(-inset - infill_peri_overlap)); if (!top_fills.empty()) polyWithoutOverlap = union_ex(polyWithoutOverlap, top_infill_exp); + if (!one_wall_top_reclaimed.empty()) + polyWithoutOverlap = union_ex(polyWithoutOverlap, one_wall_top_reclaimed); this->fill_no_overlap->insert(this->fill_no_overlap->end(), polyWithoutOverlap.begin(), polyWithoutOverlap.end()); } @@ -2138,6 +2358,11 @@ void PerimeterGenerator::process_arachne() process_no_bridge(all_surfaces, perimeter_spacing, ext_perimeter_width); // BBS: don't simplify too much which influence arc fitting when export gcode if arc_fitting is enabled double surface_simplify_resolution = (print_config->enable_arc_fitting && !this->has_fuzzy_skin) ? 0.2 * m_scaled_resolution : m_scaled_resolution; + // ORCA: neither one-wall option has a surface to act on without the shell behind it, see + // has_top_shell_layers() / has_bottom_shell_layers(). Gated here so every use below - including the + // topmost and first layers - sees the same answer. + const bool only_one_wall_top = this->config->only_one_wall_top && has_top_shell_layers(*this->config); + 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) { @@ -2150,12 +2375,12 @@ void PerimeterGenerator::process_arachne() // 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 && this->config->only_one_wall_first_layer) + 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 && config->only_one_wall_top) + 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; @@ -2175,10 +2400,10 @@ void PerimeterGenerator::process_arachne() //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 = (config->only_one_wall_top && upper_slices != nullptr) ? loop_number - 1 : -1; + const int inner_loop_number = (only_one_wall_top && upper_slices != nullptr) ? loop_number - 1 : -1; // Set one perimeter when TopSurfaces is selected. - if (config->only_one_wall_top && loop_number > 0) + if (only_one_wall_top && loop_number > 0) loop_number = 0; Arachne::WallToolPathsParams input_params_tmp = input_params; @@ -2209,7 +2434,6 @@ void PerimeterGenerator::process_arachne() 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 = fill_enclosed_top_feature_holes(top_expolygons, upper_slices_clipped, infill_contour); if (!top_expolygons.empty()) { if (lower_slices != nullptr) { @@ -2230,25 +2454,33 @@ void PerimeterGenerator::process_arachne() // 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 the not-top ExPolygons (including bridges) from current slices and expanded real top ExPolygons (without bridges). - const ExPolygons not_top_expolygons = diff_ex(infill_contour, top_expolygons); - - // Get final top ExPolygons. + // Get final top ExPolygons (bridges were excluded above, so they stay walled). top_expolygons = intersection_ex(top_expolygons, infill_contour); - const Polygons not_top_polygons = to_polygons(offset_ex(not_top_expolygons,wall_0_inset)); - Arachne::WallToolPaths inner_wall_tool_paths(not_top_polygons, perimeter_spacing, perimeter_spacing, coord_t(inner_loop_number + 1), 0, layer_height, input_params_tmp); + // 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(); - // Recalculate indexes of inner perimeters before merging them. - if (!perimeters.empty()) { - for (Arachne::VariableWidthLines &inner_perimeter : inner_perimeters) { - if (inner_perimeter.empty()) - continue; + 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()); diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 0d31f6c04c..0ef46fac92 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -1364,7 +1364,6 @@ bool PrintObject::invalidate_state_by_config_options( || opt_key == "infill_combination_max_layer_height" || opt_key == "bottom_shell_thickness" || opt_key == "top_shell_thickness" - || opt_key == "top_surface_expansion" || opt_key == "top_surface_expansion_margin" || opt_key == "top_surface_expansion_direction" || opt_key == "minimum_sparse_infill_area" @@ -1400,7 +1399,6 @@ bool PrintObject::invalidate_state_by_config_options( || opt_key == "infill_anchor" || opt_key == "infill_anchor_max" || opt_key == "top_surface_line_width" - || opt_key == "top_surface_density" || opt_key == "bottom_surface_density" || opt_key == "center_of_surface_pattern" || opt_key == "separated_infills" @@ -1434,6 +1432,24 @@ bool PrintObject::invalidate_state_by_config_options( is_approx(new_density->value, 0.) || is_approx(new_density->value, 100.)) steps.emplace_back(posPerimeters); steps.emplace_back(posPrepareInfill); + } else if (opt_key == "top_surface_density") { + // ORCA: 0% means no top solid fill, which switches off both the top surface expansion and the wall + // removal over top surfaces. Only crossing zero matters; posPerimeters cascades to posPrepareInfill. + const auto *old_density = old_config.option(opt_key); + const auto *new_density = new_config.option(opt_key); + assert(old_density && new_density); + if (is_approx(old_density->value, 0.) || is_approx(new_density->value, 0.)) + steps.emplace_back(posPerimeters); + steps.emplace_back(posInfill); + } else if (opt_key == "top_surface_expansion") { + // ORCA: without the expansion the top fill never reaches the space freed by only_one_wall_top, so the + // walls over top surfaces are kept. Only crossing zero matters; posPerimeters cascades to posPrepareInfill. + const auto *old_expansion = old_config.option(opt_key); + const auto *new_expansion = new_config.option(opt_key); + assert(old_expansion && new_expansion); + if (old_expansion->value <= 0. || new_expansion->value <= 0.) + steps.emplace_back(posPerimeters); + steps.emplace_back(posPrepareInfill); } else if (opt_key == "internal_solid_infill_line_width") { // This value is used for calculating perimeter - infill overlap, thus perimeters need to be recalculated. steps.emplace_back(posPerimeters); @@ -1760,51 +1776,50 @@ void PrintObject::detect_surfaces_type() } } - // ORCA: Expand the top surfaces outward by top_surface_expansion in every direction. This - // enlarges the top solid infill and, in particular, grows it over the covered material left - // by features rising from the middle of a top surface (filling holes and joining tops so the - // features rest on it). The expansion stays inside the section it belongs to: each connected - // solid island has its own outer wall, so the top is grown within each island separately and - // clipped to it - growing one island's top across the gap into another island (which may have - // no top surface, leaving a partially filled layer) is never allowed. The top infill sits - // inside the perimeters, so the margin is measured from the walls: the island is inset by the - // band the walls consume (outer wall + inner walls) plus the configured margin, making that - // value the real clearance between the expanded top and the walls (avoiding a hull line). The - // original top is unioned back in, so where it already sits within that band it is kept as-is. - // Never claims a bottom surface. - const double top_expansion = layerm->region().config().top_surface_expansion.value; - if (top_expansion > 0. && ! top.empty()) { - const double d = scale_(top_expansion); - const auto jt = Clipper2Lib::JoinType::Miter; - const ExPolygons T = union_ex(to_expolygons(top)); - const int wall_loops = layerm->region().config().wall_loops.value; + // ORCA: Grow the top surfaces by top_surface_expansion, so the top solid infill also covers the + // material left by features rising from the middle of a top surface (filling the holes and + // joining the tops, so the features rest on solid infill). Each connected island is grown and + // clipped separately: growing one island's top across a gap into another - which may have no top + // surface at all, leaving a partially filled layer - is never allowed. The original top is + // unioned back in and bottom surfaces are never claimed, so this can only add area. + const PrintRegionConfig ®ion_config = layerm->region().config(); + const double top_expansion = region_config.top_surface_expansion.value; + // Nothing to expand without a top fill: a 0% top surface density leaves the top layer with + // walls only, and zero top shell layers retypes it as internal in prepare_fill_surfaces(). + if (top_expansion > 0. && region_config.top_shell_layers.value > 0 && + region_config.top_surface_density.value > 0. && ! top.empty()) { + const double d = scale_(top_expansion); + const ExPolygons T = union_ex(to_expolygons(top)); + // Walls are laid out on spacing, not width; and only_one_wall_top leaves a single wall over + // a top surface, which is exactly the situation handled here. + const int wall_loops = region_config.only_one_wall_top.value ? std::min(region_config.wall_loops.value, 1) + : region_config.wall_loops.value; const double wall_band = wall_loops <= 0 ? 0. : double(layerm->flow(frExternalPerimeter).scaled_width()) + - double(layerm->flow(frPerimeter).scaled_width()) * double(wall_loops - 1); - const double margin = scale_(layerm->region().config().top_surface_expansion_margin.value); + double(layerm->flow(frPerimeter).scaled_spacing()) * double(wall_loops - 1); + const double margin = scale_(region_config.top_surface_expansion_margin.value); // minimum real top to act on: ignore anything thinner than ~2 top-infill lines const float min_top = float(layerm->flow(frTopSolidInfill).scaled_width()); - const auto direction = layerm->region().config().top_surface_expansion_direction.value; + const auto direction = region_config.top_surface_expansion_direction.value; ExPolygons grown; for (const ExPolygon &island : union_ex(layerm_slices_surfaces)) { // The top infill only exists inside the perimeters, so seed and measure from the infill - // region (the island minus the wall band), not the raw slice. A section whose only - // exposed top lies in the wall band - i.e. a layer where the top is just the walls - // themselves - has no infill here and is skipped, instead of being flooded inward by - // the expansion. Thin slivers inside the infill region are dropped by the opening too. + // region (the island minus the wall band), not the raw slice: a section whose exposed top + // is just the walls themselves is then skipped instead of being flooded inward. Clip the + // layer's tops to the island first, to keep the boolean ops proportional to the island. const ExPolygons infill_region = wall_band > 0. ? offset_ex(island, -float(wall_band)) : ExPolygons{ island }; - const ExPolygons island_top = intersection_ex(T, infill_region); + const ExPolygons island_top = intersection_ex( + ClipperUtils::clip_clipper_polygons_with_subject_bbox(T, get_extents(island).inflated(SCALED_EPSILON)), + infill_region); if (opening_ex(island_top, min_top).empty()) continue; // no real top infill in this section - never expand into it - // grow by d, then keep only the part allowed by the configured direction: inward fills - // the holes/gaps left by features (clip the growth back to the top's own filled outline, - // which leaves the outer edge fixed), outward grows the outer edge toward the walls (drop - // the growth that fell into the original holes), and inward+outward keeps both. - ExPolygons expanded = offset_ex_2(island_top, d, jt); + // Grow, then keep only what the configured direction allows, using the top's own filled + // outline (same outer edge, holes closed) to tell the two apart. + ExPolygons expanded = offset_ex_2(island_top, d, Clipper2Lib::JoinType::Miter); if (direction != TopSurfaceExpansionDirection::InwardAndOutward) { - ExPolygons outline; // the top with its holes filled (same outer edge) + ExPolygons outline; outline.reserve(island_top.size()); for (const ExPolygon &ex : island_top) outline.emplace_back(ex.contour); diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 57f25eba24..c2643b5d0f 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -703,12 +703,13 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in toggle_line("spiral_mode_max_xy_smoothing", has_spiral_vase && config->opt_bool("spiral_mode_smooth")); toggle_line("spiral_starting_flow_ratio", has_spiral_vase); toggle_line("spiral_finishing_flow_ratio", has_spiral_vase); - bool has_top_shell = config->opt_int("top_shell_layers") > 0 || (has_spiral_vase && config->opt_int("bottom_shell_layers") > 1); + bool has_top_shell_layers = config->opt_int("top_shell_layers") > 0 || (has_spiral_vase && config->opt_int("bottom_shell_layers") > 1); + bool has_top_shell = has_top_shell_layers && config->option("top_surface_density")->value > 0; bool has_bottom_shell = config->opt_int("bottom_shell_layers") > 0; - bool has_solid_infill = has_top_shell || has_bottom_shell; + bool has_solid_infill = has_top_shell_layers || has_bottom_shell; toggle_field("top_surface_pattern", has_top_shell); toggle_field("bottom_surface_pattern", has_bottom_shell); - toggle_field("top_surface_density", has_top_shell); + toggle_field("top_surface_density", has_top_shell_layers); toggle_field("bottom_surface_density", has_bottom_shell); toggle_field("top_layer_direction", has_top_shell); toggle_field("bottom_layer_direction", has_bottom_shell); @@ -751,7 +752,7 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in for (auto el : { "sparse_infill_speed", "bridge_speed", "internal_bridge_speed"}) toggle_field(el, have_infill || has_solid_infill, variant_index); - toggle_field("top_shell_thickness", ! has_spiral_vase && has_top_shell); + toggle_field("top_shell_thickness", ! has_spiral_vase && has_top_shell_layers); toggle_field("bottom_shell_thickness", ! has_spiral_vase && has_bottom_shell); // Gap fill is newly allowed in between perimeter lines even for empty infill (see GH #1476). @@ -1008,7 +1009,11 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in toggle_line("make_overhang_printable_angle", have_make_overhang_printable); toggle_line("make_overhang_printable_hole_size", have_make_overhang_printable); - toggle_line("min_width_top_surface", config->opt_bool("only_one_wall_top") || ((config->opt_float("min_length_factor") > 0.5f) && have_arachne)); // 0.5 is default value + // Orca: the one-wall options act on top/bottom surfaces, which exist only with a shell. An unfilled surface + // (0% surface density) is still a surface, so these are gated on the layer counts alone. + toggle_line("only_one_wall_first_layer", has_bottom_shell); + toggle_line("only_one_wall_top", has_top_shell_layers); + toggle_line("min_width_top_surface", (has_top_shell_layers && config->opt_bool("only_one_wall_top")) || ((config->opt_float("min_length_factor") > 0.5f) && have_arachne)); // 0.5 is default value for (auto el : { "hole_to_polyhole_threshold", "hole_to_polyhole_twisted", "hole_to_polyhole_max_edges" }) toggle_line(el, config->opt_bool("hole_to_polyhole")); diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index abddfebe5e..70ab639faa 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -11,6 +11,7 @@ add_executable(${_TEST_NAME}_tests test_gcodewriter.cpp test_model.cpp test_multifilament.cpp + test_perimeters.cpp test_print.cpp test_printobject.cpp test_skirt_brim.cpp diff --git a/tests/fff_print/test_perimeters.cpp b/tests/fff_print/test_perimeters.cpp new file mode 100644 index 0000000000..a98877ad2f --- /dev/null +++ b/tests/fff_print/test_perimeters.cpp @@ -0,0 +1,257 @@ +#include + +#include "libslic3r/ExtrusionEntity.hpp" +#include "libslic3r/ExtrusionEntityCollection.hpp" +#include "libslic3r/Layer.hpp" +#include "libslic3r/Print.hpp" + +#include +#include +#include + +#include "test_helpers.hpp" + +using namespace Slic3r; +using namespace Slic3r::Test; + +namespace { + +// The layer at this Z is the last one of the base, so its top surface is the ledge. +const double ledge_z = 5.0; + +// The first layer, at initial_layer_print_height. +const double first_layer_z = 0.2; + +// TestMesh::step scaled 3x in X/Y: a 60x60x5 base carrying a 54x54 column up to z=10, leaving a 3mm +// top ledge around a feature that keeps rising. That is the geometry both only_one_wall_top and the +// top surface expansion act on. The ledge has to stay wider than the wall band plus two top-infill +// lines, or the expansion discards it as a sliver and the tests below assert nothing. +TriangleMesh step_with_ledge() +{ + TriangleMesh m = Slic3r::Test::mesh(TestMesh::step); + m.scale(Vec3f(3.f, 3.f, 1.f)); + return m; +} + +// Every setting the assertions depend on, so none of them rests on a default. +DynamicPrintConfig base_config(const char *wall_generator) +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "wall_generator", wall_generator }, + { "layer_height", 0.2 }, // puts a layer boundary exactly on ledge_z + { "initial_layer_print_height", 0.2 }, + { "wall_loops", 3 }, + { "sparse_infill_density", "15%" }, + { "top_shell_layers", 3 }, + { "bottom_shell_layers", 3 }, + { "top_surface_density", "100%" }, + { "top_surface_expansion", 0.0 }, + { "only_one_wall_top", false }, + { "only_one_wall_first_layer", false }, + // Do not let the one-wall threshold discard the 3mm ledge before the feature sees it. + { "min_width_top_surface", 0.0 }, + }); + return config; +} + +double collection_length(const ExtrusionEntityCollection &coll) +{ + double len = 0.; + for (const ExtrusionEntity *entity : coll.flatten().entities) + if (! entity->is_collection()) + len += entity->length(); + return len; +} + +// Extruded length per layer. Two slices are compared through this rather than through their G-code, +// because the G-code carries a config block that differs whenever any setting differs. +struct SliceLengths { + std::vector perimeters; + std::vector fills; +}; + +SliceLengths slice_lengths(const Print &print) +{ + SliceLengths out; + for (const Layer *layer : print.objects().front()->layers()) { + double perimeters = 0., fills = 0.; + for (const LayerRegion *region : layer->regions()) { + perimeters += collection_length(region->perimeters); + fills += collection_length(region->fills); + } + out.perimeters.push_back(perimeters); + out.fills.push_back(fills); + } + return out; +} + +double perimeter_length_at(const Print &print, double print_z) +{ + for (const Layer *layer : print.objects().front()->layers()) + if (std::abs(layer->print_z - print_z) < 1e-4) { + double len = 0.; + for (const LayerRegion *region : layer->regions()) + len += collection_length(region->perimeters); + return len; + } + return 0.; +} + +// Largest per-layer difference between two series; a negative result means they are not comparable. +double max_difference(const std::vector &a, const std::vector &b) +{ + if (a.size() != b.size() || a.empty()) + return -1.; + double worst = 0.; + for (size_t i = 0; i < a.size(); ++ i) + worst = std::max(worst, std::abs(a[i] - b[i])); + return worst; +} + +} // namespace + +// The expansion only retypes area as top solid infill, so it can do nothing where there is no top +// fill to begin with: zero top shell layers retypes the top surfaces as internal, and a top surface +// density of 0% leaves the top layer with walls only. The last section is the control - the same +// expansion on the same model does change the slice once a top fill exists - without which the two +// equality checks above it would hold for an unrelated reason. +TEST_CASE("Top surface expansion only acts where there is a top fill", "[Perimeters]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + CAPTURE(wall_generator); + + auto lengths_for = [wall_generator](int top_shell_layers, const char *top_surface_density, double expansion) { + DynamicPrintConfig config = base_config(wall_generator); + config.set_deserialize_strict({ + { "top_shell_layers", top_shell_layers }, + { "top_surface_density", top_surface_density }, + { "top_surface_expansion", expansion }, + }); + Print print; + init_and_process_print({ step_with_ledge() }, print, config); + REQUIRE_FALSE(print.objects().empty()); + return slice_lengths(print); + }; + + SECTION("no top shell layers") { + const SliceLengths off = lengths_for(0, "100%", 0.0); + const SliceLengths on = lengths_for(0, "100%", 2.0); + REQUIRE(off.perimeters.size() == on.perimeters.size()); + CHECK_THAT(max_difference(off.perimeters, on.perimeters), Catch::Matchers::WithinAbs(0., 1.0)); + CHECK_THAT(max_difference(off.fills, on.fills), Catch::Matchers::WithinAbs(0., 1.0)); + } + + SECTION("zero top surface density") { + const SliceLengths off = lengths_for(3, "0%", 0.0); + const SliceLengths on = lengths_for(3, "0%", 2.0); + REQUIRE(off.perimeters.size() == on.perimeters.size()); + CHECK_THAT(max_difference(off.perimeters, on.perimeters), Catch::Matchers::WithinAbs(0., 1.0)); + CHECK_THAT(max_difference(off.fills, on.fills), Catch::Matchers::WithinAbs(0., 1.0)); + } + + SECTION("with a top fill the same expansion does change the slice") { + const SliceLengths off = lengths_for(3, "100%", 0.0); + const SliceLengths on = lengths_for(3, "100%", 2.0); + REQUIRE(off.fills.size() == on.fills.size()); + CHECK(max_difference(off.fills, on.fills) > scale_(0.5)); + } +} + +// With no top shell the top surfaces are retyped as internal, so the top surface density has nothing +// left to control: there is no top fill, and only_one_wall_top - the one route from the density to the +// perimeters - is itself switched off for want of a top surface to act on. +TEST_CASE("Top surface density does not affect a slice without a top shell", "[Perimeters]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + CAPTURE(wall_generator); + + auto lengths_for = [wall_generator](const char *top_surface_density) { + DynamicPrintConfig config = base_config(wall_generator); + config.set_deserialize_strict({ + { "top_shell_layers", 0 }, + { "only_one_wall_top", true }, + { "top_surface_density", top_surface_density }, + }); + Print print; + init_and_process_print({ step_with_ledge() }, print, config); + REQUIRE_FALSE(print.objects().empty()); + return slice_lengths(print); + }; + + const SliceLengths solid = lengths_for("100%"); + const SliceLengths none = lengths_for("0%"); + REQUIRE(solid.perimeters.size() == none.perimeters.size()); + CHECK_THAT(max_difference(solid.perimeters, none.perimeters), Catch::Matchers::WithinAbs(0., 1.0)); + CHECK_THAT(max_difference(solid.fills, none.fills), Catch::Matchers::WithinAbs(0., 1.0)); +} + +// On the ledge layer the inner walls are given up to the top fill, so that layer loses wall length. +// The handover needs a top fill that reaches the freed space: at a top surface density of 0% there is +// no top fill at all, and without top_surface_expansion the fill never grows over the walls. Either +// way the feature still runs, through the original generation, which keeps the inner walls up to the +// top boundary - putting that layer back between the plain and the one-wall slice. +TEST_CASE("Only one wall on top surfaces drops inner walls only where a top fill replaces them", "[Perimeters]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + CAPTURE(wall_generator); + + auto ledge_perimeters_for = [wall_generator](bool only_one_wall_top, const char *top_surface_density, double expansion) { + DynamicPrintConfig config = base_config(wall_generator); + config.set_deserialize_strict({ + { "only_one_wall_top", only_one_wall_top }, + { "top_surface_density", top_surface_density }, + { "top_surface_expansion", expansion }, + }); + Print print; + init_and_process_print({ step_with_ledge() }, print, config); + REQUIRE_FALSE(print.objects().empty()); + return perimeter_length_at(print, ledge_z); + }; + + const double plain = ledge_perimeters_for(false, "100%", 2.0); + const double one_wall = ledge_perimeters_for(true, "100%", 2.0); + const double one_wall_no_fill = ledge_perimeters_for(true, "0%", 2.0); + const double one_wall_no_expand = ledge_perimeters_for(true, "100%", 0.0); + + REQUIRE(plain > 0.); + CHECK(one_wall < plain); + // Both fall back to the original generation, which cuts the walls back to the top boundary but not past it. + CHECK(one_wall_no_fill > one_wall); + CHECK(one_wall_no_fill < plain); + CHECK(one_wall_no_expand > one_wall); + CHECK(one_wall_no_expand < plain); +} + +// The bottom counterpart: the first layer is thinned to a single wall only where a bottom shell fills the +// space behind it. With no bottom shell layers the bottom surfaces are retyped as internal, so that wall +// would ring sparse infill on the bed - the option is switched off instead, and the GUI hides it in that +// state so a profile that left it enabled cannot act behind a hidden checkbox. +TEST_CASE("Only one wall on the first layer needs a bottom shell", "[Perimeters]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + CAPTURE(wall_generator); + + auto first_layer_perimeters_for = [wall_generator](bool only_one_wall_first_layer, int bottom_shell_layers) { + DynamicPrintConfig config = base_config(wall_generator); + config.set_deserialize_strict({ + { "only_one_wall_first_layer", only_one_wall_first_layer }, + { "bottom_shell_layers", bottom_shell_layers }, + }); + Print print; + init_and_process_print({ step_with_ledge() }, print, config); + REQUIRE_FALSE(print.objects().empty()); + return perimeter_length_at(print, first_layer_z); + }; + + const double plain = first_layer_perimeters_for(false, 3); + const double one_wall = first_layer_perimeters_for(true, 3); + // Both at zero bottom shell layers, so everything else that setting changes cancels out between them. + const double plain_no_shell = first_layer_perimeters_for(false, 0); + const double one_wall_no_shell = first_layer_perimeters_for(true, 0); + + REQUIRE(plain > 0.); + CHECK(one_wall < plain); + // No bottom shell: the option is inert, down to the same walls an unchecked box gives. + CHECK_THAT(one_wall_no_shell, Catch::Matchers::WithinAbs(plain_no_shell, 1.0)); +}