From 2ede6d163115a6f54ab92dbde7b05b4582d12bec Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Sat, 10 Oct 2026 01:30:14 -0400 Subject: [PATCH] Arrange every IDEX/IQEX plate inside its own primary zone Arrange read the zones of the selected plate only and applied them to every bed it packed. Arranging all plates with a plate in Primary selected spread a parallel plate's parts across its whole bed, and with a parallel plate selected, a Primary plate's parts were squeezed into the zone. Arranging a single plate other than the first also ignored its collision strips: they were tagged with the plate's index, while the plate packs into the arranger's bed 0. The IDEX/IQEX constraints now live in ImexArranger (IMEXArrange), and ArrangeJob only snapshots each plate's zones on the main thread and calls it: - When every bed, plates the arrange may add included, has the same primary zone, the bed shape is that zone, so parts stay centered in it. - Otherwise each plate in a parallel mode fences off the rest of its bed with fixed items on its own bed, and its parts are then arranged again inside the zone, so they sit centered rather than piled against the zone edge nearest the bed's center. Plates in Primary keep the whole bed, and plates the arrange adds take the process preset's mode. - Zone edges inside the bed get the bed's own edge margin, and strips also keep room for the brim. - A part too big for its zone, which libnest2d's first-fit retry places across the fixed items, is left unarranged. Co-Authored-By: Claude Opus 5.5 --- src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/IMEXArrange.cpp | 220 ++++++++++++++++++++ src/libslic3r/IMEXArrange.hpp | 101 +++++++++ src/slic3r/GUI/Jobs/ArrangeJob.cpp | 89 ++++---- src/slic3r/GUI/Jobs/ArrangeJob.hpp | 7 +- tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_imex_arrange.cpp | 287 ++++++++++++++++++++++++++ 7 files changed, 656 insertions(+), 51 deletions(-) create mode 100644 src/libslic3r/IMEXArrange.cpp create mode 100644 src/libslic3r/IMEXArrange.hpp create mode 100644 tests/libslic3r/test_imex_arrange.cpp 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.)); +}