Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl

This commit is contained in:
Ian Chua
2026-09-16 22:59:22 +08:00
committed by GitHub
153 changed files with 17321 additions and 17726 deletions
+4 -3
View File
@@ -10,16 +10,17 @@ Rules for writing tests under `tests/`. [CATCH2.md](CATCH2.md) is the Catch2 ref
- `libnest2d`: 2D nesting and packing.
- `slic3rutils`: the Python plugin system and its slicing-pipeline bindings.
- `filament_group`: filament-to-extruder grouping, checked against golden files.
- `cli`: end-to-end runs of the built `orca-slicer` binary, Linux only. These tests carry the `RequiresApp` label, which the CI unit-test job excludes because it receives only `build/tests`; run them with `ctest --test-dir build/tests -C Release -L RequiresApp`.
## Building and running
Tests are off by default, so the build has to be told to include them.
- Windows: `build_release_vs.bat tests`, then `ctest --test-dir build/tests -C Release`
- Windows: `build_win.bat -ds --run-tests`, which builds the dependencies and the tests and runs them (`-l -x` for the clang-cl and Ninja build CI uses)
- macOS: `./build_release_macos.sh -s -a arm64 -T`, which builds and runs them
- Linux: `./build_linux.sh -t`, then `ctest --test-dir build/tests`
- Linux: `./build_linux.sh -t`, then `ctest --test-dir build/tests -C Release`
Rebuild a single suite with `cmake --build build --config Release --target <suite>_tests`. Visual Studio and Xcode are multi-configuration generators, so `ctest` needs `-C` there; on Linux it does not.
Rebuild a single suite with `cmake --build build --config Release --target <suite>_tests`. Visual Studio, Xcode and the Ninja Multi-Config generator that `build_linux.sh` uses are all multi-configuration, so `ctest` needs `-C` on every platform; without it, tests registered with plain `add_test()` lose their labels and report "Not Run".
## Where a test goes
+5
View File
@@ -95,4 +95,9 @@ add_subdirectory(fff_print)
add_subdirectory(sla_print)
add_subdirectory(filament_group)
# End-to-end checks of the orca-slicer binary. Linux only: they read result.json, which the CLI
# writes on Linux only. src/ is added before tests/, so the target is known here.
if (UNIX AND NOT APPLE AND TARGET OrcaSlicer)
add_subdirectory(cli)
endif ()
+17
View File
@@ -0,0 +1,17 @@
# Runs the real orca-slicer binary, so it needs the built app and resources/, not just build/tests.
# The CI unit-test job only receives build/tests, so the test carries the RequiresApp label that
# scripts/run_unit_tests.sh excludes. Run it with `ctest -C Release -L RequiresApp`. It also exits 77
# (skipped) when the binary is missing.
find_program(ORCA_CLI_TEST_PYTHON NAMES python3)
if (NOT ORCA_CLI_TEST_PYTHON)
message(STATUS "python3 not found, not registering the CLI tests")
return()
endif ()
add_test(NAME cli_strict_mode
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_strict.sh $<TARGET_FILE:OrcaSlicer> ${ORCA_CLI_TEST_PYTHON})
set_tests_properties(cli_strict_mode PROPERTIES
LABELS "CLI;RequiresApp"
SKIP_RETURN_CODE 77
TIMEOUT 900)
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# End-to-end check of the CLI --strict option against the real orca-slicer binary.
#
# A model with a large unsupported overhang, sliced with support off, raises the NON_CRITICAL
# "support needed" slicing warning. The CLI lists it in result.json's "warnings" array, and with
# --strict it also fails the run with CLI_SLICING_ERROR. --strict with --no-check is rejected up
# front, because --no-check skips that check.
#
# usage: test_cli_strict.sh <orca-slicer binary> <python3>
set -u
BIN="${1:-}"
PY="${2:-python3}"
# 77 is the test's SKIP_RETURN_CODE.
[ -x "$BIN" ] || { echo "SKIP: orca-slicer binary not found: $BIN"; exit 77; }
# From src/libslic3r/Utils.hpp. main() returns them, so the shell sees them modulo 256.
CLI_SUCCESS=0
CLI_INVALID_PARAMS=-2
CLI_SLICING_ERROR=-100
WORK="$(mktemp -d "${TMPDIR:-/tmp}/orca-cli-strict.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/datadir"
# Standalone presets: without "inherits" the CLI loads them as-is, with no preset bundle.
cat > "$WORK/machine.json" <<'EOF'
{
"type": "machine",
"from": "User",
"name": "CLI strict test printer",
"printable_area": ["0x0", "200x0", "200x200", "0x200"],
"printable_height": "100",
"layer_change_gcode": "G92 E0"
}
EOF
cat > "$WORK/process.json" <<'EOF'
{
"type": "process",
"from": "User",
"name": "CLI strict test process",
"enable_support": "0",
"enforce_support_layers": "0"
}
EOF
# A 40x40mm cap on an 8x8mm stem: the cap reaches ~22mm past the stem, beyond the 6mm
# cantilever limit of PrintObject::is_support_necessary().
"$PY" - "$WORK/capital.stl" <<'EOF'
import sys
def box(x0, y0, z0, x1, y1, z1):
v = [(x, y, z) for z in (z0, z1) for y in (y0, y1) for x in (x0, x1)]
# Faces wound counter-clockwise seen from outside: -z, +z, -y, +y, -x, +x.
for a, b, c, d in ((0, 2, 3, 1), (4, 5, 7, 6), (0, 1, 5, 4), (2, 6, 7, 3), (0, 4, 6, 2), (1, 3, 7, 5)):
yield v[a], v[b], v[c]
yield v[a], v[c], v[d]
with open(sys.argv[1], "w") as f:
f.write("solid capital\n")
for tri in (*box(16, 16, 0, 24, 24, 13), *box(0, 0, 12, 40, 40, 14)):
f.write("facet normal 0 0 0\nouter loop\n")
for p in tri:
f.write("vertex %g %g %g\n" % p)
f.write("endloop\nendfacet\n")
f.write("endsolid capital\n")
EOF
fails=0
fail() { echo "FAIL: $*"; fails=$((fails + 1)); }
# run <tag> [option...]: slice into $WORK/<tag>, keeping the log and the shell status there.
run() {
local out="$WORK/$1"; shift
mkdir -p "$out"
timeout 300 "$BIN" --datadir "$WORK/datadir" --load-settings "$WORK/machine.json;$WORK/process.json" \
"$@" --slice 0 --outputdir "$out" "$WORK/capital.stl" > "$out/log" 2>&1
echo $? > "$out/status"
}
# expect_status <tag> <cli code>
expect_status() {
local got; got="$(cat "$WORK/$1/status")"
[ "$got" -eq $(( $2 & 255 )) ] || fail "$1: shell status $got, want $(( $2 & 255 )) (code $2)"
}
# expect_gcode <tag> yes|no
expect_gcode() {
if compgen -G "$WORK/$1/*.gcode" > /dev/null; then
[ "$2" = yes ] || fail "$1: G-code was exported"
else
[ "$2" = no ] || fail "$1: no G-code was exported"
fi
}
# expect_result <tag> <return_code> <strict_mode true|false> <non-critical warning: some|none>
expect_result() {
"$PY" - "$WORK/$1/result.json" "$2" "$3" "$4" <<'EOF' || fail "$1: result.json"
import json, sys
path, want_rc, want_strict, want_warning = sys.argv[1], int(sys.argv[2]), sys.argv[3] == "true", sys.argv[4]
try:
with open(path) as f:
result = json.load(f)
except (OSError, ValueError) as e:
sys.exit("cannot read %s: %s" % (path, e))
errors = []
if result.get("return_code") != want_rc:
errors.append("return_code %r, want %d" % (result.get("return_code"), want_rc))
if result.get("strict_mode") is not want_strict:
errors.append("strict_mode %r, want %r" % (result.get("strict_mode"), want_strict))
warnings = result.get("warnings")
if not isinstance(warnings, list):
errors.append("warnings %r is not a list" % (warnings,))
else:
found = any(isinstance(w, dict) and w.get("class") == "slicing_warning_non_critical" for w in warnings)
if found != (want_warning == "some"):
errors.append("warnings %r, want %s slicing_warning_non_critical" % (warnings, want_warning))
for e in errors:
print(e)
sys.exit(1 if errors else 0)
EOF
}
echo "== without --strict the warning is listed and the slice succeeds"
run plain
expect_status plain $CLI_SUCCESS
expect_result plain $CLI_SUCCESS false some
expect_gcode plain yes
echo "== --strict fails the run on the same warning, before G-code export"
run strict --strict
expect_status strict $CLI_SLICING_ERROR
expect_result strict $CLI_SLICING_ERROR true some
expect_gcode strict no
echo "== --strict with --no-check is rejected before slicing"
run conflict --strict --no-check
expect_status conflict $CLI_INVALID_PARAMS
expect_result conflict $CLI_INVALID_PARAMS true none
expect_gcode conflict no
grep -q -- "--strict cannot be combined with --no-check" "$WORK/conflict/log" \
|| fail "conflict: error message missing"
if [ "$fails" -ne 0 ]; then
for log in "$WORK"/*/log; do
echo "--- $log"
tail -n 40 "$log"
done
exit 1
fi
echo "PASS"
+1
View File
@@ -21,6 +21,7 @@ add_executable(${_TEST_NAME}_tests
test_support_material.cpp
test_tree_support.cpp
test_trianglemesh.cpp
test_wipe.cpp
test_wipe_tower.cpp
)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
+44
View File
@@ -5,6 +5,7 @@
#include <cmath>
#include <map>
#include <mutex>
#include <set>
#include <vector>
@@ -128,6 +129,49 @@ TEST_CASE("Enforced support layers are generated", "[SupportMaterial]")
REQUIRE(enforced.objects().front()->support_layers().size() > 0);
}
// Support-needed statuses raised while slicing support_capital() with support off. The CLI lists these
// in result.json and fails on them under --strict. Collected under a lock: generate_support_material()
// runs on TBB workers.
static std::vector<PrintBase::SlicingStatus> support_needed_statuses(bool no_check)
{
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({ support_capital() }, print, model, {
{ "enable_support", 0 },
{ "enforce_support_layers", 0 }
});
print.set_no_check_flag(no_check);
std::mutex mutex;
std::vector<PrintBase::SlicingStatus> statuses;
print.set_status_callback([&mutex, &statuses](const PrintBase::SlicingStatus &status) {
if (status.message_type != PrintStateBase::SlicingNeedSupportOn)
return;
std::lock_guard<std::mutex> lock(mutex);
statuses.push_back(status);
});
print.process();
return statuses;
}
TEST_CASE("An overhang sliced with support off reports that support is needed", "[SupportMaterial]")
{
// The 40mm cap reaches ~22mm past its 8mm stem, beyond the 6mm cantilever limit of
// PrintObject::is_support_necessary().
const std::vector<PrintBase::SlicingStatus> statuses = support_needed_statuses(false);
REQUIRE(! statuses.empty());
for (const PrintBase::SlicingStatus &status : statuses) {
// The CLI only considers step warnings (warning_step != -1), and --strict only NON_CRITICAL ones.
CHECK(status.warning_level == PrintStateBase::WarningLevel::NON_CRITICAL);
CHECK(status.warning_step != -1);
}
}
TEST_CASE("The no-check flag skips the support-needed check", "[SupportMaterial]")
{
CHECK(support_needed_statuses(true).empty());
}
SCENARIO("Support layer Z honors contact distance", "[SupportMaterial]")
{
// Box h = 20mm, hole bottom at 5mm, hole height 10mm (top edge at 15mm).
+653
View File
@@ -0,0 +1,653 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <map>
#include <string>
#include <string_view>
#include <vector>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Layer.hpp"
#include "test_helpers.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
namespace {
DynamicPrintConfig wipe_config(const char *wall_generator, bool wipe_inward,
const char *wipe_inward_distance = "50%",
const char *seam_gap = "10%", bool wipe_on_loops = false,
const char *wall_loops = "2",
const char *wall_sequence = "inner wall/outer wall",
bool alternate_extra_wall = false,
const char *sparse_infill_density = "0%",
const char *seam_position = "aligned")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "nozzle_diameter", "0.4" },
{ "layer_height", "0.2" },
{ "initial_layer_print_height", "0.2" },
{ "line_width", "0.45" },
{ "outer_wall_line_width", "0" }, // Orca: Auto must use the actual path width.
{ "wall_loops", wall_loops },
{ "wall_generator", wall_generator },
{ "wall_sequence", wall_sequence },
{ "top_shell_layers", "0" },
{ "bottom_shell_layers", "0" },
{ "sparse_infill_density", sparse_infill_density },
{ "seam_position", seam_position },
{ "seam_gap", seam_gap },
{ "wipe", "1" },
{ "wipe_distance", "2" },
{ "retraction_length", "0.8" },
{ "retract_when_changing_layer", "1" },
{ "wipe_inward", wipe_inward ? "1" : "0" },
{ "wipe_inward_distance", wipe_inward_distance },
{ "wipe_on_loops", wipe_on_loops ? "1" : "0" },
{ "alternate_extra_wall", alternate_extra_wall ? "1" : "0" },
{ "gcode_comments", "1" },
{ "machine_start_gcode", "" },
{ "machine_end_gcode", "" },
});
return config;
}
struct WipeTrajectory {
Vec2d start;
double z;
std::vector<Vec2d> destinations;
};
std::vector<WipeTrajectory> wipe_trajectories(const std::string &gcode)
{
const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End);
std::vector<WipeTrajectory> trajectories;
bool in_wipe = false;
GCodeReader parser;
parser.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
const std::string_view comment = line.comment();
if (comment.find(start_tag) != std::string_view::npos) {
in_wipe = true;
trajectories.push_back({Vec2d(self.x(), self.y()), self.z(), {}});
return;
}
if (comment.find(end_tag) != std::string_view::npos) {
in_wipe = false;
return;
}
if (in_wipe && line.dist_XY(self) > EPSILON)
trajectories.back().destinations.emplace_back(line.new_X(self), line.new_Y(self));
});
return trajectories;
}
std::vector<Vec2d> wipe_destinations(const std::string &gcode)
{
std::vector<Vec2d> destinations;
for (const WipeTrajectory &trajectory : wipe_trajectories(gcode))
destinations.insert(destinations.end(), trajectory.destinations.begin(), trajectory.destinations.end());
return destinations;
}
bool trajectories_differ(const std::vector<Vec2d> &lhs, const std::vector<Vec2d> &rhs)
{
if (lhs.size() != rhs.size())
return true;
for (size_t i = 0; i < lhs.size(); ++i)
if ((lhs[i] - rhs[i]).norm() > 0.01)
return true;
return false;
}
double trajectory_length(const WipeTrajectory &trajectory)
{
double length = 0.;
Vec2d previous = trajectory.start;
for (const Vec2d &destination : trajectory.destinations) {
length += (destination - previous).norm();
previous = destination;
}
return length;
}
} // namespace
TEST_CASE("Wipe retraction preserves fractional speed with inward wipe disabled", "[Wipe][Regression]")
{
const char *retraction_speed = GENERATE("25.25", "25.5", "25.75");
const char *relative_e = GENERATE("0", "1");
INFO("retraction speed: " << retraction_speed);
INFO("relative E: " << relative_e);
DynamicPrintConfig config = wipe_config("classic", false);
config.set_deserialize_strict({
{"gcode_flavor", "marlin2"},
{"use_relative_e_distances", relative_e},
{"retraction_speed", retraction_speed},
{"retraction_length", "0.8"},
{"retract_before_wipe", "0%"},
{"retract_after_wipe", "0%"},
{"role_based_wipe_speed", "0"},
{"wipe_speed", "100"},
{"wipe_distance", "2"},
});
const std::string output = slice({make_cube(10., 10., 1.)}, config);
const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End);
double before_wipe = 0.;
double during_wipe = 0.;
bool in_wipe = false;
bool complete = false;
GCodeReader parser;
parser.apply_config(config);
parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (complete)
return;
if (line.comment().find(start_tag) != std::string_view::npos) {
in_wipe = true;
} else if (in_wipe && line.comment().find(end_tag) != std::string_view::npos) {
complete = true;
} else if (line.retracting(self)) {
(in_wipe ? during_wipe : before_wipe) -= line.dist_E(self);
} else if (line.extruding(self)) {
before_wipe = 0.;
}
});
REQUIRE(complete);
// At 100 mm/s, the 2 mm wipe lasts 0.02 seconds. The remaining part of
// the configured 0.8 mm retraction must be emitted before that wipe.
const double expected_during = std::stod(retraction_speed) * 2. / 100.;
CHECK_THAT(during_wipe, Catch::Matchers::WithinAbs(expected_during, 0.00005));
CHECK_THAT(before_wipe, Catch::Matchers::WithinAbs(0.8 - expected_during, 0.00005));
}
TEST_CASE("Inward wipe respects the minimum travel for retraction and Z hop", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
const char *relative_e = GENERATE("0", "1");
const char *reduce_crossing_wall = GENERATE("0", "1");
const char *minimum_travel = GENERATE("5", "0");
CAPTURE(wall_generator, relative_e, reduce_crossing_wall, minimum_travel);
DynamicPrintConfig config = wipe_config(
wall_generator, true, "50%", "10%", false, "3", "inner-outer-inner wall");
config.set_deserialize_strict({
{"gcode_flavor", "marlin2"},
{"use_relative_e_distances", relative_e},
{"reduce_crossing_wall", reduce_crossing_wall},
{"retraction_minimum_travel", minimum_travel},
{"retract_when_changing_layer", "0"},
{"use_firmware_retraction", "0"},
{"retract_before_wipe", "0%"},
{"retract_after_wipe", "0%"},
{"retraction_speed", "25.5"},
{"role_based_wipe_speed", "0"},
{"wipe_speed", "100"},
{"z_hop", "0.4"},
{"retract_lift_above", "0"},
{"retract_lift_below", "0"},
});
config.set_key_value("z_hop_types", new ConfigOptionEnumsGeneric{zhtNormal});
config.set_key_value("retract_lift_enforce", new ConfigOptionEnumsGeneric{rletAllSurfaces});
const std::string output = slice({make_cube(10., 10., 1.)}, config);
const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role);
const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End);
ExtrusionRole role = erNone;
bool after_outer_wall = false;
bool in_wipe = false;
size_t transitions = 0;
size_t same_layer_transitions = 0;
size_t inward_wipes = 0;
double retraction = 0.;
double lift = 0.;
double outer_z = 0.;
GCodeReader parser;
parser.apply_config(config);
parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.comment().find(role_tag) == 0)
role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size()));
if (line.comment().find(start_tag) == 0) {
in_wipe = true;
if (after_outer_wall)
++inward_wipes;
} else if (line.comment().find(end_tag) == 0) {
in_wipe = false;
}
if (line.extruding(self) && line.dist_XY(self) > EPSILON) {
if (role == erExternalPerimeter) {
after_outer_wall = true;
retraction = lift = 0.;
outer_z = line.new_Z(self);
} else if (after_outer_wall) {
REQUIRE(role == erPerimeter);
++transitions;
const double layer_rise = std::max(0., double(self.z()) - outer_z);
if (layer_rise < EPSILON)
++same_layer_transitions;
// A 5 mm threshold suppresses retraction across a few wall widths.
// A zero threshold still permits the ordinary retract and lift.
const bool retract = std::stod(minimum_travel) == 0.;
CHECK_THAT(retraction, Catch::Matchers::WithinAbs(retract ? 0.8 : 0., 0.00005));
// Exclude an ordinary layer change from the accumulated upward motion.
CHECK_THAT(lift - layer_rise, Catch::Matchers::WithinAbs(retract ? 0.4 : 0., 0.001));
after_outer_wall = false;
}
} else if (after_outer_wall) {
if (line.retracting(self))
retraction -= line.dist_E(self);
lift += std::max(0., double(line.dist_Z(self)));
if (in_wipe)
CHECK_THAT(line.dist_E(self), Catch::Matchers::WithinAbs(0., 0.00005));
}
});
// The 1 mm cube has five 0.2 mm layers: every outer wall must still wipe.
REQUIRE(transitions == 5);
REQUIRE(same_layer_transitions >= 4);
REQUIRE(inward_wipes == transitions);
}
TEST_CASE("Changing inward wipe settings preserves the sliced geometry", "[Wipe][Regression]")
{
const char *key = GENERATE("wipe_inward", "wipe_inward_distance");
DynamicPrintConfig config = wipe_config("classic", false);
Print print;
Model model;
init_print({make_cube(10., 10., 1.)}, print, model, config);
gcode(print);
const PrintObject &object = *print.objects().front();
REQUIRE(object.is_step_done(posPerimeters));
REQUIRE(object.is_step_done(posInfill));
REQUIRE(print.is_step_done(psWipeTower));
REQUIRE(print.is_step_done(psGCodeExport));
DynamicPrintConfig changed = config;
changed.set_deserialize_strict({{key, std::string(key) == "wipe_inward" ? "1" : "75%"}});
print.apply(model, changed);
CHECK(print.objects().front()->is_step_done(posPerimeters));
CHECK(print.objects().front()->is_step_done(posInfill));
CHECK(print.is_step_done(psWipeTower));
CHECK_FALSE(print.is_step_done(psGCodeExport));
}
TEST_CASE("Retraction and pressure advance calibration suppress inward wipe overrides", "[Wipe][Regression]")
{
const auto mode = GENERATE(CalibMode::Calib_None, CalibMode::Calib_PA_Tower,
CalibMode::Calib_Auto_PA_Line, CalibMode::Calib_Retraction_tower,
CalibMode::Calib_Flow_Rate);
const char *wall_generator = GENERATE("classic", "arachne");
const bool per_object = GENERATE(false, true);
INFO("calibration mode: " << int(mode) << ", wall generator: " << wall_generator
<< ", per-object override: " << per_object);
const auto trajectories = [&](bool inward) {
DynamicPrintConfig config = wipe_config(wall_generator, inward && !per_object);
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{{"wipe_inward", inward ? "1" : "0"}}
};
Print print;
Model model;
init_print({make_cube(10., 10., 1.)}, print, model, config, per_object ? &overrides : nullptr);
Calib_Params params;
params.mode = mode;
params.start = 0.2;
params.end = 0.4;
params.step = 0.1;
print.set_calib_params(params);
return wipe_destinations(gcode(print));
};
const auto regular = trajectories(false);
const auto inward = trajectories(true);
REQUIRE_FALSE(regular.empty());
REQUIRE_FALSE(inward.empty());
// Other calibration modes and ordinary prints must still honor the option.
const bool should_differ = mode == CalibMode::Calib_None || mode == CalibMode::Calib_Flow_Rate;
CHECK(trajectories_differ(regular, inward) == should_differ);
}
TEST_CASE("Inactive inward wipe settings preserve the exported trajectory", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
const bool disable_wiping = GENERATE(false, true);
DynamicPrintConfig regular = wipe_config(wall_generator, false);
DynamicPrintConfig inward = wipe_config(wall_generator, true, disable_wiping ? "50%" : "0");
if (disable_wiping) {
regular.set_deserialize_strict({{"wipe", "0"}});
inward.set_deserialize_strict({{"wipe", "0"}});
}
const auto regular_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, regular));
const auto inward_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, inward));
if (!disable_wiping)
REQUIRE_FALSE(regular_paths.empty());
CHECK_FALSE(trajectories_differ(regular_paths, inward_paths));
}
TEST_CASE("Inward wipe changes the exported trajectory when outer wall width is Auto", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false)));
const std::vector<Vec2d> inward = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true)));
REQUIRE_FALSE(regular.empty());
REQUIRE_FALSE(inward.empty());
REQUIRE(trajectories_differ(regular, inward));
}
TEST_CASE("Inward wipe recognizes an external wall starting on an overhang", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
const bool inward = GENERATE(false, true);
CAPTURE(wall_generator, inward);
const auto config = wipe_config(wall_generator, inward, "50%", "0%", false,
"3", "inner-outer-inner wall", false, "0%", "back");
Print print;
Model model;
init_print({make_cube(10., 10., 1.)}, print, model, config);
print.process();
size_t mixed_loops = 0;
const auto mark_overhangs = [&](auto &&self, ExtrusionEntity *entity) -> void {
if (auto *collection = dynamic_cast<ExtrusionEntityCollection *>(entity)) {
for (ExtrusionEntity *child : collection->entities)
self(self, child);
} else if (auto *loop = dynamic_cast<ExtrusionLoop *>(entity); loop && is_external_perimeter(loop->role())) {
// Keep the printed geometry intact and give the back seam overhang
// roles. The front edge remains an ordinary external-wall segment.
ExtrusionPaths paths;
bool has_overhang = false;
bool has_external = false;
for (const ExtrusionPath &source : loop->paths) {
for (size_t i = 1; i < source.polyline.points.size(); ++i) {
ExtrusionPath path = source;
path.polyline.points = {source.polyline.points[i - 1], source.polyline.points[i]};
const bool overhang = path.polyline.points.front().y() > 0 || path.polyline.points.back().y() > 0;
path.set_extrusion_role(overhang ? erOverhangPerimeter : erExternalPerimeter);
has_overhang |= overhang;
has_external |= !overhang;
paths.push_back(std::move(path));
}
}
REQUIRE(has_overhang);
REQUIRE(has_external);
loop->paths = std::move(paths);
++mixed_loops;
}
};
for (const PrintObject *object : print.objects())
for (Layer *layer : object->layers())
for (LayerRegion *region : layer->regions())
mark_overhangs(mark_overhangs, &region->perimeters);
REQUIRE(mixed_loops > 0);
bool has_inward_wipe = false;
for (const WipeTrajectory &trajectory : wipe_trajectories(gcode(print))) {
if (trajectory.destinations.empty())
continue;
const Vec2d move = trajectory.destinations.front() - trajectory.start;
if (trajectory.start.x() > 4. && trajectory.start.y() > 4. && move.x() < -0.05 && move.y() < -0.05)
has_inward_wipe = true;
}
CHECK(has_inward_wipe == inward);
}
TEST_CASE("Inward wipe keeps its offset when seam gap is zero", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "0%")));
const std::vector<Vec2d> inward = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "0%")));
REQUIRE_FALSE(regular.empty());
REQUIRE_FALSE(inward.empty());
REQUIRE(trajectories_differ(regular, inward));
}
TEST_CASE("Inward wipe is retained across layers with a back seam", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const DynamicPrintConfig inward_config = wipe_config(
wall_generator, true, "50%", "0%", false, "3", "inner-outer-inner wall", false, "0%", "back");
const std::vector<WipeTrajectory> inward = wipe_trajectories(slice({make_cube(27., 27., 1.)}, inward_config));
REQUIRE_FALSE(inward.empty());
std::map<double, bool> inward_wipe_by_layer;
for (const WipeTrajectory &trajectory : inward) {
bool &has_inward_wipe = inward_wipe_by_layer[trajectory.z];
if (trajectory.destinations.empty())
continue;
const Vec2d first_move = trajectory.destinations.front() - trajectory.start;
// Orca: a back seam lands on the cube's positive-X/positive-Y corner.
// Its inward wipe must move diagonally away from both external faces.
has_inward_wipe = has_inward_wipe ||
(trajectory.start.x() > 13. && trajectory.start.y() > 13. &&
first_move.x() < -0.05 && first_move.y() < -0.05);
}
REQUIRE(inward_wipe_by_layer.size() == 5);
for (const auto &[z, has_inward_wipe] : inward_wipe_by_layer) {
INFO("layer Z: " << z);
REQUIRE(has_inward_wipe);
}
}
TEST_CASE("Literal inward wipe distance is clamped to the outer wall width", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false)));
const std::vector<Vec2d> full_width = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "100%")));
const std::vector<Vec2d> oversized = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "2")));
REQUIRE_FALSE(full_width.empty());
REQUIRE(trajectories_differ(regular, full_width));
REQUIRE(oversized.size() == full_width.size());
for (size_t i = 0; i < full_width.size(); ++i)
REQUIRE_THAT((oversized[i] - full_width[i]).norm(), Catch::Matchers::WithinAbs(0., 0.01));
}
TEST_CASE("Inward wipe is not applied without an adjacent wall", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "10%", false, "1")));
const std::vector<Vec2d> inward = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "10%", false, "1")));
REQUIRE_FALSE(regular.empty());
REQUIRE_FALSE(trajectories_differ(regular, inward));
}
TEST_CASE("Inward wipe uses an alternate extra wall when the configured wall count is one", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const DynamicPrintConfig regular_config = wipe_config(
wall_generator, false, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%");
const DynamicPrintConfig inward_config = wipe_config(
wall_generator, true, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%");
const std::vector<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, regular_config));
const std::vector<Vec2d> inward = wipe_destinations(
slice({make_cube(10., 10., 1.)}, inward_config));
REQUIRE_FALSE(regular.empty());
REQUIRE_FALSE(inward.empty());
REQUIRE(trajectories_differ(regular, inward));
}
TEST_CASE("Inward wipe is not applied before the adjacent wall is printed", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(
wall_generator, false, "50%", "10%", false, "2", "outer wall/inner wall")));
const std::vector<Vec2d> inward = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(
wall_generator, true, "50%", "10%", false, "2", "outer wall/inner wall")));
REQUIRE_FALSE(regular.empty());
REQUIRE_FALSE(trajectories_differ(regular, inward));
}
TEST_CASE("Wipe on loops preserves the corner move with inward wipe disabled", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
const char *nozzle_diameter = GENERATE("0.4", "0.8");
const char *comments = GENERATE("0", "1");
CAPTURE(comments);
INFO("wall generator: " << wall_generator << ", nozzle diameter: " << nozzle_diameter);
// A closed square gives a 90-degree material-side corner at the seam.
DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "0", true);
config.set_deserialize_strict({{"nozzle_diameter", nozzle_diameter}, {"seam_position", "nearest"},
{"gcode_comments", comments}});
const std::string output = slice({make_cube(10., 10., 1.)}, config);
const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role);
const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
ExtrusionRole role = erNone;
std::vector<Vec2d> loop;
bool after_extrusion = false;
size_t moves = 0;
GCodeReader parser;
parser.apply_config(config);
parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.comment().find(role_tag) == 0) {
role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size()));
loop.clear();
after_extrusion = false;
}
if (line.comment().find(wipe_tag) == 0)
after_extrusion = false;
if (role != erExternalPerimeter)
return;
if (line.extruding(self) && line.dist_XY(self) > EPSILON) {
if (loop.empty())
loop.emplace_back(self.x(), self.y());
loop.emplace_back(line.new_X(self), line.new_Y(self));
after_extrusion = true;
return;
}
// The loop move is the first non-extruding XY move after the external
// wall and before the reserved wipe marker, regardless of comment text.
if (!after_extrusion || line.dist_XY(self) <= EPSILON)
return;
after_extrusion = false;
++moves;
INFO("layer Z: " << self.z());
REQUIRE(loop.size() >= 4);
const Vec2d seam = loop.front();
REQUIRE_THAT((loop.back() - seam).norm(), Catch::Matchers::WithinAbs(0., 0.003));
const Vec2d outgoing = (loop[1] - seam).normalized();
const Vec2d into_corner = (loop[loop.size() - 2] - seam).normalized();
REQUIRE_THAT(outgoing.dot(into_corner), Catch::Matchers::WithinAbs(0., 0.01));
const Vec2d move = Vec2d(line.new_X(self), line.new_Y(self)) - seam;
// The legacy corner move is 20% of the nozzle diameter, turned 30 degrees
// from the outgoing edge into the square. Check both components independently.
const double distance = 0.2 * std::stod(nozzle_diameter);
CHECK_THAT(move.dot(outgoing), Catch::Matchers::WithinAbs(distance * std::sqrt(3.) / 2., 0.003));
CHECK_THAT(move.dot(into_corner), Catch::Matchers::WithinAbs(distance / 2., 0.003));
});
REQUIRE(moves == 5);
}
TEST_CASE("Inward wipe remains valid after wipe on loops moves the nozzle", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
const char *comments = GENERATE("0", "1");
CAPTURE(comments);
INFO("wall generator: " << wall_generator);
DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "10%", true);
config.set_deserialize_strict({{"gcode_comments", comments}});
const std::string loop_move = slice({make_cube(10., 10., 1.)}, config);
config.set_deserialize_strict({{"wipe_inward", "1"}});
const std::string combined = slice({make_cube(10., 10., 1.)}, config);
config.set_deserialize_strict({{"wipe_on_loops", "0"}});
const std::string inward_only = slice({make_cube(10., 10., 1.)}, config);
for (const std::string *output : {&loop_move, &combined}) {
INFO("wipe_inward: " << (output == &combined));
std::map<double, std::vector<Vec2d>> loop_moves_by_layer;
const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role);
const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
ExtrusionRole role = erNone;
bool after_extrusion = false;
GCodeReader parser;
parser.apply_config(config);
parser.parse_buffer(*output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.comment().find(role_tag) == 0) {
role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size()));
after_extrusion = false;
}
if (line.comment().find(wipe_tag) == 0)
after_extrusion = false;
if (role != erExternalPerimeter || line.dist_XY(self) <= EPSILON)
return;
if (line.extruding(self)) {
after_extrusion = true;
} else if (after_extrusion) {
loop_moves_by_layer[line.new_Z(self)].emplace_back(line.new_X(self), line.new_Y(self));
after_extrusion = false;
}
});
// The 1 mm cube at 0.2 mm layer height has one external loop on each of five layers.
const auto trajectories = wipe_trajectories(*output);
REQUIRE(loop_moves_by_layer.size() == 5);
for (size_t layer = 1; layer <= 5; ++layer) {
const double z = layer * 0.2;
const auto moves = std::find_if(loop_moves_by_layer.begin(), loop_moves_by_layer.end(),
[z](const auto &entry) { return std::abs(entry.first - z) < 0.001; });
REQUIRE(moves != loop_moves_by_layer.end());
REQUIRE(moves->second.size() == 1);
const auto wipe = std::find_if(trajectories.begin(), trajectories.end(), [&](const WipeTrajectory &trajectory) {
return std::abs(trajectory.z - z) < 0.001 &&
(trajectory.start - moves->second.front()).norm() < 0.001;
});
REQUIRE(wipe != trajectories.end());
// The configured 2 mm wipe must be measured from the inward move's
// endpoint, including when wipe_inward is off (set_last_pos regression).
CHECK_THAT(trajectory_length(*wipe), Catch::Matchers::WithinAbs(2., 0.003));
}
}
const std::vector<WipeTrajectory> combined_trajectories = wipe_trajectories(combined);
const std::vector<WipeTrajectory> inward_trajectories = wipe_trajectories(inward_only);
REQUIRE_FALSE(combined_trajectories.empty());
REQUIRE(combined_trajectories.size() == inward_trajectories.size());
REQUIRE(trajectories_differ(wipe_destinations(combined), wipe_destinations(loop_move)));
bool start_changed = false;
for (size_t i = 0; i < combined_trajectories.size(); ++i) {
start_changed = start_changed ||
(combined_trajectories[i].start - inward_trajectories[i].start).norm() > 0.01;
REQUIRE_THAT(trajectory_length(combined_trajectories[i]),
Catch::Matchers::WithinAbs(trajectory_length(inward_trajectories[i]), 0.01));
}
REQUIRE(start_changed);
}
+2
View File
@@ -37,12 +37,14 @@ add_executable(${_TEST_NAME}_tests
test_triangle_selector.cpp
test_meshboolean.cpp
test_marchingsquares.cpp
test_lay_on_face.cpp
test_model.cpp
test_utils.cpp
test_timeutils.cpp
test_voronoi.cpp
test_wipe_tower_estimate.cpp
test_wipe_tower.cpp
test_wipe_path.cpp
test_optimizers.cpp
test_ordering_strategies.cpp
# test_png_io.cpp
+205
View File
@@ -0,0 +1,205 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/LayOnFace.hpp"
#include "libslic3r/Model.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
// Adds a box part spanning `origin` to `origin + size`, in object coordinates.
void add_box(ModelObject &object, const Vec3d &size, const Vec3d &origin = Vec3d::Zero())
{
TriangleMesh mesh = make_cube(size.x(), size.y(), size.z());
mesh.translate(origin.cast<float>());
object.add_volume(std::move(mesh), ModelVolumeType::MODEL_PART, false);
}
ModelObject &add_box_object(Model &model, const Vec3d &size)
{
ModelObject *object = model.add_object();
add_box(*object, size);
object->add_instance();
return *object;
}
// A 30 x 30 x 2 plate with three 1 mm thick, 20 mm tall ribs along Y. The rib sides facing -X add up
// to more area than the plate's bottom, but only the bottom is a face of the convex hull.
ModelObject &add_ribbed_plate(Model &model)
{
ModelObject *object = model.add_object();
add_box(*object, { 30, 30, 2 });
for (double x : { 5., 14.5, 24. })
add_box(*object, { 1, 30, 20 }, { x, 0, 2 });
object->add_instance();
return *object;
}
std::vector<LayOnFacePlane> instance_planes(const ModelObject &object)
{
return lay_on_face_planes(object, object.instances.front()->get_matrix_no_offset());
}
void lay_on_largest_face(ModelObject &object)
{
const std::vector<LayOnFacePlane> planes = instance_planes(object);
const int idx = find_largest_plane(planes);
REQUIRE(idx >= 0);
lay_on_face(object, 0, planes[idx].normal);
}
void check_size(const ModelObject &object, const Vec3d &expected)
{
const Vec3d size = object.instance_bounding_box(0).size();
CHECK_THAT(size.x(), WithinAbs(expected.x(), 1e-3));
CHECK_THAT(size.y(), WithinAbs(expected.y(), 1e-3));
CHECK_THAT(size.z(), WithinAbs(expected.z(), 1e-3));
}
void check_on_bed(const ModelObject &object) { CHECK_THAT(object.instance_bounding_box(0).min.z(), WithinAbs(0., 1e-3)); }
} // namespace
TEST_CASE("A tilted box is laid on its largest face and dropped onto the bed", "[LayOnFace]")
{
Model model;
ModelObject &box = add_box_object(model, { 40, 20, 10 }); // the 40 x 20 faces are the largest
box.instances.front()->set_rotation({ 0.3, 0.5, 0.2 });
box.instances.front()->set_offset({ 0, 0, 50 });
REQUIRE(box.instance_bounding_box(0).size().z() > 11.);
const std::vector<LayOnFacePlane> planes = instance_planes(box);
REQUIRE(planes.size() == 6);
CHECK_THAT(planes.front().area, WithinAbs(40. * 20., 1e-2));
lay_on_largest_face(box);
CHECK_THAT(box.instance_bounding_box(0).size().z(), WithinAbs(10., 1e-3));
check_on_bed(box);
}
TEST_CASE("A box lying on one of its equally large faces is not flipped", "[LayOnFace]")
{
// A half turn about X puts the other large face down, so the two cases expect different faces
// and neither can pass on the order in which the hull lists them.
const double rotation_x = GENERATE(0., PI);
Model model;
ModelObject &box = add_box_object(model, { 40, 20, 10 }); // the bottom and top are both 40 x 20
box.instances.front()->set_rotation({ rotation_x, 0, 0 });
const Transform3d before = box.instances.front()->get_matrix_no_offset();
const std::vector<LayOnFacePlane> planes = instance_planes(box);
const int idx = find_largest_plane(planes);
REQUIRE(idx >= 0);
// The face down on the plate is the object's -Z face, or its +Z face after the half turn.
CHECK_THAT(planes[idx].normal.z(), WithinAbs(rotation_x == 0. ? -1. : 1., 1e-6));
lay_on_face(box, 0, planes[idx].normal);
CHECK(box.instances.front()->get_matrix_no_offset().isApprox(before, 1e-9));
}
TEST_CASE("Faces are chosen from the orientation left by an earlier part rotation", "[LayOnFace]")
{
Model model;
ModelObject &box = add_box_object(model, { 40, 20, 10 });
box.rotate(PI / 2., X); // what --rotate-x 90 does: rotates the parts, not the instance
check_size(box, { 40, 10, 20 });
SECTION("the largest face") {
lay_on_largest_face(box);
check_size(box, { 40, 20, 10 });
check_on_bed(box);
}
SECTION("the face pointing along +X") {
const std::vector<LayOnFacePlane> planes = instance_planes(box);
const int idx = find_plane_by_normal(planes, { 1, 0, 0 });
REQUIRE(idx >= 0);
CHECK_THAT(planes[idx].normal.x(), WithinAbs(1., 1e-6));
lay_on_face(box, 0, planes[idx].normal);
check_size(box, { 20, 10, 40 });
check_on_bed(box);
}
}
TEST_CASE("Objects are laid on their own faces independently", "[LayOnFace]")
{
Model model;
// Standing on end through its instance rotation.
ModelObject &standing = add_box_object(model, { 40, 20, 10 });
standing.instances.front()->set_rotation({ 0, PI / 2., 0 });
// Standing on edge through a part rotation, lifted above the bed.
ModelObject &on_edge = add_box_object(model, { 30, 20, 5 });
on_edge.rotate(PI / 2., X);
on_edge.instances.front()->set_offset({ 100, 0, 30 });
check_size(standing, { 10, 20, 40 });
check_size(on_edge, { 30, 5, 20 });
for (ModelObject *object : model.objects)
lay_on_largest_face(*object);
check_size(standing, { 40, 20, 10 });
check_on_bed(standing);
check_size(on_edge, { 30, 20, 5 });
check_on_bed(on_edge);
}
TEST_CASE("A part rests on its largest hull face even when parallel inner faces add up to more area", "[LayOnFace]")
{
Model model;
ModelObject &plate = add_ribbed_plate(model);
double area_facing_minus_x = 0.;
for (const ModelVolume *volume : plate.volumes) {
const indexed_triangle_set &its = volume->mesh().its;
for (const Vec3i32 &face : its.indices) {
const Vec3d cross = (its.vertices[face[1]] - its.vertices[face[0]]).cast<double>().cross(
(its.vertices[face[2]] - its.vertices[face[0]]).cast<double>());
if (cross.normalized().x() < -0.999)
area_facing_minus_x += 0.5 * cross.norm();
}
}
// Summing triangle area per normal would pick a rib side over the 900 mm² bottom.
REQUIRE(area_facing_minus_x > 30. * 30.);
plate.instances.front()->set_rotation({ 0, PI / 2., 0 }); // stand the plate on its side
check_size(plate, { 22, 30, 30 });
const std::vector<LayOnFacePlane> planes = instance_planes(plate);
const int idx = find_largest_plane(planes);
REQUIRE(idx >= 0);
CHECK_THAT(planes[idx].area, WithinAbs(30. * 30., 1e-2));
CHECK_THAT(planes[idx].normal.z(), WithinAbs(-1., 1e-6));
lay_on_face(plate, 0, planes[idx].normal);
check_size(plate, { 30, 30, 22 });
check_on_bed(plate);
}
TEST_CASE("Faces are selected in object coordinates whatever the instance rotation", "[LayOnFace]")
{
Model model;
ModelObject &plate = add_ribbed_plate(model);
plate.instances.front()->set_rotation({ 0, 0, PI / 2. });
const Transform3d instance_matrix = plate.instances.front()->get_matrix_no_offset();
const std::vector<LayOnFacePlane> planes = lay_on_face_planes(plate, instance_matrix);
REQUIRE_FALSE(planes.empty());
// Every face center, as --inspect-mesh reports it, selects its own face.
for (size_t i = 0; i < planes.size(); ++i)
CHECK(find_plane_at_point(planes, instance_matrix, planes[i].center, 0.01) == int(i));
const int bottom = find_plane_at_point(planes, instance_matrix, { 15, 15, 0 }, 0.01);
REQUIRE(bottom >= 0);
CHECK_THAT(planes[bottom].normal.z(), WithinAbs(-1., 1e-6));
CHECK(find_plane_by_normal(planes, { 0, 0, -1 }) == bottom);
// Above the bottom plane, and on a rib side that lies inside the hull.
CHECK(find_plane_at_point(planes, instance_matrix, { 15, 15, 0.5 }, 0.01) == -1);
CHECK(find_plane_at_point(planes, instance_matrix, { 14.5, 15, 12 }, 0.01) == -1);
}
TEST_CASE("A part too small to rest on offers no faces", "[LayOnFace]")
{
Model model;
CHECK(instance_planes(add_box_object(model, { 2, 2, 2 })).empty()); // every face is 4 mm², under the 5 mm² minimum
}
+2 -2
View File
@@ -5,10 +5,10 @@
using namespace Slic3r;
// Golden vectors from the Python reference generate_preset_setting_id (defined in
// scripts/orca_id_tool.py). The C++ generate_preset_setting_id() MUST stay byte-identical
// scripts/orca_profile_tool.py). The C++ generate_preset_setting_id() MUST stay byte-identical
// to it, otherwise app-side on-the-fly ids would diverge from the
// script-assigned ones in the profiles. Regenerate a vector with:
// python3 -c "import sys; sys.path.insert(0, 'scripts'); from orca_id_tool import generate_preset_setting_id as g; print(g('Afinia','filament','Afinia ABS @Afinia H400'))"
// python3 -c "import sys; sys.path.insert(0, 'scripts'); from orca_profile_tool import generate_preset_setting_id as g; print(g('Afinia','filament','Afinia ABS @Afinia H400'))"
TEST_CASE("preset setting_id matches the Python reference", "[Preset][setting_id]") {
struct Vec { const char* vendor; const char* type; const char* name; const char* expected; };
const Vec vectors[] = {
File diff suppressed because it is too large Load Diff