@packvium/engine 0.1.2 → 1.0.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 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,8 @@ 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. |
103
108
  | [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. |
104
109
 
105
110
  ```bash
@@ -126,11 +131,14 @@ One request and result contract, implemented independently in four engines (Rust
126
131
  Python, PHP, JavaScript) and held to identical placements on a shared fixture set.
127
132
  Pick the package for your stack; mixing them in one system is safe.
128
133
 
134
+ Documentation, the constraint reference and the benchmarks are at
135
+ [packvium.com](https://packvium.com).
136
+
129
137
  | Package | Install | Source |
130
138
  | --- | --- | --- |
131
139
  | Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) |
132
140
  | PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) |
133
- | Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "0.1"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
141
+ | Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.0"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
134
142
  | Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) |
135
143
  | Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) |
136
144
  | 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 `0.1.x` release receives fixes. This is an early release; there is no
6
- long-term support branch yet.
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 top = box.z + box.d[2];
27
- if (!byTop.has(top)) byTop.set(top, []);
28
- byTop.get(top).push(index);
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 = levels.get(upper.z);
102
+ let level = topLevels.get(upper.z);
36
103
  if (level == null) {
37
- level = new Map();
38
- for (const index of candidates) {
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 cells(upper)) {
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
- return { supporters, children, candidateChecks };
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,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.