@packvium/engine 0.1.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/SECURITY.md +2 -2
- package/contact-graph.js +143 -29
- package/examples/constraints.mjs +97 -0
- package/examples/shapes.mjs +155 -0
- package/examples/units.mjs +115 -0
- package/fallback.js +925 -59
- package/index.js +52 -12
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
Deterministic 3D cartonization for Node.js. It uses the optional native engine when
|
|
4
4
|
available and automatically falls back to the bundled JavaScript implementation.
|
|
5
5
|
|
|
6
|
+
Full documentation, the constraint reference and benchmarks live at
|
|
7
|
+
[packvium.com](https://packvium.com).
|
|
8
|
+
|
|
6
9
|
## Install
|
|
7
10
|
|
|
8
11
|
```bash
|
|
@@ -100,6 +103,10 @@ and execute without a project around it.
|
|
|
100
103
|
| File | What it shows |
|
|
101
104
|
| --- | --- |
|
|
102
105
|
| [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. |
|
|
106
|
+
| [`objectives.mjs`](examples/objectives.mjs) | All six objectives on scenes where they genuinely disagree — the same scores the Python, PHP and Rust engines print for the same request. |
|
|
107
|
+
| [`shapes.mjs`](examples/shapes.mjs) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, and a cushion that compresses under load until the crush limit refuses it. |
|
|
108
|
+
| [`constraints.mjs`](examples/constraints.mjs) | Stacking caps, incompatible tags and atomic groups — each shown with and without the rule, plus how to read the structured refusal. |
|
|
109
|
+
| [`units.mjs`](examples/units.mjs) | Why lengths travel as strings: fractional inches kept exact, one tick deciding a fit, and the point where a JavaScript number stops being exact and a quote is refused rather than rounded. |
|
|
103
110
|
| [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. |
|
|
104
111
|
|
|
105
112
|
```bash
|
|
@@ -133,7 +140,7 @@ Documentation, the constraint reference and the benchmarks are at
|
|
|
133
140
|
| --- | --- | --- |
|
|
134
141
|
| Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) |
|
|
135
142
|
| PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) |
|
|
136
|
-
| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "0
|
|
143
|
+
| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.0"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
|
|
137
144
|
| Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) |
|
|
138
145
|
| Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) |
|
|
139
146
|
| PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) |
|
package/SECURITY.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Supported versions
|
|
4
4
|
|
|
5
|
-
Only the latest `
|
|
6
|
-
long-term support branch
|
|
5
|
+
Only the latest `1.x` release receives fixes. The `0.1.x` line is superseded by `1.0.0`
|
|
6
|
+
and receives none. There is no long-term support branch for older majors.
|
|
7
7
|
|
|
8
8
|
## Reporting a vulnerability
|
|
9
9
|
|
package/contact-graph.js
CHANGED
|
@@ -6,45 +6,106 @@
|
|
|
6
6
|
* representation can avoid. The exact overlap function remains authoritative.
|
|
7
7
|
* This module is package-internal: package.json exports only the root entry point.
|
|
8
8
|
*/
|
|
9
|
-
export function buildContactGraph(boxes, overlapXY) {
|
|
10
|
-
const supporters = boxes.map(() => []);
|
|
11
|
-
const children = boxes.map(() => []);
|
|
12
|
-
if (boxes.length === 0) return { supporters, children, candidateChecks: 0 };
|
|
13
|
-
|
|
14
|
-
const cell = Math.max(1, ...boxes.map(box => Math.max(box.d[0], box.d[1])));
|
|
15
|
-
const byTop = new Map();
|
|
16
|
-
const levels = new Map();
|
|
17
|
-
const cells = box => {
|
|
18
|
-
const x1 = Math.floor(box.x / cell);
|
|
19
|
-
const x2 = Math.floor((box.x + box.d[0] - 1) / cell);
|
|
20
|
-
const y1 = Math.floor(box.y / cell);
|
|
21
|
-
const y2 = Math.floor((box.y + box.d[1] - 1) / cell);
|
|
22
|
-
return [...new Set([`${x1}:${y1}`, `${x2}:${y1}`, `${x1}:${y2}`, `${x2}:${y2}`])];
|
|
23
|
-
};
|
|
24
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The at-most-four cells `box` occupies.
|
|
12
|
+
*
|
|
13
|
+
* `cell` must be at least as large as the largest footprint dimension of every box
|
|
14
|
+
* hashed into the index or queried against it -- not just the ones being indexed. Only
|
|
15
|
+
* then is a box guaranteed to span no more than a 2x2 block, which is what makes two
|
|
16
|
+
* overlapping boxes always share a cell. Sizing it from the indexed boxes alone would be
|
|
17
|
+
* exactly wrong: a larger querying box could step over cells in the middle of its own
|
|
18
|
+
* footprint and silently miss a real overlap.
|
|
19
|
+
*/
|
|
20
|
+
function cellsOf(box, cell) {
|
|
21
|
+
const x1 = Math.floor(box.x / cell);
|
|
22
|
+
const x2 = Math.floor((box.x + box.d[0] - 1) / cell);
|
|
23
|
+
const y1 = Math.floor(box.y / cell);
|
|
24
|
+
const y2 = Math.floor((box.y + box.d[1] - 1) / cell);
|
|
25
|
+
return [...new Set([`${x1}:${y1}`, `${x2}:${y1}`, `${x1}:${y2}`, `${x2}:${y2}`])];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function bucketsByPlane(boxes, plane) {
|
|
29
|
+
const byPlane = new Map();
|
|
25
30
|
boxes.forEach((box, index) => {
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
28
|
-
|
|
31
|
+
const key = plane(box);
|
|
32
|
+
if (!byPlane.has(key)) byPlane.set(key, []);
|
|
33
|
+
byPlane.get(key).push(index);
|
|
29
34
|
});
|
|
35
|
+
return byPlane;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const topOf = box => box.z + box.d[2];
|
|
39
|
+
const bottomOf = box => box.z;
|
|
30
40
|
|
|
41
|
+
function levelIndex(boxes, indices, cell) {
|
|
42
|
+
const level = new Map();
|
|
43
|
+
for (const index of indices) {
|
|
44
|
+
for (const key of cellsOf(boxes[index], cell)) {
|
|
45
|
+
if (!level.has(key)) level.set(key, []);
|
|
46
|
+
level.get(key).push(index);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return level;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every box in `buckets` on `plane` that really overlaps `box`, ascending by index.
|
|
54
|
+
*
|
|
55
|
+
* Ascending order is contract, not presentation: `topLoads` splits a conserved integer
|
|
56
|
+
* across the supporter list and hands the rounding remainder to whichever edge is last.
|
|
57
|
+
*/
|
|
58
|
+
function overlapsOnPlane(graph, buckets, cache, plane, box, overlapXY) {
|
|
59
|
+
const indices = buckets.get(plane);
|
|
60
|
+
if (!indices) return [];
|
|
61
|
+
let level = cache.get(plane);
|
|
62
|
+
if (level == null) {
|
|
63
|
+
level = levelIndex(graph.boxes, indices, graph.cell);
|
|
64
|
+
cache.set(plane, level);
|
|
65
|
+
}
|
|
66
|
+
const nearby = new Set();
|
|
67
|
+
for (const key of cellsOf(box, graph.cell)) {
|
|
68
|
+
for (const index of level.get(key) ?? []) nearby.add(index);
|
|
69
|
+
}
|
|
70
|
+
const found = [];
|
|
71
|
+
for (const other of [...nearby].sort((left, right) => left - right)) {
|
|
72
|
+
const area = overlapXY(graph.boxes[other], box);
|
|
73
|
+
if (area > 0) found.push([other, area]);
|
|
74
|
+
}
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `cellHint` is an upper bound on the footprint of any box that may later be appended
|
|
80
|
+
* with `appendContactBox`.
|
|
81
|
+
*
|
|
82
|
+
* Without it the cell is sized from the boxes present now, and appending anything wider
|
|
83
|
+
* has to fall back to a full rebuild -- correct, but it defeats the point, because in a
|
|
84
|
+
* search the base is what is already placed and the candidate is a *new* item that may
|
|
85
|
+
* well be the widest in the request. A caller that knows the item set passes its widest
|
|
86
|
+
* footprint once and the delta path then always applies. Too large a hint only makes
|
|
87
|
+
* each bucket coarser; too small a one cannot give a wrong answer, because the fallback
|
|
88
|
+
* covers it.
|
|
89
|
+
*/
|
|
90
|
+
export function buildContactGraph(boxes, overlapXY, cellHint = 1) {
|
|
91
|
+
const supporters = boxes.map(() => []);
|
|
92
|
+
const children = boxes.map(() => []);
|
|
93
|
+
const cell = Math.max(1, cellHint, ...boxes.map(box => Math.max(box.d[0], box.d[1])));
|
|
94
|
+
const byTop = bucketsByPlane(boxes, topOf);
|
|
95
|
+
const byBottom = bucketsByPlane(boxes, bottomOf);
|
|
96
|
+
const topLevels = new Map();
|
|
31
97
|
let candidateChecks = 0;
|
|
98
|
+
|
|
32
99
|
boxes.forEach((upper, upperIndex) => {
|
|
33
100
|
const candidates = byTop.get(upper.z);
|
|
34
101
|
if (!candidates) return;
|
|
35
|
-
let level =
|
|
102
|
+
let level = topLevels.get(upper.z);
|
|
36
103
|
if (level == null) {
|
|
37
|
-
level =
|
|
38
|
-
|
|
39
|
-
for (const key of cells(boxes[index])) {
|
|
40
|
-
if (!level.has(key)) level.set(key, []);
|
|
41
|
-
level.get(key).push(index);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
levels.set(upper.z, level);
|
|
104
|
+
level = levelIndex(boxes, candidates, cell);
|
|
105
|
+
topLevels.set(upper.z, level);
|
|
45
106
|
}
|
|
46
107
|
const nearby = new Set();
|
|
47
|
-
for (const key of
|
|
108
|
+
for (const key of cellsOf(upper, cell)) {
|
|
48
109
|
for (const index of level.get(key) ?? []) nearby.add(index);
|
|
49
110
|
}
|
|
50
111
|
for (const lowerIndex of [...nearby].sort((left, right) => left - right)) {
|
|
@@ -57,5 +118,58 @@ export function buildContactGraph(boxes, overlapXY) {
|
|
|
57
118
|
}
|
|
58
119
|
}
|
|
59
120
|
});
|
|
60
|
-
|
|
121
|
+
// The downward-facing indexes stay empty here: only an append queries them, and a graph
|
|
122
|
+
// built once and read once would otherwise pay for an index nothing looks at.
|
|
123
|
+
return { supporters, children, candidateChecks, boxes, cell, byTop, byBottom, topLevels,
|
|
124
|
+
bottomLevels: new Map() };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `graph` plus one more box, appended at the next index.
|
|
129
|
+
*
|
|
130
|
+
* Adding a box cannot create or destroy contact between two boxes already in the graph:
|
|
131
|
+
* contact is a pairwise geometric predicate over two boxes and nothing else. That is the
|
|
132
|
+
* whole reason a delta is sound, and it is why only the new box's own two planes are
|
|
133
|
+
* queried instead of every box being re-examined.
|
|
134
|
+
*
|
|
135
|
+
* The result is required to be identical to `buildContactGraph([...boxes, box])`, not
|
|
136
|
+
* merely equivalent -- see `overlapsOnPlane` on why edge order is contract. The new box
|
|
137
|
+
* takes the highest index, so appending it to an existing list keeps that list ascending.
|
|
138
|
+
*
|
|
139
|
+
* `graph` is not modified: the returned graph shares every edge list the append did not
|
|
140
|
+
* touch, and copies the two or three it did.
|
|
141
|
+
*/
|
|
142
|
+
export function appendContactBox(graph, box, overlapXY) {
|
|
143
|
+
const index = graph.boxes.length;
|
|
144
|
+
const footprint = Math.max(box.d[0], box.d[1]);
|
|
145
|
+
if (footprint > graph.cell) {
|
|
146
|
+
// The broad phase is only correct while its cell covers every box hashed into it or
|
|
147
|
+
// queried against it, so this is a correctness fallback, not an optimisation choice.
|
|
148
|
+
return buildContactGraph([...graph.boxes, box], overlapXY, footprint);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const below = overlapsOnPlane(graph, graph.byTop, graph.topLevels, box.z, box, overlapXY);
|
|
152
|
+
const above = overlapsOnPlane(graph, graph.byBottom, graph.bottomLevels, topOf(box), box, overlapXY);
|
|
153
|
+
|
|
154
|
+
const supporters = graph.supporters.slice();
|
|
155
|
+
const children = graph.children.slice();
|
|
156
|
+
supporters.push(below.map(([lower, area]) => [lower, area]));
|
|
157
|
+
children.push(above.map(([upper]) => upper));
|
|
158
|
+
for (const [lower] of below) children[lower] = [...children[lower], index];
|
|
159
|
+
for (const [upper, area] of above) supporters[upper] = [...supporters[upper], [index, area]];
|
|
160
|
+
|
|
161
|
+
// One box joins exactly two planes, so only those two buckets change, and only the two
|
|
162
|
+
// level indexes describing them are invalidated. A level index is never mutated after
|
|
163
|
+
// it is built, so every other one is shared with the base rather than rebuilt.
|
|
164
|
+
const byTop = new Map(graph.byTop);
|
|
165
|
+
byTop.set(topOf(box), [...(byTop.get(topOf(box)) ?? []), index]);
|
|
166
|
+
const byBottom = new Map(graph.byBottom);
|
|
167
|
+
byBottom.set(box.z, [...(byBottom.get(box.z) ?? []), index]);
|
|
168
|
+
const topLevels = new Map(graph.topLevels);
|
|
169
|
+
topLevels.delete(topOf(box));
|
|
170
|
+
const bottomLevels = new Map(graph.bottomLevels);
|
|
171
|
+
bottomLevels.delete(box.z);
|
|
172
|
+
|
|
173
|
+
return { supporters, children, candidateChecks: graph.candidateChecks,
|
|
174
|
+
boxes: [...graph.boxes, box], cell: graph.cell, byTop, byBottom, topLevels, bottomLevels };
|
|
61
175
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constraints: how to say "this may not go there", and how to read the refusal.
|
|
3
|
+
*
|
|
4
|
+
* Run it:
|
|
5
|
+
*
|
|
6
|
+
* node examples/constraints.mjs
|
|
7
|
+
*
|
|
8
|
+
* Most real packing rules are refusals — this side up, nothing on top of that, keep the
|
|
9
|
+
* chemicals away from the food — and the useful half of the answer is often the item that
|
|
10
|
+
* did *not* fit and the reason it did not.
|
|
11
|
+
*
|
|
12
|
+
* Every rule below is a field on an item or a container. None of them needs a custom
|
|
13
|
+
* class, none of them changes how you call `pack`, and every one is part of the shared
|
|
14
|
+
* JSON contract, so the same request answers the same way from the Python, PHP and Rust
|
|
15
|
+
* engines.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { pack } from '../index.js';
|
|
19
|
+
|
|
20
|
+
const MM = { units: { length: 'mm' } };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pack one variant and print what it cost.
|
|
24
|
+
*
|
|
25
|
+
* Both numbers matter. A constraint only sometimes shows up as a refusal; more often the
|
|
26
|
+
* solver satisfies it by opening another container, which costs money and is the outcome
|
|
27
|
+
* you actually wanted to see coming.
|
|
28
|
+
*/
|
|
29
|
+
const solve = (label, items, containers) => {
|
|
30
|
+
const result = pack({ ...MM, items, containers });
|
|
31
|
+
const placed = result.containers.reduce((n, c) => n + c.placements.length, 0);
|
|
32
|
+
console.log(
|
|
33
|
+
` ${label.padEnd(20)} ${result.containers.length} container(s), ` +
|
|
34
|
+
`${placed} placed, ${result.unpacked_items.length} refused`,
|
|
35
|
+
);
|
|
36
|
+
for (const unpacked of result.unpacked_items) {
|
|
37
|
+
console.log(` ${unpacked.item_id.padEnd(12)} ${unpacked.reason}`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const shelf = [{ id: 'shelf', inner_dimensions: { length: '800', width: '400', height: '500' } }];
|
|
42
|
+
|
|
43
|
+
// ------------------------------------------------------------------ a plain refusal
|
|
44
|
+
//
|
|
45
|
+
// The ladder is longer than the shelf's longest inner edge in every orientation, so no
|
|
46
|
+
// solver can place it. The reason code says exactly that, and it is a fact about the
|
|
47
|
+
// request rather than a solver failure — which is why it is safe to show a customer.
|
|
48
|
+
|
|
49
|
+
console.log('a refusal that no solver can avoid');
|
|
50
|
+
solve('ladder + books',
|
|
51
|
+
[{ id: 'ladder', quantity: 1, dimensions: { length: '1800', width: '300', height: '100' } },
|
|
52
|
+
{ id: 'book', quantity: 4, dimensions: { length: '210', width: '140', height: '30' } }],
|
|
53
|
+
shelf);
|
|
54
|
+
|
|
55
|
+
// --------------------------------------------------------------- one rule at a time
|
|
56
|
+
//
|
|
57
|
+
// Each rule below is shown twice: same items, same container, once without it and once
|
|
58
|
+
// with it. A constraint you cannot watch change the answer is one the reader has to take
|
|
59
|
+
// on faith, and the pair makes the rule — rather than the geometry — provably the cause.
|
|
60
|
+
|
|
61
|
+
const tin = { length: '150', width: '150', height: '120' };
|
|
62
|
+
const column = [{ id: 'column', inner_dimensions: { length: '160', width: '160', height: '600' } }];
|
|
63
|
+
|
|
64
|
+
// `max_stacked_items` caps how many units may sit above one item — a pallet-pattern rule
|
|
65
|
+
// ("three high, no more"), not a weight limit. The column is one tin wide, so height is
|
|
66
|
+
// the only way to fit more, and the second column is the price of the cap.
|
|
67
|
+
console.log('\nmax_stacked_items — five tins fit one column; three-high needs two');
|
|
68
|
+
solve('without', [{ id: 'tin', quantity: 5, dimensions: tin, weight: { value: '800', unit: 'g' } }], column);
|
|
69
|
+
solve('with', [{ id: 'tin', quantity: 5, dimensions: tin, weight: { value: '800', unit: 'g' }, max_stacked_items: 3 }], column);
|
|
70
|
+
|
|
71
|
+
// Tags are how two items refuse each other. `incompatible_tags` is checked both ways, so
|
|
72
|
+
// tagging one side is enough. Nothing asked for a second shelf — the tag did.
|
|
73
|
+
const bleach = (tags) => ({ id: 'bleach', quantity: 2,
|
|
74
|
+
dimensions: { length: '120', width: '120', height: '300' }, weight: { value: '2', unit: 'kg' }, ...tags });
|
|
75
|
+
const flour = { id: 'flour', quantity: 3,
|
|
76
|
+
dimensions: { length: '200', width: '150', height: '100' }, weight: { value: '1500', unit: 'g' }, tags: ['food'] };
|
|
77
|
+
|
|
78
|
+
console.log('\nincompatible_tags — hazmat and food cannot share a container');
|
|
79
|
+
solve('without', [bleach({}), flour], shelf);
|
|
80
|
+
solve('with', [bleach({ tags: ['hazmat'], incompatible_tags: ['food'] }), flour], shelf);
|
|
81
|
+
|
|
82
|
+
// `group` is atomic: every member ships in one container or none of them does. The third
|
|
83
|
+
// part is deliberately too long for the shelf, so it takes the other two down with it
|
|
84
|
+
// rather than shipping two thirds of an assembly nobody can use.
|
|
85
|
+
const parts = [{ length: '200', width: '200', height: '100' },
|
|
86
|
+
{ length: '200', width: '200', height: '100' },
|
|
87
|
+
{ length: '900', width: '100', height: '100' }];
|
|
88
|
+
const kit = (group) => parts.map((dimensions, n) => ({
|
|
89
|
+
id: `kit-${n + 1}`, quantity: 1, dimensions, weight: { value: '2', unit: 'kg' }, ...group }));
|
|
90
|
+
|
|
91
|
+
console.log('\ngroup — one member cannot be placed, so none of them is');
|
|
92
|
+
solve('without', kit({}), shelf);
|
|
93
|
+
solve('with', kit({ group: 'assembly' }), shelf);
|
|
94
|
+
|
|
95
|
+
// Every reason code above is structured, not prose: `reason` is a stable identifier and
|
|
96
|
+
// `proof` carries the observations behind it. Render your own wording from the code —
|
|
97
|
+
// the strings here are the contract's, not a message meant for your customer.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes: when an item is not its box.
|
|
3
|
+
*
|
|
4
|
+
* Run it:
|
|
5
|
+
*
|
|
6
|
+
* node examples/shapes.mjs
|
|
7
|
+
*
|
|
8
|
+
* Every other example treats an item as the box it declares. That is the default and it
|
|
9
|
+
* is right for almost everything, because a carton *is* a cuboid. Two kinds of goods are
|
|
10
|
+
* not: a moulded or tapered part that leaves a usable void beside it, and a soft one that
|
|
11
|
+
* gives way under whatever is stacked on it.
|
|
12
|
+
*
|
|
13
|
+
* `shape_type` narrows the box in one direction each -- `convex_hull` in space,
|
|
14
|
+
* `compressible` in height under load -- and neither is ever inferred. An engine that
|
|
15
|
+
* quietly packed a hull as its bounding box would return a plan that validates and does
|
|
16
|
+
* not physically fit, so the value must be asked for.
|
|
17
|
+
*
|
|
18
|
+
* These fields are part of the shared request contract, so the same document runs
|
|
19
|
+
* unchanged against the Python, PHP and Rust engines. It does not follow that all four
|
|
20
|
+
* print the same numbers -- see the note on the compressible section below, which is the
|
|
21
|
+
* more useful half of the lesson.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { pack } from '../index.js';
|
|
25
|
+
|
|
26
|
+
const MM = { units: { length: 'mm' } };
|
|
27
|
+
const crate = (length, width, height) => [
|
|
28
|
+
{ id: 'crate', inner_dimensions: { length, width, height } },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** Run one request and print only what the shape changed: containers and refusals. */
|
|
32
|
+
const summarise = (label, request) => {
|
|
33
|
+
const result = pack({ ...MM, ...request });
|
|
34
|
+
const placed = result.containers.reduce((n, c) => n + c.placements.length, 0);
|
|
35
|
+
console.log(
|
|
36
|
+
` ${label.padEnd(22)} ${result.status.padEnd(10)} ` +
|
|
37
|
+
`${result.containers.length} container(s), ${placed} placed, ` +
|
|
38
|
+
`${result.unpacked_items.length} refused`,
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ------------------------------------------------------------------ convex_hull
|
|
43
|
+
//
|
|
44
|
+
// Two triangular prisms, each cut from the same 100 mm cube along the diagonal. Their
|
|
45
|
+
// bounding boxes are identical and fill the crate on their own, so as cuboids the second
|
|
46
|
+
// one has nowhere to go. As hulls they are complementary halves and share the crate
|
|
47
|
+
// exactly -- the collision test is an exact integer separating-axis test on the vertices,
|
|
48
|
+
// not a box overlap.
|
|
49
|
+
//
|
|
50
|
+
// The hull is given in the item's own coordinates, in the request's length unit, and must
|
|
51
|
+
// fit inside the declared dimensions. It is not a replacement for them: the box still
|
|
52
|
+
// bounds the item, the hull only says how much of that box is solid.
|
|
53
|
+
|
|
54
|
+
const LOWER_WEDGE = [
|
|
55
|
+
{ x: '0', y: '0', z: '0' }, { x: '100', y: '0', z: '0' },
|
|
56
|
+
{ x: '0', y: '100', z: '0' }, { x: '0', y: '0', z: '100' },
|
|
57
|
+
{ x: '100', y: '0', z: '100' }, { x: '0', y: '100', z: '100' },
|
|
58
|
+
];
|
|
59
|
+
const UPPER_WEDGE = [
|
|
60
|
+
{ x: '100', y: '100', z: '0' }, { x: '100', y: '0', z: '0' },
|
|
61
|
+
{ x: '0', y: '100', z: '0' }, { x: '100', y: '100', z: '100' },
|
|
62
|
+
{ x: '100', y: '0', z: '100' }, { x: '0', y: '100', z: '100' },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const wedge = (id, vertices) => ({
|
|
66
|
+
id,
|
|
67
|
+
quantity: 1,
|
|
68
|
+
dimensions: { length: '100', width: '100', height: '100' },
|
|
69
|
+
weight: { value: '1', unit: 'kg' },
|
|
70
|
+
...(vertices ? { shape_type: 'convex_hull', hull_vertices: vertices } : {}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
console.log('convex_hull -- two complementary wedges cut from one cube');
|
|
74
|
+
summarise('as cuboids', {
|
|
75
|
+
items: [wedge('wedge-lower', null), wedge('wedge-upper', null)],
|
|
76
|
+
containers: crate('100', '100', '100'),
|
|
77
|
+
});
|
|
78
|
+
summarise('as hulls', {
|
|
79
|
+
items: [wedge('wedge-lower', LOWER_WEDGE), wedge('wedge-upper', UPPER_WEDGE)],
|
|
80
|
+
containers: crate('100', '100', '100'),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// One crate instead of two, for the same goods and the same crate. Nothing about the
|
|
84
|
+
// request changed except the claim that the items are wedges rather than blocks.
|
|
85
|
+
|
|
86
|
+
// ----------------------------------------------------------------- compressible
|
|
87
|
+
//
|
|
88
|
+
// `compression_ratio` is the fraction of its own height an item may lose when something
|
|
89
|
+
// rests on it -- 0.25 means it can give up a quarter. The mass above it is what decides
|
|
90
|
+
// how much it actually gives, so the occupied height of a compressible item is not a
|
|
91
|
+
// property of the item alone; it depends on what the solver put on top.
|
|
92
|
+
//
|
|
93
|
+
// `max_compression_pressure_kpa` is the other half of the same field. Past that pressure
|
|
94
|
+
// the item is not compressed further, it is crushed, and the load is refused instead.
|
|
95
|
+
//
|
|
96
|
+
// Note `must_be_on_floor` on the cushion. Without it the solver is free to put the brick
|
|
97
|
+
// underneath, nothing bears on the cushion, and the feature never engages -- which is the
|
|
98
|
+
// honest reason the rule is here and not an incidental detail of the example.
|
|
99
|
+
|
|
100
|
+
const cushion = (crushKpa) => ({
|
|
101
|
+
id: 'cushion',
|
|
102
|
+
quantity: 1,
|
|
103
|
+
dimensions: { length: '100', width: '100', height: '100' },
|
|
104
|
+
weight: { value: '2', unit: 'kg' },
|
|
105
|
+
must_be_on_floor: true,
|
|
106
|
+
shape_type: 'compressible',
|
|
107
|
+
compression_ratio: 0.25,
|
|
108
|
+
max_compression_pressure_kpa: crushKpa,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const brick = (kilograms) => ({
|
|
112
|
+
id: 'brick',
|
|
113
|
+
quantity: 1,
|
|
114
|
+
dimensions: { length: '100', width: '100', height: '100' },
|
|
115
|
+
weight: { value: String(kilograms), unit: 'kg' },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** One crate, one cushion, one brick -- only the brick's mass changes. */
|
|
119
|
+
const load = (label, kilograms) => {
|
|
120
|
+
const result = pack({
|
|
121
|
+
...MM,
|
|
122
|
+
items: [cushion(100), brick(kilograms)],
|
|
123
|
+
containers: crate('100', '100', '200'),
|
|
124
|
+
});
|
|
125
|
+
console.log(
|
|
126
|
+
` ${label.padEnd(22)} ${result.containers.length} container(s), ` +
|
|
127
|
+
`unused volume ${result.score[3]} ppm`,
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// The crate is 100x100x200 and the two items are 100 mm cubes, so rigidly they fill it
|
|
132
|
+
// exactly and nothing is unused. At 102 kg the brick crosses 100 kPa over the cushion's
|
|
133
|
+
// 0.01 m^2 face: the stack is refused, the brick opens a second crate, and half of each
|
|
134
|
+
// crate is empty.
|
|
135
|
+
//
|
|
136
|
+
// At 101 kg this engine also opens two crates -- and the Python, PHP and Rust engines
|
|
137
|
+
// return one, with the cushion compressed. Both answers are valid: every item is placed,
|
|
138
|
+
// no rule is broken, and an independent validator accepts each. This one is simply worse,
|
|
139
|
+
// and it is recorded as such in the suite's quality budget rather than left to be
|
|
140
|
+
// discovered here.
|
|
141
|
+
//
|
|
142
|
+
// That is the guarantee, stated exactly. What the shared contract fixes is the request
|
|
143
|
+
// shape, the validity rules and the objective vector -- not which of several valid
|
|
144
|
+
// arrangements a given engine finds. An engine may return a worse-scoring valid packing;
|
|
145
|
+
// none may return an invalid one. If you need the best answer these fields can give,
|
|
146
|
+
// solve on the Rust or Python engine and treat the JavaScript fallback as the portable
|
|
147
|
+
// one.
|
|
148
|
+
console.log('\ncompressible -- a cushion that yields to the load above it');
|
|
149
|
+
load('brick 101 kg', 101);
|
|
150
|
+
load('brick 102 kg', 102);
|
|
151
|
+
|
|
152
|
+
// Both shapes are refused rather than approximated wherever an engine cannot honour them
|
|
153
|
+
// exactly -- a hull on a route, a hull under a configured clearance, a compressible item
|
|
154
|
+
// with `nesting_height`. A wrong answer that validates is worse than a refusal that does
|
|
155
|
+
// not, which is the whole reason these are opt-in.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Units and numbers: why nothing here is a JavaScript number until you make it one.
|
|
3
|
+
*
|
|
4
|
+
* Run it:
|
|
5
|
+
*
|
|
6
|
+
* node examples/units.mjs
|
|
7
|
+
*
|
|
8
|
+
* Every length and weight in the contract travels as a **decimal string**, and every
|
|
9
|
+
* length in a result is an exact integer count of ticks — one tick is 1/16000 mm. That is
|
|
10
|
+
* not ceremony. `0.1 + 0.2 !== 0.3` is true in this language, and a packing engine that
|
|
11
|
+
* decides a fit by a hair has no room for a representation that rounds.
|
|
12
|
+
*
|
|
13
|
+
* This example is the JavaScript one on purpose. Of the four engines, this is the one
|
|
14
|
+
* whose native number type stops being exact partway through the range the contract
|
|
15
|
+
* allows, and the last section shows exactly where that boundary is and what happens when
|
|
16
|
+
* you cross it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { pack, commerce, CommerceInputError } from '../index.js';
|
|
20
|
+
|
|
21
|
+
// ------------------------------------------------------------- fractions survive intact
|
|
22
|
+
//
|
|
23
|
+
// Imperial sizes arrive as fractions far more often than as decimals, and "12 3/8" is an
|
|
24
|
+
// exact quantity while 12.375 is a float that happens to be exact and 8.1 is one that is
|
|
25
|
+
// not. Send the fraction; the engine converts once, exactly, into integer ticks.
|
|
26
|
+
|
|
27
|
+
const inches = pack({
|
|
28
|
+
units: { length: 'in' },
|
|
29
|
+
items: [{ id: 'plank', quantity: 2, dimensions: { length: '12 3/8', width: '8 1/2', height: '3/4' } }],
|
|
30
|
+
containers: [{ id: 'crate', inner_dimensions: { length: '24', width: '24', height: '24' } }],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
console.log('fractional inches');
|
|
34
|
+
console.log(` status: ${inches.status}`);
|
|
35
|
+
for (const placement of inches.containers[0].placements) {
|
|
36
|
+
const { x, y, z } = placement.position;
|
|
37
|
+
console.log(` ${placement.item_id.padEnd(9)} at ticks (${x.ticks}, ${y.ticks}, ${z.ticks})`
|
|
38
|
+
+ ` = (${x.value}, ${y.value}, ${z.value}) ${x.unit}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Both forms come back: `ticks` is the exact integer the engine reasoned with, `value` is
|
|
42
|
+
// the same quantity rendered in the unit you asked for. Compare `ticks` when you need to
|
|
43
|
+
// know whether two things are the same; `value` is for showing a human.
|
|
44
|
+
|
|
45
|
+
// ------------------------------------------------------------------ one tick decides it
|
|
46
|
+
//
|
|
47
|
+
// A container exactly one tick shorter than the item refuses it. There is no tolerance to
|
|
48
|
+
// tune, because a tolerance is a decision about someone else's warehouse.
|
|
49
|
+
|
|
50
|
+
const TICKS_PER_MM = 16000;
|
|
51
|
+
const fit = (containerMm) => {
|
|
52
|
+
const result = pack({
|
|
53
|
+
units: { length: 'mm' },
|
|
54
|
+
items: [{ id: 'rod', quantity: 1, dimensions: { length: '100', width: '10', height: '10' } }],
|
|
55
|
+
containers: [{ id: 'tube', inner_dimensions: { length: containerMm, width: '10', height: '10' } }],
|
|
56
|
+
});
|
|
57
|
+
const refused = result.unpacked_items[0];
|
|
58
|
+
return refused ? `refused: ${refused.reason}` : 'placed';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
console.log('\none tick decides it');
|
|
62
|
+
console.log(` container 100 mm exactly -> ${fit('100')}`);
|
|
63
|
+
console.log(` container one tick shorter -> ${fit(String((100 * TICKS_PER_MM - 1) / TICKS_PER_MM))}`);
|
|
64
|
+
|
|
65
|
+
// ------------------------------------------------- where JavaScript's numbers give out
|
|
66
|
+
//
|
|
67
|
+
// Lengths never reach the boundary in practice. Money does: a quote is minor currency
|
|
68
|
+
// units, and a large enough shipment at a large enough rate multiplies past `2^53 - 1`,
|
|
69
|
+
// after which a JavaScript number is no longer exact and `n + 1 === n` becomes possible.
|
|
70
|
+
//
|
|
71
|
+
// The engine refuses rather than returning a rounded price. A wrong number that looks
|
|
72
|
+
// right is the worst outcome available here — it would be invoiced.
|
|
73
|
+
|
|
74
|
+
const document = {
|
|
75
|
+
tariffs: [{
|
|
76
|
+
carrier_id: 'acme',
|
|
77
|
+
service_id: 'ground',
|
|
78
|
+
versions: [{
|
|
79
|
+
effective_at: 0,
|
|
80
|
+
dimensional_weight_divisor: 1,
|
|
81
|
+
cost_per_dimensional_kg_minor: { 'zone-a': 1_000_000 },
|
|
82
|
+
minimum_charge_minor: 0,
|
|
83
|
+
fuel_surcharge_permille: 0,
|
|
84
|
+
accessorials: [],
|
|
85
|
+
}],
|
|
86
|
+
}],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const quote = (volumeMm3) => commerce.quote(document, {
|
|
90
|
+
carrier_id: 'acme', service_id: 'ground', zone: 'zone-a',
|
|
91
|
+
as_of: 0, actual_weight_g: 0, volume_mm3: volumeMm3,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
console.log('\nwhere JavaScript stops being exact');
|
|
95
|
+
console.log(` Number.MAX_SAFE_INTEGER = ${Number.MAX_SAFE_INTEGER} (2^53 - 1)`);
|
|
96
|
+
console.log(` and past it: 2^53 + 1 === 2^53 is ${2 ** 53 + 1 === 2 ** 53}`);
|
|
97
|
+
|
|
98
|
+
for (const volume of [10 ** 12, 10 ** 15]) {
|
|
99
|
+
try {
|
|
100
|
+
const answer = quote(volume);
|
|
101
|
+
console.log(` volume ${String(volume).padEnd(16)} -> ${answer.quote.total_minor} minor units`);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!(error instanceof CommerceInputError)) throw error;
|
|
104
|
+
console.log(` volume ${String(volume).padEnd(16)} -> refused: ${error.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The refusal is the contract working, not the binding failing: an answer this engine
|
|
109
|
+
// cannot represent exactly is one it declines to give.
|
|
110
|
+
//
|
|
111
|
+
// The check lives here and nowhere else — `commerce-model.js` is the only file in the
|
|
112
|
+
// suite that carries it — because `2^53` is a property of this language's number type
|
|
113
|
+
// rather than of the contract. What the Python and PHP engines return for the same
|
|
114
|
+
// request is their own business and is not asserted here; if you need a number this large
|
|
115
|
+
// to survive, do not read it out of a JavaScript `Number`.
|