SmartPacker:用 Rust 实现的 3D 装箱(3D Bin Packing)算法库,核心 crate 零运行时依赖,Apache-2.0 开源。它也是桌面装箱应用「优载 BestLoad」的算法内核。
- GitHub:github.com/niyongsheng...
- crates.io:
cargo add smartpacker(当前 0.3.0)
为什么写 SmartPacker
3D 装箱是 NP-hard 问题:把一批货物装进集装箱,要求不越界、不重叠、不超重,还要装得多、重心稳、不悬空。精确解在真实规模下不现实,工业界普遍用启发式求"足够好的可行解"。
难点往往不在找空位,而在:
- 候选点如何生成,决定空间利用率的上限;
- 碰撞检测用什么数据结构,决定能不能跑得动;
- 稳定性怎么定义,决定结果能不能真的装车;
- 浮点误差怎么处理,决定同一输入会不会跑出两个答案。
SmartPacker 的设计就是围绕这四点展开的。
一、数据结构
1.1 Item:几何 + 业务属性
rust
pub struct Item {
pub partno: String,
pub name: String, // binding 按名字分组
pub type_of: ItemType, // Cube | Cylinder
pub width: f64,
pub height: f64,
pub depth: f64,
pub weight: f64,
pub level: i32, // 优先级,越小越先装
pub loadbear: i32, // 承重,越大越先装
pub updown: bool, // 是否允许倒放
pub allowed_float_ratio: f64, // 允许底面悬空的比例 0..=1
pub rotation_type: u8, // 0..=5
pub position: [f64; 3],
pub step: usize, // 放置序号,用于回放
}
六种旋转用置换表示,不为每种姿态复制几何:
| rotation_type | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
(x, y, z) |
(w,h,d) |
(h,w,d) |
(h,d,w) |
(d,h,w) |
(d,w,h) |
(w,d,h) |
rust
pub fn dimension(&self) -> [f64; 3] {
let (w, h, d) = (self.width, self.height, self.depth);
match self.rotation_type {
0 => [w, h, d], 1 => [h, w, d], 2 => [h, d, w],
3 => [d, h, w], 4 => [d, w, h], _ => [w, d, h],
}
}
dimension() 是"当前姿态包围盒"的唯一事实源。越界、碰撞、重力修正、重心统计全部只认它,避免尺寸字段和旋转字段各说各话。
禁止倒放的物品只有 WHD / HWD 两种姿态;圆柱体构造时强制 updown = false。
allowed_float_ratio 把悬空容忍度下放到每件货物:放置时要求底面支撑占比 ≥ 1 - allowed_float_ratio。0 必须完全支撑,1 不限制,默认建议 0.25。纸箱和精密设备本来就不该用同一把尺子。
step 记录真实放置顺序。空间重排只改数组顺序,不改 step,所以同一份结果既能按位置渲染,也能按时间回放动画。
1.2 Bin:一张区间表记录已占空间
rust
pub struct Bin {
pub partno: String,
pub width: f64,
pub height: f64,
pub depth: f64,
pub max_weight: f64,
pub corner: f64, // 角件边长,0 表示无
pub items: Vec<Item>,
pub unfitted_items: Vec<Item>,
pub gravity: Vec<f64>, // 四象限重心占比
pub(crate) fit_items: Vec<[f64; 6]>, // 已占空间 [x0,x1,y0,y1,z0,z1]
}
fit_items 不是网格,也不是八叉树,而是轴对齐区间列表。任何一次放置,本质上是往这张表里追加一个新区间。
好处是:
- 稀疏:只记录真正占用的空间;
- 尺寸无关:不需要按最小粒度栅格化;
- 判定简单:新物品与每个区间做包围盒相交,O(箱内物品数);
- 方便扫描:沿某轴收集投影重叠区间,就能找到可下落的空隙。
箱子的初始化很关键:fit_items 不是空列表,而是整个箱底平面 [[0, w, 0, h, 0, 0]]------一个高度为 0 的地面薄片。
这样"箱底"和"已放货物的顶面"就是同一种数据结构:重力修正时地面可落脚,支撑检查时地面是天然的全支撑面,不需要为地面写特判。
1.3 Packer:arena + 索引,绕开 Rust 所有权
Python 原版里,同一件 Item 被 Packer.items、Bin.items、Bin.unfitted_items 多个列表引用,放置时原地修改,所有引用都能看到。
Rust 不允许这种可变别名。如果每个列表都存克隆,放置时改的是副本,状态立刻分叉。
解法是 arena + 索引:
rust
pub struct Packer {
pub bins: Vec<Bin>,
pub unfit_items: Vec<Item>, // 最终物化结果
pub(crate) arena: Vec<Item>, // 唯一 owner
pub(crate) item_ids: Vec<usize>, // 待装物品索引
pub(crate) binding: Vec<Vec<String>>,
}
arena持有全部物品,每件只有一份;item_ids/bin.unfitted_ids只存usize;- 放置时通过
&mut arena[id]原地修改; pack()结束时把最终状态克隆物化到公开的Vec<Item>。
代价是结束时的克隆,但核心全程没有重复存储,也不会状态分叉。调用方拿到的仍是一份普通 Vec<Item>。
二、核心算法
2.1 总流程
text
add_bin / add_item
│
▼
① format_numbers 尺寸、重量统一量化
② sort bins 按体积
③ sort items 排序链
④ binding 绑定组重排(可选)
⑤ 逐箱放置 pack2_bin → put_item → 重力修正 → 支撑检查
⑥ put_order 展示顺序
⑦ 物化 items / unfitted_items
2.2 排序链
业务上希望优先级高的先装、承重强的先装、体积按配置方向排。实现用单次比较链表达复合键:
rust
self.item_ids.sort_by(|&a, &b| {
let va = arena[a].volume();
let vb = arena[b].volume();
let by_volume = if bigger_first { vb.total_cmp(&va) } else { va.total_cmp(&vb) };
arena[a].level.cmp(&arena[b].level) // 主键:level 升序
.then(arena[b].loadbear.cmp(&arena[a].loadbear)) // 次键:承重降序
.then(by_volume) // 末键:体积
});
这里用 total_cmp 而不是 partial_cmp(...).unwrap():f64 的 NaN 会让后者 panic,算法库需要全序。
2.3 候选点:轴扫描式极端点
已放物品的右、上、前三个贴边位置,最可能放得下新物品:
rust
for axis in 0..3 {
let mut i = 0;
while i < bin.items.len() {
let ib = &bin.items[i];
let (d, p) = (ib.dimension(), ib.position);
let pivot = match axis {
0 => [p[0] + d[0], p[1], p[2]], // 右侧
1 => [p[0], p[1] + d[1], p[2]], // 上方
_ => [p[0], p[1], p[2] + d[2]], // 前方
};
if bin.put_item(&mut arena[item_id], pivot) { return true; }
i += 1;
}
}
几个细节:
- 三轴依次尝试,x 轴成功就结束,不再试 y / z;
while i < bin.items.len()每轮重算长度,新放进去的物品也会贡献新的极端点;- 空箱没有锚点,直接尝试物品原始位置;有角件时先放 8 个角件再扫描。
每件物品最多尝试 3 × 箱内物品数 个候选点,每个点做一次 O(n) 相交检查,单箱近似 O(n²)。几十件货物是毫秒级;更大规模需要区间树或空间哈希。
2.4 put_item:四道闸门
put_item 接收候选点,依次判定:
- 边界 :
pivot + 当前旋转尺寸是否超出箱子; - 碰撞:与所有已放物品是否相交;
- 承重 :总重 + 本件是否超过
max_weight; - 稳定性:重力修正后的底面是否有足够支撑。
rust
for rot in 0..n_rot { // 可倒放 6 种,否则 2 种
item.rotation_type = rot as u8;
let [w, h, d] = item.dimension();
if self.width < pivot[0] + w
|| self.height < pivot[1] + h
|| self.depth < pivot[2] + d { continue; } // 闸门 1:换旋转
if self.items.iter().any(|cur| intersect(cur, item)) {
item.position = valid_item_position;
return false; // 闸门 2:碰撞失败
}
if total + item.weight > self.max_weight {
return false; // 闸门 3:超重
}
// 闸门 4:fix_point 重力修正 + bottom_support 支撑检查
// ...
self.items.push(item.clone());
return true;
}
两个关键启发式:
第一个通过边界检查的旋转就定胜负。 如果它因碰撞失败,直接返回 false,不再试其他旋转。这是性能取舍:避免"候选点 × 旋转"的笛卡尔积爆炸,牺牲部分解质量换稳定耗时。
碰撞在重力修正之前判定。 物品下落后最终位置可能与已有物品几何重叠,这是启发式的固有输出;测试只断言支撑规则,不断言两两不重叠。
碰撞检测把三维拆成三对二维投影:
rust
fn intersect(a: &Item, b: &Item) -> bool {
rect_intersect(a, b, 0, 1) // x-y
&& rect_intersect(a, b, 1, 2) // y-z
&& rect_intersect(a, b, 0, 2) // x-z
}
fn rect_intersect(a: &Item, b: &Item, x: usize, y: usize) -> bool {
let (d1, d2) = (a.dimension(), b.dimension());
let ix = ((a.position[x] + d1[x] / 2.0) - (b.position[x] + d2[x] / 2.0)).abs();
let iy = ((a.position[y] + d1[y] / 2.0) - (b.position[y] + d2[y] / 2.0)).abs();
ix < (d1[x] + d2[x]) / 2.0 && iy < (d1[y] + d2[y]) / 2.0
}
用的是严格小于,所以面贴面不算相交------否则所有紧密排列都会被误判为碰撞。
2.5 重力修正:区间扫描与缝隙吸附
候选点只是大致位置,放置前要尽量靠下、靠壁。
对某个轴:
- 收集在另外两轴上与候选物品投影重叠的已占区间;
- 取这些区间在该轴上的范围,按上界排序;
- 找第一个能容纳物品长度的缝隙;
- 把物品压到缝隙下界。
text
候选在 y 轴范围 [y, y+h]
已占区间的 y 范围:
[0,10] [12,20] [25,40]
↑
11~12 只有 2 的缝,放不下 h
10 之后:12-10=2 < h,跳过
20 之后:25-20=5,若 h<=5 则落到 y=20
三个轴共用一套 check_axis,只是重叠轴对和收集轴不同:
- y:看 x/z 投影,收集 y 区间;
- x:看 z/y 投影,收集 x 区间;
- z:看 x/y 投影,收集 z 区间。
完整修正迭代 3 轮,顺序 y → x → z。因为三个轴互相影响:下落可能腾出横向空间,横移又可能继续下落。3 轮是经验值,兼顾效果和耗时。
区间运算用 i64 截断,支撑检查用 f64 + EPS:前者追求吸附确定性,后者追求几何稳定。应用尺寸通常是整数,这个差异基本无感知。
2.6 底部支撑:从"投影重叠"到"顶面恰好托底"
这是最核心的工程约束。
很多实现只看投影是否重叠,但投影重叠 ≠ 支撑:箱子可能只有一角搭在下面货物上,其余悬空,俯视图看它在里面,物理上根本放不稳。
SmartPacker 的规则是:
只有已占区间的顶面恰好等于 物品的底面 (
y1 == y0),才算支撑物。
不是"顶面高于底面",也不是"任意投影重叠",而是顶面与底面在容差内相等。这条规则把"侧面贴着"和"底下垫着"彻底分开。
rust
fn bottom_support(&self, x: f64, w: f64, z: f64, d: f64, y0: f64)
-> (f64, bool)
{
if w * d <= EPS { return (1.0, true); } // 退化物品不限制
if y0 <= EPS { return (1.0, true); } // 箱底 = 全支撑
let mut support = 0.0;
let mut corners = [false; 4];
let cs = [[x, z], [x + w, z], [x, z + d], [x + w, z + d]];
for fi in &self.fit_items {
if (fi[3] - y0).abs() > EPS { continue; } // 顶面必须恰好托住底面
let x_ov = (x + w).min(fi[1]) - x.max(fi[0]);
let z_ov = (z + d).min(fi[5]) - z.max(fi[4]);
if x_ov > EPS && z_ov > EPS { support += x_ov * z_ov; }
for (k, c) in cs.iter().enumerate() {
if fi[0] - EPS <= c[0] && c[0] <= fi[1] + EPS
&& fi[4] - EPS <= c[1] && c[1] <= fi[5] + EPS {
corners[k] = true;
}
}
}
(support / (w * d), corners.iter().all(|&c| c))
}
判定满足其一即可:
- 面积主规则 :
支撑面积 / 底面积 >= 1 - allowed_float_ratio; - 四角兜底:底面四个角都落在某个支撑矩形内。
四角兜底处理"中间镂空、四角有支撑"的情况,比如托盘上的货物。只算面积会误杀。
allowed_float_ratio = 0 要求 100% 支撑;0.25 允许 25% 悬空;1 不限制。
这条规则也写成了测试门禁:tests/no_floating.rs 对随机场景和真实订单逐件断言,任何已放物品都必须满足声明的支撑规则。
2.7 绑定组、重心与回放
绑定组 :binding: Vec<Vec<String>>,每组按名字匹配。组内物品轮询交错排列:
text
组 A:[a1, a2, a3, a4]
组 B:[b1, b2]
最小组长 min_c = 2
输出:a1, b1, a2, b2, a3, a4
这样同组货物在队列里彼此靠近,更容易落在相邻位置;组间交错,避免某一组先塞满箱子。空绑定组直接跳过,不参与 min_c,否则整批物品会被 min_c = 0 丢掉。
四象限重心 :按 w/2、h/2 切四个象限,物品完全在象限内则整件计入;跨一个轴按重叠长度拆分;跨两轴按四象限面积拆分。输出 4 个百分比,空箱返回 [0,0,0,0]。
放置回放 :put_order 按 put_type 对结果做展示排序((x,z,y) 或 (z,y,x)),只改数组顺序,不改 step。空间顺序用于渲染,时间顺序用于动画。
三、数值语义与测试
银行家舍入 :quantize 使用 ROUND_HALF_EVEN,2.5 → 2、3.5 → 4,避免大量数据产生系统性偏置,也与 Python Decimal.quantize 默认行为一致。
EPS = 1e-9 :支撑判定的 y1 == y0、面积、四角逐点比较全部带容差。足够小,不会把真实缝隙当支撑;足够大,能吸收浮点尾差。
三层测试:
- proptest 属性测试:不越界、不超重、物品唯一、每件满足支撑规则、
step是1..=len的排列; no_floating.rs门禁:随机扫描 + 真实场景(2×40HQ + 3×20GP、474 件货物,要求装载量不低于 470 件);- 确定性回归:同输入跑两次,结果指纹必须一致。
bash
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features --workspace
人工排查:
bash
cargo run --example floating_check
四、上手与项目
rust
use smartpacker::constants::ItemType;
use smartpacker::item::Item;
use smartpacker::packer::{Bin, PackOptions, Packer};
let mut p = Packer::new();
p.add_bin(Bin::new("example", [30.0, 10.0, 15.0], 99.0));
p.add_item(Item::new(
"test", "test", ItemType::Cube,
[9.0, 8.0, 7.0], 1.0, 1, 100,
true, "red",
0.25, // 允许 25% 悬空
));
p.pack(&PackOptions::default());
for it in &p.bins[0].items {
println!("{} @ {:?} rot={}", it.partno, it.position, it.rotation_type);
}
仓库还带了多箱分发、圆柱混合、稳定性、绑定组、PNG 渲染等示例:
bash
cargo run --example multi_bin
cargo run --example cylinder_mixed
cargo run --example plot --features plot
工程结构分三层:
text
smartpacker 核心算法库(std only,可选 serde / plot)
│
smartpacker-server HTTP 服务(axum,:5050)
│
best-load Tauri 2 桌面应用「优载 BestLoad」
核心库不依赖运行时,方便嵌入和服务端部署;服务层提供 JSON 契约;应用层负责数据与交互。算法可以独立演进。
代码仓库
SmartPacker 目前是 0.3.0,Apache-2.0。欢迎试用、提 Issue、提 PR,也欢迎点个 Star: