3D 装箱算法工程化:数据结构、放置启发式与支撑规则

SmartPacker:用 Rust 实现的 3D 装箱(3D Bin Packing)算法库,核心 crate 零运行时依赖,Apache-2.0 开源。它也是桌面装箱应用「优载 BestLoad」的算法内核。


为什么写 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 不是网格,也不是八叉树,而是轴对齐区间列表。任何一次放置,本质上是往这张表里追加一个新区间。

好处是:

  1. 稀疏:只记录真正占用的空间;
  2. 尺寸无关:不需要按最小粒度栅格化;
  3. 判定简单:新物品与每个区间做包围盒相交,O(箱内物品数);
  4. 方便扫描:沿某轴收集投影重叠区间,就能找到可下落的空隙。

箱子的初始化很关键: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 接收候选点,依次判定:

  1. 边界 :pivot + 当前旋转尺寸 是否超出箱子;
  2. 碰撞:与所有已放物品是否相交;
  3. 承重 :总重 + 本件是否超过 max_weight;
  4. 稳定性:重力修正后的底面是否有足够支撑。
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 重力修正:区间扫描与缝隙吸附

候选点只是大致位置,放置前要尽量靠下、靠壁。

对某个轴:

  1. 收集在另外两轴上与候选物品投影重叠的已占区间;
  2. 取这些区间在该轴上的范围,按上界排序;
  3. 找第一个能容纳物品长度的缝隙;
  4. 把物品压到缝隙下界。
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. 面积主规则 :支撑面积 / 底面积 >= 1 - allowed_float_ratio;
  2. 四角兜底:底面四个角都落在某个支撑矩形内。

四角兜底处理"中间镂空、四角有支撑"的情况,比如托盘上的货物。只算面积会误杀。

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:

相关推荐
Hello.Reader2 小时前
tsrs 技术解析用 Rust 移植 TypeScript 7,类型检查还能怎样提速?
开发语言·rust·typescript
叉烧滑蛋3 小时前
B站台湾大学郭彦甫|MATLAB 学习笔记|04 变量与档案存取
数据结构
j7~4 小时前
【穷举、深搜与回溯算法】一棵决策树讲透全排列与子集
数据结构·c++·算法·leetcode·回溯·递归
王仔小奶4 小时前
Rust 正在接管金融圈?深度拆解一份高薪 FinTech 岗位 JD
开发语言·金融·rust
傅科摆 _ py4 小时前
DP,贪心,堆和栈,二分力扣复习
开发语言·python·算法
Shaoshing5 小时前
LeetCode100 42.接雨水
java·算法·leetcode
域限奇点访客5 小时前
用AI生成问卷的网站怎么选?看题目录入结构、题型映射和生成后的校验
算法
你为她披上外套时我正站在窗外5 小时前
subtitler v2.10 发布:3 个社区 PR、一个一天修掉的 issue,和关于 uuencode 的真相
rust
Thneonl5 小时前
熔断不是开关是状态机:Claude Code 路由内核的 3 条红线
人工智能·rust