diff --git a/.gitignore b/.gitignore index 2eaad2c412..8bba442a36 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 b4f3a964d1..08e87d7833 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 0f94d05fee..643d634cc0 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 6bec0cc642..f36a89d226 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 235c3078a2..f26cfe54a5 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 0000000000..2093881d90 --- /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 0000000000..732d325333 --- /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 a432007fd0..39f9e8c4f3 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 e1355536cb..b74c03d8d8 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 83440abfac..7ef10a021f 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 dc4806b10d..2685199775 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 ea897dd4af..30ef370598 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 059c856205..6440b18cc6 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 7a3805bb83..34b90ca78c 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 e5b82ac7a7..37c77ffe0f 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 3f3ec17122..9d937ca136 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 0000000000..2f7896d1d1 --- /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 0000000000..930fbfc1a8 --- /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 0000000000..bb75456be0 --- /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 0000000000..d81e84e1d9 --- /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 0000000000..60c3f8c628 --- /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 0000000000..c7a1eeba8f --- /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 0000000000..d16ec03c52 --- /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 0000000000..2cf3fa321c --- /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 0000000000..f6237a5c2b --- /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 0000000000..a881afaa8b --- /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 0000000000..a0ca9ce6f7 --- /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 0000000000..9e6d58adb2 --- /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 0000000000..67c47d90f5 --- /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 0000000000..2aea72b595 --- /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 0000000000..962042833b --- /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 0000000000..ebd360fdcd --- /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 0000000000..8f9ea4f621 --- /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 0000000000..2e75df0dab --- /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 0000000000..a1b39405d5 --- /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 0000000000..3ef0915b1c --- /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 0000000000..d77820121f --- /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 0000000000..0fc5edfcac --- /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 0000000000..0c46de26a0 --- /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 0000000000..8dd4db3b5b --- /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 0000000000..298da1d3a9 --- /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 0000000000..e9668ac887 --- /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 0000000000..ffec720670 --- /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 0000000000..530bc3c3c7 --- /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 0000000000..e83f29ed2d --- /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 0000000000..b7acfa9d09 --- /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 0000000000..c06ac46ccf --- /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 0000000000..2d36b525a8 --- /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 0000000000..6bc8a79c07 --- /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