mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-29 03:41:31 +00:00
Add Precise Seam placement feature (#12974)
Co-authored-by: Ioannis Giannakas <59056762+igiannakas@users.noreply.github.com> Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> Co-authored-by: Ian Bassi <ian.bassi@outlook.com>
This commit is contained in:
co-authored by
Ioannis Giannakas
Rodrigo Faselli
Ian Bassi
parent
08f086daf3
commit
1846407e93
@@ -14,9 +14,12 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_model.cpp
|
||||
test_multifilament.cpp
|
||||
test_perimeters.cpp
|
||||
# Seam geometry and pipeline regressions share the existing Print fixtures.
|
||||
test_precise_seam.cpp
|
||||
test_print.cpp
|
||||
test_printobject.cpp
|
||||
test_mixed_filament.cpp
|
||||
test_seam_placer.cpp
|
||||
test_skirt_brim.cpp
|
||||
test_slicing_pipeline_hook.cpp
|
||||
test_support_material.cpp
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
#include "libslic3r/GCode/PreciseSeam.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
Point mm(double x, double y) { return Point(scale_(x), scale_(y)); }
|
||||
|
||||
Polygon rectangle(double x0, double y0, double x1, double y1)
|
||||
{
|
||||
// Counterclockwise contours match the modifier-slice cache contract.
|
||||
return Polygon(Points{mm(x0, y0), mm(x1, y0), mm(x1, y1), mm(x0, y1)});
|
||||
}
|
||||
|
||||
struct SeamFixture {
|
||||
Model model;
|
||||
Print print;
|
||||
Model modifiers;
|
||||
Layer *layer = nullptr;
|
||||
PreciseSeam::ModifierSlicesCache cache;
|
||||
|
||||
SeamFixture()
|
||||
{
|
||||
// Only the layer/PrintObject context is needed; clipping uses explicit cached slices below.
|
||||
Test::init_print({Test::cube(20)}, print, model, {{"raft_layers", "0"}});
|
||||
REQUIRE(print.objects().size() == 1);
|
||||
PrintObject *object = print.get_object(0);
|
||||
layer = object->add_layer(int(object->slicing_parameters().raft_layers()), 0.2, 0.2, 0.1);
|
||||
modifiers.add_object();
|
||||
}
|
||||
|
||||
const ModelVolume *add(ModelVolumeType type, Polygons slices)
|
||||
{
|
||||
// These volumes own cache keys; mesh slicing is deliberately outside this geometry fixture.
|
||||
ModelVolume *volume = modifiers.objects.front()->add_volume(Test::cube(1));
|
||||
volume->set_type(type);
|
||||
cache.emplace(volume, std::vector<Polygons>{std::move(slices)});
|
||||
return volume;
|
||||
}
|
||||
};
|
||||
|
||||
void check_square_boundary(const Polygon &polygon)
|
||||
{
|
||||
// Wrong insertion edges can retrace a side without changing area: check length as well.
|
||||
CHECK_THAT(unscale<double>(polygon.length()), Catch::Matchers::WithinAbs(80.0, 0.00001));
|
||||
for (const Point &p : polygon.points) {
|
||||
CAPTURE(p.x(), p.y());
|
||||
CHECK(p.x() >= scale_(0));
|
||||
CHECK(p.x() <= scale_(20));
|
||||
CHECK(p.y() >= scale_(0));
|
||||
CHECK(p.y() <= scale_(20));
|
||||
const bool on_boundary = p.x() == 0 || p.x() == scale_(20) || p.y() == 0 || p.y() == scale_(20);
|
||||
CHECK(on_boundary);
|
||||
}
|
||||
}
|
||||
|
||||
void require_vertex(const Polygon &polygon, const Point &point)
|
||||
{
|
||||
// Integer coordinates make the micron transition helpers exact on these axis-aligned edges.
|
||||
CAPTURE(point.x(), point.y());
|
||||
REQUIRE(std::find(polygon.points.begin(), polygon.points.end(), point) != polygon.points.end());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Strong seam modes select the requested location on a clipped side", "[PreciseSeam]")
|
||||
{
|
||||
const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, ModelVolumeType::PRECISE_SEAM_CENTER,
|
||||
ModelVolumeType::PRECISE_SEAM_RIGHT);
|
||||
SeamFixture fixture;
|
||||
// Clipper may collapse all three original collinear edges into one.
|
||||
Polygon perimeter(Points{mm(0, 0), mm(2, 0), mm(4, 0), mm(8, 0), mm(20, 0), mm(20, 20), mm(0, 20)});
|
||||
const ModelVolume *modifier = fixture.add(mode, {rectangle(1, -2, 13, 2)});
|
||||
PreciseSeam::PreciseSeamWarnings warnings;
|
||||
const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache, &warnings);
|
||||
REQUIRE(seam.has_value());
|
||||
const double expected_x = mode == ModelVolumeType::PRECISE_SEAM_LEFT ? 1.0 :
|
||||
mode == ModelVolumeType::PRECISE_SEAM_RIGHT ? 13.0 : 7.0;
|
||||
CHECK(*seam == mm(expected_x, 0));
|
||||
require_vertex(perimeter, mm(expected_x - 0.001, 0));
|
||||
require_vertex(perimeter, mm(expected_x + 0.001, 0));
|
||||
check_square_boundary(perimeter);
|
||||
CHECK_FALSE(warnings.through_body.load());
|
||||
CHECK_FALSE(warnings.full_containment.load());
|
||||
CHECK_FALSE(warnings.multiple_intersections.load());
|
||||
}
|
||||
|
||||
TEST_CASE("Center seams preserve the edge order at vertices and across the contour origin", "[PreciseSeam]")
|
||||
{
|
||||
const bool wrap = GENERATE(false, true);
|
||||
SeamFixture fixture;
|
||||
Polygon perimeter(Points{mm(0, 0), mm(4, 0), mm(20, 0), mm(20, 20), mm(0, 20)});
|
||||
// Symmetric cuts put the arc midpoint exactly on an existing vertex, including vertex zero.
|
||||
const Polygon cut = wrap ? rectangle(-2, -2, 4, 4) : rectangle(1, -2, 7, 2);
|
||||
const ModelVolume *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {cut});
|
||||
const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache);
|
||||
REQUIRE(seam.has_value());
|
||||
CHECK(*seam == (wrap ? mm(0, 0) : mm(4, 0)));
|
||||
require_vertex(perimeter, wrap ? mm(0, 0.001) : mm(3.999, 0));
|
||||
require_vertex(perimeter, wrap ? mm(0.001, 0) : mm(4.001, 0));
|
||||
check_square_boundary(perimeter);
|
||||
}
|
||||
|
||||
TEST_CASE("Center seams land on the closing edge", "[PreciseSeam]")
|
||||
{
|
||||
SeamFixture fixture;
|
||||
Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(-2, 3, 2, 9)});
|
||||
const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache);
|
||||
REQUIRE(seam.has_value());
|
||||
CHECK(*seam == mm(0, 6));
|
||||
require_vertex(perimeter, mm(0, 5.999));
|
||||
require_vertex(perimeter, mm(0, 6.001));
|
||||
check_square_boundary(perimeter);
|
||||
}
|
||||
|
||||
TEST_CASE("Coincident weak boundaries do not prevent later boundary refinement", "[PreciseSeam][Regression]")
|
||||
{
|
||||
SeamFixture fixture;
|
||||
Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
// Duplicate boundaries used to stall the reverse cursor before reaching the separate segment.
|
||||
const auto *a = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(2, -2, 6, 2)});
|
||||
const auto *b = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(2, -2, 6, 2)});
|
||||
const auto *c = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(10, -2, 14, 2)});
|
||||
const auto segments = PreciseSeam::collect_weak_modifier_segments({c, b, a}, perimeter, fixture.layer, fixture.cache);
|
||||
REQUIRE(segments.size() == 3);
|
||||
for (double x : {1.999, 6.001, 9.999, 14.001})
|
||||
require_vertex(perimeter, mm(x, 0));
|
||||
check_square_boundary(perimeter);
|
||||
}
|
||||
|
||||
TEST_CASE("Weak boundaries sharing a vertex refine both sides", "[PreciseSeam]")
|
||||
{
|
||||
SeamFixture fixture;
|
||||
Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
const auto *a = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(2, -2, 6, 2)});
|
||||
const auto *b = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(6, -2, 10, 2)});
|
||||
const auto segments = PreciseSeam::collect_weak_modifier_segments({a, b}, perimeter, fixture.layer, fixture.cache);
|
||||
REQUIRE(segments.size() == 2);
|
||||
require_vertex(perimeter, mm(5.999, 0));
|
||||
require_vertex(perimeter, mm(6.001, 0));
|
||||
check_square_boundary(perimeter);
|
||||
}
|
||||
|
||||
TEST_CASE("Unsupported modifier sections are skipped with the appropriate warning", "[PreciseSeam]")
|
||||
{
|
||||
const int scenario = GENERATE(0, 1, 2, 3);
|
||||
SeamFixture fixture;
|
||||
Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
const Points original = perimeter.points;
|
||||
Polygons slices;
|
||||
if (scenario == 0) slices = {rectangle(30, 30, 40, 40)}; // Disjoint bounds.
|
||||
if (scenario == 1) slices = {rectangle(2, 2, 4, 4)}; // Wholly inside; no common boundary.
|
||||
if (scenario == 2) slices = {rectangle(-2, -2, 22, 22)}; // Contains the entire perimeter.
|
||||
if (scenario == 3) {
|
||||
Polygon hole = rectangle(2, 2, 4, 4);
|
||||
hole.reverse();
|
||||
slices = {rectangle(-2, -2, 22, 22), hole};
|
||||
}
|
||||
const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, std::move(slices));
|
||||
PreciseSeam::PreciseSeamWarnings warnings;
|
||||
CHECK_FALSE(PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache, &warnings).has_value());
|
||||
CHECK(perimeter.points == original);
|
||||
CHECK(warnings.full_containment.load() == (scenario == 2));
|
||||
CHECK(warnings.multiply_connected.load() == (scenario == 3));
|
||||
CHECK_FALSE(warnings.multiple_intersections.load());
|
||||
CHECK_FALSE(warnings.through_body.load());
|
||||
}
|
||||
|
||||
TEST_CASE("Strong modifiers warn when another slice polygon also intersects the perimeter", "[PreciseSeam]")
|
||||
{
|
||||
SeamFixture fixture;
|
||||
Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
// One helper has two disconnected sections; only its first section supplies the seam.
|
||||
const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER,
|
||||
{rectangle(2, -2, 6, 2), rectangle(12, -2, 16, 2)});
|
||||
PreciseSeam::PreciseSeamWarnings warnings;
|
||||
const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache, &warnings);
|
||||
REQUIRE(seam.has_value());
|
||||
CHECK(*seam == mm(4, 0));
|
||||
CHECK(warnings.multiple_intersections.load());
|
||||
CHECK_FALSE(warnings.through_body.load());
|
||||
CHECK_FALSE(warnings.full_containment.load());
|
||||
CHECK_FALSE(warnings.multiply_connected.load());
|
||||
check_square_boundary(perimeter);
|
||||
}
|
||||
|
||||
TEST_CASE("Modifiers crossing the entire body raise a through body warning", "[PreciseSeam]")
|
||||
{
|
||||
const bool strong = GENERATE(false, true);
|
||||
CAPTURE(strong);
|
||||
SeamFixture fixture;
|
||||
Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
// The strip exits on opposite sides, leaving two exterior pieces. None of the clipped
|
||||
// vertices matches a square corner, so this also exercises the general segment-extraction path.
|
||||
const auto type = strong ? ModelVolumeType::PRECISE_SEAM_CENTER : ModelVolumeType::PRECISE_SEAM_BLOCKED;
|
||||
const auto *modifier = fixture.add(type, {rectangle(8, -2, 12, 22)});
|
||||
PreciseSeam::PreciseSeamWarnings warnings;
|
||||
if (strong) {
|
||||
const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache, &warnings);
|
||||
REQUIRE(seam.has_value());
|
||||
CHECK(seam->x() == mm(10, 0).x());
|
||||
// Either boundary segment may be encountered first by the clipping traversal.
|
||||
const bool on_crossed_side = seam->y() == 0 || seam->y() == mm(0, 20).y();
|
||||
CHECK(on_crossed_side);
|
||||
} else {
|
||||
const auto segments = PreciseSeam::collect_weak_modifier_segments({modifier}, perimeter, fixture.layer, fixture.cache, &warnings);
|
||||
REQUIRE_FALSE(segments.empty());
|
||||
}
|
||||
CHECK(warnings.through_body.load());
|
||||
CHECK_FALSE(warnings.multiple_intersections.load()); // The clipped strip is one polygon.
|
||||
CHECK_FALSE(warnings.full_containment.load());
|
||||
CHECK_FALSE(warnings.multiply_connected.load());
|
||||
check_square_boundary(perimeter);
|
||||
}
|
||||
|
||||
TEST_CASE("Modifier hierarchy keeps strong order and applies the highest weak priority last", "[PreciseSeam]")
|
||||
{
|
||||
const auto high_type = GENERATE(ModelVolumeType::PRECISE_SEAM_BLOCKED, ModelVolumeType::PRECISE_SEAM_NEUTRAL,
|
||||
ModelVolumeType::PRECISE_SEAM_ENFORCED);
|
||||
const auto expected_type = high_type == ModelVolumeType::PRECISE_SEAM_BLOCKED ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked :
|
||||
high_type == ModelVolumeType::PRECISE_SEAM_NEUTRAL ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral :
|
||||
SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced;
|
||||
SeamFixture fixture;
|
||||
const auto *strong_a = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(1, -2, 3, 2)});
|
||||
const auto *strong_b = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(11, -2, 13, 2)});
|
||||
const auto *high = fixture.add(high_type, {rectangle(2, -2, 6, 2)});
|
||||
const auto low_type = high_type == ModelVolumeType::PRECISE_SEAM_ENFORCED ? ModelVolumeType::PRECISE_SEAM_BLOCKED :
|
||||
ModelVolumeType::PRECISE_SEAM_ENFORCED;
|
||||
const auto *low = fixture.add(low_type, {rectangle(2, -2, 6, 2)});
|
||||
std::vector<const ModelVolume*> strong, weak;
|
||||
bool has_strong = false;
|
||||
PreciseSeam::init_precise_seam_data(strong, weak, has_strong, fixture.modifiers.objects.front());
|
||||
REQUIRE(has_strong);
|
||||
CHECK(strong == std::vector<const ModelVolume*>{strong_a, strong_b});
|
||||
CHECK(weak == std::vector<const ModelVolume*>{low, high});
|
||||
Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
const auto seam = PreciseSeam::insert_strong_seam_point(strong, perimeter, fixture.layer, fixture.cache);
|
||||
REQUIRE(seam.has_value());
|
||||
CHECK(*seam == mm(2, 0));
|
||||
|
||||
perimeter = rectangle(0, 0, 20, 20);
|
||||
const auto segments = PreciseSeam::collect_weak_modifier_segments(weak, perimeter, fixture.layer, fixture.cache);
|
||||
REQUIRE(segments.size() == 2);
|
||||
PrintObjectSeamData::LayerSeams result;
|
||||
result.perimeters.emplace_back();
|
||||
auto &loop = result.perimeters.back();
|
||||
// Include a preceding candidate to exercise nonzero global layer indices.
|
||||
result.points.emplace_back(Vec3f(-1, -1, 0), loop, 0, SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral);
|
||||
loop.start_index = 1;
|
||||
for (const Point &p : perimeter.points) {
|
||||
// Match production's double-to-float conversion: weak boundary lookup uses exact equality.
|
||||
const Vec2f position = unscale(p).cast<float>();
|
||||
result.points.emplace_back(Vec3f(position.x(), position.y(), 0), loop, 0,
|
||||
SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral);
|
||||
}
|
||||
loop.end_index = result.points.size();
|
||||
bool enforced = false;
|
||||
PreciseSeam::apply_weak_modifiers_to_perimeter(segments, result, loop, enforced);
|
||||
size_t patch_count = 0;
|
||||
for (size_t i = loop.start_index; i < loop.end_index; ++i) {
|
||||
const auto &candidate = result.points[i];
|
||||
// Axis-aligned input and interpolation keep y exactly zero; this classifies, rather than measures, the patch.
|
||||
const bool in_patch = candidate.position.y() == 0 && candidate.position.x() >= 2 && candidate.position.x() <= 6;
|
||||
CHECK(candidate.type == (in_patch ? expected_type :
|
||||
SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral));
|
||||
if (in_patch) ++patch_count;
|
||||
}
|
||||
CHECK(patch_count >= 2);
|
||||
if (high_type == ModelVolumeType::PRECISE_SEAM_ENFORCED) {
|
||||
// Four millimetres of enforcement must be subdivided, not just marked at its endpoints.
|
||||
CHECK(enforced);
|
||||
CHECK(patch_count >= size_t(4.0f / SeamPlacer::enforcer_oversampling_distance));
|
||||
}
|
||||
CHECK(result.points.front().type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
#include "libslic3r/GCode/SeamPlacer.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/TriangleSelector.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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<Vec2d> &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<double>(extremes[0].y()), top_y = unscale<double>(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<Vec2d> vertices = self_touch ? std::vector<Vec2d>{{2, 2}, {10, 2}, {18, 10}, {10, 2}, {2, 18}} :
|
||||
std::vector<Vec2d>{{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<float>();
|
||||
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);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
|
||||
add_executable(${_TEST_NAME}_tests
|
||||
${_TEST_NAME}_tests.cpp
|
||||
test_3mf.cpp
|
||||
# Round-trip seam metadata and active/dormant volume settings in both formats.
|
||||
test_precise_seam_3mf.cpp
|
||||
test_aabbindirect.cpp
|
||||
test_appconfig.cpp
|
||||
test_arachne_walls.cpp
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "test_utils.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/Semver.hpp"
|
||||
#include "libslic3r/Format/3mf.hpp"
|
||||
#include "libslic3r/Format/bbs_3mf.hpp"
|
||||
#include "libslic3r/miniz_extension.hpp"
|
||||
#include "libslic3r/Zipper.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
enum class Backend { Bbs, Prusa };
|
||||
constexpr std::array<ModelVolumeType, 6> seam_types = {
|
||||
ModelVolumeType::PRECISE_SEAM_CENTER, ModelVolumeType::PRECISE_SEAM_LEFT,
|
||||
ModelVolumeType::PRECISE_SEAM_RIGHT, ModelVolumeType::PRECISE_SEAM_ENFORCED,
|
||||
ModelVolumeType::PRECISE_SEAM_BLOCKED, ModelVolumeType::PRECISE_SEAM_NEUTRAL
|
||||
};
|
||||
// Literal entities and backslashes distinguish XML escaping from config serialization.
|
||||
const std::string notes = "quoted \"value\" & <tag>\tcolumn\nnext line & path\\file";
|
||||
|
||||
struct Scene {
|
||||
// Keep the backup directory alive longer than its model; no project files are touched.
|
||||
ScopedTemporaryDir backup{"orca_seam_3mf"};
|
||||
Model model;
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
bool share_mesh = false;
|
||||
|
||||
Scene()
|
||||
{
|
||||
model.set_backup_path(backup.string());
|
||||
// Import normalizes extruder indices against this list; explicitly provide filament 2.
|
||||
config.set_key_value("filament_settings_id", new ConfigOptionStrings(std::vector<std::string>{"A", "B"}));
|
||||
}
|
||||
|
||||
void populate(bool all_modes, bool shared_mesh = false)
|
||||
{
|
||||
share_mesh = shared_mesh;
|
||||
auto *object = model.add_object();
|
||||
object->name = "seam round trip";
|
||||
auto *part = object->add_volume(make_cube(20, 20, 2));
|
||||
part->name = "printable";
|
||||
ModelVolume *first_helper = nullptr;
|
||||
for (size_t i = 0; i < (all_modes ? seam_types.size() : size_t(1)); ++i) {
|
||||
auto *volume = shared_mesh && first_helper ? object->add_volume_with_shared_mesh(*first_helper) :
|
||||
object->add_volume(make_cube(2, 3, 4));
|
||||
if (!first_helper) first_helper = volume;
|
||||
volume->name = "helper_" + std::to_string(i);
|
||||
volume->set_type(seam_types[i]);
|
||||
Geometry::Transformation transform;
|
||||
transform.set_offset(Vec3d(3.0 * double(i), -2.0, 1.0));
|
||||
transform.set_rotation(Vec3d(0.0, 0.0, 0.1 * double(i + 1)));
|
||||
transform.set_scaling_factor(Vec3d(1.0, 1.2, 0.8));
|
||||
volume->set_transformation(transform);
|
||||
// Simulate settings retained by conversion from a part/modifier into a seam helper.
|
||||
volume->config.set_key_value("extruder", new ConfigOptionInt(2));
|
||||
volume->config.set_key_value("sparse_infill_density", new ConfigOptionPercent(100.0 - 5.0 * double(i)));
|
||||
volume->config.set_key_value("notes", new ConfigOptionString(notes + std::to_string(i)));
|
||||
}
|
||||
object->add_instance();
|
||||
}
|
||||
};
|
||||
|
||||
void save(const std::string &path, Backend backend, Scene &scene)
|
||||
{
|
||||
if (backend == Backend::Prusa) {
|
||||
REQUIRE(store_3mf(path.c_str(), &scene.model, &scene.config, false));
|
||||
} else {
|
||||
StoreParams params;
|
||||
params.path = path.c_str();
|
||||
params.model = &scene.model;
|
||||
params.config = &scene.config;
|
||||
params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
if (scene.share_mesh) params.strategy = params.strategy | SaveStrategy::ShareMesh;
|
||||
REQUIRE(store_bbs_3mf(params));
|
||||
}
|
||||
}
|
||||
|
||||
void load(const std::string &path, Backend backend, Scene &scene)
|
||||
{
|
||||
ConfigSubstitutionContext substitutions{ForwardCompatibilitySubstitutionRule::Enable};
|
||||
if (backend == Backend::Prusa) {
|
||||
REQUIRE(load_3mf(path.c_str(), scene.config, substitutions, &scene.model, false));
|
||||
} else {
|
||||
struct ImportedResources {
|
||||
PlateDataPtrs plates;
|
||||
std::vector<Preset*> presets;
|
||||
// Catch failures must not leak importer-owned auxiliary objects.
|
||||
~ImportedResources() { release_PlateData_list(plates); for (auto *preset : presets) delete preset; }
|
||||
} resources;
|
||||
bool bbs = false, orca = false;
|
||||
Semver version;
|
||||
REQUIRE(load_bbs_3mf(path.c_str(), &scene.config, &substitutions, &scene.model, &resources.plates,
|
||||
&resources.presets, &bbs, &orca, &version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
|
||||
}
|
||||
}
|
||||
|
||||
using Archive = std::vector<std::pair<std::string, std::string>>;
|
||||
Archive read_archive(const std::string &path)
|
||||
{
|
||||
struct Reader {
|
||||
mz_zip_archive zip{};
|
||||
~Reader() { if (zip.m_pState) close_zip_reader(&zip); }
|
||||
} reader;
|
||||
REQUIRE(open_zip_reader(&reader.zip, path));
|
||||
Archive entries;
|
||||
for (mz_uint i = 0; i < mz_zip_reader_get_num_files(&reader.zip); ++i) {
|
||||
mz_zip_archive_file_stat stat;
|
||||
REQUIRE(mz_zip_reader_file_stat(&reader.zip, i, &stat));
|
||||
if (stat.m_is_directory) continue;
|
||||
std::string name(stat.m_filename);
|
||||
std::replace(name.begin(), name.end(), '\\', '/');
|
||||
std::string data(size_t(stat.m_uncomp_size), '\0');
|
||||
if (!data.empty()) REQUIRE(mz_zip_reader_extract_to_mem(&reader.zip, i, data.data(), data.size(), 0));
|
||||
entries.emplace_back(std::move(name), std::move(data));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
std::string &model_xml(Archive &archive, Backend backend)
|
||||
{
|
||||
const std::string name = backend == Backend::Bbs ? "Metadata/model_settings.config" : "Metadata/Slic3r_PE_model.config";
|
||||
const auto found = std::find_if(archive.begin(), archive.end(), [&](const auto &entry) { return entry.first == name; });
|
||||
REQUIRE(found != archive.end());
|
||||
return found->second;
|
||||
}
|
||||
|
||||
void write_archive(const std::string &path, const Archive &archive)
|
||||
{
|
||||
// Repack a separate temporary archive, preserving every entry except explicitly edited metadata.
|
||||
Zipper zipper(path);
|
||||
for (const auto &entry : archive) zipper.add_entry(entry.first, entry.second.data(), entry.second.size());
|
||||
zipper.finalize();
|
||||
}
|
||||
|
||||
void replace_once(std::string &text, const std::string &from, const std::string &to)
|
||||
{
|
||||
const auto pos = text.find(from);
|
||||
REQUIRE(pos != std::string::npos);
|
||||
REQUIRE(text.find(from, pos + from.size()) == std::string::npos);
|
||||
text.replace(pos, from.size(), to);
|
||||
}
|
||||
|
||||
std::string remove_mode_metadata(std::string &xml)
|
||||
{
|
||||
// Mutations must locate exactly one real metadata element; silent no-op rewrites would give false confidence.
|
||||
const auto key = xml.find("key=\"precise_seam_type\"");
|
||||
REQUIRE(key != std::string::npos);
|
||||
REQUIRE(xml.find("key=\"precise_seam_type\"", key + 1) == std::string::npos);
|
||||
const auto begin = xml.rfind("<metadata ", key);
|
||||
const auto end = xml.find("/>", key);
|
||||
REQUIRE(begin != std::string::npos);
|
||||
REQUIRE(end != std::string::npos);
|
||||
const std::string result = xml.substr(begin, end + 2 - begin);
|
||||
xml.erase(begin, result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
void check_config(const ModelVolume &volume, size_t index = 0)
|
||||
{
|
||||
REQUIRE(volume.config.has("extruder"));
|
||||
CHECK(volume.config.opt_int("extruder") == 2);
|
||||
REQUIRE(volume.config.has("sparse_infill_density"));
|
||||
CHECK_THAT(volume.config.opt_float("sparse_infill_density"), Catch::Matchers::WithinAbs(100.0 - 5.0 * double(index), 1e-9));
|
||||
REQUIRE(volume.config.has("notes"));
|
||||
CHECK(volume.config.get().opt_string("notes") == notes + std::to_string(index));
|
||||
}
|
||||
|
||||
void check_modifier_config(const ModelVolume &volume, Backend backend, size_t index = 0)
|
||||
{
|
||||
if (backend == Backend::Bbs) {
|
||||
check_config(volume, index);
|
||||
} else {
|
||||
// The Prusa importer whitelists extruder, but drops ordinary notes and infill settings.
|
||||
REQUIRE(volume.config.has("extruder"));
|
||||
CHECK(volume.config.opt_int("extruder") == 2);
|
||||
CHECK_FALSE(volume.config.has("sparse_infill_density"));
|
||||
CHECK_FALSE(volume.config.has("notes"));
|
||||
}
|
||||
}
|
||||
|
||||
void check_geometry(const ModelVolume &before, const ModelVolume &after)
|
||||
{
|
||||
// Importers may recenter a mesh and compensate in its transform; compare transformed vertices.
|
||||
REQUIRE(after.mesh().its.vertices.size() == before.mesh().its.vertices.size());
|
||||
CHECK(after.mesh().its.indices.size() == before.mesh().its.indices.size());
|
||||
std::vector<Vec3d> expected;
|
||||
for (const auto &v : before.mesh().its.vertices) expected.push_back(before.get_matrix() * v.cast<double>());
|
||||
for (const auto &v : after.mesh().its.vertices) {
|
||||
const Vec3d actual = after.get_matrix() * v.cast<double>();
|
||||
const auto match = std::find_if(expected.begin(), expected.end(), [&](const Vec3d &p) { return (p - actual).norm() < 1e-4; });
|
||||
REQUIRE(match != expected.end());
|
||||
expected.erase(match); // Match multiplicities, not merely membership in the vertex set.
|
||||
}
|
||||
CHECK(expected.empty());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("All Precise Seam types and dormant settings survive a 3MF round trip", "[PreciseSeam3mf]")
|
||||
{
|
||||
const auto backend = GENERATE(Backend::Bbs, Backend::Prusa);
|
||||
const bool shared = GENERATE(false, true);
|
||||
CAPTURE(int(backend), shared);
|
||||
Scene source;
|
||||
source.populate(true, shared);
|
||||
ScopedTemporaryFile file(".3mf");
|
||||
save(file.string(), backend, source);
|
||||
Archive archive = read_archive(file.string());
|
||||
const auto &xml = model_xml(archive, backend);
|
||||
const std::string open = backend == Backend::Bbs ? "<part " : "<volume ";
|
||||
const std::string close = backend == Backend::Bbs ? "</part>" : "</volume>";
|
||||
size_t pos = 0, helpers = 0;
|
||||
while ((pos = xml.find(open, pos)) != std::string::npos) {
|
||||
const auto end = xml.find(close, pos);
|
||||
REQUIRE(end != std::string::npos);
|
||||
const auto block = xml.substr(pos, end - pos);
|
||||
if (block.find("key=\"precise_seam_type\"") != std::string::npos) {
|
||||
++helpers;
|
||||
CHECK(block.find(backend == Backend::Bbs ? "subtype=\"modifier_part\"" : "value=\"ParameterModifier\"") != std::string::npos);
|
||||
for (const std::string key : {"extruder", "sparse_infill_density", "notes"}) {
|
||||
CAPTURE(key);
|
||||
CHECK(block.find("key=\"" + key + "\"") == std::string::npos);
|
||||
CHECK(block.find("key=\"precise_seam_config:" + key + "\"") != std::string::npos);
|
||||
}
|
||||
}
|
||||
pos = end + close.size();
|
||||
}
|
||||
REQUIRE(helpers == seam_types.size());
|
||||
Scene destination;
|
||||
load(file.string(), backend, destination);
|
||||
REQUIRE(destination.model.objects.size() == 1);
|
||||
const auto &volumes = destination.model.objects.front()->volumes;
|
||||
REQUIRE(volumes.size() == 1 + seam_types.size());
|
||||
CHECK(volumes.front()->is_model_part());
|
||||
for (size_t i = 0; i < seam_types.size(); ++i) {
|
||||
CAPTURE(i);
|
||||
CHECK(volumes[i + 1]->type() == seam_types[i]);
|
||||
CHECK(volumes[i + 1]->name == "helper_" + std::to_string(i));
|
||||
check_geometry(*source.model.objects.front()->volumes[i + 1], *volumes[i + 1]);
|
||||
check_config(*volumes[i + 1], i);
|
||||
if (shared && backend == Backend::Bbs)
|
||||
CHECK(volumes[i + 1]->mesh_ptr().get() == volumes[1]->mesh_ptr().get());
|
||||
volumes[i + 1]->set_type(ModelVolumeType::PARAMETER_MODIFIER);
|
||||
}
|
||||
|
||||
// Reverse conversion must survive another file round trip, not just retain config in memory.
|
||||
destination.share_mesh = shared;
|
||||
ScopedTemporaryFile converted_file(".3mf");
|
||||
save(converted_file.string(), backend, destination);
|
||||
Archive converted_archive = read_archive(converted_file.string());
|
||||
const auto &converted_xml = model_xml(converted_archive, backend);
|
||||
CHECK(converted_xml.find("key=\"precise_seam_type\"") == std::string::npos);
|
||||
CHECK(converted_xml.find("key=\"precise_seam_config:") == std::string::npos);
|
||||
Scene converted;
|
||||
load(converted_file.string(), backend, converted);
|
||||
REQUIRE(converted.model.objects.size() == 1);
|
||||
const auto &converted_volumes = converted.model.objects.front()->volumes;
|
||||
REQUIRE(converted_volumes.size() == volumes.size());
|
||||
for (size_t i = 0; i < seam_types.size(); ++i) {
|
||||
CAPTURE(i);
|
||||
CHECK(converted_volumes[i + 1]->type() == ModelVolumeType::PARAMETER_MODIFIER);
|
||||
CHECK(converted_volumes[i + 1]->name == "helper_" + std::to_string(i));
|
||||
check_geometry(*volumes[i + 1], *converted_volumes[i + 1]);
|
||||
check_modifier_config(*converted_volumes[i + 1], backend, i);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Ordinary modifier settings are escaped once in 3MF attributes", "[PreciseSeam3mf][Regression]")
|
||||
{
|
||||
const auto backend = GENERATE(Backend::Bbs, Backend::Prusa);
|
||||
CAPTURE(int(backend));
|
||||
Scene source;
|
||||
source.populate(false);
|
||||
source.model.objects.front()->volumes[1]->set_type(ModelVolumeType::PARAMETER_MODIFIER);
|
||||
ScopedTemporaryFile file(".3mf");
|
||||
save(file.string(), backend, source);
|
||||
Archive archive = read_archive(file.string());
|
||||
const auto &xml = model_xml(archive, backend);
|
||||
// Check the serialized value independently of the writer's escaping helper. Prusa drops
|
||||
// ordinary notes on import, so successful loading alone would not detect double escaping.
|
||||
const std::string encoded_notes = R"(quoted \"value\" & <tag>	column\nnext line &amp; path\\file0)";
|
||||
CHECK(xml.find("key=\"notes\" value=\"" + encoded_notes + "\"") != std::string::npos);
|
||||
Scene destination;
|
||||
load(file.string(), backend, destination);
|
||||
REQUIRE(destination.model.objects.size() == 1);
|
||||
REQUIRE(destination.model.objects.front()->volumes.size() == 2);
|
||||
const ModelVolume &modifier = *destination.model.objects.front()->volumes[1];
|
||||
CHECK(modifier.type() == ModelVolumeType::PARAMETER_MODIFIER);
|
||||
check_modifier_config(modifier, backend);
|
||||
}
|
||||
|
||||
TEST_CASE("Seam metadata restores only recognized modes on compatible base types", "[PreciseSeam3mf][Regression]")
|
||||
{
|
||||
const auto backend = GENERATE(Backend::Bbs, Backend::Prusa);
|
||||
// Include both XML orders, fallback cases, and the previous inline type representation.
|
||||
const int variant = GENERATE(0, 1, 2, 3, 4, 5);
|
||||
CAPTURE(int(backend), variant);
|
||||
Scene source;
|
||||
source.populate(false);
|
||||
ScopedTemporaryFile original(".3mf"), edited(".3mf");
|
||||
save(original.string(), backend, source);
|
||||
Archive archive = read_archive(original.string());
|
||||
auto &xml = model_xml(archive, backend);
|
||||
if (variant == 0 || variant == 1) {
|
||||
const std::string metadata = remove_mode_metadata(xml);
|
||||
const std::string tag = backend == Backend::Bbs ? "part" : "volume";
|
||||
// Locate the helper via its dormant config, then move its mode before/after base metadata.
|
||||
const auto key = xml.find("key=\"precise_seam_config:extruder\"");
|
||||
REQUIRE(key != std::string::npos);
|
||||
const auto start = xml.rfind("<" + tag + " ", key);
|
||||
REQUIRE(start != std::string::npos);
|
||||
if (backend == Backend::Bbs) {
|
||||
// Exercise base-type metadata too, not just the subtype attribute preceding all metadata.
|
||||
const auto end = xml.find("</part>", key);
|
||||
REQUIRE(end != std::string::npos);
|
||||
xml.insert(end, "<metadata key=\"part_type\" value=\"modifier_part\"/>");
|
||||
}
|
||||
const auto opening_end = xml.find('>', start);
|
||||
REQUIRE(opening_end != std::string::npos);
|
||||
const auto insertion = variant == 0 ? opening_end + 1 : xml.find("</" + tag + ">", key);
|
||||
REQUIRE(insertion != std::string::npos);
|
||||
xml.insert(insertion, metadata);
|
||||
} else if (variant == 2) {
|
||||
replace_once(xml, "value=\"precise_seam_center\"", "value=\"unknown_future_seam\"");
|
||||
} else if (variant == 3) {
|
||||
remove_mode_metadata(xml);
|
||||
} else if (variant == 4) {
|
||||
// A known seam mode must not reinterpret an ordinary printable part.
|
||||
if (backend == Backend::Bbs) replace_once(xml, "subtype=\"modifier_part\"", "subtype=\"normal_part\"");
|
||||
else {
|
||||
replace_once(xml, "key=\"modifier\" value=\"1\"", "key=\"modifier\" value=\"0\"");
|
||||
replace_once(xml, "value=\"ParameterModifier\"", "value=\"ModelPart\"");
|
||||
}
|
||||
} else {
|
||||
remove_mode_metadata(xml);
|
||||
if (backend == Backend::Bbs) replace_once(xml, "subtype=\"modifier_part\"", "subtype=\"precise_seam_center\"");
|
||||
else replace_once(xml, "value=\"ParameterModifier\"", "value=\"precise_seam_center\"");
|
||||
// Older files stored settings as ordinary volume keys, without the new dormant namespace.
|
||||
for (const std::string key : {"extruder", "sparse_infill_density", "notes"})
|
||||
replace_once(xml, "key=\"precise_seam_config:" + key + "\"", "key=\"" + key + "\"");
|
||||
}
|
||||
write_archive(edited.string(), archive);
|
||||
Scene destination;
|
||||
load(edited.string(), backend, destination);
|
||||
REQUIRE(destination.model.objects.size() == 1);
|
||||
REQUIRE(destination.model.objects.front()->volumes.size() == 2);
|
||||
const ModelVolume &helper = *destination.model.objects.front()->volumes[1];
|
||||
if (variant == 0 || variant == 1 || variant == 5) {
|
||||
CHECK(helper.type() == ModelVolumeType::PRECISE_SEAM_CENTER);
|
||||
if (variant == 5 && backend == Backend::Prusa) {
|
||||
// The legacy Prusa whitelist only accepted extruder among these ordinary setting keys.
|
||||
REQUIRE(helper.config.has("extruder"));
|
||||
CHECK(helper.config.opt_int("extruder") == 2);
|
||||
CHECK_FALSE(helper.config.has("sparse_infill_density"));
|
||||
CHECK_FALSE(helper.config.has("notes"));
|
||||
} else check_config(helper);
|
||||
} else {
|
||||
CHECK(helper.type() == (variant == 4 ? ModelVolumeType::MODEL_PART : ModelVolumeType::PARAMETER_MODIFIER));
|
||||
CHECK_FALSE(helper.config.has("extruder"));
|
||||
CHECK_FALSE(helper.config.has("sparse_infill_density"));
|
||||
CHECK_FALSE(helper.config.has("notes"));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_network_versions.cpp
|
||||
test_action_source.cpp
|
||||
test_plugin_host_api.cpp
|
||||
# Exercise seam enums and predicates through the embedded Python host API.
|
||||
test_precise_seam_plugin.cpp
|
||||
test_plugin_capability_config.cpp
|
||||
test_plugin_config.cpp
|
||||
test_plugin_capabilities_in_use.cpp
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
#include "python_test_support.hpp"
|
||||
|
||||
#include <pybind11/embed.h>
|
||||
#include <array>
|
||||
|
||||
namespace py = pybind11;
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("Python exposes every seam mode and distinguishes strong weak and ordinary volumes", "[PreciseSeamPlugin][Python]")
|
||||
{
|
||||
struct Mode {
|
||||
const char *name;
|
||||
ModelVolumeType type;
|
||||
bool strong;
|
||||
bool weak;
|
||||
};
|
||||
// Explicit expectations protect enum registration and both predicate groups independently.
|
||||
const std::array<Mode, 11> modes = {{
|
||||
{"PreciseSeamCenter", ModelVolumeType::PRECISE_SEAM_CENTER, true, false},
|
||||
{"PreciseSeamLeft", ModelVolumeType::PRECISE_SEAM_LEFT, true, false},
|
||||
{"PreciseSeamRight", ModelVolumeType::PRECISE_SEAM_RIGHT, true, false},
|
||||
{"PreciseSeamEnforced", ModelVolumeType::PRECISE_SEAM_ENFORCED, false, true},
|
||||
{"PreciseSeamBlocked", ModelVolumeType::PRECISE_SEAM_BLOCKED, false, true},
|
||||
{"PreciseSeamNeutral", ModelVolumeType::PRECISE_SEAM_NEUTRAL, false, true},
|
||||
{"ModelPart", ModelVolumeType::MODEL_PART, false, false},
|
||||
{"NegativeVolume", ModelVolumeType::NEGATIVE_VOLUME, false, false},
|
||||
{"ParameterModifier", ModelVolumeType::PARAMETER_MODIFIER, false, false},
|
||||
{"SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER, false, false},
|
||||
{"SupportBlocker", ModelVolumeType::SUPPORT_BLOCKER, false, false}
|
||||
}};
|
||||
|
||||
// Bootstrap holds the GIL on first initialization; acquire explicitly for an already-running interpreter too.
|
||||
ensure_python_initialized();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = import_orca_module().attr("host");
|
||||
REQUIRE(py::hasattr(host, "ModelVolumeType"));
|
||||
py::object enumeration = host.attr("ModelVolumeType");
|
||||
for (const Mode &mode : modes) {
|
||||
DYNAMIC_SECTION(mode.name) {
|
||||
REQUIRE(py::hasattr(enumeration, mode.name));
|
||||
CHECK(enumeration.attr(mode.name).cast<ModelVolumeType>() == mode.type);
|
||||
Model model;
|
||||
auto *volume = model.add_object()->add_volume(make_cube(1, 1, 1));
|
||||
volume->set_type(mode.type);
|
||||
// Borrow the volume: the Python reference is destroyed before its owning Model.
|
||||
py::object py_volume = py::cast(volume, py::return_value_policy::reference);
|
||||
REQUIRE(py::hasattr(py_volume, "is_precise_seam"));
|
||||
REQUIRE(py::hasattr(py_volume, "is_precise_seam_strong"));
|
||||
REQUIRE(py::hasattr(py_volume, "is_precise_seam_weak"));
|
||||
CHECK(py_volume.attr("type")().cast<ModelVolumeType>() == mode.type);
|
||||
const bool precise = mode.strong || mode.weak;
|
||||
CHECK(py_volume.attr("is_precise_seam")().cast<bool>() == precise);
|
||||
CHECK(py_volume.attr("is_precise_seam_strong")().cast<bool>() == mode.strong);
|
||||
CHECK(py_volume.attr("is_precise_seam_weak")().cast<bool>() == mode.weak);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user