@packvium/engine 1.0.0 → 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 +2 -0
- package/examples/constraints.mjs +97 -0
- package/examples/units.mjs +115 -0
- package/fallback.js +192 -72
- package/index.js +52 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -105,6 +105,8 @@ and execute without a project around it.
|
|
|
105
105
|
| [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. |
|
|
106
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
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. |
|
|
108
110
|
| [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. |
|
|
109
111
|
|
|
110
112
|
```bash
|
|
@@ -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,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`.
|
package/fallback.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { appendContactBox, buildContactGraph } from './contact-graph.js';
|
|
2
|
+
import { compareCodePoints } from './commerce-model.js';
|
|
2
3
|
import { parsePolicy, policyRejection, provesUnplaceable, tagOccurrences } from './policy.js';
|
|
3
4
|
|
|
4
5
|
const LEN={mm:16000,cm:160000,m:16000000,in:406400,inch:406400,inches:406400,ft:4876800,tick:1,ticks:1};
|
|
@@ -21,17 +22,22 @@ const UNSUPPORTED={
|
|
|
21
22
|
request:[],
|
|
22
23
|
configuration:[],
|
|
23
24
|
// `hull_vertices`, `compression_ratio` and `max_compression_pressure_kpa` left this list
|
|
24
|
-
// in
|
|
25
|
+
// in, the last engine to gain both the solver behaviour and the independent
|
|
25
26
|
// validation the staged rollout requires.
|
|
26
27
|
item:[],
|
|
27
|
-
|
|
28
|
+
// `pallet_overhang_limit` was reserved in the schema by at the 1.1.0 contract
|
|
29
|
+
// freeze and is refused everywhere until an engine implements it from a request: a field
|
|
30
|
+
// a caller can set and the solver ignores is worse than a refusal.
|
|
31
|
+
// `access_directions` left this list in, which wired the reserved field through
|
|
32
|
+
// to the stop-accessibility rule in all four engines at once.
|
|
33
|
+
container:['pallet_overhang_limit'],
|
|
28
34
|
obstacle:[],
|
|
29
35
|
// `item.shape_type` values this engine does not implement. Presence is the
|
|
30
36
|
// wrong test for this one field: `rigid_cuboid` is the default and is implemented, so a
|
|
31
37
|
// caller that spells the default out must be served, not refused. What is unimplemented
|
|
32
38
|
// is a *value*, and the refusal names it -- packing a `convex_hull` item as its bounding
|
|
33
39
|
// box would return a plan that looks valid and does not physically fit.
|
|
34
|
-
// Empty since
|
|
40
|
+
// Empty since: this engine implements every value the schema defines. The guard
|
|
35
41
|
// stays because the next reserved value will need it.
|
|
36
42
|
shapeType:[],
|
|
37
43
|
};
|
|
@@ -305,7 +311,7 @@ function shapeFor(vertices,rotation){
|
|
|
305
311
|
* implementation written from that document, and `conformance/scene/objective-bounds.json`
|
|
306
312
|
* holds it to the same vectors Python computes on 380 cases from the golden corpus.
|
|
307
313
|
*
|
|
308
|
-
*
|
|
314
|
+
* asks only for soundness -- the bound must never exceed the achieved objective --
|
|
309
315
|
* because this engine is not held to placement equality. That freedom does not extend to a
|
|
310
316
|
* bound: it is a function of the *request*, so there is no room for a legitimately different
|
|
311
317
|
* answer, and this port is held to equality because equality is achievable and stronger.
|
|
@@ -764,6 +770,10 @@ function axleOverloaded(container,placements,extra=null){const reaction=axleReac
|
|
|
764
770
|
return (front.max!=null&&reaction.front>BigInt(front.max)*reaction.denominator)
|
|
765
771
|
||(rear.max!=null&&reaction.rear>BigInt(rear.max)*reaction.denominator)}
|
|
766
772
|
function overlapXY(a,b){const dx=Math.max(0,Math.min(a.x+a.d[0],b.x+b.d[0])-Math.max(a.x,b.x));const dy=Math.max(0,Math.min(a.y+a.d[1],b.y+b.d[1])-Math.max(a.y,b.y));return dx*dy}
|
|
773
|
+
// `overlapXY` on a placement's own fields: the same arithmetic without first copying the
|
|
774
|
+
// placement into a `{x,y,z,d}` box, which the candidate sweep did once per comparison.
|
|
775
|
+
function footprintOverlap(placement,x,y,length,width){const d=placementDimensions(placement);
|
|
776
|
+
const dx=Math.max(0,Math.min(placement.x+d[0],x+length)-Math.max(placement.x,x));const dy=Math.max(0,Math.min(placement.y+d[1],y+width)-Math.max(placement.y,y));return dx*dy}
|
|
767
777
|
function placementDimensions(placement){return placement.ed??placement.d}
|
|
768
778
|
function placementItemType(placement){return placement.itemType??placement.item?.raw?.id??null}
|
|
769
779
|
function placementNesting(placement){return placement.nesting??placement.item?.nesting??null}
|
|
@@ -775,7 +785,16 @@ function sameNestingColumn(left,right){const leftNesting=placementNesting(left),
|
|
|
775
785
|
// One candidate's exact direct supporters in O(n). A nested predecessor replaces only
|
|
776
786
|
// shadowed face contacts from its own type/footprint column; unrelated face supporters
|
|
777
787
|
// retain their original order and semantics.
|
|
778
|
-
function directSupporters(candidate,placed){const dimensions=placementDimensions(candidate);let predecessor=null;
|
|
788
|
+
function directSupporters(candidate,placed,topPlane=null){const dimensions=placementDimensions(candidate);let predecessor=null;
|
|
789
|
+
// With the scene bucketed by top face, a non-nesting candidate reads only the placements
|
|
790
|
+
// whose top is its own base: no predecessor can exist and no supporter is shadowed, so
|
|
791
|
+
// the full scan below reduces to its second loop over that one bucket, in the same order.
|
|
792
|
+
if(topPlane!==null&&placementNesting(candidate)==null){const supporters=[],level=topPlane.get(candidate.z);
|
|
793
|
+
if(level===undefined)return supporters;
|
|
794
|
+
for(const other of level){if(other===candidate)continue;
|
|
795
|
+
const area=footprintOverlap(other,candidate.x,candidate.y,dimensions[0],dimensions[1]);
|
|
796
|
+
if(area>0)supporters.push({placement:other,area})}
|
|
797
|
+
return supporters}
|
|
779
798
|
for(const other of placed){if(other===candidate||other.z>=candidate.z||!sameNestingColumn(other,candidate))continue;
|
|
780
799
|
if(predecessor==null||other.z>=predecessor.z)predecessor=other}
|
|
781
800
|
if(predecessor!=null&&predecessor.z+placementDimensions(predecessor)[2]-candidate.z!==placementNesting(predecessor))predecessor=null;
|
|
@@ -893,8 +912,21 @@ function constraintBox(placement){return {x:placement.x,y:placement.y,z:placemen
|
|
|
893
912
|
maxCompressionKpa:placement.item.maxCompressionKpa,compressionPpm:placement.item.compressionPpm,
|
|
894
913
|
stopIndex:placement.item.stopIndex}}
|
|
895
914
|
|
|
896
|
-
|
|
897
|
-
|
|
915
|
+
// The order boxes settle in: highest top first, then highest base, then index. A strict
|
|
916
|
+
// total order, so the permutation it yields is unique -- which is what lets a candidate
|
|
917
|
+
// sweep insert one box into the scene's settled order rather than sort per candidate.
|
|
918
|
+
function settleOrder(boxes){return boxes.map((b,i)=>i).sort((a,b)=>(boxes[b].z+boxes[b].d[2])-(boxes[a].z+boxes[a].d[2])||boxes[b].z-boxes[a].z||a-b)}
|
|
919
|
+
// `settleOrder(boxes)` given the settled order of every box but the last. The last box has
|
|
920
|
+
// the highest index, so it follows every box it ties with, and the order is monotone in
|
|
921
|
+
// (top, base), so its slot is a binary search: O(n) for the copy against O(n log n) for the
|
|
922
|
+
// sort the load path used to pay per candidate.
|
|
923
|
+
function settleOrderWith(baseOrder,boxes){const last=boxes.length-1,top=boxes[last].z+boxes[last].d[2],base=boxes[last].z;
|
|
924
|
+
let low=0,high=baseOrder.length;
|
|
925
|
+
while(low<high){const mid=(low+high)>>1,box=boxes[baseOrder[mid]],boxTop=box.z+box.d[2];
|
|
926
|
+
if(top>boxTop||(top===boxTop&&base>box.z))high=mid;else low=mid+1}
|
|
927
|
+
const order=baseOrder.slice(0,low);order.push(last);for(let i=low;i<baseOrder.length;i++)order.push(baseOrder[i]);
|
|
928
|
+
return order}
|
|
929
|
+
function topLoads(boxes,graph=contactGraph(boxes),order=settleOrder(boxes)){const loads=boxes.map(()=>0n);
|
|
898
930
|
for(const upper of order){const supports=graph.supporters[upper];let total=0n;
|
|
899
931
|
for(const [,area] of supports)total+=BigInt(area);
|
|
900
932
|
if(total===0n)continue;
|
|
@@ -922,7 +954,7 @@ function groundContactAllowed(candidate,placed,supports=null){const rule=candida
|
|
|
922
954
|
const box={x:candidate.x,y:candidate.y,z:candidate.z,d:placementDimensions(candidate)},supporters=supports??directSupporters(candidate,placed);
|
|
923
955
|
if(rule==='single')return supporters.length===1;if(rule==='multiple')return supporters.length>=2;
|
|
924
956
|
if(rule==='covered'){const corners=[[box.x,box.y],[box.x+box.d[0],box.y],[box.x,box.y+box.d[1]],[box.x+box.d[0],box.y+box.d[1]]];return corners.every(([x,y])=>supporters.some(({placement})=>{const d=placementDimensions(placement);return placement.x<=x&&x<=placement.x+d[0]&&placement.y<=y&&y<=placement.y+d[1]}))}return true}
|
|
925
|
-
function routeContactAllowed(candidate,placed,supports){
|
|
957
|
+
function routeContactAllowed(candidate,placed,supports,sweep=null){
|
|
926
958
|
// An item without a declared stop rides the whole route. Infinity is the shared
|
|
927
959
|
// PHP/Python/Rust contract. Check only the new relations, as the existing scene was
|
|
928
960
|
// already valid; the one same-column face above may need an O(n) predecessor lookup
|
|
@@ -930,6 +962,18 @@ function routeContactAllowed(candidate,placed,supports){
|
|
|
930
962
|
const candidateStop=candidate.item.stopIndex??Infinity;
|
|
931
963
|
if(supports.some(({placement})=>candidateStop>(placement.item.stopIndex??Infinity)))return false;
|
|
932
964
|
const dimensions=placementDimensions(candidate);let scene=null;
|
|
965
|
+
if(sweep!==null){
|
|
966
|
+
// Every comparison below is `stop > Infinity` when nothing on the route declares a
|
|
967
|
+
// stop, so the rule cannot refuse and the scan is skipped for the request that has no
|
|
968
|
+
// route at all -- which is every request that is not a multi-drop route.
|
|
969
|
+
if(candidateStop===Infinity&&!sweep.placedStops)return true;
|
|
970
|
+
// A non-nesting candidate is never a nested predecessor, so only the placements whose
|
|
971
|
+
// base is its top can rest on it: read that one bucket instead of the whole scene.
|
|
972
|
+
if(candidate.item.nesting==null){const level=sweep.bottomPlane().get(candidate.z+dimensions[2]);
|
|
973
|
+
if(level!==undefined)for(const upper of level)
|
|
974
|
+
if(footprintOverlap(upper,candidate.x,candidate.y,dimensions[0],dimensions[1])>0&&(upper.item.stopIndex??Infinity)>candidateStop)return false;
|
|
975
|
+
return true}
|
|
976
|
+
}
|
|
933
977
|
for(const upper of placed){const upperDimensions=placementDimensions(upper),upperStop=upper.item.stopIndex??Infinity;
|
|
934
978
|
if(validNesting(candidate,upper)&&candidate.z<upper.z){if(upperStop>candidateStop)return false;continue}
|
|
935
979
|
if(candidate.z+dimensions[2]!==upper.z||overlapXY({x:candidate.x,y:candidate.y,z:candidate.z,d:dimensions},{x:upper.x,y:upper.y,z:upper.z,d:upperDimensions})<=0)continue;
|
|
@@ -1019,12 +1063,11 @@ function accessibleAgainst(base,candidateBox){
|
|
|
1019
1063
|
// the other; docs/STOP-ACCESSIBILITY.md derives the rule and the post-validator's
|
|
1020
1064
|
// whole-scene replay stays the sufficient check.
|
|
1021
1065
|
//
|
|
1022
|
-
// Inert unless the
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
// JavaScript gets to the config field Python, PHP and Rust carry.
|
|
1066
|
+
// Inert unless the container supplies exit directions. `container.access_directions` is
|
|
1067
|
+
// canonicalised into `tmpl.doors` by the request decoder; omitting it preserves the
|
|
1068
|
+
// pre-1.1.0 behaviour instead of pretending all six walls are doors. JavaScript has no
|
|
1069
|
+
// separate programmatic configuration object, so the per-container request field is its
|
|
1070
|
+
// only activation path.
|
|
1028
1071
|
//
|
|
1029
1072
|
// The blocker set is `{q : s(q) > s(p)}` -- strictly later. Same-stop items are excluded
|
|
1030
1073
|
// because the order within a stop is free: whichever is in the way comes off first.
|
|
@@ -1043,12 +1086,40 @@ function sweptHits([sx1,sy1,sz1,sx2,sy2,sz2],box){
|
|
|
1043
1086
|
return sx1<box.x+box.d.length&&box.x<sx2&&sy1<box.y+box.d.width&&box.y<sy2
|
|
1044
1087
|
&&sz1<box.z+box.d.height&&box.z<sz2}
|
|
1045
1088
|
|
|
1046
|
-
|
|
1089
|
+
// What one candidate sweep knows about its item and the placed scene before any position is
|
|
1090
|
+
// tried. `allowed` used to rediscover each of these with its own pass over `placed` for every
|
|
1091
|
+
// feasible candidate -- the tag conflict, four "does anything here declare X" gates and the
|
|
1092
|
+
// supporter scan, on a scene that cannot change until the sweep commits -- which is where
|
|
1093
|
+
// the profile put a fifth of the whole solve. Built once per (template, item) sweep, in
|
|
1094
|
+
// O(n); the plane buckets are built on first demand because a floor candidate never asks.
|
|
1095
|
+
// A caller without a sweep (the rebalance replay) builds one per call and loses nothing.
|
|
1096
|
+
function sweepContext(item,placed){
|
|
1097
|
+
const tags=item.tags,bad=item.incompatible;
|
|
1098
|
+
let tagConflict=false,anyUnstackable=false,placedMaxTop=false,placedCompressible=false,placedMaxStacked=false,placedStops=false;
|
|
1099
|
+
for(const p of placed){const other=p.item;
|
|
1100
|
+
if(bad.some(t=>other.tags.includes(t))||other.incompatible.some(t=>tags.includes(t)))tagConflict=true;
|
|
1101
|
+
if(!other.stackable)anyUnstackable=true;
|
|
1102
|
+
if(other.maxTop!=null)placedMaxTop=true;
|
|
1103
|
+
if(other.maxCompressionKpa!=null)placedCompressible=true;
|
|
1104
|
+
if(other.maxStacked!=null)placedMaxStacked=true;
|
|
1105
|
+
if(other.stopIndex!=null)placedStops=true}
|
|
1106
|
+
let byTop=null,byBottom=null;
|
|
1107
|
+
return {tagConflict,anyUnstackable,placedMaxTop,placedCompressible,placedMaxStacked,placedStops,
|
|
1108
|
+
// Buckets keep `placed` order, which is the order every supporter list is contracted to.
|
|
1109
|
+
topPlane(){if(byTop===null)byTop=bucketByPlane(placed,p=>p.z+placementDimensions(p)[2]);return byTop},
|
|
1110
|
+
bottomPlane(){if(byBottom===null)byBottom=bucketByPlane(placed,p=>p.z);return byBottom}}}
|
|
1111
|
+
function bucketByPlane(placed,planeOf){const buckets=new Map();
|
|
1112
|
+
for(const p of placed){const plane=planeOf(p),bucket=buckets.get(plane);if(bucket)bucket.push(p);else buckets.set(plane,[p])}
|
|
1113
|
+
return buckets}
|
|
1114
|
+
function allowed(candidate,placed,container,globalSupportPpm,metrics,loadBase=null,accessBase=null,sweep=null){
|
|
1047
1115
|
const box={x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed};
|
|
1048
1116
|
if(candidate.item.raw.must_be_on_floor&&box.z!==0)return false;
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1117
|
+
const scene=sweep??sweepContext(candidate.item,placed);
|
|
1118
|
+
if(scene.tagConflict)return false;
|
|
1119
|
+
// Face-to-face contact can only refuse when one of the pair declines to carry, and the
|
|
1120
|
+
// nesting rule only when the candidate nests. A non-nesting candidate consults the two
|
|
1121
|
+
// planes it can touch; anything else walks the scene exactly as before.
|
|
1122
|
+
if(candidate.item.nesting!=null||sweep===null){if(scene.anyUnstackable||!candidate.item.stackable||candidate.item.nesting!=null)for(const p of placed){
|
|
1052
1123
|
const other={x:p.x,y:p.y,z:p.z,d:p.ed};
|
|
1053
1124
|
if(overlapXY(other,box)<=0)continue;
|
|
1054
1125
|
if(other.z+other.d[2]===box.z&&!p.item.stackable)return false;
|
|
@@ -1059,10 +1130,15 @@ function allowed(candidate,placed,container,globalSupportPpm,metrics,loadBase=nu
|
|
|
1059
1130
|
const [lower]=candidate.z<=p.z?[candidate,p]:[p,candidate];
|
|
1060
1131
|
if(!lower.item.stackable)return false;
|
|
1061
1132
|
}
|
|
1133
|
+
}}else{
|
|
1134
|
+
if(scene.anyUnstackable){const level=sweep.topPlane().get(box.z);
|
|
1135
|
+
if(level!==undefined)for(const p of level)if(!p.item.stackable&&footprintOverlap(p,box.x,box.y,box.d[0],box.d[1])>0)return false}
|
|
1136
|
+
if(!candidate.item.stackable){const level=sweep.bottomPlane().get(box.z+box.d[2]);
|
|
1137
|
+
if(level!==undefined)for(const p of level)if(footprintOverlap(p,box.x,box.y,box.d[0],box.d[1])>0)return false}
|
|
1062
1138
|
}
|
|
1063
1139
|
metrics.support_checks++;
|
|
1064
1140
|
const ratio=Math.max(globalSupportPpm,candidate.item.supportPpm);
|
|
1065
|
-
const supports=box.z===0?[]:directSupporters(candidate,placed);
|
|
1141
|
+
const supports=box.z===0?[]:directSupporters(candidate,placed,sweep===null?null:sweep.topPlane());
|
|
1066
1142
|
if(supports.some(({placement})=>placement.item.stackable===false))return false;
|
|
1067
1143
|
if(box.z!==0&&ratio>0){
|
|
1068
1144
|
const area=supports.reduce((total,support)=>total+support.area,0);
|
|
@@ -1072,21 +1148,21 @@ function allowed(candidate,placed,container,globalSupportPpm,metrics,loadBase=nu
|
|
|
1072
1148
|
// decidable from the items alone; when neither fires, the three skipped checks
|
|
1073
1149
|
// return false for every box anyway, and building n+1 boxes per feasible
|
|
1074
1150
|
// candidate was pure allocation.
|
|
1075
|
-
const needsLoads=container.maxStackDensity!=null||candidate.item.maxTop!=null||
|
|
1076
|
-
||candidate.item.maxCompressionKpa!=null||
|
|
1077
|
-
const needsGraph=needsLoads||candidate.item.maxStacked!=null||
|
|
1078
|
-
if(!needsGraph)return groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports)
|
|
1151
|
+
const needsLoads=container.maxStackDensity!=null||candidate.item.maxTop!=null||scene.placedMaxTop
|
|
1152
|
+
||candidate.item.maxCompressionKpa!=null||scene.placedCompressible;
|
|
1153
|
+
const needsGraph=needsLoads||candidate.item.maxStacked!=null||scene.placedMaxStacked;
|
|
1154
|
+
if(!needsGraph)return groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports,scene)
|
|
1079
1155
|
&&(accessBase===null||accessibleAgainst(accessBase,corridorBox(candidate)));
|
|
1080
1156
|
// With a base for this sweep, both the box list and the graph come from it by
|
|
1081
1157
|
// appending one box, rather than each candidate rebuilding both from every placement.
|
|
1082
1158
|
// The two paths are required to agree exactly, which is what `contact-graph`'s append
|
|
1083
1159
|
// property test holds them to.
|
|
1084
1160
|
const candidateBox=constraintBox(candidate),base=loadBase===null?null:loadBase();
|
|
1085
|
-
const boxes=base===null?[...placed.map(constraintBox),candidateBox]:[...base.boxes,candidateBox];
|
|
1086
|
-
const graph=base===null?contactGraph(boxes):appendContactBox(base,candidateBox,overlapXY);
|
|
1087
|
-
const loads
|
|
1161
|
+
const boxes=base===null?[...placed.map(constraintBox),candidateBox]:[...base.graph.boxes,candidateBox];
|
|
1162
|
+
const graph=base===null?contactGraph(boxes):appendContactBox(base.graph,candidateBox,overlapXY);
|
|
1163
|
+
const loads=!needsLoads?null:base===null?topLoads(boxes,graph):topLoads(boxes,graph,settleOrderWith(base.order,boxes));
|
|
1088
1164
|
return !overloaded(boxes,loads)&&!crushed(boxes,loads)&&!stackLimitsExceeded(boxes,graph)&&!stackDensityExceeded(boxes,container.maxStackDensity,loads)
|
|
1089
|
-
&&groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports)
|
|
1165
|
+
&&groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports,scene)
|
|
1090
1166
|
&&(accessBase===null||accessibleAgainst(accessBase,corridorBox(candidate)));
|
|
1091
1167
|
}
|
|
1092
1168
|
|
|
@@ -1462,6 +1538,15 @@ function unstartedRecord(solverAlias,index,globalDeadlineReached){return {
|
|
|
1462
1538
|
global_deadline_reached:globalDeadlineReached,
|
|
1463
1539
|
}}
|
|
1464
1540
|
export function packFallback(req,clock=Date.now,solverAlias=null,startIndex=null,sharedDeadline=null){rejectUnsupported(req);
|
|
1541
|
+
// Admit the doors here, beside the other request-admission checks, and not
|
|
1542
|
+
// where they are canonicalised. The container template is built after the
|
|
1543
|
+
// uniform-lattice fast path has already returned, so validating there let a request
|
|
1544
|
+
// that took that path name a wall this engine has never heard of and be answered --
|
|
1545
|
+
// while Python, PHP and Rust refused the same request. The corpus could not see it:
|
|
1546
|
+
// the schema's own enum rejects a bad direction before any engine is asked, so the
|
|
1547
|
+
// divergence was reachable only from a library call, which is exactly how an
|
|
1548
|
+
// embedder reaches this engine.
|
|
1549
|
+
for(const container of req.containers??[])validateDirections(container.access_directions??[]);
|
|
1465
1550
|
const requestedSolvers=req.configuration?.solvers??[],knownSolvers=['grid','extreme_points','homogeneous_blocks','layer','maximal_spaces','exact_small'];
|
|
1466
1551
|
if(!Array.isArray(requestedSolvers)||requestedSolvers.some(name=>!knownSolvers.includes(name)))throw new RangeError(`unknown solver; expected one of ${knownSolvers.join(', ')}`);
|
|
1467
1552
|
const exactItemLimit=req.configuration?.exact_item_limit??7;
|
|
@@ -1476,7 +1561,7 @@ const restartLimit=effort?.max_restarts??Number.MAX_SAFE_INTEGER;
|
|
|
1476
1561
|
// a k-start request consume up to k*time_limit_ms while still reporting one portfolio
|
|
1477
1562
|
// deadline, which is both a determinism and an observability defect.
|
|
1478
1563
|
const deadline=sharedDeadline??new Deadline(req.configuration?.time_limit_ms??1000,clock);
|
|
1479
|
-
//
|
|
1564
|
+
// second review: the lowest_landed_cost refusal fires once, at the single
|
|
1480
1565
|
// outermost frame, on the packing actually selected for return -- the same choke point
|
|
1481
1566
|
// Rust, Python and PHP refuse at. A child solver/start run instead hands its result
|
|
1482
1567
|
// back sentinel and all, so a portfolio sibling with a priceable answer is not aborted
|
|
@@ -1518,7 +1603,7 @@ if(solverAlias===null&&requestedSolvers.length){
|
|
|
1518
1603
|
winner.termination=aggregateTermination(starts);
|
|
1519
1604
|
winner.algorithm=withPortfolioEffort(winner,runs);
|
|
1520
1605
|
const alternativeLimit=Math.max(0,(req.configuration?.alternatives??3)-1);
|
|
1521
|
-
// The sentinel is a search device, never an answer -- alternatives included (
|
|
1606
|
+
// The sentinel is a search device, never an answer -- alternatives included (review).
|
|
1522
1607
|
winner.alternatives=runs.filter((run,index)=>index!==winnerIndex&&!run.unpriceableDetail).sort((a,b)=>compareScore(a.score,b.score)).slice(0,alternativeLimit);
|
|
1523
1608
|
return finalizeOutermost(winner);
|
|
1524
1609
|
}
|
|
@@ -1608,7 +1693,10 @@ const policyRules=parsePolicy(req.policy);
|
|
|
1608
1693
|
const compact=(policyRules.length||objective==='lowest_landed_cost')?null:compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortExceeded,effortRemaining,deadline});
|
|
1609
1694
|
if(compact!==null)return compact;
|
|
1610
1695
|
const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scalar(raw.weight??0,'g',WT),rots=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)),nesting=raw.nesting_height==null?null:scalar(raw.nesting_height,u,LEN);
|
|
1611
|
-
|
|
1696
|
+
// The ordering keys below are functions of the item alone, computed once per type here
|
|
1697
|
+
// rather than as fresh BigInts on both sides of every comparison the sort makes.
|
|
1698
|
+
const vol=volume(d),longest=Math.max(...d);
|
|
1699
|
+
for(let i=1;i<=(raw.quantity??1);i++)items.push({raw,d,w,rots,vol,longest,id:`${raw.id}#${i}`,
|
|
1612
1700
|
stackable:raw.stackable!==false,maxTop:raw.max_top_load==null?null:scalar(raw.max_top_load,'g',WT),
|
|
1613
1701
|
supportPpm:Math.round((raw.minimum_support_ratio??0)*SUPPORT_SCALE),priority:raw.priority??0,
|
|
1614
1702
|
tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null,
|
|
@@ -1618,6 +1706,12 @@ const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scal
|
|
|
1618
1706
|
// Priority is a preference, not a guarantee: it leads the ordering so a caller can
|
|
1619
1707
|
// bias the search, but ties (the default, priority 0 for all items) fall through to
|
|
1620
1708
|
// the volume key unchanged.
|
|
1709
|
+
// Identifiers use the same Unicode-code-point order as Python, PHP, Rust and the commerce
|
|
1710
|
+
// API. Locale collation is host-dependent, while JavaScript's relational/default order is
|
|
1711
|
+
// UTF-16-code-unit order; both violate the cross-platform determinism contract outside
|
|
1712
|
+
// ASCII. Only the sign of a key matters to the sort, so the volume key compares BigInts
|
|
1713
|
+
// directly instead of materialising their difference.
|
|
1714
|
+
const compareId=compareCodePoints,ascendingVolume=(a,b)=>a.vol<b.vol?-1:a.vol>b.vol?1:0;
|
|
1621
1715
|
items.sort((a,b)=>{
|
|
1622
1716
|
const priority=b.priority-a.priority;if(priority)return priority;
|
|
1623
1717
|
// Under `maximum_value` the second objective key is the value left behind, so the
|
|
@@ -1631,15 +1725,15 @@ items.sort((a,b)=>{
|
|
|
1631
1725
|
// loads with the last one. Every stop is Infinity when nothing declares one, so an
|
|
1632
1726
|
// unrouted request keeps the ordering below untouched.
|
|
1633
1727
|
{const stop=(b.stopIndex??Infinity)-(a.stopIndex??Infinity);if(stop)return stop}
|
|
1634
|
-
if(qualityProfile&&(startIndex===null||startIndex===0))return
|
|
1635
|
-
if(qualityProfile&&startIndex===1)return
|
|
1636
|
-
if(solverAlias==='layer')return (b.d[2]-a.d[2])||(b.d[0]*b.d[1]-a.d[0]*a.d[1])||a.id
|
|
1637
|
-
if(solverAlias==='maximal_spaces')return (
|
|
1728
|
+
if(qualityProfile&&(startIndex===null||startIndex===0))return a.longest-b.longest||ascendingVolume(a,b)||compareId(a.id,b.id);
|
|
1729
|
+
if(qualityProfile&&startIndex===1)return ascendingVolume(a,b)||a.longest-b.longest||compareId(a.id,b.id);
|
|
1730
|
+
if(solverAlias==='layer')return (b.d[2]-a.d[2])||(b.d[0]*b.d[1]-a.d[0]*a.d[1])||compareId(a.id,b.id);
|
|
1731
|
+
if(solverAlias==='maximal_spaces')return (b.longest-a.longest)||ascendingVolume(b,a)||compareId(a.id,b.id);
|
|
1638
1732
|
// `exact_small` deliberately has no ordering of its own. It used to sort by id, which
|
|
1639
1733
|
// was harmless while it was greedy-in-disguise and actively harmful once the search
|
|
1640
1734
|
// became real: smallest-first is the worst descent order, so the first branch failed to
|
|
1641
1735
|
// pack everything and the bound never pruned.
|
|
1642
|
-
return
|
|
1736
|
+
return ascendingVolume(b,a)||compareId(a.id,b.id);
|
|
1643
1737
|
});
|
|
1644
1738
|
// Start 0 is the ordering above, so a single-start request is byte-identical to what it
|
|
1645
1739
|
// produced before restarts existed; every later start re-solves a shuffle of it.
|
|
@@ -1654,7 +1748,13 @@ const templates=req.containers.map(c=>{const d=dims(c.inner_dimensions,u),axleSp
|
|
|
1654
1748
|
// innerVolume/reserve are pure functions of the immutable template, hoisted out of
|
|
1655
1749
|
// candidatesFor's innermost (point x rotation) loop where they were recomputed as
|
|
1656
1750
|
// fresh BigInts per orientation.
|
|
1657
|
-
|
|
1751
|
+
// The walls this container may be unloaded through. Canonicalised into
|
|
1752
|
+
// ALL_DIRECTIONS order and deduplicated rather than kept as given, so two callers naming
|
|
1753
|
+
// the same doors in a different order search identically -- the same normalisation the
|
|
1754
|
+
// Python, PHP and Rust decoders apply, and the reason all four agree on the answer.
|
|
1755
|
+
validateDirections(c.access_directions??[]);
|
|
1756
|
+
const doors=Object.freeze(ALL_DIRECTIONS.filter(d=>(c.access_directions??[]).includes(d)));
|
|
1757
|
+
return {...c,d,outerD,doors,max:c.max_payload==null?null:scalar(c.max_payload,'g',WT),tare:scalar(c.tare_weight??0,'g',WT),axleSpec,reservePpm,
|
|
1658
1758
|
innerVolume:volume(d),reserve:volume(d)*BigInt(reservePpm)/BigInt(SUPPORT_SCALE),
|
|
1659
1759
|
rate:parseRateTable(c.rate_table),tagLimits:c.tag_limits??{},maxStackDensity,
|
|
1660
1760
|
obs:(c.obstacles??[]).flatMap(o=>[o,...(o.additional_boxes??[])]).map(o=>({x:scalar(o.origin?.x??0,u,LEN),y:scalar(o.origin?.y??0,u,LEN),z:scalar(o.origin?.z??0,u,LEN),d:dims(o.dimensions,u)}))}}).sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost'?(dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))):((a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))));
|
|
@@ -1704,64 +1804,84 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{
|
|
|
1704
1804
|
// hint comes from this item's own rotations, which are known here.
|
|
1705
1805
|
const widest=Math.max(1,...item.rots.flatMap(r=>{const pd=rotate(item.d,r);
|
|
1706
1806
|
return [pd[0]+2*clear,pd[1]+2*clear]}));
|
|
1707
|
-
|
|
1807
|
+
const graph=buildContactGraph(state.placements.map(constraintBox),overlapXY,widest);
|
|
1808
|
+
// The settled order of the base scene, sorted once here: each candidate then slots
|
|
1809
|
+
// itself in rather than re-sorting the scene (`settleOrderWith`).
|
|
1810
|
+
loadBaseGraph={graph,order:settleOrder(graph.boxes)};
|
|
1708
1811
|
}
|
|
1709
1812
|
return loadBaseGraph;
|
|
1710
1813
|
};
|
|
1711
1814
|
// The same argument, for the other rule that reads the whole placed scene. The
|
|
1712
|
-
// doors
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1716
|
-
const accessBase=stopAccessibilityBase(item.stopIndex,state.placements,tmpl,
|
|
1815
|
+
// doors now come from the container, which is why the hoist mattered: switching the field
|
|
1816
|
+
// on inside `allowed` would have turned an O(m*|D|) check into O(m^2*|D|) per candidate.
|
|
1817
|
+
// A container that states no doors leaves the base inert at the cost of one pass over the
|
|
1818
|
+
// stops, which is what every request that is not a multi-drop route pays.
|
|
1819
|
+
const accessBase=stopAccessibilityBase(item.stopIndex,state.placements,tmpl,tmpl.doors);
|
|
1717
1820
|
const compressionSensitive=item.shapeType==='compressible'
|
|
1718
1821
|
||state.placements.some(placement=>placement.item.shapeType==='compressible');
|
|
1719
1822
|
const found=[];
|
|
1720
1823
|
const candidates=points.length>maxCandidatePoints?points.slice(0,maxCandidatePoints):points;
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1824
|
+
// Everything that is a function of (item, rotation) alone -- the rotated envelope, the
|
|
1825
|
+
// volume it occupies, its hull -- is computed once per sweep here rather than once per
|
|
1826
|
+
// point, the class of waste docs/PERFORMANCE-PRACTICE.md ranks first. The volume gate is
|
|
1827
|
+
// also a function of `used`, fixed for the sweep, so it collapses to one boolean per
|
|
1828
|
+
// orientation; only a nesting item keeps the per-position delta, because what it can
|
|
1829
|
+
// nest into depends on where it lands. The payload and count gates depend on nothing
|
|
1830
|
+
// the point loop changes. Every gate is pure, so the order they are asked in is free.
|
|
1831
|
+
const placements=state.placements,nests=item.nesting!=null,sweep=sweepContext(item,placements);
|
|
1832
|
+
const payloadBlocked=tmpl.max!=null&&state.payload+item.w>tmpl.max,countBlocked=tmpl.max_items!=null&&placements.length>=tmpl.max_items;
|
|
1833
|
+
const exactHull=item.shapeType==='convex_hull'&&item.stopIndex==null&&clear===0;
|
|
1834
|
+
const orientations=item.rots.map(r=>{const pd=rotate(item.d,r),ed=pd.map(x=>x+2*clear);
|
|
1835
|
+
return {r,pd,ed,shape:exactHull?shapeFor(item.hullVertices,r):null,
|
|
1836
|
+
volumeBlocked:!compressionSensitive&&!nests&&used+occupiedVolume({pd,r,item})+tmpl.reserve>tmpl.innerVolume}});
|
|
1837
|
+
const [innerX,innerY,innerZ]=tmpl.d,obstacles=tmpl.obs;
|
|
1838
|
+
candidatePoints:for(const pt of candidates){if(candidateEffortExceeded())break;metrics.candidate_points_considered++;const x=pt[0],y=pt[1],z=pt[2];
|
|
1839
|
+
for(const orientation of orientations){if(candidateEffortExceeded())break candidatePoints;metrics.orientations_considered++;if(deadline.expired()){timeLimitReached=true;break candidatePoints}
|
|
1840
|
+
const {r,pd,ed}=orientation,x2=x+ed[0],y2=y+ed[1],z2=z+ed[2];
|
|
1841
|
+
if(x2>innerX||y2>innerY||z2>innerZ)continue;
|
|
1842
|
+
if(payloadBlocked||countBlocked||orientation.volumeBlocked)continue;
|
|
1843
|
+
const candidate={x,y,z,pd,ed,r,item};
|
|
1844
|
+
if(nests&&!compressionSensitive&&used+usedVolumeDelta(placements,candidate)+tmpl.reserve>tmpl.innerVolume)continue;
|
|
1727
1845
|
let collision=false;
|
|
1728
|
-
const candidateShape=
|
|
1729
|
-
|
|
1730
|
-
for(const obstacle of tmpl.obs){metrics.collision_checks++;
|
|
1846
|
+
const candidateShape=orientation.shape,box={x,y,z,d:ed};
|
|
1847
|
+
for(const obstacle of obstacles){metrics.collision_checks++;
|
|
1731
1848
|
if(intersects(box,obstacle)&&solidsOverlap(candidateShape,box,null,obstacle)){collision=true;break}}
|
|
1732
1849
|
// Broad phase: visit only the placements sharing a cell with `box`, stamping each
|
|
1733
1850
|
// so a placement spanning several cells is narrow-phase-checked once. A generation
|
|
1734
1851
|
// counter does that without allocating a set per candidate orientation.
|
|
1735
|
-
if(!collision){const [ix1,ix2,iy1,iy2,iz1,iz2]=cellRange(index,box),stamp=++index.gen
|
|
1852
|
+
if(!collision){const [ix1,ix2,iy1,iy2,iz1,iz2]=cellRange(index,box),stamp=++index.gen;
|
|
1736
1853
|
scan:for(let ix=ix1;ix<ix2;ix++)for(let iy=iy1;iy<iy2;iy++)for(let iz=iz1;iz<iz2;iz++){
|
|
1737
1854
|
const bucket=index.cells.get(cellKey(ix,iy,iz));if(!bucket)continue;
|
|
1738
1855
|
for(const position of bucket){if(index.seen[position]===stamp)continue;index.seen[position]=stamp;
|
|
1739
|
-
const placed=
|
|
1740
|
-
|
|
1741
|
-
|
|
1856
|
+
const placed=placements[position],pe=placed.ed;metrics.collision_checks++;
|
|
1857
|
+
// `intersects` on the placement's own fields: copying each visited placement into
|
|
1858
|
+
// a box first was one allocation per narrow-phase check, a million per solve. The
|
|
1859
|
+
// nesting exemption can only apply to a nesting candidate, and the box the exact
|
|
1860
|
+
// test needs is built only once an envelope overlap has been found.
|
|
1861
|
+
if(x<placed.x+pe[0]&&x2>placed.x&&y<placed.y+pe[1]&&y2>placed.y&&z<placed.z+pe[2]&&z2>placed.z
|
|
1862
|
+
&&!(nests&&validNesting(candidate,placed))
|
|
1742
1863
|
// The axis-aligned test is the broad phase and stays mandatory. Only when a hull
|
|
1743
1864
|
// is one of the two solids does the exact test get to overrule it, so a request of
|
|
1744
1865
|
// ordinary boxes never reaches the hull path at all.
|
|
1745
|
-
&&solidsOverlap(candidateShape,box,placedHull(placed),
|
|
1866
|
+
&&solidsOverlap(candidateShape,box,placedHull(placed),{x:placed.x,y:placed.y,z:placed.z,d:pe})){collision=true;break scan}}}}
|
|
1746
1867
|
if(collision)continue;
|
|
1747
|
-
|
|
1748
|
-
if(
|
|
1749
|
-
if(!allowed(candidate,state.placements,tmpl,globalSupportPpm,metrics,loadBase,accessBase))continue;
|
|
1868
|
+
if(axleOverloaded(tmpl,placements,candidate))continue;
|
|
1869
|
+
if(!allowed(candidate,placements,tmpl,globalSupportPpm,metrics,loadBase,accessBase,sweep))continue;
|
|
1750
1870
|
// With zero load the candidate is at its largest, and appending it can only shrink
|
|
1751
1871
|
// existing compressible supports. If that upper bound fits, the exact support-graph
|
|
1752
1872
|
// refresh cannot reject it; only a candidate near the reserve boundary pays the
|
|
1753
1873
|
// non-local calculation. Ordinary requests retain the incremental O(1) path above.
|
|
1754
|
-
if(compressionSensitive){const upperBound=used+occupiedVolume(
|
|
1874
|
+
if(compressionSensitive){const upperBound=used+occupiedVolume(candidate);
|
|
1755
1875
|
if(upperBound+tmpl.reserve>tmpl.innerVolume
|
|
1756
|
-
&&usedVolume([...
|
|
1876
|
+
&&usedVolume([...placements,candidate])+tmpl.reserve>tmpl.innerVolume)continue}
|
|
1757
1877
|
metrics.feasible_candidates++;
|
|
1758
1878
|
const score=solverAlias==='grid'
|
|
1759
|
-
?
|
|
1879
|
+
?z*1e12+y*1e6+x
|
|
1760
1880
|
:solverAlias==='layer'
|
|
1761
|
-
?
|
|
1881
|
+
?z2*1e12+z*1e8+y*1e4+x
|
|
1762
1882
|
:solverAlias==='maximal_spaces'
|
|
1763
|
-
?
|
|
1764
|
-
:
|
|
1883
|
+
?x2+y2+z2*1e6
|
|
1884
|
+
:z2*1e9+y2*1e4+x2;
|
|
1765
1885
|
if(width===1){if(!found.length||score<found[0].score)found[0]={score,...candidate};continue}
|
|
1766
1886
|
found.push({score,...candidate})}}
|
|
1767
1887
|
if(width===1)return found;
|
|
@@ -1826,7 +1946,7 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{
|
|
|
1826
1946
|
difference=b.state.placements.length-a.state.placements.length;if(difference)return difference;
|
|
1827
1947
|
const az=a.state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0),bz=b.state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0);
|
|
1828
1948
|
if(az!==bz)return az-bz;if(a.used!==b.used)return a.used>b.used?-1:1;
|
|
1829
|
-
const signature=node=>node.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|');return signature(a)
|
|
1949
|
+
const signature=node=>node.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|');return compareCodePoints(signature(a),signature(b))};
|
|
1830
1950
|
const greedy=tryPackIntoTemplate(tmpl,itemsRemaining);let beam=[fresh()],incumbent=fresh();incumbent.state=greedy.state;incumbent.used=greedy.used;incumbent.unplaced=greedy.next;let nodes=0;
|
|
1831
1951
|
for(let position=0;position<batches.length;position++){
|
|
1832
1952
|
const batch=batches[position],future=batches.slice(position+1).flat(),expansions=[];let exhausted=false;
|
|
@@ -1987,7 +2107,7 @@ const homogeneousBlocksSupported=()=>policyRules.length===0&&templates.every(t=>
|
|
|
1987
2107
|
&&items.every(i=>i.group==null&&!i.tags.length&&!i.incompatible.length&&!i.eligibleTags.length
|
|
1988
2108
|
&&i.stackable&&!i.raw.must_be_on_floor&&i.maxTop==null&&i.maxStacked==null
|
|
1989
2109
|
&&i.supportPpm===0&&(i.groundRule==null||i.groundRule==='free')&&i.nesting==null&&i.stopIndex==null);
|
|
1990
|
-
const compareBlockValue=(a,b)=>typeof a==='bigint'?(a<b?-1:a>b?1:0):typeof a==='string'?a
|
|
2110
|
+
const compareBlockValue=(a,b)=>typeof a==='bigint'?(a<b?-1:a>b?1:0):typeof a==='string'?compareCodePoints(a,b):a-b;
|
|
1991
2111
|
const compareBlockKey=(a,b)=>{for(let i=0;i<a.length;i++){const difference=compareBlockValue(a[i],b[i]);if(difference)return difference}return 0};
|
|
1992
2112
|
const containsSpace=(outer,inner)=>outer.x<=inner.x&&outer.y<=inner.y&&outer.z<=inner.z
|
|
1993
2113
|
&&outer.x+outer.d[0]>=inner.x+inner.d[0]&&outer.y+outer.d[1]>=inner.y+inner.d[1]&&outer.z+outer.d[2]>=inner.z+inner.d[2];
|
|
@@ -2067,7 +2187,7 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){
|
|
|
2067
2187
|
if(exhausted||!expansions.length)break;
|
|
2068
2188
|
const dominant=new Map();for(const plan of expansions){const signature=`${plan.remaining.map(i=>i.id).join('|')}::${[...plan.inventory.entries()].sort().map(([k,v])=>`${k}:${v}`).join('|')}`;
|
|
2069
2189
|
const previous=dominant.get(signature);if(!previous||compareScore(planScore(plan,[]),planScore(previous,[]))<0)dominant.set(signature,plan)}
|
|
2070
|
-
beam=[...dominant.values()].sort((a,b)=>compareScore(planBound(a),planBound(b))||a.remaining.map(i=>i.id).join('|')
|
|
2190
|
+
beam=[...dominant.values()].sort((a,b)=>compareScore(planBound(a),planBound(b))||compareCodePoints(a.remaining.map(i=>i.id).join('|'),b.remaining.map(i=>i.id).join('|'))).slice(0,containerPlanBeamWidth)}
|
|
2071
2191
|
packed.push(...incumbent.packed);remaining.splice(0,remaining.length,...incumbent.remaining);seq=incumbent.seq;
|
|
2072
2192
|
}else while(remaining.length&&packed.length<maxContainers){
|
|
2073
2193
|
if(deadline.expired()){timeLimitReached=true;break}
|
|
@@ -2155,7 +2275,7 @@ for(const c of packed){scoreCost+=c.tmpl.cost_minor??0;
|
|
|
2155
2275
|
// to invent -- so the refusal fires, but once, at the outermost frame, on the packing
|
|
2156
2276
|
// actually selected for return: a portfolio sibling with a priceable answer must not be
|
|
2157
2277
|
// aborted by this run's refusal. Rust, Python and PHP refuse at the same single choke
|
|
2158
|
-
// point (
|
|
2278
|
+
// point (second review). The detail rides the result as a non-enumerable property
|
|
2159
2279
|
// below, a search device that never serializes.
|
|
2160
2280
|
let unpriceableDetail=null;
|
|
2161
2281
|
if(objective==='lowest_landed_cost')for(const c of packed){
|
|
@@ -2272,7 +2392,7 @@ function rebalanceValid(context,result){
|
|
|
2272
2392
|
if(usedVolume(state.placements)+volume(state.tmpl.d)*BigInt(state.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)>volume(state.tmpl.d))return false;
|
|
2273
2393
|
if(axleOverloaded(state.tmpl,state.placements))return false;
|
|
2274
2394
|
const placed=[];
|
|
2275
|
-
for(const candidate of [...state.placements].sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||a.item.id
|
|
2395
|
+
for(const candidate of [...state.placements].sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||compareCodePoints(a.item.id,b.item.id))){
|
|
2276
2396
|
if(!allowed(candidate,placed,state.tmpl,context.globalSupportPpm,{support_checks:0}))return false;
|
|
2277
2397
|
// A move the rules forbid must fail the same check a placement did. Replaying the
|
|
2278
2398
|
// container in this order is what makes a cap or a segregation answerable at all:
|
|
@@ -2334,7 +2454,7 @@ export function rebalanceWeight(req,result,{maxMoves=64}={}){
|
|
|
2334
2454
|
}
|
|
2335
2455
|
const context=rebalanceContext(req,result),moves=[];
|
|
2336
2456
|
if(!rebalanceValid(context,result))throw new TypeError('result is not a valid packing of this request');
|
|
2337
|
-
//
|
|
2457
|
+
// second review: under `lowest_landed_cost` a move is a re-pricing -- shifting
|
|
2338
2458
|
// payload can push a destination past its rate table's last bracket, leaving the
|
|
2339
2459
|
// "balanced" packing with no published price. States are priced with the same helpers
|
|
2340
2460
|
// the packer bills with: an unpriceable input is refused up front in the standard
|
|
@@ -2407,7 +2527,7 @@ export class SequenceReplayError extends Error{
|
|
|
2407
2527
|
}
|
|
2408
2528
|
export class SequenceWarning{
|
|
2409
2529
|
constructor(code,index,messageKey,arguments_={}){this.code=code;this.index=index;this.message_key=messageKey;
|
|
2410
|
-
this.arguments=Object.fromEntries(Object.entries(arguments_).sort(([a],[b])=>a
|
|
2530
|
+
this.arguments=Object.fromEntries(Object.entries(arguments_).sort(([a],[b])=>compareCodePoints(a,b)));Object.freeze(this.arguments);Object.freeze(this)}
|
|
2411
2531
|
toJSON(){return {code:this.code,index:this.index,message_key:this.message_key,arguments:this.arguments}}
|
|
2412
2532
|
}
|
|
2413
2533
|
function sequenceInteger(value,name){if(!Number.isSafeInteger(value))throw new RangeError(`${name} must be a safe integer tick count`);return value}
|
package/index.js
CHANGED
|
@@ -16,20 +16,60 @@ export {
|
|
|
16
16
|
export { UnsupportedFeatureError };
|
|
17
17
|
import * as commerceFallback from './commerce.js';
|
|
18
18
|
export { CommerceInputError } from './commerce.js';
|
|
19
|
-
const require=createRequire(import.meta.url);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every module this package can load as a compiled backend, in probe order: a binary
|
|
23
|
+
* built beside this file first, then the `@packvium/native` optional dependency.
|
|
24
|
+
*
|
|
25
|
+
* `test/force-fallback.cjs` blocks exactly this list, and asserts it stays identical to
|
|
26
|
+
* the specifiers `loadNative` passes.
|
|
27
|
+
*/
|
|
28
|
+
const NATIVE_CANDIDATES = ['./packvium-native.node', '@packvium/native'];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the compiled backend, or report that there is none.
|
|
32
|
+
*
|
|
33
|
+
* Each candidate is required by a *literal* specifier rather than through a loop
|
|
34
|
+
* variable, so every module this package is able to load can be resolved by reading it.
|
|
35
|
+
* A probe that misses is not an error: absent, unbuilt and ABI-incompatible addons all
|
|
36
|
+
* land here, and every one of them means the same thing -- answer from the JavaScript
|
|
37
|
+
* fallback, which returns the same result more slowly.
|
|
38
|
+
*/
|
|
39
|
+
function loadNative() {
|
|
40
|
+
try {
|
|
41
|
+
return require('./packvium-native.node');
|
|
42
|
+
} catch { /* no addon beside this file */ }
|
|
43
|
+
try {
|
|
44
|
+
return require('@packvium/native');
|
|
45
|
+
} catch { /* optional dependency absent or unloadable */ }
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const native = loadNative();
|
|
50
|
+
|
|
51
|
+
export const backend = () => (native ? 'rust' : 'javascript');
|
|
52
|
+
|
|
53
|
+
export function packJson(input) {
|
|
54
|
+
if (native?.packJson) return native.packJson(input);
|
|
55
|
+
return JSON.stringify(packFallback(JSON.parse(input)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function pack(request) {
|
|
59
|
+
return JSON.parse(packJson(JSON.stringify(request)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function rebalanceWeight(request, result, { maxMoves = 64 } = {}) {
|
|
63
|
+
if (!Number.isSafeInteger(maxMoves) || maxMoves < 0) {
|
|
64
|
+
throw new RangeError('maxMoves must be a non-negative safe integer');
|
|
29
65
|
}
|
|
30
|
-
|
|
66
|
+
if (native?.rebalanceJson) {
|
|
67
|
+
return JSON.parse(native.rebalanceJson(JSON.stringify(request), JSON.stringify(result), maxMoves));
|
|
68
|
+
}
|
|
69
|
+
return rebalanceFallback(request, result, { maxMoves });
|
|
31
70
|
}
|
|
32
|
-
|
|
71
|
+
|
|
72
|
+
export const version = () => native?.version?.() ?? '1.1.0-js-fallback';
|
|
33
73
|
|
|
34
74
|
/**
|
|
35
75
|
* The exported commercial and control-plane API: a quote, a policy decision and catalog
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name":"@packvium/engine",
|
|
3
|
-
"version":"1.
|
|
3
|
+
"version":"1.1.0",
|
|
4
4
|
"description":"Native-first 3D cartonization with deterministic JS fallback",
|
|
5
|
+
"author":{"name":"Packvium","url":"https://packvium.com"},
|
|
5
6
|
"keywords":["3d-bin-packing","bin-packing","cartonization","packing","container-loading","logistics","shipping","deterministic"],
|
|
6
7
|
"homepage":"https://packvium.com",
|
|
7
8
|
"repository":{"type":"git","url":"git+https://github.com/toxakara/packvium-node.git"},
|
|
@@ -12,7 +13,6 @@
|
|
|
12
13
|
"exports":{".":{"types":"./index.d.ts","import":"./index.js"}},
|
|
13
14
|
"files":["index.js","fallback.js","contact-graph.js","policy.js","commerce.js","commerce-model.js","examples","index.d.ts","README.md","SECURITY.md"],
|
|
14
15
|
"engines":{"node":">=16"},
|
|
15
|
-
"optionalDependencies":{"@packvium/native":"1.0.0"},
|
|
16
16
|
"scripts":{
|
|
17
17
|
"test":"node test/run-tests.mjs",
|
|
18
18
|
"test:legacy":"node test/legacy-smoke.mjs"
|