From 02609d6dfb8f9540538dbf5ee6f36f2e2170f89e Mon Sep 17 00:00:00 2001 From: Tim Arnold Date: Thu, 3 Sep 2026 08:56:18 -0400 Subject: [PATCH] ENH: Experimental packer for Arrange (denser layouts, any-angle rotation, true outlines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds an optional second backend for Arrange based on the [sparrow](https://github.com/JeroenGar/sparrow) nesting algorithm (MIT, Rust). It is off by default and selected with a new **Use experimental packer** checkbox in the *Arrange* settings popup. The stock `libnest2d` path is untouched and remains the default. The stock arranger packs convex hulls at four fixed angles with a first-fit heuristic. The new backend packs the objects' real projected outlines with continuous rotation and an overlap-tolerant local search, which fills plates considerably tighter. ## GIF Example ## Results 73-object project, CLI arrange from a clean layout, stock libnest2d versus sparrow at 8 s per plate, then a full re-slice of every output. All runs placed every object and every re-slice passed with no printable-area or exclusion-zone errors. | Machine | Plates, libnest2d | Plates, sparrow | Time, libnest2d | Time, sparrow | |---|---|---|---|---| | P1S | 15 | 9 | 4.8 s | 66 s | | A1 | 18 | 9 | 6.3 s | 65 s | | H2C | 10 | 6 | 4.1 s | 45 s | **Averaging 40% fewer plates with sparrow over stock libnest2d.** The set includes three 200 mm parts that each take a plate on their own on the 256 mm beds. Plate counts vary by one between sparrow runs because the search is time-bounded. ## What changes for the user - *Arrange* settings gain **Use experimental packer** and a **Search time per plate** slider (2–60 s, default 8). The packer is a wall-clock local search, so more time means more attempts; total time scales with the number of plates used. - Progress is reported per plate in the existing toast: `plate 2, 31/73 objects placed`. - Both settings persist in the app config. - Applies wherever Arrange is invoked: the toolbar button, the `A` key, the Arrange menu, per-plate arrange and Arrange Selected. - Exclusion zones (e.g. the P1S front-left corner) and the wipe tower are respected. The "too close to exclusion area" checks now use the true outline too, so an object whose convex hull crosses a zone but whose real silhouette does not is no longer rejected. ## What is not covered The packer only handles the 2D problem. These cases automatically fall back to the stock arranger: - Sequential (by-object) printing, which needs height and print-order constraints. - "Allow multiple materials on same plate" disabled. - Fill Bed, which depends on the stock packer's callback protocol. Known limits when the packer does run: - Filament temperature-group and TPU-per-plate rules are not enforced. - Objects are one solid outline each. Holes are ignored and a mesh with several disconnected islands is packed as the convex hull of all of them. - Results are not bit-for-bit reproducible across runs because the search is time-bounded. ## Build Requires a Rust toolchain (`cargo` on PATH, or in `~/.cargo/bin`). CMake builds the crate as a static library and links it into libslic3r; the end user needs nothing extra. Configure with `-DSLIC3R_SPARROW_ARRANGE=OFF` to build without it, in which case the checkbox is absent and nothing else changes. Tested on macOS arm64. The Windows and Linux link lines are written but have not been exercised. ## Implementation - `src/sparrow_arrange/`: Rust crate exposing one C function, `sparrow_arrange()`, behind a frozen C header. It runs the vendored sparrow separator bed by bed: fill largest-first, separate, grow or evict, and move on when the plate is full. Exclusion zones and fixed items are modeled as pinned obstacles. 12 tests, `cargo test` runs in about a second. - `src/sparrow_arrange/vendor/sparrow/`: sparrow at `50690c4` trimmed to the separator, samplers and collision tracker. Local patches are marked `SPARROW_ARRANGE PATCH` and listed in `VENDORED.md`. - `src/libslic3r/ArrangeSparrow.cpp`: converts `ArrangePolygon`s to the C ABI, including the same inflation and bed margin the stock path applies, and dispatches from `arrangement::arrange()` when the flag is set. Any failure falls back to libnest2d. - `src/libslic3r/Model.cpp`: `ModelObject::true_outline_2d()` projects the model-part meshes to a 2D silhouette, cached per object and keyed on the volumes' meshes and transforms. - `ArrangeJob.cpp`, `GLCanvas3D.cpp`: settings plumbing, checkbox, slider, progress. - CLI: `BBS_ARRANGE_SPARROW=1` and `BBS_ARRANGE_SPARROW_TIME=` environment variables select the packer for `--arrange`. Diff is 49 files, about 4700 lines, of which 1850 are the vendored sparrow sources and 790 are `Cargo.lock`. The new code is about 2100 lines. ## Testing - `cargo test` in `src/sparrow_arrange` (12 tests: geometry contract, holes, fixed items, multi-bed spill, cancellation, determinism, FFI edge cases). - CLI arrange of both projects above, then a full re-slice of the output to confirm every plate passes `Print::validate()`. - GUI: toolbar, `A` key, menu and per-plate arrange on A1, P1S, and H2C profiles with their respective exclusion zones; drag-time exclusion checks; settings persistence across restart; progress toast. - An independent adversarial code review; all confirmed findings are fixed in the branch history. --- .gitignore | 1 + CMakeLists.txt | 1 + src/BambuStudio.cpp | 21 + src/libslic3r/Arrange.cpp | 22 + src/libslic3r/Arrange.hpp | 14 + src/libslic3r/ArrangeSparrow.cpp | 331 ++++++++ src/libslic3r/ArrangeSparrow.hpp | 24 + src/libslic3r/CMakeLists.txt | 80 ++ src/libslic3r/Model.cpp | 112 ++- src/libslic3r/Model.hpp | 26 + src/libslic3r/Print.cpp | 24 +- src/slic3r/GUI/GLCanvas3D.cpp | 80 +- src/slic3r/GUI/GLCanvas3D.hpp | 6 + src/slic3r/GUI/Jobs/ArrangeJob.cpp | 15 + src/slic3r/GUI/Jobs/FillBedJob.cpp | 2 + src/slic3r/GUI/PartPlate.cpp | 19 +- src/sparrow_arrange/.gitignore | 1 + src/sparrow_arrange/Cargo.lock | 787 ++++++++++++++++++ src/sparrow_arrange/Cargo.toml | 21 + src/sparrow_arrange/include/sparrow_arrange.h | 59 ++ src/sparrow_arrange/src/lib.rs | 162 ++++ src/sparrow_arrange/src/pack.rs | 595 +++++++++++++ src/sparrow_arrange/src/tests.rs | 508 +++++++++++ src/sparrow_arrange/vendor/sparrow/Cargo.toml | 19 + src/sparrow_arrange/vendor/sparrow/LICENSE | 21 + .../vendor/sparrow/VENDORED.md | 13 + .../vendor/sparrow/src/consts.rs | 34 + .../vendor/sparrow/src/eval/collision_loss.rs | 67 ++ .../vendor/sparrow/src/eval/lbf_evaluator.rs | 59 ++ .../vendor/sparrow/src/eval/mod.rs | 4 + .../vendor/sparrow/src/eval/sample_eval.rs | 44 + .../vendor/sparrow/src/eval/sep_evaluator.rs | 100 +++ src/sparrow_arrange/vendor/sparrow/src/lib.rs | 14 + .../vendor/sparrow/src/optimizer/mod.rs | 3 + .../vendor/sparrow/src/optimizer/separator.rs | 239 ++++++ .../vendor/sparrow/src/optimizer/worker.rs | 125 +++ .../vendor/sparrow/src/quantify/mod.rs | 51 ++ .../sparrow/src/quantify/overlap_proxy.rs | 27 + .../sparrow/src/quantify/pair_matrix.rs | 49 ++ .../vendor/sparrow/src/quantify/tracker.rs | 248 ++++++ .../vendor/sparrow/src/sample/best_samples.rs | 107 +++ .../sparrow/src/sample/coord_descent.rs | 180 ++++ .../vendor/sparrow/src/sample/mod.rs | 4 + .../vendor/sparrow/src/sample/search.rs | 101 +++ .../sparrow/src/sample/uniform_sampler.rs | 113 +++ .../vendor/sparrow/src/util/assertions.rs | 136 +++ .../vendor/sparrow/src/util/listener.rs | 45 + .../vendor/sparrow/src/util/mod.rs | 3 + .../vendor/sparrow/src/util/terminator.rs | 45 + 49 files changed, 4724 insertions(+), 38 deletions(-) create mode 100644 src/libslic3r/ArrangeSparrow.cpp create mode 100644 src/libslic3r/ArrangeSparrow.hpp create mode 100644 src/sparrow_arrange/.gitignore create mode 100644 src/sparrow_arrange/Cargo.lock create mode 100644 src/sparrow_arrange/Cargo.toml create mode 100644 src/sparrow_arrange/include/sparrow_arrange.h create mode 100644 src/sparrow_arrange/src/lib.rs create mode 100644 src/sparrow_arrange/src/pack.rs create mode 100644 src/sparrow_arrange/src/tests.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/Cargo.toml create mode 100644 src/sparrow_arrange/vendor/sparrow/LICENSE create mode 100644 src/sparrow_arrange/vendor/sparrow/VENDORED.md create mode 100644 src/sparrow_arrange/vendor/sparrow/src/consts.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/eval/collision_loss.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/eval/lbf_evaluator.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/eval/mod.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/eval/sample_eval.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/eval/sep_evaluator.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/lib.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/optimizer/mod.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/optimizer/separator.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/optimizer/worker.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/quantify/mod.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/quantify/overlap_proxy.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/quantify/pair_matrix.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/quantify/tracker.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/sample/best_samples.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/sample/coord_descent.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/sample/mod.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/sample/search.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/sample/uniform_sampler.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/util/assertions.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/util/listener.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/util/mod.rs create mode 100644 src/sparrow_arrange/vendor/sparrow/src/util/terminator.rs diff --git a/.gitignore b/.gitignore index 2eaad2c412a..8bba442a368 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ compile_commands.json # Legacy device_page bundle copies (runtime lives in resources/web/device_page/dist/) resources/web/device_page/assets/ resources/web/device_page/index.html +src/sparrow_arrange/target/ diff --git a/CMakeLists.txt b/CMakeLists.txt index b4f3a964d1e..08e87d7833f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ option(SLIC3R_MSVC_COMPILE_PARALLEL "Compile on Visual Studio in parallel" 1) option(SLIC3R_MSVC_PDB "Generate PDB files on MSVC in Release mode" 1) option(SLIC3R_PERL_XS "Compile XS Perl module and enable Perl unit and integration tests" 0) option(SLIC3R_ASAN "Enable ASan on Clang and GCC" 0) +option(SLIC3R_SPARROW_ARRANGE "Build and use the sparrow (Rust) 2D packing backend for Arrange" ON) # If SLIC3R_FHS is 1 -> SLIC3R_DESKTOP_INTEGRATION is always 0, othrewise variable. CMAKE_DEPENDENT_OPTION(SLIC3R_DESKTOP_INTEGRATION "Allow performing desktop integration during runtime" 1 "NOT SLIC3R_FHS" 0) diff --git a/src/BambuStudio.cpp b/src/BambuStudio.cpp index 0f94d05feef..643d634cc02 100644 --- a/src/BambuStudio.cpp +++ b/src/BambuStudio.cpp @@ -4911,6 +4911,23 @@ int CLI::run(int argc, char **argv) bool user_center_specified = false; Points beds = get_bed_shape(m_print_config); ArrangeParams arrange_cfg; + //BBS: BBS_ARRANGE_SPARROW=1 selects the sparrow packer for the CLI. Set before + // any ArrangePolygon is collected so get_arrange_polygon() emits true outlines. + const char *sparrow_env = std::getenv("BBS_ARRANGE_SPARROW"); + const bool use_sparrow = sparrow_env && std::string(sparrow_env) == "1"; + arrangement::use_true_outline.store(use_sparrow, std::memory_order_relaxed); + if (use_sparrow) + BOOST_LOG_TRIVIAL(info) << "BBS_ARRANGE_SPARROW=1: using the sparrow packer"; + //BBS: BBS_ARRANGE_SPARROW_TIME= overrides the per-plate search budget. + float sparrow_time_limit_s = 8.f; + if (const char *t = std::getenv("BBS_ARRANGE_SPARROW_TIME")) { + try { + sparrow_time_limit_s = std::stof(t); + BOOST_LOG_TRIVIAL(info) << "BBS_ARRANGE_SPARROW_TIME=" << sparrow_time_limit_s << "s per plate"; + } catch (const std::exception &) { + BOOST_LOG_TRIVIAL(warning) << "ignoring invalid BBS_ARRANGE_SPARROW_TIME=" << t; + } + } BOOST_LOG_TRIVIAL(info) << "will start transforms, commands count " << m_transforms.size() << "\n"; #if defined(__linux__) || defined(__LINUX__) @@ -5427,6 +5444,8 @@ int CLI::run(int argc, char **argv) //Step-2:prepare the arrange params arrange_cfg.allow_rotations = allow_rotations; + arrange_cfg.use_sparrow = use_sparrow; + arrange_cfg.sparrow_time_limit_s = sparrow_time_limit_s; arrange_cfg.allow_multi_materials_on_same_plate = allow_multicolor_oneplate; arrange_cfg.avoid_extrusion_cali_region = avoid_extrusion_cali_region; arrange_cfg.clearance_height_to_rod = height_to_rod; @@ -5879,6 +5898,8 @@ int CLI::run(int argc, char **argv) //Step-2:prepare the arrange params arrange_cfg.allow_rotations = allow_rotations; + arrange_cfg.use_sparrow = use_sparrow; + arrange_cfg.sparrow_time_limit_s = sparrow_time_limit_s; arrange_cfg.allow_multi_materials_on_same_plate = allow_multicolor_oneplate; arrange_cfg.avoid_extrusion_cali_region = avoid_extrusion_cali_region; arrange_cfg.clearance_height_to_rod = height_to_rod; diff --git a/src/libslic3r/Arrange.cpp b/src/libslic3r/Arrange.cpp index 6bec0cc6421..f36a89d2264 100644 --- a/src/libslic3r/Arrange.cpp +++ b/src/libslic3r/Arrange.cpp @@ -2,6 +2,10 @@ #include "Print.hpp" #include "BoundingBox.hpp" +#ifdef SLIC3R_SPARROW_ARRANGE +#include "ArrangeSparrow.hpp" +#endif + #include #include #include @@ -77,6 +81,8 @@ using SpatElement = std::pair; using SpatIndex = bgi::rtree< SpatElement, bgi::rstar<16, 4> >; using ItemGroup = std::vector>; +std::atomic use_true_outline{false}; + // A coefficient used in separating bigger items and smaller items. const double BIG_ITEM_TRESHOLD = 0.02; #define VITRIFY_TEMP_DIFF_THRSH 15 // bed temp can be higher than vitrify temp, but not higher than this thresh @@ -1191,6 +1197,22 @@ void arrange(ArrangePolygons & arrangables, { namespace clppr = Slic3r::ClipperLib; +#ifdef SLIC3R_SPARROW_ARRANGE + // BBS: sparrow handles rectangular beds only (every Bambu printer); other bed + // types and a failed sparrow run fall through to libnest2d. It has no notion of + // print order, height or per-plate filament grouping, so sequential print and + // "one material per plate" also stay on libnest2d. + if (params.use_sparrow && !params.is_seq_print && params.allow_multi_materials_on_same_plate) { + if constexpr (std::is_same_v) { + if (arrange_sparrow(arrangables, excludes, bed, params)) + return; + BOOST_LOG_TRIVIAL(warning) << "sparrow arrange unavailable, using libnest2d"; + } else { + BOOST_LOG_TRIVIAL(warning) << "sparrow arrange needs a rectangular bed, using libnest2d"; + } + } +#endif + std::vector items, fixeditems; items.reserve(arrangables.size()); diff --git a/src/libslic3r/Arrange.hpp b/src/libslic3r/Arrange.hpp index 235c3078a2c..f26cfe54a5e 100644 --- a/src/libslic3r/Arrange.hpp +++ b/src/libslic3r/Arrange.hpp @@ -4,6 +4,8 @@ #include "ExPolygon.hpp" #include "PrintConfig.hpp" +#include + #define BED_SHRINK_SEQ_PRINT 0 namespace Slic3r { @@ -31,6 +33,11 @@ struct InfiniteBed { explicit InfiniteBed(const Point &p = {0, 0}): center{p} {} }; +/// Set by the arrange caller (ArrangeJob, the CLI) before ArrangePolygons are +/// collected; read by ModelInstance::get_arrange_polygon() to choose between the +/// convex hull and the true outline. A global because that function has no ArrangeParams. +extern std::atomic use_true_outline; + /// A logical bed representing an object not being arranged. Either the arrange /// has not yet successfully run on this ArrangePolygon or it could not fit the /// object due to overly large size or invalid geometry. @@ -127,6 +134,11 @@ struct ArrangeParams { bool allow_rotations = false; + //BBS: use the sparrow (Rust) packer instead of the libnest2d NFP placer + bool use_sparrow = false; + /// Sparrow search budget in seconds, per plate (not for the whole run). + float sparrow_time_limit_s = 8.f; + bool do_final_align = true; //BBS: add specific arrange params @@ -170,6 +182,8 @@ struct ArrangeParams { ret += "\"accuracy\":" + std::to_string(accuracy) + ","; ret += "\"parallel\":" + std::to_string(parallel) + ","; ret += "\"allow_rotations\":" + std::to_string(allow_rotations) + ","; + ret += "\"use_sparrow\":" + std::to_string(use_sparrow) + ","; + ret += "\"sparrow_time_limit_s\":" + std::to_string(sparrow_time_limit_s) + ","; ret += "\"do_final_align\":" + std::to_string(do_final_align) + ","; ret += "\"allow_multi_materials_on_same_plate\":" + std::to_string(allow_multi_materials_on_same_plate) + ","; ret += "\"avoid_extrusion_cali_region\":" + std::to_string(avoid_extrusion_cali_region) + ","; diff --git a/src/libslic3r/ArrangeSparrow.cpp b/src/libslic3r/ArrangeSparrow.cpp new file mode 100644 index 00000000000..2093881d903 --- /dev/null +++ b/src/libslic3r/ArrangeSparrow.cpp @@ -0,0 +1,331 @@ +#include "ArrangeSparrow.hpp" + +#include "BoundingBox.hpp" +#include "ClipperUtils.hpp" +#include "I18N.hpp" + +#include // MAX_NUM_PLATES + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +//! macro used to mark string used at localization, return same string +#define L(s) Slic3r::I18N::translate(s) + +namespace Slic3r { namespace arrangement { + +namespace { + +// process_arrangeable() clamps inflation up to this; match it so spacing is identical. +const coord_t SPARROW_MIN_SEPARATION = scale_(1.0); + +// Key used to recognize the same exclusion polygon replicated across beds. +std::string poly_key(const Polygon &p) +{ + std::string k; + k.reserve(p.points.size() * 24); + for (const Point &pt : p.points) { + k += std::to_string(pt.x()); + k.push_back(','); + k += std::to_string(pt.y()); + k.push_back(';'); + } + return k; +} + +// Same jtSquare offset libnest2d's Item::inflate applies; keeps the largest contour. +bool inflate_contour(const Polygon &src, coord_t infl, Polygon &out) +{ + if (src.points.size() < 3) + return false; + if (infl <= 0) { + out = src; + return true; + } + + Polygons res = offset(src, float(infl), jtSquare); + size_t best = res.size(); + double best_area = 0.; + for (size_t i = 0; i < res.size(); ++i) { + double a = std::abs(res[i].area()); + if (a > best_area) { best_area = a; best = i; } + } + if (best == res.size() || res[best].points.size() < 3) { + // Offset collapsed the contour; keep the raw one. + out = src; + return true; + } + out = std::move(res[best]); + return true; +} + +std::vector to_sp_points(const Polygon &p, const Point &origin) +{ + std::vector pts; + pts.reserve(p.points.size()); + for (const Point &q : p.points) + pts.push_back(sp_point{unscaled(q.x() - origin.x()), + unscaled(q.y() - origin.y())}); + return pts; +} + +// Same tiny shrink arrange() applies to fixed items. +coord_t fixed_inflation(const ArrangePolygon &ap) +{ + coord_t infl = std::max(ap.inflation, SPARROW_MIN_SEPARATION) - scaled(2. * EPSILON); + return std::max(infl, coord_t(0)); +} + +Polygon world_contour(const ArrangePolygon &ap) +{ + Polygon w = ap.poly.contour; + w.rotate(ap.rotation); + w.translate(ap.translation.x(), ap.translation.y()); + return w; +} + +// Shared closure for both FFI callbacks: sp_input has one `user` pointer. +struct SparrowCallbackCtx +{ + const std::function * stopcondition = nullptr; + const std::function *progressind = nullptr; + int last_bed = -1; + int last_placed = -1; +}; + +int sparrow_should_stop_cb(void *user) +{ + const auto *ctx = static_cast(user); + if (ctx == nullptr || ctx->stopcondition == nullptr || !*ctx->stopcondition) + return 0; + return (*ctx->stopcondition)() ? 1 : 0; +} + +// Runs on the worker thread, at bed start and after each committed item. +void sparrow_on_progress_cb(void *user, int bed_idx, int placed, int total) +{ + auto *ctx = static_cast(user); + if (ctx == nullptr || ctx->progressind == nullptr || !*ctx->progressind) + return; + // A bed start repeats the previous placed count; only report real changes. + if (bed_idx == ctx->last_bed && placed == ctx->last_placed) + return; + ctx->last_bed = bed_idx; + ctx->last_placed = placed; + + // The GUI shows _L("Arranging") + " " + str, so emit only the suffix. + std::string msg = (boost::format(L("plate %1%, %2%/%3% objects placed")) + % (bed_idx + 1) % placed % total).str(); + BOOST_LOG_TRIVIAL(debug) << "sparrow: progress st=" << placed << " " << msg; + // progressind takes the count done, so pass placed. + (*ctx->progressind)(unsigned(placed), msg); +} + +} // namespace + +bool arrange_sparrow(ArrangePolygons & arrangables, + const ArrangePolygons &excludes, + const BoundingBox & bed, + const ArrangeParams & params) +{ + if (arrangables.empty()) + return true; // nothing to place; both backends agree on the empty result + if (!bed.defined) + return false; + + // `bed` is already shrunk by get_shrink_bedpts(); do not shrink again. The edge + // margin comes from item inflation, as in _arrange(), which packs inflated + // outlines into the uncorrected bin. + const Point bed_min = bed.min; + const double bed_w = unscaled(bed.max.x() - bed.min.x()); + const double bed_h = unscaled(bed.max.y() - bed.min.y()); + if (bed_w <= 0. || bed_h <= 0.) + return false; + + // Point buffers must outlive the FFI call; reserve so nothing is reallocated. + std::vector> pt_store; + pt_store.reserve(arrangables.size() + excludes.size() + params.excluded_regions.size()); + + std::vector items; + std::vector holes; + std::vector movable_src; // items[k] -> arrangables[movable_src[k]] + + // ---- movable items ----------------------------------------------------- + for (size_t i = 0; i < arrangables.size(); ++i) { + const ArrangePolygon &ap = arrangables[i]; + Polygon infl_poly; + if (!inflate_contour(ap.poly.contour, std::max(ap.inflation, SPARROW_MIN_SEPARATION), infl_poly)) + continue; // degenerate outline; process_arrangeable() drops these too + + pt_store.emplace_back(to_sp_points(infl_poly, Point(0, 0))); + sp_item it{}; + it.outline.pts = pt_store.back().data(); + it.outline.n = pt_store.back().size(); + it.fixed = 0; + it.bed_idx = -1; + it.x = unscaled(ap.translation.x() - bed_min.x()); + it.y = unscaled(ap.translation.y() - bed_min.y()); + it.rotation = ap.rotation; + // allowed_rotations is never populated and libnest2d ignores it too. + it.allow_rotation = params.allow_rotations ? 1 : 0; + items.push_back(it); + movable_src.push_back(i); + } + if (items.empty()) + return false; + const size_t n_movable = items.size(); + + // ---- fixed items and exclusion holes ---------------------------------- + // Zones replicated on every bed become one hole; anything else is a fixed + // item pinned to its bed. + std::vector virt_world(excludes.size()); + std::vector virt_keys(excludes.size()); + std::map> virt_beds; + for (size_t i = 0; i < excludes.size(); ++i) { + const ArrangePolygon &ap = excludes[i]; + if (!ap.is_virt_object || ap.bed_idx < 0) + continue; + virt_world[i] = world_contour(ap); + virt_keys[i] = poly_key(virt_world[i]); + virt_beds[virt_keys[i]].insert(ap.bed_idx); + } + + // A key present on beds {0..k-1}, k >= 2, is a per-bed replica: collapse it to + // one hole. Check each key against its own k: callers clone zones over different + // bed counts (the CLI uses 16 for exclusion zones, 36 for wipe towers). + std::set hole_keys; + for (const auto &kv : virt_beds) { + const std::set &beds_seen = kv.second; + if (beds_seen.size() >= 2 && *beds_seen.begin() == 0 && + *beds_seen.rbegin() == int(beds_seen.size()) - 1) + hole_keys.insert(kv.first); + } + + // Beds beyond the zone coverage get no holes; libnest2d has the same gap. + std::set emitted_holes; + auto add_hole = [&](const Polygon &world, const std::string &key, coord_t infl) { + if (!emitted_holes.insert(key).second) + return; + Polygon infl_poly; + if (!inflate_contour(world, infl, infl_poly)) + return; + pt_store.emplace_back(to_sp_points(infl_poly, bed_min)); + holes.push_back(sp_polygon{pt_store.back().data(), pt_store.back().size()}); + }; + + for (size_t i = 0; i < excludes.size(); ++i) { + const ArrangePolygon &ap = excludes[i]; + if (ap.bed_idx < 0) + continue; + const coord_t infl = fixed_inflation(ap); + if (ap.is_virt_object && hole_keys.count(virt_keys[i])) { + add_hole(virt_world[i], virt_keys[i], infl); + continue; + } + Polygon infl_poly; + if (!inflate_contour(ap.poly.contour, infl, infl_poly)) + continue; + pt_store.emplace_back(to_sp_points(infl_poly, Point(0, 0))); + sp_item it{}; + it.outline.pts = pt_store.back().data(); + it.outline.n = pt_store.back().size(); + it.fixed = 1; + it.bed_idx = ap.bed_idx; + it.x = unscaled(ap.translation.x() - bed_min.x()); + it.y = unscaled(ap.translation.y() - bed_min.y()); + it.rotation = ap.rotation; + it.allow_rotation = 0; + items.push_back(it); + } + + // excluded_regions apply to every bed, matching libnest2d's m_excluded_items. + for (const ArrangePolygon &ap : params.excluded_regions) { + Polygon w = world_contour(ap); + add_hole(w, poly_key(w), fixed_inflation(ap)); + } + + // libnest2d only penalizes the calibration strip; the ABI has no soft regions, + // so it becomes a hard hole here. + if (params.avoid_extrusion_cali_region) { + for (const ArrangePolygon &ap : params.nonprefered_regions) { + Polygon w = world_contour(ap); + add_hole(w, poly_key(w), fixed_inflation(ap)); + } + } + + BOOST_LOG_TRIVIAL(info) << "sparrow: bed " << bed_w << "x" << bed_h + << " mm, movable=" << n_movable + << ", fixed=" << (items.size() - n_movable) + << ", holes=" << holes.size() + << ", excludes_in=" << excludes.size() + << ", excluded_regions=" << params.excluded_regions.size() + << ", nonprefered_regions=" << params.nonprefered_regions.size(); + // ---- call the backend -------------------------------------------------- + sp_input in{}; + in.bed_w = bed_w; + in.bed_h = bed_h; + in.holes = holes.empty() ? nullptr : holes.data(); + in.n_holes = holes.size(); + in.items = items.data(); + in.n_items = items.size(); + in.max_beds = MAX_NUM_PLATES; // same cap FirstFitSelection enforces + in.time_limit_s = params.sparrow_time_limit_s; + in.seed = 0; + + SparrowCallbackCtx cb_ctx; + if (params.stopcondition) cb_ctx.stopcondition = ¶ms.stopcondition; + if (params.progressind) cb_ctx.progressind = ¶ms.progressind; + in.user = &cb_ctx; + in.should_stop = params.stopcondition ? &sparrow_should_stop_cb : nullptr; + in.on_progress = params.progressind ? &sparrow_on_progress_cb : nullptr; + + std::vector out(items.size()); + + // Empty name: the GUI prefixes it with "Arranging". Report 0 before and all + // placed after, since ArrangeJob feeds the value to update_status(num_finished). + if (params.progressind) params.progressind(0, ""); + const int rc = ::sparrow_arrange(&in, out.data()); + if (params.progressind) params.progressind(unsigned(n_movable), ""); + + if (rc != 0) { + BOOST_LOG_TRIVIAL(error) << "sparrow_arrange failed with rc=" << rc + << ", falling back to the libnest2d arranger"; + return false; + } + + // ---- write the result back -------------------------------------------- + // Same as arrange()'s tail, minus itemid: sparrow keeps caller ids. + for (size_t k = 0; k < n_movable; ++k) { + ArrangePolygon &ap = arrangables[movable_src[k]]; + ap.bed_idx = out[k].bed_idx; + if (ap.bed_idx == UNARRANGED) + continue; // leave translation/rotation untouched, like libnest2d does + ap.translation = {scaled(out[k].x) + bed_min.x(), + scaled(out[k].y) + bed_min.y()}; + ap.rotation = out[k].rotation; + + // Raw footprint in bed coordinates for the bed and hole checks. + Polygon w = ap.poly.contour; + w.rotate(ap.rotation); + w.translate(ap.translation.x() - bed_min.x(), ap.translation.y() - bed_min.y()); + const BoundingBox wb = w.bounding_box(); + BOOST_LOG_TRIVIAL(debug) << "sparrow: placed \"" << ap.name << "\" bed=" << ap.bed_idx + << " bed-bbox (" << unscaled(wb.min.x()) << "," + << unscaled(wb.min.y()) << ")-(" + << unscaled(wb.max.x()) << "," + << unscaled(wb.max.y()) << ")"; + } + + return true; +} + +}} // namespace Slic3r::arrangement diff --git a/src/libslic3r/ArrangeSparrow.hpp b/src/libslic3r/ArrangeSparrow.hpp new file mode 100644 index 00000000000..732d3253331 --- /dev/null +++ b/src/libslic3r/ArrangeSparrow.hpp @@ -0,0 +1,24 @@ +#ifndef SLIC3R_ARRANGE_SPARROW_HPP +#define SLIC3R_ARRANGE_SPARROW_HPP + +#include "Arrange.hpp" + +namespace Slic3r { + +class BoundingBox; + +namespace arrangement { + +/// Arrange with the sparrow (Rust) packing backend instead of libnest2d. +/// Only rectangular beds are supported, which is every Bambu printer. +/// Writes translation/rotation/bed_idx back into \p arrangables exactly like +/// arrange() does. Returns false when the backend could not be used at all +/// (empty/degenerate input, FFI error) so the caller can fall back. +bool arrange_sparrow(ArrangePolygons & arrangables, + const ArrangePolygons &excludes, + const BoundingBox & bed, + const ArrangeParams & params); + +}} // namespace Slic3r::arrangement + +#endif // SLIC3R_ARRANGE_SPARROW_HPP diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index a432007fd0c..39f9e8c4f37 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -509,6 +509,13 @@ if (APPLE) ) endif () +if (SLIC3R_SPARROW_ARRANGE) + list(APPEND lisbslic3r_sources + ArrangeSparrow.hpp + ArrangeSparrow.cpp + ) +endif () + add_library(libslic3r STATIC ${lisbslic3r_sources} "${CMAKE_CURRENT_BINARY_DIR}/libslic3r_version.h" ${OpenVDBUtils_SOURCES}) @@ -584,6 +591,79 @@ endif () encoding_check(libslic3r) target_compile_definitions(libslic3r PUBLIC -DUSE_TBB -DTBB_USE_CAPTURED_EXCEPTION=0) + +# BBS: sparrow_arrange -- Rust staticlib built by cargo, linked into libslic3r. +if (SLIC3R_SPARROW_ARRANGE) + set(SPARROW_ARRANGE_DIR "${CMAKE_SOURCE_DIR}/src/sparrow_arrange") + + find_program(CARGO_EXECUTABLE cargo HINTS "$ENV{HOME}/.cargo/bin" /opt/homebrew/bin /usr/local/bin) + if (NOT CARGO_EXECUTABLE) + message(FATAL_ERROR "SLIC3R_SPARROW_ARRANGE is ON but cargo was not found. Install Rust or configure with -DSLIC3R_SPARROW_ARRANGE=OFF.") + endif () + + # macOS CI cross-builds x86_64 on arm64 runners; cargo must be told the target. + set(SPARROW_ARRANGE_TARGET_ARGS) + set(SPARROW_ARRANGE_OUT "${SPARROW_ARRANGE_DIR}/target/release") + if (APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _sparrow_narch) + if (_sparrow_narch GREATER 1) + message(FATAL_ERROR "SLIC3R_SPARROW_ARRANGE does not support universal builds; build one architecture at a time.") + endif () + if (CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") + set(_sparrow_triple aarch64-apple-darwin) + else () + set(_sparrow_triple ${CMAKE_OSX_ARCHITECTURES}-apple-darwin) + endif () + set(SPARROW_ARRANGE_TARGET_ARGS --target ${_sparrow_triple}) + set(SPARROW_ARRANGE_OUT "${SPARROW_ARRANGE_DIR}/target/${_sparrow_triple}/release") + find_program(RUSTUP_EXECUTABLE rustup HINTS "$ENV{HOME}/.cargo/bin" /opt/homebrew/bin /usr/local/bin) + if (RUSTUP_EXECUTABLE) + execute_process(COMMAND "${RUSTUP_EXECUTABLE}" target add ${_sparrow_triple} RESULT_VARIABLE _sparrow_rc) + if (NOT _sparrow_rc EQUAL 0) + message(FATAL_ERROR "rustup target add ${_sparrow_triple} failed.") + endif () + endif () + endif () + if (MSVC) + set(SPARROW_ARRANGE_LIB "${SPARROW_ARRANGE_OUT}/sparrow_arrange.lib") + else () + set(SPARROW_ARRANGE_LIB "${SPARROW_ARRANGE_OUT}/libsparrow_arrange.a") + endif () + + # Globbing the crate sources is enough to make ninja rerun cargo on any edit. + file(GLOB_RECURSE SPARROW_ARRANGE_SOURCES CONFIGURE_DEPENDS + "${SPARROW_ARRANGE_DIR}/src/*.rs" + "${SPARROW_ARRANGE_DIR}/vendor/sparrow/src/*.rs" + "${SPARROW_ARRANGE_DIR}/vendor/sparrow/Cargo.toml" + "${SPARROW_ARRANGE_DIR}/Cargo.toml" + "${SPARROW_ARRANGE_DIR}/Cargo.lock") + + add_custom_command( + OUTPUT "${SPARROW_ARRANGE_LIB}" + COMMAND "${CARGO_EXECUTABLE}" build --release --manifest-path "${SPARROW_ARRANGE_DIR}/Cargo.toml" ${SPARROW_ARRANGE_TARGET_ARGS} + DEPENDS ${SPARROW_ARRANGE_SOURCES} + WORKING_DIRECTORY "${SPARROW_ARRANGE_DIR}" + COMMENT "Building sparrow_arrange (cargo build --release)" + VERBATIM) + add_custom_target(sparrow_arrange_build DEPENDS "${SPARROW_ARRANGE_LIB}") + + add_library(sparrow_arrange STATIC IMPORTED GLOBAL) + set_target_properties(sparrow_arrange PROPERTIES + IMPORTED_LOCATION "${SPARROW_ARRANGE_LIB}" + INTERFACE_INCLUDE_DIRECTORIES "${SPARROW_ARRANGE_DIR}/include") + + add_dependencies(libslic3r sparrow_arrange_build) + target_compile_definitions(libslic3r PRIVATE SLIC3R_SPARROW_ARRANGE) + # Plain target_link_libraries() signature: libslic3r uses it everywhere else and + # CMake forbids mixing. Extra libs are from `rustc --print native-static-libs`. + target_link_libraries(libslic3r sparrow_arrange) + if (APPLE) + target_link_libraries(libslic3r iconv) + elseif (WIN32) + target_link_libraries(libslic3r ntdll.lib userenv.lib ws2_32.lib bcrypt.lib synchronization.lib dbghelp.lib advapi32.lib) + endif () +endif () + target_include_directories(libslic3r PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/TextureToColor PUBLIC ${CMAKE_CURRENT_BINARY_DIR}) target_include_directories(libslic3r PUBLIC ${EXPAT_INCLUDE_DIRS}) diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index e1355536cb1..b74c03d8d83 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -1781,6 +1781,87 @@ BoundingBoxf3 ModelObject::instance_convex_hull_bounding_box(const ModelInstance // Calculate 2D convex hull of of a projection of the transformed printable volumes into the XY plane. // This method is cheap in that it does not make any unnecessary copy of the volume meshes. // This method is used by the auto arrange function. +// BBS: true 2D outline of the printable volumes, cached per transformation like +// ModelVolume::get_convex_hull_2d(): a pure translation just shifts the contour. +const Polygon &ModelObject::true_outline_2d(const Transform3d &trafo_instance) const +{ + // assemble_transform() builds T * Rz * Ry * Rx * S, so the Z rotation commutes with + // the XY projection and the cached base contour is shared by every instance that + // differs only in Z rotation and position. + auto need_recompute = [](const Geometry::Transformation &old_transform, + const Geometry::Transformation &new_transform) -> bool { + const Vec3d &old_rotation = old_transform.get_rotation(); + const Vec3d &new_rotation = new_transform.get_rotation(); + return old_transform.get_scaling_factor() != new_transform.get_scaling_factor() + || old_transform.get_mirror() != new_transform.get_mirror() + || old_rotation.x() != new_rotation.x() + || old_rotation.y() != new_rotation.y(); + }; + + std::vector> volumes_key; + for (const ModelVolume *v : this->volumes) + if (v->is_model_part()) + volumes_key.emplace_back(v->mesh_ptr(), v->get_matrix().matrix()); + const bool volumes_changed = volumes_key != m_true_outline_volumes; + + if (volumes_changed || trafo_instance.matrix() != m_true_outline_trafo.matrix() || !m_true_outline_2d.is_valid()) { + Geometry::Transformation new_trans(trafo_instance), old_trans(m_true_outline_trafo); + + if (volumes_changed || need_recompute(old_trans, new_trans) || !m_true_outline_base.is_valid()) { + calculate_true_outline_2d(new_trans); + m_true_outline_volumes = std::move(volumes_key); + } + + m_true_outline_2d = m_true_outline_base; + m_true_outline_2d.rotate(new_trans.get_rotation(Z)); + m_true_outline_2d.translate(scale_(new_trans.get_offset(X)), scale_(new_trans.get_offset(Y))); + m_true_outline_trafo = trafo_instance; + } + + return m_true_outline_2d; +} + +void ModelObject::calculate_true_outline_2d(const Geometry::Transformation &transformation) const +{ + static const double OUTLINE_SIMPLIFY_TOLERANCE_MM = 0.2; + + // Measured: decimating with its_quadric_edge_collapse() before projecting made a + // 26M-face project 23x slower. project_mesh() is already ~50 ns/face. + + // Compute at zero XY offset and zero Z rotation; true_outline_2d() rotates + // and translates the result back. + const Transform3d trafo = Geometry::assemble_transform( + Vec3d(0., 0., transformation.get_offset().z()), + Vec3d(transformation.get_rotation().x(), transformation.get_rotation().y(), 0.), + transformation.get_scaling_factor(), transformation.get_mirror()); + + // One solid contour per object, holes dropped. Several disjoint islands collapse + // to their convex hull so none is lost. + Polygons projected; + for (const ModelVolume *v : this->volumes) { + if (!v->is_model_part()) + continue; + + append(projected, project_mesh(v->mesh().its, trafo * v->get_matrix(), []() {})); + } + + Polygon p; + ExPolygons islands = union_ex(projected); + if (islands.size() == 1) + p = std::move(islands.front().contour); + else if (islands.size() > 1) + p = Geometry::convex_hull(to_polygons(islands)); + if (p.points.size() >= 3) + p.douglas_peucker(scaled(OUTLINE_SIMPLIFY_TOLERANCE_MM)); + + if (p.points.size() < 3) { + // Projection produced nothing usable; fall back to the convex hull in the same frame. + p = convex_hull_2d(trafo); + } + + m_true_outline_base = std::move(p); +} + Polygon ModelObject::convex_hull_2d(const Transform3d& trafo_instance) const { #if 0 @@ -4464,6 +4545,31 @@ double ModelInstance::get_auto_brim_width() const return get_auto_brim_width(DeltaT, adhcoeff); } +//BBS: true 2D projected outline of the instance's model parts. +const Polygon &ModelInstance::true_outline_2d(const Transform3d &trafo_instance) const +{ + return get_object()->true_outline_2d(trafo_instance); +} + +//BBS: hull-first footprint test, used by the exclusion-area checks. +bool ModelInstance::footprint_intersects(const Polygons &polys, const Transform3d &trafo_instance, + const Point &offset) const +{ + if (polys.empty()) + return false; + + // The footprint is inside the convex hull, so a clear hull never pays for the projection. + Polygon hull = get_object()->convex_hull_2d(trafo_instance); + hull.translate(offset); + if (intersection(polys, hull).empty()) + return false; + + // Hull crosses the region: a concave or rotated part may still be clear. + Polygon outline = true_outline_2d(trafo_instance); + outline.translate(offset); + return !intersection(polys, outline).empty(); +} + void ModelInstance::get_arrange_polygon(void *ap, const Slic3r::DynamicPrintConfig &config_global) const { // static const double SIMPLIFY_TOLERANCE_MM = 0.1; @@ -4476,7 +4582,11 @@ void ModelInstance::get_arrange_polygon(void *ap, const Slic3r::DynamicPrintConf trafo_instance.set_offset(Vec3d(0, 0, get_offset(Z))); - Polygon p = get_object()->convex_hull_2d(trafo_instance.get_matrix()); + // BBS: the sparrow packer places concave outlines, so give it the real 2D + // silhouette instead of the convex hull. Same trafo and item-local frame. + Polygon p = arrangement::use_true_outline.load(std::memory_order_relaxed) + ? true_outline_2d(trafo_instance.get_matrix()) + : get_object()->convex_hull_2d(trafo_instance.get_matrix()); // if (!p.points.empty()) { // Polygons pp{p}; diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 83440abfacf..7ef10a021f1 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -470,6 +470,11 @@ class ModelObject final : public ObjectBase // This method is used by the auto arrange function. Polygon convex_hull_2d(const Transform3d &trafo_instance) const; + // BBS: true 2D outline (XY projection of the transformed printable volumes, largest + // contour only). Cached per transformation like ModelVolume's 2D convex hull. + const Polygon &true_outline_2d(const Transform3d &trafo_instance) const; + void invalidate_true_outline_2d() const { m_true_outline_2d.clear(); m_true_outline_base.clear(); } + void center_around_origin(bool include_modifiers = true); void ensure_on_bed(bool allow_negative_z = false); @@ -667,6 +672,15 @@ class ModelObject final : public ObjectBase mutable BoundingBoxf3 m_raw_mesh_bounding_box; mutable bool m_raw_mesh_bounding_box_valid; + // BBS: true 2D outline cache, mirrors ModelVolume's m_convex_hull_2d trio. + // m_true_outline_base is at zero XY offset, m_true_outline_2d at m_true_outline_trafo. + mutable Polygon m_true_outline_2d; + mutable Polygon m_true_outline_base; + mutable Transform3d m_true_outline_trafo{Transform3d::Identity()}; + // Fingerprint of the volumes the base contour was built from; makes the cache self-invalidating. + mutable std::vector> m_true_outline_volumes; + void calculate_true_outline_2d(const Geometry::Transformation &transformation) const; + // Called by Print::apply() to set the model pointer after making a copy. friend class Print; friend class SLAPrint; @@ -1083,6 +1097,8 @@ class ModelVolume final : public ObjectBase void invalidate_convex_hull_2d() { m_convex_hull_2d.clear(); + // BBS: the object's true-outline cache is built from this mesh too. + if (object) object->invalidate_true_outline_2d(); } // Get count of errors in the mesh @@ -1522,6 +1538,16 @@ class ModelInstance final : public ObjectBase Polygon convex_hull_2d(); void invalidate_convex_hull_2d(); + // BBS: true 2D projected outline of this instance under `trafo_instance` (same + // frame as ModelObject::convex_hull_2d). Falls back to the convex hull. + const Polygon &true_outline_2d(const Transform3d &trafo_instance) const; + + // BBS: does this instance's footprint intersect `polys`? `trafo_instance` and + // `offset` place the instance into the frame of `polys`. Tests the convex hull + // first and only pays for the true outline when the hull intersects. + bool footprint_intersects(const Polygons &polys, const Transform3d &trafo_instance, + const Point &offset = Point(0, 0)) const; + // Getting the input polygon for arrange // We use void* as input type to avoid including Arrange.hpp in Model.hpp. void get_arrange_polygon(void *arrange_polygon, const Slic3r::DynamicPrintConfig &config = Slic3r::DynamicPrintConfig()) const; diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index dc4806b10d1..26851997751 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -683,9 +683,13 @@ StringObjectException Print::sequential_print_clearance_valid(const Print &print // Convert the shift from the PrintObject's coordinates into ModelObject's coordinates by removing the centering offset. convex_hull.translate(instance.shift - print_object->center_offset()); } - convex_hull_no_offset.translate(instance.shift - print_object->center_offset()); //juedge the exclude area - if (!intersection(exclude_polys, convex_hull_no_offset).empty()) { + // BBS: test the true footprint, not the hull -- a concave or rotated + // part whose hull clips the exclusion zone can still be clear of it. + Geometry::Transformation excl_trans(instance.model_instance->get_transformation()); + excl_trans.set_offset({0.0, 0.0, instance.model_instance->get_offset().z()}); + if (instance.model_instance->footprint_intersects(exclude_polys, excl_trans.get_matrix(), + instance.shift - print_object->center_offset())) { if (single_object_exception.string.empty()) { single_object_exception.string = (boost::format(L("%1% is too close to exclusion area, there may be collisions when printing.")) %instance.model_instance->get_object()->name).str(); single_object_exception.object = instance.model_instance->get_object(); @@ -971,6 +975,17 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, std::map map_model_volume_to_convex_hull; Polygons convex_hulls_other; for (auto& inst : print_instances_ordered) { + // BBS: exclusion check once per instance on the true footprint, not per + // volume on the hull -- a concave or rotated part whose hull clips the + // exclusion zone can still be clear of it. + if (inst->model_instance->footprint_intersects( + exclude_polys, + Geometry::assemble_transform(Vec3d::Zero(), inst->model_instance->get_rotation(), + inst->model_instance->get_scaling_factor(), inst->model_instance->get_mirror()), + inst->shift - inst->print_object->center_offset())) { + return {inst->model_instance->get_object()->name + L(" is too close to exclusion area, there may be collisions when printing.") + "\n", + inst->model_instance->get_object()}; + } for (const ModelVolume *v : inst->print_object->model_object()->volumes) { if (!v->is_model_part()) continue; auto it_convex_hull = map_model_volume_to_convex_hull.find(v); @@ -984,11 +999,6 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, Polygon &convex_hull = it_convex_hull->second; Polygons convex_hulls_temp; convex_hulls_temp.push_back(convex_hull); - if (!intersection(exclude_polys, convex_hull).empty()) { - return {inst->model_instance->get_object()->name + L(" is too close to exclusion area, there may be collisions when printing.") + "\n", - inst->model_instance->get_object()}; - } - if (print_config.enable_wrapping_detection.value && !intersection(wrapping_poly, convex_hull).empty()) { return {inst->model_instance->get_object()->name + L(" is too close to clumping detection area, there may be collisions when printing.") + "\n", inst->model_instance->get_object()}; diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index ea897dd4af9..30ef3705987 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -1355,23 +1355,24 @@ const double GLCanvas3D::DefaultCameraZoomToPlateMarginFactor = 1.25; void GLCanvas3D::load_arrange_settings() { - std::string dist_fff_str = - wxGetApp().app_config->get("arrange", "min_object_distance"); - - std::string dist_fff_seq_print_str = - wxGetApp().app_config->get("arrange", "min_object_distance_seq_print"); - - std::string dist_sla_str = - wxGetApp().app_config->get("arrange", "min_object_distance_sla"); - - std::string en_rot_fff_str = - wxGetApp().app_config->get("arrange", "enable_rotation"); + // BBS: the popup saves fff keys with the "_fff" postfix but older builds read the + // bare key, so try the postfixed key first. JSON booleans come back as "true"/"false". + auto cfg = [](const std::string &key, const std::string &postfix) { + std::string v = wxGetApp().app_config->get("arrange", key + postfix); + return v.empty() && !postfix.empty() ? wxGetApp().app_config->get("arrange", key) : v; + }; + auto to_bool = [](const std::string &v) { return v == "1" || v == "yes" || v == "true"; }; - std::string en_rot_fff_seqp_str = - wxGetApp().app_config->get("arrange", "enable_rotation_seq_print"); + std::string dist_fff_str = cfg("min_object_distance", "_fff"); + std::string dist_fff_seq_print_str = cfg("min_object_distance", "_fff_seq_print"); + std::string dist_sla_str = cfg("min_object_distance", "_sla"); + std::string en_rot_fff_str = cfg("enable_rotation", "_fff"); + std::string en_rot_fff_seqp_str = cfg("enable_rotation", "_fff_seq_print"); + std::string en_rot_sla_str = cfg("enable_rotation", "_sla"); - std::string en_rot_sla_str = - wxGetApp().app_config->get("arrange", "enable_rotation_sla"); + //BBS: one un-postfixed key, applied to every settings variant + std::string use_sparrow_str = cfg("arrange_use_sparrow", ""); + std::string sparrow_time_str = cfg("arrange_sparrow_time", ""); if (!dist_fff_str.empty()) m_arrange_settings_fff.distance = std::stof(dist_fff_str); @@ -1383,13 +1384,27 @@ void GLCanvas3D::load_arrange_settings() m_arrange_settings_sla.distance = std::stof(dist_sla_str); if (!en_rot_fff_str.empty()) - m_arrange_settings_fff.enable_rotation = (en_rot_fff_str == "1" || en_rot_fff_str == "yes"); + m_arrange_settings_fff.enable_rotation = to_bool(en_rot_fff_str); if (!en_rot_fff_seqp_str.empty()) - m_arrange_settings_fff_seq_print.enable_rotation = (en_rot_fff_seqp_str == "1" || en_rot_fff_seqp_str == "yes"); + m_arrange_settings_fff_seq_print.enable_rotation = to_bool(en_rot_fff_seqp_str); if (!en_rot_sla_str.empty()) - m_arrange_settings_sla.enable_rotation = (en_rot_sla_str == "1" || en_rot_sla_str == "yes"); + m_arrange_settings_sla.enable_rotation = to_bool(en_rot_sla_str); + + if (!use_sparrow_str.empty()) { + bool use_sparrow = to_bool(use_sparrow_str); + m_arrange_settings_fff.arrange_use_sparrow = use_sparrow; + m_arrange_settings_fff_seq_print.arrange_use_sparrow = use_sparrow; + m_arrange_settings_sla.arrange_use_sparrow = use_sparrow; + } + + if (!sparrow_time_str.empty()) { + float t = std::stof(sparrow_time_str); + m_arrange_settings_fff.arrange_sparrow_time = t; + m_arrange_settings_fff_seq_print.arrange_sparrow_time = t; + m_arrange_settings_sla.arrange_sparrow_time = t; + } //BBS: add specific arrange settings m_arrange_settings_fff_seq_print.is_seq_print = true; @@ -7446,6 +7461,8 @@ bool GLCanvas3D::_render_arrange_menu(float left, float toolbar_height) std::string avoid_extrusion_key = "avoid_extrusion_cali_region"; std::string align_to_y_axis_key = "align_to_y_axis"; std::string save_svg_key = "save_svg"; + std::string use_sparrow_key = "arrange_use_sparrow"; + std::string sparrow_time_key = "arrange_sparrow_time"; std::string postfix = settings.postfix; //BBS: bool seq_print = settings.is_seq_print; @@ -7486,6 +7503,31 @@ bool GLCanvas3D::_render_arrange_menu(float left, float toolbar_height) settings_changed = true; } + if (imgui->bbl_checkbox(_L("Use experimental packer"), settings.arrange_use_sparrow)) { + settings_out.arrange_use_sparrow = settings.arrange_use_sparrow; + appcfg->set("arrange", use_sparrow_key.c_str(), settings_out.arrange_use_sparrow ? "1" : "0"); + settings_changed = true; + } + + if (settings.arrange_use_sparrow) { + // Label on its own line: it is wider than "Spacing", whose width sets the slider column. + imgui->text(_L("Search time per plate (s)")); + ImGui::AlignTextToFramePadding(); + ImGui::Dummy(ImVec2(0, 0)); + ImGui::SameLine(1.2 * cursor_slider_left); + ImGui::PushItemWidth(window_width - slider_icon_width); + bool b_sparrow_time = imgui->bbl_slider_float_style("##SparrowTime", &settings.arrange_sparrow_time, 2.0f, 60.0f, "%.0f"); + ImGui::SameLine(window_width - slider_icon_width + 1.3 * cursor_slider_left); + ImGui::PushItemWidth(1.5 * slider_icon_width); + bool b_sparrow_time_input = ImGui::BBLDragFloat("##sparrow_time_input", &settings.arrange_sparrow_time, 1.0f, 0.0f, 0.0f, "%.0f"); + if (b_sparrow_time || b_sparrow_time_input) { + settings.arrange_sparrow_time = std::round(std::min(60.f, std::max(2.f, settings.arrange_sparrow_time))); + settings_out.arrange_sparrow_time = settings.arrange_sparrow_time; + appcfg->set("arrange", sparrow_time_key.c_str(), float_to_string_decimal_point(settings_out.arrange_sparrow_time)); + settings_changed = true; + } + } + if (imgui->bbl_checkbox(_L("Allow multiple materials on same plate"), settings.allow_multi_materials_on_same_plate)) { settings_out.allow_multi_materials_on_same_plate = settings.allow_multi_materials_on_same_plate; appcfg->set("arrange", multi_material_key.c_str(), settings_out.allow_multi_materials_on_same_plate ? "1" : "0"); @@ -7544,6 +7586,8 @@ bool GLCanvas3D::_render_arrange_menu(float left, float toolbar_height) appcfg->set("arrange", dist_key, float_to_string_decimal_point(settings_out.distance)); appcfg->set("arrange", rot_key, settings_out.enable_rotation ? "1" : "0"); appcfg->set("arrange", align_to_y_axis_key, settings_out.align_to_y_axis ? "1" : "0"); + appcfg->set("arrange", use_sparrow_key, settings_out.arrange_use_sparrow ? "1" : "0"); + appcfg->set("arrange", sparrow_time_key, float_to_string_decimal_point(settings_out.arrange_sparrow_time)); settings_changed = true; } ImGui::PopStyleVar(1); diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 059c856205c..6440b18cc63 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -568,6 +568,10 @@ class GLCanvas3D //BBS: add more arrangeSettings bool is_seq_print = false; bool align_to_y_axis = false; + //BBS: use the experimental sparrow packer + bool arrange_use_sparrow = false; + //BBS: sparrow search budget, seconds per plate + float arrange_sparrow_time = 8.f; bool save_svg = false; // for debug std::string postfix; void reset() @@ -579,6 +583,8 @@ class GLCanvas3D avoid_extrusion_cali_region = true; is_seq_print = false; align_to_y_axis = false; + arrange_use_sparrow = false; + arrange_sparrow_time = 8.f; } }; diff --git a/src/slic3r/GUI/Jobs/ArrangeJob.cpp b/src/slic3r/GUI/Jobs/ArrangeJob.cpp index 7a3805bb839..34b90ca78c1 100644 --- a/src/slic3r/GUI/Jobs/ArrangeJob.cpp +++ b/src/slic3r/GUI/Jobs/ArrangeJob.cpp @@ -614,6 +614,13 @@ void ArrangeJob::prepare() m_plater->get_notification_manager()->push_notification(NotificationType::ArrangeOngoing, NotificationManager::NotificationLevel::RegularNotificationLevel, _u8L("Arranging") + "..."); m_plater->get_notification_manager()->bbl_close_plateinfo_notification(); + // After the close above, or the toast is gone before it is seen. + if (m_plater->canvas3D()->get_arrange_settings().arrange_use_sparrow && !params.use_sparrow) { + wxString why = params.is_seq_print ? _L("print sequence is By Object") : _L("\"Allow multiple materials on same plate\" is off"); + m_plater->get_notification_manager()->push_notification(NotificationType::BBLPlateInfo, + NotificationManager::NotificationLevel::WarningNotificationLevel, + into_u8(wxString::Format(_L("Experimental packer skipped: %s.\nUsing the standard arranger."), why))); + } } } @@ -917,6 +924,7 @@ arrangement::ArrangeParams init_arrange_params(Plater *p) params.nozzle_height = print.config().nozzle_height.value; params.align_center = print_config.best_object_pos.value; params.allow_rotations = settings.enable_rotation; + params.sparrow_time_limit_s = settings.arrange_sparrow_time; params.allow_multi_materials_on_same_plate = settings.allow_multi_materials_on_same_plate; params.avoid_extrusion_cali_region = settings.avoid_extrusion_cali_region; params.is_seq_print = settings.is_seq_print; @@ -941,6 +949,13 @@ arrangement::ArrangeParams init_arrange_params(Plater *p) params.bed_shrink_x = BED_SHRINK_SEQ_PRINT; params.bed_shrink_y = BED_SHRINK_SEQ_PRINT; } + + // Decided after is_seq_print is final, so the outline flag and the "packer + // skipped" toast in prepare() agree with what arrangement::arrange() runs. + params.use_sparrow = settings.arrange_use_sparrow && !params.is_seq_print && params.allow_multi_materials_on_same_plate; + // Set before the ArrangePolygons are collected. The flag stays set until the + // next arrange, so any get_arrange_polygon() caller in between sees it too. + arrangement::use_true_outline.store(params.use_sparrow, std::memory_order_relaxed); return params; } diff --git a/src/slic3r/GUI/Jobs/FillBedJob.cpp b/src/slic3r/GUI/Jobs/FillBedJob.cpp index e5b82ac7a7e..37c77ffe0fb 100644 --- a/src/slic3r/GUI/Jobs/FillBedJob.cpp +++ b/src/slic3r/GUI/Jobs/FillBedJob.cpp @@ -32,6 +32,8 @@ void FillBedJob::prepare() m_bedpts.clear(); params = init_arrange_params(m_plater); + // Fill Bed relies on on_packed / priority, which the sparrow backend ignores. + params.use_sparrow = false; m_object_idx = m_plater->get_selected_object_idx(); if (m_object_idx == -1) diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 3f3ec17122e..9d937ca1367 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -2669,7 +2669,6 @@ bool PartPlate::check_outside(int obj_id, int instance_id, BoundingBoxf3* boundi ModelInstance* instance = object->instances[instance_id]; BoundingBoxf3 instance_box = bounding_box? *bounding_box: object->instance_convex_hull_bounding_box(instance_id); - Polygon hull = instance->convex_hull_2d(); BoundingBoxf3 plate_box = get_plate_box(); if (instance_box.max.z() > plate_box.min.z()) plate_box.min.z() += instance_box.min.z(); // not considering outsize if sinking @@ -2678,17 +2677,13 @@ bool PartPlate::check_outside(int obj_id, int instance_id, BoundingBoxf3* boundi { if (m_exclude_bounding_box.size() > 0) { - int index; - for (index = 0; index < m_exclude_bounding_box.size(); index ++) - { - Polygon p = m_exclude_bounding_box[index].polygon(true); // instance convex hull is scaled, so we need to scale here - if (intersection({ p }, { hull }).empty() == false) - //if (m_exclude_bounding_box[index].intersects(instance_box)) - { - break; - } - } - if (index >= m_exclude_bounding_box.size()) + // BBS: test the true footprint, not the hull -- a concave or rotated part + // whose hull clips the exclusion zone can still be clear of it. + Polygons exclude_polys; + exclude_polys.reserve(m_exclude_bounding_box.size()); + for (size_t index = 0; index < m_exclude_bounding_box.size(); index++) + exclude_polys.push_back(m_exclude_bounding_box[index].polygon(true)); // instance convex hull is scaled, so we need to scale here + if (!instance->footprint_intersects(exclude_polys, instance->get_matrix(false))) outside = false; } else diff --git a/src/sparrow_arrange/.gitignore b/src/sparrow_arrange/.gitignore new file mode 100644 index 00000000000..2f7896d1d13 --- /dev/null +++ b/src/sparrow_arrange/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/src/sparrow_arrange/Cargo.lock b/src/sparrow_arrange/Cargo.lock new file mode 100644 index 00000000000..930fbfc1a88 --- /dev/null +++ b/src/sparrow_arrange/Cargo.lock @@ -0,0 +1,787 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "geo" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d640a4dd1d1c98b45f4653c841a8ec15f461a71b86bc30533ae64c6f20f268" +dependencies = [ + "float_next_after", + "geo-types", + "geographiclib-rs", + "log", + "num-traits", + "robust", + "rstar", +] + +[[package]] +name = "geo-buffer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267bf0373df2f0b0b05065ebc0c84b97ccd221e19e0cb9442bcffe8fce04c130" +dependencies = [ + "geo", + "geo-types", +] + +[[package]] +name = "geo-types" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777d18aa0f12f8b285331cd867133ee14422b3f023f6d388034c47d43e28786a" +dependencies = [ + "approx", + "num-traits", + "rstar", + "serde", + "thiserror", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jagua-rs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d6a65f5a7cc1b179d29cc774c2e1c80ddc9064e4aea3cf50c85f222022c97d" +dependencies = [ + "anyhow", + "document-features", + "float-cmp", + "geo-buffer", + "geo-types", + "getrandom", + "itertools", + "log", + "ndarray", + "ordered-float", + "rand_distr", + "rayon", + "serde", + "slotmap", + "svg", + "web-time", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "numfmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1a14c0c3b00c5b3f3ab9625c35601c5cac06a94bb6b17c27327a8f1d520c6" +dependencies = [ + "dtoa", + "itoa", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "robust" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5864e7ef1a6b7bcf1d6ca3f655e65e724ed3b52546a0d0a663c991522f552ea" + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "sparrow" +version = "0.1.0" +dependencies = [ + "float-cmp", + "itertools", + "jagua-rs", + "log", + "numfmt", + "ordered-float", + "rand", + "rayon", + "slotmap", + "tap", +] + +[[package]] +name = "sparrow_arrange" +version = "0.1.0" +dependencies = [ + "jagua-rs", + "log", + "rand", + "serde_json", + "sparrow", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "svg" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94afda9cd163c04f6bee8b4bf2501c91548deae308373c436f36aeff3cf3c4a3" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/src/sparrow_arrange/Cargo.toml b/src/sparrow_arrange/Cargo.toml new file mode 100644 index 00000000000..bb75456be02 --- /dev/null +++ b/src/sparrow_arrange/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "sparrow_arrange" +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-only" + +[lib] +crate-type = ["staticlib"] + +[dependencies] +jagua-rs = "0.8.0" +sparrow = { path = "vendor/sparrow" } +rand = "0.10" +log = "0.4" +serde_json = "1.0" + +[profile.release] +opt-level = 3 +lto = "thin" +debug = false +strip = "debuginfo" diff --git a/src/sparrow_arrange/include/sparrow_arrange.h b/src/sparrow_arrange/include/sparrow_arrange.h new file mode 100644 index 00000000000..d81e84e1d95 --- /dev/null +++ b/src/sparrow_arrange/include/sparrow_arrange.h @@ -0,0 +1,59 @@ +// C ABI between libslic3r (C++) and the sparrow_arrange Rust crate. +// All lengths in millimetres, angles in radians, bed origin at bottom-left (0,0). +// Pose semantics match Slic3r::arrangement::ArrangePolygon::transformed_poly(): +// world = rotate(outline, rotation) + (x, y) +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { double x, y; } sp_point; + +/* Simple polygon (outer contour only), either winding, not self-intersecting. */ +typedef struct { const sp_point *pts; size_t n; } sp_polygon; + +typedef struct { + sp_polygon outline; /* item-local coordinates */ + int fixed; /* 1 = immovable, already placed on bed `bed_idx` at (x,y,rotation) */ + int bed_idx; /* fixed items: bed index (>=0). movable items: ignored on input */ + double x, y, rotation;/* fixed items: pose. movable items: optional hint, may be ignored */ + int allow_rotation;/* movable items: 1 = any rotation, 0 = rotation must stay 0 */ +} sp_item; + +typedef struct { + double bed_w, bed_h; /* usable bed rectangle [0,bed_w] x [0,bed_h] */ + const sp_polygon *holes; /* exclusion regions in bed coords, identical on every bed */ + size_t n_holes; + const sp_item *items; + size_t n_items; + int max_beds; /* upper bound on beds to create (>=1) */ + double time_limit_s; /* wall-clock budget PER BED, best effort; total + * runtime scales with the number of beds used. + * A bed that packs everything returns early. */ + uint64_t seed; + int (*should_stop)(void *user); /* optional cancel poll, may be NULL */ + void *user; + /* optional progress callback, may be NULL. Called when a bed starts and each time + * an item is committed: bed_idx = current bed (0-based), placed = movable items + * placed so far across all beds, total = movable items. */ + void (*on_progress)(void *user, int bed_idx, int placed, int total); +} sp_input; + +typedef struct { + int bed_idx; /* -1 = could not be placed on any bed */ + double x, y, rotation; +} sp_placement; + +/* Items are pre-inflated by the caller (spacing is baked into outlines), so + * touching-but-not-overlapping placements are valid. + * `out` must have room for in->n_items entries, written in input order + * (fixed items are echoed back unchanged). + * Returns 0 on success, non-zero on invalid input. Never throws/panics across FFI. */ +int sparrow_arrange(const sp_input *in, sp_placement *out); + +#ifdef __cplusplus +} +#endif diff --git a/src/sparrow_arrange/src/lib.rs b/src/sparrow_arrange/src/lib.rs new file mode 100644 index 00000000000..60c3f8c628b --- /dev/null +++ b/src/sparrow_arrange/src/lib.rs @@ -0,0 +1,162 @@ +//! C ABI for the sparrow_arrange nesting library. Mirrors include/sparrow_arrange.h. +//! +//! Every entry point is wrapped in `catch_unwind`: unwinding across the FFI boundary is +//! undefined behaviour, so a panic is converted into a non-zero return instead. + +#![allow(non_camel_case_types)] + +pub mod pack; + +#[cfg(test)] +mod tests; + +use pack::{ItemIn, Params}; +use std::os::raw::{c_int, c_void}; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct sp_point { + pub x: f64, + pub y: f64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct sp_polygon { + pub pts: *const sp_point, + pub n: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct sp_item { + pub outline: sp_polygon, + pub fixed: c_int, + pub bed_idx: c_int, + pub x: f64, + pub y: f64, + pub rotation: f64, + pub allow_rotation: c_int, +} + +#[repr(C)] +pub struct sp_input { + pub bed_w: f64, + pub bed_h: f64, + pub holes: *const sp_polygon, + pub n_holes: usize, + pub items: *const sp_item, + pub n_items: usize, + pub max_beds: c_int, + pub time_limit_s: f64, + pub seed: u64, + pub should_stop: Option c_int>, + pub user: *mut c_void, + pub on_progress: Option, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct sp_placement { + pub bed_idx: c_int, + pub x: f64, + pub y: f64, + pub rotation: f64, +} + +const OK: c_int = 0; +const ERR_INVALID: c_int = 1; +const ERR_PANIC: c_int = 2; + +unsafe fn read_polygon(p: &sp_polygon) -> Vec<(f64, f64)> { + if p.pts.is_null() || p.n == 0 { + return Vec::new(); + } + unsafe { std::slice::from_raw_parts(p.pts, p.n) } + .iter() + .map(|q| (q.x, q.y)) + .collect() +} + +/// # Safety +/// `in_` must point to a valid `sp_input` and `out` to at least `in_->n_items` +/// writable `sp_placement`s. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sparrow_arrange(in_: *const sp_input, out: *mut sp_placement) -> c_int { + catch_unwind(AssertUnwindSafe(|| unsafe { run(in_, out) })).unwrap_or(ERR_PANIC) +} + +unsafe fn run(in_: *const sp_input, out: *mut sp_placement) -> c_int { + if in_.is_null() || out.is_null() { + return ERR_INVALID; + } + let inp = unsafe { &*in_ }; + if !inp.bed_w.is_finite() || !inp.bed_h.is_finite() || inp.bed_w <= 0.0 || inp.bed_h <= 0.0 { + return ERR_INVALID; + } + if inp.max_beds < 1 { + return ERR_INVALID; + } + if (inp.n_items > 0 && inp.items.is_null()) || (inp.n_holes > 0 && inp.holes.is_null()) { + return ERR_INVALID; + } + + let raw_items = if inp.n_items == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(inp.items, inp.n_items) } + }; + let raw_holes = if inp.n_holes == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(inp.holes, inp.n_holes) } + }; + + let holes: Vec> = raw_holes.iter().map(|h| unsafe { read_polygon(h) }).collect(); + let items: Vec = raw_items + .iter() + .map(|it| ItemIn { + outline: unsafe { read_polygon(&it.outline) }, + fixed: it.fixed != 0, + bed_idx: it.bed_idx, + x: it.x, + y: it.y, + rotation: it.rotation, + allow_rotation: it.allow_rotation != 0, + }) + .collect(); + + let user = inp.user; + let cb = inp.should_stop; + let stop = move || -> bool { + match cb { + Some(f) => (unsafe { f(user) }) != 0, + None => false, + } + }; + + // Reported only from this thread: `pack::arrange` never hands the sink to a worker. + let total_movable = raw_items.iter().filter(|it| it.fixed == 0).count(); + let on_progress = inp.on_progress; + let progress = move |bed_idx: i32, placed: usize| { + if let Some(f) = on_progress { + unsafe { f(user, bed_idx, placed as c_int, total_movable as c_int) }; + } + }; + + let params = Params { + bed_w: inp.bed_w, + bed_h: inp.bed_h, + max_beds: inp.max_beds, + time_limit_s: inp.time_limit_s, + seed: inp.seed, + }; + + let placements = pack::arrange(¶ms, &holes, &items, &stop, &progress); + let out_slice = unsafe { std::slice::from_raw_parts_mut(out, inp.n_items) }; + for (o, p) in out_slice.iter_mut().zip(placements) { + *o = sp_placement { bed_idx: p.bed_idx, x: p.x, y: p.y, rotation: p.rotation }; + } + OK +} diff --git a/src/sparrow_arrange/src/pack.rs b/src/sparrow_arrange/src/pack.rs new file mode 100644 index 00000000000..c7a1eeba8f4 --- /dev/null +++ b/src/sparrow_arrange/src/pack.rs @@ -0,0 +1,595 @@ +//! Multi-bed nesting driven by sparrow's overlap-tolerant separator. +//! +//! Per bed we build a fixed container (the bed rectangle), drop the candidate items in with +//! overlap allowed, and let `Separator::separate` drive total overlap to zero by guided +//! local search. If it cannot, we evict the worst item and try again. +//! +//! Pose contract (same as the C header): world = rotate(outline, rotation) + (x, y), +//! about the item-local origin. Shapes are built with an identity `pre_transform`, so +//! a `DTransformation` maps 1:1 onto the header; jagua-rs' importer would re-centre them. + +use jagua_rs::collision_detection::CDEConfig; +use jagua_rs::entities::Item; +use jagua_rs::geometry::fail_fast::SPSurrogateConfig; +use jagua_rs::geometry::geo_enums::RotationRange; +use jagua_rs::geometry::geo_traits::TransformableFrom; +use jagua_rs::geometry::primitives::{Point, Rect, SPolygon}; +use jagua_rs::geometry::shape_modification::{ShapeModifyConfig, ShapeModifyMode}; +use jagua_rs::geometry::{DTransformation, OriginalShape}; +use jagua_rs::probs::spp::entities::{SPInstance, SPPlacement, SPProblem, Strip}; +use rand::{RngExt, SeedableRng}; +use rand::rngs::Xoshiro256PlusPlus; +use sparrow::consts::LBF_SAMPLE_CONFIG; +use sparrow::eval::lbf_evaluator::LBFEvaluator; +use sparrow::eval::sample_eval::SampleEval; +use sparrow::optimizer::separator::{Separator, SeparatorConfig}; +use sparrow::sample::search::{SampleConfig, search_placement}; +use sparrow::sample::uniform_sampler::UniformBBoxSampler; +use sparrow::util::listener::DummySolListener; +use sparrow::util::terminator::Terminator; +use std::time::{Duration, Instant}; + +/// Caller-facing item, already in plain Rust types. +pub struct ItemIn { + pub outline: Vec<(f64, f64)>, + pub fixed: bool, + pub bed_idx: i32, + pub x: f64, + pub y: f64, + pub rotation: f64, + pub allow_rotation: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Out { + pub bed_idx: i32, + pub x: f64, + pub y: f64, + pub rotation: f64, +} + +impl Out { + const UNPLACED: Out = Out { bed_idx: -1, x: 0.0, y: 0.0, rotation: 0.0 }; +} + +pub struct Params { + pub bed_w: f64, + pub bed_h: f64, + pub max_beds: i32, + pub time_limit_s: f64, + pub seed: u64, +} + +// Lifted from sparrow's DEFAULT_SPARROW_CONFIG. +const CDE_CONFIG: CDEConfig = CDEConfig { + quadtree_depth: 4, + cd_threshold: 16, + item_surrogate_config: SPSurrogateConfig { + n_pole_limits: [(64, 0.0), (16, 0.8), (8, 0.9)], + ff_pole_area_ratio: 0.5, + n_ff_piers: 0, + }, +}; + +const NO_MODIFY: ShapeModifyConfig = ShapeModifyConfig { + simplify_tolerance: None, + offset: None, + narrow_concavity_cutoff: None, +}; + +const SEP_SAMPLE_CONFIG: SampleConfig = + SampleConfig { n_container_samples: 50, n_focussed_samples: 25, n_coord_descents: 3 }; + +/// Initial fill target, as a fraction of obstacle-free area: just past capacity. +const TARGET_FILL: f32 = 0.92; + +/// Evict-or-add attempts per bed; also the number of slices the budget is split into. +const MAX_ATTEMPTS: u32 = 8; + +/// Termination for the separator: the caller's deadline plus the caller's cancel poll. +struct Deadline<'a> { + until: Instant, + stop: &'a dyn Fn() -> bool, +} + +impl Terminator for Deadline<'_> { + fn kill(&self) -> bool { + Instant::now() >= self.until || (self.stop)() + } + fn new_timeout(&mut self, timeout: Duration) { + self.until = Instant::now() + timeout; + } + fn timeout_at(&self) -> Option { + Some(self.until) + } +} + +/// Bounding-box fit test at 1 degree steps (0 only when rotation is locked). The +/// 16-angle sampler is too coarse to prove an item cannot fit. Returns the first +/// fitting angle and the rotated bbox centre. +fn fit_angle(vertices: &[Point], bed: Rect, allow_rotation: bool) -> Option<(f32, Point)> { + let steps = if allow_rotation { 180 } else { 1 }; + (0..steps).find_map(|i| { + let a = (i as f32).to_radians(); + let (s, c) = a.sin_cos(); + let (mut x0, mut x1, mut y0, mut y1) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN); + for p in vertices { + let (x, y) = (p.0 * c - p.1 * s, p.0 * s + p.1 * c); + x0 = x0.min(x); + x1 = x1.max(x); + y0 = y0.min(y); + y1 = y1.max(y); + } + (x1 - x0 <= bed.width() && y1 - y0 <= bed.height()) + .then(|| (a, Point((x0 + x1) * 0.5, (y0 + y1) * 0.5))) + }) +} + +fn to_spolygon(pts: &[(f64, f64)]) -> Option { + let mut v: Vec = Vec::with_capacity(pts.len()); + for &(x, y) in pts { + if !x.is_finite() || !y.is_finite() { + return None; + } + let p = Point(x as f32, y as f32); + if v.last().is_none_or(|l| *l != p) { + v.push(p); + } + } + while v.len() > 1 && v[0] == v[v.len() - 1] { + v.pop(); + } + if v.len() < 3 { + return None; + } + SPolygon::new(v).ok() +} + +/// Items are centred on their centroid, as jagua-rs' importer does: `UniformBBoxSampler` +/// intersects translation and container ranges, which needs the shape to straddle the +/// origin. `centering` is undone in `to_header_pose`. +fn centering(shape: &SPolygon) -> Point { + shape.centroid() +} + +fn original(shape: SPolygon) -> OriginalShape { + let c = centering(&shape); + OriginalShape { + shape, + pre_transform: DTransformation::new(0.0, (-c.0, -c.1)), + modify_mode: ShapeModifyMode::Inflate, + modify_config: NO_MODIFY, + } +} + +fn make_item(id: usize, shape: SPolygon, rot: RotationRange) -> Option { + Item::new(id, original(shape), rot, None, CDE_CONFIG.item_surrogate_config).ok() +} + +/// Converts a jagua-rs placement of the *centred* shape back to the header's contract, +/// `world = rotate(outline, rotation) + (x, y)`. +/// +/// jagua-rs places `outline - c` at `(r, t)`, giving +/// `world = rotate(outline, r) - rotate(c, r) + t`, so `(x, y) = t - rotate(c, r)`. +fn to_header_pose(dt: DTransformation, c: Point) -> (f64, f64, f64) { + let r = dt.rotation(); + let (t_x, t_y) = dt.translation(); + let (sin, cos) = r.sin_cos(); + let x = t_x - (c.0 * cos - c.1 * sin); + let y = t_y - (c.0 * sin + c.1 * cos); + (f64::from(x), f64::from(y), f64::from(r)) +} + +/// Continuous is genuinely continuous: sparrow seeds 16 evenly spaced angles and then +/// refines the rotation by coordinate descent ("wiggle"). +fn rotation_range(allow: bool) -> RotationRange { + if allow { RotationRange::Continuous } else { RotationRange::None } +} + +/// One movable item awaiting placement. +struct Candidate { + out_idx: usize, + shape: SPolygon, + allow_rotation: bool, + centering: Point, +} + +/// Progress sink: `(bed_idx, movable items placed so far)`. Only invoked from the +/// thread that called [`arrange`]; the separator's workers have returned by then. +pub type Progress<'a> = &'a dyn Fn(i32, usize); + +pub fn arrange( + p: &Params, + holes: &[Vec<(f64, f64)>], + items: &[ItemIn], + stop: &dyn Fn() -> bool, + progress: Progress, +) -> Vec { + let mut out = vec![Out::UNPLACED; items.len()]; + let bed_w = p.bed_w as f32; + let bed_h = p.bed_h as f32; + let max_beds = p.max_beds.max(1); + let Ok(bed_rect) = Rect::try_new(0.0, 0.0, bed_w, bed_h) else { + progress(0, 0); + return out; + }; + + // Fixed items keep their pose and, with the holes, form each bed's forbidden region. + let mut obstacles: Vec> = vec![Vec::new(); max_beds as usize]; + let hole_shapes: Vec = holes.iter().filter_map(|h| to_spolygon(h)).collect(); + for b in obstacles.iter_mut() { + b.extend(hole_shapes.iter().cloned()); + } + for (i, it) in items.iter().enumerate() { + if !it.fixed { + continue; + } + out[i] = Out { bed_idx: it.bed_idx, x: it.x, y: it.y, rotation: it.rotation }; + let Some(shape) = to_spolygon(&it.outline) else { continue }; + if it.bed_idx < 0 || it.bed_idx >= max_beds { + continue; + } + let t = DTransformation::new(it.rotation as f32, (it.x as f32, it.y as f32)).compose(); + let mut placed = shape.clone(); + placed.transform_from(&shape, &t); + obstacles[it.bed_idx as usize].push(placed); + } + + // Movable items. Degenerate outlines never reach the solver and stay at -1. + let mut pending: Vec = Vec::new(); + for (i, it) in items.iter().enumerate() { + if it.fixed { + continue; + } + let Some(shape) = to_spolygon(&it.outline) else { continue }; + // An item whose bounding box fits the bed at no angle fits on no bed at all. + if make_item(0, shape.clone(), rotation_range(it.allow_rotation)).is_none() + || fit_angle(&shape.vertices, bed_rect, it.allow_rotation).is_none() + { + continue; + } + let c = centering(&shape); + pending.push(Candidate { out_idx: i, shape, allow_rotation: it.allow_rotation, centering: c }); + } + if pending.is_empty() { + progress(0, 0); + return out; + } + + // Largest first: the big pieces decide the layout, the small ones fill in. + pending.sort_by(|a, b| { + b.shape.area.partial_cmp(&a.shape.area).unwrap_or(std::cmp::Ordering::Equal) + }); + + // `time_limit_s` is per bed. A bed that places everything returns early. + let bed_budget = p.time_limit_s.max(0.0); + let mut rng = Xoshiro256PlusPlus::seed_from_u64(p.seed); + let mut done = 0usize; + let mut last_bed = 0i32; + + for bed in 0..max_beds { + // Polled between beds as well as inside the separator, so a cancel aborts the whole + // run rather than just the bed it landed in. + if pending.is_empty() || stop() { + break; + } + last_bed = bed; + progress(bed, done); + let base = done; + let report = |n: usize| progress(bed, base + n); + let (placed, leftover) = pack_bed( + &pending, + &obstacles[bed as usize], + bed_w, + bed_h, + bed_budget, + &mut rng, + stop, + &report, + ); + // Gap fill: pack_bed tries only a handful of leftovers per bed, so give every + // remaining item, largest first, a cheap collision-free placement before moving on. + let mut placed = placed; + let mut layout = obstacles[bed as usize].clone(); + for (idx, dt) in &placed { + let (x, y, r) = to_header_pose(*dt, pending[*idx].centering); + layout.push(world_outline(&pending[*idx].shape, x, y, r)); + } + let filled = if stop() { Vec::new() } else { gap_fill(&pending, &leftover, &layout, bed_w, bed_h, &mut rng) }; + let leftover: Vec = leftover.iter().copied().filter(|i| !filled.iter().any(|(j, _)| j == i)).collect(); + placed.extend(filled); + report(placed.len()); + done += placed.len(); + + for (idx, dt) in placed { + let (x, y, rotation) = to_header_pose(dt, pending[idx].centering); + out[pending[idx].out_idx] = Out { bed_idx: bed, x, y, rotation }; + } + // Keep the leftovers, preserving the largest-first order, for the next bed. + let keep = leftover; + let mut rest = Vec::with_capacity(keep.len()); + for (i, c) in pending.into_iter().enumerate() { + if keep.binary_search(&i).is_ok() { + rest.push(c); + } + } + pending = rest; + } + progress(last_bed, done); + out +} + +/// The item's outline under the header pose `world = rotate(outline, rotation) + (x, y)`. +fn world_outline(shape: &SPolygon, x: f64, y: f64, rotation: f64) -> SPolygon { + let t = DTransformation::new(rotation as f32, (x as f32, y as f32)).compose(); + let mut w = shape.clone(); + w.transform_from(shape, &t); + w +} + +/// Collision-free poses for as many of `leftover` (largest first) as LBF sampling can +/// find among `layout`, each placement becoming an obstacle for the next. +fn gap_fill( + cands: &[Candidate], + leftover: &[usize], + layout: &[SPolygon], + bed_w: f32, + bed_h: f32, + rng: &mut Xoshiro256PlusPlus, +) -> Vec<(usize, DTransformation)> { + let mut item_vec = Vec::with_capacity(leftover.len() + layout.len()); + for (k, &idx) in leftover.iter().enumerate() { + let c = &cands[idx]; + let Some(it) = make_item(k, c.shape.clone(), rotation_range(c.allow_rotation)) else { return Vec::new() }; + item_vec.push(it); + } + for (k, o) in layout.iter().enumerate() { + let Some(it) = make_item(leftover.len() + k, o.clone(), RotationRange::None) else { return Vec::new() }; + item_vec.push(it); + } + let Ok(strip) = Strip::new(bed_h, CDE_CONFIG, NO_MODIFY, bed_w) else { return Vec::new() }; + let instance = SPInstance::new(item_vec.iter().cloned().map(|i| (i, 1)).collect(), strip); + let mut prob = SPProblem::new(instance); + for (k, o) in layout.iter().enumerate() { + let c = centering(o); + prob.place_item(SPPlacement { item_id: leftover.len() + k, d_transf: DTransformation::new(0.0, (c.0, c.1)) }); + } + let mut out = Vec::new(); + for (k, &idx) in leftover.iter().enumerate() { + let evaluator = LBFEvaluator::new(&prob.layout, &item_vec[k]); + if let (Some((dt, SampleEval::Clear { .. })), _) = + search_placement(&prob.layout, &item_vec[k], None, evaluator, LBF_SAMPLE_CONFIG, rng) + { + prob.place_item(SPPlacement { item_id: k, d_transf: dt }); + out.push((idx, dt)); + } + } + out +} + +/// Fills one bed. Returns the placed candidates (index into `cands`, pose) and the sorted +/// indices that did not fit. +fn pack_bed( + cands: &[Candidate], + obstacles: &[SPolygon], + bed_w: f32, + bed_h: f32, + budget_s: f64, + rng: &mut Xoshiro256PlusPlus, + stop: &dyn Fn() -> bool, + report: &dyn Fn(usize), +) -> (Vec<(usize, DTransformation)>, Vec) { + let n_movable = cands.len(); + let all_leftover = || (Vec::new(), (0..n_movable).collect::>()); + + // Movable items take ids 0..n_movable; obstacles follow. The separator treats every id + // at or above `n_movable` as pinned (see the vendored tracker patch). + let mut item_vec: Vec = Vec::with_capacity(n_movable + obstacles.len()); + for (id, c) in cands.iter().enumerate() { + match make_item(id, c.shape.clone(), rotation_range(c.allow_rotation)) { + Some(it) => item_vec.push(it), + None => return all_leftover(), + } + } + for (k, o) in obstacles.iter().enumerate() { + match make_item(n_movable + k, o.clone(), RotationRange::None) { + Some(it) => item_vec.push(it), + None => return all_leftover(), + } + } + + let Ok(strip) = Strip::new(bed_h, CDE_CONFIG, NO_MODIFY, bed_w) else { + return all_leftover(); + }; + let instance = SPInstance::new(item_vec.iter().cloned().map(|i| (i, 1)).collect(), strip); + let mut prob = SPProblem::new(instance.clone()); + + // Obstacles are in bed coordinates; placing them at their centring offset restores them. + for (k, o) in obstacles.iter().enumerate() { + let c = centering(o); + prob.place_item(SPPlacement { + item_id: n_movable + k, + d_transf: DTransformation::new(0.0, (c.0, c.1)), + }); + } + + // Start near capacity: an over-full bed burns the budget shedding items. Items + // beyond the target are deferred and added back if they fit. + let free_area = bed_w * bed_h - obstacles.iter().map(|o| o.area).sum::(); + let mut placed_ids: Vec = Vec::new(); + let mut deferred: Vec = Vec::new(); + let mut acc = 0.0; + for id in 0..n_movable { + let a = cands[id].shape.area; + if acc + a <= free_area * TARGET_FILL { + acc += a; + placed_ids.push(id); + } else { + deferred.push(id); + } + } + + let container_bbox = prob.layout.container.outer_cd.bbox; + for &id in &placed_ids { + let dt = warm_start_pose(&prob, &item_vec[id], container_bbox, rng); + prob.place_item(SPPlacement { item_id: id, d_transf: dt }); + } + + let sep_config = SeparatorConfig { + iter_no_imprv_limit: 200, + strike_limit: 3, + n_workers: 3, + log_level: log::Level::Debug, + sample_config: SEP_SAMPLE_CONFIG, + n_movable, + }; + let seed: u64 = rng.random(); + let mut sep = + Separator::new(instance, prob, Xoshiro256PlusPlus::seed_from_u64(seed), sep_config); + + let bed_deadline = Instant::now() + Duration::from_secs_f64(budget_s); + let mut listener = DummySolListener; + // The initial fill, and the last deferred item, keep separating while overlap still + // drops: evicting the moment a slice ran out shed items that would have fit a moment + // later. Other adds get one slice each, so a hopeless one cannot burn the budget the + // remaining deferred items need. + let slice = Duration::from_secs_f64(budget_s / f64::from(MAX_ATTEMPTS)); + let mut prev_loss = f32::INFINITY; + let mut flat_slices = 0u32; + let mut attempts = 0u32; + + // Keep the best feasible layout, ranked by (items placed, area), so more budget + // never does worse and reported progress stays monotone. + let mut best: Option<(Vec<(usize, DTransformation)>, Vec)> = None; + let mut best_rank = (0usize, -1.0f32); + + loop { + let slice_end = (Instant::now() + slice).min(bed_deadline); + let term = Deadline { until: slice_end, stop }; + let (sol, cts) = sep.separate(&term, &mut listener); + sep.rollback(&sol, Some(&cts)); + // Returned early: the separator struck out on its own. + let stalled = Instant::now() < slice_end; + let loss = sep.ct.get_total_loss(); + + if loss == 0.0 { + prev_loss = f32::INFINITY; + flat_slices = 0; + let (placed, leftover) = harvest(&sep, n_movable); + let area: f32 = placed.iter().map(|(i, _)| cands[*i].shape.area).sum(); + let rank = (placed.len(), area); + if rank > best_rank { + if rank.0 > best_rank.0 { + report(rank.0); + } + best_rank = rank; + best = Some((placed, leftover)); + } + // Feasible with time to spare: try to fit one more. + attempts += 1; + match deferred.pop() { + Some(id) if attempts < MAX_ATTEMPTS && Instant::now() < bed_deadline && !stop() => { + let dt = warm_start_pose(&sep.prob, &item_vec[id], container_bbox, rng); + sep.prob.place_item(SPPlacement { item_id: id, d_transf: dt }); + sep.resync(); + } + _ => break, + } + } else { + if Instant::now() >= bed_deadline || stop() { + break; + } + // Tight layouts plateau for a slice before the last move, so allow one. + flat_slices = if loss < prev_loss { 0 } else { flat_slices + 1 }; + if (attempts == 0 || deferred.is_empty()) && !stalled && flat_slices < 2 { + prev_loss = loss.min(prev_loss); + continue; + } + prev_loss = f32::INFINITY; + flat_slices = 0; + attempts += 1; + if attempts >= MAX_ATTEMPTS { + break; + } + match worst_item(&sep) { + Some(pk) => sep.evict_item(pk), + None => break, + } + } + } + + match best { + Some(r) => r, + // Never separated: shed the worst offenders until the layout is overlap-free. + None => { + while sep.ct.get_total_loss() > 0.0 { + if stop() { + return all_leftover(); + } + match worst_item(&sep) { + Some(pk) => sep.evict_item(pk), + None => return all_leftover(), + } + } + harvest(&sep, n_movable) + } + } +} + +/// LBF where the item fits cleanly, otherwise anywhere in the bed. Overlap is the +/// separator's job. +fn warm_start_pose( + prob: &SPProblem, + item: &Item, + container_bbox: Rect, + rng: &mut Xoshiro256PlusPlus, +) -> DTransformation { + let evaluator = LBFEvaluator::new(&prob.layout, item); + let (best, _) = search_placement(&prob.layout, item, None, evaluator, LBF_SAMPLE_CONFIG, rng); + match best { + Some((dt, SampleEval::Clear { .. })) => dt, + _ => match UniformBBoxSampler::new(container_bbox, item, container_bbox) { + Some(s) => s.sample(rng), + // Fits only between the sampler's angles: seed it there so descent can refine. + None => { + let allow = item.allowed_rotation == RotationRange::Continuous; + match fit_angle(&item.shape_cd.vertices, container_bbox, allow) { + Some((a, bc)) => { + let cc = container_bbox.centroid(); + DTransformation::new(a, (cc.0 - bc.0, cc.1 - bc.1)) + } + None => DTransformation::empty(), + } + } + }, + } +} + +/// Next item to shed: most residual overlap per unit area. "Most overlap" evicts +/// big items; "smallest area" sheds bystanders. The ratio beats both. +fn worst_item(sep: &Separator) -> Option { + sep.prob + .layout + .placed_items + .iter() + .filter(|(pk, _)| !sep.ct.is_pinned(*pk)) + .max_by(|(a, pa), (b, pb)| { + let ra = sep.ct.get_loss(*a) / pa.shape.area.max(f32::MIN_POSITIVE); + let rb = sep.ct.get_loss(*b) / pb.shape.area.max(f32::MIN_POSITIVE); + ra.partial_cmp(&rb).unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(pk, _)| pk) +} + +fn harvest(sep: &Separator, n_movable: usize) -> (Vec<(usize, DTransformation)>, Vec) { + let mut placed = Vec::new(); + let mut seen = vec![false; n_movable]; + for pi in sep.prob.layout.placed_items.values() { + if pi.item_id < n_movable { + placed.push((pi.item_id, pi.d_transf)); + seen[pi.item_id] = true; + } + } + let leftover = (0..n_movable).filter(|i| !seen[*i]).collect(); + (placed, leftover) +} diff --git a/src/sparrow_arrange/src/tests.rs b/src/sparrow_arrange/src/tests.rs new file mode 100644 index 00000000000..d16ec03c526 --- /dev/null +++ b/src/sparrow_arrange/src/tests.rs @@ -0,0 +1,508 @@ +//! Every geometric assertion re-derives world coordinates from the pose contract +//! (world = rotate(outline, rotation) + (x, y)) rather than trusting jagua-rs. + +use crate::pack::{ItemIn, Out, Params, arrange}; +use crate::{sp_input, sp_item, sp_placement, sp_point, sp_polygon, sparrow_arrange}; + +type Poly = Vec<(f64, f64)>; + +// Placements are f32, so a 256 mm bed carries ~3e-3 mm of slack. Overlap checks +// shrink each polygon by this much, keeping "touching is allowed" true. +const SHRINK: f64 = 1e-3; +const EPS: f64 = 1e-2; + +fn transform(outline: &[(f64, f64)], p: &Out) -> Poly { + let (s, c) = p.rotation.sin_cos(); + outline + .iter() + .map(|&(x, y)| (x * c - y * s + p.x, x * s + y * c + p.y)) + .collect() +} + +fn centroid(p: &[(f64, f64)]) -> (f64, f64) { + let n = p.len() as f64; + (p.iter().map(|q| q.0).sum::() / n, p.iter().map(|q| q.1).sum::() / n) +} + +fn shrink(p: &[(f64, f64)]) -> Poly { + let (cx, cy) = centroid(p); + p.iter().map(|&(x, y)| (cx + (x - cx) * (1.0 - SHRINK), cy + (y - cy) * (1.0 - SHRINK))).collect() +} + +fn seg_hit(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool { + let o = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| { + (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0) + }; + let (d1, d2, d3, d4) = (o(a, b, c), o(a, b, d), o(c, d, a), o(c, d, b)); + ((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0)) +} + +fn inside(pt: (f64, f64), poly: &[(f64, f64)]) -> bool { + let mut c = false; + let n = poly.len(); + for i in 0..n { + let (a, b) = (poly[i], poly[(i + 1) % n]); + if (a.1 > pt.1) != (b.1 > pt.1) + && pt.0 < (b.0 - a.0) * (pt.1 - a.1) / (b.1 - a.1) + a.0 + { + c = !c; + } + } + c +} + +/// Works for concave outlines (the L-shapes) as well as convex ones. +fn overlaps(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { + let (a, b) = (shrink(a), shrink(b)); + for i in 0..a.len() { + for j in 0..b.len() { + if seg_hit(a[i], a[(i + 1) % a.len()], b[j], b[(j + 1) % b.len()]) { + return true; + } + } + } + a.iter().any(|&p| inside(p, &b)) || b.iter().any(|&p| inside(p, &a)) +} + +fn rect(w: f64, h: f64) -> Poly { + vec![(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)] +} + +/// Not anchored at the origin, so a rotation about the centroid or bbox corner would fail. +fn offset_rect(w: f64, h: f64) -> Poly { + vec![(5.0, 7.0), (5.0 + w, 7.0), (5.0 + w, 7.0 + h), (5.0, 7.0 + h)] +} + +fn l_shape(s: f64) -> Poly { + vec![(0.0, 0.0), (s, 0.0), (s, s / 2.0), (s / 2.0, s / 2.0), (s / 2.0, s), (0.0, s)] +} + +struct Lcg(u64); + +impl Lcg { + fn next(&mut self, lo: f64, hi: f64) -> f64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + lo + ((self.0 >> 33) as f64 / (1u64 << 31) as f64) * (hi - lo) + } +} + +const BED: f64 = 256.0; +const HOLE: [(f64, f64); 4] = [(0.0, 0.0), (20.0, 0.0), (20.0, 30.0), (0.0, 30.0)]; + +fn movable(outline: Poly, allow_rotation: bool) -> ItemIn { + ItemIn { outline, fixed: false, bed_idx: 0, x: 0.0, y: 0.0, rotation: 0.0, allow_rotation } +} + +/// `time_limit_s` is the budget for each bed, not for the run, so keep it small here: +/// worst-case runtime is `max_beds * per_bed`. +fn params(max_beds: i32, per_bed: f64) -> Params { + Params { bed_w: BED, bed_h: BED, max_beds, time_limit_s: per_bed, seed: 42 } +} + +/// Every constraint from the header, checked against independently transformed outlines. +fn check_all(items: &[ItemIn], holes: &[Poly], out: &[Out], bed_w: f64, bed_h: f64) { + let mut world: Vec<(usize, i32, Poly)> = Vec::new(); + for (i, (it, p)) in items.iter().zip(out).enumerate() { + if p.bed_idx < 0 { + continue; + } + world.push((i, p.bed_idx, transform(&it.outline, p))); + } + + for (i, bed, poly) in &world { + for (x, y) in poly { + assert!( + *x >= -EPS && *x <= bed_w + EPS && *y >= -EPS && *y <= bed_h + EPS, + "item {i} escapes the bed at ({x}, {y})" + ); + } + for (h, hole) in holes.iter().enumerate() { + assert!(!overlaps(poly, hole), "item {i} on bed {bed} overlaps hole {h}"); + } + } + + for a in 0..world.len() { + for b in (a + 1)..world.len() { + if world[a].1 != world[b].1 { + continue; + } + assert!( + !overlaps(&world[a].2, &world[b].2), + "items {} and {} overlap on bed {}", + world[a].0, + world[b].0, + world[a].1 + ); + } + } +} + +#[test] +fn rectangles_and_l_shapes_with_hole_and_fixed_item() { + let mut rng = Lcg(7); + let mut items: Vec = Vec::new(); + for _ in 0..20 { + items.push(movable(offset_rect(rng.next(15.0, 45.0), rng.next(15.0, 45.0)), true)); + } + for _ in 0..3 { + items.push(movable(l_shape(rng.next(25.0, 40.0)), true)); + } + // A fixed item parked mid-bed that nothing may touch. + items.push(ItemIn { + outline: rect(40.0, 40.0), + fixed: true, + bed_idx: 0, + x: 100.0, + y: 100.0, + rotation: 0.0, + allow_rotation: false, + }); + + let holes = vec![HOLE.to_vec()]; + let out = arrange(¶ms(4, 3.0), &holes, &items, &|| false, &|_, _| {}); + + // The fixed item is echoed back untouched. + let f = out.last().unwrap(); + assert_eq!((f.bed_idx, f.x, f.y, f.rotation), (0, 100.0, 100.0, 0.0)); + + let placed = out.iter().filter(|p| p.bed_idx >= 0).count(); + assert_eq!(placed, items.len(), "everything should fit on a 256x256 bed"); + check_all(&items, &holes, &out, BED, BED); + + // The fixed item is an obstacle for the movable ones, not just an echo. + let fixed_world = transform(&items.last().unwrap().outline, f); + for (i, (it, p)) in items.iter().zip(&out).enumerate().take(items.len() - 1) { + assert!( + !overlaps(&transform(&it.outline, p), &fixed_world), + "item {i} overlaps the fixed item" + ); + } +} + +#[test] +fn too_many_items_spill_onto_multiple_beds() { + // 12 x 100x100 squares cannot share one 256x256 bed. + let items: Vec = (0..12).map(|_| movable(rect(100.0, 100.0), false)).collect(); + let holes: Vec = vec![]; + let (max_beds, per_bed) = (6, 1.0); + let t0 = std::time::Instant::now(); + let out = arrange(¶ms(max_beds, per_bed), &holes, &items, &|| false, &|_, _| {}); + let elapsed = t0.elapsed().as_secs_f64(); + + // The contract is per-bed: the run may take up to `max_beds * per_bed`, no more. + assert!( + elapsed <= f64::from(max_beds) * per_bed + 4.0, + "run took {elapsed:.2}s, over the per-bed budget contract" + ); + + let beds: std::collections::HashSet = + out.iter().filter(|p| p.bed_idx >= 0).map(|p| p.bed_idx).collect(); + assert!(beds.len() > 1, "expected multiple beds, got {beds:?}"); + assert!(beds.len() <= 6); + // 4 per bed is the geometric optimum for 100x100 into 256x256. + assert!(beds.len() <= 4, "should not waste beds, used {}", beds.len()); + assert_eq!(out.iter().filter(|p| p.bed_idx >= 0).count(), 12); + check_all(&items, &holes, &out, BED, BED); +} + +#[test] +fn rotation_disabled_stays_exactly_zero() { + let items: Vec = (0..8).map(|_| movable(offset_rect(60.0, 30.0), false)).collect(); + let holes = vec![HOLE.to_vec()]; + let out = arrange(¶ms(2, 2.0), &holes, &items, &|| false, &|_, _| {}); + + for (i, p) in out.iter().enumerate() { + if p.bed_idx >= 0 { + assert_eq!(p.rotation, 0.0, "item {i} was rotated despite allow_rotation=0"); + } + } + check_all(&items, &holes, &out, BED, BED); +} + +#[test] +fn degenerate_items_are_unplaced_and_do_not_panic() { + let items = vec![ + movable(vec![], false), // empty + movable(vec![(1.0, 1.0)], false), // single point + movable(vec![(0.0, 0.0), (5.0, 5.0)], false), // two points + movable(vec![(0.0, 0.0), (0.0, 0.0), (0.0, 0.0)], false), // all duplicates + movable(vec![(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)], false), // collinear, zero area + movable(vec![(0.0, 0.0), (1.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], false), // dup pt + movable(rect(20.0, 20.0), false), // clockwise-safe control + movable(vec![(0.0, 0.0), (0.0, 20.0), (20.0, 20.0), (20.0, 0.0)], false), // CW winding + movable(rect(9999.0, 9999.0), false), // cannot fit any bed + ]; + let holes: Vec = vec![]; + let out = arrange(¶ms(2, 2.0), &holes, &items, &|| false, &|_, _| {}); + + for i in [0, 1, 2, 3, 4, 8] { + assert_eq!(out[i].bed_idx, -1, "item {i} should be unplaced"); + } + // The duplicate-point polygon and both windings are recoverable, not degenerate. + for i in [5, 6, 7] { + assert_eq!(out[i].bed_idx, 0, "item {i} should have been placed"); + } + check_all(&items, &holes, &out, BED, BED); +} + +#[test] +fn cancellation_returns_early_without_overlaps() { + let items: Vec = (0..30).map(|_| movable(rect(30.0, 30.0), true)).collect(); + let holes: Vec = vec![]; + // 30s per bed across 4 beds: without the cancel poll this would run for two minutes. + let t0 = std::time::Instant::now(); + let out = arrange(¶ms(4, 30.0), &holes, &items, &|| true, &|_, _| {}); + assert!( + t0.elapsed().as_secs_f64() < 5.0, + "should_stop must abort across all beds, took {:.2}s", + t0.elapsed().as_secs_f64() + ); + check_all(&items, &holes, &out, BED, BED); +} + +/// Decisive check on the pose convention: the bed is a hair larger than the item, so +/// the only feasible pose is (x, y) = (-5, -7). Any other convention is off by tens of mm. +#[test] +fn pose_translation_is_applied_to_the_item_local_origin() { + let (w, h) = (60.0, 40.0); + let items = vec![movable(offset_rect(w, h), false)]; + let out = arrange( + &Params { bed_w: w + 0.01, bed_h: h + 0.01, max_beds: 1, time_limit_s: 0.5, seed: 1 }, + &[], + &items, + &|| false, + &|_, _| {}, + ); + assert_eq!(out[0].bed_idx, 0); + assert!((out[0].x - -5.0).abs() < EPS, "x = {}, expected -5", out[0].x); + assert!((out[0].y - -7.0).abs() < EPS, "y = {}, expected -7", out[0].y); + + let world = transform(&items[0].outline, &out[0]); + let xs: Vec = world.iter().map(|p| p.0).collect(); + let ys: Vec = world.iter().map(|p| p.1).collect(); + assert!(xs.iter().cloned().fold(f64::MAX, f64::min).abs() < EPS); + assert!(ys.iter().cloned().fold(f64::MAX, f64::min).abs() < EPS); +} + +/// A non-positive budget must mean "one pass", not "restart until the heat death". +#[test] +fn zero_time_limit_terminates() { + let items: Vec = (0..12).map(|_| movable(rect(40.0, 40.0), true)).collect(); + let holes = vec![HOLE.to_vec()]; + for limit in [0.0, -1.0] { + let out = arrange( + &Params { bed_w: BED, bed_h: BED, max_beds: 3, time_limit_s: limit, seed: 3 }, + &holes, + &items, + &|| false, + &|_, _| {}, + ); + assert_eq!(out.iter().filter(|p| p.bed_idx >= 0).count(), 12); + check_all(&items, &holes, &out, BED, BED); + } +} + +#[test] +fn same_seed_is_deterministic() { + let items: Vec = (0..10).map(|_| movable(rect(40.0, 25.0), true)).collect(); + let holes = vec![HOLE.to_vec()]; + let a = arrange(¶ms(3, 1.0), &holes, &items, &|| false, &|_, _| {}); + let b = arrange(¶ms(3, 1.0), &holes, &items, &|| false, &|_, _| {}); + assert_eq!(a, b); +} + +#[test] +fn ffi_entry_point_through_raw_c_structs() { + let outlines: Vec> = (0..6) + .map(|i| { + let w = 40.0 + i as f64 * 5.0; + vec![ + sp_point { x: 0.0, y: 0.0 }, + sp_point { x: w, y: 0.0 }, + sp_point { x: w, y: 30.0 }, + sp_point { x: 0.0, y: 30.0 }, + ] + }) + .collect(); + let hole_pts: Vec = + HOLE.iter().map(|&(x, y)| sp_point { x, y }).collect(); + let holes = [sp_polygon { pts: hole_pts.as_ptr(), n: hole_pts.len() }]; + + let items: Vec = outlines + .iter() + .map(|o| sp_item { + outline: sp_polygon { pts: o.as_ptr(), n: o.len() }, + fixed: 0, + bed_idx: 0, + x: 0.0, + y: 0.0, + rotation: 0.0, + allow_rotation: 1, + }) + .collect(); + + let input = sp_input { + bed_w: BED, + bed_h: BED, + holes: holes.as_ptr(), + n_holes: holes.len(), + items: items.as_ptr(), + n_items: items.len(), + max_beds: 2, + time_limit_s: 1.0, + seed: 1, + should_stop: None, + user: std::ptr::null_mut(), + on_progress: None, + }; + + let mut out = vec![sp_placement { bed_idx: -9, x: 0.0, y: 0.0, rotation: 0.0 }; items.len()]; + let rc = unsafe { sparrow_arrange(&input, out.as_mut_ptr()) }; + assert_eq!(rc, 0); + assert!(out.iter().all(|p| p.bed_idx >= 0)); + + // Re-check the results through the same independent geometry as the Rust-level tests. + let rust_items: Vec = outlines + .iter() + .map(|o| movable(o.iter().map(|p| (p.x, p.y)).collect(), true)) + .collect(); + let rust_out: Vec = out + .iter() + .map(|p| Out { bed_idx: p.bed_idx, x: p.x, y: p.y, rotation: p.rotation }) + .collect(); + check_all(&rust_items, &[HOLE.to_vec()], &rust_out, BED, BED); +} + +#[test] +fn ffi_rejects_invalid_input() { + let mut out = [sp_placement { bed_idx: 0, x: 0.0, y: 0.0, rotation: 0.0 }]; + assert_eq!(unsafe { sparrow_arrange(std::ptr::null(), out.as_mut_ptr()) }, 1); + + let bad = sp_input { + bed_w: 0.0, + bed_h: 256.0, + holes: std::ptr::null(), + n_holes: 0, + items: std::ptr::null(), + n_items: 0, + max_beds: 1, + time_limit_s: 1.0, + seed: 0, + should_stop: None, + user: std::ptr::null_mut(), + on_progress: None, + }; + assert_eq!(unsafe { sparrow_arrange(&bad, out.as_mut_ptr()) }, 1); +} + +/// Progress reporting through the raw C struct: counts must never go backwards, must stay +/// within [0, total], and the final call must report the real number of movable placements. +struct ProgressLog { + calls: Vec<(i32, i32, i32)>, +} + +unsafe extern "C" fn record_progress(user: *mut std::ffi::c_void, bed: i32, placed: i32, total: i32) { + let log = unsafe { &mut *user.cast::() }; + log.calls.push((bed, placed, total)); +} + +#[test] +fn ffi_progress_callback_is_monotone_and_final() { + // 12 x 100x100 squares need several beds, so progress spans bed boundaries. + let outlines: Vec> = (0..12) + .map(|_| { + vec![ + sp_point { x: 0.0, y: 0.0 }, + sp_point { x: 100.0, y: 0.0 }, + sp_point { x: 100.0, y: 100.0 }, + sp_point { x: 0.0, y: 100.0 }, + ] + }) + .collect(); + let mut items: Vec = outlines + .iter() + .map(|o| sp_item { + outline: sp_polygon { pts: o.as_ptr(), n: o.len() }, + fixed: 0, + bed_idx: 0, + x: 0.0, + y: 0.0, + rotation: 0.0, + allow_rotation: 0, + }) + .collect(); + // A fixed item must not be counted in `total`, which is movable items only. + let fixed_pts = vec![ + sp_point { x: 0.0, y: 0.0 }, + sp_point { x: 10.0, y: 0.0 }, + sp_point { x: 10.0, y: 10.0 }, + sp_point { x: 0.0, y: 10.0 }, + ]; + items.push(sp_item { + outline: sp_polygon { pts: fixed_pts.as_ptr(), n: fixed_pts.len() }, + fixed: 1, + bed_idx: 0, + x: 200.0, + y: 200.0, + rotation: 0.0, + allow_rotation: 0, + }); + + let mut log = ProgressLog { calls: Vec::new() }; + let input = sp_input { + bed_w: BED, + bed_h: BED, + holes: std::ptr::null(), + n_holes: 0, + items: items.as_ptr(), + n_items: items.len(), + max_beds: 6, + time_limit_s: 1.0, + seed: 5, + should_stop: None, + user: std::ptr::addr_of_mut!(log).cast(), + on_progress: Some(record_progress), + }; + + let mut out = vec![sp_placement { bed_idx: -9, x: 0.0, y: 0.0, rotation: 0.0 }; items.len()]; + let rc = unsafe { sparrow_arrange(&input, out.as_mut_ptr()) }; + assert_eq!(rc, 0); + + assert!(!log.calls.is_empty(), "progress callback was never invoked"); + let total_movable = 12; + let mut prev_placed = -1; + let mut prev_bed = -1; + for &(bed, placed, total) in &log.calls { + assert_eq!(total, total_movable, "total must be the movable item count"); + assert!(placed >= prev_placed, "placed went backwards: {prev_placed} -> {placed}"); + assert!(bed >= prev_bed, "bed index went backwards: {prev_bed} -> {bed}"); + assert!((0..=total_movable).contains(&placed), "placed {placed} out of range"); + prev_placed = placed; + prev_bed = bed; + } + + // A bed-start call reports the count carried in from earlier beds, so the first call is 0. + assert_eq!(log.calls[0], (0, 0, total_movable)); + + let actually_placed = out[..12].iter().filter(|p| p.bed_idx >= 0).count() as i32; + assert_eq!( + log.calls.last().unwrap().1, + actually_placed, + "final callback must report the real number of movable placements" + ); + assert_eq!(actually_placed, 12, "all 12 squares should fit across the beds"); +} + +#[test] +fn item_that_only_fits_between_sampler_angles_is_still_placed() { + // 99x10 rectangle pre-rotated by 11.25 degrees: between the sampler's 22.5 degree + // steps, so only a continuous rotation fits it on a 100x20 bed. + let a = 11.25f64.to_radians(); + let (s, c) = a.sin_cos(); + let outline: Poly = rect(99.0, 10.0).iter().map(|&(x, y)| (x * c - y * s, x * s + y * c)).collect(); + let items = vec![movable(outline.clone(), true)]; + let p = Params { bed_w: 100.0, bed_h: 20.0, max_beds: 1, time_limit_s: 2.0, seed: 42 }; + let out = arrange(&p, &[], &items, &|| false, &|_, _| {}); + assert_eq!(out[0].bed_idx, 0, "item was rejected as unplaceable"); + check_all(&items, &[], &out, 100.0, 20.0); +} diff --git a/src/sparrow_arrange/vendor/sparrow/Cargo.toml b/src/sparrow_arrange/vendor/sparrow/Cargo.toml new file mode 100644 index 00000000000..2cf3fa321c3 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/Cargo.toml @@ -0,0 +1,19 @@ +# Vendored from sparrow @ 50690c4 (MIT). See VENDORED.md for what was removed +# and LICENSE for terms. Local changes are marked `SPARROW_ARRANGE PATCH`. +[package] +name = "sparrow" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" + +[dependencies] +jagua-rs = { version = "0.8.0", features = ["spp"] } +rand = "0.10" +itertools = "0.15" +log = { version = "0.4", features = ["release_max_level_info"] } +tap = "1.0" +slotmap = "1.1" +float-cmp = "0.10" +ordered-float = "5.5" +rayon = "1.12" +numfmt = "1.2" diff --git a/src/sparrow_arrange/vendor/sparrow/LICENSE b/src/sparrow_arrange/vendor/sparrow/LICENSE new file mode 100644 index 00000000000..f6237a5c2b8 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jeroen Gardeyn, KU Leuven + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/sparrow_arrange/vendor/sparrow/VENDORED.md b/src/sparrow_arrange/vendor/sparrow/VENDORED.md new file mode 100644 index 00000000000..a881afaa8bb --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/VENDORED.md @@ -0,0 +1,13 @@ +# Vendored sparrow + +Upstream: https://github.com/JeroenGar/sparrow @ `50690c4eed08db111921ca0af5fa1845b8b9dcbf` (MIT, see LICENSE). + +Kept: the separator, samplers, evaluators, collision tracker, listener, terminator and consts. +Removed: both binaries, the TUI, SVG/JSON/ctrl-c plumbing, the SIMD overlap proxy, and the +strip-packing driver (`optimize()`, exploration, compression, the LBF builder) with the config, +consts and listener variants that only served it -- plus the dependencies they needed +(clap, ratatui, crossterm, fern, jiff, svg, serde, serde_json, num_cpus, ctrlc, test-case, +anyhow, getrandom). + +Local changes are marked `SPARROW_ARRANGE PATCH`: pinned obstacles in the collision tracker, +item eviction on the separator, and terminator polling inside the separation loop. diff --git a/src/sparrow_arrange/vendor/sparrow/src/consts.rs b/src/sparrow_arrange/vendor/sparrow/src/consts.rs new file mode 100644 index 00000000000..a0ca9ce6f72 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/consts.rs @@ -0,0 +1,34 @@ +use crate::sample::search::SampleConfig; + +pub const GLS_WEIGHT_MAX_INC_RATIO: f32 = 2.0; +pub const GLS_WEIGHT_MIN_INC_RATIO: f32 = 1.2; +pub const GLS_WEIGHT_DECAY: f32 = 0.95; +pub const OVERLAP_PROXY_EPSILON_DIAM_RATIO: f32 = 0.01; + + +/// Coordinate descent step multiplier on success +pub const CD_STEP_SUCCESS: f32 = 1.1; + +/// Coordinate descent step multiplier on failure +pub const CD_STEP_FAIL: f32 = 0.5; + +/// Ratio of the item's min dimension to be used as initial and limit step size for the first refinement +pub const PRE_REFINE_CD_TL_RATIOS: (f32, f32) = (0.25, 0.02); + +/// Step sizes for rotation in the first refinement +pub const PRE_REFINE_CD_R_STEPS: (f32, f32) = (f32::to_radians(5.0), f32::to_radians(1.0)); + +/// Ratio of the item's min dimension to be used as initial and limit step size for the second (final) refinement +pub const SND_REFINE_CD_TL_RATIOS: (f32, f32) = (0.01, 0.001); + +/// Step sizes for rotation in the second (final) refinement +pub const SND_REFINE_CD_R_STEPS: (f32, f32) = (f32::to_radians(0.5), f32::to_radians(0.05)); + +/// If two samples are closer than this ratio of the item's min dimension, they are considered duplicates +pub const UNIQUE_SAMPLE_THRESHOLD: f32 = 0.05; + +pub const LBF_SAMPLE_CONFIG: SampleConfig = SampleConfig { + n_container_samples: 1000, + n_focussed_samples: 0, + n_coord_descents: 3, +}; \ No newline at end of file diff --git a/src/sparrow_arrange/vendor/sparrow/src/eval/collision_loss.rs b/src/sparrow_arrange/vendor/sparrow/src/eval/collision_loss.rs new file mode 100644 index 00000000000..9e6d58adb2f --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/eval/collision_loss.rs @@ -0,0 +1,67 @@ +use crate::quantify::quantify_collision_poly_container; +use crate::quantify::quantify_collision_poly_poly; +use crate::quantify::tracker::CollisionTracker; +use jagua_rs::collision_detection::hazards::HazardEntity; +use jagua_rs::entities::{Layout, PItemKey}; +use jagua_rs::geometry::primitives::SPolygon; + +/// Computes Sparrow's collision loss as `jagua-rs` discovers hazards. +pub(super) struct CollisionLossEvaluator<'a> { + layout: &'a Layout, + ct: &'a CollisionTracker, + current_pk: PItemKey, + loss: f32, + loss_bound: f32, +} + +impl<'a> CollisionLossEvaluator<'a> { + pub(super) fn new(layout: &'a Layout, ct: &'a CollisionTracker, current_pk: PItemKey) -> Self { + Self { + layout, + ct, + current_pk, + loss: 0.0, + loss_bound: f32::INFINITY, + } + } + + pub(super) fn reload(&mut self, loss_bound: f32) { + self.loss = 0.0; + self.loss_bound = loss_bound; + } + + pub(super) fn add(&mut self, hazard: HazardEntity, shape: &SPolygon) -> bool { + let remaining = self.loss_bound - self.loss; + let Some(extra_loss) = self.calc_weighted_loss_bounded(hazard, shape, remaining) else { + return true; + }; + self.loss += extra_loss; + self.loss > self.loss_bound + } + + pub(super) fn loss(&self) -> f32 { + self.loss + } + + fn calc_weighted_loss_bounded( + &self, + hazard: HazardEntity, + shape: &SPolygon, + max_loss: f32, + ) -> Option { + match hazard { + HazardEntity::PlacedItem { pk: other_pk, .. } => { + let other_shape = &self.layout.placed_items[other_pk].shape; + let weight = self.ct.get_pair_weight(self.current_pk, other_pk); + + let loss = quantify_collision_poly_poly(other_shape, shape) * weight; + (loss <= max_loss).then_some(loss) + } + HazardEntity::Exterior => Some( + quantify_collision_poly_container(shape, self.layout.container.outer_cd.bbox) + * self.ct.get_container_weight(self.current_pk), + ), + _ => unimplemented!("unsupported hazard entity"), + } + } +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/eval/lbf_evaluator.rs b/src/sparrow_arrange/vendor/sparrow/src/eval/lbf_evaluator.rs new file mode 100644 index 00000000000..67c47d90f59 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/eval/lbf_evaluator.rs @@ -0,0 +1,59 @@ +use crate::eval::sample_eval::{SampleEval, SampleEvaluator}; +use jagua_rs::collision_detection::hazards::filter::NoFilter; +use jagua_rs::entities::Item; +use jagua_rs::entities::Layout; +use jagua_rs::geometry::geo_traits::TransformableFrom; +use jagua_rs::geometry::primitives::SPolygon; +use jagua_rs::geometry::DTransformation; + +pub const X_MULTIPLIER: f32 = 10.0; +pub const Y_MULTIPLIER: f32 = 1.0; + +/// Simple evaluator for the Left-Bottom-Fill constructor. +/// Basically either returns [SampleEval::Invalid] in case of any collision or [SampleEval::Clear] with a loss value +/// that rewards placements that are closer to the left-bottom corner of the container. +pub struct LBFEvaluator<'a> { + layout: &'a Layout, + item: &'a Item, + shape_buff: SPolygon, + n_evals: usize +} + +impl<'a> LBFEvaluator<'a> { + pub fn new(layout: &'a Layout, item: &'a Item) -> Self { + Self { + layout, + item, + shape_buff: item.shape_cd.as_ref().clone(), + n_evals: 0 + } + } +} + +impl<'a> SampleEvaluator for LBFEvaluator<'a> { + fn evaluate_sample(&mut self, dt: DTransformation, _upper_bound: Option) -> SampleEval { + self.n_evals += 1; + let cde = self.layout.cde(); + let transf = dt.into(); + match cde.detect_surrogate_collision(self.item.shape_cd.surrogate(), &transf, &NoFilter) { + true => SampleEval::Invalid, // Surrogate collides with something + false => { + self.shape_buff.transform_from(&self.item.shape_cd, &transf); + match cde.detect_poly_collision(&self.shape_buff, &NoFilter) { + true => SampleEval::Invalid, // Exact shape collides with something + false => { + // No collisions + let poi = self.shape_buff.poi.center; + let bbox_corner = self.shape_buff.bbox.corners()[0]; + let loss = X_MULTIPLIER * (poi.0 + bbox_corner.0) + Y_MULTIPLIER * (poi.1 + bbox_corner.1); + SampleEval::Clear{loss} + } + } + } + } + } + + fn n_evals(&self) -> usize { + self.n_evals + } +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/eval/mod.rs b/src/sparrow_arrange/vendor/sparrow/src/eval/mod.rs new file mode 100644 index 00000000000..2aea72b5950 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/eval/mod.rs @@ -0,0 +1,4 @@ +mod collision_loss; +pub mod lbf_evaluator; +pub mod sample_eval; +pub mod sep_evaluator; diff --git a/src/sparrow_arrange/vendor/sparrow/src/eval/sample_eval.rs b/src/sparrow_arrange/vendor/sparrow/src/eval/sample_eval.rs new file mode 100644 index 00000000000..962042833bb --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/eval/sample_eval.rs @@ -0,0 +1,44 @@ +use jagua_rs::geometry::DTransformation; +use jagua_rs::util::FPA; +use std::cmp::Ordering; + +use SampleEval::{Clear, Collision, Invalid}; + +#[derive(Clone, Debug, PartialEq, Copy)] +pub enum SampleEval { + /// No collisions occur + Clear { loss: f32 }, + Collision{ loss: f32 }, + Invalid, +} + +impl PartialOrd for SampleEval { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SampleEval { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (Invalid, Invalid) => Ordering::Equal, + (Invalid, _) => Ordering::Greater, + (_, Invalid) => Ordering::Less, + (Collision{..}, Clear{..}) => Ordering::Greater, + (Clear{..}, Collision{..}) => Ordering::Less, + (Collision{loss: l1}, Collision{loss: l2}) | + (Clear{loss: l1}, Clear { loss: l2 }) => { + FPA(*l1).partial_cmp(&FPA(*l2)).unwrap() + } + } + } +} + +impl Eq for SampleEval {} + +/// Simple trait for types that can evaluate samples +pub trait SampleEvaluator { + fn evaluate_sample(&mut self, dt: DTransformation, upper_bound: Option) -> SampleEval; + + fn n_evals(&self) -> usize; +} \ No newline at end of file diff --git a/src/sparrow_arrange/vendor/sparrow/src/eval/sep_evaluator.rs b/src/sparrow_arrange/vendor/sparrow/src/eval/sep_evaluator.rs new file mode 100644 index 00000000000..ebd360fdcd9 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/eval/sep_evaluator.rs @@ -0,0 +1,100 @@ +use crate::eval::collision_loss::CollisionLossEvaluator; +use crate::eval::sample_eval::{SampleEval, SampleEvaluator}; +use crate::quantify::tracker::CollisionTracker; +use jagua_rs::collision_detection::hazards::collector::BasicHazardCollector; +use jagua_rs::collision_detection::hazards::{HazKey, HazardEntity}; +use jagua_rs::entities::{Item, Layout, PItemKey}; +use jagua_rs::geometry::geo_traits::TransformableFrom; +use jagua_rs::geometry::primitives::SPolygon; +use jagua_rs::geometry::DTransformation; + +pub struct SeparationEvaluator<'a> { + layout: &'a Layout, + item: &'a Item, + collector: BasicHazardCollector, + current_hazard: (HazKey, HazardEntity), + loss_evaluator: CollisionLossEvaluator<'a>, + shape_buff: SPolygon, + n_evals: usize, +} + +impl<'a> SeparationEvaluator<'a> { + pub fn new( + layout: &'a Layout, + item: &'a Item, + current_pk: PItemKey, + ct: &'a CollisionTracker, + ) -> Self { + let current_haz_key = layout + .cde() + .haz_key_from_pi_key(current_pk) + .expect("placed item should be registered in the CDE"); + let current_hazard = ( + current_haz_key, + layout.cde().hazards_map[current_haz_key].entity, + ); + + Self { + layout, + item, + collector: BasicHazardCollector::with_capacity(layout.placed_items.len() + 1), + current_hazard, + loss_evaluator: CollisionLossEvaluator::new(layout, ct, current_pk), + shape_buff: item.shape_cd.as_ref().clone(), + n_evals: 0, + } + } +} + +impl<'a> SampleEvaluator for SeparationEvaluator<'a> { + /// Evaluates a transformation. An upper bound can be provided to early terminate the process. + /// Algorithm 7 from https://doi.org/10.48550/arXiv.2509.13329 + fn evaluate_sample(&mut self, dt: DTransformation, upper_bound: Option) -> SampleEval { + self.n_evals += 1; + let cde = self.layout.cde(); + + // Calculate an upper bound of quantification, above which samples are guaranteed to be rejected (because they are dominated by previous ones). + let loss_bound = match upper_bound { + Some(SampleEval::Collision { loss }) => loss, + Some(SampleEval::Clear { .. }) => 0.0, + _ => f32::INFINITY, + }; + let shape = self + .shape_buff + .transform_from(self.item.shape_cd.as_ref(), &dt.compose()); + self.collector.clear(); + // Mark the moving item's existing hazard as already collected so traversal skips it. + self.collector.insert(self.current_hazard.0, self.current_hazard.1); + self.loss_evaluator.reload(loss_bound); + + let mut should_stop = |hazard| self.loss_evaluator.add(hazard, shape); + let stopped_during_surrogate_check = cde.collect_surrogate_collisions_until( + shape, + &mut self.collector, + &mut should_stop, + ); + + match stopped_during_surrogate_check { + true => SampleEval::Invalid, + false => { + let stopped_during_precise_check = cde.collect_poly_collisions_until( + shape, + &mut self.collector, + &mut should_stop, + ); + + match stopped_during_precise_check { + true => SampleEval::Invalid, + false if self.collector.len() == 1 => SampleEval::Clear { loss: 0.0 }, + false => SampleEval::Collision { + loss: self.loss_evaluator.loss(), + }, + } + } + } + } + + fn n_evals(&self) -> usize { + self.n_evals + } +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/lib.rs b/src/sparrow_arrange/vendor/sparrow/src/lib.rs new file mode 100644 index 00000000000..8f9ea4f6214 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/lib.rs @@ -0,0 +1,14 @@ +use numfmt::{Formatter, Precision, Scales}; + +pub mod consts; +pub mod eval; +pub mod optimizer; +pub mod quantify; +pub mod sample; +pub mod util; + +static FMT: fn() -> Formatter = || -> Formatter { + Formatter::new() + .scales(Scales::short()) + .precision(Precision::Significance(3)) +}; diff --git a/src/sparrow_arrange/vendor/sparrow/src/optimizer/mod.rs b/src/sparrow_arrange/vendor/sparrow/src/optimizer/mod.rs new file mode 100644 index 00000000000..2e75df0dabc --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/optimizer/mod.rs @@ -0,0 +1,3 @@ +pub mod separator; +// SPARROW_ARRANGE PATCH: public so the driver can rebuild workers after an eviction. +pub mod worker; diff --git a/src/sparrow_arrange/vendor/sparrow/src/optimizer/separator.rs b/src/sparrow_arrange/vendor/sparrow/src/optimizer/separator.rs new file mode 100644 index 00000000000..a1b39405d55 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/optimizer/separator.rs @@ -0,0 +1,239 @@ +use crate::optimizer::worker::{SepStats, SeparatorWorker}; +use crate::util::terminator::Terminator; +use crate::quantify::tracker::{CTSnapshot, CollisionTracker}; +use crate::sample::search::SampleConfig; +use crate::util::listener::{ReportType, SeparationProgress, SeparationResult, SolutionListener}; +use crate::FMT; +use itertools::Itertools; +use jagua_rs::entities::PItemKey; +use jagua_rs::probs::spp::entities::{SPInstance, SPProblem, SPSolution}; +use jagua_rs::Instant; +use log::{debug, log, Level}; +use ordered_float::OrderedFloat; +use rand::{RngExt, SeedableRng}; +use rand::rngs::Xoshiro256PlusPlus; +use rayon::iter::IntoParallelRefMutIterator; +use rayon::iter::ParallelIterator; +use rayon::ThreadPool; + +#[derive(Debug, Clone, Copy)] +pub struct SeparatorConfig { + pub iter_no_imprv_limit: usize, + pub strike_limit: usize, + pub n_workers: usize, + pub log_level: Level, + pub sample_config: SampleConfig, + // SPARROW_ARRANGE PATCH: number of movable items; ids at or above it are pinned obstacles. + pub n_movable: usize, +} + +pub struct Separator { + pub instance: SPInstance, + pub rng: Xoshiro256PlusPlus, + pub prob: SPProblem, + pub ct: CollisionTracker, + pub workers: Vec, + pub config: SeparatorConfig, + pub thread_pool: Option, +} + +impl Separator { + pub fn new(instance: SPInstance, prob: SPProblem, mut rng: Xoshiro256PlusPlus, config: SeparatorConfig) -> Self { + let ct = CollisionTracker::new(&prob.layout, config.n_movable); // SPARROW_ARRANGE PATCH + let workers = (0..config.n_workers).map(|_| + SeparatorWorker { + instance: instance.clone(), + prob: prob.clone(), + ct: ct.clone(), + rng: Xoshiro256PlusPlus::seed_from_u64(rng.random()), + sample_config: config.sample_config, + }).collect(); + + let pool = if cfg!(target_arch = "wasm32") { + // On wasm32, only the global thread pool is available + None + } else { + // Create a local thread pool to keep using the same threads for the same optimization (helps the OS scheduler) + Some(rayon::ThreadPoolBuilder::new().num_threads(config.n_workers).build().unwrap()) + }; + + Self { + prob, + instance, + rng, + ct, + workers, + config, + thread_pool: pool, + } + } + + /// Algorithm 9 from https://doi.org/10.48550/arXiv.2509.13329 + pub fn separate(&mut self, term: &impl Terminator, sol_listener: &mut impl SolutionListener) -> (SPSolution, CTSnapshot) { + let mut min_loss_sol = (self.prob.save(), self.ct.save()); + let mut min_loss = self.ct.get_total_loss(); + let strip_width = self.prob.strip_width(); + let density = self.prob.density() * 100.0; + let progress = |iteration, min_loss| SeparationProgress { strip_width, density, iteration, min_loss }; + sol_listener.report_separation_progress(progress(0, min_loss)); + log!(self.config.log_level,"[SEP] separating at width: {:.3} and loss: {} ", self.prob.strip_width(), FMT().fmt2(min_loss)); + + let mut n_strikes = 0; + let mut n_iter = 0; + let mut sep_stats = SepStats { total_moves: 0, total_evals: 0 }; + let start = Instant::now(); + + // As long as the strike limit is not reached, and the solution is not yet separated. + 'outer: while n_strikes < self.config.strike_limit && !term.kill() { + let mut n_iter_no_improvement = 0; + + let initial_strike_loss = self.ct.get_total_loss(); + debug!("[SEP] [s:{n_strikes},i:{n_iter}] init_l: {}",FMT().fmt2(initial_strike_loss)); + + // SPARROW_ARRANGE PATCH: also poll the terminator here; a strike runs `iter_no_imprv_limit` + // iterations, which is far longer than the caller's cancellation granularity. + while n_iter_no_improvement < self.config.iter_no_imprv_limit && !term.kill() { + let (loss_before, w_loss_before) = (self.ct.get_total_loss(), self.ct.get_total_weighted_loss(),); + sep_stats += self.move_items_multi(); + let (loss, w_loss) = (self.ct.get_total_loss(), self.ct.get_total_weighted_loss(),); + + debug!("[SEP] [s:{n_strikes},i:{n_iter}] ( ) l: {} -> {}, wl: {} -> {}, (min l: {})", FMT().fmt2(loss_before), FMT().fmt2(loss), FMT().fmt2(w_loss_before), FMT().fmt2(w_loss), FMT().fmt2(min_loss)); + debug_assert!(w_loss <= w_loss_before * 1.001, "weighted loss should not increase: {} -> {}", FMT().fmt2(w_loss), FMT().fmt2(w_loss_before)); + + if loss == 0.0 { + //All collisions are resolved + log!(self.config.log_level,"[SEP] [s:{n_strikes},i:{n_iter}] (S) min_l: {}",FMT().fmt2(loss)); + min_loss_sol = (self.prob.save(), self.ct.save()); + sol_listener.report_separation_progress(progress(n_iter + 1, loss)); + break 'outer; + } else if loss < min_loss { + //Not all collisions are resolved, but we found a new 'best' solution + log!(self.config.log_level,"[SEP] [s:{n_strikes},i:{n_iter}] (*) min_l: {}",FMT().fmt2(loss)); + sol_listener.report(ReportType::ExplImproving, &self.prob.save(), &self.instance); + if loss < min_loss * 0.98 { + //Reset the `iter_no_improvement` counter if the best solution is a substantial improvement + n_iter_no_improvement = 0; + } + min_loss_sol = (self.prob.save(), self.ct.save()); + min_loss = loss; + } else { + // No improvement this iteration + n_iter_no_improvement += 1; + } + + sol_listener.report_separation_progress(progress(n_iter + 1, min_loss)); + // Update the GLS weights + self.ct.update_weights(); + n_iter += 1; + } + + if initial_strike_loss * 0.98 <= min_loss { + // No substantial improvement during this attempt, add a strike + n_strikes += 1; + } else { + // Substantial improvement, reset strike counter + n_strikes = 0; + } + self.rollback(&min_loss_sol.0, Some(&min_loss_sol.1)); + } + let secs = start.elapsed().as_secs_f32(); + log!(self.config.log_level, "[SEP] finished, evals/s: {} K, evals/move: {}, moves/s: {}, iter/s: {}, #workers: {}, total {:.3}s", + (sep_stats.total_evals as f32/ (1000.0 * secs)) as usize, + FMT().fmt2(sep_stats.total_evals as f32 / sep_stats.total_moves as f32), + FMT().fmt2(sep_stats.total_moves as f32 / secs), + FMT().fmt2(n_iter as f32 / secs), + self.workers.len(), + FMT().fmt2(secs), + ); + sol_listener.report_separation_result(SeparationResult { + success: self.ct.get_total_loss() == 0.0, + elapsed_seconds: secs, + total_evals: sep_stats.total_evals, + total_moves: sep_stats.total_moves, + iterations: n_iter, + }); + + // Return the best solution found: a feasible one if separation was successful, otherwise the 'least' infeasible one + (min_loss_sol.0, min_loss_sol.1) + } + + /// Algorithm 10 from https://doi.org/10.48550/arXiv.2509.13329 + fn move_items_multi(&mut self) -> SepStats { + let master_sol = self.prob.save(); + + // Define the parallel execution closure + let mut separate_multi = || -> SepStats { + self.workers.par_iter_mut().map(|worker| { + // Sync the workers with the master + worker.load(&master_sol, &self.ct); + // Let all of them run `move_items` with unique random orderings in which the items are moved + worker.move_items() + }).sum() + }; + + // Execute the parallel separation either using the local thread pool or the global one + let sep_report = match self.thread_pool.as_mut() { + Some(pool) => pool.install(&mut separate_multi), + None => separate_multi(), + }; + + debug!("[MOD] optimizers w_o's: {:?}",self.workers.iter().map(|opt| opt.ct.get_total_weighted_loss()).collect_vec()); + + // Check what run yielded the best solution (lowest collision quantification) + let (best_sol, best_ct) = self.workers.iter_mut() + .min_by_key(|opt| OrderedFloat(opt.ct.get_total_weighted_loss())) + .map(|opt| (opt.prob.save(), &opt.ct)) + .unwrap(); + + // Load this 'best' solution into the master, effectively throwing away all other work. + self.prob.restore(&best_sol); + self.ct = best_ct.clone(); + + sep_report + } + + // SPARROW_ARRANGE PATCH: remove a placed item and resynchronise the tracker and the workers. + /// Stock sparrow never removes items -- in strip packing the strip simply grows until + /// everything fits. Packing into a fixed container needs the opposite move: when a set + /// cannot be separated, shed an item and try again. + pub fn evict_item(&mut self, pk: PItemKey) { + self.prob.remove_item(pk); + self.resync(); + } + + // SPARROW_ARRANGE PATCH: rebuild tracker and workers after the caller has changed the placed set + /// directly (used when adding an item back into the layout). + pub fn resync(&mut self) { + self.ct = CollisionTracker::new(&self.prob.layout, self.config.n_movable); + self.resync_workers(); + } + + // SPARROW_ARRANGE PATCH + fn resync_workers(&mut self) { + self.workers.iter_mut().for_each(|w| { + *w = SeparatorWorker { + instance: self.instance.clone(), + prob: self.prob.clone(), + ct: self.ct.clone(), + rng: Xoshiro256PlusPlus::seed_from_u64(self.rng.random()), + sample_config: self.config.sample_config, + }; + }); + } + + pub fn rollback(&mut self, sol: &SPSolution, ots: Option<&CTSnapshot>) { + debug_assert!(sol.strip_width() == self.prob.strip_width()); + self.prob.restore(sol); + + match ots { + Some(ots) => { + //if a snapshot of the tracker was provided, restore it + self.ct.restore_but_keep_weights(ots, &self.prob.layout); + } + None => { + //otherwise, rebuild it + self.ct = CollisionTracker::new(&self.prob.layout, self.config.n_movable); // SPARROW_ARRANGE PATCH + } + } + } +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/optimizer/worker.rs b/src/sparrow_arrange/vendor/sparrow/src/optimizer/worker.rs new file mode 100644 index 00000000000..3ef0915b1c9 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/optimizer/worker.rs @@ -0,0 +1,125 @@ +use crate::eval::sep_evaluator::SeparationEvaluator; +use crate::quantify::tracker::CollisionTracker; +use crate::sample::search; +use crate::sample::search::SampleConfig; +use crate::util::assertions::tracker_matches_layout; +use crate::FMT; +use itertools::Itertools; +use jagua_rs::entities::{Instance, PItemKey}; +use jagua_rs::geometry::DTransformation; +use jagua_rs::probs::spp::entities::{SPInstance, SPPlacement, SPProblem, SPSolution}; +use log::debug; +use rand::prelude::SliceRandom; +use std::iter::Sum; +use std::ops::AddAssign; +use rand::rngs::Xoshiro256PlusPlus; +use tap::Tap; + +pub struct SeparatorWorker { + pub instance: SPInstance, + pub prob: SPProblem, + pub ct: CollisionTracker, + pub rng: Xoshiro256PlusPlus, + pub sample_config: SampleConfig, +} + +impl SeparatorWorker { + pub fn load(&mut self, sol: &SPSolution, ct: &CollisionTracker) { + // restores the state of the worker to the given solution and accompanying tracker + debug_assert!(sol.strip_width() == self.prob.strip_width()); + self.prob.restore(sol); + self.ct = ct.clone(); + } + + /// Algorithm 5 from https://doi.org/10.48550/arXiv.2509.13329 + pub fn move_items(&mut self) -> SepStats { + // Collect all colliding items in a random order + let candidates = self.prob.layout.placed_items.keys() + .filter(|pk| self.ct.get_loss(*pk) > 0.0) + .collect_vec() + .tap_mut(|v| v.shuffle(&mut self.rng)); + + let mut total_moves = 0; + let mut total_evals = 0; + + // Give each colliding item the opportunity to move to a better (eval) position + for &pk in candidates.iter() { + // First check if the item is still colliding + if self.ct.get_loss(pk) > 0.0 { + let item_id = self.prob.layout.placed_items[pk].item_id; + let item = self.instance.item(item_id); + + // Create an 'evaluator' to perform collision detection and collision quantification of the samples during the search + let evaluator = SeparationEvaluator::new(&self.prob.layout, item, pk, &self.ct); + + // Perform the search for a better position for the item + let (best_sample, n_evals) = + search::search_placement(&self.prob.layout, item, Some(pk), evaluator, self.sample_config, &mut self.rng); + + // SPARROW_ARRANGE PATCH: an item whose bbox fits the bed at no sampler + // angle and whose current pose is invalid yields no sample at all. + // Leave it where it is; the caller's eviction loop deals with it. + let Some((new_dt, _eval)) = best_sample else { continue }; + + // Move the item to the new position + self.move_item(pk, new_dt); + total_moves += 1; + total_evals += n_evals; + } + } + SepStats { total_moves, total_evals } + } + + pub fn move_item(&mut self, pk: PItemKey, d_transf: DTransformation) -> PItemKey { + debug_assert!(tracker_matches_layout(&self.ct, &self.prob.layout)); + + let item = self.instance.item(self.prob.layout.placed_items[pk].item_id); + + let (old_l, old_w_l) = (self.ct.get_loss(pk), self.ct.get_weighted_loss(pk)); + + debug_assert!(old_l > 0.0, "Item with key {:?} should be colliding, but has no loss: {}", pk, FMT().fmt2(old_l)); + debug_assert!(old_w_l > 0.0, "Item with key {:?} should be colliding, but has no weighted loss: {}", pk, FMT().fmt2(old_w_l)); + + // First removing the item and subsequently place it in its new position + let old_placement = self.prob.remove_item(pk); + let new_placement = SPPlacement { d_transf, item_id: item.id }; + let new_pk = self.prob.place_item(new_placement); + + // Update the collision tracker to reflect the changes + self.ct.register_item_move(&self.prob.layout, pk, new_pk); + + let (new_l, new_w_l) = (self.ct.get_loss(new_pk), self.ct.get_weighted_loss(new_pk)); + + debug!("Moved {:?} (l: {}, wl: {}) to {:?} (l+1: {}, wl+1: {})", old_placement, FMT().fmt2(old_l), FMT().fmt2(old_w_l), new_placement, FMT().fmt2(new_l), FMT().fmt2(new_w_l)); + debug_assert!(new_w_l <= old_w_l * 1.001, "weighted loss should never increase: {} > {}", FMT().fmt2(old_w_l), FMT().fmt2(new_w_l)); + debug_assert!(tracker_matches_layout(&self.ct, &self.prob.layout)); + + new_pk + } +} + +pub struct SepStats { + pub total_moves: usize, + pub total_evals: usize, +} + +impl Sum for SepStats { + fn sum>(iter: I) -> Self { + let mut total_moves = 0; + let mut total_evals = 0; + + for report in iter { + total_moves += report.total_moves; + total_evals += report.total_evals; + } + + SepStats { total_moves, total_evals } + } +} + +impl AddAssign for SepStats { + fn add_assign(&mut self, other: Self) { + self.total_moves += other.total_moves; + self.total_evals += other.total_evals; + } +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/quantify/mod.rs b/src/sparrow_arrange/vendor/sparrow/src/quantify/mod.rs new file mode 100644 index 00000000000..d77820121fa --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/quantify/mod.rs @@ -0,0 +1,51 @@ +use crate::consts::OVERLAP_PROXY_EPSILON_DIAM_RATIO; +use crate::quantify::overlap_proxy::overlap_area_proxy; +use jagua_rs::geometry::geo_traits::DistanceTo; +use jagua_rs::geometry::primitives::{Rect, SPolygon}; + +pub mod overlap_proxy; +mod pair_matrix; +pub mod tracker; + +/// Quantifies a collision between two simple polygons. +/// Algorithm 4 from https://doi.org/10.48550/arXiv.2509.13329 +#[inline(always)] +pub fn quantify_collision_poly_poly(s1: &SPolygon, s2: &SPolygon) -> f32 { + let epsilon = f32::max(s1.diameter, s2.diameter) * OVERLAP_PROXY_EPSILON_DIAM_RATIO; + + let overlap_proxy = overlap_area_proxy(s1.surrogate(), s2.surrogate(), epsilon) + epsilon.powi(2); + + debug_assert!(overlap_proxy.is_normal()); + + let penalty = calc_shape_penalty(s1, s2); + + overlap_proxy.sqrt() * penalty +} + +pub fn calc_shape_penalty(s1: &SPolygon, s2: &SPolygon) -> f32 { + // The shape-based penalty between two shapes is defined as the geometric mean of the square roots of their convex hull areas. + let p1 = f32::sqrt(s1.surrogate().convex_hull_area); + let p2 = f32::sqrt(s2.surrogate().convex_hull_area); + (p1 * p2).sqrt() +} + +/// Quantifies a collision between a simple polygon and the exterior of the container. +#[inline(always)] +pub fn quantify_collision_poly_container(s: &SPolygon, c_bbox: Rect) -> f32 { + let s_bbox = s.bbox; + let overlap = match Rect::intersection(s_bbox, c_bbox) { + Some(r) => { + //intersection exist, calculate the area of the intersection (+ a small value to ensure it is never zero) + (s_bbox.area() - r.area()) + 0.0001 * s_bbox.area() + } + None => { + //no intersection, guide towards intersection with container + s_bbox.area() + s_bbox.centroid().distance_to(&c_bbox.centroid()) + } + }; + debug_assert!(overlap.is_normal()); + + let penalty = calc_shape_penalty(s, s); + + 2.0 * overlap.sqrt() * penalty +} \ No newline at end of file diff --git a/src/sparrow_arrange/vendor/sparrow/src/quantify/overlap_proxy.rs b/src/sparrow_arrange/vendor/sparrow/src/quantify/overlap_proxy.rs new file mode 100644 index 00000000000..0fc5edfcac8 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/quantify/overlap_proxy.rs @@ -0,0 +1,27 @@ +use jagua_rs::geometry::fail_fast::SPSurrogate; +use jagua_rs::geometry::geo_traits::DistanceTo; +use std::f32::consts::PI; + +/// Calculates a proxy for the overlap area between two simple polygons (using poles). +/// Algorithm 3 from https://doi.org/10.48550/arXiv.2509.13329 +#[inline(always)] +pub fn overlap_area_proxy(sp1: &SPSurrogate, sp2: &SPSurrogate, epsilon: f32) -> f32 { + let mut total_overlap = 0.0; + for p1 in &sp1.poles { + for p2 in &sp2.poles { + // Penetration depth between the two poles (circles) + let pd = (p1.radius + p2.radius) - p1.center.distance_to(&p2.center); + + let pd_decay = match pd >= epsilon { + true => pd, + false => epsilon.powi(2) / (-pd + 2.0 * epsilon), + }; + + total_overlap += pd_decay * f32::min(p1.radius, p2.radius); + } + } + total_overlap *= PI; + debug_assert!(total_overlap.is_normal()); + + total_overlap +} \ No newline at end of file diff --git a/src/sparrow_arrange/vendor/sparrow/src/quantify/pair_matrix.rs b/src/sparrow_arrange/vendor/sparrow/src/quantify/pair_matrix.rs new file mode 100644 index 00000000000..0c46de26a05 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/quantify/pair_matrix.rs @@ -0,0 +1,49 @@ +use crate::quantify::tracker::CTEntry; +use std::ops::{Index, IndexMut}; + +// triangular matrix of pair-wise collision loss and weights +// supporting data structure for the `CollisionTracker` +#[derive(Debug, Clone)] +pub struct PairMatrix { + pub size: usize, + pub data: Vec, +} + +impl PairMatrix { + pub fn new(size: usize) -> Self { + let len = size * (size + 1) / 2; + Self { + size, + data: vec![CTEntry { weight: 1.0, loss: 0.0 }; len], + } + } +} + +impl Index<(usize, usize)> for PairMatrix { + type Output = CTEntry; + + fn index(&self, (row, col): (usize, usize)) -> &Self::Output { + &self.data[calc_idx(row, col, self.size)] + } +} + +impl IndexMut<(usize, usize)> for PairMatrix { + fn index_mut(&mut self, (row, col): (usize, usize)) -> &mut Self::Output { + &mut self.data[calc_idx(row, col, self.size)] + } +} + +fn calc_idx(row: usize, col: usize, size: usize) -> usize { + /* Example: + 0 1 2 3 + 4 5 6 + 7 8 + 9 + */ + debug_assert!(row < size && col < size); + if row <= col { + (row * size) + col - ((row * (row + 1)) / 2) + } else { + (col * size) + row - ((col * (col + 1)) / 2) + } +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/quantify/tracker.rs b/src/sparrow_arrange/vendor/sparrow/src/quantify/tracker.rs new file mode 100644 index 00000000000..8dd4db3b5b0 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/quantify/tracker.rs @@ -0,0 +1,248 @@ +use crate::consts::{GLS_WEIGHT_DECAY, GLS_WEIGHT_MAX_INC_RATIO, GLS_WEIGHT_MIN_INC_RATIO}; +use crate::quantify::pair_matrix::PairMatrix; +use crate::quantify::{quantify_collision_poly_container, quantify_collision_poly_poly}; +use crate::util::assertions::tracker_matches_layout; +use jagua_rs::collision_detection::hazards::collector::{BasicHazardCollector, HazardCollector}; +use jagua_rs::collision_detection::hazards::HazardEntity; +use jagua_rs::entities::{Layout, PItemKey}; +use ordered_float::Float; +use slotmap::SecondaryMap; + +/// Tracker of both collisions between pair of items and collisions with the container. +/// It also stores the weights for every pair of hazards and is used as a cache for collisions. +#[derive(Debug, Clone)] +pub struct CollisionTracker { + pub size: usize, + pub pk_idx_map: SecondaryMap, + pub pair_collisions: PairMatrix, + pub container_collisions: Vec, + // SPARROW_ARRANGE PATCH: items whose `item_id >= n_movable` are pinned obstacles (bed holes and + /// caller-fixed items, modelled as immovable placed items). They contribute loss to the + /// movable items that overlap them, but carry none of their own, which both keeps them + /// out of `move_items`' candidate list (it selects on `get_loss(pk) > 0.0`) and stops + /// obstacle-vs-obstacle or obstacle-vs-container overlap from making the layout look + /// permanently infeasible. + pub pinned: Vec, +} + +pub type CTSnapshot = CollisionTracker; + +impl CollisionTracker { + // SPARROW_ARRANGE PATCH: `n_movable` added; ids at or above it are pinned obstacles. + pub fn new(l: &Layout, n_movable: usize) -> Self { + let size = l.placed_items.len(); + + let pk_idx_map: SecondaryMap = l.placed_items.keys().enumerate() + .map(|(i, pk)| (pk, i)) + .collect(); + + let mut pinned = vec![false; size]; + for (pk, pi) in l.placed_items.iter() { + pinned[pk_idx_map[pk]] = pi.item_id >= n_movable; + } + + // Create the tracker + let mut ot = Self { + size, + pk_idx_map, + pair_collisions: PairMatrix::new(size), + container_collisions: vec![CTEntry { weight: 1.0, loss: 0.0 }; size], + pinned, + }; + + // Recompute the loss for all items + l.placed_items.keys().for_each(|pk| { + ot.recompute_loss_for_item(pk, l) + }); + + debug_assert!(tracker_matches_layout(&ot, l)); + + ot + } + + /// Returns true if this item is a pinned obstacle. // SPARROW_ARRANGE PATCH + pub fn is_pinned(&self, pk: PItemKey) -> bool { + self.pinned[self.pk_idx_map[pk]] + } + + fn recompute_loss_for_item(&mut self, pk: PItemKey, l: &Layout) { + let idx = self.pk_idx_map[pk]; + // SPARROW_ARRANGE PATCH: leave a pinned obstacle's entries at zero. Returning before the reset below is + // deliberate: the obstacle-vs-movable entries are shared, and are written when the + // *movable* side is recomputed. + if self.pinned[idx] { + return; + } + let pi = &l.placed_items[pk]; + let shape = &pi.shape; + + // Reset all current loss values for the item + for i in 0..self.size { + self.pair_collisions[(idx, i)].loss = 0.0; + } + self.container_collisions[idx].loss = 0.0; + + // Compute which hazards are currently colliding with the item + let mut collector = BasicHazardCollector::with_capacity(l.placed_items.len() + 1); + l.cde().collect_poly_collisions(shape, &mut collector); + // Remove the item itself from the detector + collector.remove_by_entity(&HazardEntity::from((pk, pi))); + + // For each colliding hazard, quantify the collision and store it in the tracker + for (_, haz) in collector.iter() { + match haz { + HazardEntity::PlacedItem { pk: other_pk, .. } => { + let shape_other = &l.placed_items[*other_pk].shape; + let idx_other = self.pk_idx_map[*other_pk]; + + let loss = quantify_collision_poly_poly(shape, shape_other); + assert!(loss > 0.0, "loss for a collision should be > 0.0"); + self.pair_collisions[(idx, idx_other)].loss = loss; + } + HazardEntity::Exterior => { + let loss = quantify_collision_poly_container(shape, l.container.outer_cd.bbox); + assert!(loss > 0.0, "loss for a collision should be > 0.0"); + self.container_collisions[idx].loss = loss; + } + _ => unimplemented!("unsupported hazard entity"), + } + } + } + + pub fn restore_but_keep_weights(&mut self, cts: &CTSnapshot, layout: &Layout) { + //Copy the loss and keys, but keep the weights + self.pk_idx_map = cts.pk_idx_map.clone(); + self.pinned = cts.pinned.clone(); // SPARROW_ARRANGE PATCH + self.pair_collisions.data.iter_mut() + .zip(cts.pair_collisions.data.iter()) + .for_each(|(a, b)| a.loss = b.loss); + self.container_collisions.iter_mut() + .zip(cts.container_collisions.iter()) + .for_each(|(a, b)| a.loss = b.loss); + debug_assert!(tracker_matches_layout(self, layout)); + } + + pub fn save(&self) -> CTSnapshot { + self.clone() + } + + pub fn register_item_move(&mut self, l: &Layout, old_pk: PItemKey, new_pk: PItemKey) { + //swap the keys in the pk_idx_map + let idx = self.pk_idx_map.remove(old_pk).unwrap(); + self.pk_idx_map.insert(new_pk, idx); + + self.recompute_loss_for_item(new_pk, l); + + debug_assert!(tracker_matches_layout(self, l)); + } + + + /// Algorithm 8 from https://doi.org/10.48550/arXiv.2509.13329 + pub fn update_weights(&mut self) { + // Find the maximum loss across all entries + let max_loss = self.pair_collisions.data.iter() + .chain(self.container_collisions.iter()) + .map(|e| e.loss) + .fold(0.0, |a, b| a.max(b)); + + // Go over all entries (pairs) and modify their weights. + for e in self.pair_collisions.data.iter_mut() + .chain(self.container_collisions.iter_mut()) { + let multiplier = match e.loss == 0.0 { + true => { + // No collision at the moment, slowly decay the weight back to 1.0 + GLS_WEIGHT_DECAY + }, + false => { + // Collision detected, increase the weight based on 'how bad' the collision is relative to the worst collision + GLS_WEIGHT_MIN_INC_RATIO + (GLS_WEIGHT_MAX_INC_RATIO - GLS_WEIGHT_MIN_INC_RATIO) * (e.loss / max_loss) + }, + }; + e.weight = (e.weight * multiplier).max(1.0); + } + } + + pub fn get_pair_weight(&self, pk1: PItemKey, pk2: PItemKey) -> f32 { + let (idx1, idx2) = (self.pk_idx_map[pk1], self.pk_idx_map[pk2]); + self.pair_collisions[(idx1, idx2)].weight + } + + pub fn get_container_weight(&self, pk: PItemKey) -> f32 { + let idx = self.pk_idx_map[pk]; + self.container_collisions[idx].weight + } + + /// Algorithm 1 from https://doi.org/10.48550/arXiv.2509.13329 + /// Evaluations between item pairs are stored in this data-structure for quick and easy retrieval. + pub fn get_pair_loss(&self, pk1: PItemKey, pk2: PItemKey) -> f32 { + let (idx1, idx2) = (self.pk_idx_map[pk1], self.pk_idx_map[pk2]); + self.pair_collisions[(idx1, idx2)].loss + } + + pub fn get_container_loss(&self, pk: PItemKey) -> f32 { + let idx = self.pk_idx_map[pk]; + self.container_collisions[idx].loss + } + + pub fn get_loss(&self, pk: PItemKey) -> f32 { + let idx = self.pk_idx_map[pk]; + // SPARROW_ARRANGE PATCH: pinned obstacles never report loss, so they are never picked to move. + if self.pinned[idx] { + return 0.0; + } + + let pair_loss = (0..self.size) + .map(|i| self.pair_collisions[(idx, i)].loss) + .sum::(); + + self.container_collisions[idx].loss + pair_loss + } + + pub fn get_weighted_loss(&self, pk: PItemKey) -> f32 { + let idx = self.pk_idx_map[pk]; + // SPARROW_ARRANGE PATCH + if self.pinned[idx] { + return 0.0; + } + + let w_pair_loss = (0..self.size) + .map(|i| self.pair_collisions[(idx, i)].weighted_loss()) + .sum::(); + + self.container_collisions[idx].weighted_loss() + w_pair_loss + } + + pub fn get_total_loss(&self) -> f32 { + let cont_o = self.container_collisions.iter().map(|e| e.loss).sum::(); + + let pair_o = self.pair_collisions.data.iter() + .map(|e| e.loss) + .sum::(); + + cont_o + pair_o + } + + pub fn get_total_weighted_loss(&self) -> f32 { + let cont_w_o = self.container_collisions.iter() + .map(|e| e.weighted_loss()) + .sum::(); + + let pair_w_o = self.pair_collisions.data.iter() + .map(|e| e.weighted_loss()) + .sum::(); + + cont_w_o + pair_w_o + } +} + +#[derive(Debug, Clone, Copy)] +pub struct CTEntry { + pub loss: f32, + pub weight: f32, +} + +impl CTEntry { + pub fn weighted_loss(&self) -> f32 { + self.weight * self.loss + } +} \ No newline at end of file diff --git a/src/sparrow_arrange/vendor/sparrow/src/sample/best_samples.rs b/src/sparrow_arrange/vendor/sparrow/src/sample/best_samples.rs new file mode 100644 index 00000000000..298da1d3a9c --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/sample/best_samples.rs @@ -0,0 +1,107 @@ +use crate::eval::sample_eval::SampleEval; +use itertools::Itertools; +use jagua_rs::geometry::DTransformation; +use std::f32::consts::PI; +use std::fmt::Debug; + +/// Data structure to store the N best samples, automatically keeps them sorted and evicts the worst. +/// It makes sure that no two included samples are too similar. +/// Also provides an upper bound in loss value for acceptance of new samples. +#[derive(Debug, Clone)] +pub struct BestSamples { + pub size: usize, + pub samples: Vec<(DTransformation, SampleEval)>, + pub unique_thresh: f32, +} + +impl BestSamples { + pub fn new(size: usize, unique_thresh: f32) -> Self { + Self { + size, + samples: vec![], + unique_thresh, + } + } + + pub fn report(&mut self, dt: DTransformation, eval: SampleEval) -> bool { + let accept = match eval < self.upper_bound() { + false => false, + true => { + let any_similar = self.samples.iter() + .any(|(d, _)| dtransfs_are_similar(*d, dt, self.unique_thresh, self.unique_thresh)); + + match any_similar { + false => { //no similar sample found, evict worst and accept + if self.samples.len() == self.size { + self.samples.pop(); + } + true + } + true => { //at least one similar sample exists + let better_than_all_similar = self.samples.iter() + .filter(|(d, _)| dtransfs_are_similar(*d, dt, self.unique_thresh, self.unique_thresh)) + .all(|(_, sim_eval)| eval < *sim_eval); + + if better_than_all_similar { + //evict all similar samples + self.samples.retain(|(d, _)| !dtransfs_are_similar(*d, dt, self.unique_thresh, self.unique_thresh)); + true + } + else { + false + } + } + } + } + }; + if accept { + self.samples.push((dt, eval)); + self.samples.sort_by_key(|(_, eval)| *eval); + debug_assert!( + self.samples.iter() + .filter(|(_, eval)| *eval != SampleEval::Invalid) + .array_combinations().all(|[a, b]| { + !dtransfs_are_similar(a.0, b.0, self.unique_thresh, self.unique_thresh) + } + ), + "BestSamples: samples are not unique: {:?}", &self.samples + ); + true + } + else{ + debug_assert!(self.samples.is_sorted_by_key(|(_, eval)| *eval)); + false + } + } + + pub fn best(&self) -> Option<(DTransformation, SampleEval)> { + self.samples.first().cloned() + } + + pub fn upper_bound(&self) -> SampleEval { + if let Some((_, eval)) = self.samples.get(self.size - 1) { + *eval + } else { + SampleEval::Invalid + } + } +} + +pub fn dtransfs_are_similar( + dt1: DTransformation, + dt2: DTransformation, + x_threshold: f32, + y_threshold: f32, +) -> bool { + let x_diff = f32::abs(dt1.translation().0 - dt2.translation().0); + let y_diff = f32::abs(dt1.translation().1 - dt2.translation().1); + + if x_diff < x_threshold && y_diff < y_threshold { + let r1 = dt1.rotation() % (2.0 * PI); + let r2 = dt2.rotation() % (2.0 * PI); + let angle_diff = f32::abs(r1 - r2); + angle_diff < (1.0f32).to_radians() + } else { + false + } +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/sample/coord_descent.rs b/src/sparrow_arrange/vendor/sparrow/src/sample/coord_descent.rs new file mode 100644 index 00000000000..e9668ac8879 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/sample/coord_descent.rs @@ -0,0 +1,180 @@ +use crate::consts::{CD_STEP_FAIL, CD_STEP_SUCCESS}; +use crate::eval::sample_eval::{SampleEval, SampleEvaluator}; +use jagua_rs::geometry::DTransformation; +use log::trace; +use rand::{Rng, RngExt}; +use std::cmp::Ordering; +use std::fmt::Debug; + +#[derive(Clone, Debug, Copy)] +pub struct CDConfig { + /// Initial step size for the coordinate descent + pub t_step_init: f32, + /// Limit for the step size, below which no more candidates are generated + pub t_step_limit: f32, + /// Initial step size for the rotation wiggle axis + pub r_step_init: f32, + /// Limit for the rotation wiggle step size, below which no more candidates are generated + pub r_step_limit: f32, + /// Defines whether the wiggle axis (rotation) is enabled + pub wiggle: bool, +} + +/// Refines an initial 'sample' (transformation and evaluation) into a local minimum using a coordinate descent inspired algorithm. +pub fn refine_coord_desc( + (init_dt, init_eval): (DTransformation, SampleEval), + evaluator: &mut impl SampleEvaluator, + cd_config: CDConfig, + rng: &mut impl Rng, +) -> (DTransformation, SampleEval) { + let n_evals_init = evaluator.n_evals(); + let init_pos = init_dt; + + // Initialize the coordinate descent. + let mut cd = CoordinateDescent { + pos: init_pos, + eval: init_eval, + axis: CDAxis::random(rng, cd_config.wiggle), + t_steps: (cd_config.t_step_init, cd_config.t_step_init), + t_step_limit: cd_config.t_step_limit, + r_step: cd_config.r_step_init, + r_step_limit: cd_config.r_step_limit, + wiggle: cd_config.wiggle, + }; + + // From the CD state, ask for candidate positions to evaluate. If none provided, stop. + while let Some(c) = cd.ask() { + // Evaluate the candidates using the evaluator. + let c_eval = c.map(|c| evaluator.evaluate_sample(c, Some(cd.eval))); + + let best = c.into_iter().zip(c_eval) + .min_by_key(|(_, eval)| *eval) + .expect("At least one candidate should be present"); + + // Report the best candidate to the coordinate descent state. + cd.tell(best, rng); + trace!("CD: {:?}", cd); + debug_assert!(evaluator.n_evals() - n_evals_init < 1000, "coordinate descent exceeded 1000 evals"); + } + trace!("CD: {} evals, {} -> {}, eval: {:?}",evaluator.n_evals() - n_evals_init, init_pos, cd.pos, cd.eval); + // Return the best transformation found by the coordinate descent. + (cd.pos, cd.eval) +} + +#[derive(Debug)] +struct CoordinateDescent { + /// The current position in the coordinate descent + pub pos: DTransformation, + /// The current evaluation of the position + pub eval: SampleEval, + /// The current axis on which new candidates are generated + pub axis: CDAxis, + /// The current step size for x and y axes + pub t_steps: (f32, f32), + /// The current step size for the rotation wiggle axis + pub r_step: f32, + /// The limit for the step size, below which no more candidates are generated + pub t_step_limit: f32, + /// The limit for the rotation wiggle step size, below which no more candidates are generated + pub r_step_limit: f32, + /// Defines whether the wiggle axis is enabled + pub wiggle: bool, +} + +impl CoordinateDescent { + + /// Generates candidates to be evaluated. + pub fn ask(&self) -> Option<[DTransformation; 2]> { + let (sx, sy) = self.t_steps; + let sr = self.r_step; + + if sx < self.t_step_limit && sy < self.t_step_limit && (sr < self.r_step_limit || !self.wiggle) { + // Stop generating candidates if both steps have reached the limit + None + } else { + // Generate two candidates on either side of the current position, according to the active axis. + let (tx, ty) = self.pos.translation(); + let r = self.pos.rotation(); + let transformations = match self.axis { + CDAxis::Horizontal => [(tx + sx, ty, r), (tx - sx, ty, r)], + CDAxis::Vertical => [(tx, ty + sy, r), (tx, ty - sy, r)], + CDAxis::ForwardDiag => [(tx + sx, ty + sy, r), (tx - sx, ty - sy, r)], + CDAxis::BackwardDiag => [(tx - sx, ty + sy, r), (tx + sx, ty - sy, r)], + CDAxis::Wiggle => [(tx, ty, r + sr), (tx, ty, r - sr)] + }; + + let c = transformations.map(|(tx, ty, r)| { + DTransformation::new(r, (tx, ty)) + }); + + Some(c) + } + } + + /// Updates the coordinate descent state with the new position and evaluation. + pub fn tell(&mut self, (pos, eval): (DTransformation, SampleEval), rng: &mut impl Rng) { + // Check if the reported evaluation is better or worse than the current one. + let eval_cmp = eval.cmp(&self.eval); + let better = eval_cmp == Ordering::Less; + let worse = eval_cmp == Ordering::Greater; + + if !worse { + // Update the current position if not worse + (self.pos, self.eval) = (pos, eval); + } + + // Determine the step size multiplier depending on whether the new evaluation is better or worse. + let m = if better { CD_STEP_SUCCESS } else { CD_STEP_FAIL }; + + // Apply the step size multiplier to the relevant steps for the current axis + match self.axis { + CDAxis::Horizontal => self.t_steps.0 *= m, + CDAxis::Vertical => self.t_steps.1 *= m, + CDAxis::ForwardDiag | CDAxis::BackwardDiag => { + //Since both axis are involved, adjust both steps but less severely + self.t_steps.0 *= m.sqrt(); + self.t_steps.1 *= m.sqrt(); + } + CDAxis::Wiggle => { + self.r_step *= m; + } + } + + // Every time a state is not improved, the axis gets changed to a new random one. + if !better { + self.axis = CDAxis::random(rng, self.wiggle); + } + } +} + +#[derive(Clone, Debug, Copy)] +enum CDAxis { + /// Left and right + Horizontal, + /// Up and down + Vertical, + /// Up-right and down-left + ForwardDiag, + /// Up-left and down-right + BackwardDiag, + /// Wiggle left and right (if allowed) + Wiggle, +} + +impl CDAxis { + fn random(rng: &mut impl Rng, rotate: bool) -> Self { + let range = if rotate { + 0..6 // Include wiggle as a possible axis + } else { + 0..4 // Exclude wiggle if not allowed + }; + match rng.random_range(range) { + 0 => CDAxis::Horizontal, + 1 => CDAxis::Vertical, + 2 => CDAxis::ForwardDiag, + 3 => CDAxis::BackwardDiag, + 4..6 => CDAxis::Wiggle, + _ => unreachable!(), + } + } +} \ No newline at end of file diff --git a/src/sparrow_arrange/vendor/sparrow/src/sample/mod.rs b/src/sparrow_arrange/vendor/sparrow/src/sample/mod.rs new file mode 100644 index 00000000000..ffec7206700 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/sample/mod.rs @@ -0,0 +1,4 @@ +mod best_samples; +mod coord_descent; +pub mod search; +pub mod uniform_sampler; \ No newline at end of file diff --git a/src/sparrow_arrange/vendor/sparrow/src/sample/search.rs b/src/sparrow_arrange/vendor/sparrow/src/sample/search.rs new file mode 100644 index 00000000000..530bc3c3c76 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/sample/search.rs @@ -0,0 +1,101 @@ +use crate::consts::{PRE_REFINE_CD_R_STEPS, PRE_REFINE_CD_TL_RATIOS, SND_REFINE_CD_R_STEPS, SND_REFINE_CD_TL_RATIOS, UNIQUE_SAMPLE_THRESHOLD}; +use crate::eval::sample_eval::{SampleEval, SampleEvaluator}; +use crate::sample::best_samples::BestSamples; +use crate::sample::coord_descent::{refine_coord_desc, CDConfig}; +use crate::sample::uniform_sampler::UniformBBoxSampler; +use jagua_rs::entities::{Item, Layout, PItemKey}; +use jagua_rs::geometry::geo_enums::RotationRange; +use jagua_rs::geometry::DTransformation; +use log::debug; +use rand::Rng; + +#[derive(Debug, Clone, Copy)] +pub struct SampleConfig { + pub n_container_samples: usize, + pub n_focussed_samples: usize, + pub n_coord_descents: usize, +} + +/// Algorithm 6 and Figure 7 from https://doi.org/10.48550/arXiv.2509.13329 +pub fn search_placement(l: &Layout, item: &Item, ref_pk: Option, mut evaluator: impl SampleEvaluator, sample_config: SampleConfig, rng: &mut impl Rng) -> (Option<(DTransformation, SampleEval)>, usize) { + let item_min_dim = f32::min(item.shape_cd.bbox.width(), item.shape_cd.bbox.height()); + + let mut best_samples = BestSamples::new(sample_config.n_coord_descents, item_min_dim * UNIQUE_SAMPLE_THRESHOLD); + + //Create the two uniform samplers, one focussed around the reference placement, one for the whole container + let focussed_sampler = match ref_pk { + Some(ref_pk) => { + //Add the current placement (and evaluation) as a candidate + let dt = l.placed_items[ref_pk].d_transf; + let eval = evaluator.evaluate_sample(dt, Some(best_samples.upper_bound())); + + debug!("[S] Starting from: {:?}", (dt, eval)); + best_samples.report(dt, eval); + + //Create a uniform sampler focussed around the current placement + let pi_bbox = l.placed_items[ref_pk].shape.bbox; + UniformBBoxSampler::new(pi_bbox, item, l.container.outer_cd.bbox) + } + None => None, + }; + let container_sampler = UniformBBoxSampler::new(l.container.outer_cd.bbox, item, l.container.outer_cd.bbox); + + //Perform the focussed sampling + if let Some(focussed_sampler) = focussed_sampler { + for _ in 0..sample_config.n_focussed_samples { + let dt = focussed_sampler.sample(rng); + let eval = evaluator.evaluate_sample(dt, Some(best_samples.upper_bound())); + best_samples.report(dt, eval); + } + } + + //Perform the container-wide sampling + if let Some(container_sampler) = container_sampler { + for _ in 0..sample_config.n_container_samples { + let dt = container_sampler.sample(rng); + let eval = evaluator.evaluate_sample(dt, Some(best_samples.upper_bound())); + best_samples.report(dt, eval); + } + } + + //Refine some of the best random samples to a local minimum in two steps: + + //1. Do a first refinement of all 'best samples' using coordinate descent + for start in best_samples.samples.clone() { + let descended = refine_coord_desc(start, &mut evaluator, prerefine_cd_config(item), rng); + best_samples.report(descended.0, descended.1); + } + + + //2. Take the best one and do an even finer coordinate descent refinement + let final_sample = best_samples.best().map(|s| + refine_coord_desc(s, &mut evaluator, final_refine_cd_config(item), rng) + ); + + debug!("[S] {} samples evaluated, final: {:?}",evaluator.n_evals(),final_sample); + (final_sample, evaluator.n_evals()) +} + +fn prerefine_cd_config(item: &Item) -> CDConfig { + let item_min_dim = f32::min(item.shape_cd.bbox.width(), item.shape_cd.bbox.height()); + let wiggle = item.allowed_rotation == RotationRange::Continuous; + CDConfig { + t_step_init: item_min_dim * PRE_REFINE_CD_TL_RATIOS.0, + t_step_limit: item_min_dim * PRE_REFINE_CD_TL_RATIOS.1, + r_step_init: PRE_REFINE_CD_R_STEPS.0, + r_step_limit: PRE_REFINE_CD_R_STEPS.1, + wiggle, + } +} + +fn final_refine_cd_config(item: &Item) -> CDConfig { + let item_min_dim = f32::min(item.shape_cd.bbox.width(), item.shape_cd.bbox.height()); + let wiggle = item.allowed_rotation == RotationRange::Continuous; + CDConfig { + t_step_init: item_min_dim * SND_REFINE_CD_TL_RATIOS.0, + t_step_limit: item_min_dim * SND_REFINE_CD_TL_RATIOS.1, + r_step_init: SND_REFINE_CD_R_STEPS.0, + r_step_limit: SND_REFINE_CD_R_STEPS.1, + wiggle + } +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/sample/uniform_sampler.rs b/src/sparrow_arrange/vendor/sparrow/src/sample/uniform_sampler.rs new file mode 100644 index 00000000000..e83f29ed2db --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/sample/uniform_sampler.rs @@ -0,0 +1,113 @@ +use itertools::Itertools; +use jagua_rs::entities::Item; +use jagua_rs::geometry::geo_enums::RotationRange; +use jagua_rs::geometry::geo_traits::TransformableFrom; +use jagua_rs::geometry::primitives::Rect; +use jagua_rs::geometry::{normalize_rotation, DTransformation, Transformation}; +use ordered_float::OrderedFloat; +use rand::prelude::IndexedRandom; +use rand::{Rng, RngExt}; +use std::f32::consts::PI; +use std::ops::Range; + +const ROT_N_SAMPLES: usize = 16; // number of rotations to sample for continuous rotation + +/// A sampler that creates uniform samples for an item within a bounding box +#[derive(Clone, Debug)] +pub struct UniformBBoxSampler { + /// The list of possible rotations and their corresponding x and y ranges + rot_entries: Vec, +} + +#[derive(Clone, Debug)] +struct RotEntry { + pub r: f32, + pub x_range: Range, + pub y_range: Range, +} + +impl UniformBBoxSampler { + pub fn new(sample_bbox: Rect, item: &Item, container_bbox: Rect) -> Option { + let rotations = match &item.allowed_rotation { + RotationRange::None => &vec![0.0], + RotationRange::Discrete(r) => r, + RotationRange::Continuous => { + // for continuous rotation, we sample a set of rotations spaced evenly + let step = (2.0 * PI) / ROT_N_SAMPLES as f32; + &(0..ROT_N_SAMPLES) + .map(|i| i as f32 * step) + .collect_vec() + } + }; + + let mut shape_buffer = item.shape_cd.as_ref().clone(); + + let sample_x_range = sample_bbox.x_min..sample_bbox.x_max; + let sample_y_range = sample_bbox.y_min..sample_bbox.y_max; + + // for each possible rotation, calculate the sample ranges (x and y) + // where the item resides fully inside the container and is within the sample bounding box + let rot_entries = rotations.iter() + .filter_map(|&r| { + let r_shape_bbox = shape_buffer.transform_from(item.shape_cd.as_ref(), &Transformation::from_rotation(r)).bbox; + + //narrow the container range to account for the rotated shape + let cont_x_range = (container_bbox.x_min - r_shape_bbox.x_min)..(container_bbox.x_max - r_shape_bbox.x_max); + let cont_y_range = (container_bbox.y_min - r_shape_bbox.y_min)..(container_bbox.y_max - r_shape_bbox.y_max); + + //intersect with the sample bbox + let x_range = intersect_range(&cont_x_range, &sample_x_range); + let y_range = intersect_range(&cont_y_range, &sample_y_range); + + //make sure the ranges are not empty + if x_range.is_empty() || y_range.is_empty() { + None + } else { + Some(RotEntry { r, x_range, y_range }) + } + }).collect_vec(); + + match rot_entries.is_empty() { + true => None, + false => Some(Self { rot_entries }), + } + } + + pub fn sample(&self, rng: &mut impl Rng) -> DTransformation { + // randomly select a rotation + let r_entry = self.rot_entries.choose(rng).unwrap(); + + // sample a random x and y value within the valid range + let r = r_entry.r; + let x_sample = rng.random_range(r_entry.x_range.clone()); + let y_sample = rng.random_range(r_entry.y_range.clone()); + + DTransformation::new(r, (x_sample, y_sample)) + } +} + +fn intersect_range(a: &Range, b: &Range) -> Range { + let min = f32::max(a.start, b.start); + let max = f32::min(a.end, b.end); + min..max +} + +/// Converts a sample transformation to the closest feasible transformation. (for now just mapping rotation to the closest allowed one) +pub fn convert_sample_to_closest_feasible(dt: DTransformation, item: &Item) -> DTransformation { + let feasible_rotation = match &item.allowed_rotation { + RotationRange::None => 0.0, + RotationRange::Discrete(v) => { + // find the closest rotation in the discrete set + v.iter().min_by_key(|&&r| { + // make sure to normalize the delta to the range [-PI, PI] + let norm_delta = normalize_rotation(dt.rotation() - r); + OrderedFloat(norm_delta.abs()) + }).cloned().unwrap() + } + RotationRange::Continuous => { + // for continuous rotation, we can just use the sample rotation + dt.rotation() + } + }; + DTransformation::new(feasible_rotation, dt.translation()) +} \ No newline at end of file diff --git a/src/sparrow_arrange/vendor/sparrow/src/util/assertions.rs b/src/sparrow_arrange/vendor/sparrow/src/util/assertions.rs new file mode 100644 index 00000000000..b7acfa9d094 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/util/assertions.rs @@ -0,0 +1,136 @@ +use crate::quantify::tracker::CollisionTracker; +use crate::quantify::{quantify_collision_poly_container, quantify_collision_poly_poly}; +use float_cmp::{approx_eq, assert_approx_eq}; +use itertools::Itertools; +use jagua_rs::collision_detection::hazards::collector::{BasicHazardCollector, HazardCollector}; +use jagua_rs::collision_detection::hazards::HazardEntity; +use jagua_rs::entities::Layout; +use jagua_rs::util::assertions; +use log::warn; + +pub fn tracker_matches_layout(ct: &CollisionTracker, l: &Layout) -> bool { + assert!(l.placed_items.keys().all(|k| ct.pk_idx_map.contains_key(k))); + assert!(assertions::layout_qt_matches_fresh_qt(l)); + + for (pk1, pi1) in l.placed_items.iter() { + // SPARROW_ARRANGE PATCH: pinned obstacles deliberately carry no loss of their own, so the + // invariants below do not apply to them. + if ct.is_pinned(pk1) { + continue; + } + let mut collector = BasicHazardCollector::new(); + l.cde().collect_poly_collisions(&pi1.shape, &mut collector); + collector.remove_by_entity(&HazardEntity::from((pk1, pi1))); + assert_eq!(ct.get_pair_loss(pk1, pk1), 0.0); + for (pk2, pi2) in l.placed_items.iter().filter(|(k, _)| *k != pk1) { + let stored_loss = ct.get_pair_loss(pk1, pk2); + match collector.iter().any(|(_, he)| he == &HazardEntity::from((pk2, pi2))) { + true => { + let calc_loss = quantify_collision_poly_poly(&pi1.shape, &pi2.shape); + let calc_loss_r = quantify_collision_poly_poly(&pi2.shape, &pi1.shape); + if !approx_eq!(f32,calc_loss,stored_loss,epsilon = 0.10 * stored_loss) && !approx_eq!(f32,calc_loss_r,stored_loss, epsilon = 0.10 * stored_loss) { + let mut opp_collector = BasicHazardCollector::new(); + l.cde().collect_poly_collisions(&pi2.shape, &mut opp_collector); + opp_collector.remove_by_entity(&HazardEntity::from((pk2, pi2))); + if opp_collector.contains_entity(&((pk1, pi1).into())) { + dbg!(&pi1.shape.vertices, &pi2.shape.vertices); + dbg!( + stored_loss, + calc_loss, + calc_loss_r, + opp_collector.iter().collect_vec(), + HazardEntity::from((pk1, pi1)), + HazardEntity::from((pk2, pi2)) + ); + panic!("tracker error"); + } else if stored_loss == 0.0 { + //detecting collisions is not symmetrical (in edge cases) + warn!("non-symmetrical collision!"); + // dbg!(stored_loss, calc_loss, calc_loss_r); + // warn!( + // "collisions: pi_1 {:?} -> {:?}", + // HazardEntity::from((pk1, pi1)), + // collector.iter().collect_vec() + // ); + // warn!( + // "opposite collisions: pi_2 {:?} -> {:?}", + // HazardEntity::from((pk2, pi2)), + // opp_collector.iter().collect_vec() + // ); + // + // warn!( + // "pi_1: {:?}", + // pi1.shape + // .vertices + // .iter() + // .map(|p| format!("({},{})", p.0, p.1)) + // .collect_vec() + // ); + // warn!( + // "pi_2: {:?}", + // pi2.shape + // .vertices + // .iter() + // .map(|p| format!("({},{})", p.0, p.1)) + // .collect_vec() + // ); + } + else { + dbg!(&pi1.shape.vertices, &pi2.shape.vertices); + dbg!( + stored_loss, + calc_loss, + calc_loss_r, + opp_collector.iter().collect_vec(), + HazardEntity::from((pk1, pi1)), + HazardEntity::from((pk2, pi2)) + ); + panic!("tracker error"); + } + } + } + false => { + if stored_loss != 0.0 { + let calc_loss = quantify_collision_poly_poly(&pi1.shape, &pi2.shape); + let mut opp_collector = BasicHazardCollector::new(); + l.cde().collect_poly_collisions(&pi2.shape, &mut opp_collector); + opp_collector.remove_by_entity(&HazardEntity::from((pk2, pi2))); + if !opp_collector.contains_entity(&HazardEntity::from((pk1, pi1))) { + dbg!(&pi1.shape.vertices, &pi2.shape.vertices); + dbg!( + stored_loss, + calc_loss, + opp_collector.iter().collect_vec(), + HazardEntity::from((pk1, pi1)), + HazardEntity::from((pk2, pi2)) + ); + panic!("tracker error"); + } else { + //detecting collisions is not symmetrical (in edge cases) + warn!("inconsistent loss"); + warn!( + "collisions: {:?} -> {:?}", + HazardEntity::from((pk1, pi1)), + collector.iter().collect_vec() + ); + warn!( + "opposite collisions: {:?} -> {:?}", + HazardEntity::from((pk2, pi2)), + opp_collector.iter().collect_vec() + ); + } + } + } + } + } + if collector.contains_entity(&HazardEntity::Exterior) { + let stored_loss = ct.get_container_loss(pk1); + let calc_loss = quantify_collision_poly_container(&pi1.shape, l.container.outer_cd.bbox); + assert_approx_eq!(f32, stored_loss, calc_loss, ulps = 5); + } else { + assert_eq!(ct.get_container_loss(pk1), 0.0); + } + } + + true +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/util/listener.rs b/src/sparrow_arrange/vendor/sparrow/src/util/listener.rs new file mode 100644 index 00000000000..c06ac46ccf3 --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/util/listener.rs @@ -0,0 +1,45 @@ +use jagua_rs::probs::spp::entities::{SPInstance, SPSolution}; + +/// Trait for listeners that can receive solutions during the optimization process +pub trait SolutionListener { + fn report(&mut self, report: ReportType, solution: &SPSolution, instance: &SPInstance); + + fn report_separation_progress(&mut self, _progress: SeparationProgress) {} + + fn report_separation_result(&mut self, _result: SeparationResult) {} + +} + +/// Progress within one call to [`crate::optimizer::separator::Separator::separate`]. +/// Iteration zero describes the initial layout; later values describe completed iterations. +#[derive(Debug, Clone, Copy)] +pub struct SeparationProgress { + pub strip_width: f32, + pub density: f32, + pub iteration: usize, + pub min_loss: f32, +} + +#[derive(Debug, Clone, Copy)] +pub struct SeparationResult { + pub success: bool, + pub elapsed_seconds: f32, + pub total_evals: usize, + pub total_moves: usize, + pub iterations: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReportType { + /// Report contains an intermediate solution that is closer to feasibility than the previous one. + ExplImproving, +} + +/// A dummy implementation of the `SolutionListener` trait that does nothing. +pub struct DummySolListener; + +impl SolutionListener for DummySolListener { + fn report(&mut self, _report: ReportType, _solution: &SPSolution, _instance: &SPInstance) { + // Do nothing + } +} diff --git a/src/sparrow_arrange/vendor/sparrow/src/util/mod.rs b/src/sparrow_arrange/vendor/sparrow/src/util/mod.rs new file mode 100644 index 00000000000..2d36b525a8f --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/util/mod.rs @@ -0,0 +1,3 @@ +pub mod assertions; +pub mod listener; +pub mod terminator; diff --git a/src/sparrow_arrange/vendor/sparrow/src/util/terminator.rs b/src/sparrow_arrange/vendor/sparrow/src/util/terminator.rs new file mode 100644 index 00000000000..6bc8a79c07c --- /dev/null +++ b/src/sparrow_arrange/vendor/sparrow/src/util/terminator.rs @@ -0,0 +1,45 @@ +use jagua_rs::Instant; +use std::time::Duration; + +/// Generic trait for any struct that can determine if the optimization process should terminate. +pub trait Terminator { + /// Checks if the termination condition is met + fn kill(&self) -> bool; + + /// Sets a new timeout duration + fn new_timeout(&mut self, timeout: Duration); + + /// Returns the instant when a timeout was set, if any + fn timeout_at(&self) -> Option; +} + +#[derive(Debug, Clone)] +pub struct BasicTerminator { + pub timeout: Option, +} + +impl Default for BasicTerminator { + fn default() -> Self { + Self::new() + } +} + +impl BasicTerminator { + pub fn new() -> Self { + Self { timeout: None } + } +} + +impl Terminator for BasicTerminator { + fn kill(&self) -> bool { + self.timeout.is_some_and(|timeout| Instant::now() > timeout) + } + + fn new_timeout(&mut self, timeout: Duration){ + self.timeout = Some(Instant::now() + timeout); + } + + fn timeout_at(&self) -> Option { + self.timeout + } +} \ No newline at end of file