From b1905ebc20e0f065519909dd0fa6670911a0610a Mon Sep 17 00:00:00 2001 From: harrierpigeon Date: Thu, 6 Aug 2026 01:08:44 -0500 Subject: [PATCH] Belt brim: fixes from review Six issues found by reviewing the previous commit against belt-printer, two of them release-blocking. Data race (high). Print::process() runs generate_support_material() for all objects in a tbb::parallel_for, and make_belt_brim() runs at its tail, but belt_brim_obstacles() read every OTHER object's support_layers() - which a concurrent task may be inside clear_support_layers() deleting. That is a use-after-free, and even when it survives, the obstacle set depends on which object finishes first. Only this object's own supports are consulted now; they are complete at that point. Foreign objects still contribute their slices, which are finished and immutable before the support phase. Apron bands dropped (high), two separate causes. An apron band prints below its own object's first layer, but another object can already be printing at that print_z, in which case process_layer() takes the ordinary path and never emitted the band - the emission is now shared by both paths. Separately, a band whose print_z matched a support layer of the SAME object was overwritten in the print-wide merge, which keeps one record per object per z and could not detect the collision because LayerToPrint::layer() is null for a band. The per-object pairing loop is now a three-way merge over object, support and apron streams, so each object contributes at most one record per z. Multi-instance was far too strict (medium). It refused belt brim for every multi-instance object, killing plain brim width and inner brim too, and only warned when a leading length was set. Only movement ALONG the belt changes an instance's belt-floor Z, so copies side by side ACROSS the belt share one set of bands perfectly well; belt_brim_instances_compatible() now tests just that, and the warning fires whenever the brim is actually suppressed. Apron layer bookkeeping (medium). Apron layers count toward m_layer_count and advance m_layer_index, but emitted no Z/height tags, left m_last_layer_z, m_max_layer_z and m_last_height stale - so the first object layer computed its height against a pre-apron Z - and skipped before_layer_change_gcode and layer_change_gcode entirely. All of that now matches the ordinary path. Obstacle cost (low). belt_brim_obstacles() ran a full-plate union per band. A bounding-box pre-filter drops non-overlapping objects before materialising any polygon, and the union is skipped for trivial inputs. Deliberately unchanged: every apron band still reports cooling layer_id 0. CoolingBuffer uses it for the initial_layer_fan_speed override and the close_fan_the_first_x_layers gate, and every band lies on the belt plane itself, so it is all first-layer material by the only definition that means anything on a belt. Numbering the bands would ramp the fan up while still printing on the belt. Now documented at the assignment rather than left implicit. --- src/libslic3r/BeltBrim.cpp | 49 ++++++++--- src/libslic3r/GCode.cpp | 121 +++++++++++++++++++++++----- src/libslic3r/GCode.hpp | 8 ++ src/libslic3r/Print.cpp | 11 ++- src/libslic3r/Print.hpp | 3 + src/libslic3r/PrintObject.cpp | 28 +++++-- tests/fff_print/test_skirt_brim.cpp | 104 ++++++++++++++++++++++++ 7 files changed, 282 insertions(+), 42 deletions(-) diff --git a/src/libslic3r/BeltBrim.cpp b/src/libslic3r/BeltBrim.cpp index 40ecc60f58..fa119c6609 100644 --- a/src/libslic3r/BeltBrim.cpp +++ b/src/libslic3r/BeltBrim.cpp @@ -324,24 +324,46 @@ static void belt_brim_band_paths(const BeltBrimContext &bc, } } -// Union of everything actually extruded at `print_z` by any object, expressed in -// `self`'s local slicing frame. Includes `self` itself: its slice at this Z can -// overhang outside the belt footprint and land in the brim ring, which the -// flattened brim_object_gap - a belt-plane separation - does not cover. -static Polygons belt_brim_obstacles(const Print &print, const PrintObject &self, coordf_t print_z, coordf_t tol) +// Union of everything extruded at `print_z` that the brim must keep clear of, expressed +// in `self`'s local slicing frame. Includes `self` itself: its slice at this Z can +// overhang outside the belt footprint and land in the brim ring, which the flattened +// brim_object_gap - a belt-plane separation - does not cover. +// +// THREADING: this runs inside posSupportMaterial, which Print::process() executes for all +// objects in a tbb::parallel_for (Print.cpp). Object slices are finished by then and safe +// to read across objects, but SUPPORT layers are not: another object's thread may be +// inside clear_support_layers() - which deletes the SupportLayer pointers - right now, so +// touching a foreign object's support_layers() here is a use-after-free. Only this +// object's own supports are consulted; they are complete, because make_belt_brim() runs at +// the tail of this object's own generate_support_material(). The cost is that the brim +// does not dodge a *different* object's support at the same Z, which needs the objects to +// overlap in the belt direction in the first place. +// `region_bbox` bounds the brim; anything outside it cannot clip a brim line, so whole +// objects are skipped without materialising their polygons. On a typical plate the +// objects do not overlap and every foreign object drops out here, which matters because +// this runs once per band - hundreds of times per object. +static Polygons belt_brim_obstacles(const Print &print, const PrintObject &self, + const BoundingBox ®ion_bbox, coordf_t print_z, coordf_t tol) { const Point shift_self = self.instances().empty() ? Point(0, 0) : self.instances().front().shift_without_plate_offset(); Polygons out; - for (const PrintObject *o : print.objects()) + for (const PrintObject *o : print.objects()) { + const bool is_self = (o == &self); for (const PrintInstance &inst : o->instances()) { const Point delta = inst.shift_without_plate_offset() - shift_self; if (const Layer *l = o->get_layer_at_printz(print_z, tol)) { - Polygons ps = to_polygons(l->lslices); - for (Polygon &p : ps) - p.translate(delta); - polygons_append(out, std::move(ps)); + BoundingBox lb = get_extents(l->lslices); + lb.translate(delta.x(), delta.y()); + if (lb.overlap(region_bbox)) { + Polygons ps = to_polygons(l->lslices); + for (Polygon &p : ps) + p.translate(delta); + polygons_append(out, std::move(ps)); + } } + if (! is_self) + continue; if (const SupportLayer *sl = o->get_support_layer_at_printz(print_z, tol)) { Polygons ps = sl->support_fills.polygons_covered_by_spacing(); for (Polygon &p : ps) @@ -349,6 +371,9 @@ static Polygons belt_brim_obstacles(const Print &print, const PrintObject &self, polygons_append(out, std::move(ps)); } } + } + if (out.size() < 2) + return out; // union_() of 0 or 1 polygons is pure overhead return union_(out); } @@ -453,7 +478,7 @@ void make_belt_brim(PrintObject &object) std::vector areas_by_layer(nlayers); for (size_t i = 0; i < nlayers; ++ i) { const Layer &layer = *object.layers()[i]; - const Polygons obstacles = belt_brim_obstacles(print, object, layer.print_z, 0.5 * layer.height); + const Polygons obstacles = belt_brim_obstacles(print, object, bc.region_bbox, layer.print_z, 0.5 * layer.height); belt_brim_band_paths(bc, layer.print_z, layer.height, obstacles, by_layer[i], areas_by_layer[i]); } @@ -473,7 +498,7 @@ void make_belt_brim(PrintObject &object) + bc.ctx.floor_offset() + bc.ctx.z_shift(); if (h > EPSILON) for (coordf_t z = first.print_z - h; z > z_lead - h; z -= h) { - const Polygons obstacles = belt_brim_obstacles(print, object, z, 0.5 * h); + const Polygons obstacles = belt_brim_obstacles(print, object, bc.region_bbox, z, 0.5 * h); BeltBrimBand band; band.print_z = z; band.height = h; diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index f2caca404f..5b76f53ab5 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -2125,22 +2125,20 @@ std::vector GCode::collect_layers_to_print(const PrintObjec std::vector> warning_ranges; - // Belt printers: the brim apron is stuck to the belt AHEAD of the part, which - // on a tilted belt means below the object's first layer. Those bands carry no - // object or support layer, so they are emitted first and handled by - // process_layer()'s brim-only branch. Already ordered lowest print_z first. - for (const BeltBrimBand &band : object.belt_brim_prologue()) { - LayerToPrint prologue_layer; - prologue_layer.belt_brim_band = &band; - prologue_layer.original_object = &object; - layers_to_print.push_back(prologue_layer); - } - // Pair the object layers with the support layers by z. + // + // Belt printers add a third stream: brim apron bands, which sit on the belt AHEAD of + // the part and so print below the object's first layer. They are merged here rather + // than pushed as standalone records, because a band's print_z can coincide with a + // support layer of this same object - and the print-wide merge downstream keeps only + // one record per object per z, so a standalone band would be silently overwritten. size_t idx_object_layer = 0; size_t idx_support_layer = 0; + size_t idx_brim_band = 0; + const auto &brim_bands = object.belt_brim_prologue(); // ordered by ascending print_z const LayerToPrint* last_extrusion_layer = nullptr; - while (idx_object_layer < object.layers().size() || idx_support_layer < object.support_layers().size()) { + while (idx_object_layer < object.layers().size() || idx_support_layer < object.support_layers().size() + || idx_brim_band < brim_bands.size()) { LayerToPrint layer_to_print; double print_z_min = std::numeric_limits::max(); if (idx_object_layer < object.layers().size()) { @@ -2153,6 +2151,11 @@ std::vector GCode::collect_layers_to_print(const PrintObjec print_z_min = std::min(print_z_min, layer_to_print.support_layer->print_z); } + if (idx_brim_band < brim_bands.size()) { + layer_to_print.belt_brim_band = &brim_bands[idx_brim_band++]; + print_z_min = std::min(print_z_min, layer_to_print.belt_brim_band->print_z); + } + if (layer_to_print.object_layer && layer_to_print.object_layer->print_z > print_z_min + EPSILON) { layer_to_print.object_layer = nullptr; --idx_object_layer; @@ -2163,11 +2166,17 @@ std::vector GCode::collect_layers_to_print(const PrintObjec --idx_support_layer; } + if (layer_to_print.belt_brim_band && layer_to_print.belt_brim_band->print_z > print_z_min + EPSILON) { + layer_to_print.belt_brim_band = nullptr; + --idx_brim_band; + } + layer_to_print.original_object = &object; layers_to_print.push_back(layer_to_print); bool has_extrusions = (layer_to_print.object_layer && layer_to_print.object_layer->has_extrusions()) - || (layer_to_print.support_layer && layer_to_print.support_layer->has_extrusions()); + || (layer_to_print.support_layer && layer_to_print.support_layer->has_extrusions()) + || (layer_to_print.belt_brim_band && ! layer_to_print.belt_brim_band->fills.empty()); // Check that there are extrusions on the very first layer. The case with empty // first layer may result in skirt/brim in the air and maybe other issues. @@ -5266,20 +5275,26 @@ LayerResult GCode::process_belt_brim_layer( const bool last_layer, const size_t single_object_instance_idx) { - // layer_id 0: the apron precedes every object layer and nothing downstream - // indexes by it. spiral_vase_enable false: spiral vase is refused alongside - // belt brim in Print::validate(). cooling_buffer_flush true: an apron layer is - // a complete layer, and the default (object_layer || raft_layer || last_layer) - // would be false here, so fan and slowdown would never be applied to it. + // layer_id 0 is deliberate, not a placeholder. CoolingBuffer reads it for the + // initial_layer_fan_speed override and the close_fan_the_first_x_layers gate + // (CoolingBuffer.cpp), and every apron band is first-layer material by the only + // definition that means anything on a belt: it lies on the belt plane itself. Numbering + // the bands 1, 2, 3... would ramp the fan up while still printing on the belt. + // spiral_vase_enable false: spiral vase is refused alongside belt brim in + // Print::validate(). cooling_buffer_flush true: an apron layer is a complete layer, and + // the default (object_layer || raft_layer || last_layer) is false here, so fan and + // slowdown would otherwise never be applied to it. LayerResult result { {}, 0, false, true }; if (layer_tools.extruders.empty()) // Nothing to extrude. return result; coordf_t print_z = 0.; + coordf_t height = 0.; for (const LayerToPrint <p : layers) if (ltp.belt_brim_band != nullptr) { print_z = ltp.belt_brim_band->print_z; + height = ltp.belt_brim_band->height; break; } @@ -5298,8 +5313,63 @@ LayerResult GCode::process_belt_brim_layer( const unsigned int extruder_id = layer_tools.extruders.front(); if (m_writer->filament() == nullptr || m_writer->filament()->id() != extruder_id) gcode += this->set_extruder(extruder_id, print_z); + + // An apron band is a real printed layer: it is counted in m_layer_count, it advances + // m_layer_index through change_layer(), and the G-code viewer needs its Z/height tags. + // Keep the same caches and hooks the ordinary path maintains, or the first object layer + // would compute its height against a stale pre-apron Z and layer-change templates would + // skip these layers entirely. + { + char buf[64]; + sprintf(buf, ";%s%g\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Layer_Change).c_str(), print_z); + gcode += buf; + sprintf(buf, ";Z:%g\n", print_z); + gcode += buf; + const float band_height = float(height); + sprintf(buf, ";%s%g\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Height).c_str(), band_height); + gcode += buf; + m_last_layer_z = float(print_z); + m_max_layer_z = std::max(m_max_layer_z, m_last_layer_z); + m_last_height = band_height; + } + + if (! m_config.before_layer_change_gcode.value.empty()) { + DynamicConfig config; + config.set_key_value("layer_num", new ConfigOptionInt(m_layer_index + 1)); + config.set_key_value("layer_z", new ConfigOptionFloat(print_z)); + config.set_key_value("max_layer_z", new ConfigOptionFloat(m_max_layer_z)); + gcode += this->placeholder_parser_process("before_layer_change_gcode", + print.config().before_layer_change_gcode.value, m_writer->filament()->id(), &config) + "\n"; + } + gcode += this->change_layer(print_z); + if (! m_config.layer_change_gcode.value.empty()) { + DynamicConfig config; + config.set_key_value("layer_num", new ConfigOptionInt(m_layer_index)); + config.set_key_value("layer_z", new ConfigOptionFloat(print_z)); + config.set_key_value("max_layer_z", new ConfigOptionFloat(m_max_layer_z)); + gcode += this->placeholder_parser_process("layer_change_gcode", + print.config().layer_change_gcode.value, m_writer->filament()->id(), &config) + "\n"; + } + + gcode += this->emit_belt_brim_bands(print, layers, single_object_instance_idx); + + result.gcode = std::move(gcode); + return result; +} + +// Emit every apron band carried by this set of layers. +// +// Shared by the brim-only branch above and the ordinary process_layer() path. Both need +// it: an apron band prints below its OWN object's first layer, but on a multi-object belt +// another object can already be printing at that print_z, in which case the layer has an +// object layer, takes the ordinary path, and the band would be silently dropped. +std::string GCode::emit_belt_brim_bands(const Print &print, + const std::vector &layers, + const size_t single_object_instance_idx) +{ + std::string gcode; for (const LayerToPrint <p : layers) { const BeltBrimBand *band = ltp.belt_brim_band; if (band == nullptr || band->fills.empty() || ltp.original_object == nullptr) @@ -5324,9 +5394,7 @@ LayerResult GCode::process_belt_brim_layer( m_avoid_crossing_perimeters.disable_once(); } } - - result.gcode = std::move(gcode); - return result; + return gcode; } // Bedslinger model. The heavier the bed load, the lower the achievable Y acceleration for a given @@ -5821,6 +5889,17 @@ LayerResult GCode::process_layer( //BBS: set layer time fan speed after layer change gcode gcode += ";_SET_FAN_SPEED_CHANGING_LAYER\n"; + // Belt printers: an apron band prints below its own object's first layer, but with + // several objects on the belt another one can already be printing at this print_z. + // The layer then has an object layer and takes this ordinary path instead of the + // brim-only branch, so the band has to be emitted here or it would be dropped. + // Before any object extrusion at this Z, as the brim must go down first. + if (print.has_belt_brim()) { + const Vec2d saved_origin = m_origin; + gcode += this->emit_belt_brim_bands(print, layers, single_object_instance_idx); + this->set_origin(saved_origin); + } + //Calibration Layer-specific GCode // ORCA-Belt: on belt printers the calibration object is counter-rotated to // stand upright in slicing space on top of a support wedge, so its first diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index d4c7bf9bc3..54b7e8abd4 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -418,6 +418,14 @@ protected: const bool last_layer, const size_t single_object_instance_idx); + // Emit the apron bands carried by these layers. Called from both the brim-only + // branch and the ordinary path, since a band's print_z can coincide with another + // object's layer on a multi-object belt. + std::string emit_belt_brim_bands( + const Print &print, + const std::vector &layers, + const size_t single_object_instance_idx); + LayerResult process_layer( const Print &print, // Set of object & print layers of the same PrintObject and with the same print_z. diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 189f9e178e..2df4cde79b 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1401,10 +1401,13 @@ StringObjectException Print::validate(std::vector *warnin "brim length."), "brim_object_gap", object->model_object()); - if (ocfg.leading_brim_length.value > 0. && object->instances().size() > 1) - warn(L("This object has several instances sharing one belt position, so no brim is " - "generated for it. Arrange the copies along the belt instead."), - "leading_brim_length", object->model_object()); + // Unconditional: this suppresses the WHOLE belt brim, not just the apron, so a + // user asking for any brim at all needs to be told they are getting none. + if (! object->belt_brim_instances_compatible()) + warn(L("This object's copies are spaced along the belt, so they would each need " + "their own brim and none is generated. Print them as separate objects, or " + "arrange the copies side by side across the belt."), + "brim_type", object->model_object()); } if (this->has_belt_brim() && m_objects.size() > 1) warn(L("Leading brim length extends ahead of each object along the belt, and Arrange does " diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 72a12918c7..4b1f4891f2 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -392,6 +392,9 @@ public: // trimming and the spiral vase probe, and widening it would perturb belt // support output. bool has_belt_brim() const; + // False when this object's instances sit at different points ALONG the belt, which + // would need a separate set of bands each. Public so validate() can explain it. + bool belt_brim_instances_compatible() const; const std::vector& belt_brim_by_layer() const { return m_belt_brim_by_layer; } const std::vector& belt_brim_areas_by_layer() const { return m_belt_brim_areas_by_layer; } const std::vector& belt_brim_prologue() const { return m_belt_brim_prologue; } diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 1b8ddf3a9e..c5d5409d73 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -1167,11 +1167,7 @@ bool PrintObject::has_belt_brim() const { if (! m_print->has_tilted_belt()) return false; - // Translating an instance along the belt axis changes its physical belt-floor - // Z, so one set of bands cannot serve several instances sharing a PrintObject. - // belt_force_separate() in PrintApply.cpp gives one instance per object - // whenever a global belt flag is set, which the shipped belt profiles do. - if (m_instances.size() > 1) + if (! this->belt_brim_instances_compatible()) return false; if (m_config.brim_type == btNoBrim) return false; @@ -1181,6 +1177,28 @@ bool PrintObject::has_belt_brim() const return ! this->has_raft(); } +bool PrintObject::belt_brim_instances_compatible() const +{ + // One set of bands is shared by every instance of this object, so they must all sit at + // the same height on the belt. Moving an instance ALONG the belt axis changes its + // physical belt-floor Z and would put its brim at the wrong height; moving it ACROSS + // the belt does not, so side-by-side copies are fine. + // + // belt_force_separate() in PrintApply.cpp already gives one instance per PrintObject + // whenever a global belt flag is set, which the shipped belt profiles do - this only + // matters for configurations that do not. + if (m_instances.size() <= 1) + return true; + const int axis = m_slicing_params.belt_floor_from_axis; + const Point &ref = m_instances.front().shift; + for (const PrintInstance &inst : m_instances) { + const coord_t along = axis == 0 ? inst.shift.x() - ref.x() : inst.shift.y() - ref.y(); + if (std::abs(along) > SCALED_EPSILON) + return false; + } + return true; +} + void PrintObject::clear_belt_brim() { m_belt_brim_by_layer.clear(); diff --git a/tests/fff_print/test_skirt_brim.cpp b/tests/fff_print/test_skirt_brim.cpp index 755dec06e0..b5d315db2a 100644 --- a/tests/fff_print/test_skirt_brim.cpp +++ b/tests/fff_print/test_skirt_brim.cpp @@ -633,3 +633,107 @@ TEST_CASE("Belt brim lines all have the same width", "[SkirtBrim][belt]") const float hi = *std::max_element(widths.begin(), widths.end()); CHECK_THAT(hi, Catch::Matchers::WithinRel(lo, 1e-4)); } + +TEST_CASE("Belt apron survives another object printing at the same Z", "[SkirtBrim][belt]") +{ + // An apron band prints below its OWN object's first layer, but with two objects on the + // belt the second one is already printing at that print_z. The layer then has an + // object layer and takes the ordinary process_layer() path rather than the brim-only + // branch, so the band must be emitted from both or it is silently dropped. A + // single-object print cannot exercise this. + auto brim_passes = [](int object_count) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 3 }, + { "leading_brim_length", 8 }, + { "brim_object_gap", 0 }, + }); + std::vector meshes; + for (int i = 0; i < object_count; ++ i) { + TriangleMesh m = cube(20); + // Offset along the belt so the second object starts well after the first. + m.translate(0.f, float(40 * i), 0.f); + meshes.emplace_back(std::move(m)); + } + Print print; + Model model; + init_print(std::move(meshes), print, model, config); + print.process(); + return role_passes(gcode(print), "brim"); + }; + + const int one = brim_passes(1); + const int two = brim_passes(2); + REQUIRE(one > 0); + // Two identical objects should carry twice the brim. Merely asserting `two > one` + // would not be decisive: the FIRST object's apron survives the bug, because nothing + // else is printing that early, so only the second object's apron goes missing. + // Requiring close to 2x is what actually detects the dropped bands. + CHECK(two >= 1.8 * one); +} + +TEST_CASE("Belt brim allows instances placed across the belt", "[SkirtBrim][belt]") +{ + // Only movement ALONG the belt changes an instance's belt-floor Z, so copies placed + // side by side ACROSS it share one set of bands and must still get a brim. The first + // version of this guard refused every multi-instance object outright, silently + // dropping the brim. + // + // The global belt flags are off here so the instances stay in one PrintObject; with + // them on, PrintApply splits each instance into its own object and the case cannot + // arise at all. + auto multi_instance_has_brim = [](double dx, double dy) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "belt_slice_rotation_global", 0 }, + { "belt_preslice_global", 0 }, + { "preslice_remap_global", 0 }, + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + }); + Print print; + Model model; + ModelObject *object = model.add_object(); + object->name += "object.stl"; + object->add_volume(cube(20)); + object->add_instance()->set_offset(Vec3d(80., 80., 0.)); + object->add_instance()->set_offset(Vec3d(80. + dx, 80. + dy, 0.)); + object->ensure_on_bed(); + print.auto_assign_extruders(object); + print.apply(model, config); + print.validate(); + print.set_status_silent(); + print.process(); + REQUIRE(print.objects().size() == 1); + REQUIRE(print.objects().front()->instances().size() == 2); + return print.objects().front()->has_belt_brim(); + }; + + // X is across the belt when the tilt is about X, since the shear then runs along Y. + CHECK(multi_instance_has_brim(40., 0.)); + // Y is along the belt: the copies sit at different belt heights and would each need + // their own bands, so the brim is refused (and validate() warns). + CHECK_FALSE(multi_instance_has_brim(0., 40.)); +} + +TEST_CASE("Belt brim coexists with support material", "[SkirtBrim][belt]") +{ + // Supports put extra layers into the same z stream as the apron bands, which is what + // the three-way merge in collect_layers_to_print() exists to handle: a band sharing a + // print_z with a support layer of the SAME object used to overwrite it in the + // print-wide merge. A smoke test - it cannot prove the collision occurred - but it + // does exercise the merge with all three streams populated. + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "leading_brim_length", 6 }, + { "brim_object_gap", 0 }, + { "enable_support", 1 }, + }); + const std::string gc = slice({ TestMesh::overhang }, config); + REQUIRE(! gc.empty()); + CHECK(role_passes(gc, "brim") > 0); +}