diff --git a/src/libslic3r/BeltGCodeWriter.cpp b/src/libslic3r/BeltGCodeWriter.cpp index e1dad4177f..4824d76f69 100644 --- a/src/libslic3r/BeltGCodeWriter.cpp +++ b/src/libslic3r/BeltGCodeWriter.cpp @@ -264,7 +264,9 @@ std::string BeltGCodeWriter::travel_to_xyz(const Vec3d &point, const std::string // Belt mode: always emit full XYZ GCodeG1Formatter w; w.emit_xyz(point_on_plate); - w.emit_f(this->config.travel_speed.get_at(m_cached_extruder_idx) * 60.0); + // Use the first-layer-aware travel_speed computed at the top of this function, + // not the raw config travel_speed, so initial-layer travels are correctly slowed. + w.emit_f(travel_speed * 60.0); w.emit_comment(GCodeWriter::full_gcode_comment, comment); m_pos = dest_point; diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 5b76f53ab5..976c99fd9a 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -2952,16 +2952,18 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato this->init_belt_writer(print, is_bbl_printers); // Standalone axis remap (works with or without belt mode). + // Sync the writer's remap state to the current export UNCONDITIONALLY — even at + // the identity mapping (0,1,2) — so a reused writer never retains a stale + // non-identity mapping from a prior export. has_axis_remap() returns false at + // identity, so identity/default output stays unchanged. { int rx = int(print.config().gcode_remap_x.value); int ry = int(print.config().gcode_remap_y.value); int rz = int(print.config().gcode_remap_z.value); - if (rx != 0 || ry != 1 || rz != 2) { - m_writer->set_axis_remap(rx, ry, rz); - BoundingBoxf bbox_bed(print.config().printable_area.values); - m_writer->set_build_volume_max(Vec3d(bbox_bed.max.x(), bbox_bed.max.y(), - print.config().printable_height.value)); - } + m_writer->set_axis_remap(rx, ry, rz); + BoundingBoxf bbox_bed(print.config().printable_area.values); + m_writer->set_build_volume_max(Vec3d(bbox_bed.max.x(), bbox_bed.max.y(), + print.config().printable_height.value)); } // Build the FirstLayerPlane evaluator. When inactive (non-belt printers @@ -5889,16 +5891,12 @@ 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); - } + // Belt printers: ordinary-layer apron bands (a band whose print_z coincides with an + // object/support layer, so it takes this path rather than the brim-only branch) are + // NOT emitted here anymore. They used to be laid down with whatever tool happened to + // be active; instead they are now emitted inside the extruder loop below, in their + // own brim-filament pass and before that pass's object extrusion, so the brim goes + // down first with the correct tool. See the emit_belt_brim_for_extruder call. //Calibration Layer-specific GCode // ORCA-Belt: on belt printers the calibration object is counter-rotated to @@ -6582,6 +6580,49 @@ LayerResult GCode::process_layer( // Extrude the skirt, brim, support, perimeters, infill ordered by the extruders. m_skirt_group_done.resize(print.skirt_brim_groups().size()); + + // Belt brim bookkeeping. A coincident belt_brim_by_layer band must be emitted + // exactly once, in its object's brim-filament pass; this records which have gone + // down so the in-visit emit and the end-of-layer orphan sweep never double it. + // Key = (LayerToPrint index, instance_id). + std::set> belt_brim_emitted; + + // Emit every ORDINARY-layer apron band (belt_brim_prologue band coinciding with an + // object/support layer) whose brim filament is this pass's extruder. Mirrors + // emit_belt_brim_bands() per band, but filtered to one brim filament so each band + // prints in the correct tool's pass (Finding B). extruder_id is 0-based (the + // reindexed tool domain); belt_brim_filament() is 1-based, so subtract one. + auto emit_belt_brim_for_extruder = [this, &print, &layers, single_object_instance_idx](unsigned int extruder_id) -> std::string { + std::string gc; + for (const LayerToPrint <p : layers) { + const BeltBrimBand *band = ltp.belt_brim_band; + if (band == nullptr || band->fills.empty() || ltp.original_object == nullptr) + continue; + const PrintObject &object = *ltp.original_object; + if (! object.has_belt_brim() || (unsigned int)(object.belt_brim_filament() - 1) != extruder_id) + continue; + // Speeds, flow and retraction all read m_config. + m_config.apply(print.default_region_config()); + m_config.apply(object.config(), true); + const size_t i_begin = single_object_instance_idx == size_t(-1) ? 0 : single_object_instance_idx; + const size_t i_end = single_object_instance_idx == size_t(-1) ? object.instances().size() + : single_object_instance_idx + 1; + for (size_t i = i_begin; i < i_end && i < object.instances().size(); ++ i) { + // Band geometry is object-local, like the object's own extrusions. + const Point &offset = object.instances()[i].shift; + this->set_origin(unscale(offset)); + this->on_set_origin(&object, offset); + m_avoid_crossing_perimeters.use_external_mp(); + for (const ExtrusionEntity *ee : band->fills.entities) + if (ee != nullptr) + gc += this->extrude_entity(*ee, "brim", NOZZLE_CONFIG(support_speed)); + m_avoid_crossing_perimeters.use_external_mp(false); + m_avoid_crossing_perimeters.disable_once(); + } + } + return gc; + }; + for (unsigned int extruder_id : layer_tools.extruders) { if (print.config().skirt_type == stCombined && !print.skirt_brim_groups().empty()) { @@ -6679,6 +6720,16 @@ LayerResult GCode::process_layer( if (layer_tools.has_wipe_tower && m_wipe_tower) m_last_processor_extrusion_role = erWipeTower; + // Belt printers: now that this pass's tool is selected, lay down any ordinary-layer + // apron band whose brim filament is this extruder, before the object extrusion at + // this Z (brim goes down first, with the correct tool). Restore the origin so the + // object-setup code below is unaffected. + if (print.has_belt_brim()) { + const Vec2d saved_origin = m_origin; + gcode += emit_belt_brim_for_extruder(extruder_id); + this->set_origin(saved_origin); + } + auto &filament_plan = filament_to_print_instances[extruder_id]; std::vector &instances_to_print = filament_plan.first; const std::vector &instance_visits = filament_plan.second; @@ -6695,8 +6746,20 @@ LayerResult GCode::process_layer( const LayerToPrint &layer_to_print = layers[instance_to_print.layer_id]; if (visit.first_visit && print_wipe_extrusions == (is_anything_overridden ? 1 : 0)) { gcode += generate_object_skirt_group(print, instance_to_print.print_object, instance_to_print.instance_id, layer_tools, layer, extruder_id); - gcode += generate_object_brim(print, instance_to_print.print_object, instance_to_print.instance_id, first_layer, - layer_to_print.object_layer); + const PrintObject &vobj = instance_to_print.print_object; + if (vobj.has_belt_brim()) { + // Coincident belt brim: emit once, only in this object's brim-filament + // pass (extruder_id and belt_brim_filament()-1 are both 0-based here), + // and dedup on the LayerToPrint index (not Layer::id()) so the orphan + // sweep below never re-emits it. + if (extruder_id == (unsigned int)(vobj.belt_brim_filament() - 1) && + belt_brim_emitted.insert({ instance_to_print.layer_id, instance_to_print.instance_id }).second) + gcode += generate_object_brim(print, vobj, instance_to_print.instance_id, first_layer, + layer_to_print.object_layer); + } else { + gcode += generate_object_brim(print, vobj, instance_to_print.instance_id, first_layer, + layer_to_print.object_layer); + } } // To control print speed of the 1st object layer printed over raft interface. @@ -6884,6 +6947,37 @@ LayerResult GCode::process_layer( } } } + + // Belt brim orphan sweep (Finding C). A coincident belt_brim_by_layer band lives on + // an object layer, but that layer can yield no InstanceVisit above - a zero-extrusion + // lead-in slice with no coinciding support - so the in-visit emit never fired and the + // band would be dropped. Emit any such band exactly once here, keyed the same way as + // the in-visit emit so already-printed bands are skipped. These orphan layers carry + // no object material, so ending on the brim's position is harmless; we still save and + // restore m_origin, and only toolchange when the brim filament differs from the active + // one - a no-op on single-extruder prints, keeping their output unchanged. + if (print.has_belt_brim()) { + const Vec2d saved_origin = m_origin; + for (const LayerToPrint <p : layers) { + const PrintObject *obj = ltp.original_object; + if (obj == nullptr || ! obj->has_belt_brim() || ltp.object_layer == nullptr) + continue; + const size_t ltp_idx = size_t(<p - layers.data()); + const unsigned int brim0 = (unsigned int)(obj->belt_brim_filament() - 1); + const size_t i_begin = single_object_instance_idx == size_t(-1) ? 0 : single_object_instance_idx; + const size_t i_end = single_object_instance_idx == size_t(-1) ? obj->instances().size() + : single_object_instance_idx + 1; + for (size_t instance_id = i_begin; instance_id < i_end && instance_id < obj->instances().size(); ++ instance_id) { + if (! belt_brim_emitted.insert({ ltp_idx, instance_id }).second) + continue; + if (m_writer->filament() == nullptr || m_writer->filament()->id() != brim0) + gcode += this->set_extruder(brim0, print_z); + gcode += generate_object_brim(print, *obj, instance_id, first_layer, ltp.object_layer); + } + } + this->set_origin(saved_origin); + } + if (first_layer) { for (auto iter = by_extruder.begin(); iter != by_extruder.end(); ++iter) { if (!iter->second.empty()) diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 43536c30b9..e872a2f652 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -2531,6 +2531,11 @@ void GCodeProcessorResult::reset() { timelapse_warning_code = 0; printable_height = 0.0f; machine_frame_transform_active = false; + belt_tilt_angle = 0.f; + belt_z_origin = 0.f; + preslice_remap_x = RemapAxis::PosX; + preslice_remap_y = RemapAxis::PosY; + preslice_remap_z = RemapAxis::PosZ; settings_ids.reset(); filaments_count = 0; backtrace_enabled = false; diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index e0520a3694..f96748848f 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -876,14 +876,9 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto // push. Deliberately not layer_tools.has_object, which drives skirt marking // and wiping overrides. if (! object.belt_brim_prologue().empty()) { - unsigned int brim_filament = 0; - for (size_t i = 0; i < object.num_printing_regions(); ++ i) { - const unsigned int f = object.printing_region(i).config().outer_wall_filament_id.value; - if (f > 0 && (brim_filament == 0 || f < brim_filament)) - brim_filament = f; - } - if (brim_filament == 0) - brim_filament = 1; + // 1-based, same domain the object/support pushes above use; reindexed to 0-based + // with the rest of the list later. + const unsigned int brim_filament = object.belt_brim_filament(); for (const BeltBrimBand &band : object.belt_brim_prologue()) { if (band.fills.empty()) continue; @@ -893,6 +888,25 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto } } + // Coincident brim bands (belt_brim_by_layer) print ON an object layer rather than + // below it, but that layer can produce no InstanceVisit in process_layer - a + // zero-extrusion lead-in slice with no coinciding support - and the band would then + // be silently dropped. Register the brim filament on every layer that carries a + // coincident band, in the same 1-based domain as the prologue push above, so a brim + // pass always exists there. + if (object.has_belt_brim()) { + const unsigned int brim_filament = object.belt_brim_filament(); + const auto &by_layer = object.belt_brim_by_layer(); + const size_t n = std::min(by_layer.size(), object.layers().size()); + for (size_t i = 0; i < n; ++ i) { + if (by_layer[i].empty()) + continue; + LayerTools &layer_tools = this->tools_for_layer(object.layers()[i]->print_z); + layer_tools.extruders.push_back(brim_filament); + layer_tools.has_belt_brim = true; + } + } + for (auto& layer : m_layer_tools) { // Sort and remove duplicates sort_remove_duplicates(layer.extruders); @@ -940,6 +954,14 @@ void ToolOrdering::fill_wipe_tower_partitions(const PrintConfig &config, coordf_ // below the object's first layer, and treating those layers as raft would put a // wipe tower at negative Z. Belt brim and the prime tower are mutually // exclusive (rejected in Print::validate()), so simply drop the clause there. + // + // Gate on config.belt_printer, NOT on has_belt_brim: every layer below the + // object bottom on a belt printer is legitimately a sub-object stream - brim + // apron, belt support printed below Z0, or the object's own lead-in - and none of + // them is ever raft, because Print::validate() rejects raft_layers>0 on a belt + // printer outright. Narrowing this to has_belt_brim would reclassify + // belt-support-below-floor layers as raft on brim-less belt prints and reintroduce + // the negative-Z wipe tower, so the broad belt_printer gate is correct. const bool belt_no_raft_gap = config.belt_printer.value; for (LayerTools < : m_layer_tools) lt.has_wipe_tower |= (lt.has_object && (config.timelapse_type == TimelapseType::tlSmooth || lt.wipe_tower_partitions > 0)) diff --git a/src/libslic3r/GCodeWriter.cpp b/src/libslic3r/GCodeWriter.cpp index 491317e567..f3566b0a4b 100644 --- a/src/libslic3r/GCodeWriter.cpp +++ b/src/libslic3r/GCodeWriter.cpp @@ -936,7 +936,10 @@ std::string GCodeWriter::travel_to_xyz(const Vec3d &point, const std::string &co Vec2d temp = delta_no_z.normalized() * delta(2) / tan(this->filament()->travel_slope()); Vec3d slope_top_point = Vec3d(temp(0), temp(1), delta(2)) + source; GCodeG1Formatter w0; - w0.emit_xyz(slope_top_point); + // A slope lift is a straight (linear) diagonal move, so remapping its + // endpoint is exact. Route the destination through apply_axis_remap() + // when a remap is active (no-op at identity). + w0.emit_xyz(has_axis_remap() ? apply_axis_remap(slope_top_point) : slope_top_point); w0.emit_f(travel_speed * 60.0); //BBS w0.emit_comment(GCodeWriter::full_gcode_comment, comment); @@ -950,7 +953,14 @@ std::string GCodeWriter::travel_to_xyz(const Vec3d &point, const std::string &co std::string xy_z_move; { GCodeG1Formatter w0; - if (this->is_current_position_clear()) { + if (has_axis_remap()) { + // Remap may couple XY with Z; emit full XYZ in machine coordinates. + w0.emit_xyz(apply_axis_remap(target)); + w0.emit_f(travel_speed * 60.0); + w0.emit_comment(GCodeWriter::full_gcode_comment, comment); + xy_z_move = w0.string(); + } + else if (this->is_current_position_clear()) { w0.emit_xyz(target); w0.emit_f(travel_speed * 60.0); w0.emit_comment(GCodeWriter::full_gcode_comment, comment); @@ -988,7 +998,13 @@ std::string GCodeWriter::travel_to_xyz(const Vec3d &point, const std::string &co Vec3d point_on_plate = { dest_point(0) - m_x_offset, dest_point(1) - m_y_offset, dest_point(2) }; std::string out_string; GCodeG1Formatter w; - if (!this->is_current_position_clear()) + if (has_axis_remap()) { + // Remap may couple XY with Z; emit full XYZ in machine coordinates. + w.emit_xyz(apply_axis_remap(point_on_plate)); + w.emit_f(this->config.travel_speed.get_at(m_cached_extruder_idx) * 60.0); + w.emit_comment(GCodeWriter::full_gcode_comment, comment); + out_string = w.string(); + } else if (!this->is_current_position_clear()) { //force to move xy first then z after filament change w.emit_xy(Vec2d(point_on_plate.x(), point_on_plate.y())); @@ -1053,6 +1069,14 @@ std::string GCodeWriter::_travel_to_z(double z, const std::string &comment) std::string GCodeWriter::_spiral_travel_to_z(double z, const Vec2d &ij_offset, const std::string &comment) { + // A circular XY arc / spiral lift cannot be correctly axis-remapped by + // transforming only its endpoint: the arc plane (G17/XY) and the I-J center + // would change under the remap. When an axis remap is active, fall back to a + // plain linear lift instead of emitting a possibly-wrong spiral/arc. This + // single guard covers every spiral call site (lazy/eager lift and travel_to_xyz). + if (has_axis_remap()) + return _travel_to_z(z, comment); + std::string output; double speed = this->config.travel_speed_z.get_at(m_cached_extruder_idx); @@ -1199,14 +1223,19 @@ std::string GCodeWriter::extrude_to_xyz(const Vec3d &point, double dE, const std //BBS: take plate offset into consider Vec3d point_on_plate = { point(0) - m_x_offset, point(1) - m_y_offset, point(2) }; - if (has_axis_remap()) - point_on_plate = apply_axis_remap(point_on_plate); - GCodeG1Formatter w; - if (z_changed) + if (has_axis_remap()) { + // z_changed was computed from the ORIGINAL slicing Z, but an axis remap can + // make machine-Z depend on slicing X/Y. An X/Y-only move (slicing-Z + // unchanged) would then drop the required machine-Z word, so always emit + // full XYZ whenever a remap is active. + point_on_plate = apply_axis_remap(point_on_plate); w.emit_xyz(point_on_plate); - else + } else if (z_changed) { + w.emit_xyz(point_on_plate); + } else { w.emit_xy(Vec2d(point_on_plate.x(), point_on_plate.y())); + } if (!force_no_extrusion) w.emit_e(filament()->E()); //BBS diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 2df4cde79b..017fa19736 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1374,9 +1374,15 @@ StringObjectException Print::validate(std::vector *warnin for (const PrintObject *object : m_objects) { const PrintObjectConfig &ocfg = object->config(); + // Mirror PrintObject::has_belt_brim(): an inner-only brim needs a positive + // brim_width (leading/extra widen only the outer ring), so keep this + // predicate in step or the belt-brim warnings below would fire for a brim + // that has_belt_brim() rejects. const bool wants_brim = ocfg.brim_type != btNoBrim - && (ocfg.brim_width.value > 0. || ocfg.leading_brim_length.value > 0. - || ocfg.extra_brim_width.value > 0.); + && (ocfg.brim_type == btInnerOnly + ? ocfg.brim_width.value > 0. + : (ocfg.brim_width.value > 0. || ocfg.leading_brim_length.value > 0. + || ocfg.extra_brim_width.value > 0.)); if (! wants_brim) continue; diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 4b1f4891f2..ff5a505360 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -392,6 +392,12 @@ public: // trimming and the spiral vase probe, and widening it would perturb belt // support output. bool has_belt_brim() const; + // Brim filament for this object's belt brim, returned in the 1-based domain of + // PrintRegion::outer_wall_filament_id and the raw values pushed into + // LayerTools::extruders in ToolOrdering::collect_extruders (ToolOrdering reindexes + // the whole list to 0-based afterwards). Lowest positive outer_wall_filament_id + // over the printing regions, 1 if none is explicitly set. + unsigned int belt_brim_filament() 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; diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index c5d5409d73..68cedfc534 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -1171,12 +1171,37 @@ bool PrintObject::has_belt_brim() const return false; if (m_config.brim_type == btNoBrim) return false; - if (m_config.brim_width.value <= 0. && m_config.leading_brim_length.value <= 0. - && m_config.extra_brim_width.value <= 0.) + // An inner-only brim has no leading/extra geometry: leading_brim_length and + // extra_brim_width both widen the OUTER ring, which btInnerOnly never emits, so it + // produces nothing unless brim_width itself is positive. Every other brim type is + // satisfied by any one of the three widths. Requiring the width here (instead of + // "any width") stops has_belt_brim() - and therefore Print::validate() - from + // rejecting the prime tower / spiral vase for a brim that would never be drawn. + if (m_config.brim_type == btInnerOnly) { + if (m_config.brim_width.value <= 0.) + return false; + } else if (m_config.brim_width.value <= 0. && m_config.leading_brim_length.value <= 0. + && m_config.extra_brim_width.value <= 0.) { return false; + } return ! this->has_raft(); } +unsigned int PrintObject::belt_brim_filament() const +{ + // 1-based, matching PrintRegion::outer_wall_filament_id and the raw values pushed + // into LayerTools::extruders in ToolOrdering::collect_extruders (the whole list is + // reindexed to 0-based later). Lowest positive outer-wall filament over the + // printing regions; 1 when none is explicitly set. + unsigned int brim_filament = 0; + for (size_t i = 0; i < this->num_printing_regions(); ++ i) { + const unsigned int f = this->printing_region(i).config().outer_wall_filament_id.value; + if (f > 0 && (brim_filament == 0 || f < brim_filament)) + brim_filament = f; + } + return brim_filament == 0 ? 1u : brim_filament; +} + 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 diff --git a/src/libslic3r/Support/SupportMaterial.cpp b/src/libslic3r/Support/SupportMaterial.cpp index ae5ba5849e..9230807b4b 100644 --- a/src/libslic3r/Support/SupportMaterial.cpp +++ b/src/libslic3r/Support/SupportMaterial.cpp @@ -1475,6 +1475,19 @@ static inline ExPolygons detect_overhangs( } } + // Apply build plate tilt: shift lower layer polygons to simulate tilted gravity. + // This is loop-invariant across regions, so compute it once here. + const Polygons *effective_lower = &lower_layer_polygons; + Polygons tilted_lower; + if (has_tilt) { + tilted_lower = lower_layer_polygons; + const double lh = lower_layer.height; + Point tilt_shift(coord_t(scale_(lh * tan(tilt_y_rad))), + coord_t(scale_(lh * tan(tilt_x_rad)))); + translate(tilted_lower, tilt_shift); + effective_lower = &tilted_lower; + } + for (LayerRegion *layerm : layer.regions()) { // Extrusion width accounts for the roundings of the extrudates. // It is the maximum widh of the extrudate. @@ -1491,17 +1504,9 @@ static inline ExPolygons detect_overhangs( // Overhang polygons for this layer and region. Polygons diff_polygons; Polygons layerm_polygons = to_polygons(layerm->slices.surfaces); - // Apply build plate tilt: shift lower layer polygons to simulate tilted gravity - Polygons effective_lower = lower_layer_polygons; - if (has_tilt) { - const double lh = lower_layer.height; - Point tilt_shift(coord_t(scale_(lh * tan(tilt_y_rad))), - coord_t(scale_(lh * tan(tilt_x_rad)))); - translate(effective_lower, tilt_shift); - } if (lower_layer_offset == 0.f) { // Support everything. - diff_polygons = diff(layerm_polygons, effective_lower); + diff_polygons = diff(layerm_polygons, *effective_lower); if (buildplate_only) { // Don't support overhangs above the top surfaces. // This step is done before the contact surface is calculated by growing the overhang region. @@ -1512,7 +1517,7 @@ static inline ExPolygons detect_overhangs( //FIXME cache the lower layer offset if this layer has multiple regions. diff_polygons = diff(layerm_polygons, - expand(effective_lower, lower_layer_offset, SUPPORT_SURFACES_OFFSET_PARAMETERS)); + expand(*effective_lower, lower_layer_offset, SUPPORT_SURFACES_OFFSET_PARAMETERS)); if (buildplate_only && ! annotations.buildplate_covered[layer_id].empty()) { // Don't support overhangs above the top surfaces. // This step is done before the contact surface is calculated by growing the overhang region. @@ -1522,7 +1527,7 @@ static inline ExPolygons detect_overhangs( // Offset the support regions back to a full overhang, restrict them to the full overhang. // This is done to increase size of the supporting columns below, as they are calculated by // propagating these contact surfaces downwards. - diff_polygons = diff(intersection(expand(diff_polygons, lower_layer_offset, SUPPORT_SURFACES_OFFSET_PARAMETERS), layerm_polygons), effective_lower); + diff_polygons = diff(intersection(expand(diff_polygons, lower_layer_offset, SUPPORT_SURFACES_OFFSET_PARAMETERS), layerm_polygons), *effective_lower); } //FIXME add user defined filtering here based on minimal area or minimum radius or whatever. diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index faed27164f..412104e0ed 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -2859,7 +2859,6 @@ void TreeSupport::drop_nodes() const size_t tip_layers = base_radius / layer_height; //The number of layers to be shrinking the circle to create a tip. This produces a 45 degree angle. const coordf_t radius_sample_resolution = m_ts_data->m_radius_sample_resolution; const bool support_on_buildplate_only = config.support_on_build_plate_only.value; - const size_t top_interface_layers = config.support_interface_top_layers.value; const auto belt_floor_mode = m_print_config->belt_support_floor_mode.value; const bool has_belt_floor = std::abs(m_slicing_params.belt_floor_shear_factor) > EPSILON && belt_floor_mode == BeltSupportFloorMode::GeneratorOnly; @@ -3100,19 +3099,21 @@ void TreeSupport::drop_nodes() // Treat as object-surface termination (not buildplate) so // the node gets floor/interface areas instead of base pads. if (has_belt_floor && print_z_next <= belt_floor_print_z(next_position)) { + std::scoped_lock lock(m_ts_data->m_mutex); node_parent->to_buildplate = false; + neighbour->valid = false; + p_node->valid = false; } else { const bool to_buildplate = !is_inside_ex(get_collision(0, obj_layer_nr_next), next_position); SupportNode* next_node = m_ts_data->create_node(next_position, node_parent->distance_to_top + 1, obj_layer_nr_next, node_parent->support_roof_layers_below - (node_parent->distance_to_top >= 0 ? 1 : 0), to_buildplate, node_parent, print_z_next, height_next); get_max_move_dist(next_node); - m_ts_data->m_mutex.lock(); + std::scoped_lock lock(m_ts_data->m_mutex); contact_nodes[layer_nr_next].push_back(next_node); - m_ts_data->m_mutex.unlock(); + neighbour->valid = false; + p_node->valid = false; } - neighbour->valid = false; - p_node->valid = false; } else if (neighbours.size() > 1) //Don't merge leaf nodes because we would then incur movement greater than the maximum move distance. { diff --git a/src/libslic3r/Support/TreeSupport3D.cpp b/src/libslic3r/Support/TreeSupport3D.cpp index 702815e5bb..66b4338d24 100644 --- a/src/libslic3r/Support/TreeSupport3D.cpp +++ b/src/libslic3r/Support/TreeSupport3D.cpp @@ -260,14 +260,17 @@ static std::vector>> group_me } else lower_layer_offset = scaled(lower_layer.height / tan_threshold); // Apply build plate tilt: shift lower layer polygons to simulate tilted gravity - Polygons lower_src = to_polygons(lower_layer.lslices_extrudable); + Polygons lower_layer_offseted; if (has_tilt) { + Polygons lower_src = to_polygons(lower_layer.lslices_extrudable); const double lh = lower_layer.height; Point tilt_shift(coord_t(scale_(lh * tan(tilt_y_rad))), coord_t(scale_(lh * tan(tilt_x_rad)))); translate(lower_src, tilt_shift); + lower_layer_offseted = offset(lower_src, lower_layer_offset); + } else { + lower_layer_offseted = offset(lower_layer.lslices_extrudable, lower_layer_offset); } - Polygons lower_layer_offseted = offset(lower_src, lower_layer_offset); overhangs = diff(current_layer.lslices_extrudable, lower_layer_offseted); if (lower_layer_offset == 0) { raw_overhangs = overhangs; diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index 6f7ca47919..1dd11bccf6 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -1075,6 +1075,20 @@ void GLVolumeCollection::render(GLVolumeCollection::ERenderType type, const float support_normal_z = get_selection_support_normal_z(); + // Compute up direction accounting for build plate tilt. This is frame-invariant + // (config cannot change mid-render), so compute it once before the volume loop. + Vec3f up_direction = Vec3f::UnitZ(); + { + const DynamicPrintConfig& prt_cfg = GUI::wxGetApp().preset_bundle->printers.get_edited_preset().config; + double tilt_x_deg = prt_cfg.opt_float("build_plate_tilt_x"); + double tilt_y_deg = prt_cfg.opt_float("build_plate_tilt_y"); + if (tilt_x_deg != 0. || tilt_y_deg != 0.) { + double tilt_x_rad = Geometry::deg2rad(tilt_x_deg); + double tilt_y_rad = Geometry::deg2rad(tilt_y_deg); + up_direction = Vec3f(float(tan(tilt_y_rad)), float(tan(tilt_x_rad)), 1.f).normalized(); + } + } + for (GLVolumeWithIdAndZ& volume : to_render) { #if ENABLE_MODIFIERS_ALWAYS_TRANSPARENT if (type == ERenderType::Transparent) { @@ -1132,21 +1146,6 @@ void GLVolumeCollection::render(GLVolumeCollection::ERenderType type, shader->set_uniform("print_volume.type", -1); } - const float normal_z = get_selection_support_normal_z(); - - // Compute up direction accounting for build plate tilt - Vec3f up_direction = Vec3f::UnitZ(); - { - const DynamicPrintConfig& prt_cfg = GUI::wxGetApp().preset_bundle->printers.get_edited_preset().config; - double tilt_x_deg = prt_cfg.opt_float("build_plate_tilt_x"); - double tilt_y_deg = prt_cfg.opt_float("build_plate_tilt_y"); - if (tilt_x_deg != 0. || tilt_y_deg != 0.) { - double tilt_x_rad = Geometry::deg2rad(tilt_x_deg); - double tilt_y_rad = Geometry::deg2rad(tilt_y_deg); - up_direction = Vec3f(float(tan(tilt_y_rad)), float(tan(tilt_x_rad)), 1.f).normalized(); - } - } - // Per-extruder printable-height shading. The flag is set to // 2.0 only for multi-extruder printers (two per-extruder heights); otherwise it is forced to 0.0 // on every render so no stale flag survives a multi->single-extruder plate switch, keeping the @@ -1167,7 +1166,7 @@ void GLVolumeCollection::render(GLVolumeCollection::ERenderType type, shader->set_uniform("volume_world_matrix", volume.first->world_matrix()); shader->set_uniform("slope.actived", m_slope.isGlobalActive && !volume.first->is_modifier && !volume.first->is_wipe_tower); shader->set_uniform("slope.volume_world_normal_matrix", static_cast(volume.first->world_matrix().matrix().block(0, 0, 3, 3).inverse().transpose().cast())); - shader->set_uniform("slope.normal_z", normal_z); + shader->set_uniform("slope.normal_z", support_normal_z); shader->set_uniform("slope.up_direction", up_direction); #if ENABLE_ENVIRONMENT_MAP diff --git a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp index 26afdbbe33..c0d791aa00 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp @@ -567,7 +567,12 @@ void GLGizmoFdmSupports::select_facets_by_angle(float threshold_deg, bool block) auto [tilt_x_deg, tilt_y_deg] = get_build_plate_tilt(); double tilt_x_rad = tilt_x_deg * M_PI / 180.0; double tilt_y_rad = tilt_y_deg * M_PI / 180.0; - Vec3d gravity_dir = Vec3d(-tan(tilt_y_rad), -tan(tilt_x_rad), -1.0).normalized(); + const bool has_tilt = (tilt_x_deg != 0. || tilt_y_deg != 0.); + // NB: use an if, not a ?:, so each branch converts to Vec3d independently + // (the two Eigen expression types don't unify in a ternary). + Vec3d gravity_dir = -Vec3d::UnitZ(); + if (has_tilt) + gravity_dir = Vec3d(-tan(tilt_y_rad), -tan(tilt_x_rad), -1.0).normalized(); int mesh_id = -1; for (const ModelVolume* mv : mo->volumes) { @@ -578,7 +583,16 @@ void GLGizmoFdmSupports::select_facets_by_angle(float threshold_deg, bool block) const Transform3d trafo_matrix = mi->get_matrix_no_offset() * mv->get_matrix_no_offset(); Vec3f down = (trafo_matrix.inverse() * gravity_dir).cast().normalized(); - float dot_limit = std::cos(threshold); + float dot_limit; + if (!has_tilt) { + // Exact upstream computation: threshold derived from a tilted limit + // vector transformed into mesh space, so non-uniform/mirror/shear + // transforms behave identically to upstream. + Vec3f limit = (trafo_matrix.inverse() * Vec3d(std::sin(threshold), 0, -std::cos(threshold))).cast().normalized(); + dot_limit = limit.dot(down); + } else { + dot_limit = std::cos(threshold); + } // Now calculate dot product of vert_direction and facets' normals. int idx = 0; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index a67e424fc8..365384ecf9 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -12387,6 +12387,12 @@ void Plater::priv::set_bed_shape(const Pointfs &shape, // G-code load time (GCodeViewer::compute_belt_back_transform), so no mesh-side // inverse needs to be pushed to the viewer here. } else { + // Reset the BuildVolume belt state too: Bed3D::set_shape early-returns when + // the bed params are unchanged, so a belt->normal switch (or toggling belt off + // on the same printer) would otherwise leave the BuildVolume with + // m_is_belt_printer=true and an inflated Y bbox, wrongly treating out-of-bounds + // objects as printable. Idempotent for a printer that was never belt. + bed.build_volume().set_belt_printer(false, 0., false); bed.set_belt_printer(false, 0.f); if (preview) preview->get_canvas3d()->get_gcode_viewer().set_belt_printer(false, 0.f); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 5508b6e099..8de8bf6e84 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -5875,6 +5875,18 @@ if (is_marlin_flavor) // this gets executed after preset is loaded and before GUI fields are updated void TabPrinter::on_preset_loaded() { + // R8: reset the belt-tilt transition tracking to reflect the freshly loaded preset WITHOUT + // running update_fff()'s reset logic. on_preset_loaded() is called from Tab::load_current_preset() + // on every printer preset load, right before update()->update_fff(). Seeding m_was_belt_printer + // from the loaded preset's belt_printer flag means a preset switch (belt preset -> non-belt preset) + // enters update_fff() with m_was_belt_printer==false, so the belt->off clear branch is skipped and + // the newly loaded preset's manual tilt is preserved. An in-place belt toggle does NOT go through + // here (only through on_value_change->update), so m_was_belt_printer stays true there and the clear + // still fires. Seed m_belt_synced_tilt from the loaded tilt as a safeguard. + m_was_belt_printer = m_config->opt_bool("belt_printer"); + m_belt_synced_tilt_x = m_config->opt_float("build_plate_tilt_x"); + m_belt_synced_tilt_y = m_config->opt_float("build_plate_tilt_y"); + // Orca //update nozzle_volume_type const Preset& current_printer = m_preset_bundle->printers.get_selected_preset(); @@ -6477,9 +6489,15 @@ void TabPrinter::update_fff() // Belt printer: auto-sync build_plate_tilt_{x,y} (which drives support gravity tilt) // from the belt slicing rotation, the single source of truth for the physical tilt. - // Tilt about X drives tilt_x, tilt about Y drives tilt_y. When belt mode is off, - // reset whichever tilt axis matches a leftover belt value so we don't clobber a - // manually-set tilt on a non-belt tilted printer. + // Tilt about X drives tilt_x, tilt about Y drives tilt_y. + // + // R8: value-guessing (zeroing any tilt matching the dormant belt-derived tilt) wiped a + // legitimate manual build_plate_tilt on a non-belt tilted-bed printer, because the belt + // defaults (rotation=X, angle=45) make a manual tilt of 45 look belt-derived. Instead we + // track the belt->non-belt transition and the exact values belt-sync wrote, and clear the + // tilt only on a genuine in-place belt-off toggle, and only if the value is still what + // belt-sync last wrote. Preset switches reset the tracking in on_preset_loaded(), so they + // never trip the reset. if (m_config->opt_bool("belt_printer")) { auto rot_axis = m_config->option>("belt_slice_rotation")->value; const auto tilt = BeltTransformPipeline::physical_tilt( @@ -6488,17 +6506,20 @@ void TabPrinter::update_fff() m_config->set_key_value("build_plate_tilt_x", new ConfigOptionFloat(tilt.tilt_x_deg)); if (m_config->opt_float("build_plate_tilt_y") != tilt.tilt_y_deg) m_config->set_key_value("build_plate_tilt_y", new ConfigOptionFloat(tilt.tilt_y_deg)); - } else { - const auto tilt = BeltTransformPipeline::physical_tilt( - m_config->option>("belt_slice_rotation")->value, - m_config->opt_float("belt_slice_rotation_angle")); - double tx = m_config->opt_float("build_plate_tilt_x"); - double ty = m_config->opt_float("build_plate_tilt_y"); - if (tx != 0. && std::abs(tx - tilt.tilt_x_deg) < 0.01) + // Remember exactly what belt-sync wrote, so an in-place belt-off toggle can distinguish + // a still-belt-derived tilt (safe to clear) from a since-edited manual one (keep). + m_belt_synced_tilt_x = tilt.tilt_x_deg; + m_belt_synced_tilt_y = tilt.tilt_y_deg; + } else if (m_was_belt_printer) { + // Genuine in-place belt->off toggle on the same preset (on_preset_loaded() was not called + // since the last update, so m_was_belt_printer still reflects belt mode). Clear each axis + // only if it still holds the value belt-sync last wrote; a manual override is preserved. + if (m_config->opt_float("build_plate_tilt_x") == m_belt_synced_tilt_x) m_config->set_key_value("build_plate_tilt_x", new ConfigOptionFloat(0.)); - if (ty != 0. && std::abs(ty - tilt.tilt_y_deg) < 0.01) + if (m_config->opt_float("build_plate_tilt_y") == m_belt_synced_tilt_y) m_config->set_key_value("build_plate_tilt_y", new ConfigOptionFloat(0.)); } + m_was_belt_printer = m_config->opt_bool("belt_printer"); toggle_options(); } diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index 9be9bc17f8..996f1b7d97 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -623,6 +623,13 @@ private: bool m_rebuild_kinematics_page = false; void update_input_shaper_menu(GCodeFlavor flavor); + // R8: track the belt->non-belt transition so update_fff() only clears the belt-derived + // build_plate_tilt on a genuine in-place belt-off toggle, never on a manual tilt or a + // preset switch. m_belt_synced_tilt_{x,y} hold the exact values belt-sync last wrote. + bool m_was_belt_printer = false; + double m_belt_synced_tilt_x = 0.; + double m_belt_synced_tilt_y = 0.; + ogStaticText* m_fff_print_host_upload_description_line {nullptr}; ogStaticText* m_sla_print_host_upload_description_line {nullptr}; diff --git a/tests/fff_print/test_skirt_brim.cpp b/tests/fff_print/test_skirt_brim.cpp index b5d315db2a..486be88f40 100644 --- a/tests/fff_print/test_skirt_brim.cpp +++ b/tests/fff_print/test_skirt_brim.cpp @@ -9,7 +9,12 @@ #include +#include #include +#include +#include +#include +#include #include "test_helpers.hpp" // get access to init_print, etc @@ -473,6 +478,298 @@ static DynamicPrintConfig belt_brim_config() return config; } +// Same belt as belt_brim_config(), but with `filaments` distinct filaments so the brim's +// tool selection can be observed. Kept separate from belt_brim_config() so the existing +// single-filament belt tests are untouched. +static DynamicPrintConfig belt_brim_multifilament_config(unsigned int filaments, + std::initializer_list extra = {}) +{ + DynamicPrintConfig config = multifilament_config(filaments); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "belt_slice_rotation_global", 1 }, + { "gcode_remap_x", "rev_x" }, + { "gcode_remap_y", "pos_z" }, + { "gcode_remap_z", "pos_y" }, + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "skirt_loops", 0 }, + { "top_shell_layers", 0 }, + { "bottom_shell_layers", 1 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + }); + if (extra.size() > 0) + config.set_deserialize_strict(extra); + return config; +} + +// 0-based tool indices used by extrusions whose role comment contains `role` (needs +// gcode_comments). Mirrors tools_for_role in test_multifilament.cpp; statics do not cross +// translation units, so it is repeated here. +static std::set belt_tools_for_role(const std::string &gcode, const std::string &role) +{ + std::set tools; + int current_tool = 0; + GCodeReader reader; + reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + const std::string cmd(line.cmd()); + if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char) cmd[1])) + current_tool = std::stoi(cmd.substr(1)); + else if (line.extruding(self) && std::string(line.comment()).find(role) != std::string::npos) + tools.insert(current_tool); + }); + return tools; +} + +// Machine Z of the first extruding move whose role comment contains `role`, in file order; +// numeric_limits::max() when the role never extrudes. +static double first_role_z(const std::string &gcode, const std::string &role) +{ + double z = std::numeric_limits::max(); + GCodeReader parser; + parser.parse_buffer(gcode, [&z, &role](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.extruding(self) && line.comment().find(role) != std::string_view::npos) { + z = self.z(); + self.quit_parsing(); + } + }); + return z; +} + +// Number of object layers that carry a belt brim band. Each such band is emitted as one +// contiguous brim pass, so for a single object whose first-contact layer carries a band +// (the apron prologue folds into that layer's pass) this equals role_passes(gcode, "brim"). +static int nonempty_belt_brim_layers(const PrintObject &object) +{ + int n = 0; + for (const ExtrusionEntityCollection &band : object.belt_brim_by_layer()) + if (! band.empty()) + ++ n; + return n; +} + +// For each active tool, the ordinal (1-based, over extruding moves) of the FIRST move whose +// role comment contains `role`. Lets a per-object ordering check key off the object's +// unique wall filament. +static std::map first_move_by_tool(const std::string &gcode, const std::string &role) +{ + std::map first; + int tool = 0; + long idx = 0; + GCodeReader reader; + reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + const std::string cmd(line.cmd()); + if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char) cmd[1])) { + tool = std::stoi(cmd.substr(1)); + return; + } + if (! line.extruding(self)) + return; + ++ idx; + if (std::string(line.comment()).find(role) != std::string::npos && ! first.count(tool)) + first[tool] = idx; + }); + return first; +} + +// C - the band coincident with the object's FIRST contact with the belt must not be dropped: +// a belt brim has to appear at or below the object's first perimeter. On the unfixed feature +// the first-contact band is dropped and the first brim then appears only at a later (higher) +// layer. Machine Z is meaningful and shared between roles under the belt remap, so the first +// brim's Z must not exceed the first perimeter's. Both with and without support. +TEST_CASE("Belt brim is laid at the object's first belt contact", "[SkirtBrim][belt]") +{ + const bool support = GENERATE(false, true); + DYNAMIC_SECTION("enable_support=" << support) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "leading_brim_length", 0 }, + { "extra_brim_width", 0 }, + { "brim_object_gap", 0 }, + { "enable_support", support ? 1 : 0 }, + }); + const std::string gcode = slice({ cube(20) }, config); + + const double brim_z = first_role_z(gcode, "brim"); + const double peri_z = first_role_z(gcode, "perimeter"); + REQUIRE(brim_z < std::numeric_limits::max()); + REQUIRE(peri_z < std::numeric_limits::max()); + CHECK(brim_z <= peri_z + EPSILON); + } +} + +// C control - when the band's own object layer has extrusion (any interior layer of a solid +// cube), the band takes the ordinary process_layer() path and must be drawn immediately +// before that layer's perimeters, and exactly once: never dropped, never double-emitted. +TEST_CASE("Belt brim on an object layer precedes its perimeters, once", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + const std::string gc = gcode(print); + + // Ordering: the first thing extruded is brim, then perimeter. + const std::vector seq = role_sequence(gc, { "brim", "perimeter" }); + REQUIRE(seq.size() >= 2); + CHECK(seq[0] == "brim"); + CHECK(seq[1] == "perimeter"); + + // Exactly once: every band is one contiguous pass (the apron prologue folds into the + // first layer's), so the pass count equals the number of layers carrying a band - not + // twice it, which double-emission would give, nor fewer, which a dropped band would. + const int bands = nonempty_belt_brim_layers(*print.objects().front()); + REQUIRE(bands > 0); + CHECK(role_passes(gc, "brim") == bands); +} + +// B - single extruder (filament id 1). Every band must survive the 1-based -> 0-based +// filament-id conversion the apron path performs: a wrong conversion drops all single-extruder +// bands, so the pass count would collapse. The expected count is derived from the sliced +// layers, not a ratio. +TEST_CASE("Belt brim on a single extruder emits every band once", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + const std::string gc = gcode(print); + + const int expected = nonempty_belt_brim_layers(*print.objects().front()); + REQUIRE(expected > 0); + CHECK(role_passes(gc, "brim") == expected); + CHECK(belt_tools_for_role(gc, "brim") == std::set{ 0 }); // filament 1 -> tool 0 +} + +// B - multi extruder (wall filament id 2). Every belt-brim line must print on the object's +// wall filament (index 2 -> tool 1), and the total number of passes must equal the +// single-extruder baseline: no per-filament doubling. +TEST_CASE("Belt brim on a multi-extruder object uses the wall filament, no doubling", "[SkirtBrim][belt]") +{ + // Single-extruder baseline built the same way (same nozzle/flow), so the band geometry - + // and thus the band count - is identical and only the filament assignment differs. + DynamicPrintConfig base = belt_brim_multifilament_config(1, { + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + }); + const int baseline = role_passes(slice({ cube(20) }, base), "brim"); + REQUIRE(baseline > 0); + + DynamicPrintConfig config = belt_brim_multifilament_config(2, { + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + { "outer_wall_filament_id", 2 }, + { "inner_wall_filament_id", 2 }, + }); + const std::string gc = slice({ cube(20) }, config); + + CHECK(belt_tools_for_role(gc, "brim") == std::set{ 1 }); // filament 2 -> tool 1 + CHECK(role_passes(gc, "brim") == baseline); +} + +// B - two objects offset ALONG the belt (Y, since the tilt is about X), each with its own +// wall filament. Each object's brim/apron must print on that object's filament AND before +// that object's own perimeters. The object is identified by its unique tool. +TEST_CASE("Belt brim of each object precedes its perimeters on its own filament", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_multifilament_config(2, { + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "leading_brim_length", 6 }, + { "brim_object_gap", 0 }, + }); + + std::vector meshes; + meshes.emplace_back(cube(20)); + TriangleMesh second = cube(20); + second.translate(0.f, 40.f, 0.f); // offset along the belt so it lands well after the first + meshes.emplace_back(std::move(second)); + + const std::vector> overrides { + { { "outer_wall_filament_id", 1 }, { "inner_wall_filament_id", 1 } }, + { { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 } }, + }; + Print print; + Model model; + init_print(std::move(meshes), print, model, config, &overrides, /*arrange=*/false); + print.process(); + const std::string gc = gcode(print); + + // Both brims appear, each on its object's wall filament (1 -> T0, 2 -> T1). + CHECK(belt_tools_for_role(gc, "brim") == std::set{ 0, 1 }); + + const std::map brim_first = first_move_by_tool(gc, "brim"); + const std::map peri_first = first_move_by_tool(gc, "perimeter"); + for (int tool : { 0, 1 }) { + REQUIRE(brim_first.count(tool) == 1); + REQUIRE(peri_first.count(tool) == 1); + CHECK(brim_first.at(tool) < peri_first.at(tool)); + } +} + +// D - the belt-brim predicate must not fire on a request that produces no belt brim. +// leading_brim_length / extra_brim_width only feed the OUTER ring, so inner_only with zero +// brim_width yields nothing and must not claim the layers the prime tower / spiral vase need. +TEST_CASE("Belt inner-only leading brim does not reject the prime tower or spiral vase", "[SkirtBrim][belt]") +{ + auto inner_leading = [](std::initializer_list extra) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "inner_only" }, + { "brim_width", 0 }, + { "leading_brim_length", 6 }, + { "brim_object_gap", 0 }, + }); + config.set_deserialize_strict(extra); + return config; + }; + + SECTION("prime tower is left alone") { + Print print; + Model model; + init_print({ cube(20) }, print, model, inner_leading({ { "enable_prime_tower", 1 } })); + CHECK_FALSE(print.objects().front()->has_belt_brim()); + CHECK(print.validate().string.empty()); + } + SECTION("spiral vase is left alone") { + Print print; + Model model; + init_print({ cube(20) }, print, model, inner_leading({ { "spiral_mode", 1 } })); + CHECK_FALSE(print.objects().front()->has_belt_brim()); + CHECK(print.validate().string.empty()); + } + SECTION("a real inner brim still rejects the prime tower") { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "inner_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + { "enable_prime_tower", 1 }, + }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + CHECK(print.objects().front()->has_belt_brim()); + CHECK_FALSE(print.validate().string.empty()); + } +} + TEST_CASE("Belt brim spans many layers instead of one", "[SkirtBrim][belt]") { DynamicPrintConfig config = belt_brim_config(); diff --git a/tests/libslic3r/test_belt_brim.cpp b/tests/libslic3r/test_belt_brim.cpp index 9f645817e9..d5e99dafaa 100644 --- a/tests/libslic3r/test_belt_brim.cpp +++ b/tests/libslic3r/test_belt_brim.cpp @@ -275,6 +275,80 @@ SCENARIO("belt_brim_region reduces to the plate brim without an apron", "[BeltBr } } +SCENARIO("belt_brim_region builds an inner ring inside a hole", "[BeltBrim]") { + // Holed prisms (a washer) are the only footprints an inner brim has anything to grab. + // The inner path offsets the hole boundary inward and keeps the ring between the two + // offsets, clipped back inside the hole - it must be non-empty and live in the hole, + // never spill out onto the plate. No apron is applied to the inner ring. + const coord_t mm = scale_(1.); + const BeltBrimFrame frame { 1.0, 1 }; + const coord_t width = 3 * mm; + const coord_t gap = 1 * mm; + + GIVEN("a 40x40 mm washer with a 20 mm square hole") { + ExPolygon washer = make_box(0, 0, 40 * mm, 40 * mm); + add_hole(washer, 10 * mm, 10 * mm, 30 * mm, 30 * mm); + const ExPolygons footprint { washer }; + const BoundingBox hole_bb = get_extents(washer.holes.front()); + + WHEN("an inner-only brim is requested") { + const ExPolygons region = belt_brim_region(footprint, false, true, width, gap, 0, 0, frame); + THEN("a non-empty ring is produced strictly inside the hole") { + REQUIRE(! region.empty()); + CHECK(area(region) > 0); + const BoundingBox rb = get_extents(region); + CHECK(rb.min.x() >= hole_bb.min.x()); + CHECK(rb.min.y() >= hole_bb.min.y()); + CHECK(rb.max.x() <= hole_bb.max.x()); + CHECK(rb.max.y() <= hole_bb.max.y()); + } + } + WHEN("no inner brim is requested") { + THEN("the hole contributes nothing") { + CHECK(belt_brim_region(footprint, false, false, width, gap, 0, 0, frame).empty()); + } + } + } +} + +SCENARIO("Leading-edge-only retains the downhill half of the brim region", "[BeltBrim]") { + // BeltBrim.cpp ~445-458 clips the region to the object's first-contact band and keeps + // only what lies at or downhill of it. That clip is built with band_box(), which is + // file-static, so the rectangular half-band is reconstructed here with the SAME sign + // rule the code uses (low_side = shear > 0, i.e. downhill is -u) to pin the convention + // for both tilt signs. downhill_sign() is the exported accessor the flag mirrors. + const coord_t mm = scale_(1.); + const double shear = GENERATE(1.0, -1.0); + DYNAMIC_SECTION("shear " << shear) { + const BeltBrimFrame frame { shear, 1 }; // from_axis 1 => u is Y + CHECK((frame.downhill_sign() < 0) == (frame.shear > 0.)); + + const ExPolygons region { make_box(0, 0, 20 * mm, 20 * mm) }; // straddles the cut + const coord_t u_cut = 8 * mm; + const BoundingBox bb = get_extents(region); + + const bool low_side = frame.shear > 0.; + const coord_t lo = low_side ? bb.min.y() : u_cut; + const coord_t hi = low_side ? u_cut : bb.max.y(); + Polygon keep; + keep.points = { Point(bb.min.x(), lo), Point(bb.max.x(), lo), + Point(bb.max.x(), hi), Point(bb.min.x(), hi) }; + const ExPolygons kept = intersection_ex(region, Polygons{ keep }); + + REQUIRE(! kept.empty()); + const BoundingBox kb = get_extents(kept); + if (frame.shear > 0.) { + // downhill is -u: nothing above the cut survives. + CHECK(kb.max.y() <= u_cut + 2); + CHECK(kb.min.y() < u_cut); + } else { + // downhill is +u: nothing below the cut survives. + CHECK(kb.min.y() >= u_cut - 2); + CHECK(kb.max.y() > u_cut); + } + } +} + SCENARIO("The apron follows the sign of the shear", "[BeltBrim]") { // Guards the one sign convention that is easiest to get backwards: which way // is downhill, i.e. which way the belt carries the part.