Compare commits

..
Author SHA1 Message Date
Hanif Koh 7c71ac0870 Clamp Ironing Line Spacing to a Usable Minimum
An ironing line spacing of 0 reached the fillers from a 3MF, the CLI or
the per-filament override, which has no GUI guard. Concentric ironing
then never finished slicing, because a zero inset never shrinks the
region, and rectilinear ironing was silently dropped. Tiny positive
values produced an unprintable number of lines.

Top surface and support ironing now clamp the spacing to the 0.05 mm
floor the process GUI guard already enforces, so these configurations
iron at that spacing. Spacings at or above the floor, including every
shipped profile, are unchanged. The concentric filler also returns early
on a non-positive step so no other caller can hang it, and the filament
settings page now resets a too-small override the same way the process
page does.
2026-09-28 13:00:25 +08:00
Ian Chua ec0d8c225f fix: slicing lifecycle event naming on cancellation (#15889)
# Description

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->

Avoid calling Print::output_filename() when a slice is canceled or
fails, since unresolved filename placeholders can throw before G-code
export completes.
Instead, we should use the model name in ctx.name and the stable model
ID in ctx.id, consistently across slice and G-code export events.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
Fix #15885
2026-09-28 12:20:08 +08:00
Ian Chua 3040ebaac1 Merge branch 'main' into fix/slicing-evt-name 2026-09-28 12:19:59 +08:00
Kris Austin 5298e49dd2 fix: Klipper/Moonraker upload errors show a raw Python traceback (#14841) 2026-09-27 19:08:27 -03:00
Ian Chua d347a80ef8 Merge branch 'main' into fix/slicing-evt-name 2026-09-27 17:57:40 +08:00
peachismomo e340a13c18 fix: use current print for slicing lifecycle identity 2026-09-27 17:53:41 +08:00
peachismomo 9e16cdb23b fix: comment above reset_export 2026-09-27 17:29:25 +08:00
peachismomo 58842bab05 test: test for SliceStarted ensuring stable model ID is presetn when model name is absent 2026-09-27 17:29:01 +08:00
peachismomo 04204ec0d3 test: cover slicing lifecycle event context 2026-09-27 17:25:56 +08:00
Ian Chua 9ed459e132 fix: slicing event name and ID 2026-09-25 13:25:45 +08:00
21 changed files with 549 additions and 316 deletions
+2 -2
View File
@@ -1692,9 +1692,9 @@ void Layer::make_ironing()
ironing_params.just_infill = false; ironing_params.just_infill = false;
// ORCA: Get filament-specific overrides if configured, otherwise use process values // ORCA: Get filament-specific overrides if configured, otherwise use process values
size_t extruder_idx = ironing_params.extruder - 1; size_t extruder_idx = ironing_params.extruder - 1;
ironing_params.line_spacing = (!config.filament_ironing_spacing.is_nil(extruder_idx) ironing_params.line_spacing = std::max(IRONING_SPACING_MIN, !config.filament_ironing_spacing.is_nil(extruder_idx)
? config.filament_ironing_spacing.get_at(extruder_idx) ? config.filament_ironing_spacing.get_at(extruder_idx)
: config.ironing_spacing); : config.ironing_spacing.value);
ironing_params.inset = (!config.filament_ironing_inset.is_nil(extruder_idx) ironing_params.inset = (!config.filament_ironing_inset.is_nil(extruder_idx)
? config.filament_ironing_inset.get_at(extruder_idx) ? config.filament_ironing_inset.get_at(extruder_idx)
: config.ironing_inset); : config.ironing_inset);
+5
View File
@@ -22,6 +22,9 @@ void FillConcentric::_fill_surface_single(
coord_t min_spacing = scale_(this->spacing) * params.multiline; coord_t min_spacing = scale_(this->spacing) * params.multiline;
coord_t distance = coord_t(min_spacing / params.density); coord_t distance = coord_t(min_spacing / params.density);
// A non-positive step never shrinks the region, so the inset loop below would not end.
if (min_spacing <= 0 || distance <= 0)
return;
if (params.density > 0.9999f && !params.dont_adjust) { if (params.density > 0.9999f && !params.dont_adjust) {
distance = this->_adjust_solid_spacing(bounding_box.size()(0), distance); distance = this->_adjust_solid_spacing(bounding_box.size()(0), distance);
@@ -108,6 +111,8 @@ void FillConcentric::_fill_surface_single(const FillParams& params,
// no rotation is supported for this infill pattern // no rotation is supported for this infill pattern
Point bbox_size = expolygon.contour.bounding_box().size(); Point bbox_size = expolygon.contour.bounding_box().size();
coord_t min_spacing = scaled<coord_t>(this->spacing); coord_t min_spacing = scaled<coord_t>(this->spacing);
if (min_spacing <= 0)
return;
if (params.density > 0.9999f && !params.dont_adjust) { if (params.density > 0.9999f && !params.dont_adjust) {
coord_t loops_count = std::max(bbox_size.x(), bbox_size.y()) / min_spacing + 1; coord_t loops_count = std::max(bbox_size.x(), bbox_size.y()) / min_spacing + 1;
+2 -2
View File
@@ -1629,7 +1629,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
} }
while (it != m_plater_data.end()) while (it != m_plater_data.end())
{ {
if (it->first <= 0 || static_cast<size_t>(it->first) > m_plater_data.size()) if (it->first > m_plater_data.size())
{ {
add_error("invalid plate index"); add_error("invalid plate index");
return false; return false;
@@ -2312,7 +2312,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
} }
while (it != m_plater_data.end()) while (it != m_plater_data.end())
{ {
if (it->first <= 0 || static_cast<size_t>(it->first) > m_plater_data.size()) if (it->first > m_plater_data.size())
{ {
add_error("invalid plate index"); add_error("invalid plate index");
return false; return false;
+10 -5
View File
@@ -2504,7 +2504,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id); ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Ok; ctx.code = LifecycleEvtCode::Ok;
ctx.msg = path; ctx.msg = path;
ctx.cancellation_check = [print]() { return print->canceled(); }; ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2556,7 +2557,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
} }
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id); ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Error; ctx.code = LifecycleEvtCode::Error;
ctx.msg = std::string(path) + "\n" + err_msg; ctx.msg = std::string(path) + "\n" + err_msg;
ctx.cancellation_check = [print]() { return print->canceled(); }; ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2580,7 +2582,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
boost::nowide::remove(path_tmp.c_str()); boost::nowide::remove(path_tmp.c_str());
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id); ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Error; ctx.code = LifecycleEvtCode::Error;
ctx.msg = std::string(path) + "\n" + ex.what(); ctx.msg = std::string(path) + "\n" + ex.what();
ctx.cancellation_check = [print]() { return print->canceled(); }; ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2693,7 +2696,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
if (ret) { if (ret) {
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id); ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Error; ctx.code = LifecycleEvtCode::Error;
ctx.msg = std::string(path) + "\nFailed to rename the output G-code file: " + ret.message(); ctx.msg = std::string(path) + "\nFailed to rename the output G-code file: " + ret.message();
ctx.cancellation_check = [print]() { return print->canceled(); }; ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2712,7 +2716,8 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(print->model().id().id); ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.code = LifecycleEvtCode::Ok; ctx.code = LifecycleEvtCode::Ok;
ctx.msg = path; ctx.msg = path;
ctx.cancellation_check = [print]() { return print->canceled(); }; ctx.cancellation_check = [print]() { return print->canceled(); };
+1 -5
View File
@@ -3702,11 +3702,7 @@ void FacetsAnnotation::set_triangle_from_string(int triangle_id, const std::stri
m_data.bitstream.insert(m_data.bitstream.end(), bool(dec & (1 << i))); m_data.bitstream.insert(m_data.bitstream.end(), bool(dec & (1 << i)));
} }
if (!m_data.update_used_states(bitstream_start_idx)) { m_data.update_used_states(bitstream_start_idx);
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": dropping malformed paint data of triangle " << triangle_id;
m_data.bitstream.resize(bitstream_start_idx);
m_data.triangles_to_split.pop_back();
}
} }
bool FacetsAnnotation::equals(const FacetsAnnotation &other) const bool FacetsAnnotation::equals(const FacetsAnnotation &other) const
+10 -5
View File
@@ -2717,7 +2717,8 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id); ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Ok; ctx.code = LifecycleEvtCode::Ok;
ctx.cancellation_check = [this]() { return canceled(); }; ctx.cancellation_check = [this]() { return canceled(); };
fire_lifecycle_event(LifecycleEvent::SliceStarted, ctx); fire_lifecycle_event(LifecycleEvent::SliceStarted, ctx);
@@ -3344,7 +3345,8 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id); ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Ok; ctx.code = LifecycleEvtCode::Ok;
ctx.cancellation_check = [this]() { return canceled(); }; ctx.cancellation_check = [this]() { return canceled(); };
fire_lifecycle_event(LifecycleEvent::SliceGeometryFinished, ctx); fire_lifecycle_event(LifecycleEvent::SliceGeometryFinished, ctx);
@@ -4950,7 +4952,8 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces
{ {
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id); ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Ok; ctx.code = LifecycleEvtCode::Ok;
ctx.msg = file; ctx.msg = file;
ctx.cancellation_check = [this]() { return canceled(); }; ctx.cancellation_check = [this]() { return canceled(); };
@@ -4980,7 +4983,8 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": found errors when process gcode file %1%") %file.c_str(); BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": found errors when process gcode file %1%") %file.c_str();
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id); ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Error; ctx.code = LifecycleEvtCode::Error;
ctx.msg = file + "\n" + ex.what(); ctx.msg = file + "\n" + ex.what();
ctx.cancellation_check = [this]() { return canceled(); }; ctx.cancellation_check = [this]() { return canceled(); };
@@ -4994,7 +4998,8 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = std::to_string(m_model.id().id); ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.code = LifecycleEvtCode::Ok; ctx.code = LifecycleEvtCode::Ok;
ctx.msg = file; ctx.msg = file;
ctx.cancellation_check = [this]() { return canceled(); }; ctx.cancellation_check = [this]() { return canceled(); };
+4
View File
@@ -178,6 +178,10 @@ enum class IroningType {
Count, Count,
}; };
// Smallest usable ironing line spacing. Anything tighter yields an unprintable number of lines,
// and zero stops the fillers from making progress.
constexpr double IRONING_SPACING_MIN = 0.05;
//BBS //BBS
enum class WallInfillOrder { enum class WallInfillOrder {
InnerOuterInfill, InnerOuterInfill,
+1 -1
View File
@@ -64,7 +64,7 @@ struct SupportParameters {
this->ironing = object_config.support_ironing; this->ironing = object_config.support_ironing;
this->ironing_flow = support_material_interface_flow.with_height(support_material_interface_flow.height() * 0.01 * object_config.support_ironing_flow.value); this->ironing_flow = support_material_interface_flow.with_height(support_material_interface_flow.height() * 0.01 * object_config.support_ironing_flow.value);
this->ironing_spacing = object_config.support_ironing_spacing; this->ironing_spacing = std::max(IRONING_SPACING_MIN, object_config.support_ironing_spacing.value);
this->ironing_pattern = object_config.support_ironing_pattern; this->ironing_pattern = object_config.support_ironing_pattern;
// Calculate a minimum support layer height as a minimum over all extruders, but not smaller than 10um. // Calculate a minimum support layer height as a minimum over all extruders, but not smaller than 10um.
+43 -68
View File
@@ -1778,13 +1778,6 @@ TriangleSelector::TriangleSplittingData TriangleSelector::serialize() const {
return out.data; return out.data;
} }
// A split code keeps the split side (one split) or the kept side (two splits) in its upper two
// bits, where 3 is not a side. The value is ignored for a three-side split.
static bool split_code_valid(int code)
{
return (code & 0b11) == 3 || (code >> 2) != 3;
}
void TriangleSelector::deserialize(const TriangleSplittingData &data, void TriangleSelector::deserialize(const TriangleSplittingData &data,
bool needs_reset, bool needs_reset,
EnforcerBlockerType max_ebt, EnforcerBlockerType max_ebt,
@@ -1819,12 +1812,11 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data,
for (auto [triangle_id, ibit] : data.triangles_to_split) { for (auto [triangle_id, ibit] : data.triangles_to_split) {
assert(triangle_id < int(m_triangles.size())); assert(triangle_id < int(m_triangles.size()));
// Set when the bitstream runs out or holds an impossible split before this triangle's tree is complete. assert(ibit < int(data.bitstream.size()));
bool corrupt = false; auto next_nibble = [&data, &ibit = ibit]() {
auto next_nibble = [&data, &ibit = ibit, &corrupt]() {
int n = 0; int n = 0;
if (! data.read_nibble(ibit, n)) for (int i = 0; i < 4; ++ i)
corrupt = true; n |= data.bitstream[ibit ++] << i;
return n; return n;
}; };
// Decode a leaf state stored behind the "11" prefix: one nibble of (state-3) for states // Decode a leaf state stored behind the "11" prefix: one nibble of (state-3) for states
@@ -1843,10 +1835,6 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data,
bool is_split = num_of_children != 0; bool is_split = num_of_children != 0;
// Only valid if not is_split. // Only valid if not is_split.
auto state = is_split ? EnforcerBlockerType::NONE : ((code & 0b1100) == 0b1100 ? decode_leaf_state() : EnforcerBlockerType(code >> 2)); auto state = is_split ? EnforcerBlockerType::NONE : ((code & 0b1100) == 0b1100 ? decode_leaf_state() : EnforcerBlockerType(code >> 2));
if (is_split && ! split_code_valid(code))
corrupt = true;
if (corrupt)
break;
// BBS // BBS
if (state == to_delete_filament) if (state == to_delete_filament)
@@ -1861,7 +1849,7 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data,
} }
// Only valid if is_split. // Only valid if is_split.
int special_side = num_of_split_sides == 3 ? 0 : code >> 2; int special_side = code >> 2;
// Take care of the first iteration separately, so handling of the others is simpler. // Take care of the first iteration separately, so handling of the others is simpler.
if (parents.empty()) { if (parents.empty()) {
@@ -1916,55 +1904,47 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data,
if (parents.empty()) if (parents.empty())
break; break;
} }
if (corrupt) {
// Every split above allocated all of its children, so the partial tree unwinds cleanly.
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": malformed paint data, dropping paint of triangle " << triangle_id;
undivide_triangle(triangle_id);
m_triangles[triangle_id].set_state(EnforcerBlockerType::NONE);
}
} }
} }
bool TriangleSelector::TriangleSplittingData::update_used_states(const size_t bitstream_start_idx) { void TriangleSelector::TriangleSplittingData::update_used_states(const size_t bitstream_start_idx) {
int ibit = static_cast<int>(bitstream_start_idx); assert(bitstream_start_idx < this->bitstream.size());
uint64_t states = 0; assert(!this->bitstream.empty() && this->bitstream.size() != bitstream_start_idx);
do { assert((this->bitstream.size() - bitstream_start_idx) % 4 == 0);
// Walk one triangle's tree depth-first, counting the nodes still to be read; a split node adds its children.
for (int pending_nodes = 1; pending_nodes > 0; --pending_nodes) {
int code;
if (!this->read_nibble(ibit, code))
return false;
if (const int num_of_split_sides = code & 0b11; num_of_split_sides != 0) { if (this->bitstream.empty() || this->bitstream.size() == bitstream_start_idx)
if (!split_code_valid(code)) return;
return false;
pending_nodes += num_of_split_sides + 1;
continue;
}
int facet_state = code >> 2; size_t nibble_idx = bitstream_start_idx;
if (facet_state == 0b11) {
// Leaf behind the "11" prefix: one nibble of (state-3), or 0b1111 + (state-18). auto read_next_nibble = [&data_bitstream = std::as_const(this->bitstream), &nibble_idx]() -> uint8_t {
int nibble; assert(nibble_idx + 3 < data_bitstream.size());
if (!this->read_nibble(ibit, nibble)) uint8_t code = 0;
return false; for (size_t bit_idx = 0; bit_idx < 4; ++bit_idx)
facet_state = nibble + 3; code |= data_bitstream[nibble_idx++] << bit_idx;
if (nibble == 0b1111) { return code;
if (!this->read_nibble(ibit, nibble)) };
return false;
facet_state = nibble + 18; while (nibble_idx < this->bitstream.size()) {
} const uint8_t code = read_next_nibble();
}
states |= uint64_t(1) << facet_state; if (const bool is_split = (code & 0b11) != 0; is_split)
continue;
uint8_t facet_state;
if ((code & 0b1100) == 0b1100) {
// Leaf behind the "11" prefix: one nibble of (state-3), or 0b1111 + (state-18).
const uint8_t nibble = read_next_nibble();
facet_state = nibble == 0b1111 ? uint8_t(read_next_nibble() + 18) : uint8_t(nibble + 3);
} else {
facet_state = code >> 2;
} }
} while (static_cast<size_t>(ibit) < this->bitstream.size()); assert(facet_state < this->used_states.size());
if (facet_state >= this->used_states.size())
continue;
// The leaf encoding tops out at state 33, so every state fits the 64-bit mask. this->used_states[facet_state] = true;
for (size_t state_idx = 0; state_idx < std::min<size_t>(this->used_states.size(), 64); ++state_idx) }
if (states & (uint64_t(1) << state_idx))
this->used_states[state_idx] = true;
return true;
} }
// Lightweight variant of deserialization, which only tests whether a face of test_state exists. // Lightweight variant of deserialization, which only tests whether a face of test_state exists.
@@ -1976,12 +1956,11 @@ bool TriangleSelector::has_facets(const TriangleSplittingData &data, const Enfor
for (const TriangleBitStreamMapping &triangle_id_and_ibit : data.triangles_to_split) { for (const TriangleBitStreamMapping &triangle_id_and_ibit : data.triangles_to_split) {
int ibit = triangle_id_and_ibit.bitstream_start_idx; int ibit = triangle_id_and_ibit.bitstream_start_idx;
// Stop reading a triangle whose stream is truncated. assert(ibit < int(data.bitstream.size()));
bool truncated = false; auto next_nibble = [&data, &ibit = ibit]() {
auto next_nibble = [&data, &ibit = ibit, &truncated]() {
int n = 0; int n = 0;
if (! data.read_nibble(ibit, n)) for (int i = 0; i < 4; ++ i)
truncated = true; n |= data.bitstream[ibit ++] << i;
return n; return n;
}; };
// < 0 -> negative of a number of children // < 0 -> negative of a number of children
@@ -1999,8 +1978,6 @@ bool TriangleSelector::has_facets(const TriangleSplittingData &data, const Enfor
}; };
int state = num_children_or_state(); int state = num_children_or_state();
if (truncated)
continue;
if (state < 0) { if (state < 0) {
// Root is split. // Root is split.
parents_children.clear(); parents_children.clear();
@@ -2008,8 +1985,6 @@ bool TriangleSelector::has_facets(const TriangleSplittingData &data, const Enfor
do { do {
if (-- parents_children.back() >= 0) { if (-- parents_children.back() >= 0) {
int state = num_children_or_state(); int state = num_children_or_state();
if (truncated)
break;
if (state < 0) if (state < 0)
// Child is split. // Child is split.
parents_children.emplace_back(- state); parents_children.emplace_back(- state);
+2 -14
View File
@@ -297,20 +297,8 @@ public:
std::fill(used_states.begin(), used_states.end(), false); std::fill(used_states.begin(), used_states.end(), false);
} }
// Update used states from the triangle trees stored between bitstream_start_idx and the end of the bitstream. // Update used states based on the bitstream. It just iterated over the bitstream from the bitstream_start_idx till the end.
// Returns false and leaves used states untouched if a tree is truncated or malformed. void update_used_states(size_t bitstream_start_idx);
bool update_used_states(size_t bitstream_start_idx);
// Read the 4-bit code at bit index ibit (LSB first) and advance ibit past it.
// Returns false without advancing when fewer than 4 bits remain.
bool read_nibble(int &ibit, int &nibble) const {
if (ibit < 0 || static_cast<size_t>(ibit) + 4 > bitstream.size())
return false;
nibble = 0;
for (int i = 0; i < 4; ++i)
nibble |= static_cast<int>(bitstream[ibit++]) << i;
return true;
}
private: private:
friend class cereal::access; friend class cereal::access;
+26 -2
View File
@@ -210,6 +210,30 @@ void ConfigManipulation::check_filament_max_volumetric_speed(DynamicPrintConfig
} }
void ConfigManipulation::check_filament_ironing_spacing(DynamicPrintConfig *config)
{
const auto *opt = config->option<ConfigOptionFloatsNullable>("filament_ironing_spacing");
if (opt == nullptr)
return;
std::vector<double> values = opt->values;
bool reset = false;
for (size_t i = 0; i < values.size(); ++i)
if (!opt->is_nil(i) && values[i] < IRONING_SPACING_MIN) {
values[i] = 0.1;
reset = true;
}
if (!reset)
return;
const wxString msg_text = _(L("Ironing spacing too small\nIt has been reset to 0.1"));
MessageDialog dialog(nullptr, msg_text, "", wxICON_WARNING | wxOK);
DynamicPrintConfig new_conf = *config;
is_msg_dlg_already_exist = true;
dialog.ShowModal();
new_conf.set_key_value("filament_ironing_spacing", new ConfigOptionFloatsNullable(values));
apply(config, &new_conf);
is_msg_dlg_already_exist = false;
}
void ConfigManipulation::check_chamber_temperature(DynamicPrintConfig* config) void ConfigManipulation::check_chamber_temperature(DynamicPrintConfig* config)
{ {
bool support_chamber_temp_control=GUI::wxGetApp().preset_bundle->printers.get_selected_preset().config.opt_bool("support_chamber_temp_control"); bool support_chamber_temp_control=GUI::wxGetApp().preset_bundle->printers.get_selected_preset().config.opt_bool("support_chamber_temp_control");
@@ -332,7 +356,7 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con
} }
//BBS: ironing_spacing shouldn't be too small or equal to zero //BBS: ironing_spacing shouldn't be too small or equal to zero
if (config->opt_float("ironing_spacing") < 0.05) if (config->opt_float("ironing_spacing") < IRONING_SPACING_MIN)
{ {
const wxString msg_text = _(L("Ironing spacing too small\nIt has been reset to 0.1")); const wxString msg_text = _(L("Ironing spacing too small\nIt has been reset to 0.1"));
MessageDialog dialog(nullptr, msg_text, "", wxICON_WARNING | wxOK); MessageDialog dialog(nullptr, msg_text, "", wxICON_WARNING | wxOK);
@@ -343,7 +367,7 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con
apply(config, &new_conf); apply(config, &new_conf);
is_msg_dlg_already_exist = false; is_msg_dlg_already_exist = false;
} }
if (config->opt_float("support_ironing_spacing") < 0.05) if (config->opt_float("support_ironing_spacing") < IRONING_SPACING_MIN)
{ {
const wxString msg_text = _(L("Ironing spacing too small\nIt has been reset to 0.1")); const wxString msg_text = _(L("Ironing spacing too small\nIt has been reset to 0.1"));
MessageDialog dialog(nullptr, msg_text, "", wxICON_WARNING | wxOK); MessageDialog dialog(nullptr, msg_text, "", wxICON_WARNING | wxOK);
+1
View File
@@ -84,6 +84,7 @@ public:
void check_nozzle_temperature_initial_layer_range(DynamicPrintConfig* config); void check_nozzle_temperature_initial_layer_range(DynamicPrintConfig* config);
void check_adaptive_pressure_advance_model(DynamicPrintConfig* config); void check_adaptive_pressure_advance_model(DynamicPrintConfig* config);
void check_filament_max_volumetric_speed(DynamicPrintConfig *config); void check_filament_max_volumetric_speed(DynamicPrintConfig *config);
void check_filament_ironing_spacing(DynamicPrintConfig *config);
void check_chamber_temperature(DynamicPrintConfig* config); void check_chamber_temperature(DynamicPrintConfig* config);
void check_chamber_minimal_temperature(DynamicPrintConfig* config); void check_chamber_minimal_temperature(DynamicPrintConfig* config);
bool check_layer_height(DynamicPrintConfig* config); bool check_layer_height(DynamicPrintConfig* config);
+12 -4
View File
@@ -12800,8 +12800,6 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt)
notification_manager->set_slicing_progress_export_possible(); notification_manager->set_slicing_progress_export_possible();
// Reset the "export G-code path" name, so that the automatic background processing will be enabled again. // Reset the "export G-code path" name, so that the automatic background processing will be enabled again.
const std::string lifecycle_job_name = this->background_process.fff_print() ?
this->background_process.fff_print()->output_filename() : std::string();
this->background_process.reset_export(); this->background_process.reset_export();
// This bool stops showing export finished notification even when process_completed_with_error is false // This bool stops showing export finished notification even when process_completed_with_error is false
bool has_error = false; bool has_error = false;
@@ -12848,8 +12846,18 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt)
{ {
Slic3r::LifecycleEventContext ctx; Slic3r::LifecycleEventContext ctx;
ctx.name = lifecycle_job_name; if (const PrintBase* print = this->background_process.current_print()) {
ctx.code = evt.cancelled() ? Slic3r::LifecycleEvtCode::Warn : (has_error ? Slic3r::LifecycleEvtCode::Error : Slic3r::LifecycleEvtCode::Ok); const Model& model = print->model();
ctx.id = std::to_string(model.id().id);
if (model.model_info)
ctx.name = model.model_info->model_name;
} else {
// Realistically Printbase* print will never be null because select_technology already asserts an active print
// and the worker thread asserts it before processing.
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": slicing completed without an active print; lifecycle event has no model ID";
}
ctx.code = evt.cancelled() ? Slic3r::LifecycleEvtCode::Warn :
(has_error ? Slic3r::LifecycleEvtCode::Error : Slic3r::LifecycleEvtCode::Ok);
ctx.msg = evt.cancelled() ? "cancelled" : (has_error ? lifecycle_error_msg : std::string()); ctx.msg = evt.cancelled() ? "cancelled" : (has_error ? lifecycle_error_msg : std::string());
Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::SlicingJobComplete, ctx); Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::SlicingJobComplete, ctx);
} }
+1
View File
@@ -4905,6 +4905,7 @@ void TabFilament::update()
return; // ys_FIXME return; // ys_FIXME
m_config_manipulation.check_filament_max_volumetric_speed(m_config); m_config_manipulation.check_filament_max_volumetric_speed(m_config);
m_config_manipulation.check_filament_ironing_spacing(m_config);
m_update_cnt++; m_update_cnt++;
+59 -1
View File
@@ -6,6 +6,7 @@
#include <boost/optional.hpp> #include <boost/optional.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <wx/string.h> #include <wx/string.h>
#include <wx/app.h> #include <wx/app.h>
@@ -115,10 +116,67 @@ std::string PrintHost::get_print_host_webui(DynamicPrintConfig* config)
return webui_url; return webui_url;
} }
namespace {
// Moonraker (Klipper's API server) reports a raised exception as { "error": { "code", "message", "traceback" } }
// under every host type that connects to it, often with the cause only in the traceback. Returns the reason to show,
// or empty for any other body.
std::string moonraker_error_reason(const std::string &body)
{
const auto root = nlohmann::json::parse(body, nullptr, false);
const auto err = root.find("error");
if (err == root.end())
return {};
const auto message = err->find("message");
const auto traceback = err->find("traceback");
if (message == err->end() || traceback == err->end() || !message->is_string() || !traceback->is_string())
return {};
const auto &msg = message->get_ref<const std::string &>();
const auto &tb = traceback->get_ref<const std::string &>();
if (msg.empty())
return {};
const auto end = tb.find_last_not_of(" \t\r\n");
if (end == std::string::npos)
return msg;
// Chained exceptions each start a new traceback; the one that failed the request is the last.
const auto header = tb.rfind("Traceback (most recent call last):", end);
// Tornado renders a raised HTTPError as "HTTP <code>: <reason>[ (<detail>)]", and the detail may span lines.
const auto code = err->find("code");
if (code != err->end() && code->is_number_integer()) {
const std::string marker = "HTTP " + std::to_string(code->get<int>()) + ": ";
const auto pos = tb.rfind(marker, end);
if (pos != std::string::npos && (header == std::string::npos || pos > header) && pos + marker.size() <= end) {
const std::string reason = tb.substr(pos + marker.size(), end + 1 - pos - marker.size());
// An HTTPError whose detail equals its reason, like HTTPError(401, "Unauthorized"), renders the phrase twice.
return reason == msg + " (" + msg + ")" ? msg : reason;
}
}
// Any other exception's type and message are everything from the first unindented line after its frames.
auto begin = (header == std::string::npos) ? std::string::npos : tb.find('\n', header);
while (begin != std::string::npos && begin < end) {
++begin;
if (tb[begin] != ' ' && tb[begin] != '\r' && tb[begin] != '\n')
break;
begin = tb.find('\n', begin);
}
if (begin == std::string::npos || begin > end) {
const auto nl = tb.rfind('\n', end);
begin = (nl == std::string::npos) ? 0 : nl + 1;
}
return msg + " (" + tb.substr(begin, end + 1 - begin) + ")";
}
} // namespace
wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const
{ {
if (status != 0) { if (status != 0) {
auto wxbody = wxString::FromUTF8(body.data()); const std::string reason = moonraker_error_reason(body);
auto wxbody = wxString::FromUTF8(reason.empty() ? body : reason);
return wxString::Format("HTTP %u: %s", status, wxbody); return wxString::Format("HTTP %u: %s", status, wxbody);
} else { } else {
if (error.find("curl:Timeout was reached") != std::string::npos) { if (error.find("curl:Timeout was reached") != std::string::npos) {
+55
View File
@@ -747,6 +747,61 @@ TEST_CASE("A region with ironing turned off is never ironed", "[Fill]")
REQUIRE(Layer::choose_ironing_extruder(cfg, spiral_mode, /*is_topmost_layer=*/true) == -1); REQUIRE(Layer::choose_ironing_extruder(cfg, spiral_mode, /*is_topmost_layer=*/true) == -1);
} }
// Ironing path count and total length in mm, over the whole object.
static std::pair<size_t, double> ironing_extent(const Print &print)
{
size_t paths = 0;
double length = 0.;
for (const Layer *layer : print.objects().front()->layers())
for (const LayerRegion *region : layer->regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (ironing_role(entity->role())) {
++paths;
length += unscale<double>(entity->length());
}
return {paths, length};
}
TEST_CASE("Ironing spacing below the minimum irons at the minimum spacing", "[Fill]")
{
const std::string pattern = GENERATE("rectilinear", "concentric");
const bool via_filament = GENERATE(false, true);
const double spacing = GENERATE(0., 0.001);
CAPTURE(pattern, via_filament, spacing);
auto ironing_for = [&pattern, via_filament](double spacing) {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"ironing_type", "top"},
{"ironing_pattern", pattern},
{"layer_height", 0.2}});
// The filament override replaces the process spacing, which stays at a usable value.
if (via_filament)
config.set_deserialize_strict({{"ironing_spacing", 0.1}, {"filament_ironing_spacing", spacing}});
else
config.set_deserialize_strict({{"ironing_spacing", spacing}});
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print, config);
return ironing_extent(print);
};
const std::pair<size_t, double> clamped = ironing_for(spacing);
const std::pair<size_t, double> minimum = ironing_for(IRONING_SPACING_MIN);
REQUIRE(minimum.first > 0);
CHECK(clamped.first == minimum.first);
CHECK_THAT(clamped.second, Catch::Matchers::WithinRel(minimum.second, 1e-9));
}
TEST_CASE("Concentric fill at zero spacing returns without paths", "[Fill]")
{
std::unique_ptr<Fill> filler(Fill::new_from_type(ipConcentric));
filler->spacing = 0.;
filler->bounding_box = BoundingBox(Point(0, 0), Point::new_scale(10, 10));
FillParams params;
params.density = 1.f;
Surface surface(stTop, ExPolygon({Point(0, 0), Point::new_scale(10, 0), Point::new_scale(10, 10), Point::new_scale(0, 10)}));
CHECK(filler->fill_surface(&surface, params).empty());
}
TEST_CASE("Solid infill direction offsets every layer when no template is set", "[Fill]") TEST_CASE("Solid infill direction offsets every layer when no template is set", "[Fill]")
{ {
auto angles_for = [](int direction) { auto angles_for = [](int direction) {
+86
View File
@@ -15,6 +15,9 @@
#include "libslic3r/Layer.hpp" #include "libslic3r/Layer.hpp"
#include "libslic3r/Model.hpp" #include "libslic3r/Model.hpp"
#include "libslic3r/GCodeReader.hpp" #include "libslic3r/GCodeReader.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Exception.hpp"
#include "libslic3r/LifecycleEvents.hpp"
#include "test_helpers.hpp" #include "test_helpers.hpp"
#include "test_utils.hpp" #include "test_utils.hpp"
@@ -22,7 +25,10 @@
#include <algorithm> #include <algorithm>
#include <fstream> #include <fstream>
#include <iterator> #include <iterator>
#include <memory>
#include <string_view> #include <string_view>
#include <utility>
#include <vector>
using namespace Slic3r; using namespace Slic3r;
using namespace Slic3r::Test; using namespace Slic3r::Test;
@@ -224,8 +230,88 @@ std::string resolved_output_name(Model& model, const std::string& format, const
return print.output_filename(filename_base); return print.output_filename(filename_base);
} }
struct ScopedLifecycleHook
{
explicit ScopedLifecycleHook(LifecycleHookFn hook) { set_lifecycle_hook_fn(std::move(hook)); }
~ScopedLifecycleHook() { set_lifecycle_hook_fn(nullptr); }
};
} // namespace } // namespace
TEST_CASE("Slicing lifecycle events identify the model", "[Print][LifecycleEvents]")
{
struct ObservedEvent {
LifecycleEvent event;
std::string id;
std::string name;
};
std::vector<ObservedEvent> events;
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
events.push_back({ event, ctx.id, ctx.name });
});
Print print;
Model model;
ModelInfo info;
info.model_name = "Lifecycle test model";
model.model_info = std::make_shared<ModelInfo>(std::move(info));
init_print({cube(20)}, print, model);
print.process();
ScopedTemporaryFile temp(".gcode");
print.export_gcode(temp.string(), nullptr, nullptr);
GCodeProcessorResult result;
print.export_gcode_from_previous_file(temp.string(), &result);
const std::string expected_id = std::to_string(print.model().id().id);
const std::vector<LifecycleEvent> expected_events = {
LifecycleEvent::SliceStarted,
LifecycleEvent::SliceGeometryFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
};
REQUIRE(events.size() == expected_events.size());
for (size_t i = 0; i < expected_events.size(); ++i) {
CHECK(events[i].event == expected_events[i]);
CHECK(events[i].id == expected_id);
CHECK(events[i].name == "Lifecycle test model");
}
}
TEST_CASE("Slicing lifecycle event name is empty without model metadata", "[Print][LifecycleEvents]")
{
std::string event_id;
std::string event_name = "unset";
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
if (event == LifecycleEvent::SliceStarted) {
event_id = ctx.id;
event_name = ctx.name;
}
});
Print print;
Model model;
init_print({cube(20)}, print, model);
print.process();
CHECK(event_id == std::to_string(print.model().id().id));
CHECK(event_name.empty());
}
TEST_CASE("Output filenames with numeric statistics fail before slicing finishes", "[Print][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("filename_format", new ConfigOptionString("{int(total_weight*10) / 10.0}"));
Print print;
Model model;
init_print({cube(20)}, print, model, config);
CHECK_THROWS_AS(print.output_filename(), PlaceholderParserError);
}
TEST_CASE("Print: {first_object_name} names the first printable object on the plate", "[Print]") TEST_CASE("Print: {first_object_name} names the first printable object on the plate", "[Print]")
{ {
Model model; Model model;
+15 -131
View File
@@ -19,7 +19,6 @@
#include <boost/filesystem/operations.hpp> #include <boost/filesystem/operations.hpp>
#include <boost/algorithm/string/predicate.hpp> #include <boost/algorithm/string/predicate.hpp>
#include <algorithm> #include <algorithm>
#include <functional>
#include <catch2/catch_tostring.hpp> #include <catch2/catch_tostring.hpp>
#include <Eigen/Core> #include <Eigen/Core>
@@ -185,13 +184,16 @@ static bool read_cad_recipe_entry(const std::string& path, std::string& out,
return found; return found;
} }
// Rewrites the archive at `path`, letting `edit` change the name or data of each entry; returns // Rewrites the archive at `path` with the recipe entry back under the name it had before the
// whether `edit` reported a change for any of them. miniz cannot edit in place and // rename, which is what every project saved by an earlier build looks like on disk. Generated
// open_zip_writer truncates, so the entries are held across the switch. // rather than checked in because a whole project archive is not frozen evidence the way a bare
static bool rewrite_3mf_entries(const std::string& path, const std::function<bool(std::string& name, std::string& data)>& edit) // recipe blob is -- it has to be whatever today's exporter writes, with only the name aged.
// miniz cannot rename in place and open_zip_writer truncates, so the entries are held across
// the switch.
static void rename_cad_recipe_entry_to_legacy(const std::string& path)
{ {
std::vector<std::pair<std::string, std::string>> entries; std::vector<std::pair<std::string, std::string>> entries;
bool changed = false; bool renamed = false;
{ {
mz_zip_archive zip; mz_zip_archive zip;
mz_zip_zero_struct(&zip); mz_zip_zero_struct(&zip);
@@ -206,140 +208,22 @@ static bool rewrite_3mf_entries(const std::string& path, const std::function<boo
std::string data((size_t) st.m_uncomp_size, '\0'); std::string data((size_t) st.m_uncomp_size, '\0');
if (st.m_uncomp_size > 0) if (st.m_uncomp_size > 0)
REQUIRE(mz_zip_reader_extract_to_mem(&zip, i, data.data(), data.size(), 0)); REQUIRE(mz_zip_reader_extract_to_mem(&zip, i, data.data(), data.size(), 0));
changed |= edit(name, data); if (boost::algorithm::iequals(name, CAD_RECIPE_ENTRY)) {
name = LEGACY_CAD_RECIPE_ENTRY;
renamed = true;
}
entries.emplace_back(std::move(name), std::move(data)); entries.emplace_back(std::move(name), std::move(data));
} }
close_zip_reader(&zip); close_zip_reader(&zip);
} }
// Without this the scenario would degrade silently into re-testing the new name if the
// exporter's constant ever moved again: every load below would still pass.
REQUIRE(renamed);
Zipper out(path); Zipper out(path);
for (const auto& e : entries) for (const auto& e : entries)
out.add_entry(e.first, e.second.data(), e.second.size()); out.add_entry(e.first, e.second.data(), e.second.size());
out.finalize(); out.finalize();
return changed;
}
// Rewrites the archive at `path` with the recipe entry back under the name it had before the
// rename, which is what every project saved by an earlier build looks like on disk. Generated
// rather than checked in because a whole project archive is not frozen evidence the way a bare
// recipe blob is -- it has to be whatever today's exporter writes, with only the name aged.
static void rename_cad_recipe_entry_to_legacy(const std::string& path)
{
const bool renamed = rewrite_3mf_entries(path, [](std::string& name, std::string&) {
if (!boost::algorithm::iequals(name, CAD_RECIPE_ENTRY))
return false;
name = LEGACY_CAD_RECIPE_ENTRY;
return true;
});
// Without this the scenario would degrade silently into re-testing the new name if the
// exporter's constant ever moved again: every load below would still pass.
REQUIRE(renamed);
}
// Replaces the first occurrence of `from` in any entry whose name ends with `suffix`.
static bool replace_in_3mf_entry(const std::string& path, const std::string& suffix, const std::string& from, const std::string& to)
{
bool replaced = false;
rewrite_3mf_entries(path, [&](std::string& name, std::string& data) {
if (replaced || !boost::algorithm::ends_with(name, suffix))
return false;
if (const size_t pos = data.find(from); pos != std::string::npos) {
data.replace(pos, from.size(), to);
replaced = true;
}
return replaced;
});
return replaced;
}
// Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and
// Extruder3, which the exporter writes as paint_color="8" and paint_color="0C".
static void store_painted_cube(const std::string& path)
{
Model model;
ModelObject* object = model.add_object();
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
{
TriangleSelector selector(volume->mesh());
selector.set_facet(0, EnforcerBlockerType::Extruder2);
selector.set_facet(1, EnforcerBlockerType::Extruder3);
REQUIRE(volume->mmu_segmentation_facets.set(selector));
}
ScopedTemporaryDir backup_dir("orca_paint_src");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig cfg;
PlateData plate;
plate.plate_index = 0;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
}
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
// importer stages metadata through `backup_dir`, which has to outlive the model.
static bool load_project(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
{
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
const bool loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, &project_presets, &is_bbl_3mf,
&is_orca_3mf, &file_version, nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
release_PlateData_list(plates);
return loaded;
}
TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]")
{
const int plate_id = GENERATE(0, -1);
INFO("plater_id " << plate_id);
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
{
ScopedTemporaryDir backup_dir("orca_plate_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
}
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", "key=\"plater_id\" value=\"1\"",
"key=\"plater_id\" value=\"" + std::to_string(plate_id) + "\""));
ScopedTemporaryDir backup_dir("orca_plate_dst");
Model model;
bool loaded = true;
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
REQUIRE_FALSE(loaded);
}
TEST_CASE("A project with malformed paint data loads without the damaged facet", "[3mf][Regression]")
{
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
// Split codes with no children behind them: the stream runs out mid-tree.
REQUIRE(replace_in_3mf_entry(temp.string(), ".model", "paint_color=\"8\"", "paint_color=\"FFFFFFFFFFFFFFFF3\""));
ScopedTemporaryDir backup_dir("orca_paint_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
const ModelVolume& volume = *model.objects.front()->volumes.front();
const auto& data = volume.mmu_segmentation_facets.get_data();
REQUIRE_FALSE(data.used_states[size_t(EnforcerBlockerType::Extruder2)]);
REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder3)]);
TriangleSelector selector(volume.mesh());
REQUIRE_NOTHROW(selector.deserialize(data));
REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder2) == 0);
REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder3) == 1);
} }
// The recipe lives only in the BBS-native backend, because that is the only one that runs: // The recipe lives only in the BBS-native backend, because that is the only one that runs:
@@ -3,8 +3,6 @@
#include "libslic3r/TriangleSelector.hpp" #include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/TriangleMesh.hpp" #include "libslic3r/TriangleMesh.hpp"
#include <algorithm>
using namespace Slic3r; using namespace Slic3r;
// A sphere gives well over ExtruderMax original facets, so every extruder state can be assigned // A sphere gives well over ExtruderMax original facets, so every extruder state can be assigned
@@ -125,77 +123,3 @@ TEST_CASE("Extruder states match the CONST_FILAMENTS hex encoding", "[TriangleSe
INFO("Hex " << c.hex << " -> extruder " << c.state); INFO("Hex " << c.hex << " -> extruder " << c.state);
REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType(c.state))); REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType(c.state)));
} }
// Pack 4-bit codes into a bitstream, least significant bit first, in the order the decoder reads them.
static std::vector<bool> pack_nibbles(const std::vector<int> &nibbles)
{
std::vector<bool> bitstream;
for (const int nibble : nibbles)
for (int bit = 0; bit < 4; ++bit)
bitstream.push_back((nibble >> bit) & 1);
return bitstream;
}
TEST_CASE("A valid paint stream with nested splits round-trips bit for bit", "[TriangleSelector]")
{
const TriangleMesh mesh = test_mesh();
TriangleSelector::TriangleSplittingData data;
data.triangles_to_split.emplace_back(0, 0);
// A three-side split whose children, in stream order, are: a one-side split (side 2) into two
// leaves, a two-side split (side 1) into leaves of states 20, 0 and 8, then two plain leaves.
const std::vector<int> triangle_0 = {0b0011,
0b1001, 0b1000, 0b0100,
0b0110, 0b1100, 0b1111, 20 - 18, 0b0000, 0b1100, 8 - 3,
0b1000,
0b0100};
data.bitstream = pack_nibbles(triangle_0);
data.triangles_to_split.emplace_back(5, int(data.bitstream.size()));
const std::vector<bool> triangle_5 = pack_nibbles({0b1100, 3 - 3});
data.bitstream.insert(data.bitstream.end(), triangle_5.begin(), triangle_5.end());
data.reset_used_states();
REQUIRE(data.update_used_states(0));
TriangleSelector restored(mesh);
restored.deserialize(data);
REQUIRE(restored.num_facets(EnforcerBlockerType::Extruder20) == 1);
REQUIRE(restored.num_facets(EnforcerBlockerType::Extruder3) == 1);
REQUIRE(restored.serialize() == data);
}
TEST_CASE("A truncated or malformed paint stream drops only the damaged triangle", "[TriangleSelector][Regression]")
{
struct Case { const char *name; std::vector<int> nibbles; };
const auto c = GENERATE(values<Case>({
{"three-side split missing two children", {0b0011, 0b1000, 0b1000}},
{"leaf missing its state nibble", {0b1100}},
{"leaf missing its second state nibble", {0b1100, 0b1111}},
{"splits nested past the end", {0b0011, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF,
0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF}},
{"one-side split of the nonexistent side 3", {0b1101, 0b1000, 0b1000}},
}));
INFO(c.name);
const TriangleMesh mesh = test_mesh();
TriangleSelector intact(mesh);
intact.set_facet(0, EnforcerBlockerType::Extruder2);
// Triangle 0 stays intact, triangle 1 carries the damaged stream.
TriangleSelector::TriangleSplittingData data = intact.serialize();
data.triangles_to_split.emplace_back(1, int(data.bitstream.size()));
const std::vector<bool> damaged = pack_nibbles(c.nibbles);
data.bitstream.insert(data.bitstream.end(), damaged.begin(), damaged.end());
TriangleSelector restored(mesh);
REQUIRE_NOTHROW(restored.deserialize(data));
// Triangle 1 unwinds completely, so the selector holds exactly the intact paint.
REQUIRE(restored.serialize() == intact.serialize());
REQUIRE_NOTHROW(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder3));
TriangleSelector::TriangleSplittingData recomputed = data;
recomputed.reset_used_states();
REQUIRE_FALSE(recomputed.update_used_states(0));
REQUIRE(std::none_of(recomputed.used_states.begin(), recomputed.used_states.end(), [](bool used) { return used; }));
}
+1
View File
@@ -18,6 +18,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_install.cpp test_plugin_install.cpp
test_plugin_lifecycle.cpp test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp test_plugin_printer_agent.cpp
test_printhost.cpp
test_slicing_pipeline_bindings.cpp test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_config.cpp test_slicing_pipeline_config.cpp
test_plugin_sort.cpp test_plugin_sort.cpp
+213
View File
@@ -0,0 +1,213 @@
#include <catch2/catch_all.hpp>
#include <nlohmann/json.hpp>
#include "slic3r/Utils/PrintHost.hpp"
using namespace Slic3r;
namespace {
class TestPrintHost : public PrintHost
{
public:
using PrintHost::format_error;
const char* get_name() const override { return "Test"; }
bool test(wxString&) const override { return true; }
wxString get_test_ok_msg() const override { return {}; }
wxString get_test_failed_msg(wxString&) const override { return {}; }
bool upload(PrintHostUpload, ProgressFn, ErrorFn, InfoFn) const override { return true; }
bool has_auto_discovery() const override { return false; }
bool can_test() const override { return false; }
PrintHostPostUploadActions get_post_upload_actions() const override { return {}; }
std::string get_host() const override { return {}; }
};
std::string format_error(const std::string& body, const std::string& error, unsigned status)
{
return TestPrintHost().format_error(body, error, status).ToStdString();
}
std::string envelope(int code, const std::string& message, const std::string& traceback)
{
return nlohmann::json{{"error", {{"code", code}, {"message", message}, {"traceback", traceback}}}}.dump();
}
std::string moonraker_error(int code, const std::string& message, const std::string& detail = {})
{
std::string line = "tornado.web.HTTPError: HTTP " + std::to_string(code) + ": " + message;
if (!detail.empty())
line += " (" + detail + ")";
return envelope(code, message, "Traceback (most recent call last):\n ...\n" + line + "\n");
}
// A real Moonraker body for uploading a file that is being printed.
constexpr const char* k_busy_file_403 =
R"JSON({"error": {"code": 403, "message": "Forbidden", "traceback": "Traceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/file_manager/file_manager.py\", line 1017, in _finish_gcode_upload\n can_start = self._handle_operation_check(check_path)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nmoonraker.utils.exceptions.ServerError: File currently in use\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/application.py\", line 1069, in post\n raise tornado.web.HTTPError(\ntornado.web.HTTPError: HTTP 403: Forbidden (File is loaded, upload not permitted)\n"}})JSON";
} // namespace
TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]")
{
const std::string msg = format_error(k_busy_file_403, "", 403);
INFO("actual: " << msg);
CHECK(msg == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("Traceback"));
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("file_manager.py"));
}
TEST_CASE("The specific cause is recovered from a file endpoint's traceback", "[PrintHost]")
{
SECTION("a plain detail")
{
const std::string body = moonraker_error(403, "Forbidden", "File is loaded, upload not permitted");
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
}
SECTION("a detail whose own parentheses nest (a filename)")
{
const std::string detail = "Directory does not exist (/home/pi/gcodes/plate (1).gcode)";
const std::string body = moonraker_error(400, "Bad Request", detail);
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
}
SECTION("a detail that contains the reason phrase")
{
const std::string body = moonraker_error(403, "Forbidden", "Forbidden zone: access denied");
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (Forbidden zone: access denied)");
}
SECTION("a detail that spans lines")
{
const std::string detail = "Move out of range\nX=250.000 Y=10.000";
const std::string body = moonraker_error(400, "Bad Request", detail);
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
}
SECTION("a detail that only repeats the reason phrase is dropped")
{
const std::string body = moonraker_error(401, "Unauthorized", "Unauthorized");
CHECK(format_error(body, "", 401) == "HTTP 401: Unauthorized");
}
}
TEST_CASE("An unhandled exception shows its type and message", "[PrintHost]")
{
const std::string frame = "Traceback (most recent call last):\n"
" File \"/home/pi/moonraker/moonraker/components/file_manager/file_manager.py\", line 1, in write\n"
" self._write(data)\n";
SECTION("a one-line message")
{
const std::string body = envelope(500, "Internal Server Error", frame + "OSError: [Errno 28] No space left on device\n");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 28] No space left on device)");
}
SECTION("a message that spans lines")
{
const std::string body = envelope(500, "Internal Server Error", frame + "ServerError: Klippy request failed\n see klippy.log\n");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (ServerError: Klippy request failed\n see klippy.log)");
}
SECTION("raised while handling an HTTPError with the same code")
{
const std::string traceback = frame + "tornado.web.HTTPError: HTTP 500: Internal Server Error (Database locked)\n\n"
"During handling of the above exception, another exception occurred:\n\n" +
frame + "OSError: [Errno 5] Input/output error\n";
const std::string body = envelope(500, "Internal Server Error", traceback);
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
}
SECTION("a traceback with no header")
{
const std::string body = envelope(500, "Internal Server Error", "OSError: [Errno 5] Input/output error");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
}
}
TEST_CASE("A reason already complete in message is shown unchanged", "[PrintHost]")
{
SECTION("message is the whole reason, no trailing detail")
{
const std::string body = moonraker_error(503, "Klippy is not ready");
CHECK(format_error(body, "", 503) == "HTTP 503: Klippy is not ready");
}
SECTION("a message that itself contains parentheses is not duplicated")
{
const std::string reason = "Requested blocks (0-5) are unavailable";
const std::string body = moonraker_error(400, reason);
CHECK(format_error(body, "", 400) == "HTTP 400: " + reason);
}
}
TEST_CASE("A Moonraker error with no usable detail shows just the reason phrase", "[PrintHost]")
{
struct Case
{
const char* name;
const char* body;
unsigned status;
const char* expected;
};
const auto c = GENERATE(
Case{"an empty traceback", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": ""}})JSON", 500,
"HTTP 500: Internal Server Error"},
Case{"a traceback of only whitespace", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": "\n \n"}})JSON",
500, "HTTP 500: Internal Server Error"});
DYNAMIC_SECTION(c.name) { CHECK(format_error(c.body, "", c.status) == c.expected); }
}
TEST_CASE("A percent sign in the reason is not a format specifier", "[PrintHost]")
{
const std::string body = moonraker_error(507, "Insufficient Storage", "disk 100% full");
CHECK(format_error(body, "", 507) == "HTTP 507: Insufficient Storage (disk 100% full)");
}
TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", "[PrintHost]")
{
SECTION("OctoPrint's string-valued error member")
{
const std::string body = R"JSON({"error": "File not found"})JSON";
CHECK(format_error(body, "", 404) == "HTTP 404: " + body);
}
SECTION("PrusaLink's top-level message, not under error")
{
const std::string body = R"JSON({"title": "Conflict", "message": "Printer is printing"})JSON";
CHECK(format_error(body, "", 409) == "HTTP 409: " + body);
}
SECTION("a body that is not JSON")
{
const std::string html = "<html><head><title>502 Bad Gateway</title></head></html>";
CHECK(format_error(html, "", 502) == "HTTP 502: " + html);
}
SECTION("an error object with no traceback")
{
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error"}})JSON";
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
}
SECTION("an error object whose traceback is null")
{
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": null}})JSON";
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
}
SECTION("an envelope whose reason phrase is empty")
{
const std::string body = envelope(403, "", "Traceback (most recent call last):\nOSError: denied\n");
CHECK(format_error(body, "", 403) == "HTTP 403: " + body);
}
SECTION("a transport error with no HTTP status")
{
CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect");
}
}