diff --git a/src/libslic3r/IMEXHelpers.cpp b/src/libslic3r/IMEXHelpers.cpp index db986ae114..b502d895b8 100644 --- a/src/libslic3r/IMEXHelpers.cpp +++ b/src/libslic3r/IMEXHelpers.cpp @@ -7,6 +7,7 @@ #include #include +#include "libslic3r/ClipperUtils.hpp" #include "libslic3r/PresetBundle.hpp" namespace Slic3r { @@ -411,4 +412,15 @@ Vec2d compute_imex_slice_offset(bool firmware_managed, return primary_zone_box->center(); } +bool imex_hull_violates_zones(const std::vector& zones, const Polygon& hull) +{ + if (hull.points.empty()) + return false; + for (const auto& box : zones) { + if (!intersection({box.polygon(true)}, {hull}).empty()) + return true; + } + return false; +} + } // namespace Slic3r diff --git a/src/libslic3r/IMEXHelpers.hpp b/src/libslic3r/IMEXHelpers.hpp index f51656bc30..21b3b26bb2 100644 --- a/src/libslic3r/IMEXHelpers.hpp +++ b/src/libslic3r/IMEXHelpers.hpp @@ -10,6 +10,7 @@ #include "libslic3r/BoundingBox.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Point.hpp" +#include "libslic3r/Polygon.hpp" namespace Slic3r { @@ -319,4 +320,11 @@ Vec2d compute_imex_slice_offset(bool firmware_managed, const std::string& parallel_mode, const std::optional& primary_zone_box); +// True if `hull` (scaled Clipper coords) overlaps any of `zones` (unscaled mm) — +// the one definition of "overlap" shared by the object and prime-tower placement +// checks. Area-based: Clipper yields no result for shapes sharing only an edge, so +// a hull flush against a zone boundary is NOT reported, it has to cross. That +// matches the long-standing object behaviour. An empty hull never violates. +bool imex_hull_violates_zones(const std::vector& zones, const Polygon& hull); + } // namespace Slic3r diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 22acc1d462..4cd996f01b 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1346,30 +1346,109 @@ void PartPlate::update_imex_ghost_transforms( } } -bool PartPlate::has_imex_placement_violations() +// True if `hull` (scaled, plate-list coordinates) overlaps a secondary zone or a +// carriage danger strip. Shared by the object and prime-tower checks so both use one +// definition of "overlap" — see imex_hull_violates_zones() for those semantics. +bool PartPlate::imex_hull_violates(const Polygon& hull) const +{ + return imex_hull_violates_zones(m_imex_secondary_zone_boxes, hull) + || imex_hull_violates_zones(m_imex_collision_zones, hull); +} + +// The prime tower is not a ModelObject, so it is invisible to the instance loop +// below. Reuse the footprint the scene already draws — GLCanvas3D sizes the tower +// preview from estimate_wipe_tower_size(), and estimate_wipe_tower_polygon() wraps +// that same estimate with the brim, so validating it matches what the user sees and +// drags. Returns an empty polygon when the plate has no tower. +Polygon PartPlate::imex_wipe_tower_hull() const +{ + // m_model is dereferenced by estimate_wipe_tower_size (it walks m_model->objects + // for the tower height), so guard it here — the instance loop below only reaches + // it behind valid_instance(), but this tail call runs even on an empty plate. + if (!m_plater || !m_print || !m_model) + return Polygon(); + PresetBundle* preset_bundle = wxGetApp().preset_bundle; + if (!preset_bundle) + return Polygon(); + + const DynamicPrintConfig& print_cfg = preset_bundle->prints.get_edited_preset().config; + const ConfigOptionBool* enable_opt = print_cfg.option("enable_prime_tower"); + if (!enable_opt || !enable_opt->value) + return Polygon(); + + // Close to, but not identical to, the reachability test the scene uses before it + // draws a tower (GLCanvas3D): a tower exists only for 2+ filaments, unless smooth + // timelapse or wrapping detection forces one. The scene additionally suppresses the + // tower for ByObject sequences and in gcode-preview mode; both omissions here fail + // conservatively (we validate a tower the scene may not draw, never the reverse). + // Without this gate a single-filament plate on a 2-extruder printer still yields a + // non-zero estimate (the extruder_count == 2 branch of estimate_wipe_tower_size + // multiplies by the filament count rather than count - 1) and we would block on a + // tower that is never printed. + auto timelapse_type = print_cfg.option>("timelapse_type"); + bool need_wipe_tower = timelapse_type ? (timelapse_type->value == TimelapseType::tlSmooth) : false; + // enable_wrapping_detection is a PRINT option, not a printer one. Reading it from + // the printer preset returns nullptr and silently leaves this false, which would + // skip validation for a tower the user can see and drag — a false negative on + // exactly the collision this check exists to catch. + if (auto wrapping_opt = print_cfg.option("enable_wrapping_detection")) + need_wipe_tower |= wrapping_opt->value; + const int plate_extruder_size = (int) get_extruders(true).size(); + if (!need_wipe_tower && plate_extruder_size < 2) + return Polygon(); + + Vec3d wt_pos, wt_size; + const int nozzle_nums = preset_bundle->get_printer_extruder_count(); + // full_config() rather than the print preset: wipe_tower_x/y are PROJECT options + // (PresetBundle s_project_options), and estimate_wipe_tower_polygon dereferences + // them unchecked. Passing a print-only config null-derefs. apply_extruder=false + // skips three update_values_to_printer_extruders() passes we do not need — none of + // the keys the estimate reads are variant-keyed. + // plate_extruder_size is passed explicitly so the estimate does not walk every + // object and volume on the plate a second time to recompute what we just counted. + arrangement::ArrangePolygon ap = estimate_wipe_tower_polygon(preset_bundle->full_config(false), m_plate_index, wt_pos, wt_size, nozzle_nums, plate_extruder_size); + if (wt_size(0) <= 0. || wt_size(1) <= 0. || ap.poly.contour.points.empty()) + return Polygon(); + + // NOTE: only the tower box and its brim are validated, and only as estimated. Three + // known gaps, all of them pre-slice limits rather than oversights: + // - estimate_wipe_tower_size is a heuristic (hard-coded 0.08 layer height, closed- + // form depth, no per-layer purge volumes), so the sliced tower can exceed it. The + // true footprint is Print::first_layer_wipe_tower_corners(), which only exists + // after slicing. + // - A Cone-walled tower prints a stabilization cone past the box. Sizing it needs + // the real tower height (m_wipe_tower_data.height), likewise post-slice; + // approximating it from object height over-reserved space and blocked legal + // placements. The default rib wall prints no cone. + // - wipe_tower_rotation_angle is ignored (estimate_wipe_tower_polygon has it + // commented out), so a rotated tower's true footprint differs from this hull. + // See also the unvalidated object brim / skirt / support gaps, which affect every + // print. + Polygon hull = ap.poly.contour; + + // estimate_wipe_tower_polygon() works in plate-local mm; the zones and the + // instance hulls it is compared against are in plate-list coordinates. + hull.translate(Point(scaled(m_origin.x()), scaled(m_origin.y()))); + return hull; +} + +PartPlate::ImexPlacementViolation PartPlate::imex_placement_violation() { ensure_imex_zones(); if (m_imex_secondary_zone_boxes.empty() && m_imex_collision_zones.empty()) - return false; + return ImexPlacementViolation::None; for (const auto& pr : obj_to_instance_set) { int obj_id = pr.first; int instance_id = pr.second; if (!valid_instance(obj_id, instance_id)) continue; ModelInstance* instance = m_model->objects[obj_id]->instances[instance_id]; - Polygon hull = instance->convex_hull_2d(); - if (hull.points.empty()) - continue; - for (const auto& box : m_imex_secondary_zone_boxes) { - if (!intersection({box.polygon(true)}, {hull}).empty()) - return true; - } - for (const auto& strip : m_imex_collision_zones) { - if (!intersection({strip.polygon(true)}, {hull}).empty()) - return true; - } + if (imex_hull_violates(instance->convex_hull_2d())) + return ImexPlacementViolation::Object; } - return false; + if (imex_hull_violates(imex_wipe_tower_hull())) + return ImexPlacementViolation::PrimeTower; + return ImexPlacementViolation::None; } bool PartPlate::has_imex_multimaterial_conflict() const @@ -3792,27 +3871,11 @@ bool PartPlate::check_outside(int obj_id, int instance_id, BoundingBoxf3* boundi // Block objects that overlap secondary (copy/mirror) zones or the carriage // danger strip at the primary zone boundary. Reuses the existing // outside=true → instance_outside_set → update_states() → blocks slicing path. - if (!outside && (!m_imex_secondary_zone_boxes.empty() || !m_imex_collision_zones.empty())) { - Polygon obj_hull = instance->convex_hull_2d(); // scaled Clipper coords - // 1. Object must not touch any secondary zone. - for (const auto& box : m_imex_secondary_zone_boxes) { - Polygon p = box.polygon(true); - if (!intersection({ p }, { obj_hull }).empty()) { - outside = true; - break; - } - } - // 2. Object must not enter the carriage danger strip inside the primary zone. - if (!outside) { - for (const auto& strip : m_imex_collision_zones) { - Polygon strip_poly = strip.polygon(true); - if (!intersection({ strip_poly }, { obj_hull }).empty()) { - outside = true; - break; - } - } - } - } + // Reuse the hull computed above: ModelInstance::convex_hull_2d() is NOT cached + // (Model.cpp has the validity guard commented out), so recomputing it here would + // cost every user a per-instance hull rebuild, including on non-IMEX printers. + if (!outside && imex_hull_violates(hull)) + outside = true; return outside; } diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index acca6f905e..87f3dc2811 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -526,8 +526,19 @@ public: { return m_ready_for_slice && !m_apply_invalid; } - // Returns true if any instance on this plate overlaps an IDEX/IQEX secondary or collision zone. - bool has_imex_placement_violations(); + // What (if anything) on this plate overlaps an IDEX/IQEX secondary or collision zone. + // Reported as a cause rather than a bool so the caller can name the offender; the + // message text lives at the Plater layer where it can be translated. + enum class ImexPlacementViolation { None, Object, PrimeTower }; + ImexPlacementViolation imex_placement_violation(); + // True if the given hull (scaled, plate-list coords) overlaps a secondary zone or + // collision strip. Shared by the object and prime-tower checks; overlap is + // area-based, so a hull flush against a boundary is legal — see + // imex_hull_violates_zones() in libslic3r/IMEXHelpers.hpp. + bool imex_hull_violates(const Polygon& hull) const; + // Footprint of the prime tower as the scene draws it, in scaled plate-list coords. + // Empty when the plate has no tower. + Polygon imex_wipe_tower_hull() const; // Returns true when IMEX parallel mode is active (non-primary) AND the plate // has objects assigned to more than one filament — a combination that warrants caution. bool has_imex_multimaterial_conflict() const; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index cd2ff14476..766df32891 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -8982,13 +8982,28 @@ unsigned int Plater::priv::update_background_process(bool force_validation, bool q->post_process_string_object_exception(err); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": validate err=%1%, warnings=%2%")%err.string%warnings.size(); - // IDEX/IQEX placement check: if objects overlap secondary zones, treat it as a + // IDEX/IQEX placement check: if anything overlaps a reserved area, treat it as a // validation error so the standard pathway handles button state, notifications, - // and auto-slice blocking consistently. + // and auto-slice blocking consistently. The message names both kinds of reserved + // area because the check covers secondary zones AND the carriage clearance strips, + // and the strips sit INSIDE the primary zone — saying "secondary zone" alone sends + // the user looking in the wrong half of the bed. if (err.string.empty()) { PartPlate* imex_plate = partplate_list.get_curr_plate(); - if (imex_plate && imex_plate->has_imex_placement_violations()) - err.string = _u8L("Cannot slice: objects are in secondary zones reserved for IDEX/IQEX parallel printing."); + if (imex_plate) { + switch (imex_plate->imex_placement_violation()) { + case PartPlate::ImexPlacementViolation::Object: + err.string = _u8L("Cannot slice: an object overlaps an area reserved for IMEX parallel printing " + "(a secondary zone, or a carriage clearance strip inside the primary zone)."); + break; + case PartPlate::ImexPlacementViolation::PrimeTower: + err.string = _u8L("Cannot slice: the prime tower overlaps an area reserved for IMEX parallel printing " + "(a secondary zone, or a carriage clearance strip inside the primary zone)."); + break; + case PartPlate::ImexPlacementViolation::None: + break; + } + } } if (err.string.empty()) { diff --git a/tests/libslic3r/test_imex_helpers.cpp b/tests/libslic3r/test_imex_helpers.cpp index 7b3ca74449..e1744010e8 100644 --- a/tests/libslic3r/test_imex_helpers.cpp +++ b/tests/libslic3r/test_imex_helpers.cpp @@ -798,3 +798,75 @@ TEST_CASE("compute_imex_slice_offset - center value tracks zone box position", " REQUIRE_THAT(z.y(), WithinAbs(0.0, 1e-9)); } + +// --------------------------------------------------------------------------- +// imex_hull_violates_zones +// +// The single predicate behind both the object and prime-tower placement checks. +// Zones are unscaled mm (BoundingBoxf3); hulls are scaled Clipper coords, which is +// what ModelInstance::convex_hull_2d() and PartPlate::imex_wipe_tower_hull() return. +// --------------------------------------------------------------------------- + +// Axis-aligned rectangle in SCALED coords, from unscaled mm corners. +static Polygon scaled_rect(double x0, double y0, double x1, double y1) { + Polygon p; + p.points = {Point(scaled(x0), scaled(y0)), Point(scaled(x1), scaled(y0)), + Point(scaled(x1), scaled(y1)), Point(scaled(x0), scaled(y1))}; + return p; +} + +// Zone in UNSCALED mm, as PartPlate stores them. +static BoundingBoxf3 zone(double x0, double y0, double x1, double y1) { + return BoundingBoxf3(Vec3d(x0, y0, 0.0), Vec3d(x1, y1, 1.0)); +} + +TEST_CASE("imex_hull_violates_zones - empty hull never violates", "[IMEX]") { + // The tower helper returns an empty Polygon for "no tower"; that must not block. + std::vector zones{zone(0, 0, 100, 100)}; + REQUIRE_FALSE(imex_hull_violates_zones(zones, Polygon())); +} + +TEST_CASE("imex_hull_violates_zones - empty zone list never violates", "[IMEX]") { + // Non-IMEX printers have no zones at all and must be unaffected. + REQUIRE_FALSE(imex_hull_violates_zones({}, scaled_rect(10, 10, 20, 20))); +} + +TEST_CASE("imex_hull_violates_zones - hull fully inside a zone violates", "[IMEX]") { + std::vector zones{zone(0, 0, 100, 100)}; + REQUIRE(imex_hull_violates_zones(zones, scaled_rect(10, 10, 20, 20))); +} + +TEST_CASE("imex_hull_violates_zones - hull straddling a zone edge violates", "[IMEX]") { + // The dragged-tower case: partly in the primary zone, partly in the reserved one. + std::vector zones{zone(100, 0, 200, 100)}; + REQUIRE(imex_hull_violates_zones(zones, scaled_rect(90, 10, 110, 20))); +} + +TEST_CASE("imex_hull_violates_zones - hull enclosing a zone violates", "[IMEX]") { + // A small collision strip swallowed by a large hull still overlaps by area. + std::vector zones{zone(45, 45, 55, 55)}; + REQUIRE(imex_hull_violates_zones(zones, scaled_rect(0, 0, 100, 100))); +} + +TEST_CASE("imex_hull_violates_zones - disjoint hull does not violate", "[IMEX]") { + std::vector zones{zone(100, 0, 200, 100)}; + REQUIRE_FALSE(imex_hull_violates_zones(zones, scaled_rect(0, 0, 50, 50))); +} + +TEST_CASE("imex_hull_violates_zones - edge-flush hull does not violate", "[IMEX]") { + // Documented area-based semantics: Clipper returns nothing for shapes sharing only + // an edge, so a hull butted exactly against a zone boundary is legal. This matches + // the long-standing object behaviour and is what lets a tower sit flush against the + // primary-zone edge. Pinned because a switch to a touch-based test would silently + // start blocking placements users currently rely on. + std::vector zones{zone(100, 0, 200, 100)}; + REQUIRE_FALSE(imex_hull_violates_zones(zones, scaled_rect(0, 0, 100, 100))); +} + +TEST_CASE("imex_hull_violates_zones - violating any one of several zones is enough", "[IMEX]") { + std::vector zones{zone(0, 0, 10, 10), zone(20, 20, 30, 30), zone(40, 40, 50, 50)}; + // Overlaps only the third. + REQUIRE(imex_hull_violates_zones(zones, scaled_rect(45, 45, 60, 60))); + // Overlaps none of the three (sits in the gaps between them). + REQUIRE_FALSE(imex_hull_violates_zones(zones, scaled_rect(12, 12, 18, 18))); +}