From 61a0db4a87c93f0ad8d6b4772331b51eca3f5da0 Mon Sep 17 00:00:00 2001 From: harrierpigeon Date: Mon, 5 Oct 2026 23:06:01 -0500 Subject: [PATCH] Belt printer: address the review on #14394 Code review items (raistlin7447): 1. PrintObject::slice() zeroes m_belt_min_z, m_belt_global_z_offset and m_belt_global_xy_correction before slicing. They were only written in belt mode, so a project switched to a normal printer, or whose tilt axis was set to None, kept the old offsets and shifted the adaptive infill octree and the organic support layers by them. 2. TreeModelVolumes shifts the support blockers into the raft-offset index space; a test now pins the index the blocker lands on. 3. The final-alignment clamp in libnest2d is opt-in (NfpPConfig::clamp_to_bin) and arrange sets it for belt printers only. Printers with an off-centre best_object_pos keep their alignment; a flat-bed test pins that. 4. The preview's belt view follows the loaded G-code, not the selected printer: GCodeProcessor carries the file's belt keys (and, for a belt file, its bed) into export_config_for_render(), and GCodeViewer enables the belt view from the header tilt. 5. belt_shift_layer_grid() also shifts the cached belt floor and the global Z offset, so a support-only or brim-only change after the purge-prism snap matches a fresh slice. 6. update_print_fff_config() resets raft_layers and draft_shield on a belt printer instead of only greying out the fields Print::validate() rejects. 7. GCodeWriter takes a first-layer point test instead of the FirstLayerPlane; GCode installs one that measures from the belt surface, like its extrusions, so the first-layer travel speed and the second-layer temperature change no longer depend on the gcode_remap_* convention. 8. belt_brim_clip_leading_edge() is exported and called by both the generator and the test. 9. Both phong.vs shaders use slope.up_direction for the overhang highlight. The pre-slice and G-code axis remaps are gated on belt_printer through BeltTransformPipeline::axis_remap_enabled(), so belt keys left in a profile cannot change a non-belt print. Tests requested in the review: belt-only keys at non-default values leave non-belt G-code unchanged; switching a sliced project from belt to non-belt (and tilt axis None) matches a fresh slice; a support-only change on a belt purge print matches a fresh slice; non-belt start G-code moves keep the first-layer Z in the processor; the belt brim's segment count catches a band emitted twice. The belt-to-non-belt test exposed an unrelated gap: invalidate_step(posSlice) re-invalidated posSupportMaterial but not posSimplifySupportPath, so after any re-slice the regenerated support paths were exported unsimplified. posSimplifySupportPath is now in that list. Co-Authored-By: Claude Fable 5.1 --- .../include/libnest2d/placers/nfpplacer.hpp | 21 +- resources/shaders/110/phong.vs | 5 +- resources/shaders/140/phong.vs | 5 +- src/libslic3r/Arrange.cpp | 4 +- src/libslic3r/BeltBrim.cpp | 32 +- src/libslic3r/BeltBrim.hpp | 8 + src/libslic3r/BeltPurge.cpp | 9 + src/libslic3r/BeltTransform.cpp | 8 +- src/libslic3r/BeltTransform.hpp | 19 +- src/libslic3r/GCode.cpp | 76 ++++- src/libslic3r/GCode.hpp | 3 + src/libslic3r/GCode/GCodeProcessor.cpp | 28 ++ src/libslic3r/GCode/GCodeProcessor.hpp | 4 + src/libslic3r/GCodeWriter.cpp | 5 +- src/libslic3r/GCodeWriter.hpp | 38 ++- src/libslic3r/PrintObject.cpp | 5 +- src/libslic3r/PrintObjectSlice.cpp | 9 + src/slic3r/GUI/ConfigManipulation.cpp | 25 ++ src/slic3r/GUI/GCodeViewer.cpp | 13 +- tests/fff_print/test_gcode_processor.cpp | 46 +++ tests/fff_print/test_print.cpp | 286 ++++++++++++++++++ tests/fff_print/test_skirt_brim.cpp | 54 ++++ tests/libslic3r/test_arrange.cpp | 28 +- tests/libslic3r/test_belt_brim.cpp | 44 +-- 24 files changed, 680 insertions(+), 95 deletions(-) diff --git a/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp b/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp index cf66c4c97e..62d231e375 100644 --- a/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp +++ b/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp @@ -88,6 +88,18 @@ struct NfpPConfig { */ bool explore_holes = false; + /** + * @brief Keep the final pile on the bin. + * + * The final alignment centres the pile on the alignment target. A target + * near an edge (a belt printer starts its parts at the leading end of the + * belt) would push part of a pile that is larger than the room around that + * point off the bed; with this set the pile stops at the edge instead, and a + * pile that does not fit along an axis is centred on it. Off by default, so + * the alignment of every other printer is unchanged. + */ + bool clamp_to_bin = false; + /** * @brief If true, use all CPUs available. Run on a single core otherwise. */ @@ -1113,12 +1125,9 @@ private: auto d = cb - ci; - // Keep the pile on the bin. A target near an edge (a belt printer starts its parts - // at the leading end of the belt) would otherwise centre a pile that is larger than - // the room around that point on it and push part of the pile off the bed. The pile - // stops at the edge instead; the items' boxes carry their inflation, which is the - // margin left there. A pile that does not fit along an axis is centred on it. - { + // Keep the pile on the bin (see Config::clamp_to_bin). The items' boxes carry + // their inflation, which is the margin left at the edge. + if (config_.clamp_to_bin) { auto on_bin = [](Coord lo, Coord hi, Coord bin_lo, Coord bin_hi, Coord shift) { if (hi - lo >= bin_hi - bin_lo) return (bin_lo + bin_hi) / 2 - (lo + hi) / 2; diff --git a/resources/shaders/110/phong.vs b/resources/shaders/110/phong.vs index 10d36e233f..d857147c28 100644 --- a/resources/shaders/110/phong.vs +++ b/resources/shaders/110/phong.vs @@ -7,6 +7,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform mat4 view_model_matrix; @@ -46,8 +47,8 @@ void main() // Point in homogenous coordinates. world_pos = volume_world_matrix * vec4(v_position, 1.0); - // z component of normal vector in world coordinate used for slope shading - world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; + // dot product of world normal with up direction, used for slope shading + world_normal_z = slope.actived ? dot(normalize(slope.volume_world_normal_matrix * v_normal), slope.up_direction) : 0.0; gl_Position = projection_matrix * position; if (is_outline) { diff --git a/resources/shaders/140/phong.vs b/resources/shaders/140/phong.vs index c7570edb95..7120b62c66 100644 --- a/resources/shaders/140/phong.vs +++ b/resources/shaders/140/phong.vs @@ -7,6 +7,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform mat4 view_model_matrix; @@ -46,8 +47,8 @@ void main() // Point in homogenous coordinates. world_pos = volume_world_matrix * vec4(v_position, 1.0); - // z component of normal vector in world coordinate used for slope shading - world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; + // dot product of world normal with up direction, used for slope shading + world_normal_z = slope.actived ? dot(normalize(slope.volume_world_normal_matrix * v_normal), slope.up_direction) : 0.0; gl_Position = projection_matrix * position; if (is_outline) { diff --git a/src/libslic3r/Arrange.cpp b/src/libslic3r/Arrange.cpp index 8c224af515..79d93f7909 100644 --- a/src/libslic3r/Arrange.cpp +++ b/src/libslic3r/Arrange.cpp @@ -301,10 +301,12 @@ template void fill_config(PConf& pcfg, const ArrangeParams ¶ms) { if (params.is_belt) { - // Pack from the end of the belt that prints first. + // Pack from the end of the belt that prints first, and keep the pile on the + // bed when it is larger than the room around that end. pcfg.starting_point = !params.belt_reversed ? PConf::Alignment::BOTTOM_LEFT : params.belt_axis == 1 ? PConf::Alignment::TOP_LEFT : PConf::Alignment::BOTTOM_RIGHT; + pcfg.clamp_to_bin = true; } else if (params.is_seq_print) { // Start placing the items from the center of the print bed diff --git a/src/libslic3r/BeltBrim.cpp b/src/libslic3r/BeltBrim.cpp index d77f163467..3f2507f24e 100644 --- a/src/libslic3r/BeltBrim.cpp +++ b/src/libslic3r/BeltBrim.cpp @@ -230,6 +230,19 @@ static Polygon band_box(const BoundingBox &bounds, int from_axis, coordf_t u_lo, return poly; } +ExPolygons belt_brim_clip_leading_edge(const ExPolygons ®ion, const BeltBrimFrame &frame, coordf_t u_cut) +{ + if (region.empty()) + return region; + BoundingBox keep_bb = get_extents(region); + keep_bb.offset(scale_(1.)); + const bool low_side = frame.downhill_sign() < 0; // downhill is -u + const Polygon keep = band_box(keep_bb, frame.from_axis, + low_side ? unscale(frame.from_axis == 0 ? keep_bb.min.x() : keep_bb.min.y()) : u_cut, + low_side ? u_cut : unscale(frame.from_axis == 0 ? keep_bb.max.x() : keep_bb.max.y())); + return keep.empty() ? ExPolygons{} : intersection_ex(region, Polygons{ keep }); +} + // Everything the per-band line generator needs, gathered once per object. struct BeltBrimContext { @@ -476,20 +489,11 @@ void make_belt_brim(PrintObject &object) width, gap, leading, lateral, bc.frame), bc.frame); - if (bt == btLeadingEdgeOnly && ! bc.region.empty()) { - // Keep only what lies at or downhill of the object's FIRST contact with the - // belt, so the part is supported as it lands and nothing is printed alongside - // it afterwards. The cut is the uphill edge of the first layer's contact band: - // everything past it belongs to later contacts. - const coordf_t u_cut = bc.ctx.cutoff_u(object.layers().front()->print_z); - BoundingBox keep_bb = get_extents(bc.region); - keep_bb.offset(scale_(1.)); - const bool low_side = bc.frame.shear > 0.; // downhill is -u - const Polygon keep = band_box(keep_bb, bc.frame.from_axis, - low_side ? unscale(bc.frame.from_axis == 0 ? keep_bb.min.x() : keep_bb.min.y()) : u_cut, - low_side ? u_cut : unscale(bc.frame.from_axis == 0 ? keep_bb.max.x() : keep_bb.max.y())); - bc.region = keep.empty() ? ExPolygons{} : intersection_ex(bc.region, Polygons{ keep }); - } + if (bt == btLeadingEdgeOnly && ! bc.region.empty()) + // The cut is the uphill edge of the first layer's contact band: everything + // past it belongs to later contacts. + bc.region = belt_brim_clip_leading_edge(bc.region, bc.frame, + bc.ctx.cutoff_u(object.layers().front()->print_z)); if (bc.region.empty()) return; diff --git a/src/libslic3r/BeltBrim.hpp b/src/libslic3r/BeltBrim.hpp index ac4986c311..3f4fcc97de 100644 --- a/src/libslic3r/BeltBrim.hpp +++ b/src/libslic3r/BeltBrim.hpp @@ -91,6 +91,14 @@ inline ExPolygons belt_unflatten(const ExPolygons &src, const BeltBrimFrame &fra // t - not when it is wider in its narrowest Euclidean direction. ExPolygons sweep_ex(const ExPolygons &src, const Point &t); +// "Leading edge only": keep the part of a brim region (unflattened, slicing XY) +// at or downhill of the object's first contact with the belt, so the part is +// supported as it lands and nothing is printed alongside it afterwards. `u_cut` +// is the uphill edge of the first layer's contact band along `frame.from_axis`, +// in mm (BeltFloorContext::cutoff_u of the first layer); downhill is the side +// `frame.downhill_sign()` points to. +ExPolygons belt_brim_clip_leading_edge(const ExPolygons ®ion, const BeltBrimFrame &frame, coordf_t u_cut); + // Brim region for one already-flattened belt footprint. All lengths are scaled // and measured in the flattened (true on-belt) metric. // diff --git a/src/libslic3r/BeltPurge.cpp b/src/libslic3r/BeltPurge.cpp index 3b5c5571a6..5fb925f9aa 100644 --- a/src/libslic3r/BeltPurge.cpp +++ b/src/libslic3r/BeltPurge.cpp @@ -333,6 +333,15 @@ void PrintObject::belt_shift_layer_grid(double delta) for (BeltBrimBand &band : m_belt_brim_prologue) band.print_z += delta; m_slicing_params.belt_floor_z_shift += delta; + // The grid stays shifted across a support-only or brim-only change (posSlice does + // not rerun), so everything slice() derived from it has to follow: the cached floor + // that update_slicing_parameters() restores, and the global offset the organic + // support layers and the adaptive infill octree are placed with. Left alone, the + // next alignment finds a delta of 0 and the floor and the supports sit up to half + // a layer off the grid, unlike a fresh slice. + if (m_belt_floor_z_shift_cache_valid) + m_belt_floor_z_shift_cached += delta; + m_belt_global_z_offset += delta; } // Belt mode: drop layers strictly above z (used to cancel the purge prism early diff --git a/src/libslic3r/BeltTransform.cpp b/src/libslic3r/BeltTransform.cpp index 0a59507ff2..d1ea31d33c 100644 --- a/src/libslic3r/BeltTransform.cpp +++ b/src/libslic3r/BeltTransform.cpp @@ -96,15 +96,13 @@ Transform3d BeltTransformPipeline::build_forward_transform(const PrintConfig &co BoundingBoxf3 BeltTransformPipeline::remap_bbox(const BoundingBoxf3 &bb, const PrintConfig &config) { + if (!has_preslice_remap(config)) + return bb; // Identity remap, or belt mode off. + int pre_rx = int(config.preslice_remap_x.value); int pre_ry = int(config.preslice_remap_y.value); int pre_rz = int(config.preslice_remap_z.value); - if (pre_rx == int(RemapAxis::PosX) && - pre_ry == int(RemapAxis::PosY) && - pre_rz == int(RemapAxis::PosZ)) - return bb; // Identity remap. - auto remap_coord = [](int r, const Vec3d &v) -> double { int axis = r % 3; if (r < 3) return v[axis]; diff --git a/src/libslic3r/BeltTransform.hpp b/src/libslic3r/BeltTransform.hpp index 2fe725e1df..59669fe36a 100644 --- a/src/libslic3r/BeltTransform.hpp +++ b/src/libslic3r/BeltTransform.hpp @@ -47,16 +47,31 @@ class BeltTransformPipeline public: // ---- Identity checks -------------------------------------------------- + // Whether the axis remaps (preslice_remap_* and gcode_remap_*) apply at all. + // The remap fields are only offered in the belt printer group, so a value + // left in a profile must not change a non-belt print: with belt mode off every + // belt-only key is a no-op. This is the one place to widen if a non-belt use + // ever needs them. + static bool axis_remap_enabled(const PrintConfig &config) { return config.belt_printer.value; } + static bool axis_remap_enabled(const DynamicPrintConfig &config) + { + auto *opt = config.option("belt_printer"); + return opt != nullptr && opt->value; + } + static bool has_preslice_remap(const PrintConfig &config) { - return int(config.preslice_remap_x.value) != int(RemapAxis::PosX) || + return axis_remap_enabled(config) && + (int(config.preslice_remap_x.value) != int(RemapAxis::PosX) || int(config.preslice_remap_y.value) != int(RemapAxis::PosY) || - int(config.preslice_remap_z.value) != int(RemapAxis::PosZ); + int(config.preslice_remap_z.value) != int(RemapAxis::PosZ)); } // Overload accepting DynamicPrintConfig (used in static slicing_parameters). static bool has_preslice_remap(const DynamicPrintConfig &config) { + if (! axis_remap_enabled(config)) + return false; auto get_int = [&](const char *key) -> int { auto *opt = config.option>(key); return opt ? int(opt->value) : 0; diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 7fefed0f87..690c4e99ac 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -107,6 +107,7 @@ #include "calib.hpp" #include "libslic3r_version.h" #include "GCode/BeltKinematics.hpp" +#include "FirstLayerPlane.hpp" // Intel redesigned some TBB interface considerably when merging TBB with their oneAPI set of libraries, see GH #7332. // We are using quite an old TBB 2017 U7. Before we update our build servers, let's use the old API, which is deprecated in up to date TBB. #if ! defined(TBB_VERSION_MAJOR) @@ -3133,15 +3134,18 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato this->init_belt_writer(print); m_writer.set_is_bbl_machine(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. + // G-code axis remap. Only belt printers get one (see + // BeltTransformPipeline::axis_remap_enabled): a remap left in a profile must + // not change a non-belt print. 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); + const bool remap = BeltTransformPipeline::axis_remap_enabled(print.config()); + int rx = remap ? int(print.config().gcode_remap_x.value) : int(RemapAxis::PosX); + int ry = remap ? int(print.config().gcode_remap_y.value) : int(RemapAxis::PosY); + int rz = remap ? int(print.config().gcode_remap_z.value) : int(RemapAxis::PosZ); 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(), @@ -3153,13 +3157,21 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato // circuit to the legacy Layer::id() == 0 path so g-code stays bit- // identical to the pre-feature behavior. m_first_layer_plane = std::make_unique(print.config()); - // Belt writers only: the plane also switches travel-speed selection to be - // per-point (see GCodeWriter::uses_pointwise_travel_speed()), which must not - // change for non-belt printers. + // Belt writers only: travel-speed selection becomes per-point (see + // GCodeWriter::uses_pointwise_travel_speed()), which must not change for + // non-belt printers. The writer gets the same test the extrusions use, so a + // travel is judged against the belt surface (belt_height_above_floor) exactly + // like the path it leads to, and not against the FirstLayerPlane, which + // misreports the height under a non-identity gcode_remap_*. Writer points + // carry the G-code origin and extruder offset that point_to_gcode() added; + // the belt surface is described in the object's own frame. if (print.config().belt_printer.value) { - m_writer.set_first_layer_plane( - m_first_layer_plane.get(), - print.config().initial_layer_print_height.value); + m_writer.set_first_layer_point_test([this](const Vec3d &point_logical) { + const Vec2d extruder_offset = m_writer.filament() != nullptr ? EXTRUDER_CONFIG(extruder_offset) : Vec2d::Zero(); + return this->on_first_layer(Vec3d(point_logical.x() - m_origin.x() + extruder_offset.x(), + point_logical.y() - m_origin.y() + extruder_offset.y(), + point_logical.z())); + }); } // How many times will be change_layer() called? @@ -6295,7 +6307,11 @@ LayerResult GCode::process_layer( // fall back to the legacy `!first_layer` predicate so behavior is // bit-identical to the pre-feature path. bool past_first_layer_band = !first_layer; - if (m_first_layer_plane && m_first_layer_plane->is_active()) { + if (int past = this->belt_layer_past_first_layer_band(object_layer); past >= 0) { + // Belt surface known for this object: measured from the belt itself, as the + // extrusions and travels are, rather than through FirstLayerPlane. + past_first_layer_band = past > 0; + } else if (m_first_layer_plane && m_first_layer_plane->is_active()) { past_first_layer_band = false; if (object_layer != nullptr) { // Conservatively walk the layer's lslice bboxes; if every bbox's @@ -10617,6 +10633,36 @@ std::string GCode::set_object_info(Print *print) { return gcode.str(); } +// Whether an object layer lies entirely past the first-layer band above the belt: +// 1 when its lowest point is at least one band thickness above the belt, 0 when +// any of it is inside the band, -1 when the belt surface is not known for this +// layer (not a belt print, an explicit first-layer plane, or no object layer), in +// which case the caller falls back to FirstLayerPlane / the slicing layer index. +int GCode::belt_layer_past_first_layer_band(const Layer *object_layer) const +{ + if (object_layer == nullptr) + return -1; + // The belt surface is linear in the sliced XY, so a bbox's lowest point above + // it is at one of its corners. + double min_height = std::numeric_limits::max(); + bool known = false; + for (const BoundingBox &bb : object_layer->lslices_bboxes) { + const double xs[2] = { unscale(bb.min.x()), unscale(bb.max.x()) }; + const double ys[2] = { unscale(bb.min.y()), unscale(bb.max.y()) }; + for (double x : xs) + for (double y : ys) { + double h; + if (! this->belt_height_above_floor(Vec3d(x, y, object_layer->print_z), h)) + return -1; + known = true; + min_height = std::min(min_height, h); + } + } + if (! known) + return -1; + return min_height >= this->first_layer_band_mm() - EPSILON ? 1 : 0; +} + bool GCode::belt_height_above_floor(const Vec3d &point_slicing_mm, double &height_mm) const { // The owning object, which is what carries the belt description. During diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 3a08ffccec..c5268705c3 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -993,6 +993,9 @@ protected: // first-layer. Measuring against the belt itself is independent of every // remap and back-transform. bool belt_height_above_floor(const Vec3d &point_slicing_mm, double &height_mm) const; + // 1 / 0 / -1: the object layer is entirely past the first-layer band above the + // belt / reaches into it / the belt surface is not known for it. + int belt_layer_past_first_layer_band(const Layer *object_layer) const; int layer_id() const { if (m_layer == nullptr) return -1; diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 1832e24a84..fa418e1b62 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -3214,6 +3214,33 @@ void GCodeProcessor::apply_config(const DynamicPrintConfig& config) { m_parser.apply_config(config); + // Belt printer: remember the file's belt keys for export_config_for_render(). The + // config block lists belt_printer for every printer, so a non-belt file loaded while + // a belt printer is selected switches the preview's belt view off, and a belt file + // loaded on another printer brings its own tilt, remaps and bed along. + m_belt_render_config.clear(); + { + const auto *belt = config.option("belt_printer"); + if (belt != nullptr) { + static const char *belt_keys[] = { + "belt_printer", "belt_slice_rotation", "belt_slice_rotation_angle", "belt_slice_rotation_global", + "belt_preslice_global", "preslice_remap_x", "preslice_remap_y", "preslice_remap_z", "preslice_remap_global", + "gcode_remap_x", "gcode_remap_y", "gcode_remap_z", "gcode_back_transform", + "belt_frame_tilt_decouple", "belt_frame_tilt_angle", + }; + for (const char *key : belt_keys) + if (const ConfigOption *opt = config.option(key); opt != nullptr) + m_belt_render_config.set_key_value(key, opt->clone()); + // The Rev remaps mirror inside the build volume, so the designed view needs + // the bed the file was sliced for. Only a belt file may override it. + static const char *bed_keys[] = { "printable_area", "printable_height" }; + if (belt->value) + for (const char *key : bed_keys) + if (const ConfigOption *opt = config.option(key); opt != nullptr) + m_belt_render_config.set_key_value(key, opt->clone()); + } + } + //BBS const ConfigOptionFloatsNullable* nozzle_volume = config.option("nozzle_volume"); if (nozzle_volume != nullptr) { @@ -3738,6 +3765,7 @@ DynamicConfig GCodeProcessor::export_config_for_render() const config.set_key_value("filament_is_support", new ConfigOptionBools(m_parser.get_config().filament_is_support.values)); config.set_key_value("filament_type", new ConfigOptionStrings(m_parser.get_config().filament_type.values)); config.set_key_value("filament_map", new ConfigOptionInts(m_parser.get_config().filament_map.values)); + config.apply(m_belt_render_config); return config; } diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 09dd99816e..a5c5ce7914 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -1105,6 +1105,10 @@ class Print; private: CommandProcessor m_command_processor; GCodeReader m_parser; + // Belt printer: the belt keys of the loaded file's config block (plus the bed they + // are relative to), handed to the preview through export_config_for_render() so the + // belt view and its back-transform follow the file, not the selected printer. + DynamicConfig m_belt_render_config; EUnits m_units; EPositioningType m_global_positioning_type; EPositioningType m_e_local_positioning_type; diff --git a/src/libslic3r/GCodeWriter.cpp b/src/libslic3r/GCodeWriter.cpp index a6e81dfe1e..6176b6693c 100644 --- a/src/libslic3r/GCodeWriter.cpp +++ b/src/libslic3r/GCodeWriter.cpp @@ -1,5 +1,4 @@ #include "GCodeWriter.hpp" -#include "FirstLayerPlane.hpp" #include "Config.hpp" #include "Extruder.hpp" #include "Geometry.hpp" @@ -57,8 +56,8 @@ bool GCodeWriter::must_skip_lift_now() const bool GCodeWriter::point_on_first_layer(const Vec3d &point_logical) const { - if (m_first_layer_plane && m_first_layer_plane->is_active()) - return m_first_layer_plane->is_first_layer(point_logical, m_first_layer_thickness_mm); + if (m_first_layer_point_test) + return m_first_layer_point_test(point_logical); return m_is_first_layer; } diff --git a/src/libslic3r/GCodeWriter.hpp b/src/libslic3r/GCodeWriter.hpp index 224ad4c1e4..88be72a6f1 100644 --- a/src/libslic3r/GCodeWriter.hpp +++ b/src/libslic3r/GCodeWriter.hpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include "Extruder.hpp" #include "Point.hpp" #include "Polygon.hpp" @@ -20,7 +22,6 @@ namespace Slic3r { -class FirstLayerPlane; class GCodeWriter { public: @@ -168,13 +169,13 @@ public: void set_kinematics(std::unique_ptr kinematics); const MachineKinematics& kinematics() const { return *m_kinematics; } - // First-layer plane evaluator. When set to an active plane, travel speed - // selection consults the plane per destination point instead of the - // layer-coarse m_is_first_layer flag. Borrowed pointer; lifetime is owned - // by GCode, which constructs the plane after the writer exists -- so this is - // deliberately a setter and not a constructor argument. - void set_first_layer_plane(const FirstLayerPlane *plane, double first_layer_height_mm) - { m_first_layer_plane = plane; m_first_layer_thickness_mm = first_layer_height_mm; } + // Per-point first-layer test. When set, travel speed selection asks it per + // destination point (in the writer's logical placed frame) instead of using + // the layer-coarse m_is_first_layer flag. GCode installs it on belt printers + // with the same test its extrusions use (GCode::on_first_layer(point)), so a + // travel is judged against the belt surface exactly as the path it leads to. + using FirstLayerPointTest = std::function; + void set_first_layer_point_test(FirstLayerPointTest test) { m_first_layer_point_test = std::move(test); } // Force every lift to a plain vertical lift. Spiral and slope lifts compute // their slope in the logical frame and do not account for a machine mapping @@ -197,9 +198,8 @@ protected: std::string _travel_to_z(double z, const std::string &comment); - // Whether a destination gets first-layer treatment. With an active plane - // evaluator, distance from the plane decides; otherwise the layer-coarse - // m_is_first_layer flag does. + // Whether a destination gets first-layer treatment. With a point test + // installed it decides; otherwise the layer-coarse m_is_first_layer flag does. bool point_on_first_layer(const Vec3d &point_logical) const; // True when a lift must be skipped because this mapping would emit the @@ -207,17 +207,15 @@ protected: bool must_skip_lift_now() const; // True when travel speed is selected per destination point rather than per - // layer. Set for writers that install a first-layer plane. The historical - // path emits the raw configured travel speed in the final branch of - // travel_to_xyz(), ignoring the first-layer selection computed at the top of - // that function; a plane-driven writer uses the first-layer-aware value - // throughout. Both are preserved exactly -- unifying them would change + // layer. Set for writers that install a first-layer point test. The + // historical path emits the raw configured travel speed in the final branch + // of travel_to_xyz(), ignoring the first-layer selection computed at the top + // of that function; a point-test-driven writer uses the first-layer-aware + // value throughout. Both are preserved exactly -- unifying them would change // emitted feedrates and belongs in its own commit. - bool uses_pointwise_travel_speed() const { return m_first_layer_plane != nullptr; } + bool uses_pointwise_travel_speed() const { return bool(m_first_layer_point_test); } - // Borrowed; null = inactive. - const FirstLayerPlane *m_first_layer_plane = nullptr; - double m_first_layer_thickness_mm = 0.; + FirstLayerPointTest m_first_layer_point_test; bool m_force_normal_lift = false; // The machine frame mapping. Owns the axis-remap state that used to live diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 417b472bd0..748bed4a1f 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -1777,7 +1777,10 @@ bool PrintObject::invalidate_step(PrintObjectStep step) invalidated |= this->invalidate_steps({ posIroning, posContouring, posSimplifyInfill }); invalidated |= m_print->invalidate_steps({ psSkirtBrim }); } else if (step == posSlice) { - invalidated |= this->invalidate_steps({ posPerimeters, posPrepareInfill, posInfill, posIroning, posContouring, posSupportMaterial, posSimplifyPath, posSimplifyInfill }); + // posSimplifySupportPath is listed with posSupportMaterial: invalidate_steps() does not + // propagate, so without it a re-slice regenerated the supports but kept the step done, + // and the new support paths were exported unsimplified, unlike a fresh slice. + invalidated |= this->invalidate_steps({ posPerimeters, posPrepareInfill, posInfill, posIroning, posContouring, posSupportMaterial, posSimplifyPath, posSimplifyInfill, posSimplifySupportPath }); invalidated |= m_print->invalidate_steps({ psSkirtBrim }); m_slicing_params.valid = false; // The exact belt_floor_z_shift is recomputed when slice() runs again. diff --git a/src/libslic3r/PrintObjectSlice.cpp b/src/libslic3r/PrintObjectSlice.cpp index 8795cdc444..b384a28ef3 100644 --- a/src/libslic3r/PrintObjectSlice.cpp +++ b/src/libslic3r/PrintObjectSlice.cpp @@ -895,6 +895,15 @@ void PrintObject::slice() this->update_layer_height_profile(*this->model_object(), m_slicing_params, layer_height_profile); m_print->throw_if_canceled(); m_typed_slices = false; + // The belt state below is only written while belt mode is on (and the min-Z + // lift only when there is a rotation or remap). Start every slice from zero, + // or a project switched from a belt printer to a normal one, or whose tilt + // axis was set to None, keeps the previous offsets: the adaptive infill octree + // and the organic support layers (PrintObject.cpp) would still be shifted by + // them. + m_belt_min_z = 0.; + m_belt_global_z_offset = 0.; + m_belt_global_xy_correction = Vec2d::Zero(); this->clear_layers(); m_layers = new_layers(this, generate_object_layers(m_slicing_params, layer_height_profile, m_config.precise_z_height.value)); this->slice_volumes(); diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 0d1c141fa8..9c4a03ca47 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -353,6 +353,31 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con bool is_object_config = (!is_global_config && !is_plate_config); + // Belt printer: a raft and a draft shield are refused by Print::validate(), and + // the fields that would clear them are greyed out in belt mode, so a preset that + // carries either could not be sliced at all. Reset them instead of only disabling + // the fields. + if (GUI::wxGetApp().preset_bundle != nullptr) { + const auto *belt_opt = GUI::wxGetApp().preset_bundle->printers.get_edited_preset().config.option("belt_printer"); + const auto *raft_opt = config->option("raft_layers"); + const auto *shield_opt = config->option>("draft_shield"); + const bool has_raft = raft_opt != nullptr && raft_opt->value > 0; + const bool has_shield = shield_opt != nullptr && shield_opt->value != dsDisabled; + if (belt_opt != nullptr && belt_opt->value && (has_raft || has_shield)) { + const wxString msg_text = _(L("Raft and draft shield are not available on belt printers.\nThey have been disabled.")); + MessageDialog dialog(m_msg_dlg_parent, msg_text, "", wxICON_WARNING | wxOK); + DynamicPrintConfig new_conf = *config; + is_msg_dlg_already_exist = true; + dialog.ShowModal(); + if (has_raft) + new_conf.set_key_value("raft_layers", new ConfigOptionInt(0)); + if (has_shield) + new_conf.set_key_value("draft_shield", new ConfigOptionEnum(dsDisabled)); + apply(config, &new_conf); + is_msg_dlg_already_exist = false; + } + } + // layer_height shouldn't be equal to zero auto layer_height = config->opt_float("layer_height"); if (layer_height < EPSILON) diff --git a/src/slic3r/GUI/GCodeViewer.cpp b/src/slic3r/GUI/GCodeViewer.cpp index cec7f9507e..b03e6b5f03 100644 --- a/src/slic3r/GUI/GCodeViewer.cpp +++ b/src/slic3r/GUI/GCodeViewer.cpp @@ -1376,9 +1376,12 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const m_loaded_as_preview = false; // Belt printers: drive the designed/raw view UI (legend checkbox, hotkey B, canvas-toolbar - // menu item) from the loaded print. The tilt magnitude comes from the G-code header - // (gcode_result.belt_tilt_angle, abs of the slicing rotation). - m_belt_view_enabled = print.config().belt_printer.value; + // menu item) from the G-code itself. Only BeltGCode writes the belt header, so its tilt + // (gcode_result.belt_tilt_angle, abs of the slicing rotation) says whether this is belt + // G-code; the selected printer does not, for a file opened from disk. The back-transform + // below still reads print.config(), which Plater::load_gcode() fills from the file's own + // config block (GCodeProcessor::export_config_for_render). + m_belt_view_enabled = gcode_result.belt_tilt_angle > 0.f; m_belt_angle_deg = gcode_result.belt_tilt_angle; const bool current_top_layer_only = m_viewer.is_top_layer_only_view_range(); @@ -1394,7 +1397,7 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const // On a belt printer the toolpath geometry fed to libvgcode also depends on the // designed/raw view state (the back-transform is applied in convert), so the // same result is converted again only when that view has been toggled. - const bool same_belt_view = !print.config().belt_printer.value || m_last_belt_show_designed == m_belt_show_designed; + const bool same_belt_view = !m_belt_view_enabled || m_last_belt_show_designed == m_belt_show_designed; if (m_last_result_id == gcode_result.id && wxGetApp().is_editor() && same_belt_view) { //BBS: add logs BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": the same id %1%, return directly, result %2% ") % m_last_result_id % (&gcode_result); @@ -1441,7 +1444,7 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const // the toolpath geometry into model/Cartesian space using the general belt // inverse (handles any mesh rotation + shear + axis remap). When off, the raw // machine-frame G-code is shown (useful for debugging the transform itself). - const bool is_belt = print.config().belt_printer.value; + const bool is_belt = m_belt_view_enabled && print.config().belt_printer.value; Transform3d belt_inv = (is_belt && m_belt_show_designed) ? compute_belt_back_transform(print.config()) : Transform3d::Identity(); // Belt: move positions are stored as gcode_Z + belt_z_origin (the start G-code's diff --git a/tests/fff_print/test_gcode_processor.cpp b/tests/fff_print/test_gcode_processor.cpp index 2d3aab2909..d45d6534f7 100644 --- a/tests/fff_print/test_gcode_processor.cpp +++ b/tests/fff_print/test_gcode_processor.cpp @@ -6,6 +6,7 @@ #include "test_utils.hpp" +#include #include #include #include @@ -47,3 +48,48 @@ TEST_CASE("The belt header's angle marks G-code as belt G-code", "[GCodeProcesso "; CONFIG_BLOCK_START\n; belt_printer = 1\n; belt_slice_rotation_angle = -45\n; CONFIG_BLOCK_END\n"; CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(45., 1e-6)); } + +TEST_CASE("Non-belt start G-code moves keep the first-layer Z in the processor", "[GCodeProcessor]") +{ + // The belt path (GCodeWriter tests: "start-gcode prepare-stage moves keep their real Z") + // stores the real Z of a move inside the start G-code. Every other printer must keep + // the historical behaviour: a prepare-stage move is pinned to the first-layer height + // so the preview does not draw the start sequence's travel. The gate is the belt + // header, so a file without one, whatever its config block says, takes this path. + struct BBLPrinterGuard { + bool prev = GCodeProcessor::s_IsBBLPrinter; + BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = false; } + ~BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = prev; } + } bbl_guard; + + const std::string gcode = + "G90\n" + "G21\n" + "M83\n" + ";TYPE:Custom\n" + "G1 E-1.5 F2100\n" + "G1 X45 Y0.3 Z50 F12000\n" // prepare-stage travel to a high Z + "G1 E1.5 F1800\n" + ";TYPE:Outer wall\n" + "G1 X46 Y0.3 Z50 E0.05\n" + "; CONFIG_BLOCK_START\n; belt_printer = 0\n; belt_slice_rotation_angle = 45\n; CONFIG_BLOCK_END\n"; + + GCodeProcessor processor; + FullPrintConfig config; + config.initial_layer_print_height.value = 0.3; + processor.apply_config(config); + processor.process_buffer(gcode); + const GCodeProcessorResult &result = processor.get_result(); + REQUIRE_THAT(result.belt_tilt_angle, WithinAbs(0., 1e-6)); + + size_t first_extrude = result.moves.size(); + for (size_t i = 0; i < result.moves.size(); ++i) + if (result.moves[i].type == EMoveType::Extrude) { first_extrude = i; break; } + REQUIRE(first_extrude < result.moves.size()); + REQUIRE(first_extrude > 0); + + // The extrusion keeps its real Z; the prepare-stage move before it is pinned to the + // first-layer height. + CHECK_THAT(result.moves[first_extrude].position.z(), WithinAbs(50., 1e-3)); + CHECK_THAT(result.moves[first_extrude - 1].position.z(), WithinAbs(0.3, 1e-3)); +} diff --git a/tests/fff_print/test_print.cpp b/tests/fff_print/test_print.cpp index a098a4a653..bdebf052b6 100644 --- a/tests/fff_print/test_print.cpp +++ b/tests/fff_print/test_print.cpp @@ -14,6 +14,7 @@ #include #include #include "libslic3r/PrintConfig.hpp" +#include "libslic3r/libslic3r.h" #include #include "libslic3r/Surface.hpp" #include "libslic3r/Config.hpp" @@ -26,6 +27,10 @@ #include "libslic3r/Print.hpp" #include "libslic3r/Layer.hpp" +#include "libslic3r/BuildVolume.hpp" +#include "libslic3r/Support/TreeModelVolumes.hpp" +#include "libslic3r/Support/TreeSupportCommon.hpp" +#include "libslic3r/Polygon.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/GCodeReader.hpp" #include "libslic3r/GCode/GCodeProcessor.hpp" @@ -969,3 +974,284 @@ TEST_CASE("Slicing errors are reported per object with the object's name", "[Pri CHECK(message.rfind("floating cube: ", 0) == 0); CHECK(message.find("empty first layer") != std::string::npos); } + +// --------------------------------------------------------------------------- +// Belt mode must be invisible when it is off, and must not leave traces behind. +// --------------------------------------------------------------------------- + +// Everything the slicer decided, without the lines that legitimately differ between +// two exports of the same print: comments (the config block lists every key, the +// header carries the export time) and the thumbnail blocks. +static std::string gcode_body(const std::string &gcode) +{ + std::string body; + std::istringstream in(gcode); + for (std::string line; std::getline(in, line); ) { + line.erase(std::min(line.size(), line.find(';'))); + while (! line.empty() && line.back() == ' ') + line.pop_back(); + if (! line.empty()) + body += line + '\n'; + } + return body; +} + +static DynamicPrintConfig belt_test_config() +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + 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 }, + { "z_hop", 0 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + return config; +} + +TEST_CASE("Belt-only keys at non-default values leave non-belt G-code unchanged", "[Print][belt][Regression]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "z_hop", 0 }, + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "sparse_infill_pattern", "adaptivecubic" }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + const std::string reference = gcode_body(slice({ TestMesh::overhang }, config)); + REQUIRE(! reference.empty()); + + // Every belt key a profile can carry, at a value that would change a belt print. + // belt_printer stays off, so none of them may reach the G-code: the axis remaps are + // gated on belt mode, the rest is only read on belt printers. build_plate_tilt_x/y + // and an explicit first_layer_plane are features of their own on a flat bed and are + // left alone here; "leading_edge_only" prints as an outer brim by design. + config.set_deserialize_strict({ + { "belt_printer", 0 }, + { "belt_printer_infinite_y", 0 }, + { "belt_slice_rotation", "y" }, + { "belt_slice_rotation_angle", 30 }, + { "belt_slice_rotation_global", 0 }, + { "belt_preslice_global", 0 }, + { "preslice_remap_x", "pos_x" }, + { "preslice_remap_y", "pos_z" }, + { "preslice_remap_z", "neg_y" }, + { "preslice_remap_global", 1 }, + { "gcode_remap_x", "rev_x" }, + { "gcode_remap_y", "pos_z" }, + { "gcode_remap_z", "pos_y" }, + { "gcode_back_transform", 0 }, + { "belt_frame_tilt_decouple", 1 }, + { "belt_frame_tilt_angle", 30 }, + { "first_layer_plane_offset", 1 }, + { "first_layer_plane_thickness", 1 }, + { "belt_support_floor_offset", -5 }, + { "belt_support_floor_mode", "none" }, + { "belt_support_z_offset_mode", "raft_only" }, + { "enable_belt_purge_tower", 1 }, + { "belt_purge_tower_width", 10 }, + { "leading_brim_length", 10 }, + { "extra_brim_width", 5 }, + }); + CHECK(gcode_body(slice({ TestMesh::overhang }, config)) == reference); +} + +TEST_CASE("Switching a sliced project from belt to non-belt matches a fresh slice", "[Print][belt][Regression]") +{ + // The organic support layers and the adaptive infill octree are placed with the + // belt global Z offset, and the mesh with the belt min-Z lift. Both are only + // written while belt mode slices, so they used to survive a switch away from it. + DynamicPrintConfig flat = DynamicPrintConfig::full_print_config(); + flat.set_deserialize_strict({ + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "skirt_loops", 0 }, + { "z_hop", 0 }, + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "sparse_infill_pattern", "adaptivecubic" }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + DynamicPrintConfig belt = belt_test_config(); + belt.set_deserialize_strict({ + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "sparse_infill_pattern", "adaptivecubic" }, + }); + + // Both prints are placed with the belt config, so only the slicing history differs. + auto fresh_slice = [&](const DynamicPrintConfig &target) { + Print print; + Model model; + init_print({ TestMesh::overhang }, print, model, belt); + print.apply(model, target); + const std::string out = gcode(print); + return gcode_body(out); + }; + auto resliced = [&](const DynamicPrintConfig &target) { + Print print; + Model model; + init_print({ TestMesh::overhang }, print, model, belt); + REQUIRE(! gcode(print).empty()); + print.apply(model, target); + const std::string out = gcode(print); + return gcode_body(out); + }; + SECTION("belt printer to a flat-bed printer") { + CHECK(resliced(flat) == fresh_slice(flat)); + } + SECTION("belt tilt axis set to None") { + DynamicPrintConfig untilted = belt; + untilted.set_deserialize_strict({ { "belt_slice_rotation", "none" } }); + CHECK(resliced(untilted) == fresh_slice(untilted)); + } +} + +TEST_CASE("A support-only change on a belt purge print matches a fresh slice", "[Print][belt][PurgeTower][Regression]") +{ + // Snapping the purge prism onto the parts' layer grid shifts every object's layers by + // up to half a layer. A support-only change reruns support generation without + // reslicing, so the cached belt floor and the global Z offset have to carry the + // snap too, or the supports land on the pre-snap grid. + auto make_config = [](bool support) { + DynamicPrintConfig config = multifilament_config(2, { + { "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 }, + { "z_hop", 0 }, + { "enable_belt_purge_tower", 1 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + config.set_deserialize_strict({ + { "enable_support", support ? 1 : 0 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + }); + return config; + }; + const std::vector> overrides { + { { "extruder", 1 } }, { { "extruder", 2 } }, + }; + auto build = [&](Print &print, Model &model, const DynamicPrintConfig &config) { + init_print(std::vector{ mesh(TestMesh::overhang), cube(20) }, print, model, config, &overrides); + model.objects.back()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true)); + print.apply(model, config); + REQUIRE(print.has_belt_purge_tower()); + }; + + std::string fresh; + { + Print print; + Model model; + build(print, model, make_config(true)); + fresh = gcode_body(gcode(print)); + } + REQUIRE(! fresh.empty()); + + Print print; + Model model; + build(print, model, make_config(false)); + REQUIRE(! gcode(print).empty()); + // Support only: posSlice stays valid, posSupportMaterial reruns. + print.apply(model, make_config(true)); + CHECK(gcode_body(gcode(print)) == fresh); +} + +TEST_CASE("Organic tree supports place a support blocker at its own height above a raft", "[Print][Support][Regression]") +{ + // TreeModelVolumes consumes the support blockers in the same index space as the + // layer outlines, where object layer i sits at num_raft_layers + i, but + // slice_support_blockers() returns them in object-layer space. Without the shift + // every blocker lands num_raft_layers too low, so branches are kept out of the + // wrong layers and may pass through the blocked ones. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "skirt_loops", 0 }, + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "raft_layers", 3 }, + }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + // A blocker floating beside the cube, 8 mm to 12 mm above the bed, so a collision at + // its centre can only come from the blocker itself (the part keeps its mesh + // coordinates in object space, hence the offset relative to the part). + ModelObject *object = model.objects.front(); + ModelVolume *blocker = object->add_volume(TriangleMesh(its_make_cube(6., 6., 4.))); + blocker->set_type(ModelVolumeType::SUPPORT_BLOCKER); + const Vec3d part_offset = object->volumes.front()->get_offset(); + blocker->set_offset(Vec3d(part_offset.x() + 20., part_offset.y(), 10.)); + print.apply(model, config); + print.set_status_silent(); + print.process(); + + const PrintObject &print_object = *print.objects().front(); + const std::vector bed = { { 0., 0. }, { 200., 0. }, { 200., 200. }, { 0., 200. } }; + const BuildVolume build_volume{ bed, print.config().printable_height.value, {}, {} }; + TreeSupport3D::TreeModelVolumes volumes{ print_object, build_volume, scaled(1.), scaled(0.5), 0, {} }; + + // The generator's raft layer count: the raft itself plus the gap layers up to the object. + const size_t num_raft = TreeSupport3D::TreeSupportSettings(TreeSupport3D::TreeSupportMeshGroupSettings(print_object), + print_object.slicing_parameters()).raft_layers.size(); + REQUIRE(num_raft >= 3); + // Object layers the blocker was sliced into (object-layer space, as the generator + // receives them). + const std::vector blockers = print_object.slice_support_blockers(); + size_t first = 0, last = 0; + bool found = false; + for (size_t i = 0; i < blockers.size(); ++ i) + if (! blockers[i].empty()) { + if (! found) { first = i; found = true; } + last = i; + } + REQUIRE(found); + REQUIRE(last - first > num_raft); + // The blocker's centre in the slicing frame (add_volume centred its mesh on its offset). + const Vec3d centre3 = print_object.trafo_sliced() * blocker->get_offset(); + const Point centre = Point::new_scale(centre3.x(), centre3.y()); + auto collides = [&](size_t tree_layer) { + for (const Polygon &poly : volumes.getCollision(0, TreeSupport3D::LayerIndex(tree_layer), false)) + if (poly.contains(centre)) + return true; + return false; + }; + // In TreeModelVolumes' index space the blocker lives at num_raft + object layer. + CHECK(collides(num_raft + first)); + CHECK(collides(num_raft + last)); + // The layers just below it, where an unshifted blocker would land, are free; the + // layers just above the unshifted range, which the blocker does occupy, are not. + CHECK_FALSE(collides(first)); + CHECK_FALSE(collides(first + num_raft - 1)); + CHECK(collides(last + 1)); + CHECK(collides(last + num_raft)); +} diff --git a/tests/fff_print/test_skirt_brim.cpp b/tests/fff_print/test_skirt_brim.cpp index 537aac8528..45086b9d2a 100644 --- a/tests/fff_print/test_skirt_brim.cpp +++ b/tests/fff_print/test_skirt_brim.cpp @@ -834,6 +834,60 @@ TEST_CASE("Belt brim on a single extruder emits every band once", "[SkirtBrim][b CHECK(belt_tools_for_role(gc, "brim") == std::set{ 0 }); // filament 1 -> tool 0 } +// Number of brim segments the belt brim generator produced for `object`: the lattice +// lines of every per-layer band plus the apron prologue. Each segment is written as one +// extruding move, so this is what a G-code count has to match. A pass count cannot see a +// band emitted twice back to back (two copies of the same band merge into one pass). +static long belt_brim_segments(const PrintObject &object) +{ + auto segments = [](const ExtrusionEntityCollection &fills) { + long n = 0; + for (const ExtrusionEntity *entity : fills.flatten().entities) + for (const Polyline &pl : entity->as_polylines()) + n += long(pl.size()) - 1; + return n; + }; + long n = 0; + for (const ExtrusionEntityCollection &band : object.belt_brim_by_layer()) + n += segments(band); + for (const BeltBrimBand &band : object.belt_brim_prologue()) + n += segments(band.fills); + return n; +} + +// Number of extruding moves in the G-code whose role is `role`. +static long role_segments(const std::string &gcode, const std::string &role) +{ + long n = 0; + GCodeReader reader; + reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.extruding(self) && line.dist_XY(self) > EPSILON && line.comment().find(role) != std::string_view::npos) + ++ n; + }); + return n; +} + +TEST_CASE("Belt brim writes every generated segment exactly once", "[SkirtBrim][belt]") +{ + // The pass count above cannot tell one band from the same band twice in a row; the + // segment count can, so a brim band emitted twice back to back fails here. + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + { "leading_brim_length", 6 }, + }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + const std::string gc = gcode(print); + + const long expected = belt_brim_segments(*print.objects().front()); + REQUIRE(expected > 100); + CHECK(role_segments(gc, "brim") == expected); +} + // 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. diff --git a/tests/libslic3r/test_arrange.cpp b/tests/libslic3r/test_arrange.cpp index d8c675954a..f4dad02808 100644 --- a/tests/libslic3r/test_arrange.cpp +++ b/tests/libslic3r/test_arrange.cpp @@ -279,12 +279,15 @@ TEST_CASE("Arrange aligns the pile to a custom center", "[Arrange]") // Centring a pile on a point that close to the edge pushed everything longer than the room // around it off the bed: four 90 mm parts on a 95 x 500 mm belt ended with one across the // edge and one outside, with 290 mm of belt free behind them. The pile stops at the edge. -TEST_CASE("Arrange keeps a pile aligned near an edge on the bed", "[Arrange]") +TEST_CASE("Arrange keeps a pile aligned near an edge on the bed", "[Arrange][belt]") { const BoundingBox belt = bed(95, 500); ArrangePolygons items = squares(4, 90.); ArrangeParams params = quiet_params(scaled(2.)); params.align_center = Vec2d(0.5, 0.05); + params.is_belt = true; + params.belt_axis = 1; + params.belt_tilt_slope = 1.f; arrange(items, belt, params); @@ -300,6 +303,29 @@ TEST_CASE("Arrange keeps a pile aligned near an edge on the bed", "[Arrange]") require_no_overlap(items); } +// The clamp is a belt feature. Printers whose best_object_pos is off-centre (the A1 mini +// and the H2 family) keep their final alignment: the pile is centred on that point, even +// when that puts part of it outside the bed. +TEST_CASE("Arrange leaves the final alignment of a flat bed unclamped", "[Arrange]") +{ + const BoundingBox bed_ = bed(95, 500); + ArrangePolygons items = squares(4, 90.); + ArrangeParams params = quiet_params(scaled(2.)); + params.align_center = Vec2d(0.5, 0.05); + + arrange(items, bed_, params); + + BoundingBox pile; + for (const ArrangePolygon &ap : items) { + REQUIRE(ap.bed_idx == 0); + pile.merge(ap.transformed_poly().contour.bounding_box()); + } + // Centred on the 5% mark of the bed's length, not pushed inside it. + CHECK_THAT(unscaled(pile.center().y()), Catch::Matchers::WithinAbs(0.05 * 500., 15.)); + CHECK(pile.min.y() < 0); + require_no_overlap(items); +} + // On a belt the parts print in belt order, so two colours that alternate along the // belt, or sit side by side, cost a filament change on every shared layer. Arrange // keeps each colour together: no part shares belt length with a part of another diff --git a/tests/libslic3r/test_belt_brim.cpp b/tests/libslic3r/test_belt_brim.cpp index 05c650a809..f6d794f939 100644 --- a/tests/libslic3r/test_belt_brim.cpp +++ b/tests/libslic3r/test_belt_brim.cpp @@ -324,11 +324,9 @@ SCENARIO("belt_brim_region builds an inner ring inside a hole", "[BeltBrim]") { } 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. + // The production clip, belt_brim_clip_leading_edge(), keeps what lies at or downhill + // of the first-contact cut. downhill_sign() pins the convention for both tilt signs: + // low_side = shear > 0, i.e. downhill is -u. const coord_t mm = scale_(1.); const double shear = GENERATE(1.0, -1.0); DYNAMIC_SECTION("shear " << shear) { @@ -336,27 +334,37 @@ SCENARIO("Leading-edge-only retains the downhill half of the brim region", "[Bel 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 coordf_t u_cut = 8.; // mm - 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 }); + const ExPolygons kept = belt_brim_clip_leading_edge(region, frame, u_cut); 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); + CHECK(kb.max.y() <= 8 * mm + 2); + CHECK(kb.min.y() < 8 * mm); } else { // downhill is +u: nothing below the cut survives. - CHECK(kb.min.y() >= u_cut - 2); - CHECK(kb.max.y() > u_cut); + CHECK(kb.min.y() >= 8 * mm - 2); + CHECK(kb.max.y() > 8 * mm); + } + // Half of the box is kept either way, and the full width across the belt. + CHECK_THAT(area(kept), Catch::Matchers::WithinRel(area(region) * (frame.shear > 0. ? 8. / 20. : 12. / 20.), 0.01)); + CHECK(kb.min.x() == 0); + CHECK(kb.max.x() == 20 * mm); + } + WHEN("the cut lies beyond the region") { + const BeltBrimFrame frame { 1.0, 1 }; + const ExPolygons region { make_box(0, 0, 20 * mm, 20 * mm) }; + THEN("a cut past the uphill end keeps everything") { + CHECK_THAT(area(belt_brim_clip_leading_edge(region, frame, 30.)), Catch::Matchers::WithinRel(area(region), 0.001)); + } + THEN("a cut before the downhill end keeps nothing") { + CHECK(belt_brim_clip_leading_edge(region, frame, -5.).empty()); + } + THEN("an empty region stays empty") { + CHECK(belt_brim_clip_leading_edge(ExPolygons{}, frame, 8.).empty()); } } }