#include #include #include #include #include #include #include "test_helpers.hpp" #include "libslic3r/GCode/SeamPlacer.hpp" #include "libslic3r/Layer.hpp" #include "libslic3r/TriangleSelector.hpp" #include #include #include "libslic3r/Model.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/TriangleMesh.hpp" #include #include #include "libslic3r/Point.hpp" #include #include "libslic3r/ExtrusionEntity.hpp" #include "libslic3r/Polyline.hpp" #include "libslic3r/libslic3r.h" using namespace Slic3r; namespace { struct PipelineFixture { Model model; Print print; DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); explicit PipelineFixture(bool trapezoid = false) { auto mesh = its_make_cube(20, 20, 0.4); if (trapezoid) { // Opposite painted sides have deliberately different lengths: 20 mm and 6 mm. for (auto &vertex : mesh.vertices) if (vertex.y() == 20.0f) vertex.x() = 7.0f + 0.3f * vertex.x(); } config.set_deserialize_strict("seam_position", "back"); // Rear needs no visibility ray tracing. config.set_deserialize_strict("layer_height", "0.2"); config.set_deserialize_strict("initial_layer_print_height", "0.2"); config.set_deserialize_strict("outer_wall_line_width", "0.4"); config.set_deserialize_strict("initial_layer_line_width", "0.4"); config.set_deserialize_strict("wall_loops", "1"); config.set_deserialize_strict("raft_layers", "0"); config.set_deserialize_strict("gcode_comments", "1"); // Match init_print so later apply calls change only the model. Test::init_print({TriangleMesh(std::move(mesh))}, print, model, config); } void paint(bool all_faces) { auto &volume = *model.objects.front()->volumes.front(); const auto &mesh = volume.mesh(); const auto bounds = mesh.bounding_box(); TriangleSelector selector(mesh); size_t painted = 0; for (size_t i = 0; i < mesh.its.indices.size(); ++i) { const auto &face = mesh.its.indices[i]; bool lower = true, upper = true; for (int j = 0; j < 3; ++j) { const auto &v = mesh.its.vertices[face[j]]; lower = lower && std::abs(double(v.y()) - bounds.min.y()) < 1e-6; upper = upper && std::abs(double(v.y()) - bounds.max.y()) < 1e-6; } if (all_faces || lower || upper) { selector.set_facet(int(i), EnforcerBlockerType::ENFORCER); ++painted; } } REQUIRE(painted > 0); volume.seam_facets.set(selector); print.apply(model, config); } PrintObject &prepare() { REQUIRE(print.objects().size() == 1); auto &object = *print.get_object(0); object.slice(); // Real layers/regions are sufficient: each test supplies its own perimeter loops. REQUIRE_FALSE(object.layers().empty()); REQUIRE_FALSE(object.layers().front()->regions().empty()); return object; } Points points_in_layer(const PrintObject &object, const std::vector &xy) const { const auto &volume = *object.model_object()->volumes.front(); const auto minimum = volume.mesh().bounding_box().min; const Transform3d transform = object.trafo_centered() * volume.get_matrix(); Points points; for (const auto &point : xy) { // add_volume centers the mesh; restore its local offset before applying the slicing transform. const Vec3d local = minimum + Vec3d(point.x(), point.y(), 0.2); const Vec3d placed = transform * local; points.emplace_back(scale_(placed.x()), scale_(placed.y())); } return points; } }; void append_loop(LayerRegion ®ion, Points points, bool separate_paths = false) { // Inject deterministic external loops while keeping the real layer, region and paint-query machinery. REQUIRE(points.size() >= 3); points.push_back(points.front()); ExtrusionPaths paths; if (separate_paths) { for (size_t i = 1; i < points.size(); ++i) { ExtrusionPath path(erExternalPerimeter, 0.08, 0.4f, 0.2f); path.polyline = Polyline3(Polyline(Points{points[i - 1], points[i]})); paths.push_back(std::move(path)); } } else { ExtrusionPath path(erExternalPerimeter, 0.08, 0.4f, 0.2f); path.polyline = Polyline3(Polyline(std::move(points))); paths.push_back(std::move(path)); } region.perimeters.append(ExtrusionLoop(std::move(paths))); } LayerRegion &clear_first_layer(PrintObject &object) { Layer &layer = *object.layers().front(); for (LayerRegion *region : layer.regions()) region->perimeters.clear(); return *layer.get_region(0); } } // namespace TEST_CASE("Painted seams prefer the longer candidate patch regardless of contour origin", "[SeamPlacer][Regression]") { const bool clockwise = GENERATE(false, true); const bool wrapped = GENERATE(false, true); CAPTURE(clockwise, wrapped); PipelineFixture fixture(true); fixture.paint(false); PrintObject &object = fixture.prepare(); REQUIRE(object.model_object()->volumes.size() == 1); CHECK_FALSE(object.model_object()->volumes.front()->is_precise_seam()); auto ®ion = clear_first_layer(object); // A neutral loop ensures that patch indices are offsets in the layer, not zero-based local indices. append_loop(region, fixture.points_in_layer(object, {{9, 8}, {11, 8}, {10, 10}})); Points outline = fixture.points_in_layer(object, {{10, 20}, {7, 20}, {3.5, 10}, {0, 0}, {10, 0}, {20, 0}, {13, 20}}); if (clockwise) std::reverse(outline.begin() + 1, outline.end()); // Keep the same starting vertex. if (!wrapped) { const Point neutral = fixture.points_in_layer(object, {{3.5, 10}}).front(); const auto start = std::find(outline.begin(), outline.end(), neutral); REQUIRE(start != outline.end()); std::rotate(outline.begin(), start, outline.end()); } append_loop(region, std::move(outline)); SeamPlacer placer; placer.init(fixture.print, [] {}); const auto &data = placer.m_seam_per_object.at(&object).layers.front(); REQUIRE(data.perimeters.size() == 2); const auto &perimeter = data.perimeters[1]; REQUIRE(perimeter.start_index > 0); REQUIRE(perimeter.end_index > perimeter.start_index); using Type = SeamPlacerImpl::EnforcedBlockedSeamPoint; CHECK((data.points[perimeter.start_index].type == Type::Enforced) == wrapped); if (wrapped) CHECK(data.points[perimeter.end_index - 1].type == Type::Enforced); const auto extremes = fixture.points_in_layer(object, {{10, 0}, {10, 20}}); const double bottom_y = unscale(extremes[0].y()), top_y = unscale(extremes[1].y()); size_t bottom_count = 0, top_count = 0, centers = 0; for (size_t i = perimeter.start_index; i < perimeter.end_index; ++i) { const auto &candidate = data.points[i]; if (candidate.type == Type::Enforced) { // Include the small paint-radius fringe at the ends of each face. const bool bottom = std::abs(candidate.position.y() - bottom_y) < 0.5; const bool top = std::abs(candidate.position.y() - top_y) < 0.5; const bool on_painted_face = bottom || top; CAPTURE(candidate.position.x(), candidate.position.y()); CHECK(on_painted_face); bottom_count += bottom; top_count += top; } if (candidate.central_enforcer) { ++centers; CHECK(candidate.type == Type::Enforced); CHECK_THAT(double(candidate.position.y()), Catch::Matchers::WithinAbs(bottom_y, 0.5)); } } REQUIRE(top_count > 0); REQUIRE(bottom_count > top_count); CHECK(centers == 1); // The old wrapped-length formula instead selected the short top patch. } TEST_CASE("Entirely painted contours keep valid enforced seam candidates", "[SeamPlacer]") { PipelineFixture fixture; fixture.paint(true); PrintObject &object = fixture.prepare(); auto ®ion = clear_first_layer(object); append_loop(region, fixture.points_in_layer(object, {{0, 0}, {20, 0}, {20, 20}, {0, 20}})); SeamPlacer placer; placer.init(fixture.print, [] {}); const auto &data = placer.m_seam_per_object.at(&object).layers.front(); REQUIRE(data.perimeters.size() == 1); const auto &perimeter = data.perimeters.front(); CHECK(perimeter.seam_index >= perimeter.start_index); CHECK(perimeter.seam_index < perimeter.end_index); for (const auto &candidate : data.points) { CHECK(candidate.type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced); CHECK_FALSE(candidate.central_enforcer); // There is no bounded patch to mark as central. } } TEST_CASE("Precise Seam removes path junction duplicates but preserves separate visits", "[SeamPlacer][PreciseSeam]") { const bool enable_ps = GENERATE(false, true); const bool self_touch = GENERATE(false, true); PipelineFixture fixture; if (enable_ps) { auto *helper = fixture.model.objects.front()->add_volume(make_cube(1, 1, 1)); helper->set_type(ModelVolumeType::PRECISE_SEAM_NEUTRAL); helper->set_offset(Vec3d(100, 100, 0)); // Enable normalization without intersecting the synthetic loop. fixture.print.apply(fixture.model, fixture.config); } PrintObject &object = fixture.prepare(); auto ®ion = clear_first_layer(object); const std::vector vertices = self_touch ? std::vector{{2, 2}, {10, 2}, {18, 10}, {10, 2}, {2, 18}} : std::vector{{2, 2}, {18, 2}, {18, 18}, {2, 18}}; const Points outline = fixture.points_in_layer(object, vertices); append_loop(region, outline, true); SeamPlacer placer; placer.init(fixture.print, [] {}); const auto &data = placer.m_seam_per_object.at(&object).layers.front(); REQUIRE(data.perimeters.size() == 1); // Each separate path contributes both endpoints in ordinary mode; PS removes only adjacent copies. REQUIRE(data.points.size() == (enable_ps ? outline.size() : 2 * outline.size())); for (size_t i = 0; i < outline.size(); ++i) { const Vec2f target = unscale(outline[i]).cast(); const size_t input_count = std::count(outline.begin(), outline.end(), outline[i]); // Both paths convert the same integer coordinates to float; exact identity detects duplicate copies. const size_t actual_count = std::count_if(data.points.begin(), data.points.end(), [&](const auto &candidate) { return candidate.position.template head<2>() == target; }); CHECK(actual_count == (enable_ps ? input_count : 2 * input_count)); } if (enable_ps) { for (size_t i = 0; i < data.points.size(); ++i) { CHECK(std::isfinite(data.points[i].local_ccw_angle)); CHECK(data.points[i].position != data.points[(i + 1) % data.points.size()].position); } } } TEST_CASE("Print apply synchronizes support and seam helpers through type changes and restored models", "[SeamPlacer][PreciseSeam][Print]") { const int changed = GENERATE(0, 1, 2); // Support only, seam only, or both including cross-family switches. PipelineFixture fixture; auto *model_object = fixture.model.objects.front(); auto *support = model_object->add_volume(make_cube(1, 1, 1)); support->set_type(ModelVolumeType::SUPPORT_BLOCKER); auto *seam = model_object->add_volume(make_cube(1, 1, 1)); seam->set_type(ModelVolumeType::PRECISE_SEAM_CENTER); fixture.print.apply(fixture.model, fixture.config); REQUIRE(fixture.print.objects().size() == 1); const PrintObject *original_print_object = fixture.print.objects().front(); const ModelVolume *original_part = original_print_object->model_object()->volumes.front(); const Model before(fixture.model); // A restored model snapshot preserves IDs, as the apply path requires. if (changed == 0 || changed == 2) { support->set_type(changed == 2 ? ModelVolumeType::PRECISE_SEAM_LEFT : ModelVolumeType::SUPPORT_ENFORCER); support->set_offset(Vec3d(3, 4, 0)); } if (changed == 1 || changed == 2) { seam->set_type(changed == 2 ? ModelVolumeType::SUPPORT_BLOCKER : ModelVolumeType::PRECISE_SEAM_RIGHT); seam->set_offset(Vec3d(-3, 2, 0)); } if (changed == 2) std::swap(model_object->volumes[1], model_object->volumes[2]); const auto check_applied = [&](const Model &expected) { REQUIRE(fixture.print.objects().size() == 1); // Helper-only changes should preserve the print object and its unaffected printable volume. CHECK(fixture.print.objects().front() == original_print_object); const auto &actual = fixture.print.objects().front()->model_object()->volumes; const auto &wanted = expected.objects.front()->volumes; REQUIRE(actual.size() == wanted.size()); CHECK(actual.front() == original_part); for (size_t i = 0; i < wanted.size(); ++i) { CAPTURE(changed, i); CHECK(actual[i]->id() == wanted[i]->id()); CHECK(actual[i]->type() == wanted[i]->type()); CHECK(actual[i]->get_matrix().isApprox(wanted[i]->get_matrix(), 1e-9)); } }; fixture.print.apply(fixture.model, fixture.config); check_applied(fixture.model); fixture.print.apply(before, fixture.config); check_applied(before); fixture.print.apply(fixture.model, fixture.config); check_applied(fixture.model); }