Fix Arachne duplicate extrusion caused by bead count mismatch (#14031)

* Add test for Arachne duplicate wall segment detection

Add test cases that reproduce an issue where Arachne generates
duplicate/coinciding extrusion segments at certain min_bead_width settings.

Test configuration:
- Profile: 0.28mm Extra Draft @BBL X1C (0.4mm nozzle, 0.28mm layer)
- outer_wall_line_width: 0.42mm, inner_wall_line_width: 0.45mm
- wall_loops: 2, precise_outer_wall: enabled
- Test polygon: outer rectangle (0,0)-(20,20) with inner cutout (0.5,0.5)-(19.5,19.5)
  This creates a 0.5mm wide frame around the perimeter.

Results:
- 50% min_bead_width (0.20mm): FAILS - detects 4 duplicate segments (all 4 sides)
- 60% min_bead_width (0.24mm): PASSES - no duplicates

At 50%, Arachne generates two separate closed loops that share all 4 edges
of the inner square. At 60%, Arachne generates a single closed loop.

SVG output is exported to /tmp/opencode/ for visual debugging.

* Fix Arachne duplicate extrusion caused by bead count mismatch

WideningBeadingStrategy::compute() used optimal_width (inner wall width)
to determine if a thin wall should produce a single bead. However,
getOptimalBeadCount() uses optimal_width_outer (outer wall width) via
RedistributeBeadingStrategy to decide the bead count.

This inconsistency caused situations where getOptimalBeadCount() returned
2 beads, but compute() produced only 1 bead at full thickness. The single
bead was then generated for both inner and outer contours, resulting in
duplicate extrusion paths.

Fix: Use getTransitionThickness(1) instead of optimal_width. This method
returns the exact threshold for the 1-to-2 bead transition, ensuring
consistency between bead count calculation and bead generation.

Reproduces with: 50% min_bead_width, 0.42mm outer wall, 0.45mm inner wall,
0.5mm polygon inset creating ~0.38mm wall thickness.

Fixes #13917

---------

Co-authored-by: SoftFever <softfeverever@gmail.com>
This commit is contained in:
Alexander
2026-06-06 17:55:37 +03:00
committed by GitHub
parent e87625e023
commit 1bbf8b64f4
3 changed files with 217 additions and 1 deletions

View File

@@ -26,7 +26,13 @@ std::string WideningBeadingStrategy::toString() const
WideningBeadingStrategy::Beading WideningBeadingStrategy::compute(coord_t thickness, coord_t bead_count) const
{
if (thickness < optimal_width) {
// Use getTransitionThickness(1) to determine if this is a thin wall that should produce
// a single bead. This ensures consistency with getOptimalBeadCount() which uses the same
// threshold (via RedistributeBeadingStrategy) to decide between 1 and 2 beads.
// Previously used optimal_width which could differ from the outer wall width used in
// bead count calculations, causing inconsistency where bead_count=2 was requested but
// only 1 bead was produced.
if (thickness < getTransitionThickness(1)) {
Beading ret;
ret.total_thickness = thickness;
if (thickness >= min_input_width) {

View File

@@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests
test_3mf.cpp
test_aabbindirect.cpp
test_appconfig.cpp
test_arachne_walls.cpp
test_bambu_networking.cpp
test_clipper_offset.cpp
test_clipper_utils.cpp

View File

@@ -0,0 +1,209 @@
// Test file for Arachne wall generation
//
// Tests for duplicate/coinciding wall segment detection in Arachne output.
//
// This test reproduces an issue where Arachne generates duplicate extrusion
// segments at certain min_bead_width settings. The test uses a polygon with
// an outer rectangle (0,0)-(20,20) and an inner cutout (0.5,0.5)-(19.5,19.5).
//
// With precise_outer_wall enabled and min_bead_width at 50% (0.20mm), Arachne
// generates two separate closed contours that share a coinciding edge at y=19.75.
// At 60% (0.24mm), Arachne handles this differently and avoids the duplicate.
//
// Parameters are based on "0.28mm Extra Draft @BBL X1C" profile with:
// - 0.4mm nozzle, 0.28mm layer height
// - outer_wall_line_width: 0.42mm, inner_wall_line_width: 0.45mm
// - wall_loops: 2, precise_outer_wall: enabled
#include <catch2/catch_all.hpp>
#include "libslic3r/Arachne/WallToolPaths.hpp"
#include "libslic3r/Arachne/utils/ExtrusionLine.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/Point.hpp"
#include <cmath>
using namespace Slic3r;
using namespace Slic3r::Arachne;
namespace {
// Represents a segment with direction-independent comparison
struct Segment {
Point from;
Point to;
size_t inset_idx;
// Normalize segment so that the "smaller" point comes first
// This allows direction-independent comparison
Segment normalized() const {
if (from < to || (from.x() == to.x() && from.y() < to.y())) {
return *this;
}
return {to, from, inset_idx};
}
bool operator<(const Segment& other) const {
auto a = normalized();
auto b = other.normalized();
if (a.inset_idx != b.inset_idx) return a.inset_idx < b.inset_idx;
if (a.from != b.from) return a.from < b.from;
return a.to < b.to;
}
bool operator==(const Segment& other) const {
auto a = normalized();
auto b = other.normalized();
return a.inset_idx == b.inset_idx && a.from == b.from && a.to == b.to;
}
};
// Check if two points are approximately equal within tolerance
bool points_approx_equal(const Point& a, const Point& b, coord_t tolerance) {
return std::abs(a.x() - b.x()) <= tolerance && std::abs(a.y() - b.y()) <= tolerance;
}
// Check if two segments are approximately equal (direction-independent)
bool segments_approx_equal(const Segment& a, const Segment& b, coord_t tolerance) {
if (a.inset_idx != b.inset_idx) return false;
// Check both directions
bool same_dir = points_approx_equal(a.from, b.from, tolerance) &&
points_approx_equal(a.to, b.to, tolerance);
bool reverse_dir = points_approx_equal(a.from, b.to, tolerance) &&
points_approx_equal(a.to, b.from, tolerance);
return same_dir || reverse_dir;
}
// Extract all segments from toolpaths (all inset indices)
std::vector<Segment> extract_all_segments(const std::vector<VariableWidthLines>& toolpaths) {
std::vector<Segment> segments;
for (const auto& inset : toolpaths) {
for (const auto& line : inset) {
if (line.junctions.size() < 2) continue;
for (size_t i = 0; i + 1 < line.junctions.size(); ++i) {
segments.push_back({
line.junctions[i].p,
line.junctions[i + 1].p,
line.inset_idx
});
}
}
}
return segments;
}
// Find duplicate segments within tolerance
std::vector<std::pair<Segment, Segment>> find_duplicate_segments(
const std::vector<Segment>& segments,
coord_t tolerance)
{
std::vector<std::pair<Segment, Segment>> duplicates;
for (size_t i = 0; i < segments.size(); ++i) {
for (size_t j = i + 1; j < segments.size(); ++j) {
if (segments_approx_equal(segments[i], segments[j], tolerance)) {
duplicates.emplace_back(segments[i], segments[j]);
}
}
}
return duplicates;
}
// Create params matching "0.28mm Extra Draft @BBL X1C" profile
WallToolPathsParams make_bbl_x1c_028_params(int min_bead_width_percent) {
constexpr double nozzle_diameter = 0.4;
WallToolPathsParams params;
params.min_bead_width = float(min_bead_width_percent / 100.0 * nozzle_diameter);
params.min_feature_size = float(0.25 * nozzle_diameter);
params.wall_transition_filter_deviation = float(0.25 * nozzle_diameter);
params.wall_transition_length = float(1.0 * nozzle_diameter);
params.wall_transition_angle = 10.0f;
params.wall_distribution_count = 1;
params.min_length_factor = 0.5f;
params.is_top_or_bottom_layer = false;
return params;
}
// Run Arachne wall generation test with specified min_bead_width percentage
// Returns the number of duplicate segments found
size_t run_arachne_test(int min_bead_width_percent) {
constexpr double layer_height = 0.28;
constexpr double ext_perimeter_width_mm = 0.42;
constexpr double perimeter_width_mm = 0.45;
// Spacing calculation: width - height * (1 - PI/4)
constexpr double spacing_factor = 1.0 - 0.25 * M_PI;
double ext_perimeter_spacing_mm = ext_perimeter_width_mm - layer_height * spacing_factor;
double perimeter_spacing_mm = perimeter_width_mm - layer_height * spacing_factor;
coord_t ext_perimeter_width = scaled<coord_t>(ext_perimeter_width_mm);
coord_t ext_perimeter_spacing = scaled<coord_t>(ext_perimeter_spacing_mm);
coord_t perimeter_spacing = scaled<coord_t>(perimeter_spacing_mm);
coord_t bead_width_0 = ext_perimeter_spacing;
coord_t bead_width_x = perimeter_spacing;
size_t inset_count = 2;
// precise_outer_wall enabled
float precise_offset = -float(ext_perimeter_width - ext_perimeter_spacing);
coord_t wall_0_inset = -coord_t(ext_perimeter_width / 2 - ext_perimeter_spacing / 2);
auto params = make_bbl_x1c_028_params(min_bead_width_percent);
// Test polygon: outer rectangle with inner cutout creating 0.5mm frame
Polygon outer_raw;
outer_raw.points.emplace_back(Point::new_scale(0.0, 0.0));
outer_raw.points.emplace_back(Point::new_scale(20.0, 0.0));
outer_raw.points.emplace_back(Point::new_scale(20.0, 20.0));
outer_raw.points.emplace_back(Point::new_scale(0.0, 20.0));
Polygon inner_raw;
inner_raw.points.emplace_back(Point::new_scale(0.5, 0.5));
inner_raw.points.emplace_back(Point::new_scale(0.5, 19.5));
inner_raw.points.emplace_back(Point::new_scale(19.5, 19.5));
inner_raw.points.emplace_back(Point::new_scale(19.5, 0.5));
ExPolygon input_expolygon;
input_expolygon.contour = outer_raw;
input_expolygon.holes.push_back(inner_raw);
ExPolygons offset_result = offset_ex(input_expolygon, precise_offset);
Polygons outline;
for (const auto& expoly : offset_result) {
outline.push_back(expoly.contour);
for (const auto& hole : expoly.holes) {
outline.push_back(hole);
}
}
WallToolPaths wallToolPaths(outline, bead_width_0, bead_width_x,
inset_count, wall_0_inset,
layer_height, params);
auto toolpaths = wallToolPaths.getToolPaths();
auto all_segments = extract_all_segments(toolpaths);
auto duplicates = find_duplicate_segments(all_segments, scaled<coord_t>(0.1));
return duplicates.size();
}
} // anonymous namespace
TEST_CASE("Arachne wall generation - 50% min_bead_width", "[Arachne]") {
size_t duplicates = run_arachne_test(50);
REQUIRE(duplicates == 0);
}
TEST_CASE("Arachne wall generation - 60% min_bead_width", "[Arachne]") {
size_t duplicates = run_arachne_test(60);
REQUIRE(duplicates == 0);
}