Merge branch 'main' into cad-mainline

This commit is contained in:
SoftFever
2026-08-13 15:22:08 +08:00
committed by GitHub
832 changed files with 84686 additions and 33800 deletions
@@ -0,0 +1,172 @@
# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice,
# captured from the main branch at a10d9e77cf. Regeneration is described
# at the test that reads this file: "Toolchange temperature commands are unchanged
# when the wipe tower wait is off" in tests/fff_print/test_multifilament.cpp.
#
# The "time:" and "lead" values are toolchain-specific -- GCC, Clang and MSVC each produce
# slightly different estimates from an identical toolpath -- so they are compared with a
# tolerance, not exactly. Do not regenerate this file to resolve a mismatch in them: no single
# capture satisfies all three, and recapturing just moves the failure to other platforms.
M104 S215 T0 ; set nozzle temperature
M104 S215 T1 ; set nozzle temperature
; CP PRIMING START
T1 ; change extruder
M109 S215 T1 ; set nozzle temperature and wait for it to be reached
M104 S175 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S215 T0 ; set nozzle temperature and wait for it to be reached
; CP PRIMING END
M104 S215 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S175 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S215 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; set nozzle temperature
M104 S240 T0 ; preheat T0 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.4s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 31s lead 30.9s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 31s lead 30.7s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 31s lead 30.6s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.3s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 31s lead 30.6s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 31s lead 30.7s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.4s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.4s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
; CP TOOLCHANGE START
; CP TOOLCHANGE END
M104 S0 ; turn off temperature
+2
View File
@@ -11,12 +11,14 @@ add_executable(${_TEST_NAME}_tests
test_gcodewriter.cpp
test_model.cpp
test_multifilament.cpp
test_perimeters.cpp
test_print.cpp
test_printobject.cpp
test_skirt_brim.cpp
test_slicing_pipeline_hook.cpp
test_support_material.cpp
test_trianglemesh.cpp
test_wipe_tower.cpp
)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
-11
View File
@@ -491,17 +491,6 @@ SCENARIO("init_print functionality", "[test_helpers]") {
THEN("Export gcode functions outputs text.") {
REQUIRE(! Slic3r::Test::gcode(print).empty());
}
#if 0
THEN("Embedded meshes exported") {
std::string path = "C:\\data\\temp\\embedded_meshes\\";
for (auto kvp : Slic3r::Test::mesh_names) {
Slic3r::TriangleMesh m = mesh(kvp.first);
std::string name = kvp.second;
REQUIRE(Slic3r::store_stl((path + name + ".stl").c_str(), &m, true) == true);
REQUIRE(Slic3r::store_obj((path + name + ".obj").c_str(), &m) == true);
}
}
#endif
}
}
}
+611
View File
@@ -1,12 +1,25 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <limits>
#include <optional>
#include <set>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -27,6 +40,210 @@ static std::set<int> tools_for_role(const std::string& gcode, const std::string&
return tools;
}
// X where the nozzle sits while each tagged _WAIT_FOR_TEMP_ON_WIPE_TOWER M109 blocks:
// the nearest preceding G1 carrying an X (the park travel emitted just before the wait).
static std::vector<double> wait_park_xs(const std::string& gcode)
{
std::vector<std::string> lines;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);)
lines.emplace_back(std::move(line));
std::vector<double> xs;
for (size_t i = 0; i < lines.size(); ++i) {
if (lines[i].rfind("M109", 0) != 0 || lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos)
continue;
for (size_t j = i; j-- > 0;) {
if (lines[j].rfind("G1 ", 0) != 0)
continue;
const size_t x_pos = lines[j].find('X');
if (x_pos == std::string::npos)
continue;
xs.push_back(std::stod(lines[j].substr(x_pos + 1)));
break;
}
}
return xs;
}
// Estimated print time at each 1-based line of an exported G-code file, from a second
// GCodeProcessor pass over it. MoveVertex::time is the duration of one move and gcode_id is the
// line it came from (already rebased past the M73 insertions), so the running sum before the first
// move of a line is the elapsed time at that line. The file carries its own config footer, so
// process_file configures the processor -- including the shared s_IsBBLPrinter static that other
// tests in this binary mutate -- from the settings the export itself used.
static std::vector<double> elapsed_time_by_line(const std::string& gcode)
{
ScopedTemporaryFile temp_gcode(".gcode");
{
std::ofstream os(temp_gcode.string());
os << gcode;
}
GCodeProcessor processor;
processor.process_file(temp_gcode.string());
constexpr size_t NORMAL = size_t(PrintEstimatedStatistics::ETimeMode::Normal);
const size_t n_lines = size_t(std::count(gcode.begin(), gcode.end(), '\n')) + 2;
std::vector<double> elapsed(n_lines, 0.);
double running = 0.;
size_t next = 0;
for (const auto& move : processor.get_result().moves) {
const size_t id = std::min<size_t>(move.gcode_id, n_lines - 1);
while (next <= id)
elapsed[next++] = running;
running += move.time[NORMAL];
}
while (next < n_lines)
elapsed[next++] = running;
return elapsed;
}
// The temperature-relevant projection of `gcode`: every M104/M109/Tn line, plus the toolchange and
// priming markers that anchor them, in order. A preheat -- an M104 the GCodeProcessor backtrace
// inserts mid-object, outside any block, naming a tool other than the one currently loaded -- also
// carries "lead <n>s", the estimated time from there to the tool change it heats for, which is the
// property preheat_time controls. No other temperature command gets one: for an M104 retargeting
// the active tool (the first-layer-to-other-layers bump) or one inside a block, the distance to the
// next Tn is a layer time or a handful of moves and says nothing about preheat_time. Everything
// else is dropped, so the trace does not move when travel, tower geometry or line numbering do.
static std::vector<std::string> temperature_trace(const std::string& gcode)
{
std::vector<std::string> lines;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);) {
line.erase(0, line.find_first_not_of(" \t"));
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
lines.emplace_back(std::move(line));
}
const std::vector<double> elapsed = elapsed_time_by_line(gcode);
const auto is_tool = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char) l[1]); };
const auto is_temp = [](const std::string& l) { return l.rfind("M104", 0) == 0 || l.rfind("M109", 0) == 0; };
const auto marker = [](const std::string& l) -> const char* {
for (const char* m : { "; CP TOOLCHANGE START", "; CP TOOLCHANGE END", "; CP PRIMING START", "; CP PRIMING END" })
if (l.find(m) != std::string::npos)
return m;
return nullptr;
};
// Tool a "T<n>" line, or the "T<n>" argument of an M104, names -- or -1 when it names none.
const auto tool_of = [&is_tool](const std::string& l) -> int {
size_t t = std::string::npos; // index of the 'T'
if (is_tool(l))
t = 0;
else if (l.rfind("M104", 0) == 0 && l.find(" T") != std::string::npos)
t = l.find(" T") + 1;
if (t == std::string::npos || t + 1 >= l.size() || !std::isdigit((unsigned char) l[t + 1]))
return -1;
return std::stoi(l.substr(t + 1));
};
std::vector<std::string> trace;
bool in_block = false;
int current_tool = -1;
for (size_t i = 0; i < lines.size(); ++i) {
if (const char* m = marker(lines[i])) {
in_block = std::string(m).find("START") != std::string::npos;
trace.emplace_back(m); // the marker alone: some carry a trailing tool id, some do not
} else if (is_tool(lines[i]) || is_temp(lines[i])) {
std::string entry = lines[i];
const int named = tool_of(lines[i]);
if (!in_block && lines[i].rfind("M104", 0) == 0 && current_tool != -1 && named != -1 && named != current_tool) {
size_t tn = i;
while (tn < lines.size() && !is_tool(lines[tn]))
++tn;
if (tn < lines.size()) {
char lead[32];
std::snprintf(lead, sizeof(lead), "\tlead %.1fs", elapsed[tn + 1] - elapsed[i + 1]);
entry += lead;
}
}
if (is_tool(lines[i]))
current_tool = named;
trace.emplace_back(std::move(entry));
}
}
return trace;
}
// "M104 S240 T0 ; preheat T0 time: 31s<TAB>lead 30.9s" carries the same quantity twice, and both
// vary by toolchain: the backtrace picks the first line at least preheat_time out, so a sub-tenth
// difference in the estimate selects a neighbouring move and "lead" steps by that move's duration.
// Tolerate "lead", still far below the tens of seconds a displaced preheat would shift it. Check
// "time:" against its own entry's "lead" instead of across runs -- being a rounding of it, that
// still catches a change in how it is derived without tracking the absolute estimate.
static constexpr double TRACE_TIME_TOLERANCE_S = 1.5;
static constexpr double TRACE_ROUNDING_SLACK_S = 0.05; // correct rounding keeps |time - lead| <= 0.5
struct TraceEntry
{
std::string text; // timing values replaced by a placeholder
std::optional<double> time_s;
std::optional<double> lead_s;
};
static TraceEntry parse_trace_entry(const std::string& entry)
{
TraceEntry out;
std::string text = entry;
// Split off the tail only when it really is a "lead <n>s", so an unexpected one still compares.
const size_t tab = text.find('\t');
if (tab != std::string::npos) {
const std::string tail = text.substr(tab + 1); // "lead 30.2s"
const size_t sp = tail.find(' ');
if (sp != std::string::npos && sp + 1 < tail.size()
&& std::isdigit(static_cast<unsigned char>(tail[sp + 1]))) {
out.lead_s = std::stod(tail.substr(sp + 1));
text.erase(tab);
}
}
static constexpr std::string_view k_time = "time: ";
const size_t at = text.find(k_time);
// Require a digit first: a dots-only run would otherwise reach std::stod and throw.
if (at != std::string::npos && at + k_time.size() < text.size()
&& std::isdigit(static_cast<unsigned char>(text[at + k_time.size()]))) {
const size_t first = at + k_time.size();
size_t last = first;
while (last < text.size() && (std::isdigit(static_cast<unsigned char>(text[last])) || text[last] == '.'))
++last;
out.time_s = std::stod(text.substr(first, last - first));
text.replace(first, last - first, "<n>"); // surrounding text, incl. the "s", still compared
}
out.text = std::move(text);
return out;
}
static bool timings_match(const std::optional<double>& a, const std::optional<double>& b)
{
if (a.has_value() != b.has_value())
return false;
return !a.has_value() || std::abs(*a - *b) <= TRACE_TIME_TOLERANCE_S;
}
// "time:" must be its own entry's "lead" rounded to a whole second.
static bool time_is_rounded_lead(const TraceEntry& e)
{
if (!e.time_s.has_value() || !e.lead_s.has_value())
return true; // nothing to cross-check
return std::abs(*e.time_s - *e.lead_s) <= 0.5 + TRACE_ROUNDING_SLACK_S;
}
// `a` is the slice under test, `b` the recorded golden.
static bool trace_entries_match(const std::string& a, const std::string& b)
{
const auto x = parse_trace_entry(a);
const auto y = parse_trace_entry(b);
if (x.text != y.text)
return false;
// A field appearing or disappearing is a real change even though the values are tolerated.
if (x.time_s.has_value() != y.time_s.has_value())
return false;
return timings_match(x.lead_s, y.lead_s) && time_is_rounded_lead(x);
}
// Tool index = filament id - 1; brim and skirt follow the wall filament.
TEST_CASE("Each feature prints with its assigned filament", "[MultiFilament]")
{
@@ -86,6 +303,399 @@ TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]")
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 0 }); // infill not overridden: stays on F1
}
// With wait_for_temp_on_wipe_tower the blocking M109 moves from right after the Tn command to
// a stop point parked beside the wipe tower (heat-up drool falls next to the tower, not onto
// its top): tagged with _WAIT_FOR_TEMP_ON_WIPE_TOWER, after the toolchange and before the
// repositioning move and the first extrusion of the purge. The restore that used to block there
// demotes to a non-blocking M104 and moves ahead of the Tn, so the incoming tool heats up over
// the change itself. Ordering and the off-tower stop are the contract here.
TEST_CASE("Toolchange temperature wait moves to the wipe tower when enabled", "[MultiFilament]")
{
const bool wait_on_tower = GENERATE(false, true);
DYNAMIC_SECTION("wait_for_temp_on_wipe_tower " << (wait_on_tower ? 1 : 0)) {
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
// The post-processor's own preheat pass also inserts an M104 for the incoming
// filament ahead of the Tn; switch it off so the temperature commands under test
// are the only ones in the toolchange block.
{ "preheat_time", 0 },
{ "wait_for_temp_on_wipe_tower", wait_on_tower ? 1 : 0 },
}),
// One filament per object -> a toolchange on every layer. Assigned at the object
// level: the used-filament count that gates the prime tower is derived from
// object/volume configs on the harness's single apply (region filament ids such
// as sparse_infill_filament_id are not counted there and the tower would be
// silently disabled).
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
// Split into lines and scan the "; CP TOOLCHANGE START".."; CP TOOLCHANGE END" blocks.
std::vector<std::string> lines;
std::istringstream gcode_stream(gcode);
for (std::string line; std::getline(gcode_stream, line);)
lines.emplace_back(std::move(line));
const auto is_tool_line = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char)l[1]); };
const auto is_m109_line = [](const std::string& l) { return l.rfind("M109", 0) == 0; };
// A non-blocking set-temperature naming one specific tool, e.g. "M104 S255 T1".
const auto is_m104_for_tool = [](const std::string& l, int tool) {
if (l.rfind("M104", 0) != 0)
return false;
const std::string token = " T" + std::to_string(tool);
const size_t at = l.find(token);
return at != std::string::npos && !std::isdigit((unsigned char)l[at + token.size()]);
};
const auto is_tagged_wait = [](const std::string& l) { return l.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") != std::string::npos; };
const auto is_extruding = [](const std::string& l) {
if (l.rfind("G1 ", 0) != 0)
return false;
const size_t e = l.find(" E");
return e != std::string::npos && l.find_first_of("XY") != std::string::npos && l[e + 2] != '-';
};
int checked_blocks = 0;
for (size_t i = 0; i < lines.size(); ++i) {
if (lines[i].find("; CP TOOLCHANGE START") == std::string::npos)
continue;
size_t block_end = i;
while (block_end < lines.size() && lines[block_end].find("; CP TOOLCHANGE END") == std::string::npos)
++block_end;
size_t tool_line = block_end;
for (size_t j = i; j < block_end; ++j)
if (is_tool_line(lines[j])) { tool_line = j; break; }
if (tool_line == block_end)
continue; // final unload block, no toolchange
++checked_blocks;
// Where the incoming tool's target temperature is raised, relative to its Tn.
const int new_tool = std::stoi(lines[tool_line].substr(1));
size_t preheat = tool_line, restore = block_end;
for (size_t j = i; j < tool_line; ++j)
if (is_m104_for_tool(lines[j], new_tool)) { preheat = j; break; }
for (size_t j = tool_line + 1; j < block_end; ++j)
if (is_m104_for_tool(lines[j], new_tool)) { restore = j; break; }
size_t tagged_wait = block_end, untagged_m109 = block_end, first_extrusion = block_end;
for (size_t j = tool_line + 1; j < block_end; ++j) {
if (is_m109_line(lines[j]) && tagged_wait == block_end && is_tagged_wait(lines[j]))
tagged_wait = j;
if (is_m109_line(lines[j]) && untagged_m109 == block_end && !is_tagged_wait(lines[j]))
untagged_m109 = j;
if (first_extrusion == block_end && is_extruding(lines[j]))
first_extrusion = j;
}
INFO("toolchange block at line " << i + 1);
if (wait_on_tower) {
// The only blocking wait is the tagged one, parked beside the tower before the purge.
REQUIRE(tagged_wait < block_end);
CHECK(untagged_m109 == block_end);
// The target is raised ahead of the toolchange, so the incoming tool heats up
// while it is picked up, and nothing sets it again afterwards.
CHECK(preheat < tool_line);
CHECK(restore == block_end);
REQUIRE(first_extrusion < block_end);
CHECK(tagged_wait < first_extrusion);
// The travel preceding the wait parks outside the tower footprint. The tower
// auto-sizes, so derive its extent from the purge extrusions of this block.
size_t stop_line = block_end;
for (size_t j = tagged_wait; j-- > tool_line;)
if (lines[j].rfind("G1 ", 0) == 0 && lines[j].find('X') != std::string::npos) { stop_line = j; break; }
REQUIRE(stop_line < block_end);
const double stop_x = std::stod(lines[stop_line].substr(lines[stop_line].find('X') + 1));
double purge_min_x = std::numeric_limits<double>::max(), purge_max_x = std::numeric_limits<double>::lowest();
for (size_t j = tagged_wait; j < block_end; ++j) {
const size_t x_pos = lines[j].find('X');
if (!is_extruding(lines[j]) || x_pos == std::string::npos)
continue;
const double x = std::stod(lines[j].substr(x_pos + 1));
purge_min_x = std::min(purge_min_x, x);
purge_max_x = std::max(purge_max_x, x);
}
REQUIRE(purge_min_x <= purge_max_x);
INFO("stop travel: " << lines[stop_line] << " purge x range: " << purge_min_x << ".." << purge_max_x);
const bool beside_tower = stop_x < purge_min_x - 0.5 || stop_x > purge_max_x + 0.5;
CHECK(beside_tower);
} else {
// Stock behavior: the blocking wait follows the toolchange command directly, and
// nothing raises the incoming tool's target before it.
REQUIRE(untagged_m109 < block_end);
CHECK(tagged_wait == block_end);
CHECK(preheat == tool_line);
if (first_extrusion < block_end)
CHECK(untagged_m109 < first_extrusion);
}
i = block_end;
}
REQUIRE(checked_blocks > 0);
if (!wait_on_tower)
CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos);
}
}
// Priming runs before the first layer is set up, so set_extruder sees no layer at all: its
// on_first_layer() test is false and print_z is the initial layer height rather than 0. The
// tower nonetheless blocks on the first layer temperature there, so the pre-heat raised ahead
// of each priming Tn has to name that same temperature — pre-heating to the "other layers"
// value instead leaves the tagged M109 asking the firmware to cool back down before the
// priming lines are extruded.
TEST_CASE("Wipe tower priming pre-heats to the first layer temperature", "[MultiFilament]")
{
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "single_extruder_multi_material_priming", 1 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
{ "preheat_time", 0 }, // see the wait test above
// Distinct enough that picking the wrong one is unambiguous.
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
{ "wait_for_temp_on_wipe_tower", 1 },
}),
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
std::vector<std::string> lines;
std::istringstream gcode_stream(gcode);
for (std::string line; std::getline(gcode_stream, line);)
lines.emplace_back(std::move(line));
// Temperature of an M104/M109, or -1 when the line is neither.
const auto temp_of = [](const std::string& l) {
if (l.rfind("M104", 0) != 0 && l.rfind("M109", 0) != 0)
return -1;
const size_t s = l.find('S');
return s == std::string::npos ? -1 : std::stoi(l.substr(s + 1));
};
size_t start = lines.size(), end = lines.size();
for (size_t i = 0; i < lines.size(); ++i) {
if (start == lines.size() && lines[i].find("; CP PRIMING START") != std::string::npos)
start = i;
else if (start < lines.size() && lines[i].find("; CP PRIMING END") != std::string::npos) {
end = i;
break;
}
}
REQUIRE(start < end);
int checked_waits = 0;
for (size_t i = start; i < end; ++i) {
if (lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos)
continue;
++checked_waits;
INFO("priming wait at line " << i + 1 << ": " << lines[i]);
CHECK(temp_of(lines[i]) == 215); // the tower waits on the first layer temperature
// The most recent set-temperature before it is the pre-heat, and must agree with it.
int preheat = -1;
for (size_t j = i; j-- > start;)
if ((preheat = temp_of(lines[j])) != -1)
break;
CHECK(preheat == 215);
}
REQUIRE(checked_waits > 0); // the feature under test is active
}
// The temperature-wait park picks its side of the tower by testing bed containment with the
// tower position at psWipeTower generation time, while WipeTowerIntegration shifts the cached
// moves by the CURRENT position at export. Moving the tower normally invalidates only
// psSkirtBrim (tower gcode is position-independent), but the park makes it bed-relative, so a
// GUI-style move-and-reslice on the same Print must regenerate the tower — otherwise the stale
// park prints outside the bed. Contract: every tagged wait parks inside the printable area.
TEST_CASE("Wipe tower temperature-wait park is regenerated when the tower moves", "[MultiFilament]")
{
// Two objects, one filament each: a toolchange (and a tagged wait) on every layer, like
// the wait test above — but on a single-extruder machine profile: the synthetic
// dual-extruder keys would drag in the extruder-variant expansion, which is not
// idempotent on the default machine profile and would pollute the re-apply diff below.
// Rectangle wall and no brim keep the tower-local footprint inside [0, 35], so the park
// sits at the generator's 2mm side gap: local -2 or 37.
DynamicPrintConfig config = multifilament_config(2, {
{ "single_extruder_multi_material", 0 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_wall_type", "rectangle" }, // the default rib bulges past the width
{ "prime_tower_brim_width", 0 }, // the default 3 widens the first-layer envelope
{ "printable_area", "0x0,200x0,200x200,0x200" },
{ "wipe_tower_x", "0" },
{ "wipe_tower_y", "50" },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "wait_for_temp_on_wipe_tower", 1 },
});
// init_print force-sets this on its own copy; set it here too so the re-apply below
// diffs in wipe_tower_x ONLY — the exact GUI increment under test.
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
Print print;
Model model;
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", 1 } }, { { "extruder", 2 } } }; // object-level, see the wait test above
init_print(std::vector<TriangleMesh>{ cube(20), cube(20) }, print, model, config, &overrides);
const std::string at_edge = gcode(print);
const std::vector<double> at_edge_parks = wait_park_xs(at_edge);
REQUIRE(!at_edge_parks.empty()); // the feature under test is active
for (double x : at_edge_parks) {
INFO("wait park X " << x << " with the tower at x=0 on a 200mm bed");
CHECK(x >= -0.05);
CHECK(x <= 200.05);
}
REQUIRE(print.is_step_done(psWipeTower));
// Move the tower to the right bed edge (164 + 35 = 199 keeps the body printable) and
// re-apply on the SAME Print, as the GUI does. Base the re-apply on the print's own
// resolved config so the diff is wipe_tower_x alone — re-applying the caller's config
// would also diff the apply-time extruder normalization write-backs, and those keys
// regenerate the tower for the wrong reason. The cached right-side park would export
// at 164 + 37 = 201, off the bed; regeneration clamps the park against the bed edge.
// Assemble the moved config exactly the way init_print assembled the first one — the
// apply-time normalization is only idempotent when both applies start from the same
// derivation, and any stray diff key would regenerate the tower for the wrong reason.
config.set_deserialize_strict({ { "wipe_tower_x", "164" } });
DynamicPrintConfig moved_config = DynamicPrintConfig::full_print_config();
moved_config.apply(config);
moved_config.set_key_value("gcode_comments", new ConfigOptionBool(true));
print.apply(model, moved_config);
CHECK_FALSE(print.is_step_done(psWipeTower)); // the move must re-generate the tower
const std::string moved = gcode(print);
const std::vector<double> moved_parks = wait_park_xs(moved);
REQUIRE(!moved_parks.empty()); // the waits must survive the re-slice
for (double x : moved_parks) {
INFO("wait park X " << x << " with the tower at x=164 on a 200mm bed");
CHECK(x >= -0.05);
CHECK(x <= 200.05);
}
}
// The flag-off half of the three tests above. Every site wait_for_temp_on_wipe_tower touches is
// guarded -- set_extruder's pre-toolchange preheat block and its post_toolchange skip,
// toolchange_Change's park, the interface-temp guard in WipeTower2::tool_change, and append_tcr2's
// tagged-M109 filter -- so with the option off the feature has to be inert and temperature emission
// has to stay exactly as it was before the option existed. That is pinned against a trace captured
// from main rather than against expectations written from the current code, which would be
// re-derived from the very code they are meant to guard.
//
// Note what main emits here, since it is easy to misread as a missing wait: with preheat_time set,
// the toolchange carries no blocking M109 at all. GCodeProcessor's backtrace moves the heat-up to
// an M104 preheat_time seconds earlier and demotes the in-place command, which is the entire point
// of preheating. The lead times below are what pin that placement.
TEST_CASE("Toolchange temperature commands are unchanged when the wipe tower wait is off", "[MultiFilament][Regression]")
{
// 20x20x5 cubes at the default 0.2mm layer height are 25 layers, one filament each, so there is
// a toolchange -- and a preheat ahead of it -- on every layer.
const std::string gcode = slice_with_object_overrides(
{ make_cube(20., 20., 5.), make_cube(20., 20., 5.) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "single_extruder_multi_material_priming", 1 }, // reaches toolchange_Change's priming path
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
// GCodeProcessor::apply_config enables the preheat backtrace on
// ooze_prevention && preheat_time > 0 && !SEMM && filaments > 1. That is what puts an
// M104 preheat_time seconds ahead of every Tn, and it also gives set_extruder's
// standby/restore pair, which the option demotes and moves when it is on.
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "preheat_time", 30 },
{ "preheat_steps", 1 },
// enable_tower_interface_features is deliberately left off: the interface temperature
// is observable only through a change_filament_gcode template that reads
// new_filament_temp, since append_tcr2 strips the tower's own M109 for it, and the
// default template here has none. The option's interface-temp guard is covered by the
// enabled-path tests above instead.
//
// Distinct enough that a wrong pick between the two is unambiguous in the trace.
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
{ "wait_for_temp_on_wipe_tower", 0 },
}),
// Object-level, so the used-filament count that gates the prime tower is derived from it.
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
const std::vector<std::string> trace = temperature_trace(gcode);
REQUIRE(trace.size() > 1);
CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos);
const std::string golden_path = std::string(TEST_DATA_DIR PATH_SEPARATOR "wipe_tower_temperature_trace_main.txt");
// Regenerate by appending this test and its helpers to the same file on main (dropping the
// wait_for_temp_on_wipe_tower key, which main's config does not know), rebuilding
// fff_print_tests there, running it with ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE=1, copying the file
// it writes back here, and filling in the commit it was captured from.
if (std::getenv("ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE") != nullptr) {
std::ofstream out(golden_path);
REQUIRE(out.good());
out << "# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice,\n"
"# captured from the main branch at <fill in the commit>. Regeneration is described\n"
"# at the test that reads this file: \"Toolchange temperature commands are unchanged\n"
"# when the wipe tower wait is off\" in tests/fff_print/test_multifilament.cpp.\n";
for (const std::string& entry : trace)
out << entry << "\n";
WARN("Rewrote " << golden_path << " from this run; it no longer reflects main.");
return;
}
std::vector<std::string> golden;
{
std::ifstream in(golden_path);
INFO("reading " << golden_path);
REQUIRE(in.good());
for (std::string line; std::getline(in, line);) {
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (!line.empty() && line[0] != '#')
golden.push_back(std::move(line));
}
}
REQUIRE(!golden.empty());
// Reported separately from the golden comparison below: it is a different failure.
for (size_t i = 0; i < trace.size(); ++i) {
const auto entry = parse_trace_entry(trace[i]);
if (time_is_rounded_lead(entry))
continue;
INFO("at trace entry " << i + 1);
INFO(" " << trace[i]);
FAIL("\"time:\" is not its entry's \"lead\" rounded to a whole second");
}
const size_t common = std::min(trace.size(), golden.size());
for (size_t i = 0; i < common; ++i) {
if (trace_entries_match(trace[i], golden[i]))
continue;
// Report the first difference only: past it the two are misaligned and every later entry
// would be reported as a difference too.
INFO("first difference at trace entry " << i + 1);
INFO(" main: " << golden[i]);
INFO(" branch: " << trace[i]);
FAIL("temperature emission differs from main with wait_for_temp_on_wipe_tower off");
}
CHECK(trace.size() == golden.size());
}
// max_layer_height can be shorter than the extruder count (normalization sizes it to the
// filament count under single_extruder_multi_material). calc_max_layer_height() in ToolOrdering
// indexed it per-nozzle and read past the end. Shortened directly here to isolate that read;
@@ -104,3 +714,4 @@ TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height",
init_and_process_print({ cube(20) }, print, config);
REQUIRE_FALSE(print.objects().front()->layers().empty());
}
+257
View File
@@ -0,0 +1,257 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Print.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
#include "test_helpers.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
namespace {
// The layer at this Z is the last one of the base, so its top surface is the ledge.
const double ledge_z = 5.0;
// The first layer, at initial_layer_print_height.
const double first_layer_z = 0.2;
// TestMesh::step scaled 3x in X/Y: a 60x60x5 base carrying a 54x54 column up to z=10, leaving a 3mm
// top ledge around a feature that keeps rising. That is the geometry both only_one_wall_top and the
// top surface expansion act on. The ledge has to stay wider than the wall band plus two top-infill
// lines, or the expansion discards it as a sliver and the tests below assert nothing.
TriangleMesh step_with_ledge()
{
TriangleMesh m = Slic3r::Test::mesh(TestMesh::step);
m.scale(Vec3f(3.f, 3.f, 1.f));
return m;
}
// Every setting the assertions depend on, so none of them rests on a default.
DynamicPrintConfig base_config(const char *wall_generator)
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "wall_generator", wall_generator },
{ "layer_height", 0.2 }, // puts a layer boundary exactly on ledge_z
{ "initial_layer_print_height", 0.2 },
{ "wall_loops", 3 },
{ "sparse_infill_density", "15%" },
{ "top_shell_layers", 3 },
{ "bottom_shell_layers", 3 },
{ "top_surface_density", "100%" },
{ "top_surface_expansion", 0.0 },
{ "only_one_wall_top", false },
{ "only_one_wall_first_layer", false },
// Do not let the one-wall threshold discard the 3mm ledge before the feature sees it.
{ "min_width_top_surface", 0.0 },
});
return config;
}
double collection_length(const ExtrusionEntityCollection &coll)
{
double len = 0.;
for (const ExtrusionEntity *entity : coll.flatten().entities)
if (! entity->is_collection())
len += entity->length();
return len;
}
// Extruded length per layer. Two slices are compared through this rather than through their G-code,
// because the G-code carries a config block that differs whenever any setting differs.
struct SliceLengths {
std::vector<double> perimeters;
std::vector<double> fills;
};
SliceLengths slice_lengths(const Print &print)
{
SliceLengths out;
for (const Layer *layer : print.objects().front()->layers()) {
double perimeters = 0., fills = 0.;
for (const LayerRegion *region : layer->regions()) {
perimeters += collection_length(region->perimeters);
fills += collection_length(region->fills);
}
out.perimeters.push_back(perimeters);
out.fills.push_back(fills);
}
return out;
}
double perimeter_length_at(const Print &print, double print_z)
{
for (const Layer *layer : print.objects().front()->layers())
if (std::abs(layer->print_z - print_z) < 1e-4) {
double len = 0.;
for (const LayerRegion *region : layer->regions())
len += collection_length(region->perimeters);
return len;
}
return 0.;
}
// Largest per-layer difference between two series; a negative result means they are not comparable.
double max_difference(const std::vector<double> &a, const std::vector<double> &b)
{
if (a.size() != b.size() || a.empty())
return -1.;
double worst = 0.;
for (size_t i = 0; i < a.size(); ++ i)
worst = std::max(worst, std::abs(a[i] - b[i]));
return worst;
}
} // namespace
// The expansion only retypes area as top solid infill, so it can do nothing where there is no top
// fill to begin with: zero top shell layers retypes the top surfaces as internal, and a top surface
// density of 0% leaves the top layer with walls only. The last section is the control - the same
// expansion on the same model does change the slice once a top fill exists - without which the two
// equality checks above it would hold for an unrelated reason.
TEST_CASE("Top surface expansion only acts where there is a top fill", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto lengths_for = [wall_generator](int top_shell_layers, const char *top_surface_density, double expansion) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "top_shell_layers", top_shell_layers },
{ "top_surface_density", top_surface_density },
{ "top_surface_expansion", expansion },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return slice_lengths(print);
};
SECTION("no top shell layers") {
const SliceLengths off = lengths_for(0, "100%", 0.0);
const SliceLengths on = lengths_for(0, "100%", 2.0);
REQUIRE(off.perimeters.size() == on.perimeters.size());
CHECK_THAT(max_difference(off.perimeters, on.perimeters), Catch::Matchers::WithinAbs(0., 1.0));
CHECK_THAT(max_difference(off.fills, on.fills), Catch::Matchers::WithinAbs(0., 1.0));
}
SECTION("zero top surface density") {
const SliceLengths off = lengths_for(3, "0%", 0.0);
const SliceLengths on = lengths_for(3, "0%", 2.0);
REQUIRE(off.perimeters.size() == on.perimeters.size());
CHECK_THAT(max_difference(off.perimeters, on.perimeters), Catch::Matchers::WithinAbs(0., 1.0));
CHECK_THAT(max_difference(off.fills, on.fills), Catch::Matchers::WithinAbs(0., 1.0));
}
SECTION("with a top fill the same expansion does change the slice") {
const SliceLengths off = lengths_for(3, "100%", 0.0);
const SliceLengths on = lengths_for(3, "100%", 2.0);
REQUIRE(off.fills.size() == on.fills.size());
CHECK(max_difference(off.fills, on.fills) > scale_(0.5));
}
}
// With no top shell the top surfaces are retyped as internal, so the top surface density has nothing
// left to control: there is no top fill, and only_one_wall_top - the one route from the density to the
// perimeters - is itself switched off for want of a top surface to act on.
TEST_CASE("Top surface density does not affect a slice without a top shell", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto lengths_for = [wall_generator](const char *top_surface_density) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "top_shell_layers", 0 },
{ "only_one_wall_top", true },
{ "top_surface_density", top_surface_density },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return slice_lengths(print);
};
const SliceLengths solid = lengths_for("100%");
const SliceLengths none = lengths_for("0%");
REQUIRE(solid.perimeters.size() == none.perimeters.size());
CHECK_THAT(max_difference(solid.perimeters, none.perimeters), Catch::Matchers::WithinAbs(0., 1.0));
CHECK_THAT(max_difference(solid.fills, none.fills), Catch::Matchers::WithinAbs(0., 1.0));
}
// On the ledge layer the inner walls are given up to the top fill, so that layer loses wall length.
// The handover needs a top fill that reaches the freed space: at a top surface density of 0% there is
// no top fill at all, and without top_surface_expansion the fill never grows over the walls. Either
// way the feature still runs, through the original generation, which keeps the inner walls up to the
// top boundary - putting that layer back between the plain and the one-wall slice.
TEST_CASE("Only one wall on top surfaces drops inner walls only where a top fill replaces them", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto ledge_perimeters_for = [wall_generator](bool only_one_wall_top, const char *top_surface_density, double expansion) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "only_one_wall_top", only_one_wall_top },
{ "top_surface_density", top_surface_density },
{ "top_surface_expansion", expansion },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return perimeter_length_at(print, ledge_z);
};
const double plain = ledge_perimeters_for(false, "100%", 2.0);
const double one_wall = ledge_perimeters_for(true, "100%", 2.0);
const double one_wall_no_fill = ledge_perimeters_for(true, "0%", 2.0);
const double one_wall_no_expand = ledge_perimeters_for(true, "100%", 0.0);
REQUIRE(plain > 0.);
CHECK(one_wall < plain);
// Both fall back to the original generation, which cuts the walls back to the top boundary but not past it.
CHECK(one_wall_no_fill > one_wall);
CHECK(one_wall_no_fill < plain);
CHECK(one_wall_no_expand > one_wall);
CHECK(one_wall_no_expand < plain);
}
// The bottom counterpart: the first layer is thinned to a single wall only where a bottom shell fills the
// space behind it. With no bottom shell layers the bottom surfaces are retyped as internal, so that wall
// would ring sparse infill on the bed - the option is switched off instead, and the GUI hides it in that
// state so a profile that left it enabled cannot act behind a hidden checkbox.
TEST_CASE("Only one wall on the first layer needs a bottom shell", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto first_layer_perimeters_for = [wall_generator](bool only_one_wall_first_layer, int bottom_shell_layers) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "only_one_wall_first_layer", only_one_wall_first_layer },
{ "bottom_shell_layers", bottom_shell_layers },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return perimeter_length_at(print, first_layer_z);
};
const double plain = first_layer_perimeters_for(false, 3);
const double one_wall = first_layer_perimeters_for(true, 3);
// Both at zero bottom shell layers, so everything else that setting changes cancels out between them.
const double plain_no_shell = first_layer_perimeters_for(false, 0);
const double one_wall_no_shell = first_layer_perimeters_for(true, 0);
REQUIRE(plain > 0.);
CHECK(one_wall < plain);
// No bottom shell: the option is inert, down to the same walls an unchecked box gives.
CHECK_THAT(one_wall_no_shell, Catch::Matchers::WithinAbs(plain_no_shell, 1.0));
}
+16
View File
@@ -338,6 +338,22 @@ TEST_CASE("G-code lists the resolved extrusion-width settings", "[Print]")
CHECK(with_first_layer.find("; first layer extrusion width") != std::string::npos);
}
// gcode_skip_config_block suppresses the resolved-settings block while leaving the
// header and executable blocks intact.
TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "[Print]")
{
const std::string gcode = slice({ cube(20) }, {
{ "gcode_skip_config_block", true },
{ "gcode_comments", true },
});
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
CHECK(gcode.find("; layer_height =") == std::string::npos);
CHECK(gcode.find("; fill_density =") == std::string::npos);
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
}
// Custom G-code templates substitute placeholders during export.
TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
{
+168
View File
@@ -4,6 +4,7 @@
#include "libslic3r/Config.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Geometry/ConvexHull.hpp"
#include "libslic3r/Layer.hpp"
#include <boost/algorithm/string.hpp>
@@ -32,6 +33,30 @@ static size_t brim_loop_count(Print &print)
return n;
}
static bool brim_enters_first_layer_hole(Print &print)
{
const PrintObject *object = print.get_object(0);
Polygons holes;
for (const ExPolygon &slice : object->layers().front()->lslices)
holes.insert(holes.end(), slice.holes.begin(), slice.holes.end());
const Vec3d plate_origin = print.get_plate_origin();
Point shift = object->instances().front().shift_without_plate_offset();
shift += Point(scaled(plate_origin.x()), scaled(plate_origin.y()));
for (Polygon &hole : holes)
hole.translate(shift);
for (const auto &kv : print.get_brimMap()) {
Polylines brim_paths;
kv.second.collect_polylines(brim_paths);
for (const Polyline &path : brim_paths)
for (const Point &point : path.points)
if (contains(holes, point, false))
return true;
}
return false;
}
// The span is skirt_height layers, or every layer when a draft shield is on (forced even at
// height 0); per-object skirts are rejected in By object printing (no room between objects).
TEST_CASE("Skirt is emitted once per layer it spans", "[SkirtBrim]")
@@ -153,6 +178,24 @@ TEST_CASE("Object brims are generated per instance", "[SkirtBrim]")
}
}
TEST_CASE("Uncombined neighboring brims precede their respective objects", "[SkirtBrim]")
{
Print print;
Model model;
place_two_cubes_apart(0, {
{ "skirt_loops", 0 },
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
{ "combine_brims", 0 },
}, print, model);
print.process();
REQUIRE(print.skirt_brim_groups().size() == 1);
REQUIRE(print.skirt_brim_groups().front().brims.size() == 2);
CHECK(role_sequence(gcode(print), { "brim", "perimeter" }) ==
std::vector<std::string>{ "brim", "perimeter", "brim", "perimeter" });
}
TEST_CASE("Combine brims merges neighboring object instances", "[SkirtBrim]")
{
Print print;
@@ -207,6 +250,131 @@ TEST_CASE("Brim ears appear only at corners within the max angle", "[SkirtBrim]"
}
}
TEST_CASE("Outer-only brim ears stay out of model holes", "[SkirtBrim]")
{
const bool outer_only = GENERATE(false, true);
DYNAMIC_SECTION("brim_ears_outer_only=" << outer_only) {
Print print;
init_and_process_print({ TestMesh::cube_with_concave_hole }, print, {
{ "skirt_loops", 0 },
{ "brim_type", "brim_ears" },
{ "brim_width", 2 },
{ "brim_ears_max_angle", 125 },
{ "brim_ears_detection_length", 0 },
{ "brim_ears_outer_only", outer_only },
{ "initial_layer_line_width", 0.5 },
});
REQUIRE(brim_loop_count(print) > 0);
CHECK(brim_enters_first_layer_hole(print) != outer_only);
}
}
TEST_CASE("Painted brim ear radius controls sliced size", "[SkirtBrim]")
{
constexpr double ear_radius = 10.0;
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "skirt_loops", 0 },
{ "brim_type", "painted" },
{ "brim_width", 15 },
{ "brim_object_gap", 0.1 },
{ "brim_ears_outer_only", true },
{ "initial_layer_line_width", 0.5 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
print.process();
const PrintObject *object = print.get_object(0);
REQUIRE(!object->layers().front()->lslices.empty());
const Point ear_center = object->layers().front()->lslices.front().contour.points.front();
Transform3d model_transform = model.objects.front()->instances.front()->get_transformation().get_matrix_no_offset();
const Point &center_offset = object->center_offset();
model_transform = model_transform.pretranslate(
Vec3d(-unscale<double>(center_offset.x()), -unscale<double>(center_offset.y()), 0));
Vec3d model_pos = model_transform.inverse() *
Vec3d(unscale<double>(ear_center.x()), unscale<double>(ear_center.y()), 0);
model_pos.z() = model.objects.front()->raw_mesh_bounding_box().min.z() - 0.0001;
model.objects.front()->brim_points = {
BrimPoint(model_pos.cast<float>(), float(ear_radius)),
};
print.apply(model, config);
print.process();
const Vec3d plate_origin = print.get_plate_origin();
Point path_center = ear_center + object->instances().front().shift_without_plate_offset();
path_center += Point(scaled(plate_origin.x()), scaled(plate_origin.y()));
double max_path_radius = 0.0;
for (const auto &kv : print.get_brimMap()) {
Polylines brim_paths;
kv.second.collect_polylines(brim_paths);
for (const Polyline &path : brim_paths)
for (const Point &point : path.points)
max_path_radius = std::max(max_path_radius, unscale<double>((point - path_center).cast<double>().norm()));
}
REQUIRE(max_path_radius > 0.0);
INFO("Outermost painted-ear path radius: " << max_path_radius << " mm");
CHECK(max_path_radius > ear_radius - 0.5);
CHECK(max_path_radius < ear_radius);
}
TEST_CASE("Outer-only painted brim ears stay out of model holes", "[SkirtBrim]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "skirt_loops", 0 },
{ "brim_type", "painted" },
{ "brim_ears_outer_only", true },
{ "initial_layer_line_width", 0.5 },
});
Print print;
Model model;
init_print({ TestMesh::cube_with_concave_hole }, print, model, config);
// Slice once to obtain exact outer and inner contour points in print
// coordinates, then express them in the model coordinates painted ears store.
print.process();
const PrintObject *object = print.get_object(0);
REQUIRE(!object->layers().front()->lslices.empty());
REQUIRE(!object->layers().front()->lslices.front().holes.empty());
Transform3d model_transform = model.objects.front()->instances.front()->get_transformation().get_matrix_no_offset();
const Point &center_offset = object->center_offset();
model_transform = model_transform.pretranslate(
Vec3d(-unscale<double>(center_offset.x()), -unscale<double>(center_offset.y()), 0));
const double bottom_z = model.objects.front()->raw_mesh_bounding_box().min.z() - 0.0001;
auto painted_point = [&model_transform, bottom_z](const Point &point) {
Vec3d model_pos = model_transform.inverse() *
Vec3d(unscale<double>(point.x()), unscale<double>(point.y()), 0);
model_pos.z() = bottom_z;
return BrimPoint(model_pos.cast<float>(), 3.f);
};
const ExPolygon &first_slice = object->layers().front()->lslices.front();
Polygon inner_contour = first_slice.holes.front();
inner_contour.reverse();
const Points inner_ear_points = inner_contour.concave_points(55. * PI / 180.);
REQUIRE(!inner_ear_points.empty());
model.objects.front()->brim_points = {
painted_point(first_slice.contour.points.front()),
painted_point(inner_ear_points.front()),
};
print.apply(model, config);
print.process();
REQUIRE(brim_loop_count(print) > 0);
CHECK_FALSE(brim_enters_first_layer_hole(print));
}
SCENARIO("Skirt has the configured number of loops", "[SkirtBrim]") {
GIVEN("20mm cube and default config") {
WHEN("skirt_loops is set to 2") {
+15 -1
View File
@@ -218,10 +218,24 @@ TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pip
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
}
// Editing a slicing plugin's config (print_plugin_config_overrides) must re-run posSlice, where the
// plugin transforms each layer's geometry; otherwise the cached slice keeps the old config's result.
TEST_CASE("Changing print_plugin_config_overrides invalidates posSlice", "[slicing_pipeline]") {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
init_print({cube(20)}, print, model, config);
print.process();
REQUIRE(print.objects().front()->is_step_done(posSlice));
config.set_key_value("print_plugin_config_overrides",
new Slic3r::ConfigOptionString("[{\"type\":\"slicing-pipeline\",\"name\":\"Twistify\",\"config\":{\"twist_deg_per_mm\":2.0}}]"));
print.apply(model, config);
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
}
#include <catch2/matchers/catch_matchers_floating_point.hpp>
// A similarity transform (rotate + uniform scale) applied to slices at Step.posSlice, matching
// what the Twistify sample (sandboxes/orca_twistify_plugin_example_any.py) does. This C++ analogue
// what the Twistify plugin (sandboxes/orca_twistify_plugin_any.py) does. This C++ analogue
// rotates every region's slices a fixed 45 deg about the object's base-footprint center -- the same
// seam and cascade the sample drives through the slices.set() + Layer::make_slices() path. Two
// end-to-end invariants after process() confirm the approach:
+184
View File
@@ -0,0 +1,184 @@
#include <catch2/catch_all.hpp>
#include <string>
#include <vector>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/WipeTower.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_helpers.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
// Taken from the config enum map rather than hand-listed, so a flavor added to GCodeFlavor later
// is covered here without editing this file.
static std::vector<GCodeFlavor> non_klipper_flavors()
{
std::vector<GCodeFlavor> flavors;
for (const auto &[name, value] : ConfigOptionEnum<GCodeFlavor>::get_enum_values())
if (GCodeFlavor(value) != gcfKlipper)
flavors.push_back(GCodeFlavor(value));
return flavors;
}
static std::string flavor_name(GCodeFlavor flavor)
{
return ConfigOptionEnum<GCodeFlavor>::get_enum_names()[int(flavor)];
}
TEST_CASE("Klipper flushes the wipe tower planner queue with M400", "[WipeTower]")
{
CHECK(std::string(flush_planner_queue_command(gcfKlipper)) == "M400\n");
}
TEST_CASE("Other flavors flush the wipe tower planner queue with a zero dwell", "[WipeTower]")
{
const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors()));
INFO("gcode flavor: " << flavor_name(flavor));
CHECK(std::string(flush_planner_queue_command(flavor)) == "G4 S0\n");
}
// 1.5s is exactly representable as a float, so neither form can drift when rounded.
TEST_CASE("Klipper waits in the wipe tower with a millisecond dwell", "[WipeTower]")
{
CHECK(wait_command(gcfKlipper, 1.5f) == "G4 P1500\n");
}
TEST_CASE("Other flavors wait in the wipe tower with a seconds dwell", "[WipeTower]")
{
const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors()));
INFO("gcode flavor: " << flavor_name(flavor));
CHECK(wait_command(flavor, 1.5f) == "G4 S1.500\n");
}
// The prime tower is validated against the real printable outline, so the placement clamps have to
// agree with it wherever that outline is not a rectangle. A regular hexagon inscribed in a 200mm
// circle stands in for the shipped delta beds.
TEST_CASE("The wipe tower placement clamp follows a non-rectangular bed outline", "[WipeTower]")
{
const coord_t margin = scaled<coord_t>(1.);
auto square_at = [](double x, double y, double side) {
return BoundingBox(Point::new_scale(x, y), Point::new_scale(x + side, y + side));
};
// Does the footprint, padded by pad, sit inside the outline once the returned move is applied?
auto lands_inside = [](BoundingBox box, const Polygons &bed, const Vec2f &move, coord_t pad) {
box.translate(Point::new_scale(move.x(), move.y()));
return diff(Polygons{box.inflated(pad).polygon()}, bed).empty();
};
const Polygons hex_bed{make_circle_num_segments(scaled<double>(100.), 6)};
const Polygons square_bed{Polygon::new_scale(Pointfs{{0., 0.}, {200., 0.}, {200., 200.}, {0., 200.}})};
SECTION("a rectangular bed is left to the bounding box clamp") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(50., 50., 30.), square_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(0., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
// Dragging the tower off one edge may not pull it away from the other, or it would jump out from
// under the cursor instead of sliding along the edge.
SECTION("only the violated axis is clamped") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(185., 50., 30.), square_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(-16., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
SECTION("a footprint already inside the outline is left alone") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(-15., -15., 30.), hex_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(0., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
SECTION("a footprint in the bounding box corner is pulled onto the bed") {
const BoundingBox box = square_at(55., 50., 30.);
REQUIRE_FALSE(lands_inside(box, hex_bed, Vec2f::Zero(), margin)); // in the bbox, off the hexagon
CHECK(lands_inside(box, hex_bed, WipeTower::move_box_inside_polygon(box, hex_bed, margin), margin));
}
// An unresolved auto brim width reaches the drag clamp as a negative margin. Padding by it would
// shrink the footprint and hand back a position the slice validation still rejects.
SECTION("a negative margin still lands the footprint inside the outline") {
const BoundingBox box = square_at(55., 50., 30.);
const coord_t brim = scaled<coord_t>(-0.5);
CHECK(lands_inside(box, hex_bed, WipeTower::move_box_inside_polygon(box, hex_bed, brim), 0));
}
SECTION("a footprint too large for the bed is left alone") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(-200., -200., 400.), hex_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(0., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
}
// The cases above only exercise the helpers in isolation. The one below slices a real
// two-filament print, so it also covers the binding constraint of both changes: that the
// configured `gcode_flavor` reaches the wipe tower writer and lands in the exported G-code.
// The G-code inside each WIPE_TOWER_START/WIPE_TOWER_END pair, concatenated, so an M400 emitted
// outside the tower (e.g. GCodeProcessor's pre-heat injector) cannot create a false match.
static std::string wipe_tower_regions(const std::string &gcode)
{
const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_Start);
const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_End);
std::string regions;
size_t pos = 0;
while (true) {
size_t start = gcode.find(start_tag, pos);
if (start == std::string::npos)
break;
size_t end = gcode.find(end_tag, start);
if (end == std::string::npos)
break;
regions.append(gcode, start, end - start);
pos = end + 1;
}
return regions;
}
// A per-layer toolchange between the wall and infill filaments, same shape as
// test_multifilament.cpp's "Each feature prints with its assigned filament", so the wipe tower
// runs its toolchange path (and so `flush_planner_queue()`) on every layer.
static DynamicPrintConfig wipe_tower_toolchange_config(const std::string &gcode_flavor)
{
return multifilament_config(2, {
{ "sparse_infill_filament_id", 1 },
{ "internal_solid_filament_id", 1 },
{ "top_surface_filament_id", 1 },
{ "bottom_surface_filament_id", 1 },
{ "outer_wall_filament_id", 2 },
{ "inner_wall_filament_id", 2 },
{ "enable_prime_tower", true },
{ "layer_height", 0.3 },
{ "gcode_flavor", gcode_flavor },
});
}
// Slices a 10mm cube under `config`. Not plain Test::slice: a brand-new Print's first `apply()`
// counts one filament in use, and DynamicPrintConfig::normalize_fdm_2's single-filament rule then
// clears `enable_prime_tower`. A second apply, once init_print's regions have settled, sees both
// filaments and the tower survives.
static std::string slice_with_prime_tower(const DynamicPrintConfig &config)
{
Print print;
Model model;
init_print({ cube(10) }, print, model, config);
print.apply(model, config);
return gcode(print);
}
TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", "[WipeTower]")
{
auto [flavor, expected, unexpected] = GENERATE(table<std::string, std::string, std::string>({
{ "klipper", "M400", "G4 S0" },
{ "marlin", "G4 S0", "M400" } }));
DYNAMIC_SECTION(flavor) {
const std::string tower = wipe_tower_regions(slice_with_prime_tower(wipe_tower_toolchange_config(flavor)));
REQUIRE_FALSE(tower.empty());
CHECK_THAT(tower, Catch::Matchers::ContainsSubstring(expected));
CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring(unexpected));
}
}
+3
View File
@@ -18,6 +18,7 @@ add_executable(${_TEST_NAME}_tests
test_preset_setting_id.cpp
test_preset_diff.cpp
test_elephant_foot_compensation.cpp
test_fill_plane_path.cpp
test_geometry.cpp
test_multimaterial_segmentation.cpp
test_placeholder_parser.cpp
@@ -28,10 +29,12 @@ add_executable(${_TEST_NAME}_tests
test_stl.cpp
test_meshboolean.cpp
test_marchingsquares.cpp
test_model.cpp
test_utils.cpp
test_timeutils.cpp
test_voronoi.cpp
test_optimizers.cpp
test_ordering_strategies.cpp
# test_png_io.cpp
test_indexed_triangle_set.cpp
../libnest2d/printer_parts.cpp
+16 -88
View File
@@ -11,6 +11,8 @@
#include "libslic3r/ProjectTask.hpp"
#include "libslic3r/Utils.hpp" // set_temporary_dir
#include "test_utils.hpp"
#include <boost/filesystem/operations.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <algorithm>
@@ -113,8 +115,8 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
src_object->instances.front()->set_transformation(src_instance_transform);
WHEN("model is saved+loaded to/from 3mf file") {
// save the model to 3mf file
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/prusa.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
store_3mf(test_file.c_str(), &src_model, nullptr, false);
// load back the model from the 3mf file
@@ -124,7 +126,6 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
load_3mf(test_file.c_str(), dst_config, ctxt, &dst_model, false);
}
boost::filesystem::remove(test_file);
// compare meshes
TriangleMesh src_mesh = src_model.mesh();
@@ -259,10 +260,8 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
// store_bbs_3mf stages Metadata/project_settings.config through the model's backup path;
// point it at a writable temp dir (the default lives under a read-only root in CI).
std::string backup_dir =
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_mn_%%%%%%%%")).string();
boost::filesystem::create_directories(backup_dir);
model.set_backup_path(backup_dir);
ScopedTemporaryDir backup_dir("orca_mn");
model.set_backup_path(backup_dir.string());
// Global (printer) config: give nozzle_volume_type a non-default value so the slice_info
// read-back is a meaningful assertion (High Flow == 1).
@@ -284,7 +283,8 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
plate->config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
WHEN("stored to and reloaded from a .3mf") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/mn_roundtrip.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
@@ -306,8 +306,6 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
boost::filesystem::remove(test_file);
THEN("every multi-nozzle key round-trips as expected") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
@@ -337,7 +335,6 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
release_PlateData_list(dst_plates);
}
delete plate; // store_bbs_3mf does not take ownership of the source plate
boost::filesystem::remove_all(backup_dir);
}
}
@@ -354,10 +351,8 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
std::string backup_dir =
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_nd_%%%%%%%%")).string();
boost::filesystem::create_directories(backup_dir);
model.set_backup_path(backup_dir);
ScopedTemporaryDir backup_dir("orca_nd");
model.set_backup_path(backup_dir.string());
// Single extruder with a non-standard 0.5 mm nozzle; extruder_max_nozzle_count stays at its
// default (no nozzle cluster), so the writer must emit the exact config diameter.
@@ -380,7 +375,8 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
plate->slice_filaments_info.push_back(fi);
WHEN("stored to and reloaded from a .3mf") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/nd_roundtrip.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
@@ -400,8 +396,6 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
boost::filesystem::remove(test_file);
THEN("the saved nozzle diameter is the exact 0.5, not the rounded 0.4") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
@@ -419,7 +413,6 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
release_PlateData_list(dst_plates);
}
delete plate; // store_bbs_3mf does not take ownership of the source plate
boost::filesystem::remove_all(backup_dir);
}
}
@@ -540,10 +533,8 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
std::string backup_dir =
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_ng_%%%%%%%%")).string();
boost::filesystem::create_directories(backup_dir);
model.set_backup_path(backup_dir);
ScopedTemporaryDir backup_dir("orca_ng");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
@@ -563,7 +554,8 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
plate->config.set_key_value("filament_map", new ConfigOptionInts({ 1, 2, 1 }));
WHEN("stored to and reloaded from a .3mf") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/ng_roundtrip.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
@@ -583,8 +575,6 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
boost::filesystem::remove(test_file);
THEN("the <nozzle> tags round-trip into the loaded plate's nozzles_info") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
@@ -610,67 +600,5 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
release_PlateData_list(dst_plates);
}
delete plate;
boost::filesystem::remove_all(backup_dir);
}
}
SCENARIO("2D convex hull of sinking object", "[3mf][.]") {
GIVEN("model") {
// load a model
Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
WHEN("model is rotated, scaled and set as sinking") {
ModelObject* object = model.objects[0];
object->center_around_origin(false);
// This outputs the same exact data as the Prusaslicer test
object->volumes[0]->mesh().write_ascii("/tmp/orca.ascii");
// set instance's attitude so that it is rotated, scaled (and sinking? how is it sinking? the rotation? does it matter if it's sinking?)
ModelInstance* instance = object->instances[0];
instance->set_rotation(X, -M_PI / 4.0);
instance->set_offset(Vec3d::Zero());
instance->set_scaling_factor({ 2.0, 2.0, 2.0 });
// calculate 2D convex hull
auto trafo = instance->get_transformation().get_matrix();
// This matrix is the same exact matrix as the Prusaslicer test
CAPTURE(trafo);
Polygon hull_2d = object->convex_hull_2d(trafo);
// But we get different hull_2d.points here (and somehow decimal numbers despite being int64_t values, but that's probabaly printing configuration somewhere -- Prusaslicer's prints out with newlines between the X&Y and not one between coordinates, which is about the worse possible output).
// I think it's something to do with PrusaSlicer ignoring everything under the Z plane, which makes sense from the results.
// See the comments added to ModelObject::convex_hull_2d for more information.
// verify result
Points result = {
{ -91501496, -15914144 },
{ 91501496, -15914144 },
{ 91501496, 4243 },
{ 78229680, 4246883 },
{ 56898100, 4246883 },
{ -85501496, 4242641 },
{ -91501496, 4243 }
};
THEN("2D convex hull should match with reference") {
// Allow 1um error due to floating point rounding.
bool res = hull_2d.points.size() == result.size();
if (res) {
for (size_t i = 0; i < result.size(); ++ i) {
const Point &p1 = result[i];
const Point &p2 = hull_2d.points[i];
CHECK((std::abs(p1.x() - p2.x()) > 1 || std::abs(p1.y() - p2.y()) > 1));
}
}
CAPTURE(hull_2d.points);
REQUIRE(res);
}
}
}
}
+3 -4
View File
@@ -4,6 +4,8 @@
#include "libslic3r/PrintConfigConstants.hpp"
#include "libslic3r/LocalesUtils.hpp"
#include "test_utils.hpp"
#include <cereal/types/polymorphic.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
@@ -407,8 +409,7 @@ SCENARIO("update_diff_values_to_child_config tolerates legacy machine-limit vect
// }
TEST_CASE("save_to_json round-trips plugin capability references as strings", "[Config][plugins]") {
namespace fs = boost::filesystem;
const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json");
ScopedTemporaryFile tmp(".json");
const std::vector<std::string> refs = {
"local_plugin;;inset",
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset"
@@ -435,8 +436,6 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0);
CHECK(reason.empty());
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
fs::remove(tmp);
}
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
@@ -43,18 +43,67 @@ TEST_CASE("apply_override fills nil entries from the 0-based default index", "[C
REQUIRE(resolved.values == std::vector<double>({30., 42.}));
}
SECTION("an index past the machine slots falls back to the first slot") {
SECTION("an index past the machine slots keeps the slot's own value") {
std::vector<int> slot_index{5, 0};
ConfigOptionFloats resolved(machine);
REQUIRE(resolved.apply_override(&filament, slot_index));
REQUIRE(resolved.values == std::vector<double>({10., 42.}));
}
SECTION("a negative index (unresolved slot) falls back to the first slot") {
std::vector<int> slot_index{-1, 0};
SECTION("a negative index (unresolved slot) keeps the slot's own value") {
ConfigOptionFloatsNullable all_nil;
all_nil.values = {ConfigOptionFloatsNullable::nil_value(), ConfigOptionFloatsNullable::nil_value(),
ConfigOptionFloatsNullable::nil_value()};
std::vector<int> slot_index{2, -1, 0};
ConfigOptionFloats resolved(machine);
REQUIRE(resolved.apply_override(&filament, slot_index));
REQUIRE(resolved.values == std::vector<double>({10., 42.}));
REQUIRE(!resolved.apply_override(&all_nil, slot_index));
REQUIRE(resolved.values == std::vector<double>({30., 20., 10.}));
}
SECTION("all-nil overrides keyed by unresolved slots leave the machine values intact") {
// The failed-lookup map a degenerate print_extruder_id used to produce; the negative
// slots must not collapse the machine array to its first value.
ConfigOptionFloats per_extruder({100., 70., 70., 70., 100.});
ConfigOptionFloatsNullable all_nil;
all_nil.values.assign(5, ConfigOptionFloatsNullable::nil_value());
std::vector<int> slot_index{0, -1, -1, -1, 0};
ConfigOptionFloats resolved(per_extruder);
REQUIRE(!resolved.apply_override(&all_nil, slot_index));
REQUIRE(resolved.values == std::vector<double>({100., 70., 70., 70., 100.}));
}
}
TEST_CASE("support_different_extruders is true only when the printer defines more than one variant column", "[Config]")
{
int extruder_count = 0;
SECTION("a non-Bambu dual-nozzle printer with one variant column reports false") {
DynamicPrintConfig config;
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
// Both extruders resolve to the same default variant, so there is only one column.
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard",
"Direct Drive Standard"};
REQUIRE(config.support_different_extruders(extruder_count) == false);
REQUIRE(extruder_count == 2);
}
SECTION("a Bambu H2D-style printer with distinct variants reports true") {
DynamicPrintConfig config;
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {
"Direct Drive Standard,Direct Drive High Flow",
"Direct Drive Standard,Direct Drive High Flow,Direct Drive TPU High Flow"};
REQUIRE(config.support_different_extruders(extruder_count) == true);
REQUIRE(extruder_count == 2);
}
SECTION("a many-toolhead printer that never opts into variants reports false") {
// A Snapmaker U1 has four identical toolheads and never defines extruder_variant_list,
// so the config falls back to a single default variant token.
DynamicPrintConfig config;
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4, 0.4, 0.4};
REQUIRE(config.support_different_extruders(extruder_count) == false);
REQUIRE(extruder_count == 4);
}
}
@@ -238,6 +287,102 @@ TEST_CASE("update_values_to_printer_extruders expands one slot per (extruder x v
}
}
TEST_CASE("update_values_to_printer_extruders synthesizes degenerate process variant columns", "[Config]")
{
// Non-BBL process presets and 3mf project configs keep the length-1 defaults for
// print_extruder_id/print_extruder_variant; only BBL system presets ship full-width columns.
auto add_degenerate_print_columns = [](DynamicPrintConfig &config) {
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1};
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard"};
config.option<ConfigOptionFloats>("outer_wall_speed", true)->values = {30.};
};
SECTION("a single-column pair on a multi-extruder machine expands to one column per extruder") {
DynamicPrintConfig config;
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtStandard};
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard", "Direct Drive Standard"};
add_degenerate_print_columns(config);
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
int extruder_count = 2;
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
REQUIRE(variant_index == std::vector<int>({0, 1}));
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1, 2}));
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
std::vector<std::string>({"Direct Drive Standard", "Direct Drive Standard"}));
// width-1 data arrays replicate their only column into every slot
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 30.}));
}
SECTION("a multi-variant list synthesizes one column per (extruder x variant)") {
DynamicPrintConfig config = make_hybrid_printer_config();
add_degenerate_print_columns(config);
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
int extruder_count = 2;
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
REQUIRE(count == 3);
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
// same slot resolution as the explicit BBL-style 4-column layout
REQUIRE(variant_index == std::vector<int>({0, 2, 3}));
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1, 2, 2}));
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
std::vector<std::string>({"Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"}));
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 30., 30.}));
}
SECTION("a single-extruder single-column layout is not treated as degenerate") {
DynamicPrintConfig config;
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive};
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard};
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard"};
add_degenerate_print_columns(config);
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
int extruder_count = 1;
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1}));
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30.}));
}
SECTION("a second expansion leaves the synthesized layout unchanged") {
DynamicPrintConfig config;
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtStandard};
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard", "Direct Drive Standard"};
add_degenerate_print_columns(config);
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
int extruder_count = 2;
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
DynamicPrintConfig once = config;
config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values ==
once.option<ConfigOptionInts>("print_extruder_id")->values);
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
once.option<ConfigOptionStrings>("print_extruder_variant")->values);
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values ==
once.option<ConfigOptionFloats>("outer_wall_speed")->values);
}
}
TEST_CASE("update_values_to_printer_extruders_for_multiple_filaments resolves per-filament slots", "[Config]")
{
auto make_filament_arrays = [](DynamicPrintConfig &config) {
+164
View File
@@ -0,0 +1,164 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
#include "libslic3r/Fill/FillPlanePath.hpp"
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
namespace {
constexpr double output_scale = 1'000'000.;
class TestableHilbertCurve : public FillHilbertCurve
{
public:
Points generate_points(double resolution, double smooth_factor = 0., coord_t max_coordinate = 7)
{
InfillPolylineOutput output(output_scale);
FillParams params;
params.smooth_factor = smooth_factor;
FillHilbertCurve::generate(0, 0, max_coordinate, max_coordinate, resolution, params, output);
return std::move(output.result());
}
};
double path_length(const Points &points)
{
double length = 0.;
for (size_t i = 1; i < points.size(); ++i)
length += (points[i] - points[i - 1]).cast<double>().norm();
return length;
}
double discrete_curvature_at(const Points &points, const Point &point)
{
const auto point_it = std::find(points.begin(), points.end(), point);
REQUIRE(point_it != points.end());
const size_t point_idx = size_t(std::distance(points.begin(), point_it));
REQUIRE(point_idx > 0);
REQUIRE(point_idx + 1 < points.size());
const Vec2d incoming = (points[point_idx] - points[point_idx - 1]).cast<double>() / output_scale;
const Vec2d outgoing = (points[point_idx + 1] - points[point_idx]).cast<double>() / output_scale;
const Vec2d chord = incoming + outgoing;
const double cross = std::abs(incoming.x() * outgoing.y() - incoming.y() * outgoing.x());
return 2. * cross / (incoming.norm() * outgoing.norm() * chord.norm());
}
} // namespace
TEST_CASE("Hilbert curve exposes a smoothing factor", "[FillPlanePath]")
{
const ConfigOptionDef *factor_def = print_config_def.get("sparse_infill_smooth_factor");
REQUIRE(factor_def != nullptr);
REQUIRE(factor_def->type == coPercent);
REQUIRE_THAT(factor_def->min, Catch::Matchers::WithinAbs(0., 1e-12));
REQUIRE_THAT(factor_def->max, Catch::Matchers::WithinAbs(100., 1e-12));
REQUIRE_THAT(factor_def->get_default_value<ConfigOptionPercent>()->value,
Catch::Matchers::WithinAbs(0., 1e-12));
}
TEST_CASE("Hilbert curve smoothing rounds right angle turns", "[FillPlanePath]")
{
const Points sharp = TestableHilbertCurve().generate_points(0.005);
const Points smooth = TestableHilbertCurve().generate_points(0.005, 1.);
REQUIRE(smooth.front() == sharp.front());
REQUIRE(smooth.back() == sharp.back());
REQUIRE(smooth.size() > sharp.size());
bool has_turn = false;
for (size_t i = 1; i < smooth.size(); ++i) {
const Vec2d segment = (smooth[i] - smooth[i - 1]).cast<double>();
REQUIRE(segment.squaredNorm() > 0.);
}
for (size_t i = 1; i + 1 < smooth.size(); ++i) {
const Vec2d incoming = (smooth[i] - smooth[i - 1]).cast<double>();
const Vec2d outgoing = (smooth[i + 1] - smooth[i]).cast<double>();
const double cross = incoming.x() * outgoing.y() - incoming.y() * outgoing.x();
const double cosine = incoming.dot(outgoing) / (incoming.norm() * outgoing.norm());
has_turn |= std::abs(cross) > 0.;
REQUIRE(cosine > 0.);
}
REQUIRE(has_turn);
const coord_t upper_bound = coord_t(7 * output_scale);
for (const Point &point : smooth) {
REQUIRE(point.x() >= 0);
REQUIRE(point.y() >= 0);
REQUIRE(point.x() <= upper_bound);
REQUIRE(point.y() <= upper_bound);
}
}
TEST_CASE("Smoothed Hilbert curve honors path resolution", "[FillPlanePath]")
{
const Points coarse = TestableHilbertCurve().generate_points(0.1, 1.);
const Points fine = TestableHilbertCurve().generate_points(0.001, 1.);
REQUIRE(fine.size() > coarse.size());
REQUIRE(fine.front() == coarse.front());
REQUIRE(fine.back() == coarse.back());
}
TEST_CASE("Smoothed Hilbert corners use a uniform subdivision depth", "[FillPlanePath]")
{
const Points smooth = TestableHilbertCurve().generate_points(0.0035, 1., 1);
const Point curve_entry(0, coord_t(0.5 * output_scale));
const Point curve_exit(coord_t(0.5 * output_scale), coord_t(output_scale));
const auto entry_it = std::find(smooth.begin(), smooth.end(), curve_entry);
REQUIRE(entry_it != smooth.end());
const auto exit_it = std::find(entry_it, smooth.end(), curve_exit);
REQUIRE(exit_it != smooth.end());
const size_t segment_count = size_t(std::distance(entry_it, exit_it));
REQUIRE(segment_count > 1);
REQUIRE((segment_count & (segment_count - 1)) == 0);
double previous_length = (entry_it[1] - entry_it[0]).cast<double>().norm();
REQUIRE(previous_length > 0.);
double max_length_ratio = 1.;
for (size_t segment = 1; segment < segment_count; ++segment) {
const double current_length = (entry_it[segment + 1] - entry_it[segment]).cast<double>().norm();
REQUIRE(current_length > 0.);
max_length_ratio = std::max(max_length_ratio,
std::max(current_length / previous_length, previous_length / current_length));
previous_length = current_length;
}
REQUIRE(max_length_ratio < 1.5);
}
TEST_CASE("Hilbert smoothing joins straight segments with continuous curvature", "[FillPlanePath]")
{
const Points coarse = TestableHilbertCurve().generate_points(0.005, 0.5, 1);
const Points fine = TestableHilbertCurve().generate_points(0.0001, 0.5, 1);
const Point first_curve_entry(0, coord_t(0.75 * output_scale));
const double coarse_entry_curvature = discrete_curvature_at(coarse, first_curve_entry);
const double fine_entry_curvature = discrete_curvature_at(fine, first_curve_entry);
REQUIRE(coarse_entry_curvature > 0.);
REQUIRE(fine_entry_curvature < 0.25 * coarse_entry_curvature);
}
TEST_CASE("Hilbert curve smooth factor controls corner curvature", "[FillPlanePath]")
{
const Points sharp = TestableHilbertCurve().generate_points(0.005);
const Points half_smooth = TestableHilbertCurve().generate_points(0.005, 0.5);
const Points full_smooth = TestableHilbertCurve().generate_points(0.005, 1.);
const Points invalid_factor = TestableHilbertCurve().generate_points(
0.005, std::numeric_limits<double>::quiet_NaN());
REQUIRE(full_smooth.front() == half_smooth.front());
REQUIRE(full_smooth.back() == half_smooth.back());
REQUIRE(path_length(full_smooth) < path_length(half_smooth));
REQUIRE(invalid_factor == sharp);
for (size_t i = 1; i < full_smooth.size(); ++i)
REQUIRE((full_smooth[i] - full_smooth[i - 1]).squaredNorm() > 0);
}
+3 -1
View File
@@ -4,6 +4,8 @@
#include "libslic3r/SLA/Hollowing.hpp"
#include "test_utils.hpp"
TEST_CASE("Hollow two overlapping spheres") {
using namespace Slic3r;
@@ -16,6 +18,6 @@ TEST_CASE("Hollow two overlapping spheres") {
sla::hollow_mesh(sphere1, sla::HollowingConfig{}, sla::HollowingFlags::hfRemoveInsideTriangles);
sphere1.WriteOBJFile("twospheres.obj");
write_debug_obj("hollowing/twospheres.obj", sphere1);
}
@@ -5,6 +5,8 @@
#include "libslic3r/TriangleMesh.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
TEST_CASE("Split empty mesh", "[its_split][its]") {
@@ -29,13 +31,15 @@ TEST_CASE("Split simple mesh consisting of one part", "[its_split][its]") {
REQUIRE(res.front().vertices.size() == cube.vertices.size());
}
// Dump each split part as its own OBJ for eyeballing; no-op in release.
void debug_write_obj(const std::vector<indexed_triangle_set> &res, const std::string &name)
{
#ifndef NDEBUG
size_t part_idx = 0;
for (auto &part_its : res) {
its_write_obj(part_its, (name + std::to_string(part_idx++) + ".obj").c_str());
}
for (const auto &part_its : res)
write_debug_obj("indexed_triangle_set/" + name + std::to_string(part_idx++) + ".obj", part_its);
#else
(void) res; (void) name;
#endif
}
@@ -260,7 +264,6 @@ TEST_CASE("Reduce one edge by Quadric Edge Collapse", "[its]")
CHECK(is_similar(its_, its, cfg));
}
#include "test_utils.hpp"
TEST_CASE("Simplify mesh by Quadric edge collapse to 5%", "[its]")
{
TriangleMesh mesh = load_model("frog_legs.obj");
+21 -29
View File
@@ -191,22 +191,21 @@ static void test_expolys(Rst&& rst, const ExPolygons& ref, Vec2i32 window, const
for (const ExPolygon& expoly : ref)
rst.draw(expoly);
std::fstream out(name + ".png", std::ios::out);
out << rst.encode(sla::PNGRasterEncoder{});
out.close();
write_debug_stream("marchingsquares/" + name + ".png",
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
const ExPolygons bmp = rstGetPolys(rst);
const ExPolygons ext = sla::raster_to_polygons(rst, window);
SVG svg(name + ".svg", raster_bb);
svg.draw(bmp, "green");
if (pixel_size.x() >= scale_(0.5))
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
if (window_size.x() >= scale_(1.0))
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
svg.draw_outline(ref, "red", "red", scale_(0.3));
svg.draw_outline(ext, "blue", "blue");
svg.Close();
write_debug_svg("marchingsquares/" + name + ".svg", raster_bb, [&](SVG &svg) {
svg.draw(bmp, "green");
if (pixel_size.x() >= scale_(0.5))
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
if (window_size.x() >= scale_(1.0))
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
svg.draw_outline(ref, "red", "red", scale_(0.3));
svg.draw_outline(ext, "blue", "blue");
});
// Note all these areas are unscaled back to mm^2.
double raster_area = unscaled(unscaled(area(bmp)));
@@ -432,9 +431,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
double disp_w = 120.96;
double disp_h = 68.04;
#ifndef NDEBUG
size_t cntr = 0;
#endif
for (ExPolygons& layer : layers) {
auto rst = create_raster(res, disp_w, disp_h);
@@ -442,11 +439,8 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
rst.draw(island);
}
#ifndef NDEBUG
std::fstream out(objname + std::to_string(cntr) + ".png", std::ios::out);
out << rst.encode(sla::PNGRasterEncoder{});
out.close();
#endif
write_debug_stream("marchingsquares/" + objname + std::to_string(cntr) + ".png",
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
ExPolygons layer_ = sla::raster_to_polygons(rst);
// float delta = scaled(std::min(rst.pixel_dimensions().h_mm,
@@ -454,21 +448,19 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
// layer_ = expolygons_simplify(layer_, delta);
#ifndef NDEBUG
SVG svg(objname + std::to_string(cntr) + ".svg", rstBBox(rst));
svg.draw(layer_);
svg.draw(layer, "green");
svg.Close();
#endif
write_debug_svg("marchingsquares/" + objname + std::to_string(cntr) + ".svg", rstBBox(rst),
[&](SVG &svg) {
svg.draw(layer_);
svg.draw(layer, "green");
});
double layera = 0., layera_ = 0.;
for (auto& p : layer)
layera += p.area();
for (auto& p : layer_)
layera_ += p.area();
#ifndef NDEBUG
std::cout << cntr++ << std::endl;
#endif
++cntr;
double diff = std::abs(layera_ - layera);
REQUIRE((diff <= 0.1 * layera || diff < scaled<double>(1.) * scaled<double>(1.)));
@@ -477,7 +469,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
indexed_triangle_set out = slices_to_mesh(layers, bb.min.z(), double(lh), double(lh));
its_write_obj(out, "out_from_rasters.obj");
write_debug_obj("marchingsquares/out_from_rasters.obj", out);
}
TEST_CASE("Recreate object from rasters", "[SL1Import]") { recreate_object_from_rasters("frog_legs.obj", 0.05f); }
+40
View File
@@ -0,0 +1,40 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Model.hpp"
using namespace Slic3r;
// convex_hull_2d does not clip geometry below the bed, so these cases avoid
// sinking transforms.
TEST_CASE("A part's 2D convex hull is its footprint projected onto the bed", "[Model]")
{
Model model;
ModelObject* object = model.add_object();
// Keep the cube's raw coordinates ([0,20] on every axis): the default
// add_volume re-centers the geometry, which would move the footprint.
object->add_volume(make_cube(20, 20, 20), ModelVolumeType::MODEL_PART, false);
SECTION("identity transform yields the 20 mm square") {
const Polygon hull = object->convex_hull_2d(Geometry::Transformation{}.get_matrix());
const BoundingBox bb = hull.bounding_box();
CHECK(hull.size() == 4);
CHECK(bb.min.x() == scaled(0.));
CHECK(bb.min.y() == scaled(0.));
CHECK(bb.max.x() == scaled(20.));
CHECK(bb.max.y() == scaled(20.));
}
SECTION("scaling and offset move and grow the footprint") {
Geometry::Transformation t;
t.set_scaling_factor({2, 2, 2}); // cube now spans [0,40]
t.set_offset({10, 5, 0}); // then shift +10 in X, +5 in Y
const Polygon hull = object->convex_hull_2d(t.get_matrix());
const BoundingBox bb = hull.bounding_box();
CHECK(hull.size() == 4);
CHECK(bb.min.x() == scaled(10.));
CHECK(bb.min.y() == scaled(5.));
CHECK(bb.max.x() == scaled(50.));
CHECK(bb.max.y() == scaled(45.));
}
}
@@ -0,0 +1,297 @@
#include <catch2/catch_all.hpp>
#define SLIC3R_TEST_HARNESS
#include "libslic3r/Point.hpp"
#include "libslic3r/GCode/OrderingStrategies.hpp"
#include "libslic3r/Geometry.hpp"
#include <algorithm>
#include <unordered_set>
using namespace Slic3r;
// --- Helpers ---
static double euclidean_path_length(const std::vector<size_t>& path, const Points& centers)
{
return tsp_cycle_path_length(path, centers);
}
static bool has_crossings(const std::vector<size_t>& path, const Points& centers)
{
size_t pn = path.size();
if (pn < 4) return false;
for (size_t i = 0; i < pn; ++i) {
size_t i_next = (i + 1) % pn;
for (size_t j = i + 2; j < pn; ++j) {
if (j == i_next) continue;
if (j == (pn - 1) && i == 0) continue;
size_t j_next = (j + 1) % pn;
if (Geometry::segments_intersect(
centers[path[i]], centers[path[i_next]],
centers[path[j]], centers[path[j_next]])) {
return true;
}
}
}
return false;
}
static bool is_permutation(const std::vector<size_t>& path, size_t n)
{
if (path.size() != n) return false;
std::unordered_set<size_t> seen(path.begin(), path.end());
for (size_t i = 0; i < n; ++i) {
if (seen.count(i) != 1) return false;
}
return true;
}
// --- Test fixtures ---
static Points make_grid_4x4()
{
Points pts;
for (int row = 0; row < 4; ++row)
for (int col = 0; col < 4; ++col)
pts.emplace_back(100000 * col, 100000 * row);
return pts;
}
static Points make_linear_5()
{
Points pts;
for (int i = 0; i < 5; ++i)
pts.emplace_back(100000 * i, 0);
return pts;
}
static Points make_ring_8()
{
Points pts;
constexpr double R = 100000.0;
for (int i = 0; i < 8; ++i) {
double angle = 2.0 * M_PI * i / 8.0;
pts.emplace_back(static_cast<coord_t>(R * std::cos(angle)),
static_cast<coord_t>(R * std::sin(angle)));
}
return pts;
}
static Points make_random_16()
{
// Deterministic "random" points via simple hash.
Points pts;
for (int i = 0; i < 16; ++i) {
uint32_t h = static_cast<uint32_t>(i * 2654435761u);
coord_t x = static_cast<coord_t>((h >> 16) & 0xFFFF) * 10;
coord_t y = static_cast<coord_t>(h & 0xFFFF) * 10;
pts.emplace_back(x, y);
}
return pts;
}
// --- TSP Post-Processing Tests ---
TEST_CASE("tsp_2opt_improve reduces path length", "[TSPPostProcessing]") {
Points centers = make_random_16();
std::vector<size_t> path(centers.size());
// Reverse half the path to create a deliberately bad ordering.
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
std::reverse(path.begin(), path.end() - path.size() / 2);
double before = euclidean_path_length(path, centers);
tsp_2opt_improve(path, centers);
double after = euclidean_path_length(path, centers);
REQUIRE(is_permutation(path, centers.size()));
CHECK(after <= before);
}
TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") {
Points centers = make_random_16();
std::vector<size_t> path(centers.size());
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
// Create a crossing by reversing a middle segment.
if (path.size() >= 4) {
std::reverse(path.begin() + 1, path.end() - 1);
}
tsp_remove_crossings(path, centers);
CHECK(!has_crossings(path, centers));
REQUIRE(is_permutation(path, centers.size()));
}
TEST_CASE("tsp_rotate_minimize_closing shortens closing edge", "[TSPPostProcessing]") {
Points centers = make_random_16();
std::vector<size_t> path(centers.size());
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
// Compute all possible closing edge lengths.
size_t pn = path.size();
double min_closing2 = std::numeric_limits<double>::max();
for (size_t start = 0; start < pn; ++start) {
size_t last = (start + pn - 1) % pn;
double d2 = (centers[path[start]].cast<double>() - centers[path[last]].cast<double>()).squaredNorm();
if (d2 < min_closing2) min_closing2 = d2;
}
tsp_rotate_minimize_closing(path, centers);
// Closing edge should be the minimum possible.
double actual_closing2 = (centers[path.front()].cast<double>() - centers[path.back()].cast<double>()).squaredNorm();
CHECK(actual_closing2 == min_closing2);
REQUIRE(is_permutation(path, centers.size()));
}
TEST_CASE("tsp_cycle_path_length is correct for triangle", "[TSPPostProcessing]") {
Points pts;
pts.emplace_back(0, 0);
pts.emplace_back(100000, 0);
pts.emplace_back(50000, 86602); // equilateral ~100mm sides
std::vector<size_t> path = {0, 1, 2};
double len = tsp_cycle_path_length(path, pts);
// Perimeter of equilateral triangle with side ~100000.
REQUIRE(len > 290000);
REQUIRE(len < 310000);
}
TEST_CASE("tsp_max_edge_length finds longest edge", "[TSPPostProcessing]") {
Points pts;
pts.emplace_back(0, 0);
pts.emplace_back(100000, 0);
pts.emplace_back(50000, 0);
std::vector<size_t> path = {0, 1, 2};
double mx = tsp_max_edge_length(path, pts);
// Longest edge is 0->1 = 100000.
CHECK(mx == Catch::Approx(100000).margin(1));
}
// --- Core Strategy Tests: Empty / Small Inputs ---
TEST_CASE("snake_core handles empty input", "[Snake]") {
Points centers;
auto path = snake_core(centers);
REQUIRE(path.empty());
}
TEST_CASE("snake_core handles single point", "[Snake]") {
Points pts{{100, 200}};
CHECK(snake_core(pts) == std::vector<size_t>{0});
}
TEST_CASE("snake_core handles two points", "[Snake]") {
Points pts{{100, 200}, {300, 400}};
auto p2 = snake_core(pts);
REQUIRE(is_permutation(p2, 2));
}
// --- Core Strategy Tests: Grid Layout ---
TEST_CASE("snake produces good path on grid", "[Snake]") {
Points centers = make_grid_4x4();
auto path = snake_core(centers);
REQUIRE(is_permutation(path, centers.size()));
CHECK(!has_crossings(path, centers));
}
// --- Core Strategy Tests: Variable Row Spacing ---
TEST_CASE("snake handles variable Y spacing", "[Snake]") {
// Rows at Y = 0, 50, 100, 1000 (large gap between last two rows).
// The adaptive row detection should identify the tight cluster (0, 50, 100)
// and the isolated row (1000) without splitting them incorrectly.
Points pts;
pts.emplace_back(0, 0); pts.emplace_back(100000, 0);
pts.emplace_back(0, 50000); pts.emplace_back(100000, 50000);
pts.emplace_back(0, 100000); pts.emplace_back(100000, 100000);
pts.emplace_back(0, 1000000); pts.emplace_back(100000, 1000000);
auto path = snake_core(pts);
REQUIRE(is_permutation(path, pts.size()));
CHECK(!has_crossings(path, pts));
}
// --- Core Strategy Tests: All Points Same Y ---
TEST_CASE("snake handles all points on same Y", "[Snake]") {
// All points share the same Y coordinate. This exercises the
// division-by-zero guard (ys.size() == 1).
Points pts;
for (int i = 0; i < 6; ++i)
pts.emplace_back(100000 * i, 50000);
auto path = snake_core(pts);
REQUIRE(is_permutation(path, pts.size()));
}
// --- Core Strategy Tests: Collinear Points ---
TEST_CASE("snake_core handles collinear points", "[Snake]") {
Points centers = make_linear_5();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Core Strategy Tests: Ring Layout ---
TEST_CASE("snake_core produces valid paths on ring", "[Snake]") {
Points centers = make_ring_8();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Core Strategy Tests: Random Layout ---
TEST_CASE("snake_core produces valid paths on random input", "[Snake]") {
Points centers = make_random_16();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Quality Comparison Tests ---
TEST_CASE("snake has no crossings on random input", "[Snake]") {
Points centers = make_random_16();
auto path = snake_core(centers);
REQUIRE(is_permutation(path, centers.size()));
CHECK(!has_crossings(path, centers));
}
// --- Edge Cases ---
TEST_CASE("snake_core handles duplicate points", "[Snake]") {
Points pts;
pts.emplace_back(100, 200);
pts.emplace_back(100, 200); // duplicate
pts.emplace_back(300, 400);
auto p2 = snake_core(pts);
REQUIRE(p2.size() == pts.size());
}
TEST_CASE("snake_core handles three points", "[Snake]") {
Points pts;
pts.emplace_back(0, 0);
pts.emplace_back(100000, 0);
pts.emplace_back(50000, 86602);
auto p2 = snake_core(pts);
REQUIRE(is_permutation(p2, 3));
}
+108 -32
View File
@@ -5,28 +5,14 @@
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/AppConfig.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
namespace {
namespace fs = boost::filesystem;
struct TempPresetDir {
fs::path path;
TempPresetDir()
{
path = fs::temp_directory_path() / fs::unique_path("orcaslicer-preset-%%%%-%%%%-%%%%");
fs::create_directories(path);
}
~TempPresetDir()
{
boost::system::error_code ec;
fs::remove_all(path, ec);
}
};
void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {})
{
DynamicPrintConfig config(default_config);
@@ -82,17 +68,17 @@ struct RenameTestCollection : public PresetCollection
TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
PresetsConfigSubstitutions substitutions;
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_PRINT_NAME / "User.json", "User");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "User.json", "User");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
bundle.prints.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
const Preset *root_user = bundle.prints.find_preset("User");
REQUIRE(root_user != nullptr);
@@ -112,14 +98,14 @@ TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]
TEST_CASE("Legacy bundle import without bundle metadata stays in the user preset directory", "[Preset][Identity]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
PresetsConfigSubstitutions substitutions;
std::vector<std::string> result;
int overwrite = 0;
std::string file = (temp_dir.path / "legacy-bundle" / "Imported.json").string();
const fs::path user_root = temp_dir.path / "user";
std::string file = (temp_dir.path() / "legacy-bundle" / "Imported.json").string();
const fs::path user_root = temp_dir.path() / "user";
write_print_preset(bundle.prints.default_preset().config, file, "Imported");
fs::create_directories(user_root);
@@ -252,7 +238,7 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
RenameTestCollection coll;
// Current parent, renamed from "Old Process".
@@ -262,10 +248,10 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
// A user preset on disk that still inherits the OLD name.
write_preset_with_inherits(coll.default_preset().config,
temp_dir.path / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
temp_dir.path() / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
PresetsConfigSubstitutions substitutions;
coll.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions,
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::Disable);
const Preset *child = coll.find_preset("Child");
@@ -279,17 +265,17 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
TEST_CASE("Removed Generic parent is normalized into a loaded filament's inherits", "[Preset][Rename]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
add_inmemory_preset(bundle.filaments, "Generic PLA @System");
// A user filament that still inherits a removed "<vendor> Generic PLA" profile.
write_preset_with_inherits(bundle.filaments.default_preset().config,
temp_dir.path / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
temp_dir.path() / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
PresetsConfigSubstitutions substitutions;
bundle.filaments.load_presets(temp_dir.path.string(), PRESET_FILAMENT_NAME, substitutions,
bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::Disable);
const Preset *child = bundle.filaments.find_preset("MyPLA");
@@ -464,3 +450,93 @@ TEST_CASE("Profile validator flags dangling and renamed preset references", "[Pr
}
}
// Under a shared override key, the last preset merged into the full config overwrote the others', so an
// edited slicing-pipeline override never reached Print::apply's diff and re-configuring a plugin never
// re-sliced. Per-type keys make that collision impossible; guard the scoping here.
TEST_CASE("Plugin capability override keys are scoped per preset type", "[Preset][Plugin]")
{
// Pin the key names: presets and 3mf files store them verbatim, so a rename is a format change.
CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINT) == std::string("print_plugin_config_overrides"));
CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINTER) == std::string("printer_plugin_config_overrides"));
CHECK(Preset::plugin_overrides_key(Preset::TYPE_FILAMENT) == std::string("filament_plugin_config_overrides"));
// ...and each key lives on exactly its own preset type's option list, so no two ever share a slot.
const std::pair<Preset::Type, const std::vector<std::string>*> scopes[] = {
{Preset::TYPE_PRINT, &Preset::print_options()},
{Preset::TYPE_PRINTER, &Preset::printer_options()},
{Preset::TYPE_FILAMENT, &Preset::filament_options()},
};
for (const auto &owner : scopes)
for (const auto &scoped : scopes) {
const std::string key = Preset::plugin_overrides_key(scoped.first);
CAPTURE(owner.first, key);
CHECK(contains(*owner.second, key) == (owner.first == scoped.first));
}
}
namespace {
// A standalone filament collection that exposes the protected library masking builder, so the Orca
// Filament Library scenario can be set up without the full system-profile load pipeline.
struct LibraryFilamentTestCollection : public PresetCollection
{
LibraryFilamentTestCollection()
: PresetCollection(Preset::TYPE_FILAMENT, Preset::filament_options(),
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()))
{}
using PresetCollection::update_library_profile_excluded_from;
};
} // namespace
// Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic
// library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible
// with that printer and the plater combo box lists the shared alias twice.
TEST_CASE("A printer specific filament supersedes the generic library filament with the same alias", "[Preset][Bundle]")
{
LibraryFilamentTestCollection filaments;
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
// The masking keys off the vendor name, which VendorProfile's constructor does not derive from the id.
VendorProfile library(PresetBundle::ORCA_FILAMENT_LIBRARY);
VendorProfile vendor("Vendor");
library.name = PresetBundle::ORCA_FILAMENT_LIBRARY;
vendor.name = "Vendor";
auto add_filament = [&filaments](const VendorProfile &owner, const std::string &name, std::vector<std::string> compatible_printers) {
Preset &preset = add_inmemory_preset(filaments, name);
preset.alias = "Generic ABS";
preset.vendor = &owner;
preset.config.option<ConfigOptionStrings>("compatible_printers", true)->values = std::move(compatible_printers);
};
add_filament(library, "Generic ABS @System", {});
add_filament(library, "Generic ABS @Printer A", { "Printer A" });
add_filament(vendor, "Generic ABS @Printer B", { "Printer B" });
filaments.update_library_profile_excluded_from();
const Preset *generic = filaments.find_preset("Generic ABS @System");
REQUIRE(generic != nullptr);
CHECK(generic->m_excluded_from.count("Printer A") == 1);
CHECK(generic->m_excluded_from.count("Printer B") == 1);
CHECK(generic->m_excluded_from.size() == 2);
// A printer specific profile names printers, so it is never the one being hidden - not even by itself.
const Preset *specific = filaments.find_preset("Generic ABS @Printer A");
REQUIRE(specific != nullptr);
CHECK(specific->m_excluded_from.empty());
// ...and the generic profile really drops out of the compatible set on the printer it is hidden from.
add_inmemory_preset(printers, "Printer A");
add_inmemory_preset(printers, "Printer C");
const Preset *printer_a = printers.find_preset("Printer A");
const Preset *printer_c = printers.find_preset("Printer C");
REQUIRE(printer_a != nullptr);
REQUIRE(printer_c != nullptr);
const PresetWithVendorProfile generic_lib(*generic, &library);
CHECK_FALSE(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_a, nullptr)));
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
}
@@ -8,6 +8,8 @@
#include "libslic3r/Print.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <map>
#include <set>
@@ -500,6 +502,52 @@ TEST_CASE("Re-applying an unchanged config after slicing keeps the result valid"
REQUIRE(print.is_step_done(psSlicingFinished));
}
TEST_CASE("A degenerate process variant map on a custom multi-extruder printer slices to a stable result", "[Print][Regression]")
{
// Non-BBL multi-extruder printers get machine-scope variant columns synthesized on preset
// load (extend_extruder_variant), but nothing ships process-scope print_extruder_id /
// print_extruder_variant: presets and 3mf project configs carry the length-1 defaults. The
// apply-time expansion must synthesize the process columns from extruder_variant_list;
// otherwise the failed per-extruder lookups collapse the per-extruder retract overrides
// during slicing and the post-slice re-apply invalidates every fresh result, forever.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_num_extruders(5);
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4, 0.4, 0.4, 0.4};
// per-extruder machine values that a first-slot collapse would destroy
config.option<ConfigOptionPercents>("retract_before_wipe", true)->values = {100., 70., 70., 70., 100.};
config.option<ConfigOptionEnumsGeneric>("z_hop_types", true)->values = {zhtSlope, zhtNormal, zhtNormal, zhtNormal, zhtSlope};
// filament presets carry the nullable override twins (all-nil = "no override"); they are what
// routes the machine values through apply_override in the in-slice override recompute
config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values =
std::vector<double>(5, ConfigOptionPercentsNullable::nil_value());
config.option<ConfigOptionEnumsGenericNullable>("filament_z_hop_types", true)->values =
std::vector<int>(5, ConfigOptionEnumsGenericNullable::nil_value());
config.option<ConfigOptionFloats>("filament_diameter", true)->values = std::vector<double>(5, 1.75);
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#00FFFF"};
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 3, 4, 1};
Model model;
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance()->set_offset(Vec3d(100., 100., 0.));
Print print;
print.apply(model, config);
print.process();
REQUIRE(print.is_step_done(psSlicingFinished));
// BackgroundSlicingProcess reads the engine-computed maps back into the plate config after
// slicing; the next apply overlays that written-back state.
config.option<ConfigOptionInts>("filament_map", true)->values = print.get_filament_maps();
config.option<ConfigOptionInts>("filament_volume_map", true)->values = print.get_filament_volume_maps();
config.option<ConfigOptionInts>("filament_nozzle_map", true)->values = print.get_filament_nozzle_maps();
auto status = print.apply(model, config);
REQUIRE(status == PrintBase::APPLY_STATUS_UNCHANGED);
REQUIRE(print.is_step_done(psSlicingFinished));
// the per-extruder machine values must survive the in-slice override recompute
REQUIRE(print.config().retract_before_wipe.values == std::vector<double>({100., 70., 70., 70., 100.}));
REQUIRE(print.config().z_hop_types.values == std::vector<int>({zhtSlope, zhtNormal, zhtNormal, zhtNormal, zhtSlope}));
}
TEST_CASE("normalize_nozzle_map_per_layer makes per-filament assignments gap-free", "[MultiNozzle][H2C][Dynamic]")
{
SECTION("gaps inherit the last used nozzle, entries on used layers stay untouched") {
@@ -662,10 +710,9 @@ TEST_CASE("Sequential selector prints publish a stitched result and cache the pl
REQUIRE(print.config().filament_self_index.values.size() >= print.config().filament_map.values.size());
// Export must consume the cached plans and produce g-code without throwing.
boost::filesystem::path gcode_path = boost::filesystem::temp_directory_path() / "orca_seq_dynamic_publish_test.gcode";
REQUIRE_NOTHROW(print.export_gcode(gcode_path.string(), nullptr, nullptr));
REQUIRE(boost::filesystem::exists(gcode_path));
boost::filesystem::remove(gcode_path);
ScopedTemporaryFile gcode(".gcode");
REQUIRE_NOTHROW(print.export_gcode(gcode.string(), nullptr, nullptr));
REQUIRE(boost::filesystem::exists(gcode.path()));
}
TEST_CASE("Per-variant expansion gives migrating filaments one slot per variant", "[PrintConfig][H2C][Dynamic]")
+1 -1
View File
@@ -229,7 +229,7 @@ TEST_CASE("halfcone test", "[halfcone]") {
indexed_triangle_set m = sla::get_mesh(br, 45);
its_merge_vertices(m);
its_write_obj(m, "Halfcone.obj");
write_debug_obj("sla_print/Halfcone.obj", m);
}
TEST_CASE("Test concurrency")
+5 -5
View File
@@ -13,7 +13,7 @@ TEST_CASE("Overhanging point should be supported", "[SupGen]") {
// Pyramid with 45 deg slope
TriangleMesh mesh = make_pyramid(10.f, 10.f);
mesh.rotate_y(float(PI));
mesh.WriteOBJFile("Pyramid.obj");
write_debug_obj("sla_supptgen/Pyramid.obj", mesh);
sla::SupportPoints pts = calc_support_pts(mesh);
@@ -55,7 +55,7 @@ TEST_CASE("Overhanging horizontal surface should be supported", "[SupGen]") {
TriangleMesh mesh = make_cube(width, depth, height);
mesh.translate(0., 0., 5.); // lift up
mesh.WriteOBJFile("Cuboid.obj");
write_debug_obj("sla_supptgen/Cuboid.obj", mesh);
sla::SupportPointGenerator::Config cfg;
sla::SupportPoints pts = calc_support_pts(mesh, cfg);
@@ -81,7 +81,7 @@ TEST_CASE("Overhanging edge should be supported", "[SupGen]") {
TriangleMesh mesh = make_prism(width, depth, height);
mesh.rotate_y(float(PI)); // rotate on its back
mesh.translate(0., 0., height);
mesh.WriteOBJFile("Prism.obj");
write_debug_obj("sla_supptgen/Prism.obj", mesh);
sla::SupportPointGenerator::Config cfg;
sla::SupportPoints pts = calc_support_pts(mesh, cfg);
@@ -106,7 +106,7 @@ TEST_CASE("Hollowed cube should be supported from the inside", "[SupGen][Hollowe
hollow_mesh(mesh, HollowingConfig{});
mesh.WriteOBJFile("cube_hollowed.obj");
write_debug_obj("sla_supptgen/cube_hollowed.obj", mesh);
auto bb = mesh.bounding_box();
auto h = float(bb.max.z() - bb.min.z());
@@ -129,7 +129,7 @@ TEST_CASE("Two parallel plates should be supported", "[SupGen][Hollowed]")
mesh_high.translate(0., 0., 10.); // lift up
mesh.merge(mesh_high);
mesh.WriteOBJFile("parallel_plates.obj");
write_debug_obj("sla_supptgen/parallel_plates.obj", mesh);
sla::SupportPointGenerator::Config cfg;
sla::SupportPoints pts = calc_support_pts(mesh, cfg);
+15 -16
View File
@@ -47,8 +47,9 @@ void test_support_model_collision(const std::string &obj_filename,
notouch = notouch && area(intersections) < PI * pinhead_r * pinhead_r;
}
/*if (!notouch) */export_failed_case(support_slices, byproducts);
if (!notouch)
export_failed_case(support_slices, byproducts);
REQUIRE(notouch);
}
@@ -62,11 +63,11 @@ void export_failed_case(const std::vector<ExPolygons> &support_slices, const Sup
std::stringstream ss;
if (!intersections.empty()) {
ss << byproducts.obj_fname << std::setprecision(4) << n << ".svg";
SVG svg(ss.str());
svg.draw(sup_slice, "green");
svg.draw(mod_slice, "blue");
svg.draw(intersections, "red");
svg.Close();
write_debug_svg("sla/" + ss.str(), [&](SVG &svg) {
svg.draw(sup_slice, "green");
svg.draw(mod_slice, "blue");
svg.draw(intersections, "red");
});
}
}
@@ -74,8 +75,8 @@ void export_failed_case(const std::vector<ExPolygons> &support_slices, const Sup
byproducts.supporttree.retrieve_full_mesh(its);
TriangleMesh m{its};
m.merge(byproducts.input_mesh);
m.WriteOBJFile((Catch::getResultCapture().getCurrentTestName() + "_" +
byproducts.obj_fname).c_str());
write_debug_obj("sla/" + Catch::getResultCapture().getCurrentTestName() +
"_" + byproducts.obj_fname, m);
}
void test_supports(const std::string &obj_filename,
@@ -350,13 +351,11 @@ void check_raster_transformations(sla::RasterBase::Orientation o, sla::RasterBas
REQUIRE((w < res.width_px && h < res.height_px));
auto px = raster.read_pixel(w, h);
if (px != FullWhite) {
std::fstream outf("out.png", std::ios::out);
outf << raster.encode(sla::PNGRasterEncoder());
}
if (px != FullWhite)
write_debug_stream("sla/raster_transform_mismatch.png",
[&] { return raster.encode(sla::PNGRasterEncoder()); });
REQUIRE(px == FullWhite);
}
+9 -11
View File
@@ -6,6 +6,8 @@
#include <string>
#include "test_utils.hpp"
namespace Slic3r {
// Point data_dir() at a throwaway directory for the lifetime of a test and
@@ -13,24 +15,20 @@ namespace Slic3r {
// disposable tree and tests don't leak state into each other.
struct ScopedDataDir
{
ScopedTemporaryDir tmp; // owns the temp dir (create + recursive remove)
boost::filesystem::path dir; // = tmp.path(); kept as a member for callers
std::string previous;
boost::filesystem::path dir;
explicit ScopedDataDir(const std::string& tag)
: tmp("orca-" + tag), dir(tmp.path()), previous(data_dir())
{
namespace fs = boost::filesystem;
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
boost::filesystem::remove_all(dir, ec);
}
~ScopedDataDir() { set_data_dir(previous); } // tmp removes the directory
// The plugin manager scans {data_dir}/orca_plugins.
boost::filesystem::path plugins_dir() const { return dir / "orca_plugins"; }
ScopedDataDir(const ScopedDataDir&) = delete;
ScopedDataDir& operator=(const ScopedDataDir&) = delete;
+58 -19
View File
@@ -6,6 +6,8 @@
#include "libslic3r/Utils.hpp"
#include "slic3r/Utils/bambu_networking.hpp"
#include "plugin_test_utils.hpp"
using namespace Slic3r;
namespace fs = boost::filesystem;
@@ -25,27 +27,16 @@ static const char* PLUGIN_EXT = ".so";
struct PluginFolderFixture
{
fs::path root;
std::string previous_data_dir;
ScopedDataDir data{"netver"};
PluginFolderFixture()
{
previous_data_dir = data_dir();
root = fs::temp_directory_path() / fs::unique_path("orca-netver-%%%%%%%%");
fs::create_directories(root / "plugins");
set_data_dir(root.string());
}
~PluginFolderFixture()
{
set_data_dir(previous_data_dir);
boost::system::error_code ec;
fs::remove_all(root, ec);
fs::create_directories(data.dir / "plugins");
}
void add_plugin(const std::string& version)
{
boost::nowide::ofstream f((root / "plugins" / (PLUGIN_PREFIX + version + PLUGIN_EXT)).string());
boost::nowide::ofstream f((data.dir / "plugins" / (PLUGIN_PREFIX + version + PLUGIN_EXT)).string());
f << "stub";
}
};
@@ -85,7 +76,8 @@ TEST_CASE_METHOD(PluginFolderFixture, "Managed builds fold into the series; cust
{
add_plugin("02.08.01.55"); // managed, same series -> folded into the 02.08.01 row
add_plugin("02.09.00.10"); // managed, unknown series -> not listed
add_plugin("02.03.00.62"); // managed, series no longer whitelisted -> not listed
add_plugin("02.03.00.62"); // managed, older whitelisted series -> folded into 02.03.00
add_plugin("02.01.01.52"); // managed, series with no ABI in this build -> not listed
add_plugin("02.08.01_custom"); // custom, whitelisted series -> listed under it
add_plugin("02.08.01.52-dev"); // custom (dash-suffixed), whitelisted series -> listed
@@ -96,17 +88,24 @@ TEST_CASE_METHOD(PluginFolderFixture, "Managed builds fold into the series; cust
REQUIRE(count_version(versions, "02.08.01") == 1);
REQUIRE(count_version(versions, "02.09.00.10") == 0);
REQUIRE(count_version(versions, "02.03.00.62") == 0);
REQUIRE(count_version(versions, "02.03.00") == 1);
REQUIRE(count_version(versions, "02.01.01.52") == 0);
// Custom-named builds are distinct files kept under their own name.
REQUIRE(count_version(versions, "02.08.01_custom") == 1);
REQUIRE(count_version(versions, "02.08.01.52-dev") == 1);
// Newest series first, its customs nested under it (suffix sort: "" < ".52-dev" < "_custom"),
// legacy last.
// then older series, legacy last.
REQUIRE(versions[0].version == "02.08.01");
REQUIRE(versions[1].version == "02.08.01.52-dev");
REQUIRE(versions[2].version == "02.08.01_custom");
REQUIRE(versions[3].version == "02.03.00");
REQUIRE(versions.back().version == BAMBU_NETWORK_AGENT_VERSION_LEGACY);
// An older whitelisted series is a flat row of its own, and never holds "(Latest)".
REQUIRE(versions[3].suffix.empty());
REQUIRE_FALSE(versions[3].is_latest);
// Customs sort/render nested under their series (non-empty suffix, base = the series).
REQUIRE(versions[1].base_version == "02.08.01");
REQUIRE_FALSE(versions[1].suffix.empty());
@@ -137,6 +136,16 @@ TEST_CASE_METHOD(PluginFolderFixture, "Only the loaded series is marked installe
REQUIRE(marked == 1);
}
// An older series is marked the same way, and never bleeds onto the latest row.
{
add_plugin("02.03.00.62");
auto versions = get_all_available_versions("02.03.00.62");
int marked = 0;
for (const auto& info : versions)
if (info.is_loaded) { ++marked; REQUIRE(info.version == "02.03.00"); }
REQUIRE(marked == 1);
}
// A loaded custom build matches its own row, never the bare series.
{
auto versions = get_all_available_versions("02.08.01_custom");
@@ -153,19 +162,25 @@ TEST_CASE_METHOD(PluginFolderFixture, "Only the loaded series is marked installe
TEST_CASE("Only whitelisted series pass the load gate", "[NetworkVersions]")
{
// The whitelisted series, its builds, and custom-named builds of that series.
// Each whitelisted series, its builds, and custom-named builds of that series.
REQUIRE(is_supported_network_version("02.08.01"));
REQUIRE(is_supported_network_version("02.08.01.52"));
REQUIRE(is_supported_network_version("02.08.01.55"));
REQUIRE(is_supported_network_version("02.08.01_custom"));
REQUIRE(is_supported_network_version("02.08.01.52-dev"));
REQUIRE(is_supported_network_version("02.03.00"));
REQUIRE(is_supported_network_version("02.03.00.62"));
REQUIRE(is_supported_network_version("02.03.00.70"));
REQUIRE(is_supported_network_version("02.03.00_custom"));
REQUIRE(is_supported_network_version(BAMBU_NETWORK_AGENT_VERSION_LEGACY));
// Series whitelisted by previous Orca releases - their ABI no longer matches.
REQUIRE_FALSE(is_supported_network_version("02.03.00.62"));
// Series whitelisted by previous Orca releases that no generation here can call.
REQUIRE_FALSE(is_supported_network_version("02.01.01.52"));
REQUIRE_FALSE(is_supported_network_version("02.00.02.50"));
// A neighbouring series of a whitelisted one is still its own ABI.
REQUIRE_FALSE(is_supported_network_version("02.03.01.51"));
// Unknown series, legacy siblings, and malformed values.
REQUIRE_FALSE(is_supported_network_version("02.09.00.10"));
std::string legacy = BAMBU_NETWORK_AGENT_VERSION_LEGACY;
@@ -175,6 +190,30 @@ TEST_CASE("Only whitelisted series pass the load gate", "[NetworkVersions]")
REQUIRE_FALSE(is_supported_network_version("02.08"));
}
TEST_CASE("Each version resolves to the ABI generation that can call it", "[NetworkVersions]")
{
// The generation is keyed on the series, so every build of a series - including the
// custom-named ones - resolves to the same one.
CHECK(network_plugin_abi("02.08.01") == NetworkAbi::Current);
CHECK(network_plugin_abi("02.08.01.55") == NetworkAbi::Current);
CHECK(network_plugin_abi("02.08.01.52-dev") == NetworkAbi::Current);
CHECK(network_plugin_abi("02.03.00") == NetworkAbi::V0203);
CHECK(network_plugin_abi("02.03.00.62") == NetworkAbi::V0203);
CHECK(network_plugin_abi("02.03.00_custom") == NetworkAbi::V0203);
CHECK(network_plugin_abi(BAMBU_NETWORK_AGENT_VERSION_LEGACY) == NetworkAbi::Legacy);
// Anything the load gate rejects must dispatch through nothing at all, rather than
// defaulting to a layout it does not share.
CHECK(network_plugin_abi("02.01.01.52") == NetworkAbi::Unsupported);
CHECK(network_plugin_abi("02.00.02.50") == NetworkAbi::Unsupported);
CHECK(network_plugin_abi("02.09.00.10") == NetworkAbi::Unsupported);
CHECK(network_plugin_abi("") == NetworkAbi::Unsupported);
// A series may only be offered once the dispatch layer implements its generation.
for (size_t i = 0; i < AVAILABLE_NETWORK_VERSIONS_COUNT; ++i)
CHECK(AVAILABLE_NETWORK_VERSIONS[i].abi != NetworkAbi::Unsupported);
}
TEST_CASE_METHOD(PluginFolderFixture, "Legacy series never adopts discovered builds", "[NetworkVersions]")
{
// A different build of the legacy series must not be surfaced: is_legacy_version()
@@ -13,6 +13,7 @@
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <chrono>
#include <fstream>
#include <string>
@@ -48,6 +49,18 @@ const char* const CLOUD_PLUGIN_SOURCE = R"PY(# /// script
# version = "1.0"
# ///
print('ok')
import orca
class stubscript(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "stubscript"
def execute(self):
return orca.ExecutionResult.success("Stub orca script.")
@orca.plugin
class stubpackage(orca.base):
def register_capabilities(self):
orca.register_capability(stubscript)
)PY";
} // namespace
@@ -152,4 +165,31 @@ TEST_CASE("cloud metadata refresh preserves a plugin's stored config", "[PluginC
reloaded.load();
REQUIRE(reloaded.has_config(id));
CHECK(reloaded.get_config(id)->config == configured);
// A local package can remain after the cloud subscription disappears. The cloud identity is
// retained for diagnosis, but the orphaned state must suppress update availability until the
// plugin is returned by a later cloud refresh.
PluginDescriptor orphaned_record = cloud_record;
orphaned_record.cloud->orphaned = true;
orphaned_record.cloud->update_available = true;
manager.update_cloud_metadata({orphaned_record});
const PluginDescriptor orphaned = find_by_uuid();
REQUIRE(orphaned.cloud.has_value());
CHECK(orphaned.cloud->orphaned);
CHECK_FALSE(orphaned.has_error());
CHECK(orphaned.get_update_status() == PluginUpdateStatus::Normal);
// Orphaned is informational only: the local package must remain loadable and usable.
std::string load_error;
manager.load_plugin(uuid, /*skip_deps=*/true);
REQUIRE(manager.wait_for_plugin_load(uuid, std::chrono::seconds(120), load_error));
INFO("load error: " << load_error);
CHECK(load_error.empty());
CHECK(manager.is_plugin_loaded(uuid));
CHECK(manager.unload_plugin(uuid));
// Seeing the plugin in a subsequent cloud response clears the orphaned marker.
manager.update_cloud_metadata({cloud_record});
CHECK_FALSE(find_by_uuid().cloud->orphaned);
}
+2 -26
View File
@@ -6,6 +6,8 @@
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp>
#include <algorithm>
@@ -25,32 +27,6 @@ namespace fs = boost::filesystem;
namespace {
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
// value afterwards, so discovery scans a disposable {data_dir}/orca_plugins tree and tests don't
// leak state into each other.
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
fs::path plugins_dir() const { return dir / "orca_plugins"; }
};
// Brings the plugin system up, and tears it down explicitly at the end of the test.
//
// Shutting the interpreter down here, rather than leaving it to PythonInterpreter's static
@@ -16,6 +16,8 @@ TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pi
#include "libslic3r/Point.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Surface.hpp"
#include "test_utils.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
@@ -142,7 +144,7 @@ TEST_CASE("orca.slicing psGCodePostProcess context: file edit in place + config
import_orca_module();
py::gil_scoped_acquire gil;
const fs::path gpath = fs::temp_directory_path() / fs::unique_path("orca_pp_%%%%-%%%%.gcode");
ScopedTemporaryFile gpath(".gcode");
{
boost::nowide::ofstream ofs(gpath.string());
ofs << "; header\nG1 X0 Y0\n";
@@ -196,9 +198,7 @@ _pp_result = Stamp().execute(_pp_ctx)
boost::nowide::ifstream ifs(gpath.string());
std::stringstream ss; ss << ifs.rdbuf(); contents = ss.str();
}
CHECK(contents.find("; stamped by File") != std::string::npos);
fs::remove(gpath);
}
CHECK(contents.find("; stamped by File") != std::string::npos);}
// ---------------------------------------------------------------------------
// Toolpath helpers for the raw-graph tests.
+127 -14
View File
@@ -3,9 +3,14 @@
#include <libslic3r/TriangleMesh.hpp>
#include <libslic3r/Format/OBJ.hpp>
#include <libslic3r/SVG.hpp>
#include <boost/filesystem.hpp>
#include <cstdio>
#include <fstream>
#include <string>
#if defined(WIN32) || defined(_WIN32)
#define PATH_SEPARATOR R"(\)"
#else
@@ -22,26 +27,134 @@ inline Slic3r::TriangleMesh load_model(const std::string &obj_filename)
return mesh;
}
// RAII holder for a unique temporary file path, removed when the guard goes out
// of scope so a failing assertion never leaks it. Uses the system temp dir with
// a unique name (parallel-safe, cross-platform). The file itself is created by
// whoever writes to path()/string(); this only reserves the name and cleans up.
class ScopedTemporaryFile
// ---------------------------------------------------------------------------
// Scoped temporary paths
// ---------------------------------------------------------------------------
// Owns a unique path under the system temp dir, "<prefix>-<unique>[<extension>]"
// (parallel-safe, cross-platform). Shared base for the two RAII temp guards below.
class ScopedTemporaryPath
{
public:
explicit ScopedTemporaryFile(const std::string &extension = ".tmp")
: m_path(boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("orca-%%%%-%%%%-%%%%" + extension))
{}
~ScopedTemporaryFile() { boost::system::error_code ec; boost::filesystem::remove(m_path, ec); }
ScopedTemporaryFile(const ScopedTemporaryFile &) = delete;
ScopedTemporaryFile &operator=(const ScopedTemporaryFile &) = delete;
const boost::filesystem::path &path() const { return m_path; }
std::string string() const { return m_path.string(); }
ScopedTemporaryPath(const ScopedTemporaryPath &) = delete;
ScopedTemporaryPath &operator=(const ScopedTemporaryPath &) = delete;
protected:
ScopedTemporaryPath(const std::string &prefix, const std::string &extension)
: m_path(boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path(prefix + "-%%%%-%%%%-%%%%" + extension))
{}
~ScopedTemporaryPath() = default; // non-virtual: never deleted through a base pointer
private:
boost::filesystem::path m_path;
};
// A temp file the caller creates by writing to path()/string(); the guard only
// reserves the name and removes the file on scope exit.
class ScopedTemporaryFile : public ScopedTemporaryPath
{
public:
explicit ScopedTemporaryFile(const std::string &extension = ".tmp")
: ScopedTemporaryPath("orca", extension) {}
~ScopedTemporaryFile() { boost::system::error_code ec; boost::filesystem::remove(m_path, ec); }
};
// A temp directory created on construction and removed recursively on scope exit.
class ScopedTemporaryDir : public ScopedTemporaryPath
{
public:
explicit ScopedTemporaryDir(const std::string &prefix = "orca")
: ScopedTemporaryPath(prefix, "") { boost::filesystem::create_directories(m_path); }
~ScopedTemporaryDir() { boost::system::error_code ec; boost::filesystem::remove_all(m_path, ec); }
};
// ---------------------------------------------------------------------------
// Debug-only test artifacts
//
// Files a test dumps for inspection: a mesh, an SVG, or any streamable blob such
// as a PNG. In debug builds each run writes to a fresh temp folder (path printed
// once); the name may include a subfolder (e.g. "marchingsquares/foo.svg").
// ---------------------------------------------------------------------------
// Maps name to a path under the run's temp folder, creating any parent dirs
// (forward slashes work on Windows). Not gated, so only call it from a
// write_debug_* helper or inside an #ifndef NDEBUG block.
inline std::string debug_artifact_path(const std::string &name)
{
static const boost::filesystem::path root = [] {
boost::filesystem::path dir = boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("orca-test-artifacts-%%%%-%%%%");
boost::filesystem::create_directories(dir);
std::fprintf(stderr, "Debug test artifacts will be written to %s\n", dir.string().c_str());
return dir;
}();
boost::filesystem::path full = root / name;
boost::filesystem::create_directories(full.parent_path());
return full.string();
}
// Dump a mesh as OBJ.
inline void write_debug_obj([[maybe_unused]] const std::string &name,
[[maybe_unused]] const Slic3r::TriangleMesh &mesh)
{
#ifndef NDEBUG
mesh.WriteOBJFile(debug_artifact_path(name).c_str());
#endif
}
inline void write_debug_obj([[maybe_unused]] const std::string &name,
[[maybe_unused]] const indexed_triangle_set &its)
{
#ifndef NDEBUG
its_write_obj(its, debug_artifact_path(name).c_str());
#endif
}
// Dump a mesh as ASCII STL.
inline void write_debug_stl([[maybe_unused]] const std::string &name,
[[maybe_unused]] const Slic3r::TriangleMesh &mesh)
{
#ifndef NDEBUG
mesh.write_ascii(debug_artifact_path(name).c_str());
#endif
}
// Draw an SVG artifact through a callback that receives the open SVG. Second
// overload takes a BoundingBox when the drawing needs one.
template<class Draw>
inline void write_debug_svg([[maybe_unused]] const std::string &name, [[maybe_unused]] Draw &&draw)
{
#ifndef NDEBUG
Slic3r::SVG svg(debug_artifact_path(name));
draw(svg);
svg.Close();
#endif
}
template<class Draw>
inline void write_debug_svg([[maybe_unused]] const std::string &name,
[[maybe_unused]] const Slic3r::BoundingBox &bbox,
[[maybe_unused]] Draw &&draw)
{
#ifndef NDEBUG
Slic3r::SVG svg(debug_artifact_path(name), bbox);
draw(svg);
svg.Close();
#endif
}
// Write a callback's result (e.g. raster.encode(sla::PNGRasterEncoder{})) to an
// artifact. operator<< is resolved by ADL at the call site, so this header needn't
// include the producer's headers.
template<class Produce>
inline void write_debug_stream([[maybe_unused]] const std::string &name, [[maybe_unused]] Produce &&produce)
{
#ifndef NDEBUG
std::ofstream out(debug_artifact_path(name), std::ios::out | std::ios::binary);
out << produce();
#endif
}
#endif // SLIC3R_TEST_UTILS