Merge branch 'main' into weilun/speed_dial

This commit is contained in:
Lam Wei Lun
2026-09-11 11:14:19 +08:00
60 changed files with 832 additions and 661 deletions
+66
View File
@@ -9,6 +9,7 @@
#include <vector>
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/AABBTreeLines.hpp"
#include "libslic3r/Fill/Fill.hpp"
#include "libslic3r/Flow.hpp"
#include "libslic3r/Geometry.hpp"
@@ -1229,3 +1230,68 @@ TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", "
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
}
TEST_CASE("Sparse plane-path anchors match the printed infill", "[Fill][InternalBridge][Regression]")
{
// Orca: Compare generated anchors with actual extrusion across plane-path patterns,
// smoothing, multiline and rotations; an origin shift must not pass as valid support.
const std::string pattern = GENERATE("hilbertcurve", "octagramspiral", "archimedeanchords");
const std::string smoothing = GENERATE("0%", "100%");
const int multiline = GENERATE(1, 2);
const bool rotated = GENERATE(false, true);
const bool separated = GENERATE(false, true);
CAPTURE(pattern, smoothing, multiline, rotated, separated);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "15%"},
{"sparse_infill_smooth_factor", smoothing},
{"fill_multiline", multiline},
{"infill_direction", 45},
{"sparse_infill_rotate_template", rotated ? "0,25,50" : ""},
{"align_infill_direction_to_model", rotated},
{"separated_infills", separated},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0},
{"top_shell_thickness", 0},
{"bottom_shell_thickness", 0},
{"layer_height", 0.2},
{"initial_layer_print_height", 0.2},
{"resolution", 0.012}});
Print print;
Model model;
TriangleMesh mesh = make_cube(30, 24, 1);
if (separated) {
// Orca: Two disconnected bodies in one object must each use their own infill origin.
TriangleMesh second = make_cube(30, 24, 1);
second.translate(50, 0, 0);
mesh.merge(second);
}
Slic3r::Test::init_print({mesh}, print, model, config, nullptr, false);
if (rotated) {
model.objects.front()->instances.front()->set_rotation(Vec3d(0., 0., Geometry::deg2rad(23.)));
print.apply(model, config);
}
print.process();
const Layer &layer = *print.objects().front()->get_layer(4);
Polylines printed;
for (const LayerRegion *region : layer.regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (entity->role() == erInternalInfill)
entity->collect_polylines(printed);
REQUIRE_FALSE(printed.empty());
const AABBTreeLines::LinesDistancer<Line> printed_tree(to_lines(printed));
// Orca: Exclude perimeter connections: anchoring and extrusion can trim those differently.
const Polylines anchors = intersection_pl(layer.generate_sparse_infill_polylines_for_anchoring(nullptr, nullptr, nullptr),
shrink(to_polygons(layer.lslices), scale_(3.)));
REQUIRE_FALSE(anchors.empty());
double max_distance = 0.;
for (const Polyline &path : anchors)
for (const Point &point : path.equally_spaced_points(scale_(0.25)))
max_distance = std::max(max_distance, printed_tree.distance_from_lines<false>(point));
// Orca: Allow only the configured simplification tolerance; infill-scale offsets
// would hide anchors that no longer coincide with printed lines.
CHECK(unscale<double>(max_distance) <= config.opt_float("resolution"));
}
+442
View File
@@ -4,11 +4,18 @@
#include "libslic3r/Print.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/AABBTreeLines.hpp"
#include "test_helpers.hpp"
#include <cmath>
#include <iterator>
#include <map>
#include <set>
#include <string>
#include <utility>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -130,3 +137,438 @@ TEST_CASE("Initial layer height is honored", "[PrintObject]")
REQUIRE_THAT(*layer_zs.begin(), Catch::Matchers::WithinAbs(0.3, 1e-4));
REQUIRE_THAT(*std::next(layer_zs.begin()), Catch::Matchers::WithinAbs(0.5, 1e-4));
}
static TriangleMesh internal_bridge_step()
{
// Orca: The smaller tower leaves a shoulder whose solid skin needs internal bridges
// over the sparse infill in the base, without relying on an external model file.
TriangleMesh mesh = make_cube(30, 24, 3);
TriangleMesh tower = make_cube(14, 10, 1);
tower.translate(8, 7, 3);
mesh.merge(tower);
return mesh;
}
static DynamicPrintConfig internal_bridge_config(const std::string &pattern, int multiline)
{
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"fill_multiline", multiline},
{"sparse_infill_density", "15%"},
{"sparse_infill_smooth_factor", "100%"},
{"infill_direction", 45},
{"internal_bridge_angle", 0},
{"thick_internal_bridges", true},
{"top_shell_layers", 3},
{"bottom_shell_layers", 2},
{"top_shell_thickness", 0},
{"bottom_shell_thickness", 0},
{"layer_height", 0.2},
{"initial_layer_print_height", 0.2}});
return config;
}
TEST_CASE("Internal bridge angles follow the lower infill layer and model rotation", "[PrintObject][InternalBridge][Regression]")
{
const std::string pattern = GENERATE("hilbertcurve", "octagramspiral");
// Orca: Cover both a central line (odd counts) and offset pairs (even counts).
const int multiline = GENERATE(1, 2, 3);
CAPTURE(multiline);
const double rotation = GENERATE(23., -123.);
const std::vector<double> cycle{10., 30., 70.};
auto config = internal_bridge_config(pattern, multiline);
config.set_deserialize_strict({{"sparse_infill_rotate_template", "10,30,70"},
{"align_infill_direction_to_model", true},
{"separated_infills", false}});
Print print;
Model model;
init_print({internal_bridge_step()}, print, model, config, nullptr, false);
model.objects.front()->instances.front()->set_rotation(Vec3d(0., 0., Geometry::deg2rad(rotation)));
print.apply(model, config);
print.process();
const PrintObject &object = *print.objects().front();
size_t bridges = 0;
for (size_t i = 1; i < object.layer_count(); ++i) {
// Orca: The support is one layer below the bridge. Check the template and model
// rotation together, including normalization when the resulting angle is negative.
double expected = std::fmod(cycle[(i - 1) % cycle.size()] + 90. + rotation, 180.);
if (expected < 0.) expected += 180.;
for (const LayerRegion *region : object.get_layer(i)->regions())
for (const Surface *surface : region->fill_surfaces.filter_by_type(stInternalBridge)) {
CAPTURE(pattern, rotation, i);
CHECK_THAT(Geometry::rad2deg(surface->bridge_angle), Catch::Matchers::WithinAbs(expected, 0.001));
++bridges;
}
}
REQUIRE(bridges > 0);
}
TEST_CASE("Turning infill does not replace the anchors of another region", "[PrintObject][InternalBridge][Regression]")
{
// Orca: Keep the right-hand region fixed while changing the left-hand pattern in the
// same object. Its bridge areas must be independent of a previous candidate's anchors.
const int multiline = GENERATE(1, 2, 3);
CAPTURE(multiline);
auto right_bridges = [multiline](const std::string &left_pattern) {
auto config = internal_bridge_config(left_pattern, multiline);
Print print;
Model model;
init_print({internal_bridge_step()}, print, model, config, nullptr, false);
TriangleMesh right = internal_bridge_step();
right.translate(50, 0, 0);
ModelVolume *volume = model.objects.front()->add_volume(std::move(right));
volume->config.set_key_value("sparse_infill_pattern", new ConfigOptionEnum<InfillPattern>(ipRectilinear));
volume->config.set_key_value("infill_direction", new ConfigOptionFloat(17.));
print.apply(model, config);
print.process();
std::map<size_t, Polygons> result;
const PrintObject &object = *print.objects().front();
for (size_t i = 0; i < object.layer_count(); ++i)
for (const LayerRegion *region : object.get_layer(i)->regions())
if (region->region().config().infill_direction == 17.)
polygons_append(result[i], to_polygons(region->fill_surfaces.filter_by_type(stInternalBridge)));
return result;
};
const auto baseline = right_bridges("rectilinear");
const auto actual = right_bridges(GENERATE("hilbertcurve", "octagramspiral"));
REQUIRE(actual.size() == baseline.size());
double total_area = 0.;
for (const auto &[layer, expected] : baseline) {
CAPTURE(layer);
const auto &polys = actual.at(layer);
CHECK(area(diff(expected, polys)) < scaled<double>(1.) * scaled<double>(1.) * 1e-6);
CHECK(area(diff(polys, expected)) < scaled<double>(1.) * scaled<double>(1.) * 1e-6);
total_area += area(expected);
}
REQUIRE(total_area > 0.);
}
TEST_CASE("Rounded internal bridges end on printed support", "[PrintObject][InternalBridge][Regression]")
{
const std::string pattern = GENERATE("hilbertcurve", "octagramspiral");
const bool separated = GENERATE(false, true);
CAPTURE(pattern, separated);
auto config = internal_bridge_config(pattern, 1);
config.set_deserialize_strict({{"infill_wall_overlap", "0%"}, {"separated_infills", separated}});
TriangleMesh mesh = internal_bridge_step();
if (separated) {
TriangleMesh second = internal_bridge_step();
second.translate(50, 0, 0);
mesh.merge(second);
}
Print print;
Model model;
init_print({mesh}, print, model, config, nullptr, false);
print.process();
// Orca: Check final extrusion endpoints after polygon cleanup and fill generation.
// A correct bridge angle and correct sparse anchors alone do not guarantee contact.
const PrintObject &object = *print.objects().front();
size_t checked = 0;
for (size_t i = 1; i < object.layer_count(); ++i) {
Polygons support;
Polylines walls;
for (const LayerRegion *region : object.get_layer(i - 1)->regions()) {
region->perimeters.polygons_covered_by_width(support, 0.f);
region->fills.polygons_covered_by_width(support, 0.f);
region->perimeters.collect_polylines(walls);
}
REQUIRE_FALSE(support.empty());
const AABBTreeLines::LinesDistancer<Line> support_tree(to_lines(union_(support)));
const AABBTreeLines::LinesDistancer<Line> wall_tree(to_lines(walls));
for (const LayerRegion *region : object.get_layer(i)->regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities) {
if (entity->role() != erInternalBridgeInfill)
continue;
const auto *path = dynamic_cast<const ExtrusionPath *>(entity);
REQUIRE(path != nullptr);
for (const Line &line : path->polyline.to_polyline().lines()) {
// Orca: Sample span ends, excluding short connectors and wall overlap.
if (line.length() < scale_(std::max(0.7, 3. * path->width)))
continue;
for (const Point &point : {line.a, line.b}) {
if (wall_tree.distance_from_lines<false>(point) <= scale_(0.5))
continue;
CAPTURE(i, point.x(), point.y());
const double gap = unscale<double>(support_tree.distance_from_lines<true>(point)) - 0.5 * path->width;
CHECK(gap <= 0.1);
++checked;
}
}
}
}
REQUIRE(checked > 0);
}
TEST_CASE("Enabling separated infill recomputes body origins", "[PrintObject][InternalBridge][Regression]")
{
const std::string pattern = GENERATE("hilbertcurve", "octagramspiral", "archimedeanchords");
CAPTURE(pattern);
auto footprint = [&](bool reslice) {
auto config = internal_bridge_config(pattern, 2);
config.set_deserialize_strict({{"separated_infills", !reslice}});
TriangleMesh mesh = internal_bridge_step();
TriangleMesh second = internal_bridge_step();
second.translate(50, 0, 0);
mesh.merge(second);
Print print;
Model model;
init_print({mesh}, print, model, config, nullptr, false);
print.process();
if (reslice) {
// Orca: Enabling centering after a completed slice must rebuild the body
// origins now shared by bridge preparation and printed infill.
config.set_deserialize_strict({{"separated_infills", true}});
print.apply(model, config);
print.process();
}
Polygons result;
for (const LayerRegion *region : print.objects().front()->get_layer(4)->regions())
region->fills.polygons_covered_by_width(result, 0.f);
return union_(result);
};
const Polygons fresh = footprint(false);
const Polygons resliced = footprint(true);
REQUIRE_FALSE(fresh.empty());
CHECK(area(diff(fresh, resliced)) < scaled<double>(1.) * scaled<double>(1.) * 1e-6);
CHECK(area(diff(resliced, fresh)) < scaled<double>(1.) * scaled<double>(1.) * 1e-6);
}
TEST_CASE("Surface centering survives changes to separated infill settings", "[PrintObject][SurfaceInfill][Regression]")
{
const std::string pattern = GENERATE("archimedeanchords", "octagramspiral");
const std::string initial_center = GENERATE("each_surface", "each_model", "each_assembly");
const std::string final_center = GENERATE("each_surface", "each_model", "each_assembly");
const bool separated = GENERATE(false, true);
const std::string top_order = GENERATE("default", "outward", "inward");
const std::string bottom_order = top_order == "outward" ? "inward" : top_order == "inward" ? "outward" : "default";
const std::string density = GENERATE("80%", "100%");
const bool change_center = initial_center != final_center;
CAPTURE(pattern, initial_center, final_center, separated, top_order, bottom_order, density);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"top_surface_pattern", pattern},
{"bottom_surface_pattern", pattern},
{"top_surface_fill_order", top_order},
{"bottom_surface_fill_order", bottom_order},
{"top_surface_density", density},
{"bottom_surface_density", density},
{"center_of_surface_pattern", initial_center},
{"separated_infills", change_center ? separated : !separated},
{"sparse_infill_pattern", "rectilinear"},
{"sparse_infill_density", "15%"},
{"top_shell_layers", 2},
{"bottom_shell_layers", 2},
{"top_shell_thickness", 0},
{"bottom_shell_thickness", 0},
{"layer_height", 0.2},
{"initial_layer_print_height", 0.2}});
// Orca: Two disconnected bodies exercise per-body centering. The offset tower also
// makes each-surface and each-model centering differ on the top surfaces.
TriangleMesh mesh = make_cube(30, 24, 2);
TriangleMesh tower = make_cube(12, 10, 1);
tower.translate(4, 3, 2);
mesh.merge(tower);
TriangleMesh second = mesh;
second.translate(50, 0, 0);
mesh.merge(second);
// Orca: Equal footprints can hide reordered or reversed paths. Retain their point
// sequences and ordering protection to cover the directional surface behavior too.
struct SurfaceFillSnapshot {
std::map<bool, std::vector<Points>> paths;
bool protected_order = true;
};
auto surface_fills = [](const Print &print) {
std::map<std::pair<size_t, ExtrusionRole>, SurfaceFillSnapshot> result;
const PrintObject &object = *print.objects().front();
for (size_t i = 0; i < object.layer_count(); ++i) {
auto collect = [&](const auto &self, const ExtrusionEntity &entity, bool no_sort) -> void {
if (const auto *collection = dynamic_cast<const ExtrusionEntityCollection *>(&entity)) {
for (const ExtrusionEntity *child : collection->entities)
self(self, *child, no_sort || collection->no_sort);
} else if (entity.role() == erTopSolidInfill || entity.role() == erBottomSurface) {
const auto *path = dynamic_cast<const ExtrusionPath *>(&entity);
REQUIRE(path != nullptr);
auto &snapshot = result[{i, entity.role()}];
// Orca: The centered test model has one body on either side of X=0.
// Their traversal order may vary; preserve path order within each body.
Points points = path->polyline.to_polyline().points;
REQUIRE_FALSE(points.empty());
snapshot.paths[points.front().x() > 0].push_back(std::move(points));
snapshot.protected_order &= no_sort && !path->can_reverse();
}
};
for (const LayerRegion *region : object.get_layer(i)->regions())
collect(collect, region->fills, false);
}
return result;
};
Print print;
Model model;
init_print({mesh}, print, model, config, nullptr, false);
print.process();
const auto initial = surface_fills(print);
config.set_deserialize_strict({{"center_of_surface_pattern", final_center}, {"separated_infills", separated}});
print.apply(model, config);
// Orca: Preparation owns the body origins, and its invalidation must also force
// regeneration of top/bottom extrusion paths, even when sparse infill is unchanged.
CHECK_FALSE(print.objects().front()->is_step_done(posPrepareInfill));
CHECK_FALSE(print.objects().front()->is_step_done(posInfill));
print.process();
const auto resliced = surface_fills(print);
Print fresh_print;
Model fresh_model;
init_print({mesh}, fresh_print, fresh_model, config, nullptr, false);
fresh_print.process();
const auto fresh = surface_fills(fresh_print);
REQUIRE_FALSE(fresh.empty());
REQUIRE(resliced.size() == fresh.size());
std::set<ExtrusionRole> roles;
bool changed_paths = false;
for (const auto &entry : fresh) {
CAPTURE(entry.first.first, entry.first.second);
REQUIRE_FALSE(entry.second.paths.empty());
roles.insert(entry.first.second);
REQUIRE(resliced.count(entry.first) == 1);
REQUIRE(initial.count(entry.first) == 1);
const auto &actual = resliced.at(entry.first);
const auto &expected = entry.second;
const auto &before = initial.at(entry.first);
CHECK((actual.paths == expected.paths));
if (!change_center)
CHECK((actual.paths == before.paths));
if (top_order != "default") {
CHECK(expected.protected_order);
CHECK(actual.protected_order);
CHECK(before.protected_order);
}
changed_paths |= expected.paths != before.paths;
}
CHECK(roles.count(erTopSolidInfill) == 1);
CHECK(roles.count(erBottomSurface) == 1);
// Orca: Guard against a vacuous comparison: changing surface centering must change
// the printed pattern, while toggling separated sparse infill must leave it alone.
CHECK(changed_paths == change_center);
}
TEST_CASE("Separated infill keeps fragmented and nested bodies independent", "[PrintObject][SurfaceInfill][Regression]")
{
constexpr size_t grid_size = 8;
TriangleMesh mesh;
auto add_box = [&](double x, double y, double width, double depth) {
TriangleMesh box = make_cube(width, depth, 0.6);
box.translate(x, y, 0);
mesh.merge(box);
};
// Orca: Many small islands exercise spatial pruning and the tree's original
// island indices. A pillar inside a frame also overlaps its bounding box,
// but must remain a separate body because it lies entirely inside the hole.
for (size_t x = 0; x < grid_size; ++ x)
for (size_t y = 0; y < grid_size; ++ y)
add_box(6 * x, 6 * y, 3, 3);
add_box(54, 0, 20, 4);
add_box(54, 16, 20, 4);
add_box(54, 0, 4, 20);
add_box(70, 0, 4, 20);
add_box(62, 8, 4, 4);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"separated_infills", true},
{"center_of_surface_pattern", "each_surface"},
{"layer_height", 0.2},
{"initial_layer_print_height", 0.2},
{"elefant_foot_compensation", 0},
{"wall_loops", 1}});
Print print;
Model model;
init_print({mesh}, print, model, config, nullptr, false);
// Orca: Prepare body bounds through the public pipeline, then inspect the object read-only.
print.process();
const PrintObject &object = *print.objects().front();
REQUIRE(object.layer_count() > 1);
for (const Layer *layer : object.layers()) {
REQUIRE(layer->lslices.size() == grid_size * grid_size + 2);
REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size());
size_t holes = 0;
for (size_t i = 0; i < layer->lslices.size(); ++ i) {
const BoundingBox &body = layer->lslices_separated_component_bboxes[i];
const BoundingBox &island = layer->lslices_bboxes[i];
CHECK(body.min == island.min);
CHECK(body.max == island.max);
holes += layer->lslices[i].holes.size();
}
CHECK(holes == 1);
}
}
TEST_CASE("Body centering survives islands merging and splitting between layers", "[PrintObject][SurfaceInfill][Regression]")
{
const bool separated = GENERATE(false, true);
CAPTURE(separated);
// Orca: Four posts join through horizontal then vertical rails, creating a
// cycle of overlaps before splitting into four islands again. This exercises
// redundant connections and indexing either adjacent layer. A fifth post
// stays separate at every height.
TriangleMesh mesh;
for (int x : {0, 8})
for (int y : {0, 8}) {
TriangleMesh post = make_cube(4, 4, 1);
post.translate(x, y, 0);
mesh.merge(post);
}
for (int y : {0, 8}) {
TriangleMesh rail = make_cube(12, 4, 0.2);
rail.translate(0, y, 0.2);
mesh.merge(rail);
}
for (int x : {0, 8}) {
TriangleMesh rail = make_cube(4, 12, 0.2);
rail.translate(x, 0, 0.4);
mesh.merge(rail);
}
TriangleMesh isolated = make_cube(4, 4, 1);
isolated.translate(20, 0, 0);
mesh.merge(isolated);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"separated_infills", separated},
{"center_of_surface_pattern", separated ? "each_surface" : "each_model"},
{"layer_height", 0.2},
{"initial_layer_print_height", 0.2},
{"elefant_foot_compensation", 0},
{"wall_loops", 1}});
Print print;
Model model;
init_print({mesh}, print, model, config, nullptr, false);
// Orca: Prepare body bounds through the public pipeline, then inspect the object read-only.
print.process();
const PrintObject &object = *print.objects().front();
REQUIRE(object.layer_count() == 5);
REQUIRE(object.get_layer(0)->lslices.size() == 5);
REQUIRE(object.get_layer(1)->lslices.size() == 3);
REQUIRE(object.get_layer(2)->lslices.size() == 3);
REQUIRE(object.get_layer(4)->lslices.size() == 5);
BoundingBox isolated_bbox = object.get_layer(0)->lslices_bboxes.front();
for (const BoundingBox &bbox : object.get_layer(0)->lslices_bboxes)
if (bbox.min.x() > isolated_bbox.min.x())
isolated_bbox = bbox;
BoundingBox connected_bbox;
for (const Layer *layer : object.layers())
for (const BoundingBox &bbox : layer->lslices_bboxes)
if (bbox.min.x() < isolated_bbox.min.x())
connected_bbox.merge(bbox);
for (const Layer *layer : object.layers()) {
REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size());
for (size_t i = 0; i < layer->lslices.size(); ++ i) {
const BoundingBox &expected = layer->lslices_bboxes[i].min.x() < isolated_bbox.min.x() ? connected_bbox : isolated_bbox;
const BoundingBox &actual = layer->lslices_separated_component_bboxes[i];
CHECK(actual.min == expected.min);
CHECK(actual.max == expected.max);
}
}
}
+2
View File
@@ -1,4 +1,6 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <catch2/catch_all.hpp>
#include "test_utils.hpp"