Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ resolver = "3"
members = ["crates/geo", "crates/cde", "crates/optimizer", "crates/ironnest", "crates/py"]

[workspace.package]
version = "0.5.0" # bump deliberately; re-bless goldens if placement code changed
version = "0.6.0" # bump deliberately; re-bless goldens if placement code changed
edition = "2024" # jagua-rs lineage; edition 2024 needs Rust ≥ 1.85
rust-version = "1.88" # MSRV floor: i_overlay 7.x (docs/03 §3.5); jagua's cast_signed floor was 1.87
# (stable 1.87). We build on the pinned 1.96.0 — see rust-toolchain.toml.
Expand All @@ -26,6 +26,18 @@ pyo3 = { version = "0.29", features = ["abi3-py313", "extension-module"] }
codegen-units = 1
lto = "thin"

# The engine is compute-bound (tight geometry / collision-detection / separation-search loops), so an
# UNOPTIMIZED build runs ~10-12× slower on real nests — which is what made `cargo test` painfully slow
# (measured: the golden test ran golden_dump ×3 at opt-level 0 in ~633 s; at opt-level 2 it is ~51 s).
# opt-level is a code-generation choice ONLY: it never enables fast-math or changes IEEE `+ − × ÷ sqrt`
# results, so placements stay byte-identical and the goldens pass unchanged (the determinism contract
# is unaffected — that lever is codegen-units/target-cpu, pinned above for the *shipped* artifact, and
# deliberately NOT set here so dev/test incremental compiles stay fast). `[profile.test]` inherits this.
# opt-level 3 measured no faster than 2 here; the residual test time is the separation-search tail
# doing real work (docs/04), which optimization can only speed so far — the deeper lever is the engine.
[profile.dev]
opt-level = 2

# Make the determinism prime directive a build gate. clippy.toml supplies the banned lists;
# this enables the lints. Engine crates opt in with `[lints] workspace = true` (py does not,
# to avoid false positives from pyo3 macro expansion). Promoted to errors by `-D warnings`.
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,46 @@ placements, unplaced = ironnest.nest(
— e.g. rectangles pinned to `[0, 90]` while triangles interlock on `[0, 45, …, 315]`. Per-item sets
are equally byte-identical across platforms (the cross-platform golden covers a per-item case).

## Denser packing & speed knobs

`nest` / `nest_multi` take optional keyword arguments that trade run time for density or speed. Each
keeps the byte-identical determinism contract — the same arguments always reproduce the same layout:

- **`strategy`** — `"sampling"` (default) or **`"nfp"`**, the exact no-fit-polygon constructive mode:
integer-arithmetic Minkowski placement that puts parts in *contact* (killing the touching-artifact
gaps the sampler leaves) and nests into concave pockets. Recommended for irregular / curved parts.
- **`restarts`** — deterministic best-of-K multi-start (default `1`). Runs K decorrelated seeds and
keeps the densest, remnant-first result; `8`–`16` meaningfully tightens heterogeneous packs.
- **`separation_effort`** — how hard the post-construction search works on the leftovers:
- `"full"` (default) — the shipped behaviour;
- `"fast"` — skip provably-hopeless placements + a reduced budget: **~15× faster** on
over-subscribed jobs (a deliberately different, still-feasible layout);
- `"max"` — a **densification / compaction** pass that shrinks the pack toward one corner to leave
the largest usable **remnant** (a clean free strip) at the sheet's end.
- **`column_weight`** — the Left-Bottom-Fill horizontal weight for NFP placement (default `10`);
lowering it toward `3` closes vertical gaps between irregular parts.

```python
# Densest end-remnant for a fits-on-one-sheet job of curved parts:
placements, unplaced = ironnest.nest(
items, qty, container, holes=[], min_sep=0.375,
rotations=[0, 90, 180, 270], seed=1, budget=400,
strategy="nfp", column_weight=3, restarts=8, separation_effort="max",
)
```

### Reusing a part library across many containers

For *part-in-part* workloads — nesting one part set into many parent shapes — **`prepare`** builds the
container-independent geometry (item import + the O(P²) pairwise NFP table) **once** and reuses it
(~M× faster over M calls, byte-identical):

```python
prepared = ironnest.prepare(items, rotations=[0, 90, 180, 270], min_sep=0.375, strategy="nfp")
for parent in parents:
placements, unplaced = prepared.nest(qty, parent, holes=[], seed=1, budget=400)
```

## Why it exists

Existing irregular nesters force a bad trade:
Expand Down Expand Up @@ -99,6 +139,14 @@ A complete, end-to-end deterministic nester, shipping as a PyPI wheel.
maturin/cibuildwheel.
- **Phase 6 — interior voids + multi-sheet.** `nest(holes=…)` keep-out zones (nesting inside parts)
and `nest_multi(sheets=…)`, with a deterministic min-separation offsetter.
- **Exact-NFP construction mode (v0.5.0).** `strategy="nfp"` — an integer-arithmetic no-fit-polygon
constructive placer that places in contact and nests concave pockets
([`docs/03`](docs/03-nfp-construction-mode.md)).
- **Density & speed knobs (v0.6.0).** Separation-effort tiers (`"fast"` ≈ 15× on over-subscribed
packs, `"max"` remnant-maximizing densification); a tunable LBF `column_weight`; a reusable
`prepare()` part library for part-in-part workloads (~M× faster); and a dev-profile build fix that
cut the test suite ~12×. All opt-in and cross-platform byte-identical
([`docs/04`](docs/04-performance-remediation-plan.md)).

**Next:** Phase 5 — integration into the first consumer (a plasma CAD/CAM application). See
[`docs/00-ironnest-architecture-and-plan.md`](docs/00-ironnest-architecture-and-plan.md) (the plan)
Expand Down
9 changes: 5 additions & 4 deletions crates/ironnest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@

#[doc(inline)]
pub use ironnest_optimizer::{
MultiSheetSolution, NestConfig, NestError, NestSolution, Placement, PlacementStrategy, Scalar,
Sheet, nest, nest_multi, nest_multi_multistart, nest_multi_multistart_per_item,
nest_multi_per_item, nest_multi_with_config, nest_multistart, nest_multistart_per_item,
nest_per_item, nest_with_config,
DENSITY_COLUMN_WEIGHT, MultiSheetSolution, NestConfig, NestError, NestSolution, Placement,
PlacementStrategy, PreparedParts, Scalar, SeparationEffort, Sheet, nest, nest_multi,
nest_multi_multistart, nest_multi_multistart_per_item, nest_multi_per_item,
nest_multi_with_config, nest_multistart, nest_multistart_per_item, nest_per_item,
nest_with_config, nest_with_prepared, nest_with_prepared_weighted, prepare,
};
7 changes: 6 additions & 1 deletion crates/optimizer/examples/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
//! budget)`. Timing is reported only to stderr-style stdout text; it is NOT part of any golden.

use ironnest_optimizer::{
NestConfig, PlacementStrategy, Scalar, nest, nest_multistart, nest_with_config,
NestConfig, PlacementStrategy, Scalar, SeparationEffort, nest, nest_multistart,
nest_with_config,
};

fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> {
Expand Down Expand Up @@ -255,6 +256,8 @@ fn nfp_cache_probe() {
budget: 1,
restarts: 1,
strategy: ironnest_optimizer::PlacementStrategy::Nfp,
separation_effort: ironnest_optimizer::SeparationEffort::Full,
column_weight: 10,
};
let mut pair_count = 0usize;
let ms = best_ms(3, || {
Expand Down Expand Up @@ -291,6 +294,8 @@ fn nfp_probe(
budget: 2000, // drives only the sampling fallback + the sep tail's insertion attempts
restarts: k,
strategy: PlacementStrategy::Nfp,
separation_effort: SeparationEffort::Full,
column_weight: 10,
};
let mut placed = 0usize;
let mut placed_area = 0.0;
Expand Down
155 changes: 155 additions & 0 deletions crates/optimizer/examples/density_ab.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! Density A/B: column_weight × effort on the cone+square sector pack (consumer image family).
use ironnest_optimizer::{
DENSITY_COLUMN_WEIGHT, NestConfig, PlacementStrategy, Scalar, SeparationEffort,
nest_with_config,
};

fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> {
vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]]
}

#[allow(clippy::disallowed_methods)]
fn sector(ri: Scalar, ro: Scalar, deg: Scalar, steps: usize) -> Vec<[Scalar; 2]> {
let half = deg.to_radians() / 2.0;
let a0 = std::f64::consts::FRAC_PI_2 - half;
let a1 = std::f64::consts::FRAC_PI_2 + half;
let mut pts = Vec::new();
for i in 0..=steps {
let a = a0 + (a1 - a0) * (i as Scalar) / (steps as Scalar);
pts.push([ro * a.cos(), ro * a.sin()]);
}
for i in 0..=steps {
let a = a1 - (a1 - a0) * (i as Scalar) / (steps as Scalar);
pts.push([ri * a.cos(), ri * a.sin()]);
}
let (mut mnx, mut mny) = (f64::INFINITY, f64::INFINITY);
for p in &pts {
mnx = mnx.min(p[0]);
mny = mny.min(p[1]);
}
pts.iter().map(|p| [p[0] - mnx, p[1] - mny]).collect()
}

fn area(o: &[[Scalar; 2]]) -> Scalar {
let n = o.len();
let mut a = 0.0;
for i in 0..n {
let j = (i + 1) % n;
a += o[i][0] * o[j][1] - o[j][0] * o[i][1];
}
(0.5 * a).abs()
}

#[allow(clippy::disallowed_methods)]
fn time_ms<F: FnMut()>(mut f: F) -> f64 {
let t = std::time::Instant::now();
f();
t.elapsed().as_secs_f64() * 1000.0
}

fn main() {
let cone = sector(28.0, 60.0, 55.0, 10);
let sq = rect(10.0, 10.0);
let items = vec![cone.clone(), sq.clone()];
let areas = [area(&cone), area(&sq)];
let qty = [20usize, 150];
let container = rect(200.0, 120.0);
let cont_area = 200.0 * 120.0;
let cardinal = vec![0.0, 90.0, 180.0, 270.0];
let rots = vec![cardinal.clone(), cardinal.clone()];

println!("--- density_ab: cone+square oversubscribed; NFP ---");
for (label, cw, effort, restarts) in [
("w=10 Full K=1", 10u32, SeparationEffort::Full, 1usize),
(
"w=3 Full K=1",
DENSITY_COLUMN_WEIGHT,
SeparationEffort::Full,
1,
),
("w=10 Fast K=1", 10, SeparationEffort::Fast, 1),
(
"w=3 Fast K=1",
DENSITY_COLUMN_WEIGHT,
SeparationEffort::Fast,
1,
),
(
"w=3 Full K=4",
DENSITY_COLUMN_WEIGHT,
SeparationEffort::Full,
4,
),
(
"w=3 Max K=1",
DENSITY_COLUMN_WEIGHT,
SeparationEffort::Max,
1,
),
] {
let cfg = NestConfig {
min_sep: 0.0,
seed: 1,
budget: 1000,
restarts,
strategy: PlacementStrategy::Nfp,
separation_effort: effort,
column_weight: cw,
};
let mut placed = [0usize; 2];
let ms = time_ms(|| {
let sol = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap();
placed = [0, 0];
for p in &sol.placements {
placed[p.item] += 1;
}
});
let util =
(areas[0] * placed[0] as Scalar + areas[1] * placed[1] as Scalar) / cont_area * 100.0;
println!(
"{label:<16} cones {:>2} sq {:>3} util {:>5.1}% {ms:>8.0} ms",
placed[0], placed[1], util
);
}

// Remnant (all-fit) case
println!("--- remnant (6 cones + 24 sq in 240x120) ---");
let qty2 = [6usize, 24];
let container2 = rect(240.0, 120.0);
let sheet = 240.0 * 120.0;
for (label, cw, effort) in [
("w=10 Full", 10u32, SeparationEffort::Full),
("w=3 Full", DENSITY_COLUMN_WEIGHT, SeparationEffort::Full),
("w=3 Max", DENSITY_COLUMN_WEIGHT, SeparationEffort::Max),
] {
let cfg = NestConfig {
min_sep: 0.0,
seed: 1,
budget: 1500,
restarts: 1,
strategy: PlacementStrategy::Nfp,
separation_effort: effort,
column_weight: cw,
};
let sol = nest_with_config(&items, &qty2, &container2, &[], &rots, &cfg).unwrap();
let mut x0 = f64::INFINITY;
let mut x1 = f64::NEG_INFINITY;
let mut y0 = f64::INFINITY;
let mut y1 = f64::NEG_INFINITY;
for p in &sol.placements {
// crude used extent from placement origin (good enough for A/B ranking)
x0 = x0.min(p.x);
x1 = x1.max(p.x + 60.0);
y0 = y0.min(p.y);
y1 = y1.max(p.y + 60.0);
}
let used = (x1 - x0) * (y1 - y0);
println!(
"{label:<12} placed {:>2} rough_used_bbox {:>8.0} ({:>4.1}% of sheet) max_x~{:>6.1}",
sol.placements.len(),
used,
used / sheet * 100.0,
x1
);
}
}
Loading
Loading