From 770748725253bca43d95c40245db96e7b88eba28 Mon Sep 17 00:00:00 2001 From: Ioannis Giannakas <59056762+igiannakas@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:59:39 +0100 Subject: [PATCH 1/8] Fix overhang slowdown and fan applied to whole walls ahead of an overhang (#15945) --- src/libslic3r/GCode.cpp | 51 ++--- src/libslic3r/GCode/ExtrusionProcessor.hpp | 184 +++++++++++-------- tests/fff_print/test_extrusion_processor.cpp | 93 ++++++++++ 3 files changed, 231 insertions(+), 97 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 77a05ed171..941818c78f 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -7969,6 +7969,20 @@ double GCode::calc_max_volumetric_speed(const double layer_height, const double return res; } +// ORCA: Overlap at or below which the overhang fan switches on; negative when it does not depend on overlap +// (Overhang_threshold_none cools every external perimeter). +static float overhang_fan_overlap_threshold(int overhang_fan_threshold) +{ + switch (overhang_fan_threshold) { + case (int) Overhang_threshold_1_4: return 0.9f; + case (int) Overhang_threshold_2_4: return 0.75f; + case (int) Overhang_threshold_3_4: return 0.5f; + case (int) Overhang_threshold_4_4: return 0.25f; + case (int) Overhang_threshold_bridge: return 0.05f; + default: return -1.f; + } +} + std::string GCode::_extrude(const ExtrusionPath &path, std::string description, double speed) { std::string gcode; @@ -8311,6 +8325,13 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, ConfigOptionPercents overhang_overlap_levels({90, 75, 50, 25, 13, 0}); + // ORCA: Lets the path be split where the overhang fan switches, not only where the speed changes. + // Bridges and overhang perimeters are cooled regardless of overlap. + float fan_overlap_threshold = -1.f; + if (FILAMENT_CONFIG(enable_overhang_bridge_fan) && m_enable_cooling_markers && path.role() != erBridgeInfill && + path.role() != erOverhangPerimeter) + fan_overlap_threshold = overhang_fan_overlap_threshold(FILAMENT_CONFIG(overhang_fan_threshold)); + if (NOZZLE_CONFIG(slowdown_for_curled_perimeters)){ ConfigOptionFloatsOrPercents dynamic_overhang_speeds( {FloatOrPercent{100, true}, @@ -8331,7 +8352,8 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, FloatOrPercent{NOZZLE_CONFIG(overhang_4_4_speed).get_abs_value(ref_speed) * 100 / ref_speed, true}}); new_points = m_extrusion_quality_estimator.estimate_extrusion_quality(path, overhang_overlap_levels, dynamic_overhang_speeds, - ref_speed, speed, NOZZLE_CONFIG(slowdown_for_curled_perimeters)); + ref_speed, speed, NOZZLE_CONFIG(slowdown_for_curled_perimeters), + fan_overlap_threshold); }else{ ConfigOptionFloatsOrPercents dynamic_overhang_speeds( {FloatOrPercent{100, true}, @@ -8350,7 +8372,8 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, FloatOrPercent{NOZZLE_CONFIG(bridge_speed) * 100 / ref_speed, true}}); new_points = m_extrusion_quality_estimator.estimate_extrusion_quality(path, overhang_overlap_levels, dynamic_overhang_speeds, - ref_speed, speed, NOZZLE_CONFIG(slowdown_for_curled_perimeters)); + ref_speed, speed, NOZZLE_CONFIG(slowdown_for_curled_perimeters), + fan_overlap_threshold); } variable_speed = std::any_of(new_points.begin(), new_points.end(), [speed](const ProcessedPoint &p) { return fabs(double(p.speed) - speed) > 1; }); // Ignore small speed variations (under 1mm/sec) @@ -8502,28 +8525,10 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, if (role == erBridgeInfill || role == erOverhangPerimeter) { // ORCA: Split out bridge infill to internal and external to apply separate fan settings return true; } - switch (overhang_fan_threshold) { - case (int)Overhang_threshold_1_4: - return overlap <= 0.9f; - break; - case (int)Overhang_threshold_2_4: - return overlap <= 0.75f; - break; - case (int)Overhang_threshold_3_4: - return overlap <= 0.5f; - break; - case (int)Overhang_threshold_4_4: - return overlap <= 0.25f; - break; - case (int)Overhang_threshold_bridge: - return overlap <= 0.05f; - break; - case (int)Overhang_threshold_none: + if (overhang_fan_threshold == Overhang_threshold_none) return is_external_perimeter(role); - break; - default: - return false; - } + const float overlap_threshold = overhang_fan_overlap_threshold(overhang_fan_threshold); + return overlap_threshold >= 0.f && overlap <= overlap_threshold; }; std::string comment; diff --git a/src/libslic3r/GCode/ExtrusionProcessor.hpp b/src/libslic3r/GCode/ExtrusionProcessor.hpp index 1d65e83f3e..8ed829aa6e 100644 --- a/src/libslic3r/GCode/ExtrusionProcessor.hpp +++ b/src/libslic3r/GCode/ExtrusionProcessor.hpp @@ -41,10 +41,12 @@ std::vector> estimate_points_properties(const POINTS& float flow_width, float max_line_length = -1.0f, float min_distance = -1.0f, - // Maps an overhang distance onto the speed it will be printed at. Interior sampling - // needs it to tell which of the points it could add would change the G-code, and is - // skipped without it. - const std::function& distance_to_speed = {}) + // Speed an overhang distance prints at. Without it, interior sampling + // is skipped and every line over 4mm is split. + const std::function& distance_to_speed = {}, + // Overlap (1 - distance / flow_width) at or below which the overhang + // fan switches on; negative when the fan does not depend on overlap. + float fan_overlap_threshold = -1.0f) { bool looped = input_points.front() == input_points.back(); std::function get_prev_index = [](size_t idx, size_t count) { @@ -125,29 +127,43 @@ std::vector> estimate_points_properties(const POINTS& points.push_back(next_point); } + // ORCA: How an overhang distance prints, which is what the passes below compare. A segment is printed at the lower + // of the speeds at its two ends, and with the overhang fan on if the overlap at either end turns it on. A point + // added to a path can therefore only change the G-code where it prints at a different speed or fan state from the + // points either side of it, and the passes below add points there and nowhere else. + const float width_inv = 1.f / flow_width; + // Whether an overhang distance turns the overhang fan on. The overlap test check_overhang_fan applies in GCode.cpp. + auto fan_on = [fan_overlap_threshold, width_inv](float distance) { + return fan_overlap_threshold >= 0.f && 1.f - distance * width_inv <= fan_overlap_threshold; + }; + // Whether two overhang distances are interchangeable ie have the same speed (beyond a 1mm/sec threshold that gcode.cpp filters out on) + // and the same fan state. + auto same_speed_and_fan = [&distance_to_speed, &fan_on](float a, float b) { + return std::abs(distance_to_speed(a) - distance_to_speed(b)) <= 1.f && fan_on(a) == fan_on(b); + }; + // Whether the first overhang distance prints slower than the second, beyond the 1mm/sec gcode.cpp tolerance, or turns the overhang + // fan on where the second does not. Against a supported point (overhang distance 0) it tells whether an end is + // affected by the overhang. + auto slower_or_cooled = [&distance_to_speed, &fan_on](float a, float b) { + return distance_to_speed(a) < distance_to_speed(b) - 1.f || (fan_on(a) && !fan_on(b)); + }; + // ORCA: Interior sampling - // The passes below infer the support under a span from its endpoints alone, so an interior that is supported - // differently from both ends is invisible to them: the outer perimeter of an overhang whose ends are caged by - // full height walls reads as supported along its whole length. Probe the interior, keep the samples the - // endpoint interpolation fails to predict, and bisect either side of each one, so a span that is only partly - // unsupported gets points where its support actually changes instead of one reading spread across all of it. - if (PREV_LAYER_BOUNDARY_OFFSET && ADD_INTERSECTIONS && min_distance > 0 && distance_to_speed) { + // The passes below infer the support under a span from its endpoints alone, so a part that is supported + // differently from both ends is invisible to them. The outer perimeter of an overhang whose ends are supported by + // reads as supported along its whole length. Probe the interior, keep the samples the endpoint interpolation fails to predict, + // and bisect either side of each one, so a span that is only partly unsupported gets points where its support actually changes + // instead of one reading being spread across all of it. + // Skipped where there is nothing to find: min_distance <= 0 when no overhang can slow this path down, and + // fan_overlap_threshold < 0 when no overhang switches the fan on. It also needs distance_to_speed to tell which of + // the points it could add would change the G-code. + if (PREV_LAYER_BOUNDARY_OFFSET && ADD_INTERSECTIONS && distance_to_speed && (min_distance > 0 || fan_overlap_threshold >= 0.f)) { // Probe at least this densely before treating matching samples as evidence that a span is uniform. The // segmentation pass below only splits lines of 2mm or more, and every pass here drops points closer - // together than min_spacing, so finer discovery would not produce a more precise speed transition. + // together than min_spacing, so finer discovery would not produce a more precise transition. const double max_probe_spacing = std::max(2., 4. * min_spacing); // A backstop for that length test, which on a non-finite length would never be met. constexpr int max_bisection_depth = 10; - // Whether two readings are interchangeable. A segment is printed at the lower of the speeds its ends - // read, so a sample that agrees on speed with what is already known cannot change the G-code, whatever - // its distance says. The distances themselves are far too coarse a stand-in for this: the speed sections - // interpolate, so readings a small fraction of min_distance apart can still be tens of mm/s apart. - // The tolerance matches the one GCode.cpp applies when it decides a path has a variable speed at all. - auto same_speed = [&distance_to_speed](float a, float b) { - return std::abs(distance_to_speed(a) - distance_to_speed(b)) <= 1.f; - }; - // Whether the first reading is printed slower than the second, once they are known to differ. - auto prints_slower = [&distance_to_speed](float a, float b) { return distance_to_speed(a) < distance_to_speed(b); }; // Part of a segment still to bisect: its positions along the segment and bisections left. struct Subspan { double t0, t1; int depth; }; @@ -185,8 +201,8 @@ std::vector> estimate_points_properties(const POINTS& if (!interior.empty()) { std::sort(interior.begin(), interior.end(), [](const std::pair& l, const std::pair& r) { return l.first < r.first; }); - // Coarse probing keeps every sample it took until this pass can see which ones bracket a speed - // transition. Matching samples cannot be discarded during discovery: one may be the last + // Coarse probing keeps every sample it took until this pass can see which ones bracket a speed or + // fan transition. Matching samples cannot be discarded during discovery: one may be the last // supported point before a narrow unsupported pocket found by a later probe. size_t kept = 0; for (size_t i = 0; i < interior.size(); ++i) { @@ -195,15 +211,15 @@ std::vector> estimate_points_properties(const POINTS& const bool at_end = i + 1 == interior.size(); // And nothing follows the last sample but the segment's end const float before = at_start ? curr.distance : interior[kept - 1].second; const float after = at_end ? next.distance : interior[i + 1].second; - // A sample is worth a point in the path only where it prints at a different speed from the - // readings either side of it. Differing from one of the segment's own ends is not enough on - // its own where the sample is the faster of the two: the segmentation pass below already - // ends the slowdown an end reads, at a distance taken from how far out that end is rather - // than from wherever bisection happened to stop, and a point here would leave the span - // beside the end too short for that pass to run at all. Support an end cannot account for, - // where the interior is the slower reading, is exactly what this pass is here to find. - const bool worth_before = !same_speed(sample, before) && (!at_start || prints_slower(sample, before)); - const bool worth_after = !same_speed(sample, after) && (!at_end || prints_slower(sample, after)); + // A sample is worth a point in the path only where it prints differently, in speed or fan state, + // from the points either side of it. Differing from one of the segment's own ends is not enough on + // its own where the sample is not the slower or cooled of the two: the segmentation pass below + // already confines the slowdown and cooling at an end, at a distance taken from how far out that + // end is rather than from wherever bisection happened to stop, and a point here would leave the + // span beside the end too short for that pass to run at all. Support an end cannot account for, + // where the interior is the slower or cooled of the two, is exactly what this pass is here to find. + const bool worth_before = !same_speed_and_fan(sample, before) && (!at_start || slower_or_cooled(sample, before)); + const bool worth_after = !same_speed_and_fan(sample, after) && (!at_end || slower_or_cooled(sample, after)); if (worth_before || worth_after) interior[kept++] = interior[i]; } @@ -238,52 +254,60 @@ std::vector> estimate_points_properties(const POINTS& if ((curr.distance > -boundary_offset && curr.distance < boundary_offset + 2.0f) || (next.distance > -boundary_offset && next.distance < boundary_offset + 2.0f)) { double line_len = (next.position - curr.position).norm(); - - // ORCA: Segment path to smaller lines by adding additional points only if the path has an overhang that - // will trigger a slowdown and the path is also reasonably large, i.e. 2mm in length or more - // If there is no overhang in the start/end point, dont segment it. - // Ignore this check if the control of segmentation for overhangs is disabled (min_distance=-1) - if ((min_distance > 0 && ((std::abs(curr.distance) > min_distance) || (std::abs(next.distance) > min_distance)) && line_len >= 2.f) || - (min_distance <= 0 && line_len > 4.0f)) { + + // ORCA: A line prints as slow as its slower end and is cooled if either end is, so an overhang at one + // end would otherwise slow down or cool the whole line. Split the line so that only the part beside + // that end prints that way, if the line is reasonably long (2mm or more) and at least one end prints + // slower or cooled compared with a supported point (overhang distance 0). Deciding on how the end + // prints, rather than on its overhang distance against min_distance, also catches an end whose overhang + // distance is exactly where the slowdown begins, such as an outline crossing at half a line width. + // Without distance_to_speed, split every line over 4mm. + const bool split_line = distance_to_speed ? + line_len >= 2.f && (slower_or_cooled(curr.distance, 0.f) || slower_or_cooled(next.distance, 0.f)) : + line_len > 4.0f; + if (split_line) { + // Each end's piece is that end's overhang distance plus 1.5 line widths (3 * boundary_offset) long: + // a0 ends the piece beside curr, a1 starts the piece beside next. double a0 = std::clamp((curr.distance + 3 * boundary_offset) / line_len, 0.0, 1.0); double a1 = std::clamp(1.0f - (next.distance + 3 * boundary_offset) / line_len, 0.0, 1.0); double t0 = std::min(a0, a1); double t1 = std::max(a0, a1); - if (t0 < 1.0) { - Vec p0 = curr.position + t0 * (next.position - curr.position); - auto [p0_dist, p0_near_l, p0_x] = unscaled_prev_layer.template distance_from_lines_extra( - p0.template cast()); - ExtendedPoint new_p{}; - new_p.position = p0; - new_p.distance = float(p0_dist + boundary_offset); - // ORCA: only create a new point in the path if the new point overhang distance will be used to generate a speed change - // or if this option is disabled (min_distance<=0) - if( (std::abs(p0_dist) > min_distance) || (min_distance<=0)){ - // ORCA: also filter out points that are introduced to the start of the path when their distance from the start point is - // not meaningful - if ((p0 - curr.position).norm() > min_spacing && (next.position - p0).norm() > min_spacing) { - new_points.push_back(new_p); - } - } + // Up to two cut points, in order along the line. Each takes its own overhang distance, so every + // piece prints by the overhang distances at its own two ends. t0 >= 1 or t1 <= 0 falls on the + // line's own end, so there is no cut. A cut closer than min_spacing to either end of the line is + // not meaningful and is filtered out (#6714). + ExtendedPoint cut[2]{}; + bool keep[2] = {false, false}; + for (int k = 0; k < 2; ++k) { + const double t = k == 0 ? t0 : t1; + if (k == 0 ? t >= 1.0 : t <= 0.0) + continue; + const Vec p = curr.position + t * (next.position - curr.position); + auto [p_dist, p_near_l, p_x] = unscaled_prev_layer.template distance_from_lines_extra( + p.template cast()); + cut[k].position = p; + cut[k].distance = float(p_dist + boundary_offset); + keep[k] = (p - curr.position).norm() > min_spacing && (next.position - p).norm() > min_spacing; } - if (t1 > 0.0) { - Vec p1 = curr.position + t1 * (next.position - curr.position); - auto [p1_dist, p1_near_l, p1_x] = unscaled_prev_layer.template distance_from_lines_extra( - p1.template cast()); - ExtendedPoint new_p{}; - new_p.position = p1; - new_p.distance = float(p1_dist + boundary_offset); - // ORCA: only create a new point in the path if the new point overhang distance will be used to generate a speed change - // or if this option is disabled (min_distance<=0) - if( (std::abs(p1_dist) > min_distance) || (min_distance<=0)){ - // ORCA: filter out points that are introduced to the end of the path when their distance from the end point is - // not meaningful - if ((p1 - curr.position).norm() > min_spacing && (next.position - p1).norm() > min_spacing) { - new_points.push_back(new_p); - } - } + if (distance_to_speed) { + // Only keep a cut that changes the G-code: one that prints differently from at least one of the + // points either side of it, which are the line's ends or the other cut where that is kept. A cut + // that prints like both would only split a move into two identical ones. + if (keep[0]) + keep[0] = !same_speed_and_fan(cut[0].distance, curr.distance) || + !same_speed_and_fan(cut[0].distance, keep[1] ? cut[1].distance : next.distance); + if (keep[1]) + keep[1] = !same_speed_and_fan(cut[1].distance, keep[0] ? cut[0].distance : curr.distance) || + !same_speed_and_fan(cut[1].distance, next.distance); + // Two cuts closer together than min_spacing would leave a micro segment between them, so only the + // first is kept. + if (keep[0] && keep[1] && (cut[1].position - cut[0].position).norm() <= min_spacing) + keep[1] = false; } + for (int k = 0; k < 2; ++k) + if (keep[k]) + new_points.push_back(cut[k]); } } new_points.push_back(next); @@ -423,7 +447,10 @@ public: const ConfigOptionFloatsOrPercents &speeds, float ext_perimeter_speed, float original_speed, - bool slowdown_for_curled_edges) + bool slowdown_for_curled_edges, + // Overlap at or below which the overhang fan switches on; negative when the fan + // does not depend on overlap. + float fan_overlap_threshold = -1.0f) { size_t speed_sections_count = std::min(overlaps.values.size(), speeds.values.size()); std::vector> speed_sections; @@ -463,7 +490,8 @@ public: } } - // If a meaningful (i.e. needing slowdown) overhang distance was not found, then we shouldn't split the lines + // If no overhang distance slows this path down, -1 turns interior sampling off unless the overhang fan can switch. + // Lines are only split where an end prints slower or cooled, so here only a fan switch splits them. if (!found) smallest_distance_with_lower_speed=-1.f; @@ -487,9 +515,17 @@ public: return round(final_speed); }; + // ORCA: The speed sections are built from ext_perimeter_speed, which can be above the speed this path prints at + // (original_speed, e.g. held down by resonance avoidance). Every segment is capped at original_speed below, so + // overhang distances whose speeds differ only above it print the same and must not count as a speed change when + // the path is split. + auto effective_speed = [&calculate_speed, original_speed](float distance) { + return std::min(calculate_speed(distance), original_speed); + }; + std::vector> extended_points = estimate_points_properties(path.polyline.points, prev_layer_boundaries[current_object], path.width, -1, - smallest_distance_with_lower_speed, calculate_speed); + smallest_distance_with_lower_speed, effective_speed, fan_overlap_threshold); const auto width_inv = 1.0f / path.width; std::vector processed_points; processed_points.reserve(extended_points.size()); diff --git a/tests/fff_print/test_extrusion_processor.cpp b/tests/fff_print/test_extrusion_processor.cpp index 76e331d66a..47f9a62ecf 100644 --- a/tests/fff_print/test_extrusion_processor.cpp +++ b/tests/fff_print/test_extrusion_processor.cpp @@ -244,6 +244,44 @@ float furthest_reading(const std::vector>& points) })->distance; } +// A wall along a supported edge of the previous layer, ending past or just short of the edge's end. Crossing the edge's +// end reads half a line width out. +constexpr double edge_run_length = 64.; // mm, wall start, measured from the end of the previous layer's edge +constexpr double edge_step = 0.384; // mm, how far this layer's contour extends past the previous layer's end +// The centreline is inset half a line width from the contour. +constexpr double edge_wall_end_past = edge_step - 0.5 * caged_wall_width; +constexpr double edge_wall_end_short = 0.05; // mm short of the edge, reading 0.21 - 0.05 = 0.16mm out +// Segmentation splits 1.5 line widths plus the end's reading from an end, so an end's slowdown and cooling stay within this. +constexpr double edge_affected_length = 3. * caged_wall_width; + +std::vector> sampled_wall_along_edge(double wall_end_x, + const std::function& distance_to_speed, + float min_distance, + float fan_overlap_threshold) +{ + const AABBTreeLines::LinesDistancer prev_layer(std::vector{ + {{0., 0.}, {edge_run_length + 10., 0.}}, + {{edge_run_length + 10., 0.}, {edge_run_length + 10., -10.}}, + {{edge_run_length + 10., -10.}, {0., -10.}}, + {{0., -10.}, {0., 0.}}, + }); + const double wall_y = -0.5 * caged_wall_width; + const Points wall{Point::new_scale(edge_run_length, wall_y), Point::new_scale(wall_end_x, wall_y)}; + + return estimate_points_properties(wall, prev_layer, caged_wall_width, -1.f, min_distance, + distance_to_speed, fan_overlap_threshold); +} + +// Length printed with the overhang fan on: segments with either end's overlap at or below the threshold. +double cooled_length(const std::vector>& points, float fan_overlap_threshold) +{ + double length = 0.; + for (size_t i = 0; i + 1 < points.size(); ++i) + if (1.f - std::max(points[i].distance, points[i + 1].distance) / float(caged_wall_width) <= fan_overlap_threshold) + length += (points[i + 1].position - points[i].position).norm(); + return length; +} + DynamicPrintConfig caged_overhang_config(const char* wall_generator){ DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.set_deserialize_strict({ @@ -431,6 +469,61 @@ TEST_CASE("A supported wall between overhanging corners is slowed no further tha REQUIRE(sampled <= unsampled); } +// Regression: the line up to a step past the previous layer was not split, so the step's slowdown and cooling covered the +// whole wall. The split required an end reading beyond where the slowdown begins, and an edge crossing reads exactly +// there when the wall speed is held below the reference speed (e.g. resonance avoidance). +TEST_CASE("A wall stepping past the previous layer is slowed and cooled only beside the step", "[ExtrusionProcessor][Regression]") +{ + const float crossing_reading = 0.5f * float(caged_wall_width); + const std::function distance_to_speed = [crossing_reading](float distance) { + return distance < crossing_reading ? 70.f : 15.f; + }; + const float fan_overlap_threshold = 0.75f; // The fan switches on at a 25% overhang + + const std::vector> points = sampled_wall_along_edge(-edge_wall_end_past, distance_to_speed, crossing_reading, + fan_overlap_threshold); + const double slowed = slowed_length(points, distance_to_speed); + const double cooled = cooled_length(points, fan_overlap_threshold); + + REQUIRE(slowed > 0.); + REQUIRE(cooled > 0.); + REQUIRE(slowed < edge_affected_length); + REQUIRE(cooled < edge_affected_length); +} + +// Regression: the fan can switch on at a smaller overhang than the first slowdown. Splitting only on speed changes left +// the whole wall cooled when its end read between the two. +TEST_CASE("A wall is split where only the overhang fan changes", "[ExtrusionProcessor][Regression]") +{ + const float crossing_reading = 0.5f * float(caged_wall_width); + const std::function distance_to_speed = [crossing_reading](float distance) { + return distance < crossing_reading ? 70.f : 15.f; + }; + // The end reads 0.16mm out (overlap 0.62): cooled at a 25% threshold, but not slowed. + const float fan_overlap_threshold = 0.75f; + + const std::vector> points = sampled_wall_along_edge(edge_wall_end_short, distance_to_speed, crossing_reading, + fan_overlap_threshold); + const double cooled = cooled_length(points, fan_overlap_threshold); + + REQUIRE_THAT(slowed_length(points, distance_to_speed), Catch::Matchers::WithinAbs(0., 1e-9)); + REQUIRE(cooled > 0.); + REQUIRE(cooled < edge_affected_length); +} + +// With one speed and a fan threshold no reading reaches, only the wall's ends and the edge crossing remain. +TEST_CASE("A wall is left whole where neither its speed nor its cooling changes", "[ExtrusionProcessor]") +{ + const std::function distance_to_speed = [](float) { return 70.f; }; + // 95% overhang; the step reads 0.384mm out (overlap 0.09). + const float fan_overlap_threshold = 0.05f; + + const std::vector> points = sampled_wall_along_edge(-edge_wall_end_past, distance_to_speed, -1.f, + fan_overlap_threshold); + + REQUIRE(points.size() == 3); +} + TEST_CASE("Benchmark caged overhang interior sampling", "[ExtrusionProcessor][!benchmark]"){ const char* wall_generator = GENERATE("classic", "arachne"); From 00bde9265bb6602e17439d68e20c6f203d8d154d Mon Sep 17 00:00:00 2001 From: Kenneth Raplee <101818165+kenrap@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:42:25 +0000 Subject: [PATCH 2/8] Add missing OCCT (8.x) header includes (#15825) * Add missing TopTools includes to GeometryEngine.cpp * Add missing TDF_LabelSequence include in STEP.cpp --------- Co-authored-by: yw4z --- src/libslic3r/CAD/GeometryEngine.cpp | 2 ++ src/libslic3r/Format/STEP.cpp | 1 + 2 files changed, 3 insertions(+) diff --git a/src/libslic3r/CAD/GeometryEngine.cpp b/src/libslic3r/CAD/GeometryEngine.cpp index ede3924056..2b923938c2 100644 --- a/src/libslic3r/CAD/GeometryEngine.cpp +++ b/src/libslic3r/CAD/GeometryEngine.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/src/libslic3r/Format/STEP.cpp b/src/libslic3r/Format/STEP.cpp index 22e39b207f..8305174e73 100644 --- a/src/libslic3r/Format/STEP.cpp +++ b/src/libslic3r/Format/STEP.cpp @@ -25,6 +25,7 @@ #include "XCAFDoc_DocumentTool.hxx" #include "XCAFDoc_ShapeTool.hxx" #include "XCAFApp_Application.hxx" +#include "TDF_LabelSequence.hxx" #include "TopoDS_Solid.hxx" #include "TopoDS_Compound.hxx" #include "TopoDS_Builder.hxx" From f3a8f711fd4e57b685470b514428c3b6ddc0e754 Mon Sep 17 00:00:00 2001 From: Kenneth Raplee <101818165+kenrap@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:44:32 +0000 Subject: [PATCH 3/8] Catch Standard_Failure before std::exception (OCCT >= 8) (#15826) Co-authored-by: yw4z --- src/libslic3r/CAD/CadDocument.cpp | 4 ++-- src/slic3r/GUI/CAD/DesignPanel.cpp | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/CAD/CadDocument.cpp b/src/libslic3r/CAD/CadDocument.cpp index c20b636ae7..ea20ffd966 100644 --- a/src/libslic3r/CAD/CadDocument.cpp +++ b/src/libslic3r/CAD/CadDocument.cpp @@ -2673,10 +2673,10 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, ridge = pipe.Shape(); have_ridge = !ridge.IsNull(); } + } catch (const Standard_Failure&) { + have_ridge = false; // OCCT failure — on OCCT >= 8 Standard_Failure derives from std::exception, so this handler must come first } catch (const std::exception&) { have_ridge = false; // fall back to the bare cylinder/bore below - } catch (const Standard_Failure&) { - have_ridge = false; // OCCT failure (not a std::exception) — must be caught here too } if (f.thread_internal) { diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 0b4f86a618..d247ddfd26 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -4862,13 +4862,13 @@ void DesignPanel::on_import_mesh() try { shape = GeometryEngine::mesh_to_brep(mesh.its, MESH_IMPORT_TOLERANCE, MESH_IMPORT_MERGE_ANGLE_DEG, stats); - } catch (const std::exception& e) { - fail(_L("Mesh conversion failed: ") + wxString::FromUTF8(e.what())); - return; - } catch (const Standard_Failure& e) { // OCCT throws outside std::exception + } catch (const Standard_Failure& e) { // on OCCT >= 8 Standard_Failure derives from std::exception — must precede that handler fail(_L("Mesh conversion failed: ") + wxString::FromUTF8( e.GetMessageString() ? e.GetMessageString() : "OCCT error")); return; + } catch (const std::exception& e) { + fail(_L("Mesh conversion failed: ") + wxString::FromUTF8(e.what())); + return; } if (shape.IsNull()) { fail(_L("Mesh conversion produced no geometry")); return; } From 576cce2f72d2191cbbaa9ffe96ae0a4a2c588f2b Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Mon, 28 Sep 2026 18:47:41 +0800 Subject: [PATCH 4/8] test: bump elegoo profile to test OFL OTA E2E [TO BE REVERTED] (#15954) Merged by /bot merge on behalf of @peachismomo (id 52488812). Grants: resources/profiles/OrcaFilamentLibrary/filament/Elegoo, resources/profiles/OrcaFilamentLibrary.json, resources/profiles/Elegoo, resources/profiles/Elegoo.json Head: d4a18c633e31600b16b848d42799a93e96ddd67f --- resources/profiles/OrcaFilamentLibrary.json | 2 +- .../OrcaFilamentLibrary/filament/Elegoo/Elegoo PLA @base.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/profiles/OrcaFilamentLibrary.json b/resources/profiles/OrcaFilamentLibrary.json index 3d8a191dc6..f54e45df5a 100644 --- a/resources/profiles/OrcaFilamentLibrary.json +++ b/resources/profiles/OrcaFilamentLibrary.json @@ -1,6 +1,6 @@ { "name": "OrcaFilamentLibrary", - "version": "02.04.00.12", + "version": "02.04.00.13", "force_update": "0", "description": "Orca Filament Library", "filament_list": [ diff --git a/resources/profiles/OrcaFilamentLibrary/filament/Elegoo/Elegoo PLA @base.json b/resources/profiles/OrcaFilamentLibrary/filament/Elegoo/Elegoo PLA @base.json index 3ba4c8c14f..624c4ed95b 100644 --- a/resources/profiles/OrcaFilamentLibrary/filament/Elegoo/Elegoo PLA @base.json +++ b/resources/profiles/OrcaFilamentLibrary/filament/Elegoo/Elegoo PLA @base.json @@ -6,7 +6,7 @@ "filament_id": "OFvgE0Zh", "instantiation": "false", "fan_cooling_layer_time": [ - "80" + "81" ], "fan_min_speed": [ "50" From 77f8c64d37a75f3aa5835c8b69f0e4e4e3f04354 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Mon, 28 Sep 2026 19:53:23 +0800 Subject: [PATCH 5/8] fix: OFL workflow checkpoint and clear ordering --- .github/workflows/ofl-ota-cronjob.yml | 140 +++++++++++++++++----- .github/workflows/post_merge_profiles.yml | 15 +++ 2 files changed, 122 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ofl-ota-cronjob.yml b/.github/workflows/ofl-ota-cronjob.yml index 3192cc4f05..c7feeca5fb 100644 --- a/.github/workflows/ofl-ota-cronjob.yml +++ b/.github/workflows/ofl-ota-cronjob.yml @@ -12,9 +12,9 @@ name: Daily OFL OTA Update # vendor-dispatch path is also what makes post_merge_profiles.yml call the OTA auto-publish API after # uploading - see post_merge_profiles.yml for both sides of that contract. # -# At the start of each run, the pending-publish table is cleared up to a captured -# timestamp (POST /api/v1/ota/ofl/pending/clear?timestamp=...). Changes merged after -# that timestamp remain pending for the next run. +# Each run captures a timestamp, dispatches the needed OFL publishers, waits for +# all of them to finish, then clears the pending-publish table once. Changes merged +# after that timestamp remain pending for the next run. on: schedule: @@ -34,32 +34,14 @@ jobs: if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' }} runs-on: ubuntu-24.04 steps: - - name: Capture start timestamp and clear OFL pending queue + - name: Capture start timestamp id: start shell: bash - env: - OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }} - OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }} run: | set -euo pipefail - [ -n "$OTA_API_BASE_URL" ] || { echo "::error::vars.OTA_API_BASE_URL is not set"; exit 1; } - [ -n "$OTA_API_KEY" ] || { echo "::error::secrets.OFL_OTA_PUBLISH_KEY is not set"; exit 1; } - timestamp="$(date -u +%s)" echo "timestamp=$timestamp" >> "$GITHUB_OUTPUT" - resp_file="$RUNNER_TEMP/ota-pending-clear-response.json" - status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \ - "${OTA_API_BASE_URL%/}/api/v1/ota/ofl/pending/clear?timestamp=$timestamp" \ - -H "Authorization: Bearer $OTA_API_KEY")" - body="$(cat "$resp_file")" - echo "$body" - - if [ "$status" != "200" ]; then - echo "::error::OTA pending-clear call failed with HTTP $status" - exit 1 - fi - - name: Checkout repository uses: actions/checkout@v7 with: @@ -93,15 +75,17 @@ jobs: # by $branch would never match anything except main. post_merge_profiles.yml # genuinely runs per-branch (this dispatch below sets --ref "$branch"), # so its history is the real per-branch checkpoint. It also means a - # failed publish naturally gets retried tomorrow: the checkpoint only - # advances on a run that actually succeeded. + # failed publish naturally gets retried tomorrow. Only runs marked by + # this cron count, so an unrelated successful manual run cannot advance + # the OFL checkpoint. # --method GET is required, not cosmetic: gh api defaults to POST # whenever -f fields are present unless a method is given # explicitly, and POST on this list-runs endpoint 404s - confirmed # on real Actions infrastructure, not just reasoned about. - since="$(gh api --method GET "repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \ - -f status=success -f branch="$branch" -f per_page=1 \ - --jq '.workflow_runs[0].run_started_at // empty')" + successful_runs="$(gh api --method GET "repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \ + -f status=success -f branch="$branch" -f per_page=100 --paginate \ + --jq '.workflow_runs[] | select((.display_title // "") | contains("[OFL cron "))')" + since="$(jq -rs 'sort_by(.run_started_at) | last.run_started_at // empty' <<< "$successful_runs")" if [ -z "$since" ]; then echo "No prior successful run for $branch; checking OFL changes up to $SCAN_UNTIL." @@ -124,16 +108,106 @@ jobs: fi if [ "$changed" = true ]; then - # Tolerate a per-branch failure (e.g. a pre-existing release branch - # whose post_merge_profiles.yml predates the vendor/auto_publish - # inputs) rather than aborting the whole scan under set -e. - if ! gh workflow run post_merge_profiles.yml \ + dispatch_id="${GITHUB_RUN_ID}-${branch//\//-}" + # Record successful dispatches for the barrier step below. A + # dispatch failure prevents clearing, so the branch is retried + # on the next cron run. + if gh workflow run post_merge_profiles.yml \ --repo "${{ github.repository }}" \ --ref "$branch" \ - -f vendor="$VENDOR" -f auto_publish=true; then - echo "::warning::failed to dispatch post_merge_profiles.yml for $branch - its post_merge_profiles.yml at this ref may predate the vendor/auto_publish inputs" + -f vendor="$VENDOR" -f auto_publish=true \ + -f ofl_cron_dispatch_id="$dispatch_id"; then + printf '%s\t%s\n' "$branch" "$dispatch_id" >> "$RUNNER_TEMP/ofl-dispatches.tsv" + else + echo "::error::failed to dispatch post_merge_profiles.yml for $branch" + printf '%s\n' "$branch" >> "$RUNNER_TEMP/ofl-dispatch-failures.txt" fi fi echo "::endgroup::" done + + - name: Wait for OFL publishers + id: wait + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DISPATCHES_FILE: ${{ runner.temp }}/ofl-dispatches.tsv + DISPATCH_FAILURES_FILE: ${{ runner.temp }}/ofl-dispatch-failures.txt + run: | + set -euo pipefail + + if [ -s "$DISPATCH_FAILURES_FILE" ]; then + echo "::error::one or more OFL publisher workflows could not be dispatched:" + sed 's/^/ - /' "$DISPATCH_FAILURES_FILE" + exit 1 + fi + + if [ ! -s "$DISPATCHES_FILE" ]; then + echo "No OFL publisher workflows were dispatched; pending queue will not be cleared." + echo "publishers_dispatched=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + : > "$RUNNER_TEMP/ofl-run-ids.tsv" + while IFS=$'\t' read -r branch dispatch_id; do + [ -n "$branch" ] || continue + echo "Waiting for OFL publisher on $branch ($dispatch_id)" + + run_id="" + for _ in {1..120}; do + runs_json="$(gh api --method GET \ + "repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \ + -f branch="$branch" -f event=workflow_dispatch -f per_page=100)" + run_id="$(jq -r --arg marker "[OFL cron $dispatch_id]" \ + '[.workflow_runs[] | select((.display_title // "") | contains($marker))] \ + | sort_by(.created_at) | last | .id // empty' <<< "$runs_json")" + [ -n "$run_id" ] && break + sleep 5 + done + + if [ -z "$run_id" ]; then + echo "::error::could not find dispatched post_merge_profiles run for $branch ($dispatch_id)" + exit 1 + fi + printf '%s\t%s\n' "$branch" "$run_id" >> "$RUNNER_TEMP/ofl-run-ids.tsv" + done < "$DISPATCHES_FILE" + + all_success=true + while IFS=$'\t' read -r branch run_id; do + [ -n "$run_id" ] || continue + echo "Watching OFL publisher run $run_id for $branch" + if ! gh run watch "$run_id" --repo "${{ github.repository }}" --exit-status; then + all_success=false + fi + done < "$RUNNER_TEMP/ofl-run-ids.tsv" + + if [ "$all_success" != true ]; then + echo "::error::one or more OFL publisher workflows failed; pending queue will not be cleared" + exit 1 + fi + echo "publishers_dispatched=true" >> "$GITHUB_OUTPUT" + + - name: Clear OFL pending queue + if: steps.wait.outputs.publishers_dispatched == 'true' + shell: bash + env: + OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }} + OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }} + TIMESTAMP: ${{ steps.start.outputs.timestamp }} + run: | + set -euo pipefail + [ -n "$OTA_API_BASE_URL" ] || { echo "::error::vars.OTA_API_BASE_URL is not set"; exit 1; } + [ -n "$OTA_API_KEY" ] || { echo "::error::secrets.OFL_OTA_PUBLISH_KEY is not set"; exit 1; } + + resp_file="$RUNNER_TEMP/ota-pending-clear-response.json" + status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \ + "${OTA_API_BASE_URL%/}/api/v1/ota/ofl/pending/clear?timestamp=$TIMESTAMP" \ + -H "Authorization: Bearer $OTA_API_KEY")" + body="$(cat "$resp_file")" + echo "$body" + + if [ "$status" != "200" ]; then + echo "::error::OTA pending-clear call failed with HTTP $status" + exit 1 + fi diff --git a/.github/workflows/post_merge_profiles.yml b/.github/workflows/post_merge_profiles.yml index 90bd9ccf7e..04ffd0429f 100644 --- a/.github/workflows/post_merge_profiles.yml +++ b/.github/workflows/post_merge_profiles.yml @@ -1,5 +1,14 @@ name: Post-merge profiles +run-name: >- + Post-merge profiles${{ + inputs.ofl_cron_dispatch_id != '' && + inputs.vendor == 'OrcaFilamentLibrary' && + (inputs.auto_publish == true || inputs.auto_publish == 'true') && + format(' [OFL cron {0}]', inputs.ofl_cron_dispatch_id) || + '' + }} + # Push-triggered counterpart to check_profiles.yml (which only gates PRs). When a # profile change lands on main or a release branch, rebuild the affected vendors' # binary preset caches (.opc) and publish each as a versioned ZIP asset on @@ -59,6 +68,12 @@ on: required: false type: boolean default: false + ofl_cron_dispatch_id: + description: >- + Unique marker supplied by the trusted OFL daily cron so it can find + and wait for this dispatched workflow run. + required: false + type: string permissions: contents: read From c30c9beb09e670ef4ec1e3cfb7ba6972a9bf2bcf Mon Sep 17 00:00:00 2001 From: anjis Date: Mon, 28 Sep 2026 19:58:24 +0800 Subject: [PATCH 6/8] Fix clipped tall models in G-code preview for gcode files (#15360) Fix clipped tall models in G-code preview Co-authored-by: yw4z --- src/slic3r/GUI/GCodeViewer.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/slic3r/GUI/GCodeViewer.cpp b/src/slic3r/GUI/GCodeViewer.cpp index 68e81493c1..cb3fc8d292 100644 --- a/src/slic3r/GUI/GCodeViewer.cpp +++ b/src/slic3r/GUI/GCodeViewer.cpp @@ -1443,6 +1443,7 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const libvgcode::EGCodeExtrusionRole::SupportTransition, libvgcode::EGCodeExtrusionRole::Mixed }); m_paths_bounding_box = BoundingBoxf3(libvgcode::convert(bbox[0]).cast(), libvgcode::convert(bbox[1]).cast()); + m_max_bounding_box = m_paths_bounding_box; if (wxGetApp().is_editor()) m_contained_in_bed = wxGetApp().plater()->build_volume().all_paths_inside(gcode_result, m_paths_bounding_box); From faeb84da726fec1830f13c699a33c6f95a76016b Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Mon, 28 Sep 2026 19:59:01 +0800 Subject: [PATCH 7/8] fix: change checkpointing to use last cronjob instead --- .github/workflows/ofl-ota-cronjob.yml | 32 +++++++++++---------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ofl-ota-cronjob.yml b/.github/workflows/ofl-ota-cronjob.yml index c7feeca5fb..1cd830a271 100644 --- a/.github/workflows/ofl-ota-cronjob.yml +++ b/.github/workflows/ofl-ota-cronjob.yml @@ -1,5 +1,7 @@ name: Daily OFL OTA Update +run-name: Daily OFL OTA Update [OFL barrier] + # This workflow is intended for creating and publishing the OrcaFilamentLibrary (OFL) OPC package to # https://github.com/OrcaSlicer/orcaslicer-profiles, which generates an OTA update. # This cronjob runs daily at 00:00 UTC every day and scans main plus every release/vX.Y.Z branch for @@ -66,29 +68,21 @@ jobs: | grep -E '^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$' | sort -u ) + # The cron run is the checkpoint: a successful run means every + # dispatched branch publisher completed and the pending queue was + # cleared. Manual or push-triggered post_merge_profiles runs are not + # checkpoints for this scan. + successful_cron_runs="$(gh api --method GET \ + "repos/${{ github.repository }}/actions/workflows/ofl-ota-cronjob.yml/runs" \ + -f status=success -f branch=main -f per_page=100 --paginate \ + --jq '.workflow_runs[] | select((.display_title // "") | contains("[OFL barrier]"))')" + since="$(jq -rs 'sort_by(.run_started_at) | last.run_started_at // empty' <<< "$successful_cron_runs")" + for branch in "${branches[@]}"; do echo "::group::$branch" - # post_merge_profiles.yml's own run history, not this workflow's: this - # workflow only ever runs against main (schedule, or workflow_dispatch - # --ref main), so its head branch never varies - filtering ITS history - # by $branch would never match anything except main. post_merge_profiles.yml - # genuinely runs per-branch (this dispatch below sets --ref "$branch"), - # so its history is the real per-branch checkpoint. It also means a - # failed publish naturally gets retried tomorrow. Only runs marked by - # this cron count, so an unrelated successful manual run cannot advance - # the OFL checkpoint. - # --method GET is required, not cosmetic: gh api defaults to POST - # whenever -f fields are present unless a method is given - # explicitly, and POST on this list-runs endpoint 404s - confirmed - # on real Actions infrastructure, not just reasoned about. - successful_runs="$(gh api --method GET "repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \ - -f status=success -f branch="$branch" -f per_page=100 --paginate \ - --jq '.workflow_runs[] | select((.display_title // "") | contains("[OFL cron "))')" - since="$(jq -rs 'sort_by(.run_started_at) | last.run_started_at // empty' <<< "$successful_runs")" - if [ -z "$since" ]; then - echo "No prior successful run for $branch; checking OFL changes up to $SCAN_UNTIL." + echo "No prior successful OFL cron run; checking $branch through $SCAN_UNTIL." changed_files="$(git log --until="$SCAN_UNTIL" --name-only --pretty=format: "origin/$branch" -- \ resources/profiles/OrcaFilamentLibrary resources/profiles/OrcaFilamentLibrary.json \ | sed '/^$/d')" From 08f086daf317c657e86c8292cc34ea14c6322527 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Mon, 28 Sep 2026 20:40:24 +0800 Subject: [PATCH 8/8] fix: tolerate unsupported OFL publisher branches (pre 2.5.x) (#15962) --- .github/workflows/ofl-ota-cronjob.yml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ofl-ota-cronjob.yml b/.github/workflows/ofl-ota-cronjob.yml index 1cd830a271..9d027190ae 100644 --- a/.github/workflows/ofl-ota-cronjob.yml +++ b/.github/workflows/ofl-ota-cronjob.yml @@ -103,9 +103,9 @@ jobs: if [ "$changed" = true ]; then dispatch_id="${GITHUB_RUN_ID}-${branch//\//-}" - # Record successful dispatches for the barrier step below. A - # dispatch failure prevents clearing, so the branch is retried - # on the next cron run. + # Record successful dispatches for the barrier step below. + # Branches whose workflow predates workflow_dispatch are skipped + # with a warning, as they were before the barrier was added. if gh workflow run post_merge_profiles.yml \ --repo "${{ github.repository }}" \ --ref "$branch" \ @@ -113,8 +113,7 @@ jobs: -f ofl_cron_dispatch_id="$dispatch_id"; then printf '%s\t%s\n' "$branch" "$dispatch_id" >> "$RUNNER_TEMP/ofl-dispatches.tsv" else - echo "::error::failed to dispatch post_merge_profiles.yml for $branch" - printf '%s\n' "$branch" >> "$RUNNER_TEMP/ofl-dispatch-failures.txt" + echo "::warning::skipping $branch because post_merge_profiles.yml could not be dispatched at that ref" fi fi @@ -127,16 +126,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DISPATCHES_FILE: ${{ runner.temp }}/ofl-dispatches.tsv - DISPATCH_FAILURES_FILE: ${{ runner.temp }}/ofl-dispatch-failures.txt run: | set -euo pipefail - if [ -s "$DISPATCH_FAILURES_FILE" ]; then - echo "::error::one or more OFL publisher workflows could not be dispatched:" - sed 's/^/ - /' "$DISPATCH_FAILURES_FILE" - exit 1 - fi - if [ ! -s "$DISPATCHES_FILE" ]; then echo "No OFL publisher workflows were dispatched; pending queue will not be cleared." echo "publishers_dispatched=false" >> "$GITHUB_OUTPUT"