diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 31f39921f4..1f85cf7358 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -4015,7 +4015,7 @@ int CLI::run(int argc, char **argv) } }; - auto check_plate_wipe_tower = [get_print_sequence, is_smooth_timelapse, new_extruder_count](Slic3r::GUI::PartPlate* plate, int plate_index, DynamicPrintConfig& print_config, plate_obj_size_info_t &plate_obj_size_info) { + auto check_plate_wipe_tower = [get_print_sequence, is_smooth_timelapse](Slic3r::GUI::PartPlate* plate, int plate_index, DynamicPrintConfig& print_config, plate_obj_size_info_t &plate_obj_size_info) { plate_obj_size_info.obj_bbox= plate->get_objects_bounding_box(); BOOST_LOG_TRIVIAL(info) << boost::format("plate %1%, object bbox: min {%2%, %3%, %4%} - max {%5%, %6%, %7%}") %(plate_index+1) %plate_obj_size_info.obj_bbox.min.x() % plate_obj_size_info.obj_bbox.min.y() % plate_obj_size_info.obj_bbox.min.z() %plate_obj_size_info.obj_bbox.max.x() % plate_obj_size_info.obj_bbox.max.y() % plate_obj_size_info.obj_bbox.max.z(); @@ -4059,22 +4059,13 @@ int CLI::run(int argc, char **argv) plate_obj_size_info.wipe_x = wipe_x_option->get_at(plate_index); plate_obj_size_info.wipe_y = wipe_y_option->get_at(plate_index); - ConfigOptionFloat* width_option = print_config.option("prime_tower_width", true); - plate_obj_size_info.wipe_width = width_option->value; + // Body and brim from one estimate: resolving an auto (-1) brim against a different + // height would size the two halves of the same tower from two different objects. + const WipeTowerFootprint footprint = plate->estimate_wipe_tower_footprint(print_config, filaments_cnt); + float brim_width = float(footprint.brim_width); - ConfigOptionFloat* brim_width_option = print_config.option("prime_tower_brim_width", true); - float brim_width = brim_width_option->value; - if (brim_width < 0) brim_width = WipeTower::get_auto_brim_by_height((float)plate_obj_size_info.obj_bbox.max.z()); - - ConfigOptionFloat* volume_option = print_config.option("prime_volume", true); - float wipe_volume = volume_option->value; - - const ConfigOptionBool * wrapping_detection = print_config.option("enable_wrapping_detection"); - bool enable_wrapping = (wrapping_detection != nullptr) && wrapping_detection->value; - - Vec3d wipe_tower_size = plate->estimate_wipe_tower_size(print_config, plate_obj_size_info.wipe_width, wipe_volume, new_extruder_count, filaments_cnt, false, enable_wrapping); - plate_obj_size_info.wipe_width = wipe_tower_size(0); - plate_obj_size_info.wipe_depth = wipe_tower_size(1); + plate_obj_size_info.wipe_width = footprint.width; + plate_obj_size_info.wipe_depth = footprint.depth; Vec3d origin = plate->get_origin(); Vec3d start(origin(0) + plate_obj_size_info.wipe_x - brim_width, origin(1) + plate_obj_size_info.wipe_y, 0.f); @@ -4841,7 +4832,10 @@ int CLI::run(int argc, char **argv) int plate_count = partplate_list.get_plate_count(); auto printer_structure_opt = m_print_config.option>("printer_structure"); - const float tower_brim_width = m_print_config.option("prime_tower_width", true)->value; + // This margin only pre-adjusts the default away from the near edges; + // estimate_wipe_tower_polygon below computes the real clamped position. + float tower_brim_width = m_print_config.option("prime_tower_brim_width", true)->value; + if (tower_brim_width < 0.f) tower_brim_width = 8.f; // auto: object heights unknown here, 8 mm is the auto cap const float tower_margin = WIPE_TOWER_MARGIN + tower_brim_width; // set the default position, the same with print config(left top) @@ -4875,7 +4869,7 @@ int CLI::run(int argc, char **argv) wipe_y_option->set_at(&wt_y_opt, i, 0); Vec3d wipe_tower_size, wipe_tower_pos; - ArrangePolygon wipe_tower_ap = cur_plate->estimate_wipe_tower_polygon(m_print_config, i, wipe_tower_pos, wipe_tower_size, new_extruder_count, assemble_plate.filaments_count, true); + ArrangePolygon wipe_tower_ap = cur_plate->estimate_wipe_tower_polygon(m_print_config, i, wipe_tower_pos, wipe_tower_size, assemble_plate.filaments_count, true); //update the new wp position wt_x_opt.value = wipe_tower_pos(0); @@ -5138,7 +5132,10 @@ int CLI::run(int argc, char **argv) int extruder_size = used_filament_set.size(); auto printer_structure_opt = m_print_config.option>("printer_structure"); - const float tower_brim_width = m_print_config.option("prime_tower_width", true)->value; + // This margin only pre-adjusts the default away from the near edges; + // estimate_wipe_tower_polygon below computes the real clamped position. + float tower_brim_width = m_print_config.option("prime_tower_brim_width", true)->value; + if (tower_brim_width < 0.f) tower_brim_width = 8.f; // auto: object heights unknown here, 8 mm is the auto cap const float tower_margin = WIPE_TOWER_MARGIN + tower_brim_width; // set the default position, the same with print config(left top) float x = WIPE_TOWER_DEFAULT_X_POS; @@ -5175,7 +5172,7 @@ int CLI::run(int argc, char **argv) } Vec3d wipe_tower_size, wipe_tower_pos; - ArrangePolygon wipe_tower_ap = partplate_list.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(m_print_config, plate_index_valid, wipe_tower_pos, wipe_tower_size, new_extruder_count, extruder_size, true); + ArrangePolygon wipe_tower_ap = partplate_list.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(m_print_config, plate_index_valid, wipe_tower_pos, wipe_tower_size, extruder_size, true); //update the new wp position if (bedid < plate_count) { @@ -5276,22 +5273,16 @@ int CLI::run(int argc, char **argv) //float depth = v * (filaments_cnt - 1) / (layer_height * w); - const ConfigOptionBool *wrapping_detection = m_print_config.option("enable_wrapping_detection"); - bool enable_wrapping = (wrapping_detection != nullptr) && wrapping_detection->value; - - Vec3d wipe_tower_size = cur_plate->estimate_wipe_tower_size(m_print_config, w, v, new_extruder_count, filaments_cnt, false, enable_wrapping); + const WipeTowerFootprint footprint = cur_plate->estimate_wipe_tower_footprint(m_print_config, filaments_cnt); + Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); Vec3d plate_origin = cur_plate->get_origin(); int plate_width, plate_depth; double plate_height; partplate_list.get_plate_size(plate_width, plate_depth, plate_height); float depth = wipe_tower_size(1); - float margin = 15.f, wp_brim_width = 0.f; - ConfigOption *wipe_tower_brim_width_opt = m_print_config.option("prime_tower_brim_width"); - if (wipe_tower_brim_width_opt ) { - wp_brim_width = wipe_tower_brim_width_opt->getFloat(); - if (wp_brim_width < 0) wp_brim_width = WipeTower::get_auto_brim_by_height((float) wipe_tower_size.z()); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%")%wp_brim_width; - } + // Brim already resolved against the height the body was sized from. + float margin = 15.f, wp_brim_width = float(footprint.brim_width); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%")%wp_brim_width; w = wipe_tower_size(0); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: x=%1%, y=%2%, width=%3%, depth=%4%, angle=%5%, prime_volume=%6%, filaments_cnt=%7%, layer_height=%8%, plate_width=%9%, plate_depth=%10%") @@ -5807,6 +5798,34 @@ int CLI::run(int argc, char **argv) //Print fff_print; std::vector plate_triangle_counts(partplate_list.get_plate_count(), 0); + // The stored (or default) tower position may not fit the tower these plates + // need, and no CLI placement site runs on a plain slice - mirror the GUI's + // reload clamp and fit every plate's tower into the printable area first. + if (m_print_config.option("enable_prime_tower", true)->value) { + for (int index = 0; index < partplate_list.get_plate_count(); index++) { + if ((plate_to_slice != 0) && (plate_to_slice != (index + 1))) + continue; + Slic3r::GUI::PartPlate *plate = partplate_list.get_plate(index); + // Printing by object disables the tower only with more than one instance. + bool is_seq_print = false; + get_print_sequence(plate, m_print_config, is_seq_print); + if (is_seq_print && plate->printable_instance_size() > 1) + continue; + // An empty estimate is a plate that prints no tower (one filament and + // neither smooth timelapse, wrapping detection nor a raft). + Vec3d wt_pos, wt_size; + plate->estimate_wipe_tower_polygon(m_print_config, index, wt_pos, wt_size); + if (wt_size(0) < EPSILON || wt_size(1) < EPSILON) + continue; + ConfigOptionFloat wt_x_opt((float) wt_pos(0)); + ConfigOptionFloat wt_y_opt((float) wt_pos(1)); + m_print_config.option("wipe_tower_x", true)->set_at(&wt_x_opt, index, 0); + m_print_config.option("wipe_tower_y", true)->set_at(&wt_y_opt, index, 0); + BOOST_LOG_TRIVIAL(info) << boost::format("plate %1%: wipe tower clamped to {%2%, %3%}, size {%4%, %5%}") + % (index + 1) % wt_pos(0) % wt_pos(1) % wt_size(0) % wt_size(1); + } + } + while(!finished) { //BBS: slice every partplate one by one diff --git a/src/dev-utils/OrcaSlicer_profile_validator.cpp b/src/dev-utils/OrcaSlicer_profile_validator.cpp index 67d0ba444d..56acbaa732 100644 --- a/src/dev-utils/OrcaSlicer_profile_validator.cpp +++ b/src/dev-utils/OrcaSlicer_profile_validator.cpp @@ -8,7 +8,11 @@ #define NANOSVGRAST_IMPLEMENTATION #include "nanosvg/nanosvgrast.h" +#include "libslic3r/BoundingBox.hpp" #include "libslic3r/GCode.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" +#include "libslic3r/Geometry.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/PresetBundle.hpp" @@ -116,15 +120,45 @@ Vec2d printable_area_center(const DynamicPrintConfig &cfg) return 0.5 * (lo + hi); } +// Put the prime tower where the GUI and CLI would before slicing. The config default (x 15, y 220) +// lies off any bed shallower than the tower, and generation rejects an off-plate tower instead of +// exporting it. Beside the centred cube, clear of the edge exclusion strips some beds carry, then +// pulled inside the printable outline by the tower's own estimated footprint, with a few mm of +// clearance so the conflict checker never sees the two touch. +void place_wipe_tower(DynamicPrintConfig &cfg, const Vec2d ¢er) +{ + const auto *area = cfg.option("printable_area"); + if (area == nullptr || area->values.size() < 3) + return; + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(cfg, resolve_wipe_tower_type(cfg), {0, 1}, cfg.opt_float("layer_height"), 10.); + if (footprint.depth < EPSILON) + return; + const double margin = WIPE_TOWER_MARGIN + footprint.brim_width; + // The position is the tower's own origin; a rotated tower extends from it in another + // direction, so place the rotated box's extents rather than the origin. + Slic3r::Polygon box({Point::new_scale(0., 0.), Point::new_scale(footprint.width, 0.), Point::new_scale(footprint.width, footprint.depth), Point::new_scale(0., footprint.depth)}); + box.rotate(Geometry::deg2rad(cfg.opt_float("wipe_tower_rotation_angle"))); + const BoundingBox local = get_extents(box); + const Vec2d lo = unscale(local.min); + const Vec2d size = unscale(local.max) - lo; + Vec2d pos(center.x() + 5. + margin + 5. - lo.x(), center.y() - size.y() / 2. - lo.y()); + box.translate(Point::new_scale(pos.x(), pos.y())); + const Vec2f move = WipeTower::move_box_inside_polygon(get_extents(box), Polygons{Polygon::new_scale(area->values)}, scaled(margin)); + pos += move.cast(); + cfg.option("wipe_tower_x", true)->values = {pos.x()}; + cfg.option("wipe_tower_y", true)->values = {pos.y()}; +} + // Slice one centered cube that switches from filament 1 to filament 2 partway up, so exactly one // filament change fires, then export. The change drives the printer's own change_filament_gcode: on a // single-nozzle machine it rides the AMS prime tower (append_tcr), on a multi-nozzle machine it routes // through the nozzle swap (set_extruder / append_tcr2) - the engine picks the path from the printer's // topology, so one model covers both. An undefined placeholder in any shipped custom g-code throws // Slic3r::PlaceholderParserError from export. -std::string slice_two_color_cube_and_export(const DynamicPrintConfig &cfg, bool is_bbl) +std::string slice_two_color_cube_and_export(DynamicPrintConfig cfg, bool is_bbl) { const Vec2d center = printable_area_center(cfg); + place_wipe_tower(cfg, center); TriangleMesh m = make_cube(10, 10, 10); m.translate(float(center.x() - 5.), float(center.y() - 5.), 0.f); diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index ae8385b2bc..e860f50280 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -272,6 +272,8 @@ set(lisbslic3r_sources GCode/WipeTower2.hpp GCode/WipeTower.cpp GCode/WipeTower.hpp + GCode/WipeTowerEstimate.cpp + GCode/WipeTowerEstimate.hpp GCodeWriter.cpp GCodeWriter.hpp Geometry/ArcWelder.hpp diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 20e63e9d5e..96d98dbe9f 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -102,45 +102,6 @@ struct ZipUnicodePathExtraField } }; -// Validate that a relative file path does not escape the root directory via path traversal. -static bool is_path_within_root(const std::string& file_path, const boost::filesystem::path& root) -{ - if (file_path.empty()) - return false; - - boost::filesystem::path p(file_path); - if (p.is_absolute()) - return false; - - // Reject any path component that is ".." - for (const auto& component : p) { - if (component == "..") - return false; - } - - // Resolve the full path and verify it starts with the canonical root (also catches symlink escapes) - try { - boost::filesystem::path full_path = root / p; - boost::filesystem::path canonical_root = boost::filesystem::weakly_canonical(root); - boost::filesystem::path canonical_full = boost::filesystem::weakly_canonical(full_path); - - auto root_str = canonical_root.string(); - auto full_str = canonical_full.string(); - if (full_str.length() < root_str.length()) - return false; - if (full_str.compare(0, root_str.length(), root_str) != 0) - return false; - // Ensure it's a proper prefix (not just a substring of a longer directory name) - if (full_str.length() > root_str.length() && - full_str[root_str.length()] != boost::filesystem::path::preferred_separator) - return false; - } catch (const boost::filesystem::filesystem_error&) { - return false; - } - - return true; -} - // VERSION NUMBERS // 0 : .3mf, files saved by older slic3r or other applications. No version definition in them. // 1 : Introduction of 3mf versioning. No other change in data saved into 3mf files. diff --git a/src/libslic3r/GCode/WipeTower.cpp b/src/libslic3r/GCode/WipeTower.cpp index 8aff5f4a3f..bef3803c55 100644 --- a/src/libslic3r/GCode/WipeTower.cpp +++ b/src/libslic3r/GCode/WipeTower.cpp @@ -1630,6 +1630,94 @@ float WipeTower::get_auto_brim_by_height(float max_height) { return 8.f; } +float WipeTower::estimate_brim_real_width(float brim_width, float nozzle_diameter, float first_layer_height, bool type2) +{ + if (brim_width <= 0.f) + return brim_width; + const float spacing = nozzle_diameter * 1.25f - first_layer_height * float(1. - M_PI_4); // Width_To_Nozzle_Ratio + if (spacing <= EPSILON) + return brim_width; + const int loops_num = int((brim_width + spacing / 2.f) / spacing); + return loops_num * spacing + (type2 ? 0.f : spacing / 2.f); +} + +float WipeTower::get_wrapping_detection_depth() +{ + return float(wrapping_wipe_tower_depth); +} + +float WipeTower::nozzle_change_perimeter_width(float nozzle_diameter) +{ + auto it = nozzle_diameter_to_nozzle_change_width.find(nozzle_diameter); + return it != nozzle_diameter_to_nozzle_change_width.end() ? it->second : 2.f * nozzle_diameter * 1.25f; +} + +float WipeTower::estimate_tower_blocks_depth(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing) +{ + if (purges.empty() || layer_height < EPSILON || nozzle_diameter < EPSILON) + return 0.f; + const float pw = nozzle_diameter * 1.25f; // Width_To_Nozzle_Ratio + const float ncpw = nozzle_change_perimeter_width(nozzle_diameter); + const float line_width = width - 2.f * pw; + if (line_width <= EPSILON) + return 0.f; + // Line cross-section as volume_to_length() sees it; the infill gap stretches the perimeter + // width by the configured ratio and nozzle-change lines keep their own width + // (calc_block_infill_gap). + auto line_area = [layer_height](float w) { return layer_height * (w - layer_height * float(1. - M_PI_4)); }; + const float extra_width = (extra_spacing - 1.f) * pw; + const float gap = pw + extra_width; + const float nc_gap = ncpw + extra_width; + // A layer purges into at most (filaments - 1) targets, so a category holding every filament + // never sees its smallest purge (the layer's first filament) in its worst layer. + struct Block { float depth = 0.f; float min_purge = 0.f; size_t filaments = 0; }; + std::map blocks; + for (const PurgeEstimate &purge : purges) { + Block &block = blocks[purge.category]; + const float purge_depth = std::ceil(purge.prime_volume / line_area(pw) / line_width) * gap; + block.min_purge = block.filaments == 0 ? purge_depth : std::min(block.min_purge, purge_depth); + block.depth += purge_depth; + ++block.filaments; + if (purge.filament_change_length > EPSILON) { + // The leaving filament is rammed over the nozzle-change flow, again in whole lines. + const float filament_area = float(M_PI) * purge.filament_diameter * purge.filament_diameter / 4.f; + const float nc_length = purge.filament_change_length * filament_area / line_area(ncpw); + block.depth += std::ceil(nc_length / (width - ncpw - pw)) * nc_gap; + } + } + float depth = pw; // plan_tower_new starts the first block one perimeter width in + for (const auto &[category, block] : blocks) + depth += block.filaments == purges.size() ? block.depth - block.min_purge : block.depth; + return depth; +} + +float WipeTower::rib_footprint_side(float width, float depth, float rib_width, float extra_rib_length, float max_height) +{ + if (width < EPSILON || depth < EPSILON) + return 0.f; + // Ribs run the diagonal; below the height-based minimum they are extended rather than the + // body, then by the extra length, never ending up shorter than the diagonal. + const float diagonal = std::sqrt(width * width + depth * depth); + float rib_length = diagonal; + if (depth + EPSILON < get_limit_depth_by_height(max_height)) + rib_length = std::max(rib_length, get_limit_depth_by_height(max_height) * float(std::sqrt(2.))); + rib_length = std::max(diagonal, rib_length + extra_rib_length); + // Half the extension at each end of the diagonal plus half the rib width, projected onto the axes. + const float rib_w = std::min(rib_width, std::min(width, depth) / 2.f); + const float per_side = ((rib_length - diagonal) / 2.f + rib_w / 2.f) / float(std::sqrt(2.)); + return std::max(width, depth) + 2.f * per_side; +} + +float WipeTower::estimate_rib_tower_bbox_side(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing, float rib_width, float extra_rib_length, float max_height) +{ + if (purges.empty() || width < EPSILON || layer_height < EPSILON || nozzle_diameter < EPSILON) + return 0.f; + const float pw = nozzle_diameter * 1.25f; // Width_To_Nozzle_Ratio + const float square = align_ceil(std::sqrt(estimate_tower_blocks_depth(purges, width, layer_height, nozzle_diameter, extra_spacing) * width), pw); + const float depth = estimate_tower_blocks_depth(purges, square, layer_height, nozzle_diameter, extra_spacing); + return rib_footprint_side(square, depth, rib_width, extra_rib_length, max_height); +} + Vec2f WipeTower::move_box_inside_polygon(const BoundingBox &box, const Polygons &polygons, coord_t offset) { if (polygons.empty()) return Vec2f{0.f, 0.f}; diff --git a/src/libslic3r/GCode/WipeTower.hpp b/src/libslic3r/GCode/WipeTower.hpp index 045c82cbf3..9303f09691 100644 --- a/src/libslic3r/GCode/WipeTower.hpp +++ b/src/libslic3r/GCode/WipeTower.hpp @@ -42,9 +42,36 @@ public: static const std::map min_depth_per_height; static float get_limit_depth_by_height(float max_height); static float get_auto_brim_by_height(float max_height); + // Both generators lay the brim in whole loops one line spacing apart, so the printed width + // differs from the configured one. WipeTower reports it with half a spacing of line width + // added, WipeTower2 reports the loops alone; an estimate has to round like the generator + // whose G-code it stands in for. + static float estimate_brim_real_width(float brim_width, float nozzle_diameter, float first_layer_height, bool type2); + // Depth a Type1 tower reserves once nothing but wrapping detection asks for one. + static float get_wrapping_detection_depth(); + // Line width of the nozzle-change purge lines at this nozzle diameter. + static float nozzle_change_perimeter_width(float nozzle_diameter); static TriangleMesh its_make_rib_tower(float width, float depth, float height, float rib_length, float rib_width, bool fillet_wall); static TriangleMesh its_make_rib_brim(const Polygon& brim, float layer_height); static Polygon rib_section(float width, float depth, float rib_length, float rib_width, bool fillet_wall); + // One filament's share of a Type1 tower layer, as plan_tower_new() reserves it. + struct PurgeEstimate + { + float prime_volume = 0.f; // mm3 wiped after changing to this filament + int category = 0; // filament_adhesiveness_category; one purge block per category + float filament_change_length = 0.f; // mm of filament rammed when it leaves its nozzle; 0 when no nozzle change is planned + float filament_diameter = 1.75f; + }; + // Depth of the Type1 purge stack at the given width (also the rectangle-wall depth): each + // purge is whole lines at the block infill gap, one block per adhesiveness category sized by + // its worst layer, stacked behind one perimeter width. + static float estimate_tower_blocks_depth(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing); + // Side of the square bounding a rib-wall tower's first layer, brim excluded: the body plus the + // rib bulge, with the ribs extended to the height-based minimum as both generators do. + static float rib_footprint_side(float width, float depth, float rib_width, float extra_rib_length, float max_height); + // Type1 rib tower: plan_tower_new() squares the tower from the depth at the configured width, + // then re-plans the depth at the squared width. + static float estimate_rib_tower_bbox_side(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing, float rib_width, float extra_rib_length, float max_height); // Translation that brings a footprint inside the printable outline, padded by offset. The prime // tower is validated against the real outline (see layered_print_cleareance_valid), so clamping // against the bounding box alone would leave it off a delta or hexagonal bed. box and polygons diff --git a/src/libslic3r/GCode/WipeTower2.cpp b/src/libslic3r/GCode/WipeTower2.cpp index ee0f9c375a..4e752bd772 100644 --- a/src/libslic3r/GCode/WipeTower2.cpp +++ b/src/libslic3r/GCode/WipeTower2.cpp @@ -2129,6 +2129,23 @@ std::pair WipeTower2::get_wipe_tower_cone_base(double width, dou return std::make_pair(R, support_scale); } +Polygon WipeTower2::cone_base_polygon(double width, double depth, double height, double angle_deg) +{ + Polygon box({Point::new_scale(Vec2d(0., 0.)), Point::new_scale(Vec2d(width, 0.)), + Point::new_scale(Vec2d(width, depth)), Point::new_scale(Vec2d(0., depth))}); + if (angle_deg <= EPSILON || height <= EPSILON || width <= EPSILON || depth <= EPSILON) + return box; + const auto [R, x_scale] = get_wipe_tower_cone_base(width, height, depth, angle_deg); + if (R <= EPSILON) + return box; + const Vec2d center(width / 2., depth / 2.); + Polygon ellipse; + for (double alpha = 0.; alpha < 2. * M_PI; alpha += M_PI / 20.) + ellipse.points.push_back(Point::new_scale(center + R * Vec2d(std::cos(alpha) / x_scale, std::sin(alpha)))); + Polygons u = union_({box, ellipse}); + return u.empty() ? box : u.front(); +} + // Static method to extract wipe_volumes[from][to] from the configuration. // Takes a ConfigBase so the GUI's wipe tower size estimate can pass the plate's // DynamicPrintConfig directly instead of materializing a full PrintConfig per call. diff --git a/src/libslic3r/GCode/WipeTower2.hpp b/src/libslic3r/GCode/WipeTower2.hpp index 5b1a474b5d..232cad1a6b 100644 --- a/src/libslic3r/GCode/WipeTower2.hpp +++ b/src/libslic3r/GCode/WipeTower2.hpp @@ -27,6 +27,10 @@ public: // in WipeTowerIntegration::append_tcr2 does not strip it. static const std::string wait_for_temp_tag() { return ";_WAIT_FOR_TEMP_ON_WIPE_TOWER"; } static std::pair get_wipe_tower_cone_base(double width, double height, double depth, double angle_deg); + // First-layer outline of a cone-wall tower in tower-local (scaled) coordinates: body box + // unioned with the cone's base ellipse — the model first_layer_wipe_tower_corners uses, + // and generate_support_cone_wall stays within it. Brim not included. + static Polygon cone_base_polygon(double width, double depth, double height, double angle_deg); static std::vector> extract_wipe_volumes(const ConfigBase& config); // Estimated total flush volume of a SEMM print with the given number of filaments, // used to reserve wipe tower space before the tower is generated. diff --git a/src/libslic3r/GCode/WipeTowerEstimate.cpp b/src/libslic3r/GCode/WipeTowerEstimate.cpp new file mode 100644 index 0000000000..d8cfe74527 --- /dev/null +++ b/src/libslic3r/GCode/WipeTowerEstimate.cpp @@ -0,0 +1,202 @@ +#include "WipeTowerEstimate.hpp" + +#include "WipeTower.hpp" +#include "WipeTower2.hpp" +#include "../Config.hpp" +#include "../PrintConfig.hpp" +#include "../libslic3r.h" + +#include +#include +#include + +namespace Slic3r { + +// Every caller today declares all these keys, but the signature accepts any ConfigBase: fall +// back to the key's declared default, never to a hand-copied constant. +static const ConfigOption *option_of(const ConfigBase &config, const char *key) +{ + if (const ConfigOption *opt = config.option(key); opt != nullptr) + return opt; + if (const ConfigDef *def = config.def(); def != nullptr) + if (const ConfigOptionDef *opt_def = def->get(key); opt_def != nullptr) + return opt_def->default_value.get(); + return nullptr; +} + +WipeTowerType resolve_wipe_tower_type(const ConfigBase &config) +{ + // printer_model is what the CLI keys its Bambu Lab detection on; the GUI's vendor flag + // agrees for every shipped profile. + if (const auto *model = dynamic_cast(config.option("printer_model")); + model != nullptr && model->value.compare(0, 9, "Bambu Lab") == 0) + return WipeTowerType::Type1; + // By value, not by concrete type: a static PrintConfig holds ConfigOptionEnum, a + // DynamicConfig built from presets holds ConfigOptionEnumGeneric, and both answer getInt(). + const ConfigOption *type = option_of(config, "wipe_tower_type"); + return type != nullptr ? WipeTowerType(type->getInt()) : WipeTowerType::Type2; +} + +Polygon estimate_wipe_tower_first_layer_outline(const ConfigBase &config, WipeTowerType tower_type, double width, double depth, double height) +{ + // Type1 ignores the cone option. The wall type is read by value: a preset-shaped config + // holds it as ConfigOptionEnumGeneric, which a cast to ConfigOptionEnum cannot see. + const ConfigOption *wall_type = option_of(config, "wipe_tower_wall_type"); + const ConfigOption *cone_angle = option_of(config, "wipe_tower_cone_angle"); + const bool cone = tower_type == WipeTowerType::Type2 && wall_type != nullptr && + wall_type->getInt() == int(WipeTowerWallType::wtwCone) && cone_angle != nullptr; + return WipeTower2::cone_base_polygon(width, depth, height, cone ? cone_angle->getFloat() : 0.); +} + +WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, WipeTowerType tower_type, const std::vector &filament_ids, double layer_height, double max_object_height) +{ + WipeTowerFootprint footprint; + footprint.height = max_object_height; + const size_t filaments_cnt = filament_ids.size(); + if (filaments_cnt == 0 || layer_height < EPSILON) + return footprint; + + auto opt_float = [&config](const char *key) { + const ConfigOption *opt = option_of(config, key); + return opt != nullptr ? opt->getFloat() : 0.; + }; + auto opt_bool = [&config](const char *key) { + const ConfigOption *opt = option_of(config, key); + return opt != nullptr && opt->getBool(); + }; + auto opt_enum = [&config](const char *key, int fallback) { + const ConfigOption *opt = option_of(config, key); + return opt != nullptr ? opt->getInt() : fallback; + }; + auto floats_of = [&config](const char *key) { return dynamic_cast(option_of(config, key)); }; + auto max_of = [&floats_of](const char *key, double fallback) { + const auto *opt = floats_of(key); + return (opt != nullptr && !opt->values.empty()) ? *std::max_element(opt->values.begin(), opt->values.end()) : fallback; + }; + auto float_at = [&floats_of](const char *key, unsigned int id, double fallback) { + const auto *opt = floats_of(key); + return (opt != nullptr && !opt->values.empty()) ? opt->get_at(id) : fallback; + }; + auto int_at = [&config](const char *key, unsigned int id, int fallback) { + const auto *opt = dynamic_cast(option_of(config, key)); + return (opt != nullptr && !opt->values.empty()) ? opt->get_at(id) : fallback; + }; + + // Both planners size every layer, so the tower has to fit its thinnest one: the first layer + // when it is printed thinner than the rest. + const double first_layer_height = opt_float("initial_layer_print_height"); + if (first_layer_height > EPSILON) + layer_height = std::min(layer_height, first_layer_height); + + const bool type1 = tower_type == WipeTowerType::Type1; + const double width = opt_float("prime_tower_width"); + const double prime_volume = opt_float("prime_volume"); + // Type1 spaces its purge lines by prime_tower_infill_gap, Type2 by wipe_tower_extra_spacing. + // Type2's extra flow cancels out of the depth: the line length is divided by it and the row + // pitch multiplied by it (WipeTower2::get_wipe_depth). + const double extra_spacing = opt_float(type1 ? "prime_tower_infill_gap" : "wipe_tower_extra_spacing") / 100.; + const double rib_width = opt_float("wipe_tower_rib_width"); + const double extra_rib_length = opt_float("wipe_tower_extra_rib_length"); + const auto *nozzle_opt = floats_of("nozzle_diameter"); + const double nozzle_diameter = (nozzle_opt != nullptr && !nozzle_opt->values.empty()) ? nozzle_opt->values.front() : 0.4; + const bool dual_nozzle = nozzle_opt != nullptr && nozzle_opt->values.size() == 2; + const bool rib_wall = opt_enum("wipe_tower_wall_type", int(WipeTowerWallType::wtwRectangle)) == int(WipeTowerWallType::wtwRib); + const bool smooth_timelapse = opt_enum("timelapse_type", int(TimelapseType::tlTraditional)) == int(TimelapseType::tlSmooth); + const bool wrapping = opt_bool("enable_wrapping_detection"); + // Reasons a tower is printed with no tool change to purge for: the ones that stop + // normalize_fdm_2 clearing enable_prime_tower. Its mixed-filament case is not modelled. + const bool need_wipe_tower = smooth_timelapse || wrapping; + + // A tower printed for one of the reasons above has no tool change to purge for; both + // planners give it the idle depth below and nothing more. + const size_t purge_count = filaments_cnt > 1 ? (dual_nozzle ? filaments_cnt : filaments_cnt - 1) : 0; + + // Type2 purges one volume per tool change. Type1 plans per filament below; here the volume + // only decides whether a tower exists. + double volume = prime_volume * double(purge_count); + if (dual_nozzle) { + // Dual-nozzle printers also purge the filament change length on the tower. + const double length = max_of("filament_change_length", 0.); + const double diameter = max_of("filament_diameter", 1.75); + volume += length * PI * diameter * diameter / 4. * double(filaments_cnt / 2); + } + // Single-extruder multi-material purges the flush matrix instead of the prime volume. + const bool semm_flush = opt_bool("purge_in_prime_tower") && opt_bool("single_extruder_multi_material"); + if (semm_flush) + volume = WipeTower2::estimate_semm_flush_volume(config, filaments_cnt); + + // The Type1 planner wipes each filament's own prime volume after changing to it, in a block + // per adhesiveness category. On a two-nozzle printer the leaving filament is also rammed at + // every nozzle change; the tool order groups filaments by nozzle, so a layer crosses + // (nozzles used - 1) times, charged here to the longest ramming. + std::vector purges; + if (type1 && filaments_cnt > 1) { + const bool saving_mode = opt_enum("prime_volume_mode", int(PrimeVolumeMode::pvmDefault)) == int(PrimeVolumeMode::pvmSaving); + std::set nozzles; + size_t longest_ramming = 0; + for (size_t i = 0; i < filaments_cnt; ++i) { + const unsigned int id = filament_ids[i]; + WipeTower::PurgeEstimate purge; + purge.prime_volume = saving_mode ? 15.f : float(float_at("filament_prime_volume", id, prime_volume)); + purge.category = int_at("filament_adhesiveness_category", id, 0); + purge.filament_diameter = float(float_at("filament_diameter", id, 1.75)); + purges.push_back(purge); + if (dual_nozzle) { + nozzles.insert(int_at("filament_map", id, 1)); + if (float_at("filament_change_length", id, 0.) > float_at("filament_change_length", filament_ids[longest_ramming], 0.)) + longest_ramming = i; + } + } + if (nozzles.size() > 1) + purges[longest_ramming].filament_change_length = float(float_at("filament_change_length", filament_ids[longest_ramming], 0.) * double(nozzles.size() - 1)); + } + + // Both wall types decide this together: over-reserving only wastes bed area, but reporting + // no tower for one that is built collapses the validation hull to a point. + // A tool change is a reason on its own (see the base commit); Type1 already reserves + // per filament, Type2 has only the volume, which can resolve to zero. + const bool has_purge = type1 ? !purges.empty() : volume > EPSILON; + if (!has_purge && filaments_cnt < 2 && !need_wipe_tower) + return footprint; + + const double min_depth = WipeTower::get_limit_depth_by_height(float(max_object_height)); + const float perimeter_width = float(nozzle_diameter) * 1.25f; // Width_To_Nozzle_Ratio + // With nothing to purge, plan_tower_new sizes the tower for wrapping detection or the + // stability minimum; WipeTower2 only knows the latter. + const double idle_depth = (type1 && wrapping && !smooth_timelapse) ? WipeTower::get_wrapping_detection_depth() : min_depth; + if (rib_wall) { + // Both planners square the tower to the purge area and extend the ribs, not the body, + // below the stability minimum. + double side; + if (!purges.empty()) + side = WipeTower::estimate_rib_tower_bbox_side(purges, float(width), float(layer_height), float(nozzle_diameter), float(extra_spacing), float(rib_width), float(extra_rib_length), float(max_object_height)); + else { + const double square = has_purge ? std::sqrt(volume / layer_height * extra_spacing) : idle_depth; + side = WipeTower::rib_footprint_side(float(square), float(square), float(rib_width), float(extra_rib_length), float(max_object_height)); + } + footprint.width = footprint.depth = side; + } else { + double depth; + if (type1) { + // plan_tower_new stretches a short purge stack to the stability minimum behind its + // leading perimeter width. + depth = purges.empty() ? idle_depth : std::max(min_depth + perimeter_width, double(WipeTower::estimate_tower_blocks_depth(purges, float(width), float(layer_height), float(nozzle_diameter), float(extra_spacing)))); + } else { + depth = volume / (layer_height * width); + // The flush volumes already hold the spacing between wipes. + if (!semm_flush) + depth *= extra_spacing; + depth = std::max(min_depth, depth); + } + footprint.width = width; + footprint.depth = depth; + } + + footprint.brim_width = opt_float("prime_tower_brim_width"); + if (footprint.brim_width < 0) + footprint.brim_width = WipeTower::get_auto_brim_by_height(float(max_object_height)); + footprint.brim_width = WipeTower::estimate_brim_real_width(float(footprint.brim_width), float(nozzle_diameter), float(first_layer_height > EPSILON ? first_layer_height : layer_height), !type1); + return footprint; +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/WipeTowerEstimate.hpp b/src/libslic3r/GCode/WipeTowerEstimate.hpp new file mode 100644 index 0000000000..387028649b --- /dev/null +++ b/src/libslic3r/GCode/WipeTowerEstimate.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include "../Polygon.hpp" + +namespace Slic3r { + +class ConfigBase; +enum class WipeTowerType; + +// Pre-slice footprint of the wipe tower, shared by validation (Print), the GUI's placement +// clamp/preview/arrange and the CLI placement. The arithmetic is shared; the inputs below are +// not, so a change to how one caller derives them has to be mirrored in the others. +struct WipeTowerFootprint +{ + double width = 0.; // effective width: equals depth for a rib wall, which squares the tower + double depth = 0.; // 0 when these inputs imply no tower + double height = 0.; // tallest object; drives the stability floor and the auto brim + double brim_width = 0.; // printed width: auto (-1) resolved by height, laid in whole loops +}; + +// Which planner builds the tower: Bambu Lab printers always get Type1, the rest follow +// wipe_tower_type. The rule Print::wipe_tower_type() and the CLI apply, read off the config so +// the GUI and CLI placement can resolve it without a Print. +WipeTowerType resolve_wipe_tower_type(const ConfigBase &config); + +// First-layer outline of an estimated tower in tower-local scaled coordinates, brim excluded: +// the body box, or for a Type2 cone wall the box unioned with the cone's base. The preview, +// the placement margin and validation all take the outline from here so they cannot disagree +// about whether a cone exists. +Polygon estimate_wipe_tower_first_layer_outline(const ConfigBase &config, WipeTowerType tower_type, double width, double depth, double height); + +// filament_ids: 0-based filaments purged on the plate. The config cannot see custom G-code tool +// changes, so ids derived from the model must include them +// (Print::extruders(true)) or a real tower is sized as if it were never built. +// layer_height: thinnest layer the objects are sliced at. The first layer is folded in here. +// +// A raft is deliberately not a reason: normalize_fdm_2 clears enable_prime_tower for a plate +// purging one filament unless smooth timelapse or wrapping detection is on. +WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, + WipeTowerType tower_type, + const std::vector &filament_ids, + double layer_height, + double max_object_height); + +} // namespace Slic3r diff --git a/src/libslic3r/MinimumSpanningTree.cpp b/src/libslic3r/MinimumSpanningTree.cpp index ff8fe6e5dd..88555e70ee 100644 --- a/src/libslic3r/MinimumSpanningTree.cpp +++ b/src/libslic3r/MinimumSpanningTree.cpp @@ -60,10 +60,15 @@ auto MinimumSpanningTree::prim(std::vector vertices) const -> AdjacencyGr //This search is O(V) right now, which can be made down to O(log(V)). This reduces the overall time complexity from O(V*V) to O(V*log(E)). //However that requires an implementation of a heap that supports the decreaseKey operation, which is not in the std library. //TODO: Implement this? + // Break equal-distance ties on coordinates: the map is keyed by address, so its + // iteration order (and therefore the first minimum) would otherwise depend on where + // the vertices were allocated. using MapValue = std::pair; const auto closest = std::min_element(smallest_distance.begin(), smallest_distance.end(), [](const MapValue& a, const MapValue& b) { - return a.second < b.second; + if (a.second != b.second) + return a.second < b.second; + return *a.first < *b.first; }); //Add this point to the graph and remove it from the candidates. diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 4f093ad123..c90ebc756b 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -1635,6 +1635,12 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector metadata.id = to_string(uuid); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " bundle_id was empty, so generating a UUID: " << metadata.id; } + if (has_bundle_structure && !is_path_within_root(metadata.id, user_folder / user_id / PRESET_LOCAL_DIR)) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " bundle id escapes the bundle directory, not importing: " << metadata.id; + fclose(zipFile); + fs::remove_all(temp_folder, ec); + continue; + } // Build bundle directory path based on whether bundle_structure.json was present fs::path bundle_base_dir; @@ -1657,11 +1663,15 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector if (status) { std::string file_name = file_stat.m_filename; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " From zip file: " << file << ". Read file name: " << file_stat.m_filename; - size_t index = file_name.find_last_of('/'); + size_t index = file_name.find_last_of("/\\"); if (std::string::npos != index) { file_name = file_name.substr(index + 1); } if (BUNDLE_STRUCTURE_JSON_NAME == file_name) continue; + if (!is_path_within_root(file_name, temp_folder)) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " zip entry escapes the temp directory, skipping: " << file_stat.m_filename; + continue; + } // create target file path std::string target_file_path = boost::filesystem::path(temp_folder / file_name).make_preferred().string(); @@ -1750,6 +1760,10 @@ bool PresetBundle::import_json_presets(PresetsConfigSubstitutions & s BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Preset type is unknown, not loading: " << name; return false; } + if (!is_path_within_root(name, fs::path(collection->m_dir_path))) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Preset name escapes the preset directory, not loading: " << name; + return false; + } const PresetOrigin load_origin = detect_origin_from_path(boost::filesystem::path(bundle_dir)); const std::string preset_name = get_preset_canonical_name(name, load_origin); diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index aee50db534..60f747ce04 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -20,6 +20,7 @@ #include "GCode.hpp" #include "GCode/WipeTower.hpp" #include "GCode/WipeTower2.hpp" +#include "GCode/WipeTowerEstimate.hpp" #include "Utils.hpp" #include "PrintConfig.hpp" #include "MaterialType.hpp" @@ -1031,20 +1032,21 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, //BBS: add the wipe tower check logic const PrintConfig & config = print.config(); - int filaments_count = print.extruders().size(); + // Custom G-code tool changes (MultiAsSingle) build a real tower on a plate whose objects + // all use one filament, so they have to be counted or the hull below collapses to a point. + int filaments_count = print.extruders(true).size(); int plate_index = print.get_plate_index(); const Vec3d plate_origin = print.get_plate_origin(); float x = config.wipe_tower_x.get_at(plate_index) + plate_origin(0); float y = config.wipe_tower_y.get_at(plate_index) + plate_origin(1); - float width = config.prime_tower_width.value; float a = config.wipe_tower_rotation_angle.value; //float v = config.wiping_volume.value; - float depth = print.wipe_tower_data(filaments_count).depth; - //float brim_width = print.wipe_tower_data(filaments_count).brim_width; - - if (config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib) - width = depth; + // The estimate resolves the effective width (a rib wall squares the tower). + const WipeTowerData &wipe_tower_estimate = print.wipe_tower_data(filaments_count); + float width = wipe_tower_estimate.width; + float depth = wipe_tower_estimate.depth; + float brim_width = wipe_tower_estimate.brim_width; Polygons convex_hulls_temp; if (print.has_wipe_tower()) { @@ -1066,36 +1068,54 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, convex_hulls_temp.push_back(wipe_tower_polygon); } } + // Post-generation the mesh bottom already carries the brim. Pre-generation the body grows + // by the brim only when its width is explicit; the auto brim and a Type2 cone base depend on + // the tower height, exact only once generated, so they only warn here - the exact footprint + // is re-checked in _make_wipe_tower. + const bool exact_footprint = print.is_step_done(psWipeTower); + Polygons tower_polys_checked = (!exact_footprint && config.prime_tower_brim_width.value >= 0) ? + offset(convex_hulls_temp, float(scale_(brim_width))) : + convex_hulls_temp; + Polygons tower_polys_estimated; + if (!exact_footprint && !convex_hulls_temp.empty()) { + double max_height = 0.; + for (const PrintObject *object : print.objects()) + max_height = std::max(max_height, unscale_(object->size().z())); + Polygon base = estimate_wipe_tower_first_layer_outline(config, print.wipe_tower_type(), width, depth, max_height); + base.rotate(Geometry::deg2rad(a)); + base.translate(Point(scale_(x), scale_(y))); + tower_polys_estimated = offset(base, float(scale_(brim_width))); + } + // Object proximity stays a body-only warning: brim near-misses would newly warn on + // many setups that print fine. if (!intersection(convex_hulls_other, convex_hulls_temp).empty()) { if (warning) { warning->string += L("Prime Tower") + L(" is too close to others, and collisions may be caused.\n"); } } - if (!intersection(exclude_polys, convex_hulls_temp).empty()) { - /*if (warning) { - warning->string += L("Prime Tower is too close to exclusion area, there may be collisions when printing.\n"); - }*/ + if (!intersection(exclude_polys, tower_polys_checked).empty()) { return {L("Prime Tower") + L(" is too close to an exclusion area, and collisions will be caused.\n")}; } - if (print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, convex_hulls_temp).empty()) { + if (print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, tower_polys_checked).empty()) { return {L("Prime Tower") + L(" is too close to clumping detection area, and collisions will be caused.\n")}; } - // Skip the containment check for towers that will never be printed (single-filament - // prints without smooth timelapse keep the config's tower position but emit nothing). - // Pre-generation only the body square is tested — the auto-brim estimate can overshoot - // the generated brim by several mm and must not hard-fail a print that physically fits. - // Post-generation the mesh bottom already includes the real brim, so the exact - // footprint is tested. - if (filaments_count > 1 || print.enable_timelapse_print()) { - // The shared printable polygon is plate-local, while the tower polygons above are - // already shifted by the plate origin. - Polygons printable_polys = print.get_extruder_shared_printable_polygon(); - const Point plate_shift(scale_(plate_origin.x()), scale_(plate_origin.y())); - for (Polygon &p : printable_polys) - p.translate(plate_shift); - if (!diff(convex_hulls_temp, printable_polys).empty()) - return {L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")}; + if (warning && !intersection(exclude_polys, tower_polys_estimated).empty()) { + warning->string += L("Prime Tower") + L(" is too close to exclusion area, there may be collisions when printing.") + "\n"; } + if (warning && print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, tower_polys_estimated).empty()) { + warning->string += L("Prime Tower") + L(" is too close to clumping detection area, there may be collisions when printing.") + "\n"; + } + // No gate on "is there a tower": one that is not printed estimates to zero, so the hulls + // are degenerate and every check passes. Re-deriving it here missed the wrapping-detection + // tower on a single-filament plate. + Polygons printable_polys = print.get_extruder_shared_printable_polygon(); + const Point plate_shift(scale_(plate_origin.x()), scale_(plate_origin.y())); + for (Polygon &p : printable_polys) + p.translate(plate_shift); + if (!diff(tower_polys_checked, printable_polys).empty()) + return {L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")}; + if (warning && !diff(tower_polys_estimated, printable_polys).empty()) + warning->string += L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n"); return {}; } @@ -3997,74 +4017,25 @@ bool Print::has_wipe_tower() const const WipeTowerData &Print::wipe_tower_data(size_t filaments_cnt) const { - // If the wipe tower wasn't created yet, make sure the depth and brim_width members are set to default. - double max_height = 0; - for (size_t obj_idx = 0; obj_idx < m_objects.size(); obj_idx++) { - double object_z = (double) m_objects[obj_idx]->size().z(); - max_height = std::max(unscale_(object_z), max_height); + // Until the tower is generated, size it with the estimate the GUI/CLI placement uses, so + // validation cannot reject a position the clamp just accepted. + if (is_step_done(psWipeTower) || filaments_cnt == 0) + return m_wipe_tower_data; + + double max_height = 0.; + double layer_height = std::numeric_limits::max(); + for (const PrintObject *object : m_objects) { + max_height = std::max(max_height, unscale_(double(object->size().z()))); + layer_height = std::min(layer_height, object->config().layer_height.value); } - if (max_height < EPSILON) return m_wipe_tower_data; + if (max_height < EPSILON) + return m_wipe_tower_data; - double layer_height = 0.08f; // hard code layer height - layer_height = m_objects.front()->config().layer_height.value; - - auto timelapse_type = config().option>("timelapse_type"); - bool need_wipe_tower = (timelapse_type ? (timelapse_type->value == TimelapseType::tlSmooth) : false) | (m_config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib); - double extra_spacing = config().option("prime_tower_infill_gap")->getFloat() / 100.; - double rib_width = config().option("wipe_tower_rib_width")->getFloat(); - - double filament_change_volume = 0.; - { - std::vector filament_change_lengths; - auto filament_change_lengths_opt = config().option("filament_change_length"); - if (filament_change_lengths_opt) filament_change_lengths = filament_change_lengths_opt->values; - double length = filament_change_lengths.empty() ? 0 : *std::max_element(filament_change_lengths.begin(), filament_change_lengths.end()); - double diameter = 1.75; - std::vector diameters; - auto filament_diameter_opt = config().option("filament_diameter"); - if (filament_diameter_opt) diameters = filament_diameter_opt->values; - diameter = diameters.empty() ? diameter : *std::max_element(diameters.begin(), diameters.end()); - filament_change_volume = length * PI * diameter * diameter / 4.; - } - - - if (! is_step_done(psWipeTower) && filaments_cnt !=0) { - double wipe_volume = m_config.prime_volume; - int filament_depth_count = m_config.nozzle_diameter.values.size() == 2 ? filaments_cnt : filaments_cnt - 1; - if (filaments_cnt == 1 && enable_timelapse_print()) filament_depth_count = 1; - double volume = wipe_volume * filament_depth_count; - if (m_config.nozzle_diameter.values.size() == 2) volume += filament_change_volume * (int) (filaments_cnt / 2); - - // Sizing should take into account currently set wiping volumes. - // For a long time, the initial preview would just use 900/width per toolchange (15mm on a 60mm wide tower) - // and it worked well enough. Let's try to do slightly better by accounting for the purging volumes. - const bool semm_flush = m_config.purge_in_prime_tower && m_config.single_extruder_multi_material; - if (semm_flush) volume = WipeTower2::estimate_semm_flush_volume(m_config, filaments_cnt); - - if (m_config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib) { - double depth = std::sqrt(volume / layer_height * extra_spacing); - if (need_wipe_tower || filaments_cnt > 1) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - depth = std::max((double) min_wipe_tower_depth, depth); - depth += rib_width / std::sqrt(2) + config().wipe_tower_extra_rib_length.value; - const_cast(this)->m_wipe_tower_data.depth = depth; - const_cast(this)->m_wipe_tower_data.brim_width = m_config.prime_tower_brim_width; - } - } - else { - double width = m_config.prime_tower_width; - double depth = volume / (layer_height * width); - // The flush volumes already hold the spacing between wipes. - if (!semm_flush) depth *= extra_spacing; - if (need_wipe_tower || depth > EPSILON) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - depth = std::max((double) min_wipe_tower_depth, depth); - } - const_cast(this)->m_wipe_tower_data.depth = depth; - const_cast(this)->m_wipe_tower_data.brim_width = m_config.prime_tower_brim_width; - } - if (m_config.prime_tower_brim_width < 0) const_cast(this)->m_wipe_tower_data.brim_width = WipeTower::get_auto_brim_by_height(max_height); - } + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(m_config, this->wipe_tower_type(), this->extruders(true), layer_height, max_height); + WipeTowerData &data = const_cast(this)->m_wipe_tower_data; + data.depth = float(footprint.depth); + data.width = float(footprint.width); + data.brim_width = float(footprint.brim_width); return m_wipe_tower_data; } @@ -4290,6 +4261,7 @@ void Print::_make_wipe_tower() m_wipe_tower_data.tool_changes.reserve(m_wipe_tower_data.tool_ordering.layer_tools().size()); wipe_tower.generate_new(m_wipe_tower_data.tool_changes); m_wipe_tower_data.depth = wipe_tower.get_depth(); + m_wipe_tower_data.width = wipe_tower.width(); m_wipe_tower_data.brim_width = wipe_tower.get_brim_width(); m_wipe_tower_data.bbx = wipe_tower.get_bbx(); m_wipe_tower_data.rib_offset = wipe_tower.get_rib_offset(); @@ -4403,6 +4375,7 @@ void Print::_make_wipe_tower() m_wipe_tower_data.tool_changes.reserve(m_wipe_tower_data.tool_ordering.layer_tools().size()); wipe_tower.generate(m_wipe_tower_data.tool_changes); m_wipe_tower_data.depth = wipe_tower.get_depth(); + m_wipe_tower_data.width = wipe_tower.width(); m_wipe_tower_data.z_and_depth_pairs = wipe_tower.get_z_and_depth_pairs(); m_wipe_tower_data.brim_width = wipe_tower.get_brim_width(); m_wipe_tower_data.height = wipe_tower.get_wipe_tower_height(); @@ -4438,7 +4411,9 @@ void Print::_make_wipe_tower() wipe_tower.get_wipe_tower_height(), wipe_tower.get_brim_width(), config().wipe_tower_wall_type.value == WipeTowerWallType::wtwRib, wipe_tower.get_rib_width(), wipe_tower.get_rib_length(), - config().wipe_tower_fillet_wall.value); + config().wipe_tower_fillet_wall.value, + config().wipe_tower_wall_type.value == WipeTowerWallType::wtwCone ? + (float) config().wipe_tower_cone_angle.value : 0.f); const Vec3d origin = Vec3d::Zero(); // FakeWipeTower::pos is a bed-frame translation applied after rotation // (getFakeExtrusionPathsFromWipeTower2 rotates about the local origin), so the @@ -4451,6 +4426,28 @@ void Print::_make_wipe_tower() config().wipe_tower_rotation_angle, config().wipe_tower_cone_angle, {scale_(origin.x()), scale_(origin.y())}); } + + // The clamps and checks above work from estimates; re-test the exact generated footprint + // so an off-plate tower fails with a clear error instead of exporting unprintable G-code + // (validate() only sees the mesh on its next run). + if (m_wipe_tower_data.wipe_tower_mesh_data) { + Polygon footprint = m_wipe_tower_data.wipe_tower_mesh_data->bottom; // includes brim and rib offset + footprint.rotate(Geometry::deg2rad(m_config.wipe_tower_rotation_angle.value)); + footprint.translate(Point(scale_(m_config.wipe_tower_x.get_at(m_plate_index)), + scale_(m_config.wipe_tower_y.get_at(m_plate_index)))); + const Polygons printable_polys = this->get_extruder_shared_printable_polygon(); + if (!printable_polys.empty() && !diff(Polygons{footprint}, printable_polys).empty()) { + const BoundingBox fp = get_extents(footprint); + const BoundingBox pr = get_extents(printable_polys); + BOOST_LOG_TRIVIAL(error) << boost::format("wipe tower footprint [%1%,%2%]-[%3%,%4%] leaves printable [%5%,%6%]-[%7%,%8%]") % + unscaled(fp.min.x()) % unscaled(fp.min.y()) % unscaled(fp.max.x()) % unscaled(fp.max.y()) % + unscaled(pr.min.x()) % unscaled(pr.min.y()) % unscaled(pr.max.x()) % unscaled(pr.max.y()); + throw Slic3r::SlicingError(L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")); + } + // The cutter/purge corner is a physical obstacle — the brim must stay out like the body. + if (!intersection(get_bed_excluded_area(m_config), Polygons{footprint}).empty()) + throw Slic3r::SlicingError(L("Prime Tower") + L(" is too close to an exclusion area, and collisions will be caused.\n")); + } } // Generate a recommended G-code output file name based on the format template, default extension, and template parameters @@ -5999,17 +5996,26 @@ ExtrusionLayers FakeWipeTower::getTrueExtrusionLayersFromWipeTower() const } return wtels; } -void WipeTowerData::construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length,bool fillet_wall) +void WipeTowerData::construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length,bool fillet_wall, float cone_angle) { wipe_tower_mesh_data = WipeTowerMeshData{}; float first_layer_height=0.08; //brim height if (width < EPSILON || depth < EPSILON || height < EPSILON) return; - if (!is_rib_wipe_tower || rib_length < EPSILON) { + if (cone_angle > EPSILON && (!is_rib_wipe_tower || rib_length < EPSILON)) { + // Cone tower: the base bulges past the body box; this bottom polygon feeds the + // containment checks, so it must carry the bulge and the brim (cone not lofted). + wipe_tower_mesh_data->real_wipe_tower_mesh = make_cube(width, depth, height); + wipe_tower_mesh_data->bottom = WipeTower2::cone_base_polygon(width, depth, height, cone_angle); + auto brim_bottom = offset(wipe_tower_mesh_data->bottom, scaled(brim_width)); + if (!brim_bottom.empty()) + wipe_tower_mesh_data->bottom = brim_bottom.front(); + wipe_tower_mesh_data->real_brim_mesh = WipeTower::its_make_rib_brim(wipe_tower_mesh_data->bottom, first_layer_height); + } else if (!is_rib_wipe_tower || rib_length < EPSILON) { wipe_tower_mesh_data->real_wipe_tower_mesh = make_cube(width, depth, height); wipe_tower_mesh_data->real_brim_mesh = make_cube(width + 2 * brim_width, depth + 2 * brim_width, first_layer_height); wipe_tower_mesh_data->real_brim_mesh.translate({-brim_width, -brim_width, 0}); - wipe_tower_mesh_data->bottom = {scaled(Vec2f{-brim_width, -brim_width}), scaled(Vec2f{width + brim_width, 0}), scaled(Vec2f{width + brim_width, depth + brim_width}), - scaled(Vec2f{0, depth})}; + wipe_tower_mesh_data->bottom = {scaled(Vec2f{-brim_width, -brim_width}), scaled(Vec2f{width + brim_width, -brim_width}), + scaled(Vec2f{width + brim_width, depth + brim_width}), scaled(Vec2f{-brim_width, depth + brim_width})}; } else { wipe_tower_mesh_data->real_wipe_tower_mesh = WipeTower::its_make_rib_tower(width, depth, height, rib_length, rib_width, fillet_wall); wipe_tower_mesh_data->bottom = WipeTower::rib_section(width, depth, rib_length, rib_width, fillet_wall); diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 9e20061501..9822c5520c 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -782,6 +782,9 @@ struct WipeTowerData // Depth of the wipe tower to pass to GLCanvas3D for exact bounding box: float depth; + // Effective width (a rib wall squares the tower): the estimate until generation, then the + // generated width, so it never disagrees with depth. + float width; std::vector> z_and_depth_pairs; float brim_width; float height; @@ -795,12 +798,13 @@ struct WipeTowerData used_filament.clear(); number_of_toolchanges = -1; depth = 0.f; + width = 0.f; brim_width = 0.f; height = 0.f; rib_offset = Vec2f::Zero(); wipe_tower_mesh_data = std::nullopt; } - void construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length, bool fillet_wall); + void construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length, bool fillet_wall, float cone_angle = 0.f); private: // Only allow the WipeTowerData to be instantiated internally by Print, diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index 2b06f11244..519b6e826e 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -2846,7 +2846,9 @@ void TreeSupport::drop_nodes() const MinimumSpanningTree& mst = spanning_trees[group_index]; //In the first pass, merge all nodes that are close together. std::vector> nodes_vec(nodes_this_part.begin(), nodes_this_part.end()); - tbb::parallel_for_each(nodes_vec.begin(), nodes_vec.end(), [&](const std::pair& entry) { + // Sequential: nodes merge into and invalidate each other in place, so parallel execution + // makes the merge order (and thus the result) depend on thread scheduling. + std::for_each(nodes_vec.begin(), nodes_vec.end(), [&](const std::pair& entry) { SupportNode* p_node = entry.second; SupportNode& node = *p_node; if (!p_node->valid) @@ -2934,7 +2936,32 @@ void TreeSupport::drop_nodes() ); //In the second pass, move all middle nodes. - tbb::parallel_for_each(nodes_vec.begin(), nodes_vec.end(), [&](const std::pair& entry) { + // Still parallel: this pass only reads other nodes. Side effects (invalidation, new + // nodes, contact_nodes/unsupported_branch_leaves updates) are recorded per node and + // applied afterwards in node order. Node creation must be deferred too, since + // SupportNode's constructor writes `parent->child = this` on other nodes. + struct PendingNode { + Point position; + int distance_to_top = 0; + int support_roof_layers_below = 0; + bool to_buildplate = false; + SupportNode *parent = nullptr; + bool zero_max_move = false; + bool has_overhang = false; + ExPolygon overhang; + bool clamp_radius = false; + coordf_t parent_radius = 0; + double dist_to_outer = 0; + }; + struct PassTwoResult { + bool invalidate = false; + bool unsupported_leaf = false; + std::vector pending; + }; + std::vector pass2_results(nodes_vec.size()); + auto pass2_body = [&](size_t node_idx) { + const std::pair& entry = nodes_vec[node_idx]; + PassTwoResult& pass2_out = pass2_results[node_idx]; SupportNode* p_node = entry.second; const SupportNode& node = *p_node; @@ -2949,14 +2976,16 @@ void TreeSupport::drop_nodes() ExPolygons overhangs_next = diff_clipped({ node.overhang }, get_collision(0, obj_layer_nr_next)); for(auto& overhang:overhangs_next) { Point next_pt = overhang.contour.centroid(); - SupportNode *next_node = m_ts_data->create_node(next_pt, p_node->distance_to_top + 1, obj_layer_nr_next, - p_node->support_roof_layers_below - (p_node->distance_to_top >= 0 ? 1 : 0), - to_buildplate, p_node, print_z_next, height_next); - next_node->max_move_dist = 0; - next_node->overhang = std::move(overhang); - m_ts_data->m_mutex.lock(); - contact_nodes[layer_nr_next].emplace_back(next_node); - m_ts_data->m_mutex.unlock(); + PendingNode pending; + pending.position = next_pt; + pending.distance_to_top = p_node->distance_to_top + 1; + pending.support_roof_layers_below = p_node->support_roof_layers_below - (p_node->distance_to_top >= 0 ? 1 : 0); + pending.to_buildplate = to_buildplate; + pending.parent = p_node; + pending.zero_max_move = true; + pending.has_overhang = true; + pending.overhang = std::move(overhang); + pass2_out.pending.emplace_back(std::move(pending)); } return; @@ -2973,17 +3002,17 @@ void TreeSupport::drop_nodes() { if (support_on_buildplate_only) { - unsupported_branch_leaves.push_front({ layer_nr, p_node }); + pass2_out.unsupported_leaf = true; } else { - p_node->valid = false; + pass2_out.invalidate = true; } return; } // if the link between parent and current is cut by contours, mark current as bottom contact node if (p_node->parent && intersection_ln({p_node->position, p_node->parent->position}, layer_contours).empty()==false) { - p_node->valid = false; + pass2_out.invalidate = true; return; } } @@ -3096,20 +3125,47 @@ void TreeSupport::drop_nodes() } auto next_collision = get_collision(0, obj_layer_nr_next); const bool to_buildplate = !is_inside_ex(m_ts_data->m_layer_outlines[obj_layer_nr_next], next_layer_vertex); - SupportNode * next_node = m_ts_data->create_node(next_layer_vertex, node.distance_to_top + 1, obj_layer_nr_next, - node.support_roof_layers_below - (node.distance_to_top >= 0 ? 1 : 0), - to_buildplate, p_node, print_z_next, height_next); // don't increase radius if next node will collide partially with the object (STUDIO-7883) - to_outside = projection_onto(next_collision, next_node->position); + to_outside = projection_onto(next_collision, next_layer_vertex); direction_to_outer = to_outside - node.position; double dist_to_outer = unscale_(direction_to_outer.cast().norm()); - next_node->radius = std::max(node.radius, std::min(next_node->radius, dist_to_outer)); - get_max_move_dist(next_node); - m_ts_data->m_mutex.lock(); - contact_nodes[layer_nr_next].push_back(next_node); - m_ts_data->m_mutex.unlock(); + PendingNode pending; + pending.position = next_layer_vertex; + pending.distance_to_top = node.distance_to_top + 1; + pending.support_roof_layers_below = node.support_roof_layers_below - (node.distance_to_top >= 0 ? 1 : 0); + pending.to_buildplate = to_buildplate; + pending.parent = p_node; + pending.clamp_radius = true; + pending.parent_radius = node.radius; + pending.dist_to_outer = dist_to_outer; + pass2_out.pending.emplace_back(std::move(pending)); + }; + tbb::parallel_for(tbb::blocked_range(0, nodes_vec.size()), + [&pass2_body](const tbb::blocked_range& node_range) { + for (size_t node_idx = node_range.begin(); node_idx < node_range.end(); ++ node_idx) + pass2_body(node_idx); + }); + // Apply the recorded side effects in node order. + for (size_t node_idx = 0; node_idx < nodes_vec.size(); ++ node_idx) { + PassTwoResult& pass2_out = pass2_results[node_idx]; + for (PendingNode& pending : pass2_out.pending) { + SupportNode* next_node = m_ts_data->create_node(pending.position, pending.distance_to_top, obj_layer_nr_next, + pending.support_roof_layers_below, pending.to_buildplate, pending.parent, print_z_next, height_next); + if (pending.zero_max_move) + next_node->max_move_dist = 0; + if (pending.has_overhang) + next_node->overhang = std::move(pending.overhang); + if (pending.clamp_radius) { + next_node->radius = std::max(pending.parent_radius, std::min(next_node->radius, pending.dist_to_outer)); + get_max_move_dist(next_node); + } + contact_nodes[layer_nr_next].push_back(next_node); + } + if (pass2_out.unsupported_leaf) + unsupported_branch_leaves.push_front({ layer_nr, nodes_vec[node_idx].second }); + if (pass2_out.invalidate) + nodes_vec[node_idx].second->valid = false; } - ); } #ifdef SUPPORT_TREE_DEBUG_TO_SVG diff --git a/src/libslic3r/Support/TreeSupport3D.cpp b/src/libslic3r/Support/TreeSupport3D.cpp index 29131502d2..3cd3f4110d 100644 --- a/src/libslic3r/Support/TreeSupport3D.cpp +++ b/src/libslic3r/Support/TreeSupport3D.cpp @@ -2382,13 +2382,10 @@ static void merge_influence_areas( size_t num_buckets_initial; { // How many buckets per first merge iteration? - const size_t num_threads = tbb::this_task_arena::max_concurrency(); - // 4 buckets per thread if possible, - const size_t num_buckets_min = (input_size + 2) / 4; - // 2 buckets per thread otherwise. - const size_t num_buckets_max = input_size / 2; - num_buckets_initial = num_buckets_min >= num_threads ? num_buckets_min : num_buckets_max; - const size_t bucket_size = num_buckets_min >= num_threads ? 4 : 2; + // Fixed at 4: merging is not associative, so sizing buckets off max_concurrency() made + // results depend on the core count of the slicing machine. + const size_t bucket_size = 4; + num_buckets_initial = (input_size + 2) / 4; // Fill in the buckets. SupportElementMerging *it = influence_areas.data(); // Reserve one more bucket to keep a single influence area which will not be merged in the first iteration. diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index 55d9b716cf..797894442a 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -255,6 +255,10 @@ extern bool is_gallery_file(const std::string& path, char const* type); extern bool is_shapes_dir(const std::string& dir); //BBS: add json support extern bool is_json_file(const std::string& path); +// True if rel_path is relative, has no ".." component and, joined to root, still resolves inside it. +// Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS +// is rejected on all of them. +extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root); // Orca: custom protocal support utils inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); } diff --git a/src/libslic3r/libslic3r.h b/src/libslic3r/libslic3r.h index 6584566f40..c339da566a 100644 --- a/src/libslic3r/libslic3r.h +++ b/src/libslic3r/libslic3r.h @@ -93,6 +93,9 @@ static constexpr double INSET_OVERLAP_TOLERANCE = 0.4; static constexpr double EXTERNAL_INFILL_MARGIN = 3; static constexpr double BRIDGE_INFILL_MARGIN = 1; static constexpr double WIPE_TOWER_MARGIN = 1.; +// Margin for system placement of the wipe tower (defaults, re-placement, CLI). Positions +// within WIPE_TOWER_MARGIN stay valid: a user drag down to that limit is respected. +static constexpr double WIPE_TOWER_AUTO_MARGIN = 15.; //FIXME Better to use an inline function with an explicit return type. //inline coord_t scale_(coordf_t v) { return coord_t(floor(v / SCALING_FACTOR + 0.5f)); } #define scale_(val) ((val) / SCALING_FACTOR) diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 5f4baac951..875c90f6ab 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -1088,6 +1088,30 @@ bool is_json_file(const std::string& path) return boost::iends_with(path, ".json"); } +bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root) +{ + auto is_separator = [](char c) { return c == '/' || c == '\\'; }; + if (rel_path.empty() || is_separator(rel_path.front()) || (rel_path.size() > 1 && rel_path[1] == ':')) + return false; + for (size_t start = 0; start <= rel_path.size();) { + size_t end = start; + while (end < rel_path.size() && !is_separator(rel_path[end])) + ++end; + if (rel_path.compare(start, end - start, "..") == 0) + return false; + start = end + 1; + } + // Resolve against the canonical root so a symlink inside it cannot lead back out. + try { + const std::string root_str = boost::filesystem::weakly_canonical(root).string(); + const std::string full_str = boost::filesystem::weakly_canonical(root / rel_path).string(); + return full_str.compare(0, root_str.size(), root_str) == 0 && + (full_str.size() == root_str.size() || full_str[root_str.size()] == boost::filesystem::path::preferred_separator); + } catch (const boost::filesystem::filesystem_error &) { + return false; + } +} + bool is_img_file(const std::string &path) { return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg"); diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index f65c0e3532..6dbea1272a 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -20,6 +20,8 @@ #include "libslic3r/AppConfig.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/Tesselate.hpp" #include "libslic3r/PrintConfig.hpp" @@ -919,6 +921,21 @@ int GLVolumeCollection::load_wipe_tower_preview( GUI::PartPlateList& ppl = GUI::wxGetApp().plater()->get_partplate_list(); std::vector plate_extruders = ppl.get_plate(plate_idx)->get_extruders(true); TriangleMesh wipe_tower_shell = make_cube(width, depth, height); + // The brim is part of the printed footprint: draw it and fold it into the shell so the + // outside-bed shader and the drag clamp react to the true first-layer extent. + const bool show_brim = brim_width > 0.f; + const float brim_height = 0.2f; // one first layer, visual only + TriangleMesh brim_slab; + if (show_brim) { + // The brim follows the real first-layer outline: a Type2 cone-wall tower's base bulges + // past the body box. The wall type and angle are print settings, the planner a printer one. + const DynamicPrintConfig &print_cfg = GUI::wxGetApp().preset_bundle->prints.get_edited_preset().config; + const DynamicPrintConfig &printer_cfg = GUI::wxGetApp().preset_bundle->printers.get_edited_preset().config; + const Polygon outline = estimate_wipe_tower_first_layer_outline(print_cfg, resolve_wipe_tower_type(printer_cfg), width, depth, height); + const Polygons brim_outline = offset(outline, scaled(brim_width)); + brim_slab = WipeTower::its_make_rib_brim(brim_outline.empty() ? outline : brim_outline.front(), brim_height); + wipe_tower_shell.merge(brim_slab); + } for (int extruder_id : plate_extruders) { if (extruder_id <= extruder_colors.size()) colors.push_back(extruder_colors[extruder_id - 1]); @@ -929,14 +946,19 @@ int GLVolumeCollection::load_wipe_tower_preview( // Orca: make it transparent for(auto& color : colors) color.a(0.66f); + const size_t slab_count = colors.size(); // per-filament body slabs; the brim part comes after + if (show_brim && !colors.empty()) + colors.push_back(colors.front()); volumes.emplace_back(new GLWipeTowerVolume(colors)); GLWipeTowerVolume& v = *dynamic_cast(volumes.back()); v.model_per_colors.resize(colors.size()); - for (int i = 0; i < colors.size(); i++) { - TriangleMesh color_part = make_cube(width, depth / colors.size(), height); - color_part.translate({ 0.f, depth * i / colors.size(), 0. }); + for (size_t i = 0; i < slab_count; i++) { + TriangleMesh color_part = make_cube(width, depth / slab_count, height); + color_part.translate({ 0.f, depth * i / slab_count, 0. }); v.model_per_colors[i].init_from(color_part); } + if (show_brim && !colors.empty()) + v.model_per_colors[slab_count].init_from(brim_slab); v.model.init_from(wipe_tower_shell); v.mesh_raycaster = std::make_unique(std::make_shared(wipe_tower_shell)); v.set_convex_hull(wipe_tower_shell); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 6cdc27ed65..8d72405cdd 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2891,23 +2891,37 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re DynamicPrintConfig& proj_cfg = wxGetApp().preset_bundle->project_config; float x = dynamic_cast(proj_cfg.option("wipe_tower_x"))->get_at(plate_id); float y = dynamic_cast(proj_cfg.option("wipe_tower_y"))->get_at(plate_id); - float w = dynamic_cast(m_config->option("prime_tower_width"))->value; float a = dynamic_cast(m_config->option("wipe_tower_rotation_angle"))->value; - // BBS - float v = dynamic_cast(m_config->option("prime_volume"))->value; Vec3d plate_origin = ppl.get_plate(plate_id)->get_origin(); - const Print* print = m_process->fff_print(); const Print* current_print = part_plate->fff_print(); - if (!need_wipe_tower && part_plate->get_extruders(true).size() < 2) continue; if (part_plate->get_objects_on_this_plate().empty()) continue; - float brim_width = print->wipe_tower_data(filaments_count).brim_width; - int nozzle_nums = wxGetApp().preset_bundle->get_printer_extruder_count(); - Vec3d wipe_tower_size = ppl.get_plate(plate_id)->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, 0, false, dynamic_cast(dconfig.option("enable_wrapping_detection"))->value); + // Body and brim from this plate's own estimate: m_process->fff_print() is the + // selected plate's, so an auto brim drew every tower with that plate's brim. + const WipeTowerFootprint footprint = part_plate->estimate_wipe_tower_footprint(full_config); + // The estimate is also the answer to whether this plate prints a tower; + // deciding it here as well only gave the two room to drift. + if (footprint.depth <= 0.) continue; + float brim_width = float(footprint.brim_width); + Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); + + // set_default_wipe_tower_pos_for_plate doesn't rerun when painting changes the + // filament count, so redo its clamp here on every reload — unconditionally: a + // paint-triggered reload can arrive before the background process invalidates + // psWipeTower, so gating on it would skip the clamp exactly when it is needed. + { + Vec3d clamped_pos, clamped_size; + part_plate->estimate_wipe_tower_polygon(full_config, plate_id, clamped_pos, clamped_size); + if (std::abs(x - (float) clamped_pos(0)) > EPSILON || std::abs(y - (float) clamped_pos(1)) > EPSILON) { + x = (float) clamped_pos(0); + y = (float) clamped_pos(1); + ConfigOptionFloat wt_x_opt(x), wt_y_opt(y); + dynamic_cast(proj_cfg.option("wipe_tower_x"))->set_at(&wt_x_opt, plate_id, 0); + dynamic_cast(proj_cfg.option("wipe_tower_y"))->set_at(&wt_y_opt, plate_id, 0); + } + } - // The stored position is already clamped onto the bed, by - // set_default_wipe_tower_pos_for_plate and again on every drag. if (!current_print->is_step_done(psWipeTower) || !current_print->wipe_tower_data().wipe_tower_mesh_data) { // update for wipe tower position int volume_idx_wipe_tower_new = m_volumes.load_wipe_tower_preview(1000 + plate_id, x + plate_origin(0), y + plate_origin(1), diff --git a/src/slic3r/GUI/Jobs/ArrangeJob.cpp b/src/slic3r/GUI/Jobs/ArrangeJob.cpp index 6b74b71af1..c0cc7bdb5a 100644 --- a/src/slic3r/GUI/Jobs/ArrangeJob.cpp +++ b/src/slic3r/GUI/Jobs/ArrangeJob.cpp @@ -265,8 +265,7 @@ arrangement::ArrangePolygon estimate_wipe_tower_info(int plate_index, std::setget_printer_extruder_count(); - auto arrange_poly = ppl.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(full_config, plate_index, wipe_tower_pos, wipe_tower_size, nozzle_nums, extruder_size); + auto arrange_poly = ppl.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(full_config, plate_index, wipe_tower_pos, wipe_tower_size, extruder_size); arrange_poly.bed_idx = plate_index; return arrange_poly; } diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 849aa4e31a..90e2c96ab6 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include "libslic3r/libslic3r.h" #include "libslic3r/Polygon.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/BoundingBox.hpp" #include "libslic3r/Geometry.hpp" @@ -1531,8 +1533,23 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const if (check_objects_empty_and_gcode3mf(plate_extruders)) { return plate_extruders; } - // if 3mf file - const DynamicPrintConfig& glb_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; + return get_extruders(conside_custom_gcode, wxGetApp().preset_bundle->prints.get_edited_preset().config, wxGetApp().preset_bundle->project_config); +} + +// The plate's filaments, with the global keys read from the given configs rather than the +// application's presets: the wipe tower estimate is also called under the CLI, which has no +// application object. get_extruders(bool) passes the edited presets; a full config serves both. +std::vector PartPlate::get_extruders(bool conside_custom_gcode, const DynamicPrintConfig& glb_config, const DynamicPrintConfig& project_config) const +{ + std::vector plate_extruders; + // A plate from a sliced .gcode.3mf holds no objects, so report the filaments the G-code + // used. check_objects_empty_and_gcode3mf does this for get_extruders(bool), but reaches + // the plater, which the CLI has none of; slice_filaments_info is only filled for such a plate. + if (m_model->objects.empty()) { + for (const FilamentInfo &info : slice_filaments_info) + plate_extruders.push_back(info.id + 1); + return plate_extruders; + } int glb_support_intf_extr = glb_config.opt_int("support_interface_filament"); int glb_support_extr = glb_config.opt_int("support_filament"); int glb_outer_wall_extr = glb_config.opt_int("outer_wall_filament_id"); @@ -1549,7 +1566,9 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const glb_support |= glb_config.opt_int("raft_layers") > 0; for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) { - if (!contain_instance_totally(obj_idx, 0)) + // Any instance on the plate counts, as PrintApply does: after an arrange, instance 0 + // can sit on a different plate. + if (!contain_any_instance_totally(obj_idx)) continue; ModelObject* mo = m_model->objects[obj_idx]; @@ -1662,7 +1681,7 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const if (conside_custom_gcode) { //BBS int nums_extruders = 0; - if (const ConfigOptionStrings *color_option = dynamic_cast(wxGetApp().preset_bundle->project_config.option("filament_colour"))) { + if (const ConfigOptionStrings *color_option = dynamic_cast(project_config.option("filament_colour"))) { nums_extruders = color_option->values.size(); if (m_model->plates_custom_gcodes.find(m_plate_index) != m_model->plates_custom_gcodes.end()) { for (auto item : m_model->plates_custom_gcodes.at(m_plate_index).gcodes) { @@ -1681,9 +1700,8 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const // is never loaded into a tray, so callers (AMS mapping, filament checks) must see the // physical filaments it resolves to instead. { - auto& project_config = wxGetApp().preset_bundle->project_config; - auto* is_mixed_opt = project_config.option("filament_is_mixed"); - auto* comp_strs_opt = project_config.option("filament_mixed_components"); + const auto* is_mixed_opt = project_config.option("filament_is_mixed"); + const auto* comp_strs_opt = project_config.option("filament_mixed_components"); if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { std::vector ext_0based; for (int e : plate_extruders) @@ -2311,113 +2329,97 @@ bool PartPlate::check_compatible_of_nozzle_and_filament(const DynamicPrintConfig return wipe_tower_size; }*/ -Vec3d PartPlate::estimate_wipe_tower_size(const DynamicPrintConfig & config, const double w, const double wipe_volume, int extruder_count, int plate_extruder_size, bool use_global_objects, bool enable_wrapping_detection) const +WipeTowerFootprint PartPlate::estimate_wipe_tower_footprint(const DynamicPrintConfig &config, int plate_extruder_size, bool use_global_objects) const { - Vec3d wipe_tower_size; - double layer_height = 0.08f; // hard code layer height - double max_height = 0.f; - wipe_tower_size.setZero(); + // The CLI calls this too, so the plate's filaments are derived from the passed config: + // get_extruders(bool) reads the same keys off wxGetApp()'s presets, which the CLI has none of. + // An explicit count is a floor: init-time and arrange estimates size an empty plate for that + // many generic filaments, the lowest ids not already on the plate. + std::vector plate_extruders = get_extruders(true, config, config); + for (int id = 1; int(plate_extruders.size()) < plate_extruder_size; ++id) + if (std::find(plate_extruders.begin(), plate_extruders.end(), id) == plate_extruders.end()) + plate_extruders.push_back(id); + // The wipe tower filament joins the tool ordering even when unused (Print::extruders), so + // validation counts it - but only where there is a tower to join, which is the + // has_wipe_tower() half of that guard. + const ConfigOption *wipe_tower_filament_opt = config.option("wipe_tower_filament"); + const ConfigOption *enable_prime_tower_opt = config.option("enable_prime_tower"); + const int wipe_tower_filament = wipe_tower_filament_opt != nullptr ? wipe_tower_filament_opt->getInt() : 0; + if (enable_prime_tower_opt != nullptr && enable_prime_tower_opt->getBool() && plate_extruders.size() > 1 && wipe_tower_filament > 0 && + std::find(plate_extruders.begin(), plate_extruders.end(), wipe_tower_filament) == plate_extruders.end()) + plate_extruders.push_back(wipe_tower_filament); + if (plate_extruders.empty()) + return WipeTowerFootprint(); - const ConfigOption* layer_height_opt = config.option("layer_height"); - if (layer_height_opt) - layer_height = layer_height_opt->getFloat(); - - // empty plate - if (plate_extruder_size == 0) - { - std::vector plate_extruders = get_extruders(true); - plate_extruder_size = plate_extruders.size(); - } - if (plate_extruder_size == 0) - return wipe_tower_size; - - for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) { - if (!use_global_objects && !contain_instance_totally(obj_idx, 0)) + // Tallest object on this plate and the thinnest layer it is sliced at, resolved per object + // as PrintObject resolves them (override, else preset) and over this plate's objects only - + // seeding from the global value, or folding in an off-plate override, diverges from Print. + const ConfigOption *layer_height_opt = config.option("layer_height"); + const double global_layer_height = layer_height_opt != nullptr ? layer_height_opt->getFloat() : 0.08; + double max_height = 0.; + double layer_height = std::numeric_limits::max(); + for (int obj_idx = 0; obj_idx < int(m_model->objects.size()); ++obj_idx) { + const ModelObject *object = m_model->objects[obj_idx]; + if (!use_global_objects && !contain_any_instance_totally(obj_idx)) continue; - - BoundingBoxf3 bbox = m_model->objects[obj_idx]->bounding_box_exact(); - max_height = std::max(bbox.size().z(), max_height); - } - wipe_tower_size(2) = max_height; - //const DynamicPrintConfig &dconfig = wxGetApp().preset_bundle->prints.get_edited_preset().config; - auto timelapse_type = config.option>("timelapse_type"); - bool need_wipe_tower = (timelapse_type ? (timelapse_type->value == TimelapseType::tlSmooth) : false) | enable_wrapping_detection; - double extra_spacing = config.option("prime_tower_infill_gap")->getFloat() / 100.; - const ConfigOptionEnum* use_rib_wall_opt = config.option>("wipe_tower_wall_type"); - bool use_rib_wall = use_rib_wall_opt ? use_rib_wall_opt->value == WipeTowerWallType::wtwRib: false; - double rib_width = config.option("wipe_tower_rib_width")->getFloat(); - double depth; - double filament_change_volume=0.; - { - std::vector filament_change_lengths; - auto filament_change_lengths_opt = m_print->config().option("filament_change_length"); - if (filament_change_lengths_opt) filament_change_lengths = filament_change_lengths_opt->values; - double length = filament_change_lengths.empty() ? 0 : *std::max_element(filament_change_lengths.begin(), filament_change_lengths.end()); - double diameter = 1.75; - std::vector diameters; - auto filament_diameter_opt = m_print->config().option("filament_diameter"); - if (filament_diameter_opt) diameters = filament_diameter_opt->values; - diameter = diameters.empty() ? diameter : *std::max_element(diameters.begin(), diameters.end()); - filament_change_volume = length * PI * diameter * diameter / 4.; - } - double volume = wipe_volume * (extruder_count == 2 ? plate_extruder_size : (plate_extruder_size - 1)); - if (extruder_count == 2) volume += filament_change_volume * (int) (plate_extruder_size / 2); - // Read from the passed plate config — m_print may not have been applied yet - // (fresh plates, CLI), in which case its PrintConfig still holds defaults. - const auto *purge_opt = config.option("purge_in_prime_tower"); - const auto *semm_opt = config.option("single_extruder_multi_material"); - const bool semm_flush = purge_opt && purge_opt->value && semm_opt && semm_opt->value; - if (semm_flush) volume = WipeTower2::estimate_semm_flush_volume(config, plate_extruder_size); - if (use_rib_wall) { - depth = std::sqrt(volume / layer_height * extra_spacing); - if (need_wipe_tower || plate_extruder_size > 1) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - double volume_depth = depth; - depth = std::max((double) min_wipe_tower_depth, depth); - rib_width = std::min(rib_width, depth / 2); - depth = rib_width / std::sqrt(2) + std::max(depth + m_print->config().wipe_tower_extra_rib_length.value, volume_depth); - wipe_tower_size(0) = wipe_tower_size(1) = depth; + // Per instance, to match PrintObject::size(); the union over instances differs once + // they are rotated apart. The cached convex hull has the mesh's z extent and is cheap + // enough for every scene reload. + for (int inst_idx = 0; inst_idx < int(object->instances.size()); ++inst_idx) { + if (!use_global_objects && !contain_instance_totally(obj_idx, inst_idx)) + continue; + max_height = std::max(max_height, object->instance_convex_hull_bounding_box(inst_idx, true).size().z()); } + const ConfigOption *object_layer_height = object->config.option("layer_height"); + layer_height = std::min(layer_height, object_layer_height != nullptr ? object_layer_height->getFloat() : global_layer_height); } - else { - depth = volume / (layer_height * w); - // The flush volumes already hold the spacing between wipes. - if (!semm_flush) depth *= extra_spacing; - if (need_wipe_tower || depth > EPSILON) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - depth = std::max((double)min_wipe_tower_depth, depth); - } - wipe_tower_size(0) = w; - wipe_tower_size(1) = depth; - } + if (layer_height == std::numeric_limits::max()) + layer_height = global_layer_height; - return wipe_tower_size; + std::vector filament_ids; + for (int id : plate_extruders) + if (id > 0) + filament_ids.push_back(static_cast(id - 1)); + return Slic3r::estimate_wipe_tower_footprint(config, resolve_wipe_tower_type(config), filament_ids, layer_height, max_height); } -arrangement::ArrangePolygon PartPlate::estimate_wipe_tower_polygon(const DynamicPrintConfig& config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int extruder_count, int plate_extruder_size, bool use_global_objects) const +arrangement::ArrangePolygon PartPlate::estimate_wipe_tower_polygon(const DynamicPrintConfig& config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int plate_extruder_size, bool use_global_objects) const { float x = dynamic_cast(config.option("wipe_tower_x"))->get_at(plate_index); float y = dynamic_cast(config.option("wipe_tower_y"))->get_at(plate_index); - float w = dynamic_cast(config.option("prime_tower_width"))->value; //float a = dynamic_cast(config.option("wipe_tower_rotation_angle"))->value; - float v = dynamic_cast(config.option("prime_volume"))->value; - float tower_brim_width = dynamic_cast(config.option("prime_tower_brim_width"))->value; - const ConfigOptionBool * wrapping_opt = dynamic_cast(config.option("enable_wrapping_detection")); - bool enable_wrapping = (wrapping_opt != nullptr) && wrapping_opt->value; - wt_size = estimate_wipe_tower_size(config, w, v, extruder_count, plate_extruder_size, use_global_objects, enable_wrapping); + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(config, plate_extruder_size, use_global_objects); + wt_size = Vec3d(footprint.width, footprint.depth, footprint.height); int plate_width=m_width, plate_depth=m_depth; - w = wt_size(0); // effective width; differs from prime_tower_width when the rib wall squares the tower + float w = wt_size(0); // effective width; differs from prime_tower_width when the rib wall squares the tower float depth = wt_size(1); - float margin = WIPE_TOWER_MARGIN + tower_brim_width, wp_brim_width = 0.f; - const ConfigOption* wipe_tower_brim_width_opt = config.option("prime_tower_brim_width"); - if (wipe_tower_brim_width_opt) { - wp_brim_width = wipe_tower_brim_width_opt->getFloat(); - if (wp_brim_width < 0) wp_brim_width = WipeTower::get_auto_brim_by_height((float) wt_size.z()); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%") % wp_brim_width; - } - - x = std::clamp(x, margin, (float)plate_width - w - margin - wp_brim_width); - y = std::clamp(y, margin, (float)plate_depth - depth - margin - wp_brim_width); + // Resolved brim, not the raw option: "Auto" (-1) would yield a margin of 0 and let the + // clamp put the brim off the bed. Matches set_default_wipe_tower_pos_for_plate. + float wp_brim_width = float(footprint.brim_width); + // A Type2 stabilization cone bulges past the body box like a brim does - fold its worst-axis + // bulge into the same margin. + const BoundingBox outline = get_extents(estimate_wipe_tower_first_layer_outline(config, resolve_wipe_tower_type(config), w, depth, wt_size.z())); + wp_brim_width += float(std::max({0., unscaled(outline.max.x()) - w, unscaled(outline.max.y()) - depth, -unscaled(outline.min.x()), -unscaled(outline.min.y())})); + // A position valid by WIPE_TOWER_MARGIN is the user's choice and stays untouched; an + // invalid one is re-placed with the comfort margin (falling back to the validity bounds + // on cramped plates). std::clamp is UB if lo > hi, so keep every hi >= lo. + const float margin = WIPE_TOWER_MARGIN + wp_brim_width; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%") % wp_brim_width; + const float x_hi = std::max(margin, (float) plate_width - w - margin); + const float y_hi = std::max(margin, (float) plate_depth - depth - margin); + const float margin_c = (float) WIPE_TOWER_AUTO_MARGIN + wp_brim_width; + float x_lo_c = margin_c, x_hi_c = (float) plate_width - w - margin_c; + if (x_lo_c > x_hi_c) { x_lo_c = margin; x_hi_c = x_hi; } + float y_lo_c = margin_c, y_hi_c = (float) plate_depth - depth - margin_c; + if (y_lo_c > y_hi_c) { y_lo_c = margin; y_hi_c = y_hi; } + // Drag clamps reach this limit through the volume's bounding box (post-slice: the real + // mesh, a couple of mm inside this reserved estimate), so a drop can land slightly out + // of bounds — snap it onto the bound; only far-out positions get the comfort re-place. + const float tol = 5.f; + if (x < margin - tol || x > x_hi + tol) x = std::clamp(x, x_lo_c, x_hi_c); + else x = std::clamp(x, margin, x_hi); + if (y < margin - tol || y > y_hi + tol) y = std::clamp(y, y_lo_c, y_hi_c); + else y = std::clamp(y, margin, y_hi); wt_pos(0) = x; wt_pos(1) = y; wt_pos(2) = 0.f; @@ -2755,6 +2757,20 @@ bool PartPlate::contain_instance_totally(int obj_id, int instance_id) const return result; } +//judge whether any of the object's instances is totally included in plate or not +bool PartPlate::contain_any_instance_totally(int obj_id) const +{ + if (obj_id < 0 || obj_id >= int(m_model->objects.size())) + return false; + + const ModelObject *object = m_model->objects[obj_id]; + for (int instance_id = 0; instance_id < int(object->instances.size()); ++instance_id) + if (contain_instance_totally(obj_id, instance_id)) + return true; + + return false; +} + //check whether instance is outside the plate or not bool PartPlate::check_outside(int obj_id, int instance_id, BoundingBoxf3* bounding_box) { @@ -4488,26 +4504,16 @@ void PartPlateList::set_default_wipe_tower_pos_for_plate(int plate_idx, bool ini f_volume_maps = wxGetApp().preset_bundle->get_default_nozzle_volume_types_for_filaments(filament_maps); } DynamicPrintConfig full_config = wxGetApp().preset_bundle->full_config(false, filament_maps, f_volume_maps); - float w = dynamic_cast(full_config.option("prime_tower_width"))->value; - float v = dynamic_cast(full_config.option("prime_volume"))->value; - bool enable_wrapping = false; - const ConfigOptionBool *wrapping_opt = dynamic_cast(full_config.option("enable_wrapping_detection")); - if (wrapping_opt) enable_wrapping = wrapping_opt->value; - int nozzle_nums = wxGetApp().preset_bundle->get_printer_extruder_count(); - Vec3d wipe_tower_size = part_plate->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, init_pos ? 2 : 0, false, enable_wrapping); + WipeTowerFootprint footprint = part_plate->estimate_wipe_tower_footprint(full_config, init_pos ? 2 : 0); - if (!init_pos && (is_approx(wipe_tower_size(0), 0.0) || is_approx(wipe_tower_size(1), 0.0))) { - wipe_tower_size = part_plate->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, 2, false, enable_wrapping); + if (!init_pos && (is_approx(footprint.width, 0.0) || is_approx(footprint.depth, 0.0))) { + footprint = part_plate->estimate_wipe_tower_footprint(full_config, 2); } + Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); - // Compute brim-aware margin: brim extends outward from tower position - float brim_width = 0.f; - const ConfigOptionFloat *brim_opt = full_config.option("prime_tower_brim_width"); - if (brim_opt) { - brim_width = brim_opt->value; - if (brim_width < 0) brim_width = WipeTower::get_auto_brim_by_height((float) wipe_tower_size.z()); - } - const float margin = WIPE_TOWER_MARGIN + brim_width; + // Brim-aware margin: the brim extends outward from the tower position. + const float brim_width = float(footprint.brim_width); + const float margin = WIPE_TOWER_AUTO_MARGIN + brim_width; // clamp wipe tower position within plate boundaries { diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 8ad2f4a7d1..6d7eb18beb 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -11,6 +11,7 @@ #include "libslic3r/GCode/GCodeProcessor.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Slicing.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/Arrange.hpp" #include "Plater.hpp" #include "libslic3r/Model.hpp" @@ -339,11 +340,16 @@ public: Vec3d get_origin() { return m_origin; } //Vec3d calculate_wipe_tower_size(const DynamicPrintConfig &config, const double w, const double wipe_volume, int plate_extruder_size = 0, bool use_global_objects = false) const; - Vec3d estimate_wipe_tower_size(const DynamicPrintConfig & config, const double w, const double wipe_volume, int extruder_count = 1, int plate_extruder_size = 0, bool use_global_objects = false, bool enable_wrapping_detection = false) const; - arrangement::ArrangePolygon estimate_wipe_tower_polygon(const DynamicPrintConfig & config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int extruder_count = 1, int plate_extruder_size = 0, bool use_global_objects = false) const; + // plate_extruder_size: a floor on the filaments purged on the plate; its own are always + // counted, so 0 sizes for exactly those. + // use_global_objects skips the containment test, which the CLI needs before objects are + // assigned to plates - the layer height is then the project's thinnest, which over-reserves. + WipeTowerFootprint estimate_wipe_tower_footprint(const DynamicPrintConfig & config, int plate_extruder_size = 0, bool use_global_objects = false) const; + arrangement::ArrangePolygon estimate_wipe_tower_polygon(const DynamicPrintConfig & config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int plate_extruder_size = 0, bool use_global_objects = false) const; bool check_objects_empty_and_gcode3mf(std::vector &result) const; // get used filaments from config, 1 based idx std::vector get_extruders(bool conside_custom_gcode = false) const; + std::vector get_extruders(bool conside_custom_gcode, const DynamicPrintConfig& glb_config, const DynamicPrintConfig& project_config) const; std::vector get_extruders_under_cli(bool conside_custom_gcode, DynamicPrintConfig& full_config) const; std::vector get_extruders_without_support(bool conside_custom_gcode = false) const; // get used filaments from gcode result, 1 based idx @@ -366,6 +372,8 @@ public: bool contain_instance_totally(ModelObject* object, int instance_id) const; //judge whether instance is totally included in plate or not bool contain_instance_totally(int obj_id, int instance_id) const; + //judge whether any of the object's instances is totally included in plate or not + bool contain_any_instance_totally(int obj_id) const; //judge whether the plate's origin is at the left of instance or not bool is_left_top_of(int obj_id, int instance_id); diff --git a/src/slic3r/GUI/Selection.cpp b/src/slic3r/GUI/Selection.cpp index b6d7abde21..84f4a0b836 100644 --- a/src/slic3r/GUI/Selection.cpp +++ b/src/slic3r/GUI/Selection.cpp @@ -1273,10 +1273,9 @@ void Selection::translate(const Vec3d &displacement, TransformationType transfor const Polygons bed_polys{wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->get_shared_printable_polygon()}; Vec3d tower_origin = m_cache.volumes_data[i].get_volume_position(); Vec3d actual_displacement = displacement; - bool show_read_wipe_tower = wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->fff_print()->is_step_done(psWipeTower); - float brim_width = wxGetApp().preset_bundle->prints.get_edited_preset().config.opt_float("prime_tower_brim_width"); - - const double margin = show_read_wipe_tower ? WIPE_TOWER_MARGIN : brim_width + 0.5; // 0.5 is the line width of wipe tower + // Both preview volumes carry the brim in their bounding box, and the release + // clamp holds it WIPE_TOWER_MARGIN inside — same margin, so drops don't snap. + const double margin = WIPE_TOWER_MARGIN; actual_displacement = (m_cache.volumes_data[i].get_instance_rotation_matrix() * m_cache.volumes_data[i].get_instance_scale_matrix() * m_cache.volumes_data[i].get_instance_mirror_matrix()) diff --git a/tests/fff_print/test_gcodewriter.cpp b/tests/fff_print/test_gcodewriter.cpp index b09b38e794..c0c9e794b9 100644 --- a/tests/fff_print/test_gcodewriter.cpp +++ b/tests/fff_print/test_gcodewriter.cpp @@ -574,6 +574,9 @@ static DynamicPrintConfig dual_extruder_toolchange_config() config.set_key_value("nozzle_temperature_range_high", new ConfigOptionInts({240, 240})); config.set_key_value("flush_multiplier", new ConfigOptionFloats({1})); config.set_key_value("flush_volumes_matrix", new ConfigOptionFloats({0, 140, 140, 0})); + // Inside the 200x200 test bed; the default y, 220, is not, and generation rejects that. + config.set_key_value("wipe_tower_x", new ConfigOptionFloats({50.})); + config.set_key_value("wipe_tower_y", new ConfigOptionFloats({50.})); return config; } diff --git a/tests/fff_print/test_tree_support.cpp b/tests/fff_print/test_tree_support.cpp index 362eb0ca56..fbb40bb8f3 100644 --- a/tests/fff_print/test_tree_support.cpp +++ b/tests/fff_print/test_tree_support.cpp @@ -1,5 +1,7 @@ #include +#include + #include "libslic3r/Layer.hpp" #include "libslic3r/TriangleMesh.hpp" @@ -33,10 +35,13 @@ TriangleMesh scaled(TestMesh id, float scale) return mesh; } +// `extra` is applied last, so a caller can add or override any key. void slice_with_tree_support(const TriangleMesh &mesh, Slic3r::Print &print, const char *style, - int threshold_angle = 30, int build_plate_only = 0, int raft_layers = 0) + int threshold_angle = 30, int build_plate_only = 0, int raft_layers = 0, + std::initializer_list extra = {}) { - Slic3r::Test::init_and_process_print({ mesh }, print, { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ { "enable_support", 1 }, { "support_type", "tree(auto)" }, { "support_style", style }, @@ -45,6 +50,8 @@ void slice_with_tree_support(const TriangleMesh &mesh, Slic3r::Print &print, con { "raft_layers", raft_layers }, { "layer_height", 0.2 }, }); + config.set_deserialize_strict(extra); + Slic3r::Test::init_and_process_print({ mesh }, print, config); } Points support_points(const Slic3r::Print &print) @@ -63,6 +70,32 @@ size_t support_point_count(const TriangleMesh &mesh, const char *style, int thre return support_points(print).size(); } +// Index of the first differing point, or the common length when they match. An index keeps a +// failure readable; comparing the vectors themselves dumps thousands of points. +size_t first_difference(const Points &a, const Points &b) +{ + const size_t common = std::min(a.size(), b.size()); + for (size_t i = 0; i < common; ++i) + if (a[i] != b[i]) + return i; + return common; +} + +// Slice `mesh` twice and require an identical support point sequence. Point counts and total +// length are order insensitive, so the sequence is what a reordering shows up in. +void sliced_twice_matches(const TriangleMesh &mesh, int build_plate_only, const char *style = "tree_slim", + std::initializer_list extra = {}) +{ + Slic3r::Print first_print, second_print; + slice_with_tree_support(mesh, first_print, style, 30, build_plate_only, 0, extra); + slice_with_tree_support(mesh, second_print, style, 30, build_plate_only, 0, extra); + const Points first = support_points(first_print); + const Points second = support_points(second_print); + REQUIRE(first.size() > 1000); // without support the comparison below passes vacuously + REQUIRE(second.size() == first.size()); + REQUIRE(first_difference(first, second) == first.size()); +} + } // namespace TEST_CASE("Tree support is generated for an overhang and not for a plain cube", "[TreeSupport]") @@ -123,3 +156,35 @@ TEST_CASE("A raft is still generated under tree support", "[TreeSupport]") // The raft goes under the object. REQUIRE(rafted_object->layers().front()->print_z > unrafted_object->layers().front()->print_z); } + +// drop_nodes() decides the node merges and spawns the next layer's nodes in parallel. Every one of +// those decisions has to be applied in a fixed order, or the same model gives different branches on +// each slice. +TEST_CASE("Tree support toolpaths do not depend on thread scheduling", "[TreeSupport][Regression]") +{ + // Scaled up so that a layer holds enough nodes for the parallel range to be split. At stock + // size it stays in one chunk and the order never varies. + SECTION("overhang") { sliced_twice_matches(scaled(TestMesh::overhang, 2.f), 0); } + SECTION("bridge with hole") { sliced_twice_matches(scaled(TestMesh::bridge_with_hole, 3.f), 0); } + // Dropping every branch that cannot reach the bed leaves the survivors dense enough that the + // neighbour merge fires in bulk. + SECTION("on the build plate") { sliced_twice_matches(scaled(TestMesh::overhang, 4.f), 1); } + // Branches resting on the model are what put nodes in a part group other than 0, which is the + // only way to reach the prune in the second pass. tree_hybrid additionally builds polygon + // nodes, so it is the only style that exercises the overhang merge. + SECTION("resting on the model") { sliced_twice_matches(two_tier_mesh(), 0); } + SECTION("hybrid on the model") { sliced_twice_matches(two_tier_mesh(), 0, "tree_hybrid"); } +} + +// Prim breaks equal-distance ties by heap address. A 1 mm branch diameter puts neighbours close +// enough to tie, and an explicit line width pins max_move_dist, so the moved tie winner reaches +// the support toolpaths. +TEST_CASE("Tree support toolpaths do not depend on the MST tie order", "[TreeSupport][Regression]") +{ + sliced_twice_matches(two_tier_mesh(), 0, "tree_hybrid", { + { "tree_support_branch_diameter", 1.0 }, + { "tree_support_branch_distance", 5.0 }, + { "tree_support_branch_angle", 40 }, + { "support_line_width", 0.4 }, + }); +} diff --git a/tests/fff_print/test_wipe_tower.cpp b/tests/fff_print/test_wipe_tower.cpp index 2bd7ac4189..24d50d6e66 100644 --- a/tests/fff_print/test_wipe_tower.cpp +++ b/tests/fff_print/test_wipe_tower.cpp @@ -152,6 +152,8 @@ static DynamicPrintConfig wipe_tower_toolchange_config(const std::string &gcode_ { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 }, { "enable_prime_tower", true }, + { "wipe_tower_x", 50 }, // inside the 200x200 test bed + { "wipe_tower_y", 50 }, // (the default y, 220, is not) { "layer_height", 0.3 }, { "gcode_flavor", gcode_flavor }, }); @@ -182,3 +184,148 @@ TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring(unexpected)); } } + +// What Print feeds the shared estimate. The libslic3r WipeTowerEstimate cases cannot see this: +// they call the estimator directly. The estimate counts the filaments the print really uses, +// so the two-filament shape gives the outer wall the second one. +static DynamicPrintConfig tower_estimate_config(const char *wall_type, unsigned int filaments = 2) +{ + // 100 mm3 per purge on a 50 mm wide tower: one purge is 100/(layer_height * 50) of depth. + return multifilament_config(filaments, { + { "outer_wall_filament_id", filaments == 2 ? "2" : "1" }, + { "enable_prime_tower", "1" }, + { "wipe_tower_wall_type", wall_type }, + { "prime_tower_width", "50" }, + { "prime_volume", "100" }, + { "prime_tower_infill_gap", "100%" }, + { "prime_tower_brim_width", "3" }, + { "purge_in_prime_tower", "0" }, + { "single_extruder_multi_material", "0" }, + { "timelapse_type", "0" }, + { "layer_height", "0.2" }, + { "enable_wrapping_detection", "0" }, + { "raft_layers", "0" } }); +} + +TEST_CASE("The tower is sized for the thinnest layer any object on the plate is sliced at", "[WipeTower]") +{ + // The tower has to survive its thinnest layer, so an override finer than the preset drives + // the estimate even on the second object. Two 20 mm cubes, the second at 0.1 mm. + const DynamicPrintConfig config = tower_estimate_config("rectangle"); + const std::vector> overrides = { + {}, { { "layer_height", "0.1" } } }; + + Print print; + Model model; + init_print({ cube(20), cube(20) }, print, model, config, &overrides); + + // One purge at 0.1 mm: 100 / (0.1 * 50) = 20 mm, above the 20 mm-tall tower's stability + // floor. At the preset's 0.2 mm it would be half that, so the two are easy to tell apart. + const float floor_20mm = WipeTower::get_limit_depth_by_height(20.f); + REQUIRE(floor_20mm < 10.f); + CHECK_THAT(print.wipe_tower_data(2).depth, Catch::Matchers::WithinAbs(20., 1e-4)); +} + +TEST_CASE("Validation is given the tower's effective width, not the configured one", "[WipeTower]") +{ + // A rib wall squares the tower, so its width is its depth. Validation reads this rather + // than re-deriving the rule from the wall type. + Print print; + Model model; + + SECTION("a rectangle wall keeps the configured width") { + const DynamicPrintConfig config = tower_estimate_config("rectangle"); + init_print({ cube(20) }, print, model, config); + const WipeTowerData &data = print.wipe_tower_data(2); + CHECK_THAT(data.width, Catch::Matchers::WithinAbs(50., 1e-4)); + CHECK(data.depth < data.width); + } + + SECTION("a rib wall reports the squared footprint") { + const DynamicPrintConfig config = tower_estimate_config("rib"); + init_print({ cube(20) }, print, model, config); + const WipeTowerData &data = print.wipe_tower_data(2); + CHECK_THAT(data.width, Catch::Matchers::WithinAbs(data.depth, 1e-4)); + CHECK(data.width > 0.f); + } +} + +TEST_CASE("Generating the tower keeps its reported width current", "[WipeTower]") +{ + // width is handed out after the slice, so leaving it at the estimate reports a zero-width + // tower to every post-generation consumer. + const DynamicPrintConfig config = wipe_tower_toolchange_config("marlin"); + Print print; + Model model; + init_print({ cube(10) }, print, model, config); + print.apply(model, config); + REQUIRE(print.wipe_tower_data(2).width > 0.f); + + print.process(); + REQUIRE(print.is_step_done(psWipeTower)); + const WipeTowerData &data = print.wipe_tower_data(); + // A width the generator never wrote reads as zero. A rib wall squares the tower, so the + // generated width is the body square: under the configured 50 mm, and inside the depth. + CHECK(data.width > 0.f); + CHECK(data.width < 50.f); + CHECK(data.width <= data.depth + EPSILON); +} + +TEST_CASE("A single-filament plate reserves a tower only when one is actually printed", "[WipeTower]") +{ + // The estimate has to answer this the way Print::apply does: reporting no tower for one + // that is built collapses the validation hull to a point, and reporting one for a tower + // that is not built takes that bed area away from the arranger and draws a preview box + // over nothing. + Print print; + Model model; + + SECTION("no tool change and nothing else that prints one") { + const DynamicPrintConfig config = tower_estimate_config("rib", 1); + init_print({ cube(20) }, print, model, config); + REQUIRE_FALSE(print.has_wipe_tower()); + CHECK_THAT(print.wipe_tower_data(1).depth, Catch::Matchers::WithinAbs(0., 1e-6)); + } + + // A raft puts the tower on every layer below the object, but only where there is a tower: + // Print::apply runs normalize_fdm_2, which clears enable_prime_tower for a plate that + // purges one filament and has neither smooth timelapse nor wrapping detection on. + SECTION("a raft alone does not print one") { + DynamicPrintConfig config = tower_estimate_config("rib", 1); + config.set_deserialize_strict({ { "raft_layers", "3" } }); + init_print({ cube(20) }, print, model, config); + REQUIRE_FALSE(print.config().enable_prime_tower.value); + REQUIRE_FALSE(print.has_wipe_tower()); + CHECK_THAT(print.wipe_tower_data(1).depth, Catch::Matchers::WithinAbs(0., 1e-6)); + } + + SECTION("smooth timelapse prints one, and keeps enable_prime_tower on") { + DynamicPrintConfig config = tower_estimate_config("rib", 1); + config.set_deserialize_strict({ { "timelapse_type", "1" } }); + init_print({ cube(20) }, print, model, config); + REQUIRE(print.has_wipe_tower()); + CHECK(print.wipe_tower_data(1).depth > 0.f); + } +} + +TEST_CASE("A tower printed without a tool change is still validated against the bed", "[WipeTower]") +{ + // Wrapping detection prints a tower on a plate that purges one filament. Neither the old + // estimate (which read the wall type and smooth timelapse) nor the old containment gate (the + // filament count or smooth timelapse) knew about it, so between them that tower was never + // checked against the bed. + Print print; + Model model; + DynamicPrintConfig config = tower_estimate_config("rectangle", 1); + // Relative E without a per-layer G92 is rejected before the tower is ever looked at, and + // has_wipe_tower() wants a real exclusion polygon before it honours wrapping detection. + config.set_deserialize_strict({ { "enable_wrapping_detection", "1" }, + { "wrapping_exclude_area", "180x180,190x180,190x190,180x190" }, + { "wipe_tower_x", "500" }, { "wipe_tower_y", "500" }, { "use_relative_e_distances", "0" } }); + + init_print({ cube(20) }, print, model, config); + REQUIRE(print.extruders(true).size() == 1); + REQUIRE(print.has_wipe_tower()); + CHECK(print.wipe_tower_data(1).depth > 0.f); + CHECK_THAT(print.validate().string, Catch::Matchers::ContainsSubstring("printable area")); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 5d3e301ea7..5c10ab1496 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable(${_TEST_NAME}_tests test_polygon.cpp test_mutable_polygon.cpp test_mutable_priority_queue.cpp + test_minimum_spanning_tree.cpp test_nozzle_volume_type.cpp test_step.cpp test_stl.cpp @@ -39,6 +40,8 @@ add_executable(${_TEST_NAME}_tests test_utils.cpp test_timeutils.cpp test_voronoi.cpp + test_wipe_tower_estimate.cpp + test_wipe_tower.cpp test_optimizers.cpp test_ordering_strategies.cpp # test_png_io.cpp diff --git a/tests/libslic3r/test_minimum_spanning_tree.cpp b/tests/libslic3r/test_minimum_spanning_tree.cpp new file mode 100644 index 0000000000..5f9998171c --- /dev/null +++ b/tests/libslic3r/test_minimum_spanning_tree.cpp @@ -0,0 +1,66 @@ +#include + +#include + +#include "libslic3r/MinimumSpanningTree.hpp" +#include "libslic3r/Point.hpp" + +using namespace Slic3r; + +// A 5x5 lattice: at every step of Prim's algorithm several candidates sit at the same +// distance from the tree, so the tie-break decides the tree's shape. +static std::vector lattice() +{ + std::vector vertices; + for (int y = 0; y < 5; ++y) + for (int x = 0; x < 5; ++x) + vertices.emplace_back(Point::new_scale(x, y)); + return vertices; +} + +static std::vector sorted_neighbours(const MinimumSpanningTree &mst, const Point &vertex) +{ + std::vector neighbours = mst.adjacent_nodes(vertex); + std::sort(neighbours.begin(), neighbours.end()); + return neighbours; +} + +TEST_CASE("Minimum spanning tree connects every vertex", "[MinimumSpanningTree]") +{ + const std::vector vertices = lattice(); + const MinimumSpanningTree mst(vertices); + + REQUIRE(mst.vertices().size() == vertices.size()); + size_t adjacency_entries = 0; + for (const Point &vertex : vertices) { + const std::vector neighbours = mst.adjacent_nodes(vertex); + REQUIRE(! neighbours.empty()); + adjacency_entries += neighbours.size(); + } + // A tree on n vertices has n - 1 edges, each listed from both ends. + REQUIRE(adjacency_entries == 2 * (vertices.size() - 1)); +} + +TEST_CASE("Minimum spanning tree does not depend on the order of the non-root vertices", "[MinimumSpanningTree][Regression]") +{ + const std::vector vertices = lattice(); + const MinimumSpanningTree reference(vertices); + + // The root stays first: Prim's tree legitimately depends on where it starts. + // Every other order of the remaining vertices must give the same tree. + std::vector> orders; + orders.emplace_back(vertices); + std::reverse(orders.back().begin() + 1, orders.back().end()); + for (size_t shift = 1; shift + 1 < vertices.size(); ++shift) { + orders.emplace_back(vertices); + std::rotate(orders.back().begin() + 1, orders.back().begin() + 1 + shift, orders.back().end()); + } + + for (const std::vector &order : orders) { + const MinimumSpanningTree mst(order); + for (const Point &vertex : vertices) { + INFO("vertex " << vertex.x() << "," << vertex.y()); + REQUIRE(sorted_neighbours(mst, vertex) == sorted_neighbours(reference, vertex)); + } + } +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index bb760efad5..ecdede7053 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -8,6 +8,8 @@ #include "libslic3r/AppConfig.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/Utils.hpp" +#include "libslic3r/miniz_extension.hpp" #include "test_utils.hpp" @@ -5104,3 +5106,91 @@ TEST_CASE("Published 3MF denylist and mixed-key sets match the import/export con for (const std::string &key : mixed) CHECK(structural.count(key) == 0); } + +namespace { + +// data_dir() is a process-wide global that import_presets extracts into; scope it to the test. +struct ScopedDataDir +{ + std::string previous = data_dir(); + explicit ScopedDataDir(const fs::path &dir) { set_data_dir(dir.string()); } + ~ScopedDataDir() { set_data_dir(previous); } +}; + +std::string read_file(const fs::path &file) +{ + std::ifstream in(file.string(), std::ios::binary); + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +void write_zip(const fs::path &zip_file, const std::vector> &entries) +{ + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + REQUIRE(open_zip_writer(&zip, zip_file.string())); + for (const auto &[name, content] : entries) + REQUIRE(mz_zip_writer_add_mem(&zip, name.c_str(), content.data(), content.size(), MZ_DEFAULT_COMPRESSION)); + REQUIRE(mz_zip_writer_finalize_archive(&zip)); + REQUIRE(close_zip_writer(&zip)); +} + +bool any_filename_contains(const fs::path &root, const std::string &needle) +{ + for (fs::recursive_directory_iterator it(root), end; it != end; ++it) + if (it->path().filename().string().find(needle) != std::string::npos) + return true; + return false; +} + +} // namespace + +TEST_CASE("Config import confines zip entries, preset names and bundle ids to the preset directory", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir temp_dir; + const fs::path data_root = temp_dir.path() / "datadir"; + const fs::path src_dir = temp_dir.path() / "src"; + fs::create_directories(src_dir); + ScopedDataDir scoped_data_dir(data_root); + + PresetBundle bundle; + AppConfig app_config; + const auto confirm = [](std::string const &) { return 1; }; + const auto import = [&](const fs::path &file) { + std::vector files{file.string()}; + bundle.import_presets(files, confirm, ForwardCompatibilitySubstitutionRule::Disable, app_config); + return files; + }; + + const fs::path good_file = src_dir / "Good.json"; + write_print_preset(bundle.prints.default_preset().config, good_file, "Good"); + const std::string good_json = read_file(good_file); + + // Four levels up from where import_presets writes (/user/default/temp) is temp_dir + // itself, so anything that escapes lands where the scan below can see it. + const std::string up = "../../../../"; + const std::string up_win = "..\\..\\..\\..\\"; + + SECTION("zip entry names with either separator are reduced to a basename") { + const fs::path zip = src_dir / "bundle.zip"; + write_zip(zip, {{up + "zip-escape.json", "{}"}, {up_win + "zip-escape.json", "{}"}, {"presets/Good.json", good_json}}); + import(zip); + CHECK(bundle.prints.find_preset("Good") != nullptr); + CHECK_FALSE(any_filename_contains(temp_dir.path(), "zip-escape")); + } + + SECTION("a preset name that walks out of the preset directory is rejected") { + for (const std::string &name : {up + "name-escape", up_win + "name-escape"}) { + const fs::path file = src_dir / "escape.json"; + write_print_preset(bundle.prints.default_preset().config, file, name); + CHECK(import(file).empty()); + CHECK_FALSE(any_filename_contains(temp_dir.path(), "name-escape")); + } + } + + SECTION("a bundle id that walks out of the bundle directory is rejected") { + const fs::path zip = src_dir / "bundle.zip"; + write_zip(zip, {{BUNDLE_STRUCTURE_JSON_NAME, "{\"id\": \"" + up + "bundle-escape\"}"}, {"Good.json", good_json}}); + CHECK(import(zip).empty()); + CHECK_FALSE(any_filename_contains(temp_dir.path(), "bundle-escape")); + } +} diff --git a/tests/libslic3r/test_wipe_tower.cpp b/tests/libslic3r/test_wipe_tower.cpp new file mode 100644 index 0000000000..2987dce9da --- /dev/null +++ b/tests/libslic3r/test_wipe_tower.cpp @@ -0,0 +1,93 @@ +#include + +#include + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTower2.hpp" + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +// A Bambu P1S project that reproduced the off-plate brim: two PLAs priming 30 and 45 mm3 in +// separate adhesiveness categories on a 35 mm tower, 0.21 mm layers, 0.4 nozzle (0.5 mm lines), +// 150 % infill gap (0.75 mm line pitch), rib width 8, 16 mm tall. +static std::vector cube_purges(int first_category = 100) +{ + return {{30.f, first_category}, {45.f, 0}}; +} + +TEST_CASE("Cone base polygon bulges past the body box", "[WipeTower]") { + // Zero angle: plain body box. + const Polygon box = WipeTower2::cone_base_polygon(35., 20., 100., 0.); + CHECK(box.points.size() == 4); + CHECK(get_extents(box).size() == Point::new_scale(Vec2d(35., 20.))); + // A 25-degree cone on a 100 mm tower: base radius R = tan(12.5deg)*100 = 22.2 mm, + // which exceeds the body half-depth, so the footprint bulges to center +- R in y + // (support_scale keeps the x extent compressed near the body). + const Polygon base = WipeTower2::cone_base_polygon(35., 20., 100., 25.); + const BoundingBox bb = get_extents(base); + const double R = std::tan(25. / 2. * M_PI / 180.) * 100.; + CHECK_THAT(unscaled(bb.min.y()), WithinAbs(10. - R, 0.1)); + CHECK_THAT(unscaled(bb.max.y()), WithinAbs(10. + R, 0.1)); + // The footprint always contains the body box. + CHECK(diff(Polygons{box}, Polygons{base}).empty()); +} + +TEST_CASE("Type1 block-stack depth quantizes each purge to whole lines", "[WipeTower]") { + // A 0.5 mm line at 0.21 mm carries 0.0955 mm3 per mm, so across the 34 mm between the + // perimeters 30 mm3 is 10 lines and 45 mm3 is 14: 7.5 + 10.5 at the 0.75 mm pitch behind + // one perimeter width. The generated mesh of the project measured exactly this. + CHECK_THAT(WipeTower::estimate_tower_blocks_depth(cube_purges(), 35.f, 0.21f, 0.4f, 1.5f), WithinAbs(18.5f, 0.01f)); + // Sharing one category, a layer can never purge into every filament (one of them starts + // the layer), so the block is sized by its worst layer and the 10-line purge drops out. + CHECK_THAT(WipeTower::estimate_tower_blocks_depth(cube_purges(0), 35.f, 0.21f, 0.4f, 1.5f), WithinAbs(11.0f, 0.01f)); + CHECK_THAT(WipeTower::estimate_tower_blocks_depth({}, 35.f, 0.2f, 0.4f, 1.f), WithinAbs(0.f, 1e-6f)); + // A width narrower than two perimeter widths cannot hold purge lines. + CHECK_THAT(WipeTower::estimate_tower_blocks_depth({{45.f, 0}}, 0.9f, 0.2f, 0.4f, 1.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("A nozzle change adds its ramming lines to the block", "[WipeTower]") { + // 10 mm of 1.75 mm filament (24.05 mm3) laid as 1.0 mm nozzle-change lines at 0.2 mm + // (0.1914 mm2 each) is 125.7 mm; across the 48.5 mm available that is 3 lines of 1.0 mm. + std::vector purges{{100.f, 0}, {100.f, 0}}; + const float without_change = WipeTower::estimate_tower_blocks_depth(purges, 50.f, 0.2f, 0.4f, 1.f); + purges.front().filament_change_length = 10.f; + CHECK_THAT(WipeTower::estimate_tower_blocks_depth(purges, 50.f, 0.2f, 0.4f, 1.f) - without_change, WithinAbs(3.f, 1e-4f)); +} + +TEST_CASE("Rib tower footprint estimate covers the generated footprint", "[WipeTower]") { + // The generated first-layer wall bbox of the project measured 29.56 mm from the sliced + // G-code; the volume-only estimate said 23.585 mm. + const float side = WipeTower::estimate_rib_tower_bbox_side(cube_purges(), 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 16.f); + CHECK(side >= 29.56f); + CHECK(side <= 29.56f + 4.f); // without grossly over-reserving plate space + // Separate categories stack their blocks, so the footprint must not shrink when they differ. + CHECK(side >= WipeTower::estimate_rib_tower_bbox_side(cube_purges(0), 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 16.f)); + CHECK_THAT(WipeTower::estimate_rib_tower_bbox_side({}, 35.f, 0.2f, 0.4f, 1.f, 8.f, 0.f, 16.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("Rib footprint extends the ribs, not the body, below the stability minimum", "[WipeTower]") { + // A 10 mm body under a 90 mm print: the ribs stretch to the minimum depth's diagonal, and + // the rib width is capped at half the body, so the square grows to minimum + 5 / sqrt(2). + const float min_depth = WipeTower::get_limit_depth_by_height(90.f); + REQUIRE(min_depth > 10.f); + CHECK_THAT(WipeTower::rib_footprint_side(10.f, 10.f, 8.f, 0.f, 90.f), WithinAbs(min_depth + 5.f / std::sqrt(2.f), 1e-4f)); + // The extra rib length runs along the diagonal, so it shows as its projection on each axis. + const float plain = WipeTower::rib_footprint_side(30.f, 30.f, 8.f, 0.f, 5.f); + CHECK_THAT(plain, WithinAbs(30.f + 8.f / std::sqrt(2.f), 1e-4f)); + CHECK_THAT(WipeTower::rib_footprint_side(30.f, 30.f, 8.f, 4.f, 5.f) - plain, WithinAbs(4.f / std::sqrt(2.f), 1e-4f)); + // A negative extra length cannot pull the ribs inside the diagonal. + CHECK_THAT(WipeTower::rib_footprint_side(30.f, 30.f, 8.f, -4.f, 5.f), WithinAbs(plain, 1e-4f)); + CHECK_THAT(WipeTower::rib_footprint_side(0.f, 30.f, 8.f, 0.f, 5.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("Brim width estimate matches each generator's loop quantization", "[WipeTower]") { + // 3 mm configured, 0.4 nozzle, 0.2 first layer: 0.4571 mm spacing, 7 loops. WipeTower2 + // prints and reports the 7 loops; WipeTower reports half a spacing of line width on top. + const float spacing = 0.5f - 0.2f * float(1. - M_PI_4); + CHECK_THAT(WipeTower::estimate_brim_real_width(3.f, 0.4f, 0.2f, true), WithinAbs(7.f * spacing, 1e-4f)); + CHECK_THAT(WipeTower::estimate_brim_real_width(3.f, 0.4f, 0.2f, false), WithinAbs(7.5f * spacing, 1e-4f)); + CHECK_THAT(WipeTower::estimate_brim_real_width(0.f, 0.4f, 0.2f, true), WithinAbs(0.f, 1e-6f)); +} diff --git a/tests/libslic3r/test_wipe_tower_estimate.cpp b/tests/libslic3r/test_wipe_tower_estimate.cpp new file mode 100644 index 0000000000..20644200f3 --- /dev/null +++ b/tests/libslic3r/test_wipe_tower_estimate.cpp @@ -0,0 +1,351 @@ +#include + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTower2.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" +#include "libslic3r/PrintConfig.hpp" + +#include +#include +#include + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +// Rectangle wall, one nozzle, 100 mm3 prime volume on a 50 mm wide tower at 0.2 mm layers: one +// purge is 10 mm of depth. The flush matrix is off here; the shipped-default case covers it. +// Built as PresetBundle::full_config builds the GUI's: apply() creates each enum as a +// ConfigOptionEnumGeneric, where full_print_config() would clone the static defaults' +// ConfigOptionEnum. The estimate has to read either. +static DynamicPrintConfig preset_shaped_defaults() +{ + DynamicPrintConfig config; + config.apply(FullPrintConfig::defaults()); + return config; +} + +static DynamicPrintConfig make_config(const char *wall_type = "rectangle") +{ + DynamicPrintConfig config = preset_shaped_defaults(); + config.set_key_value("prime_tower_width", new ConfigOptionFloat(50.)); + config.set_key_value("prime_volume", new ConfigOptionFloat(100.)); + config.set_key_value("filament_prime_volume", new ConfigOptionFloats({100.})); + config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({0})); + config.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(100.)); + config.set_key_value("wipe_tower_extra_spacing", new ConfigOptionPercent(100.)); + config.set_key_value("prime_tower_brim_width", new ConfigOptionFloat(3.)); + config.set_deserialize_strict("wipe_tower_wall_type", wall_type); + config.set_key_value("wipe_tower_rib_width", new ConfigOptionFloat(8.)); + config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(0.)); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4})); + config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.2)); + config.set_deserialize_strict("timelapse_type", "0"); + config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + config.set_key_value("raft_layers", new ConfigOptionInt(0)); + config.set_key_value("purge_in_prime_tower", new ConfigOptionBool(false)); + config.set_key_value("single_extruder_multi_material", new ConfigOptionBool(false)); + return config; +} + +static std::vector filaments(size_t count) +{ + std::vector ids(count); + std::iota(ids.begin(), ids.end(), 0u); + return ids; +} + +// The first `count` filaments on the given planner; Type2 unless a case says otherwise. +static WipeTowerFootprint estimate(const ConfigBase &config, size_t count, double layer_height, double height, WipeTowerType type = WipeTowerType::Type2) +{ + return estimate_wipe_tower_footprint(config, type, filaments(count), layer_height, height); +} + +// What both planners print for a 3 mm brim at 0.4 nozzle and 0.2 first layer (0.4571 mm loops). +static double printed_brim(double configured, WipeTowerType type) +{ + return WipeTower::estimate_brim_real_width(float(configured), 0.4f, 0.2f, type == WipeTowerType::Type2); +} + +TEST_CASE("A rectangle wall tower is sized by the purge volume", "[WipeTowerEstimate]") { + const DynamicPrintConfig config = make_config(); + // Three filaments purge twice per layer; a 5 mm object keeps the stability floor at 5 mm. + const WipeTowerFootprint fp = estimate(config, 3, 0.2, 5.); + CHECK_THAT(fp.width, WithinAbs(50., 1e-9)); + CHECK_THAT(fp.depth, WithinAbs(20., 1e-9)); + CHECK_THAT(fp.height, WithinAbs(5., 1e-9)); + CHECK_THAT(fp.brim_width, WithinAbs(printed_brim(3., WipeTowerType::Type2), 1e-6)); + // Thinner layers need more depth for the same volume. + CHECK_THAT(estimate(config, 3, 0.1, 5.).depth, WithinAbs(40., 1e-9)); +} + +TEST_CASE("Each planner spaces its purge lines by its own option", "[WipeTowerEstimate]") { + // Type2 reads wipe_tower_extra_spacing and Type1 prime_tower_infill_gap; neither sees the + // other's key. Type2's extra flow cancels out of its depth. + DynamicPrintConfig config = make_config(); + config.set_key_value("wipe_tower_extra_flow", new ConfigOptionPercent(250.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs(20., 1e-9)); + config.set_key_value("wipe_tower_extra_spacing", new ConfigOptionPercent(150.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs(30., 1e-9)); + const double type1_spaced = estimate(config, 3, 0.2, 5., WipeTowerType::Type1).depth; + config.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(150.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs(30., 1e-9)); + // Type1 stacks whole lines behind one 0.5 mm perimeter width, so only the stack scales. + CHECK_THAT(estimate(config, 3, 0.2, 5., WipeTowerType::Type1).depth - 0.5, WithinAbs(1.5 * (type1_spaced - 0.5), 1e-6)); +} + +TEST_CASE("Type1 sizes the tower from each filament's own prime volume", "[WipeTowerEstimate]") { + // The Bambu P1S project of the WipeTower cases: 30 and 45 mm3 in two categories on a 35 mm + // tower at 0.21 mm, 150 % gap, is 18.5 mm of stacked blocks (11 mm sharing one category). + DynamicPrintConfig config = make_config(); + config.set_key_value("prime_tower_width", new ConfigOptionFloat(35.)); + config.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(150.)); + config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.21)); + config.set_key_value("filament_prime_volume", new ConfigOptionFloats({30., 45.})); + config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({100, 0})); + const std::vector purges{{30.f, 100}, {45.f, 0}}; + const double blocks = WipeTower::estimate_tower_blocks_depth(purges, 35.f, 0.21f, 0.4f, 1.5f); + REQUIRE_THAT(blocks, WithinAbs(18.5, 0.01)); + CHECK_THAT(estimate(config, 2, 0.21, 5., WipeTowerType::Type1).depth, WithinAbs(blocks, 1e-4)); + // The ids pick the volumes, so their order does not matter and a lone filament has no purge. + CHECK_THAT(estimate_wipe_tower_footprint(config, WipeTowerType::Type1, {1, 0}, 0.21, 5.).depth, WithinAbs(blocks, 1e-4)); + CHECK_THAT(estimate(config, 1, 0.21, 5., WipeTowerType::Type1).depth, WithinAbs(0., 1e-9)); + config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({0, 0})); + CHECK_THAT(estimate(config, 2, 0.21, 5., WipeTowerType::Type1).depth, WithinAbs(11., 0.01)); + // A rib wall squares the same stack. + config.set_deserialize_strict("wipe_tower_wall_type", "rib"); + const WipeTowerFootprint rib = estimate(config, 2, 0.21, 5., WipeTowerType::Type1); + CHECK_THAT(rib.width, WithinAbs(rib.depth, 1e-9)); + CHECK_THAT(rib.depth, WithinAbs(WipeTower::estimate_rib_tower_bbox_side({{30.f, 0}, {45.f, 0}}, 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 5.f), 1e-4)); +} + +TEST_CASE("A second nozzle adds the ramming of one nozzle change per layer", "[WipeTowerEstimate]") { + // Two filaments on two nozzles: the tool order crosses once per layer, and Type1 rams 10 mm + // of filament as three 1.0 mm nozzle-change lines (see the WipeTower case). + DynamicPrintConfig config = make_config(); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); + config.set_key_value("filament_change_length", new ConfigOptionFloats({10., 10.})); + config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75})); + config.set_key_value("filament_map", new ConfigOptionInts({1, 1})); + const double same_nozzle = estimate(config, 2, 0.2, 5., WipeTowerType::Type1).depth; + config.set_key_value("filament_map", new ConfigOptionInts({1, 2})); + CHECK_THAT(estimate(config, 2, 0.2, 5., WipeTowerType::Type1).depth - same_nozzle, WithinAbs(3., 1e-4)); +} + +TEST_CASE("The tower is sized for the first layer when it is the thinnest", "[WipeTowerEstimate]") { + // Both planners reserve the worst layer: a 0.28 mm print with a 0.2 mm first layer needs + // the 0.2 mm depth, while a thicker first layer changes nothing. + DynamicPrintConfig config = make_config(); + const double at_thinnest = estimate(config, 3, 0.2, 5.).depth; + CHECK_THAT(estimate(config, 3, 0.28, 5.).depth, WithinAbs(at_thinnest, 1e-9)); + config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.3)); + CHECK(estimate(config, 3, 0.28, 5.).depth < at_thinnest); +} + +TEST_CASE("Object height sets the stability floor and the auto brim", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config(); + // Two filaments purge once: 10 mm, lifted to the 20 mm floor of a 100 mm tower. + CHECK_THAT(estimate(config, 2, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + config.set_key_value("prime_tower_brim_width", new ConfigOptionFloat(-1.)); + const double auto_brim = WipeTower::get_auto_brim_by_height(50.f); + CHECK_THAT(estimate(config, 2, 0.2, 50.).brim_width, WithinAbs(printed_brim(auto_brim, WipeTowerType::Type2), 1e-6)); + CHECK_THAT(estimate(config, 2, 0.2, 50., WipeTowerType::Type1).brim_width, WithinAbs(printed_brim(auto_brim, WipeTowerType::Type1), 1e-6)); +} + +TEST_CASE("A single filament only gets a tower when one is printed anyway", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config(); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 0, 0.2, 100.).width, WithinAbs(0., 1e-9)); + + // Wrapping detection prints a tower on the first layers whatever the filament count: the + // Type1 planner's fixed 10 mm, the stability floor otherwise. + config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true)); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 100., WipeTowerType::Type1).depth, WithinAbs(WipeTower::get_wrapping_detection_depth(), 1e-9)); + config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + + // A raft is not one of them: normalize_fdm_2 clears enable_prime_tower for a plate that + // purges one filament unless smooth timelapse or wrapping detection is on, so a raft + // alone leaves no tower to reserve for. + config.set_key_value("raft_layers", new ConfigOptionInt(3)); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(0., 1e-9)); + config.set_key_value("raft_layers", new ConfigOptionInt(0)); + + config.set_deserialize_strict("timelapse_type", "1"); + // A tower printed with no tool change is exactly the planner's idle depth: there is + // nothing to purge, and WipeTower2 sizes it at the stability floor. + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 5.).depth, WithinAbs(WipeTower::get_limit_depth_by_height(5.f), 1e-9)); +} + +TEST_CASE("A tool change reserves a tower even with nothing to purge", "[WipeTowerEstimate]") { + // The purge volumes are configurable down to zero, but the tool changes are still printed + // on the tower and both planners still floor it - so the estimate has to floor it too. + // Type1 plans per filament and already reserves one; Type2 has only the volume to go on. + const double height = GENERATE(5., 100.); + const float floor = WipeTower::get_limit_depth_by_height(float(height)); + const char *wall = GENERATE("rectangle", "rib"); + DynamicPrintConfig config = make_config(wall); + config.set_key_value("prime_volume", new ConfigOptionFloat(0.)); + config.set_key_value("filament_prime_volume", new ConfigOptionFloats({0.})); + + CHECK(estimate(config, 3, 0.2, height, WipeTowerType::Type2).depth >= floor); + CHECK(estimate(config, 3, 0.2, height, WipeTowerType::Type1).depth >= floor); + // Still nothing for a lone filament with no other reason. + CHECK_THAT(estimate(config, 1, 0.2, height, WipeTowerType::Type2).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, height, WipeTowerType::Type1).depth, WithinAbs(0., 1e-9)); +} + +TEST_CASE("Both wall types agree on whether there is a tower at all", "[WipeTowerEstimate]") { + // A wall type may only change the shape of the tower, never whether one is reserved: + // reporting no tower for one that is built collapses the validation hull to a point. + const double height = GENERATE(5., 100.); + DynamicPrintConfig rect = make_config(); + DynamicPrintConfig rib = make_config("rib"); + + // No tool change and nothing else that prints a tower - neither wall type reserves one. + CHECK_THAT(estimate(rect, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(rib, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + + // Not even on a dual-nozzle printer, where a lone filament still needs no purge. + rect.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); + rib.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); + CHECK_THAT(estimate(rect, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(rib, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + + // With a tool change both reserve one, and both respect the stability floor. + CHECK(estimate(rect, 2, 0.2, height).depth >= WipeTower::get_limit_depth_by_height(float(height))); + CHECK(estimate(rib, 2, 0.2, height).depth >= WipeTower::get_limit_depth_by_height(float(height))); +} + +TEST_CASE("A rib wall squares the tower and caps the rib width", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config("rib"); + // sqrt(200 / 0.2) = 31.62 mm square, plus the 8 mm rib bulge along the diagonal. + const double body = std::sqrt(1000.); + WipeTowerFootprint fp = estimate(config, 3, 0.2, 5.); + CHECK_THAT(fp.depth, WithinAbs(8. / std::sqrt(2.) + body, 1e-5)); + CHECK_THAT(fp.width, WithinAbs(fp.depth, 1e-9)); + // The extra rib length runs along the diagonal and grows the footprint by its projection. + config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(4.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs((8. + 4.) / std::sqrt(2.) + body, 1e-5)); + // A tiny tower caps the rib width at half its depth: 5 mm body, 2.5 mm rib. + config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(0.)); + config.set_key_value("prime_volume", new ConfigOptionFloat(5.)); + CHECK_THAT(estimate(config, 2, 0.2, 5.).depth, WithinAbs(2.5 / std::sqrt(2.) + 5., 1e-5)); +} + +TEST_CASE("Every wall and tower type is read the same from a preset and a static config", "[WipeTowerEstimate]") { + // The GUI, arrange and the CLI pass a DynamicPrintConfig whose enums are + // ConfigOptionEnumGeneric; Print passes a static config whose enums are ConfigOptionEnum. + // Both the wall type and the planner selection are read by value, so both give the same shape. + const char *wall_type = GENERATE("rectangle", "cone", "rib"); + const char *tower_type = GENERATE("type1", "type2"); + DynamicPrintConfig preset = make_config(wall_type); + preset.set_deserialize_strict("wipe_tower_type", tower_type); + REQUIRE(dynamic_cast(preset.option("wipe_tower_wall_type")) != nullptr); + + FullPrintConfig static_config; + static_config.apply(preset, true); + REQUIRE(static_config.wipe_tower_wall_type.serialize() == wall_type); + REQUIRE(static_config.wipe_tower_type.serialize() == tower_type); + + const WipeTowerType type = resolve_wipe_tower_type(preset); + CHECK(type == (std::string(tower_type) == "type1" ? WipeTowerType::Type1 : WipeTowerType::Type2)); + CHECK(resolve_wipe_tower_type(static_config) == type); + + // Three filaments purge twice per layer on a 5 mm object. + const WipeTowerFootprint fp = estimate(preset, 3, 0.2, 5., type); + const WipeTowerFootprint from_static = estimate(static_config, 3, 0.2, 5., type); + CHECK(fp.depth > 0.); + if (std::string(wall_type) == "rib") + CHECK_THAT(fp.width, WithinAbs(fp.depth, 1e-9)); + else + CHECK_THAT(fp.width, WithinAbs(50., 1e-9)); + CHECK_THAT(from_static.width, WithinAbs(fp.width, 1e-9)); + CHECK_THAT(from_static.depth, WithinAbs(fp.depth, 1e-9)); + CHECK_THAT(from_static.brim_width, WithinAbs(fp.brim_width, 1e-9)); + + // Smooth timelapse is the other enum the estimate reads: a lone filament gets a tower + // through both storages too. + preset.set_deserialize_strict("timelapse_type", "1"); + static_config.apply(preset, true); + CHECK(estimate(preset, 1, 0.2, 5., type).depth > 0.); + CHECK(estimate(static_config, 1, 0.2, 5., type).depth > 0.); +} + +TEST_CASE("The first-layer outline bulges only for a Type2 cone wall", "[WipeTowerEstimate]") { + // Read off a preset-shaped config, whose enums are ConfigOptionEnumGeneric: a cast to + // ConfigOptionEnum sees no wall type there and would never find the cone. + DynamicPrintConfig config = make_config("cone"); + config.set_key_value("wipe_tower_cone_angle", new ConfigOptionFloat(25.)); + REQUIRE(dynamic_cast(config.option("wipe_tower_wall_type")) != nullptr); + const Polygon box = Polygon::new_scale({{0., 0.}, {35., 0.}, {35., 20.}, {0., 20.}}); + auto is_box = [&box](const Polygon &outline) { return diff(Polygons{outline}, Polygons{box}).empty(); }; + + // A 25-degree cone on a 100 mm tower has a 22 mm base radius, past the 10 mm half-depth. + const Polygon cone = estimate_wipe_tower_first_layer_outline(config, WipeTowerType::Type2, 35., 20., 100.); + CHECK(unscaled(get_extents(cone).max.y()) > 20. + 1.); + CHECK(diff(Polygons{box}, Polygons{cone}).empty()); + // Type1 ignores the cone option, and the other wall types have no cone. + CHECK(is_box(estimate_wipe_tower_first_layer_outline(config, WipeTowerType::Type1, 35., 20., 100.))); + for (const char *wall_type : {"rectangle", "rib"}) { + config.set_deserialize_strict("wipe_tower_wall_type", wall_type); + CHECK(is_box(estimate_wipe_tower_first_layer_outline(config, WipeTowerType::Type2, 35., 20., 100.))); + } + // The static config Print holds gives the same outline. + config.set_deserialize_strict("wipe_tower_wall_type", "cone"); + FullPrintConfig static_config; + static_config.apply(config, true); + const Polygon from_static = estimate_wipe_tower_first_layer_outline(static_config, WipeTowerType::Type2, 35., 20., 100.); + CHECK(from_static.points == cone.points); +} + +TEST_CASE("A Bambu Lab printer always gets the Type1 planner", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config(); + config.set_deserialize_strict("wipe_tower_type", "type2"); + config.set_key_value("printer_model", new ConfigOptionString("Bambu Lab X1 Carbon")); + CHECK(resolve_wipe_tower_type(config) == WipeTowerType::Type1); + config.set_key_value("printer_model", new ConfigOptionString("Voron 2.4")); + CHECK(resolve_wipe_tower_type(config) == WipeTowerType::Type2); + config.erase("wipe_tower_type"); + CHECK(resolve_wipe_tower_type(config) == WipeTowerType::Type2); +} + +TEST_CASE("A dual nozzle purges every filament plus the filament change", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config(); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); + config.set_key_value("filament_change_length", new ConfigOptionFloats({10., 10.})); + config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75})); + // Two purges of 100 mm3 plus one 10 mm filament change: (200 + 10 * pi * 1.75^2 / 4) / (0.2 * 50). + const double change_volume = 10. * PI * 1.75 * 1.75 / 4.; + CHECK_THAT(estimate(config, 2, 0.2, 5.).depth, WithinAbs((200. + change_volume) / 10., 1e-9)); +} + +TEST_CASE("The shipped defaults size the tower from the flush matrix", "[WipeTowerEstimate]") { + // Both keys default to true, so the shipped configuration purges the flush volumes rather + // than the prime volume, with no infill gap on top - the flush volumes already hold it. + DynamicPrintConfig config = preset_shaped_defaults(); + REQUIRE(config.opt_bool("purge_in_prime_tower")); + REQUIRE(config.opt_bool("single_extruder_multi_material")); + config.set_key_value("prime_tower_width", new ConfigOptionFloat(50.)); + config.set_deserialize_strict("wipe_tower_wall_type", "rectangle"); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4})); + + const double flush_volume = WipeTower2::estimate_semm_flush_volume(config, 2); + const double expected = std::max(double(WipeTower::get_limit_depth_by_height(5.f)), flush_volume / (0.2 * 50.)); + CHECK_THAT(estimate(config, 2, 0.2, 5.).depth, WithinAbs(expected, 1e-6)); +} + +TEST_CASE("A config missing a tower key falls back to that key's default", "[WipeTowerEstimate]") { + // The signature takes any ConfigBase: an absent key must read as its declared default. + const DynamicPrintConfig full = make_config(); + DynamicPrintConfig partial = full; + partial.erase("wipe_tower_extra_spacing"); + REQUIRE(partial.option("wipe_tower_extra_spacing") == nullptr); + + DynamicPrintConfig defaulted = full; + defaulted.set_key_value("wipe_tower_extra_spacing", + print_config_def.get("wipe_tower_extra_spacing")->default_value->clone()); + CHECK_THAT(estimate(partial, 3, 0.2, 5.).depth, WithinAbs(estimate(defaulted, 3, 0.2, 5.).depth, 1e-9)); +}