diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 44607e53c1..16f08e6a56 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -325,6 +325,8 @@ set(lisbslic3r_sources Geometry/VoronoiUtils.cpp Geometry/VoronoiUtils.hpp Geometry/VoronoiVisualUtils.hpp + IMEXArrange.cpp + IMEXArrange.hpp IMEXHelpers.cpp IMEXHelpers.hpp IMEXZones.cpp diff --git a/src/libslic3r/IMEXArrange.cpp b/src/libslic3r/IMEXArrange.cpp new file mode 100644 index 0000000000..ff4007f98a --- /dev/null +++ b/src/libslic3r/IMEXArrange.cpp @@ -0,0 +1,220 @@ +#include "libslic3r/IMEXArrange.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include "libslic3r/Arrange.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/Config.hpp" +#include "libslic3r/IMEXHelpers.hpp" +#include "libslic3r/IMEXZones.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Polygon.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/libslic3r.h" + +namespace Slic3r { + +namespace { + +// The arranger's names for the keep-outs. finish() leaves the fences out when it arranges a +// plate inside its zone, where the zone itself is the bed shape. +constexpr const char* kFenceName = "IMEXOutsidePrimaryZone"; +constexpr const char* kStripName = "IMEXCollisionZone"; + +// Each plate computes its zones from where it sits, so the same zone differs in the last bits. +bool same_zone(const BoundingBoxf& a, const BoundingBoxf& b) +{ + return is_approx(a.min, b.min) && is_approx(a.max, b.max); +} + +BoundingBox inset(BoundingBox box, const Point& by) +{ + box.min += by; + box.max -= by; + return box; +} + +// The parts of `bed` outside `zone`: full-depth boxes left and right of it, and the pieces in +// front of and behind it. Empty pieces are left out. +std::vector boxes_around(const BoundingBox& bed, const BoundingBox& zone) +{ + std::vector out; + auto add = [&out](coord_t x0, coord_t y0, coord_t x1, coord_t y1) { + if (x1 > x0 && y1 > y0) + out.emplace_back(Point(x0, y0), Point(x1, y1)); + }; + const coord_t x0 = std::max(zone.min.x(), bed.min.x()); + const coord_t x1 = std::min(zone.max.x(), bed.max.x()); + add(bed.min.x(), bed.min.y(), zone.min.x(), bed.max.y()); + add(zone.max.x(), bed.min.y(), bed.max.x(), bed.max.y()); + add(x0, bed.min.y(), x1, zone.min.y()); + add(x0, zone.max.y(), x1, bed.max.y()); + return out; +} + +} // namespace + +std::optional imex_arrange_zones(const ImexZoneLayout& layout) +{ + if (!layout.primary_zone_box) + return std::nullopt; + ImexArrangeZones zones{*layout.primary_zone_box, {}}; + for (const BoundingBoxf3& strip : layout.collision_zones) + zones.collision_zones.emplace_back(Vec2d(strip.min.head<2>()), Vec2d(strip.max.head<2>())); + return zones; +} + +void ImexArrangeInput::read_config(const DynamicPrintConfig& full_config) +{ + // A plate the arrange adds has no mode of its own, so it takes the process preset's. + added_plate.reset(); + if (adds_plates) { + const auto* mode = full_config.option("imex_parallel_mode"); + added_plate = imex_arrange_zones(compute_imex_zone_layout(full_config, kImexPrimaryMode, mode ? mode->value : std::string(), + get_extents(full_config.opt("printable_area")->values))); + } + + // An estimate: auto brim picks its own width, which brim_width stands in for. + const auto* brim_type = full_config.option>("brim_type"); + brim = !brim_type || brim_type->value == btNoBrim || brim_type->value == btInnerOnly ? + 0. : + full_config.opt_float("brim_width") + full_config.opt_float("brim_object_gap"); +} + +ImexArranger::ImexArranger(const ImexArrangeInput& input, const arrangement::ArrangeParams& params, const Points& bed) + : m_bed(bed) + , m_bed_bb(bed) + , m_margin(scaled(std::max(0.f, params.bed_shrink_x)), scaled(std::max(0.f, params.bed_shrink_y))) + , m_adds_plates(input.adds_plates) +{ + for (size_t i = 0; i < input.beds.size(); ++i) + if (input.beds[i]) + m_beds.push_back({int(i), *input.beds[i]}); + + // The bed shape can be a zone only when every plate being arranged has it, and so does every + // plate the arrange may add. + const auto& first = input.beds.empty() ? std::nullopt : input.beds.front(); + if (first && + std::all_of(input.beds.begin(), input.beds.end(), + [&](const std::optional& b) { return b && same_zone(b->primary_zone, first->primary_zone); }) && + (!input.adds_plates || (input.added_plate && same_zone(input.added_plate->primary_zone, first->primary_zone)))) + m_shared_room = room_in(first->primary_zone); + + // The beds past the plates being arranged become new plates. + if (input.adds_plates && input.added_plate) + for (int bed_idx = int(input.beds.size()); bed_idx < MAX_NUM_PLATES; ++bed_idx) + m_beds.push_back({bed_idx, *input.added_plate}); + + // A zone narrower than its margins leaves no room, and its fences then fill the whole bed. + const Point strip_margin = m_margin + Point::Constant(scaled(input.brim)); + for (const Bed& b : m_beds) { + if (!m_shared_room) + for (const BoundingBox& box : boxes_around(m_bed_bb, inset(scaled(b.zones.primary_zone), m_margin))) + m_keep_outs.push_back({b.bed_idx, box, true}); + for (const BoundingBoxf& strip : b.zones.collision_zones) + m_keep_outs.push_back({b.bed_idx, inset(scaled(strip), -strip_margin), false}); + } +} + +std::optional ImexArranger::room_in(const BoundingBoxf& zone) const +{ + BoundingBox room = inset(scaled(zone), m_margin); + room.min = room.min.cwiseMax(m_bed_bb.min); + room.max = room.max.cwiseMin(m_bed_bb.max); + if (room.min.x() >= room.max.x() || room.min.y() >= room.max.y()) + return std::nullopt; + return room; +} + +Points ImexArranger::bed_shape() const { return m_shared_room ? m_shared_room->polygon().points : m_bed; } + +void ImexArranger::add_keep_outs(arrangement::ArrangePolygons& fixed) const +{ + for (const KeepOut& keep_out : m_keep_outs) { + arrangement::ArrangePolygon ap; + ap.poly.contour = keep_out.box.polygon(); + ap.is_virt_object = true; + ap.bed_idx = keep_out.bed_idx; + ap.height = 1; + ap.name = keep_out.fence ? kFenceName : kStripName; + fixed.push_back(std::move(ap)); + } +} + +bool ImexArranger::crosses_keep_out(const arrangement::ArrangePolygon& part, int bed_idx) const +{ + const Polygon hull = part.transformed_poly().contour; + return std::any_of(m_keep_outs.begin(), m_keep_outs.end(), [&](const KeepOut& keep_out) { + return keep_out.bed_idx == bed_idx && !intersection(hull, keep_out.box.polygon()).empty(); + }); +} + +void ImexArranger::finish(arrangement::ArrangePolygons& items, + const arrangement::ArrangePolygons& before, + const arrangement::ArrangePolygons& fixed, + const arrangement::ArrangeParams& params) const +{ + for (arrangement::ArrangePolygon& part : items) + if (crosses_keep_out(part, part.bed_idx)) + part.bed_idx = arrangement::UNARRANGED; + + // Only fenced plates need centering, and arranging one plate never fences. + if (m_shared_room || !m_adds_plates) + return; + + arrangement::ArrangeParams one_plate = params; + one_plate.progressind = {}; + for (const Bed& b : m_beds) { + const std::optional room = room_in(b.zones.primary_zone); + std::vector on_bed; + for (size_t i = 0; i < items.size(); ++i) + if (items[i].bed_idx == b.bed_idx) + on_bed.push_back(i); + if (on_bed.empty() || !room) + continue; + + arrangement::ArrangePolygons parts, bed_fixed; + // Bed 0, where the nester takes a part as placeable rather than as one that does not fit. + for (size_t i : on_bed) { + parts.push_back(before[i]); + parts.back().bed_idx = 0; + } + for (const arrangement::ArrangePolygon& ap : fixed) + if (ap.bed_idx == b.bed_idx && ap.name != kFenceName) { + bed_fixed.push_back(ap); + bed_fixed.back().bed_idx = 0; + } + arrangement::arrange(parts, bed_fixed, room->polygon().points, one_plate); + // A canceled pass leaves parts it never reached on bed 0 with their old ids. + if (one_plate.stopcondition && one_plate.stopcondition()) + return; + + // Keep the first layout when this one does not fit. + if (!std::all_of(parts.begin(), parts.end(), [&](const arrangement::ArrangePolygon& part) { + return part.bed_idx == 0 && !crosses_keep_out(part, b.bed_idx); + })) + continue; + + // By-object printing follows the packing order, which this pass numbered 0..n-1: carry + // it over onto the ids the first pass gave these parts. + std::vector ids; + for (size_t i : on_bed) + ids.push_back(items[i].itemid); + std::sort(ids.begin(), ids.end()); + for (size_t k = 0; k < on_bed.size(); ++k) { + items[on_bed[k]].translation = parts[k].translation; + items[on_bed[k]].rotation = parts[k].rotation; + items[on_bed[k]].itemid = ids[parts[k].itemid]; + } + } +} + +} // namespace Slic3r diff --git a/src/libslic3r/IMEXArrange.hpp b/src/libslic3r/IMEXArrange.hpp new file mode 100644 index 0000000000..c4fd3877c4 --- /dev/null +++ b/src/libslic3r/IMEXArrange.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include + +#include "libslic3r/Arrange.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/Point.hpp" + +namespace Slic3r { + +class DynamicPrintConfig; +struct ImexZoneLayout; + +// A plate's IDEX/IQEX zones as Arrange sees them, in plate-local millimeters. +struct ImexArrangeZones +{ + BoundingBoxf primary_zone; + std::vector collision_zones; +}; + +// The zones of `layout`, none when it has no primary zone, i.e. outside a parallel mode. +std::optional imex_arrange_zones(const ImexZoneLayout& layout); + +// What an arrange needs to know about the plates it packs into. +struct ImexArrangeInput +{ + // Per arranger bed, in order: the zones of the plate it packs into, none for a plate whose + // parts may go anywhere on the bed. + std::vector> beds; + // Whether the beds past `beds` become new plates, as when arranging every plate, and the + // zones such a plate gets. + bool adds_plates = false; + std::optional added_plate; + // Room a strip keeps for the brim: brim width plus its gap to the part, in mm. + double brim = 0.; + + // Sets `added_plate` and `brim` from the full config. Call after setting `adds_plates`. + void read_config(const DynamicPrintConfig& full_config); +}; + +// Keeps an arrange inside each IDEX/IQEX plate's primary zone and out of that zone's carriage +// collision strips. +// +// The arranger packs every bed into one bed shape. When every bed has the same primary zone, +// that shape becomes the zone, which keeps the parts centered in it. Otherwise each plate in a +// parallel mode fences off the rest of its bed with fixed items on its own bed, leaving the +// other plates the whole bed, and finish() arranges each fenced plate again inside its zone, +// since a fenced pile sits against the zone edge nearest the bed's center. +// +// A zone edge inside the bed gets the margin the bed's own edges get, since a copy printed from +// a part at the zone edge sits at the far edge of the bed. A strip also gets the brim, which +// would take the nozzle into it. +class ImexArranger +{ +public: + // `bed` is the arranger's bed shape, shrunk by params.bed_shrink_x/y. + ImexArranger(const ImexArrangeInput& input, const arrangement::ArrangeParams& params, const Points& bed); + + // Whether any bed has a zone. Nothing below has an effect otherwise. + bool active() const { return !m_beds.empty(); } + + // The bed shape to arrange in. + Points bed_shape() const; + + // Adds the fixed items that keep parts out of each bed's keep-outs. + void add_keep_outs(arrangement::ArrangePolygons& fixed) const; + + // Call after arrangement::arrange(items, fixed, bed_shape(), params), with `before` holding + // `items` as they were before it. Leaves unarranged any part across a keep-out: one that does + // not fit an empty bed is retried there without the bed's fixed items, so a part too big for + // its zone comes back across them. Then centers each fenced plate's parts in its zone. + void finish(arrangement::ArrangePolygons& items, const arrangement::ArrangePolygons& before, + const arrangement::ArrangePolygons& fixed, const arrangement::ArrangeParams& params) const; + +private: + struct Bed + { + int bed_idx; + ImexArrangeZones zones; + }; + struct KeepOut + { + int bed_idx; + BoundingBox box; + bool fence; // outside the primary zone, rather than a strip + }; + + std::optional room_in(const BoundingBoxf& zone) const; + bool crosses_keep_out(const arrangement::ArrangePolygon& part, int bed_idx) const; + + Points m_bed; + BoundingBox m_bed_bb; + Point m_margin; + bool m_adds_plates = false; + std::vector m_beds; + std::optional m_shared_room; + std::vector m_keep_outs; +}; + +} // namespace Slic3r diff --git a/src/slic3r/GUI/Jobs/ArrangeJob.cpp b/src/slic3r/GUI/Jobs/ArrangeJob.cpp index 9f7df5c8b0..4fb66176b6 100644 --- a/src/slic3r/GUI/Jobs/ArrangeJob.cpp +++ b/src/slic3r/GUI/Jobs/ArrangeJob.cpp @@ -5,6 +5,7 @@ #include "libslic3r/MTUtils.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/ModelArrange.hpp" +#include "libslic3r/IMEXArrange.hpp" #include "slic3r/GUI/PartPlate.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" @@ -537,6 +538,36 @@ void ArrangeJob::prepare_partplate() { prepare_belt_regions(current_plate_index + 1); } +// Snapshot each plate's IMEX zones on the main thread, so process() never has to touch +// PartPlate's IMEX cache; see the members' declaration for why that matters. The beds are the +// arranger's: arranging one plate packs into bed 0, and arranging all of them numbers the beds +// over the unlocked plates, as postprocess_bed_index_for_selected() maps them back. +void ArrangeJob::prepare_imex_zones() +{ + m_imex = {}; + PartPlateList& plate_list = m_plater->get_partplate_list(); + std::vector plates; + if (only_on_partplate) + plates.push_back(plate_list.get_curr_plate()); + else + for (int i = 0; i < plate_list.get_plate_count(); ++i) + if (!plate_list.get_plate(i)->is_locked()) + plates.push_back(plate_list.get_plate(i)); + + for (PartPlate* plate : plates) { + std::optional zones; + if (const std::optional pz = plate ? plate->imex_primary_zone() : std::nullopt) { + const Vec2d o = plate->get_origin().head<2>(); + zones = ImexArrangeZones{BoundingBoxf(Vec2d(pz->min - o), Vec2d(pz->max - o)), {}}; + for (const BoundingBoxf3& strip : plate->imex_collision_zones()) + zones->collision_zones.emplace_back(Vec2d(strip.min.head<2>() - o), Vec2d(strip.max.head<2>() - o)); + } + m_imex.beds.push_back(std::move(zones)); + } + m_imex.adds_plates = !only_on_partplate; + m_imex.read_config(wxGetApp().preset_bundle->full_config()); +} + //BBS: add partplate logic void ArrangeJob::prepare() { @@ -555,22 +586,6 @@ void ArrangeJob::prepare() Model::setExtruderParams(config, numExtruders); Model::setPrintSpeedTable(config, print_config); - // Snapshot the IMEX zones here, on the main thread, so process() never has to touch - // PartPlate's IMEX cache. See the members' declaration for why that matters. - m_imex_primary_zone_local.reset(); - m_imex_collision_zones_local.clear(); - if (PartPlate* curr_plate = m_plater->get_partplate_list().get_curr_plate()) { - if (auto pz = curr_plate->imex_primary_zone()) { - const Vec3d plate_origin = curr_plate->get_origin(); - const double ox = plate_origin.x(), oy = plate_origin.y(); - m_imex_primary_zone_local = BoundingBoxf(Vec2d(pz->min.x() - ox, pz->min.y() - oy), - Vec2d(pz->max.x() - ox, pz->max.y() - oy)); - for (const BoundingBoxf3& cz : curr_plate->imex_collision_zones()) - m_imex_collision_zones_local.emplace_back(Vec2d(cz.min.x() - ox, cz.min.y() - oy), - Vec2d(cz.max.x() - ox, cz.max.y() - oy)); - } - } - int state = m_plater->get_prepare_state(); if (state == Job::JobPrepareState::PREPARE_STATE_DEFAULT) { only_on_partplate = false; @@ -581,6 +596,9 @@ void ArrangeJob::prepare() prepare_partplate(); } + // After prepare_all(), which locks the plates it must leave alone. + prepare_imex_zones(); + #if SAVE_ARRANGE_POLY if (1) @@ -662,38 +680,10 @@ void ArrangeJob::process(Ctl &ctl) Points bedpts = get_shrink_bedpts(m_plater->config(),params); - // When an IDEX/IQEX parallel mode is active, constrain auto-arrange to the primary zone only - // and treat carriage collision strips as hard excluded regions. - // NOTE: the plate's zone boxes are in global (world) coordinates because they derive from - // m_shape, which includes the plate origin offset. The arranger works in plate-local space - // (origin = 0,0), so the plate origin is subtracted when the snapshot is taken in prepare(); - // m_imex_primary_zone_local / m_imex_collision_zones_local are already plate-local here. - if (const auto& pz = m_imex_primary_zone_local) { - BoundingBox scaled_pz = scaled(*pz); - bedpts = { - { scaled_pz.min.x(), scaled_pz.min.y() }, - { scaled_pz.max.x(), scaled_pz.min.y() }, - { scaled_pz.max.x(), scaled_pz.max.y() }, - { scaled_pz.min.x(), scaled_pz.max.y() }, - }; - for (const BoundingBoxf& cz : m_imex_collision_zones_local) { - Polygon poly({ - { scaled(cz.min.x()), scaled(cz.min.y()) }, - { scaled(cz.max.x()), scaled(cz.min.y()) }, - { scaled(cz.max.x()), scaled(cz.max.y()) }, - { scaled(cz.min.x()), scaled(cz.max.y()) }, - }); - arrangement::ArrangePolygon ap; - ap.poly.contour = poly; - ap.translation = Vec2crd(0, 0); - ap.rotation = 0.0; - ap.is_virt_object = true; - ap.bed_idx = current_plate_index; - ap.height = 1; - ap.name = "IMEXCollisionZone"; - m_unselected.emplace_back(std::move(ap)); - } - } + // Keep the parts on IDEX/IQEX plates in their primary zones; see ImexArranger. + const ImexArranger imex(m_imex, params, bedpts); + bedpts = imex.bed_shape(); + imex.add_keep_outs(m_unselected); bool enable_wrapping = global_config.option("enable_wrapping_detection")->value; partplate_list.preprocess_exclude_areas(params.excluded_regions, enable_wrapping, 1, scale_(1)); @@ -719,7 +709,10 @@ void ArrangeJob::process(Ctl &ctl) <<", bbox:"< m_imex_primary_zone_local; - std::vector m_imex_collision_zones_local; + ImexArrangeInput m_imex; arrangement::ArrangeParams params; int current_plate_index = 0; @@ -69,6 +69,7 @@ class ArrangeJob : public Job void prepare_partplate(); void prepare_wipe_tower(); void prepare_belt_regions(int num_plates); + void prepare_imex_zones(); ArrangePolygon prepare_arrange_polygon(void* instance); diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 81d9c75a71..9e1e7c44f8 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -37,6 +37,7 @@ add_executable(${_TEST_NAME}_tests test_fill_plane_path.cpp test_fill_tpms_adaptive.cpp test_geometry.cpp + test_imex_arrange.cpp test_imex_helpers.cpp test_imex_zones.cpp test_multimaterial_segmentation.cpp diff --git a/tests/libslic3r/test_imex_arrange.cpp b/tests/libslic3r/test_imex_arrange.cpp new file mode 100644 index 0000000000..54b22fb929 --- /dev/null +++ b/tests/libslic3r/test_imex_arrange.cpp @@ -0,0 +1,287 @@ +#include + +#include +#include +#include + +#include "catch2/catch_test_macros.hpp" +#include "catch2/matchers/catch_matchers.hpp" +#include "catch2/matchers/catch_matchers_floating_point.hpp" +#include "libslic3r/Arrange.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/ExPolygon.hpp" +#include "libslic3r/IMEXArrange.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Polygon.hpp" + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +namespace { + +// A 300 x 200 mm bed. A two-carriage IDEX in a parallel mode gives T0 the left half, and a +// mirror mode adds a 30 mm strip along that half's inner edge. +const BoundingBoxf kBed{Vec2d(0., 0.), Vec2d(300., 200.)}; +const BoundingBoxf kLeftHalf{Vec2d(0., 0.), Vec2d(150., 200.)}; +const BoundingBoxf kRightHalf{Vec2d(150., 0.), Vec2d(300., 200.)}; +const BoundingBoxf kStrip{Vec2d(120., 0.), Vec2d(150., 200.)}; + +const ImexArrangeZones kCopy{kLeftHalf, {}}; +const ImexArrangeZones kMirror{kLeftHalf, {kStrip}}; + +// A 1 mm bed edge margin, and no rotation, so every expected box below is exact. +arrangement::ArrangeParams test_params() +{ + arrangement::ArrangeParams params; + params.progressind = {}; + params.bed_shrink_x = 1.f; + params.bed_shrink_y = 1.f; + params.allow_rotations = false; + return params; +} + +// The bed shrunk by that 1 mm margin, as get_shrink_bedpts() hands it to the arranger. +Points shrunk_bed() +{ + return scaled(BoundingBoxf(Vec2d(1., 1.), Vec2d(299., 199.))).polygon().points; +} + +// A `width` x `depth` mm part, kept 1 mm from its neighbors, ready to arrange: on bed 0, as +// get_instance_arrange_poly() leaves it. +arrangement::ArrangePolygon part(double width, double depth) +{ + arrangement::ArrangePolygon ap; + ap.poly.contour = scaled(BoundingBoxf(Vec2d(-width / 2., -depth / 2.), Vec2d(width / 2., depth / 2.))).polygon(); + ap.inflation = scaled(1.); + ap.extrude_ids = {0}; + ap.height = 10.; + ap.bed_idx = 0; + return ap; +} + +arrangement::ArrangePolygons squares(int count, double size) +{ + arrangement::ArrangePolygons out(count, part(size, size)); + for (int i = 0; i < count; ++i) + out[i].itemid = i; + return out; +} + +// Arranges `items` the way ArrangeJob does. +void arrange(arrangement::ArrangePolygons& items, const ImexArrangeInput& input) +{ + const arrangement::ArrangeParams params = test_params(); + const ImexArranger imex(input, params, shrunk_bed()); + arrangement::ArrangePolygons fixed; + imex.add_keep_outs(fixed); + const arrangement::ArrangePolygons before = items; + arrangement::arrange(items, fixed, imex.bed_shape(), params); + imex.finish(items, before, fixed, params); +} + +BoundingBoxf extents(const arrangement::ArrangePolygons& items) +{ + BoundingBox bb; + for (const arrangement::ArrangePolygon& ap : items) + bb.merge(get_extents(ap.transformed_poly())); + return BoundingBoxf(unscaled(bb.min), unscaled(bb.max)); +} + +bool crosses(const arrangement::ArrangePolygon& ap, const BoundingBoxf& box) +{ + return !intersection(ap.transformed_poly().contour, scaled(box).polygon()).empty(); +} + +} // namespace + +TEST_CASE("ImexArranger changes nothing when no plate has zones", "[IMEXArrange][IMEX]") +{ + ImexArrangeInput input; + input.beds = {std::nullopt, std::nullopt}; + input.adds_plates = true; + const ImexArranger imex(input, test_params(), shrunk_bed()); + + CHECK_FALSE(imex.active()); + CHECK(imex.bed_shape() == shrunk_bed()); + arrangement::ArrangePolygons fixed; + imex.add_keep_outs(fixed); + CHECK(fixed.empty()); +} + +TEST_CASE("Plates sharing a primary zone arrange inside it, clear of the strip", "[IMEXArrange][IMEX]") +{ + // The second plate computes the same zone from where it sits, off in the last bits. + ImexArrangeZones shifted = kMirror; + shifted.primary_zone.max.x() += 1e-9; + ImexArrangeInput input; + input.beds = {kMirror, shifted}; + input.adds_plates = true; + input.added_plate = kMirror; + + // The bed shape is the zone less the bed's 1 mm edge margin. + const ImexArranger imex(input, test_params(), shrunk_bed()); + const BoundingBox shape(imex.bed_shape()); + CHECK(shape.min == scaled(Vec2d(1., 1.))); + CHECK(shape.max == scaled(Vec2d(149., 199.))); + + // Enough parts that, centered in the zone, they would reach the strip. + arrangement::ArrangePolygons items = squares(20, 20.); + arrange(items, input); + for (const arrangement::ArrangePolygon& ap : items) { + CHECK(ap.bed_idx >= 0); + CHECK_FALSE(crosses(ap, kStrip)); + } +} + +TEST_CASE("Beside a plate in Primary, a parallel plate's parts are centered in its zone", "[IMEXArrange][IMEX]") +{ + ImexArrangeInput input; + input.beds = {kMirror, std::nullopt}; + input.adds_plates = true; + + // Different zones: the bed shape stays the whole bed, and the mirror plate is fenced in. + const ImexArranger imex(input, test_params(), shrunk_bed()); + CHECK(imex.bed_shape() == shrunk_bed()); + + arrangement::ArrangePolygons items = squares(4, 20.); + arrange(items, input); + for (const arrangement::ArrangePolygon& ap : items) + REQUIRE(ap.bed_idx == 0); + const BoundingBoxf pile = extents(items); + CHECK(kLeftHalf.contains(pile.min)); + CHECK(kLeftHalf.contains(pile.max)); + // Centered in the zone, rather than piled against its edge nearest the bed's center. + CHECK_THAT(pile.center().x(), WithinAbs(75., 5.)); + CHECK_THAT(pile.center().y(), WithinAbs(100., 5.)); +} + +TEST_CASE("A plate in Primary uses the whole bed beside a parallel plate", "[IMEXArrange][IMEX]") +{ + ImexArrangeInput input; + input.beds = {std::nullopt, kMirror}; + input.adds_plates = true; + + arrangement::ArrangePolygons items = squares(4, 20.); + arrange(items, input); + for (const arrangement::ArrangePolygon& ap : items) + REQUIRE(ap.bed_idx == 0); + CHECK_THAT(extents(items).center().x(), WithinAbs(150., 5.)); +} + +TEST_CASE("A part too big for its primary zone is left unarranged", "[IMEXArrange][IMEX]") +{ + ImexArrangeInput input; + SECTION("arranging one plate, the zone is the bed shape") + { + input.beds = {kCopy}; + } + SECTION("arranging all plates, the zone is fenced in") + { + input.beds = {kCopy, std::nullopt}; + input.adds_plates = true; + } + + // Narrower than the bed but wider than the zone. + arrangement::ArrangePolygons items{part(180., 50.)}; + arrange(items, input); + CHECK(items.front().bed_idx == arrangement::UNARRANGED); +} + +TEST_CASE("A strip keeps room for the bed margin and the brim", "[IMEXArrange][IMEX]") +{ + ImexArrangeInput input; + input.beds = {kMirror}; + input.brim = 5.; + const ImexArranger imex(input, test_params(), shrunk_bed()); + + arrangement::ArrangePolygons fixed; + imex.add_keep_outs(fixed); + REQUIRE(fixed.size() == 1); + // Grown by the 1 mm margin and the 5 mm brim on every side. + const BoundingBox strip = get_extents(fixed.front().poly); + CHECK(strip.min == scaled(Vec2d(114., -6.))); + CHECK(strip.max == scaled(Vec2d(156., 206.))); +} + +TEST_CASE("A plate the arrange adds takes the zones it will have", "[IMEXArrange][IMEX]") +{ + ImexArrangeInput input; + input.beds = {kCopy}; + input.adds_plates = true; + + SECTION("the same zone keeps it the bed shape, with the new plates' strips") + { + input.added_plate = kMirror; + const ImexArranger imex(input, test_params(), shrunk_bed()); + CHECK(imex.bed_shape() != shrunk_bed()); + arrangement::ArrangePolygons fixed; + imex.add_keep_outs(fixed); + auto on = [&fixed](int bed_idx) { + return std::count_if(fixed.begin(), fixed.end(), [bed_idx](const arrangement::ArrangePolygon& ap) { return ap.bed_idx == bed_idx; }); + }; + CHECK(on(0) == 0); + CHECK(on(1) == 1); + } + SECTION("a new plate in Primary gets the whole bed, so the zone is fenced in instead") + { + const ImexArranger imex(input, test_params(), shrunk_bed()); + CHECK(imex.bed_shape() == shrunk_bed()); + arrangement::ArrangePolygons fixed; + imex.add_keep_outs(fixed); + for (const arrangement::ArrangePolygon& ap : fixed) + CHECK(ap.bed_idx == 0); + } + SECTION("another zone makes the whole bed the bed shape") + { + input.added_plate = ImexArrangeZones{kRightHalf, {}}; + const ImexArranger imex(input, test_params(), shrunk_bed()); + CHECK(imex.bed_shape() == shrunk_bed()); + } + SECTION("arranging one plate never adds one") + { + input.adds_plates = false; + input.added_plate = ImexArrangeZones{kRightHalf, {}}; + const ImexArranger imex(input, test_params(), shrunk_bed()); + CHECK(imex.bed_shape() != shrunk_bed()); + } +} + +TEST_CASE("A zone narrower than its margins takes no parts", "[IMEXArrange][IMEX]") +{ + ImexArrangeInput input; + input.beds = {ImexArrangeZones{BoundingBoxf(Vec2d(0., 0.), Vec2d(1.5, 200.)), {}}}; + const ImexArranger imex(input, test_params(), shrunk_bed()); + CHECK(imex.bed_shape() == shrunk_bed()); + + arrangement::ArrangePolygons items = squares(1, 20.); + arrange(items, input); + CHECK(items.front().bed_idx == arrangement::UNARRANGED); +} + + +TEST_CASE("Parts that spill onto a new plate in Primary use its whole bed", "[IMEXArrange][IMEX]") +{ + // Every plate in copy mode, while a plate the arrange adds is in Primary. + ImexArrangeInput input; + input.beds = {kCopy}; + input.adds_plates = true; + + // The zone holds 3 x 4 of these 40 mm parts, so the rest spill onto a new plate. + arrangement::ArrangePolygons items = squares(16, 40.); + arrange(items, input); + + arrangement::ArrangePolygons spilled; + for (const arrangement::ArrangePolygon& ap : items) { + REQUIRE(ap.bed_idx >= 0); + if (ap.bed_idx == 0) { + const BoundingBoxf placed = extents({ap}); + CHECK(kLeftHalf.contains(placed.min)); + CHECK(kLeftHalf.contains(placed.max)); + } else + spilled.push_back(ap); + } + REQUIRE_FALSE(spilled.empty()); + // Centered on the whole bed, not packed into the copy plate's zone. + CHECK_THAT(extents(spilled).center().x(), WithinAbs(150., 10.)); +}