From 85dc86642588eb5c872ad6e0278b16ad70027fef Mon Sep 17 00:00:00 2001 From: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:36:23 -0300 Subject: [PATCH] Spiral Inset infill (spiral-concentric infill) (#15085) Co-authored-by: Ian Bassi --- resources/images/param_spiralinset.svg | 1 + src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/Fill/Fill.cpp | 6 +- src/libslic3r/Fill/FillBase.cpp | 2 + src/libslic3r/Fill/FillSpiralInset.cpp | 426 ++++++++++++++++++ src/libslic3r/Fill/FillSpiralInset.hpp | 37 ++ src/libslic3r/Layer.cpp | 1 + src/libslic3r/PrintConfig.cpp | 10 +- src/libslic3r/PrintConfig.hpp | 4 +- src/libslic3r/Support/SupportParameters.hpp | 2 + src/slic3r/GUI/ConfigManipulation.cpp | 13 +- tests/fff_print/test_fill.cpp | 207 +++++++++ .../test_config_variant_expansion.cpp | 6 + 13 files changed, 708 insertions(+), 9 deletions(-) create mode 100644 resources/images/param_spiralinset.svg create mode 100644 src/libslic3r/Fill/FillSpiralInset.cpp create mode 100644 src/libslic3r/Fill/FillSpiralInset.hpp diff --git a/resources/images/param_spiralinset.svg b/resources/images/param_spiralinset.svg new file mode 100644 index 0000000000..6ec1ab36aa --- /dev/null +++ b/resources/images/param_spiralinset.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 2880a3cc6b..c33b7be429 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -147,6 +147,8 @@ set(lisbslic3r_sources Fill/FillBase.hpp Fill/FillConcentric.cpp Fill/FillConcentric.hpp + Fill/FillSpiralInset.cpp + Fill/FillSpiralInset.hpp Fill/FillConcentricInternal.cpp Fill/FillConcentricInternal.hpp Fill/FillCornerSmoothing.cpp diff --git a/src/libslic3r/Fill/Fill.cpp b/src/libslic3r/Fill/Fill.cpp index 0888e1bb55..dc772580ca 100644 --- a/src/libslic3r/Fill/Fill.cpp +++ b/src/libslic3r/Fill/Fill.cpp @@ -950,7 +950,7 @@ std::vector group_fills(const Layer &layer, LockRegionParam &lock_p params.extruder = region_config.internal_solid_filament_id; // Orca: forced fill order applies only to top/bottom surfaces filled with a // center-based pattern; everything else stays at Default to keep batching together. - if (params.pattern == ipConcentric || params.pattern == ipArchimedeanChords || params.pattern == ipOctagramSpiral) { + if (params.pattern == ipConcentric || params.pattern == ipSpiralInset || params.pattern == ipArchimedeanChords || params.pattern == ipOctagramSpiral) { if (params.extrusion_role == erTopSolidInfill) params.fill_order = region_config.top_surface_fill_order.value; else if (params.extrusion_role == erBottomSurface) @@ -1332,7 +1332,8 @@ void Layer::make_fills(FillAdaptive::Octree* adaptive_fill_octree, FillAdaptive: params.anchor_length = surface_fill.params.anchor_length; params.anchor_length_max = surface_fill.params.anchor_length_max; params.resolution = resolution; - params.use_arachne = surface_fill.params.pattern == ipConcentric || surface_fill.params.pattern == ipConcentricInternal; + params.use_arachne = surface_fill.params.pattern == ipConcentric || surface_fill.params.pattern == ipSpiralInset || + surface_fill.params.pattern == ipConcentricInternal; params.layer_height = layerm->layer()->height; params.lateral_lattice_angle_1 = surface_fill.params.lateral_lattice_angle_1; params.lateral_lattice_angle_2 = surface_fill.params.lateral_lattice_angle_2; @@ -1515,6 +1516,7 @@ Polylines Layer::generate_sparse_infill_polylines_for_anchoring(FillAdaptive::Oc case ipCubic: case ipLine: case ipConcentric: + case ipSpiralInset: case ipHoneycomb: case ipLateralHoneycomb: case ip3DHoneycomb: diff --git a/src/libslic3r/Fill/FillBase.cpp b/src/libslic3r/Fill/FillBase.cpp index 45157ec42d..2b307007b8 100644 --- a/src/libslic3r/Fill/FillBase.cpp +++ b/src/libslic3r/Fill/FillBase.cpp @@ -15,6 +15,7 @@ #include "FillBase.hpp" #include "FillConcentric.hpp" +#include "FillSpiralInset.hpp" #include "FillHoneycomb.hpp" #include "Fill3DHoneycomb.hpp" #include "FillGyroid.hpp" @@ -41,6 +42,7 @@ Fill* Fill::new_from_type(const InfillPattern type) { switch (type) { case ipConcentric: return new FillConcentric(); + case ipSpiralInset: return new FillSpiralInset(); case ipHoneycomb: return new FillHoneycomb(); case ipLateralHoneycomb: return new FillLateralHoneycomb(); case ip3DHoneycomb: return new Fill3DHoneycomb(); diff --git a/src/libslic3r/Fill/FillSpiralInset.cpp b/src/libslic3r/Fill/FillSpiralInset.cpp new file mode 100644 index 0000000000..e6323c2572 --- /dev/null +++ b/src/libslic3r/Fill/FillSpiralInset.cpp @@ -0,0 +1,426 @@ +#include "../ClipperUtils.hpp" +#include "../ExPolygon.hpp" +#include "../Surface.hpp" +#include "../VariableWidth.hpp" +#include "Arachne/WallToolPaths.hpp" + +#include "FillSpiralInset.hpp" + +#include +#include +#include + +namespace Slic3r { + +// Index of the corner the spiral should start at. Every following loop is split at the point nearest +// the end of the one before it, so this choice propagates inwards and decides where the whole spiral +// hands over from ring to ring. A tight corner is the worst place for it: there the next ring +// retreats along the bisector by spacing/sin(angle), so the spiral has to strike out several spacings +// to reach it instead of stepping across to a ring running parallel one spacing away. +// +// A right angle is taken first when the loop has one. It clips cleanly, since the trimming below +// scales with 1/sin(angle) and so is at its shortest and least sensitive there, and it holds its +// shape as the loop is offset inwards, which keeps the handover in the same place ring after ring. +// Failing that the widest corner is the flattest stretch on offer, which is the next best handover. +// A straight point is no corner at all and only turns up as an artefact of the offsetting, so it is +// skipped. +static int find_spiral_start_corner(const Polygon& loop) +{ + const size_t n = loop.points.size(); + if (n < 3) + return 0; + + // cos(85 deg): a corner within five degrees of square counts as a right angle. + static const double right_angle_cos = 0.08716; + // cos(179 deg): anything flatter than this counts as a straight point rather than a corner. + static const double straight_cos = -0.99985; + + // Only convex corners qualify. A reflex corner spans the same angle between its two edges but + // bulges the other way, so the next ring in steps away from it along the bisector instead of + // hugging it, and starting there hands over across a long diagonal on every single ring. Loops + // arrive counter-clockwise, in which case a convex corner turns left, but check the winding + // rather than trust it. A closed loop always has at least one convex corner. + const double convex_turn = loop.is_counter_clockwise() ? 1.0 : -1.0; + + double best_right_cos = right_angle_cos; + int best_right = -1; + double best_wide_cos = 1.0; + int best_wide = -1; + for (size_t i = 0; i < n; ++i) { + const Point& p_prev = loop.points[(i - 1 + n) % n]; + const Point& p = loop.points[i]; + const Point& p_next = loop.points[(i + 1) % n]; + + Vec2d e_in = (p - p_prev).cast(); + Vec2d e_out = (p_next - p).cast(); + double len1 = e_in.norm(); + double len2 = e_out.norm(); + if (len1 < 1e-6 || len2 < 1e-6) + continue; + if (convex_turn * (e_in.x() * e_out.y() - e_in.y() * e_out.x()) <= 0.0) + continue; + + // Cosine of the angle the two edges span at the corner: 1 at a spike, 0 square, -1 straight. + double cos_val = -e_in.dot(e_out) / (len1 * len2); + if (std::abs(cos_val) < best_right_cos) { + best_right_cos = std::abs(cos_val); + best_right = int(i); + } + if (cos_val > straight_cos && cos_val < best_wide_cos) { + best_wide_cos = cos_val; + best_wide = int(i); + } + } + if (best_right >= 0) + return best_right; + // A loop smooth enough to have no corner at all, a circle say, hands over equally well anywhere. + return best_wide < 0 ? 0 : best_wide; +} + +// Length to trim off the end of a loop so that it does not overlap the start of the next one. +// The theoretical gap is distance/sin(alpha), alpha being the angle between the last segment of the +// loop and the first segment of the next one. +static double loop_clip_length(const Polyline& loop_path, const double gap) +{ + const Point& p_prev = loop_path.points[loop_path.points.size() - 2]; + const Point& p_last = loop_path.points.back(); + const Point& p_next = loop_path.points[1]; + Vec2d v1 = (p_last - p_prev).cast(); + Vec2d v2 = (p_next - p_last).cast(); + if (v1.norm() < 1e-6 || v2.norm() < 1e-6) + return gap; + + double alpha = std::atan2(std::abs(v1.x() * v2.y() - v1.y() * v2.x()), v1.dot(v2)); + // Outside 45deg < alpha < 120deg the 1/sin(alpha) term would clip far too much, so fall back to the plain gap. + return (alpha > M_PI / 4 && alpha < 2 * M_PI / 3) ? gap / std::sin(alpha) : gap; +} + +// The chaining below drives two kinds of loop: the plain offset polygons of the classic path, and +// Arachne's variable width walls. These are the only four steps that differ between them. Widths run +// two per segment, so every point added or removed takes a pair with it. +static Polyline open_loop(const Polygon& loop, int start_index) { return loop.split_at_index(start_index); } + +static ThickPolyline open_loop(const Arachne::ExtrusionLine& loop, int start_index) +{ + ThickPolyline path = Arachne::to_thick_polyline(loop); + // start_at_index() rotates a closed path, and wants it closed with a matching width at both ends. + if (path.points.front() != path.points.back()) { + const coordf_t w_first = path.width.front(), w_last = path.width.back(); + path.points.emplace_back(path.points.front()); + path.width.emplace_back(w_last); + path.width.emplace_back(w_first); + } + path.start_at_index(start_index); + return path; +} + +static void clip_path_end(Polyline& path, double distance) { path.clip_end(distance); } + +static void clip_path_end(ThickPolyline& path, double distance) +{ + // Polyline::clip_end() knows nothing about the widths, so walk back trimming the two together. + while (distance > 0 && path.points.size() >= 2) { + const Point last = path.points.back(); + const coordf_t w_end = path.width.back(); + path.points.pop_back(); + path.width.pop_back(); + const coordf_t w_start = path.width.back(); + path.width.pop_back(); + + const Vec2d v = (path.points.back() - last).cast(); + const double len = v.norm(); + if (len > distance) { + const double t = distance / len; + path.points.emplace_back((last.cast() + v * t).cast()); + path.width.emplace_back(w_start); + path.width.emplace_back(w_start + (w_end - w_start) * (1.0 - t)); + return; + } + distance -= len; + } + path.clear(); +} + +static void append_path(Polyline& dst, Polyline&& src) { dst.append(std::move(src)); } + +static void append_path(ThickPolyline& dst, ThickPolyline&& src) +{ + if (dst.empty()) { + dst = std::move(src); + return; + } + if (dst.points.back() == src.points.front()) { + // Carrying straight on from the same point, so there is no run across to give a width to. + src.points.erase(src.points.begin()); + src.width.erase(src.width.begin(), src.width.begin() + 2); + } else { + // The run across to the next loop tapers between the two ends it joins. + const coordf_t w_from = dst.width.back(), w_to = src.width.front(); + dst.width.emplace_back(w_from); + dst.width.emplace_back(w_to); + } + append(dst.points, std::move(src.points)); + append(dst.width, std::move(src.width)); +} + +// The classic loops all carry the same width, so the innermost one of an island can still ring an +// unfilled pin hole, which the spiral plugs by running into the middle. Arachne's walls widen to take +// up whatever is left over, so there is nothing there to plug and the stub would only double back +// over the wall that just filled it. +static bool leaves_a_centre_hole(const Polygon&) { return true; } +static bool leaves_a_centre_hole(const Arachne::ExtrusionLine&) { return false; } + +static void append_path_point(Polyline& path, const Point& point) { path.points.emplace_back(point); } + +static void append_path_point(ThickPolyline& path, const Point& point) +{ + const coordf_t w = path.width.back(); + path.points.emplace_back(point); + path.width.emplace_back(w); + path.width.emplace_back(w); +} + +// Chain the loops of one surface into as few continuous spirals as its shape allows. The loops arrive +// ordered outside in, depth first, each paired with its outline in loop_outlines; every decision here +// is made on those outlines, so the two kinds of loop take exactly the same route. +template +static std::vector generate_spiral_insets(const FillParams& params, + const std::vector& loops, + const Polygons& loop_outlines, + const coord_t distance, + const ExPolygon& original_expoly) +{ + std::vector output; + PathType spiral; + Point current_pos(0, 0); + // Index into loops of the innermost loop appended to the spiral currently being built. + int innermost_loop = -1; + + // Whether the spiral can run straight from one point to the other. The run across is extruded, + // not travelled, so it has to be a genuine step over to the ring alongside: + // - up to a ring spacing and a half it cannot leave the material, and needs no check at all, + // which covers all but a few of the loops; + // - beyond that it is tested against the surface, which catches the points that are close in a + // straight line but separated by a hole or a notch; + // - past four spacings it is refused outright. A handover does stretch at a corner, where the + // next ring retreats along the bisector by spacing/sin(angle), but four spacings is already a + // fifteen degree wedge, and down a wedge that tight the run across would trace the bisector, + // which is where the tail is filled from anyway. Anything longer is a traverse across the + // surface that prints over what it crosses. Breaking the spiral leaves the G-code to travel it. + const double free_hop = 1.5 * double(distance); + const double max_hop = 4.0 * double(distance); + auto reachable = [&](const Point& from, const Point& to) { + const double hop = from.distance_to(to); + if (hop > max_hop) + return false; + return hop <= free_hop || original_expoly.contains(Line(from, to)); + }; + + // The centre point plugs the pin hole left in the middle of an island, it is not meant to + // traverse it, so it is only worth adding when the innermost loop has shrunk to about a ring. + const double max_center_stub = 2.0 * double(distance); + + // Emit the spiral built so far as one path and start over on a fresh island. + auto flush_spiral = [&]() { + if (spiral.empty()) + return; + // Run into the middle of the innermost loop so the island's centre is filled instead of being + // left as a pin hole. Only where there is a hole to fill: the loop has to still enclose open + // space once its own bead is accounted for, or the stub just runs back over that bead. And + // the point has to sit inside the loop and be reachable, or it runs off across the surface. + if (innermost_loop >= 0 && leaves_a_centre_hole(*loops[innermost_loop])) { + const Polygon& innermost = loop_outlines[innermost_loop]; + const Point centroid = innermost.centroid(); + if (!offset(innermost, -float(0.5 * double(distance))).empty() && centroid != spiral.last_point() && + spiral.last_point().distance_to(centroid) <= max_center_stub && innermost.contains(centroid) && + reachable(spiral.last_point(), centroid)) + append_path_point(spiral, centroid); + } + output.emplace_back(std::move(spiral)); + spiral.clear(); + innermost_loop = -1; + current_pos = Point(0, 0); + }; + + for (size_t i = 0; i < loops.size(); ++i) { + const Polygon& outline = loop_outlines[i]; + if (outline.points.empty()) + continue; + + // The loop is opened into a path with the split point repeated at both ends, so a usable one + // has at least 3 points. Both kinds of loop share the outline's indices, hence its start point. + PathType loop_path = open_loop(*loops[i], spiral.empty() ? find_spiral_start_corner(outline) : + current_pos.nearest_point_index(outline.points)); + if (loop_path.size() < 3) + continue; + + // Island jumping: the loops are ordered by their nesting, depth first, so the next one + // continues the current spiral exactly when it lies inside the one just laid down. Distance + // cannot stand in for that test: at a sharp corner the next ring retreats along the bisector + // by spacing/sin(angle), which leaves it several spacings away while still being the very + // next ring in, and the spiral would break off at every spike. + const bool same_island = innermost_loop >= 0 && loop_outlines[innermost_loop].contains(loop_path.points.front()); + if (!spiral.empty() && (!same_island || !reachable(spiral.last_point(), loop_path.points.front()))) { + flush_spiral(); + loop_path = open_loop(*loops[i], find_spiral_start_corner(outline)); + if (loop_path.size() < 3) + continue; + } + + // Clip the end of the loop to leave room for the run into the next one. The last loop of the + // surface has no successor, so it only gives up half of the gap. + clip_path_end(loop_path, loop_clip_length(loop_path, (i + 1 == loops.size() ? 0.5 : 1.0) * double(distance))); + // Clipping empties the path when the loop is shorter than the clipping length, which happens + // on the degenerate slivers that offsetting leaves behind. Such a loop carries no extrusion. + if (loop_path.size() < 2) + continue; + + append_path(spiral, std::move(loop_path)); + innermost_loop = int(i); + current_pos = spiral.last_point(); + } + + flush_spiral(); + + // An outward fill order runs every spiral from its centre to its outer edge, innermost island first. + if (params.fill_order != SurfaceFillOrder::Inward) { + for (PathType& path : output) + path.reverse(); + std::reverse(output.begin(), output.end()); + } + + return output; +} + +void FillSpiralInset::_fill_surface_single(const FillParams& params, + unsigned int thickness_layers, + const std::pair& direction, + ExPolygon expolygon, + Polylines& polylines_out) +{ + BoundingBox bounding_box = expolygon.contour.bounding_box(); + + coord_t min_spacing = scale_(this->spacing); + coord_t distance = coord_t(min_spacing / params.density); + + if (params.density > 0.9999f && !params.dont_adjust) { + distance = this->_adjust_solid_spacing(bounding_box.size()(0), distance); + this->spacing = unscale(distance); + } + + Polygons loops = to_polygons(expolygon); + + ExPolygons last{std::move(expolygon)}; + while (!last.empty()) { + last = offset2_ex(last, -(distance + min_spacing / 2), +min_spacing / 2); + append(loops, to_polygons(last)); + } + + // Orders the loops outside in, depth first, which is the order the chaining below expects. + loops = union_pt_chained_outside_in(loops); + + std::vector loop_refs; + loop_refs.reserve(loops.size()); + for (const Polygon& loop : loops) + loop_refs.emplace_back(&loop); + + Polylines spiral_result = generate_spiral_insets(params, loop_refs, loops, distance, expolygon); + + append(polylines_out, spiral_result); +} + +void FillSpiralInset::_fill_surface_single(const FillParams& params, + unsigned int thickness_layers, + const std::pair& direction, + ExPolygon expolygon, + ThickPolylines& thick_polylines_out) +{ + assert(params.use_arachne); + assert(this->print_config != nullptr && this->print_object_config != nullptr); + + // Only a solid surface is worth the variable width walls; a sparse one falls back to plain loops. + if (params.density <= 0.9999f || params.dont_adjust) { + Polylines polylines; + this->_fill_surface_single(params, thickness_layers, direction, expolygon, polylines); + append(thick_polylines_out, to_thick_polylines(std::move(polylines), scaled(this->spacing))); + return; + } + + // no rotation is supported for this infill pattern + Point bbox_size = expolygon.contour.bounding_box().size(); + coord_t min_spacing = scaled(this->spacing); + + coord_t loops_count = std::max(bbox_size.x(), bbox_size.y()) / min_spacing + 1; + Polygons polygons = offset(expolygon, float(min_spacing) / 2.f); + + double min_nozzle_diameter = *std::min_element(print_config->nozzle_diameter.values.begin(), print_config->nozzle_diameter.values.end()); + Arachne::WallToolPathsParams input_params; + input_params.min_bead_width = 0.85 * min_nozzle_diameter; + input_params.min_feature_size = 0.25 * min_nozzle_diameter; + input_params.wall_transition_length = 1.0 * min_nozzle_diameter; + input_params.wall_transition_angle = 10; + input_params.wall_transition_filter_deviation = 0.25 * min_nozzle_diameter; + input_params.wall_distribution_count = 1; + + Arachne::WallToolPaths wallToolPaths(polygons, min_spacing, min_spacing, loops_count, 0, params.layer_height, input_params); + std::vector walls_by_inset = wallToolPaths.getToolPaths(); + + // Open walls are the thin features Arachne fits between the closed ones. They cannot join a + // spiral, so they go out as they are; leaving them behind is what would put the gaps back. + std::vector walls; + Polygons wall_outlines; + ThickPolylines open_walls; + for (const Arachne::VariableWidthLines& inset : walls_by_inset) + for (const Arachne::ExtrusionLine& wall : inset) { + if (wall.empty()) + continue; + if (wall.is_closed) { + walls.emplace_back(&wall); + wall_outlines.emplace_back(wall.toPolygon()); + } else { + open_walls.emplace_back(Arachne::to_thick_polyline(wall)); + } + } + + // Arachne hands the walls back grouped by inset, which is not their nesting: around a hole the + // wall of a given inset lies inside the wall of that same inset around the contour. Nest them by + // containment instead, so the spiral follows one island all the way in before starting the next, + // the same order union_pt_chained_outside_in gives the classic path above. + const size_t wall_count = walls.size(); + std::vector nesting_depth(wall_count, 0), parent(wall_count, -1); + std::vector inside(wall_count * wall_count, 0); + for (size_t i = 0; i < wall_count; ++i) + for (size_t j = 0; j < wall_count; ++j) + if (i != j && wall_outlines[j].contains(walls[i]->junctions.front().p)) { + inside[i * wall_count + j] = 1; + ++nesting_depth[i]; + } + // The innermost of the walls containing this one, which is the deepest of them, is its parent. + for (size_t i = 0; i < wall_count; ++i) + for (size_t j = 0; j < wall_count; ++j) + if (inside[i * wall_count + j] && (parent[i] < 0 || nesting_depth[parent[i]] < nesting_depth[j])) + parent[i] = int(j); + + std::vector ordered; + Polygons outlines; + ordered.reserve(wall_count); + outlines.reserve(wall_count); + std::function descend = [&](int idx) { + ordered.emplace_back(walls[idx]); + outlines.emplace_back(wall_outlines[idx]); + for (size_t k = 0; k < wall_count; ++k) + if (parent[k] == idx) + descend(int(k)); + }; + for (size_t i = 0; i < wall_count; ++i) + if (parent[i] < 0) + descend(int(i)); + + ThickPolylines spiral_result = + generate_spiral_insets(params, ordered, outlines, min_spacing, expolygon); + + append(thick_polylines_out, std::move(spiral_result)); + append(thick_polylines_out, std::move(open_walls)); +} + +} // namespace Slic3r diff --git a/src/libslic3r/Fill/FillSpiralInset.hpp b/src/libslic3r/Fill/FillSpiralInset.hpp new file mode 100644 index 0000000000..eb799ec2f3 --- /dev/null +++ b/src/libslic3r/Fill/FillSpiralInset.hpp @@ -0,0 +1,37 @@ +#ifndef slic3r_FillSpiralInset_hpp_ +#define slic3r_FillSpiralInset_hpp_ + +#include "FillBase.hpp" + +namespace Slic3r { + +class FillSpiralInset : public Fill +{ +public: + ~FillSpiralInset() override = default; + bool is_self_crossing() override { return false; } + +protected: + Fill* clone() const override { return new FillSpiralInset(*this); }; + void _fill_surface_single( + const FillParams ¶ms, + unsigned int thickness_layers, + const std::pair &direction, + ExPolygon expolygon, + Polylines &polylines_out) override; + + // Orca: solid surfaces are filled with Arachne's variable width walls, which widen to take up + // whatever the fixed width loops above would have left over as gaps. + void _fill_surface_single( + const FillParams ¶ms, + unsigned int thickness_layers, + const std::pair &direction, + ExPolygon expolygon, + ThickPolylines &thick_polylines_out) override; + + bool no_sort() const override { return true; } +}; + +} // namespace Slic3r + +#endif // slic3r_FillSpiralInset_hpp_ diff --git a/src/libslic3r/Layer.cpp b/src/libslic3r/Layer.cpp index 87ad11bcf8..1d6c2b0703 100644 --- a/src/libslic3r/Layer.cpp +++ b/src/libslic3r/Layer.cpp @@ -419,6 +419,7 @@ coordf_t Layer::get_sparse_infill_max_void_area() double spacing = flow.scaled_spacing() * (100 - density) / density; switch (pattern) { case ipConcentric: + case ipSpiralInset: case ipRectilinear: case ipLine: case ipGyroid: diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 100089fd24..1db476eede 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -275,6 +275,7 @@ static t_config_enum_values s_keys_map_InfillPattern { { "tpmsfk", ipTpmsFK }, { "gyroid", ipGyroid }, { "concentric", ipConcentric }, + { "spiralinset", ipSpiralInset }, { "hilbertcurve", ipHilbertCurve }, { "archimedeanchords", ipArchimedeanChords }, { "octagramspiral", ipOctagramSpiral } @@ -371,6 +372,7 @@ static t_config_enum_values s_keys_map_SupportMaterialInterfacePattern { { "auto", smipAuto }, { "rectilinear", smipRectilinear }, { "concentric", smipConcentric }, + { "spiralinset", smipSpiralInset }, { "rectilinear_interlaced", smipRectilinearInterlaced}, { "grid", smipGrid } }; @@ -2292,6 +2294,7 @@ void PrintConfigDef::init_fff_params() def->enum_values.push_back("rectilinear"); def->enum_values.push_back("alignedrectilinear"); def->enum_values.push_back("concentric"); + def->enum_values.push_back("spiralinset"); def->enum_values.push_back("hilbertcurve"); def->enum_values.push_back("archimedeanchords"); def->enum_values.push_back("octagramspiral"); @@ -2300,6 +2303,7 @@ void PrintConfigDef::init_fff_params() def->enum_labels.push_back(L("Rectilinear")); def->enum_labels.push_back(L("Aligned Rectilinear")); def->enum_labels.push_back(L("Concentric")); + def->enum_labels.push_back(L("Spiral Inset")); def->enum_labels.push_back(L("Hilbert Curve")); def->enum_labels.push_back(L("Archimedean Chords")); def->enum_labels.push_back(L("Octagram Spiral")); @@ -2382,7 +2386,7 @@ void PrintConfigDef::init_fff_params() def->label = L("Top surface fill order"); def->category = L("Strength"); def->tooltip = L("Direction in which top surfaces are filled when using a center-based pattern " - "(Concentric, Archimedean Chords, Octagram Spiral).\n" + "(Concentric, Spiral Inset, Archimedean Chords, Octagram Spiral).\n" "Outward starts at the center of the surface, so any excess material is pushed " "towards the edge where it is least visible. Inward starts at the edge and ends " "with the tight curves at the center.\n" @@ -2401,7 +2405,7 @@ void PrintConfigDef::init_fff_params() def->label = L("Bottom surface fill order"); def->category = L("Strength"); def->tooltip = L("Direction in which bottom surfaces are filled when using a center-based pattern " - "(Concentric, Archimedean Chords, Octagram Spiral).\n" + "(Concentric, Spiral Inset, Archimedean Chords, Octagram Spiral).\n" "Inward starts each surface with the wider outer curves, which improves first layer " "adhesion on build plates where the tight curves at the center may not stick. " "Outward starts at the center, pushing any excess material towards the edge.\n" @@ -6963,11 +6967,13 @@ void PrintConfigDef::init_fff_params() def->enum_values.push_back("auto"); def->enum_values.push_back("rectilinear"); def->enum_values.push_back("concentric"); + def->enum_values.push_back("spiralinset"); def->enum_values.push_back("rectilinear_interlaced"); def->enum_values.push_back("grid"); def->enum_labels.push_back(L("Default")); def->enum_labels.push_back(L("Rectilinear")); def->enum_labels.push_back(L("Concentric")); + def->enum_labels.push_back(L("Spiral Inset")); def->enum_labels.push_back(L("Rectilinear Interlaced")); def->enum_labels.push_back(L("Grid")); def->mode = comAdvanced; diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 330151c4d3..f7cbe8b2e5 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -113,7 +113,7 @@ enum InfillPattern : int { ipCubic, ipAdaptiveCubic, ipQuarterCubic, ipSupportCubic, ipLightning, ipHoneycomb, ip3DHoneycomb, ipLateralHoneycomb, ipLateralLattice, ipCrossHatch, ipTpmsD, ipTpmsFK, ipGyroid, - ipConcentric, ipHilbertCurve, ipArchimedeanChords, ipOctagramSpiral, + ipConcentric, ipSpiralInset, ipHilbertCurve, ipArchimedeanChords, ipOctagramSpiral, ipSupportBase, ipConcentricInternal, ipCount, }; @@ -271,7 +271,7 @@ enum LongRectrationLevel }; enum SupportMaterialInterfacePattern { - smipAuto, smipRectilinear, smipConcentric, smipRectilinearInterlaced, smipGrid + smipAuto, smipRectilinear, smipConcentric, smipSpiralInset, smipRectilinearInterlaced, smipGrid }; // BBS diff --git a/src/libslic3r/Support/SupportParameters.hpp b/src/libslic3r/Support/SupportParameters.hpp index dc0e94ed03..8d43edb444 100644 --- a/src/libslic3r/Support/SupportParameters.hpp +++ b/src/libslic3r/Support/SupportParameters.hpp @@ -141,6 +141,8 @@ struct SupportParameters { this->contact_fill_pattern = ipGrid; else if (object_config.support_interface_pattern == smipRectilinearInterlaced) this->contact_fill_pattern = ipRectilinear; + else if (object_config.support_interface_pattern == smipSpiralInset) + this->contact_fill_pattern = ipSpiralInset; else this->contact_fill_pattern = (object_config.support_interface_pattern == smipAuto && zero_gap_contact_interface) || diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 09b7fcfad4..8eab1c785c 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -741,14 +741,21 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in bool have_infill = config->option("sparse_infill_density")->value > 0; // sparse_infill_filament_id uses the same logic as in Print::extruders() for (auto el : { "sparse_infill_pattern", "infill_combination", "fill_multiline","infill_direction", - "minimum_sparse_infill_area", "sparse_infill_filament_id", "infill_anchor", "infill_anchor_max","infill_shift_step","sparse_infill_rotate_template","symmetric_infill_y_axis"}) + "minimum_sparse_infill_area", "sparse_infill_filament_id","infill_shift_step","sparse_infill_rotate_template","symmetric_infill_y_axis"}) toggle_line(el, have_infill); + InfillPattern pattern = config->opt_enum("sparse_infill_pattern"); + + // Orca: the concentric patterns follow the surface outline instead of crossing it, so there is + // nothing for an infill anchor to attach to. Hide the anchor settings for them. + bool have_infill_anchor = have_infill && pattern != ipConcentric && pattern != ipSpiralInset; + toggle_line("infill_anchor", have_infill_anchor); + toggle_line("infill_anchor_max", have_infill_anchor); + bool have_combined_infill = config->opt_bool("infill_combination") && have_infill; toggle_line("infill_combination_max_layer_height", have_combined_infill); // Infill patterns that support multiline infill. - InfillPattern pattern = config->opt_enum("sparse_infill_pattern"); bool have_multiline_infill_pattern = pattern == ipGyroid || pattern == ipGrid || pattern == ipRectilinear || pattern == ipTpmsD || pattern == ipTpmsFK || pattern == ipCrossHatch || pattern == ipHoneycomb || pattern == ipLateralLattice || pattern == ipLateralHoneycomb || pattern == ipConcentric || pattern == ipCubic || pattern == ipStars || pattern == ipAlignedRectilinear || pattern == ipLightning || pattern == ip3DHoneycomb || pattern == ipAdaptiveCubic || pattern == ipSupportCubic|| pattern == ipTriangles || pattern == ipQuarterCubic|| pattern == ipArchimedeanChords || pattern == ipHilbertCurve || pattern == ipOctagramSpiral; @@ -831,7 +838,7 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in toggle_line("separated_infills", is_internal_infill_separable); // Fill order is only meaningful for the center-based surface fill patterns; hide it otherwise. - auto is_centered_fill = [](InfillPattern p) { return p == ipConcentric || p == ipArchimedeanChords || p == ipOctagramSpiral; }; + auto is_centered_fill = [](InfillPattern p) { return p == ipConcentric || p == ipSpiralInset || p == ipArchimedeanChords || p == ipOctagramSpiral; }; toggle_line("top_surface_fill_order", has_top_shell && is_centered_fill(config->opt_enum("top_surface_pattern"))); toggle_line("bottom_surface_fill_order", has_bottom_shell && is_centered_fill(config->opt_enum("bottom_surface_pattern"))); diff --git a/tests/fff_print/test_fill.cpp b/tests/fff_print/test_fill.cpp index d26639c659..04e5b61831 100644 --- a/tests/fff_print/test_fill.cpp +++ b/tests/fff_print/test_fill.cpp @@ -699,6 +699,213 @@ TEST_CASE("Solid infill direction offsets every layer when no template is set", } } +// Orca: the spiral inset pattern chains the concentric loops into a single continuous path per +// island, so it has to cope with the degenerate loops offsetting leaves behind and it must not join +// loops that only look adjacent. +namespace { + +Slic3r::Polylines spiral_inset_fill(const Slic3r::ExPolygon &surface_shape, double spacing) +{ + std::unique_ptr filler(Slic3r::Fill::new_from_type("spiralinset")); + filler->spacing = spacing; + // Cancel the half-spacing contraction fill_surface() applies, so the filler sees the shape as given. + filler->overlap = 0.5 * spacing; + + Slic3r::FillParams fill_params; + fill_params.density = 1.f; + fill_params.dont_adjust = true; + + Slic3r::Surface surface(Slic3r::stBottom, surface_shape); + return filler->fill_surface(&surface, fill_params); +} + +Slic3r::ExPolygon rectangle(double x, double y, double w, double h) +{ + return Slic3r::ExPolygon({Slic3r::Point::new_scale(x, y), Slic3r::Point::new_scale(x + w, y), + Slic3r::Point::new_scale(x + w, y + h), Slic3r::Point::new_scale(x, y + h)}); +} + +// Area of the surface the toolpaths fail to cover, and the largest single patch of it, in mm2. Each +// bead is measured at its own width so the variable width walls are not sold short. +std::pair uncovered_area(const Slic3r::ExPolygon &surface_shape, const Slic3r::Polygons &covered) +{ + double total = 0, biggest = 0; + for (const Slic3r::ExPolygon &gap : Slic3r::diff_ex(Slic3r::ExPolygons{surface_shape}, Slic3r::union_(covered))) { + const double area = unscale(unscale(gap.area())); + total += area; + biggest = std::max(biggest, area); + } + return {total, biggest}; +} + +Slic3r::Polygons beads_of(const Slic3r::Polylines &paths, double width) +{ + return Slic3r::offset(paths, float(scale_(0.5 * width))); +} + +Slic3r::Polygons beads_of(const Slic3r::ThickPolylines &paths) +{ + Slic3r::Polygons covered; + for (const Slic3r::ThickPolyline &path : paths) + for (size_t i = 0; i + 1 < path.points.size(); ++i) { + Slic3r::Polyline segment; + segment.points = {path.points[i], path.points[i + 1]}; + Slic3r::append(covered, Slic3r::offset(Slic3r::Polylines{segment}, + float(0.5 * std::max(path.width[2 * i], path.width[2 * i + 1])))); + } + return covered; +} + +} // namespace + +TEST_CASE("Spiral inset fill drops loops shorter than the end clipping", "[Fill][Regression]") +{ + // A sliver whose whole perimeter is shorter than the length clipped off the end of a loop, so the + // clipping consumes the path entirely. Such a loop carries no extrusion and must be dropped + // rather than kept as an empty path and read back from. + const double spacing = 0.45; + + Slic3r::Polylines paths; + REQUIRE_NOTHROW(paths = spiral_inset_fill(rectangle(0, 0, 0.05, 0.05), spacing)); + for (const Slic3r::Polyline &path : paths) + CHECK(path.size() >= 2); + + // The same surface at a size the clipping cannot swallow still gets filled. + REQUIRE_NOTHROW(paths = spiral_inset_fill(rectangle(0, 0, 5, 5), spacing)); + REQUIRE(paths.size() == 1); + CHECK(paths.front().size() >= 2); +} + +TEST_CASE("Spiral inset fill keeps separate islands on separate paths", "[Fill]") +{ + // Two lobes joined by a neck narrower than the loop spacing: the inward offsets break the surface + // into two islands, which cannot share one spiral, and no path may leave the surface. + const double spacing = 0.45; + Slic3r::ExPolygon dumbbell = rectangle(0, 0, 6, 6); + dumbbell = Slic3r::union_ex(Slic3r::ExPolygons{dumbbell, rectangle(6, 2.9, 4, 0.2), rectangle(10, 0, 6, 6)}).front(); + + const Slic3r::Polylines paths = spiral_inset_fill(dumbbell, spacing); + REQUIRE(paths.size() >= 2); + + // Inflate by a hair so that loops sitting exactly on the outline still count as contained. + const Slic3r::ExPolygons within = Slic3r::offset_ex(dumbbell, float(SCALED_EPSILON)); + REQUIRE(within.size() == 1); + for (const Slic3r::Polyline &path : paths) { + CHECK(path.size() >= 2); + CHECK(within.front().contains(path)); + } +} + + +TEST_CASE("Spiral inset fill stays connected across sharp corners", "[Fill][Regression]") +{ + // At a corner of half-angle a, the next ring inward retreats along the bisector by spacing/sin(a), + // which leaves it several spacings from the end of the ring it continues. Judging the break by + // distance broke the spiral into loose rings at every spike; nesting is what decides the island. + const double spacing = 0.45; + const Slic3r::ExPolygon spike({Slic3r::Point::new_scale(0, 0), Slic3r::Point::new_scale(30, 0), + Slic3r::Point::new_scale(15, 4)}); + + const Slic3r::Polylines paths = spiral_inset_fill(spike, spacing); + CHECK(paths.size() == 1); + + const Slic3r::ExPolygons within = Slic3r::offset_ex(spike, float(SCALED_EPSILON)); + REQUIRE(within.size() == 1); + for (const Slic3r::Polyline &path : paths) + CHECK(within.front().contains(path)); +} + +TEST_CASE("Spiral inset fill starts on a convex corner", "[Fill][Regression]") +{ + // The only right angle on this outline is the reflex one: the two edges meeting at the origin + // span 90 degrees exactly as a square corner would, but the material lies outside them. The next + // ring in steps away from a reflex corner along the bisector instead of hugging it, so starting + // the spiral there sent it across a long diagonal on every single ring. + const double spacing = 0.45; + const Slic3r::ExPolygon notched({Slic3r::Point::new_scale(0, 0), Slic3r::Point::new_scale(0, 10), + Slic3r::Point::new_scale(-16, 18), Slic3r::Point::new_scale(-16, -2), + Slic3r::Point::new_scale(-8, -16), Slic3r::Point::new_scale(18, -16), + Slic3r::Point::new_scale(10, 0)}); + + const Slic3r::Polylines paths = spiral_inset_fill(notched, spacing); + REQUIRE(paths.size() >= 1); + + // Every edge of the outline is at least 45 degrees off the bisector of that reflex corner, and + // so is every ring offset from it. A long segment running along the bisector can therefore only + // be the spiral striking out across the rings to reach the next one. + for (const Slic3r::Polyline &path : paths) + for (const Slic3r::Line &segment : path.lines()) { + const Vec2d v = (segment.b - segment.a).cast(); + const double direction = std::fmod(std::atan2(v.y(), v.x()) * 180.0 / M_PI + 180.0, 180.0); + if (std::abs(direction - 45.0) > 25.0) + continue; + CAPTURE(direction, unscale(segment.length())); + CHECK(segment.length() <= scale_(1.5 * spacing)); + } +} + +TEST_CASE("Spiral inset fill closes the gaps with variable width walls", "[Fill]") +{ + // Fixed width loops cannot fill a region that is not a whole number of lines across and leave the + // remainder open, which on a ring shows up as a wedge several lines wide. Plain concentric avoids + // that by building solid surfaces out of Arachne's variable width walls, and so must this pattern. + const double spacing = 0.45; + Slic3r::ExPolygon ring = rectangle(0, 0, 24, 24); + Slic3r::Polygon hole; + for (int i = 0; i < 64; ++i) { + const double angle = -2.0 * PI * i / 64.0; // clockwise, so it reads as a hole + hole.points.emplace_back(Slic3r::Point::new_scale(12 + 7.3 * std::cos(angle), 12 + 7.3 * std::sin(angle))); + } + ring.holes.emplace_back(hole); + + Slic3r::PrintConfig print_config; + Slic3r::PrintObjectConfig object_config; + auto make_filler = [&]() { + std::unique_ptr filler(Slic3r::Fill::new_from_type("spiralinset")); + filler->spacing = spacing; + filler->overlap = 0.5 * spacing; // cancel the contraction, so both see the same surface + filler->print_config = &print_config; + filler->print_object_config = &object_config; + return filler; + }; + + Slic3r::FillParams params; + params.density = 1.f; + params.dont_adjust = false; + params.layer_height = 0.2; + + const Slic3r::Surface surface(Slic3r::stTop, ring); + + std::unique_ptr fixed = make_filler(); + const Slic3r::Polylines fixed_width = fixed->fill_surface(&surface, params); + REQUIRE(!fixed_width.empty()); + const auto fixed_gaps = uncovered_area(ring, beads_of(fixed_width, fixed->spacing)); + + params.use_arachne = true; + std::unique_ptr variable = make_filler(); + const Slic3r::ThickPolylines variable_width = variable->fill_surface_arachne(&surface, params); + REQUIRE(!variable_width.empty()); + const auto variable_gaps = uncovered_area(ring, beads_of(variable_width)); + + CAPTURE(fixed_gaps.first, fixed_gaps.second, variable_gaps.first, variable_gaps.second); + // The wedges the fixed width loops leave behind are what the variable width walls take up. + CHECK(variable_gaps.second < 0.5 * fixed_gaps.second); + CHECK(variable_gaps.first < fixed_gaps.first); + + // And it is still a spiral: far fewer paths than the ring has loops. + // And the walls are still chained into spirals rather than printed one path per wall. The ring is + // at its narrowest (12 - 7.3) mm across and is filled from both sides, so it is at least this many + // walls thick there and thicker elsewhere. Arachne's short thin feature walls cannot join a spiral, + // so only the substantial paths count towards this. + const size_t walls_across = size_t(2.0 * (12.0 - 7.3) / spacing); + size_t spirals = 0; + for (const Slic3r::ThickPolyline &path : variable_width) + if (path.length() > scale_(10.0 * spacing)) + ++spirals; + CAPTURE(spirals, walls_across, variable_width.size(), fixed_width.size()); + CHECK(2 * spirals < walls_across); +} + TEST_CASE("Honeycomb infill rounds its cell corners with the smooth factor", "[Fill]") { // A cell whose sides are several times the line width, so that the corners have room to be rounded. diff --git a/tests/libslic3r/test_config_variant_expansion.cpp b/tests/libslic3r/test_config_variant_expansion.cpp index 28901702cc..2469789d5b 100644 --- a/tests/libslic3r/test_config_variant_expansion.cpp +++ b/tests/libslic3r/test_config_variant_expansion.cpp @@ -130,6 +130,12 @@ TEST_CASE("get_config_index_base resolves (volume type, extruder type, id) to a } } +TEST_CASE("support interface pattern registry includes spiral inset", "[Config]") +{ + const auto &values = ConfigOptionEnum::get_enum_values(); + REQUIRE(values.at("spiralinset") == SupportMaterialInterfacePattern::smipSpiralInset); +} + TEST_CASE("get_extruder_nozzle_volume_count reads the per-extruder volume-type layout", "[Config]") { std::vector> nozzle_volume_types;