mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-26 18:31:11 +00:00
Merge branch 'main' into feat/printer-agent-infra
This commit is contained in:
+4
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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, ®ion->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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user