@packvium/engine 0.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/LICENSE +21 -0
- package/README.md +55 -0
- package/contact-graph.js +61 -0
- package/fallback.js +1685 -0
- package/index.d.ts +53 -0
- package/index.js +30 -0
- package/package.json +17 -0
package/fallback.js
ADDED
|
@@ -0,0 +1,1685 @@
|
|
|
1
|
+
import { buildContactGraph } from './contact-graph.js';
|
|
2
|
+
import { parsePolicy, policyRejection, provesUnplaceable, tagOccurrences } from './policy.js';
|
|
3
|
+
|
|
4
|
+
const LEN={mm:16000,cm:160000,m:16000000,in:406400,inch:406400,inches:406400,ft:4876800,tick:1,ticks:1};
|
|
5
|
+
const WT={g:8000000,kg:8000000000,mg:8000,lb:3628738960,lbs:3628738960,oz:226796185,tick:1,ticks:1};
|
|
6
|
+
const ROT={LWH:[0,1,2],LHW:[0,2,1],WLH:[1,0,2],WHL:[1,2,0],HLW:[2,0,1],HWL:[2,1,0]};
|
|
7
|
+
const SUPPORT_SCALE=1000000;
|
|
8
|
+
const hasOwn=(value,key)=>Object.prototype.hasOwnProperty.call(value,key);
|
|
9
|
+
function cloneValue(value){
|
|
10
|
+
if(Array.isArray(value))return value.map(cloneValue);
|
|
11
|
+
if(value!==null&&typeof value==='object'){
|
|
12
|
+
const copy={};
|
|
13
|
+
for(const [key,entry] of Object.entries(value))copy[key]=cloneValue(entry);
|
|
14
|
+
return copy
|
|
15
|
+
}
|
|
16
|
+
return value
|
|
17
|
+
}
|
|
18
|
+
const UNSUPPORTED={
|
|
19
|
+
// Top-level scope: a block describing the whole request rather than one item or
|
|
20
|
+
// container, which none of the per-entry loops below would ever see.
|
|
21
|
+
request:[],
|
|
22
|
+
configuration:[],
|
|
23
|
+
item:[],
|
|
24
|
+
container:[],
|
|
25
|
+
obstacle:[],
|
|
26
|
+
};
|
|
27
|
+
// The admission boundary for staged public-field rollouts, exported so a test can assert
|
|
28
|
+
// that what the lists name is exactly what the guard refuses -- the counterpart of
|
|
29
|
+
// `ArrayCodec::UNSUPPORTED_FIELDS` in PHP and `UNSUPPORTED_FIELDS` in Python. Empty while
|
|
30
|
+
// this engine is current with the shared schema.
|
|
31
|
+
export const UNSUPPORTED_FIELDS=Object.freeze(Object.fromEntries(
|
|
32
|
+
Object.entries(UNSUPPORTED).map(([scope,fields])=>[scope,Object.freeze([...fields])])));
|
|
33
|
+
/**
|
|
34
|
+
* A carrier rate card, parsed once per container.
|
|
35
|
+
*
|
|
36
|
+
* `weight_brackets_g` is an ascending ladder of upper bounds in whole grams; the first
|
|
37
|
+
* bound the billed weight does not exceed sets the price. Validation is strict and
|
|
38
|
+
* up front because a malformed tariff misprices silently: a descending ladder would
|
|
39
|
+
* make an unreachable bracket look priced, and a length mismatch would pair a weight
|
|
40
|
+
* with someone else's price.
|
|
41
|
+
*/
|
|
42
|
+
function parseRateTable(raw){
|
|
43
|
+
if(raw==null)return null;
|
|
44
|
+
const brackets=raw.weight_brackets_g,prices=raw.prices_minor;
|
|
45
|
+
if(!Array.isArray(brackets)||!Array.isArray(prices)||!brackets.length)throw new RangeError('rate_table requires non-empty weight_brackets_g and prices_minor');
|
|
46
|
+
if(brackets.length!==prices.length)throw new RangeError('rate_table weight_brackets_g and prices_minor must have the same length');
|
|
47
|
+
for(let index=0;index<brackets.length;index++){
|
|
48
|
+
if(!Number.isSafeInteger(brackets[index])||brackets[index]<=0)throw new RangeError('rate_table weight_brackets_g must be positive safe integers');
|
|
49
|
+
if(index>0&&brackets[index]<=brackets[index-1])throw new RangeError('rate_table weight_brackets_g must be strictly ascending');
|
|
50
|
+
if(!Number.isSafeInteger(prices[index])||prices[index]<0)throw new RangeError('rate_table prices_minor must be non-negative safe integers');
|
|
51
|
+
}
|
|
52
|
+
const minimum=raw.minimum_charge_minor??0,fuel=raw.fuel_surcharge_permille??0;
|
|
53
|
+
if(!Number.isSafeInteger(minimum)||minimum<0)throw new RangeError('rate_table minimum_charge_minor must be a non-negative safe integer');
|
|
54
|
+
if(!Number.isSafeInteger(fuel)||fuel<0)throw new RangeError('rate_table fuel_surcharge_permille must be a non-negative safe integer');
|
|
55
|
+
return {brackets,prices,minimum,fuel}
|
|
56
|
+
}
|
|
57
|
+
// Billed weight in whole grams, rounded up -- how a carrier reads a scale. A shipment
|
|
58
|
+
// fractionally over a bracket is in the next bracket; rounding down would price it
|
|
59
|
+
// below what the carrier charges.
|
|
60
|
+
const billedGrams=ticks=>ceilDiv(ticks,WT.g);
|
|
61
|
+
/** The charge for a billed weight, or `null` when the tariff does not price it. */
|
|
62
|
+
function chargeMinor(table,grams){
|
|
63
|
+
for(let index=0;index<table.brackets.length;index++)if(grams<=table.brackets[index]){
|
|
64
|
+
const base=Math.max(table.prices[index],table.minimum);
|
|
65
|
+
return base+ceilDiv(base*table.fuel,1000)
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
// A weight past the last bracket has no published price. Ranking it free would make the
|
|
70
|
+
// objective prefer exactly the packing the caller cannot ship, so it is ranked worst
|
|
71
|
+
// instead. Unlike a missing rate card -- rejected at admission, since that is a static
|
|
72
|
+
// property of the request -- this depends on how the search happened to fill the box,
|
|
73
|
+
// so it must lose a candidate rather than abort the run.
|
|
74
|
+
const UNPRICEABLE=Number.MAX_SAFE_INTEGER;
|
|
75
|
+
const addLanded=(total,template,billedTicks)=>{
|
|
76
|
+
if(total===UNPRICEABLE)return total;
|
|
77
|
+
const charge=template.rate==null?null:chargeMinor(template.rate,billedGrams(billedTicks));
|
|
78
|
+
return charge==null?UNPRICEABLE:total+charge
|
|
79
|
+
};
|
|
80
|
+
export class UnsupportedFeatureError extends Error{
|
|
81
|
+
constructor(fields){super(`unsupported_feature: JavaScript fallback does not yet implement ${fields.join(', ')}; the request was rejected instead of silently ignoring public fields`);this.name='UnsupportedFeatureError';this.code='unsupported_feature';this.fields=fields}
|
|
82
|
+
}
|
|
83
|
+
function rejectUnsupported(req){const fields=[];
|
|
84
|
+
for(const key of UNSUPPORTED.request)if(hasOwn(req,key))fields.push(key);
|
|
85
|
+
for(const key of UNSUPPORTED.configuration)if(hasOwn(req.configuration??{},key))fields.push(`configuration.${key}`);
|
|
86
|
+
for(const raw of req.items??[])for(const key of UNSUPPORTED.item)if(hasOwn(raw,key))fields.push(`item.${key}`);
|
|
87
|
+
for(const raw of req.containers??[]){
|
|
88
|
+
for(const key of UNSUPPORTED.container)if(hasOwn(raw,key))fields.push(`container.${key}`);
|
|
89
|
+
for(const obstacle of raw.obstacles??[])for(const key of UNSUPPORTED.obstacle)if(hasOwn(obstacle,key))fields.push(`obstacle.${key}`);
|
|
90
|
+
}
|
|
91
|
+
if(fields.length)throw new UnsupportedFeatureError([...new Set(fields)].sort());
|
|
92
|
+
}
|
|
93
|
+
function rat(s){s=String(s).trim();if(s.includes(' ')){const [w,f]=s.split(/\s+/,2),[n,d]=f.split('/').map(BigInt),wb=BigInt(w),sg=s.startsWith('-')?-1n:1n,mag=(wb<0n?-wb:wb)*d+n;return [sg*mag,d]}if(s.includes('/')){const[n,d]=s.split('/').map(BigInt);return[n,d]}if(s.includes('.')){const neg=s.startsWith('-'),[a,b]=s.replace(/^[-+]/,'').split('.');const d=10n**BigInt(b.length),n=BigInt(a)*d+BigInt(b);return[neg?-n:n,d]}return[BigInt(s),1n]}
|
|
94
|
+
function round(n,d){const sg=n<0n?-1n:1n,a=n<0n?-n:n,q=a/d,r=a%d;const z=r*2n<d?q:r*2n>d?q+1n:q%2n===0n?q:q+1n;return sg*z}
|
|
95
|
+
function scalar(v,def,table){let raw=v,unit=def;if(v&&typeof v==='object'){raw=v.value;unit=v.unit??def}else if(typeof v==='string'){const m=v.trim().match(/^(.+?)\s*(millimeters|millimeter|inches|ticks|inch|lbs|tick|mm|cm|ft|in|mg|kg|oz|lb|g|m)$/i);if(m){raw=m[1];unit=m[2]}}const[n,d]=rat(raw);return Number(round(n*BigInt(table[unit.toLowerCase()]),d))}
|
|
96
|
+
function dims(v,u){return [scalar(v.length,u,LEN),scalar(v.width,u,LEN),scalar(v.height,u,LEN)]}
|
|
97
|
+
function rotate(d,r){return ROT[r].map(i=>d[i])}
|
|
98
|
+
function volume(d){return BigInt(d[0])*BigInt(d[1])*BigInt(d[2])}
|
|
99
|
+
function intersects(a,b){return a.x<b.x+b.d[0]&&a.x+a.d[0]>b.x&&a.y<b.y+b.d[1]&&a.y+a.d[1]>b.y&&a.z<b.z+b.d[2]&&a.z+a.d[2]>b.z}
|
|
100
|
+
function validNesting(a,b){if(a.item.raw.id!==b.item.raw.id||a.item.nesting==null||b.item.nesting==null||a.item.nesting!==b.item.nesting)return false;
|
|
101
|
+
if(a.x!==b.x||a.y!==b.y||a.x+a.ed[0]!==b.x+b.ed[0]||a.y+a.ed[1]!==b.y+b.ed[1])return false;
|
|
102
|
+
const [low,high]=a.z<=b.z?[a,b]:[b,a];return low.z!==high.z&&low.z+low.ed[2]-high.z===a.item.nesting}
|
|
103
|
+
function usedVolume(placements){let total=placements.reduce((s,p)=>s+volume(p.pd),0n),overlap=0n;
|
|
104
|
+
for(let i=0;i<placements.length;i++)for(let j=i+1;j<placements.length;j++)if(validNesting(placements[i],placements[j]))
|
|
105
|
+
overlap+=BigInt(placements[i].item.nesting)*BigInt(placements[i].ed[0])*BigInt(placements[i].ed[1]);
|
|
106
|
+
return total-overlap}
|
|
107
|
+
// What `tentative` would add to `usedVolume(placements.concat(tentative))`, without
|
|
108
|
+
// recomputing it. Appending to the end only creates the pairs that include the new
|
|
109
|
+
// placement, so the delta is its own volume less the nesting overlap it forms against
|
|
110
|
+
// what is already there -- O(n) where `usedVolume` is O(n^2). The search calls this once
|
|
111
|
+
// per candidate orientation, which is what made the whole solve super-linear in item
|
|
112
|
+
// count before.
|
|
113
|
+
function usedVolumeDelta(placements,tentative){let delta=volume(tentative.pd);
|
|
114
|
+
for(const placed of placements)if(validNesting(placed,tentative))
|
|
115
|
+
delta-=BigInt(placed.item.nesting)*BigInt(placed.ed[0])*BigInt(placed.ed[1]);
|
|
116
|
+
return delta}
|
|
117
|
+
// Candidate points are consumed smallest-first by (z, y, x) and only the leading
|
|
118
|
+
// `max_candidate_points` are ever read, so the search keeps one array in that order and
|
|
119
|
+
// inserts into it as placements are committed, rather than rebuilding and re-sorting the
|
|
120
|
+
// whole set for every item. Two points compare equal only when all three
|
|
121
|
+
// coordinates match, i.e. only when they are the same point, so insertion order among
|
|
122
|
+
// equals cannot change which set the slice returns.
|
|
123
|
+
// Broad-phase collision index: a uniform 3D cell grid over the container's own inner
|
|
124
|
+
// dimensions, sized so a typical container holds roughly `CELLS_PER_AXIS` cells along each
|
|
125
|
+
// axis -- coarse enough that a handful of placements do not each get a private cell, fine
|
|
126
|
+
// enough that hundreds spread across many. Mirrors Python's `spatial_index.SpatialIndex`
|
|
127
|
+
// and the PHP and Rust equivalents. The JavaScript fallback was the one engine
|
|
128
|
+
// still scanning every placement linearly for every candidate orientation, which is what
|
|
129
|
+
// made its collision work grow with the square of the item count: 212 million narrow-phase
|
|
130
|
+
// checks at 100 items, against Python's zero.
|
|
131
|
+
const CELLS_PER_AXIS=8;
|
|
132
|
+
function ceilDiv(a,b){return Math.floor((a+b-1)/b)}
|
|
133
|
+
// One numeric key per cell: a Map keyed by number avoids building a string per lookup in
|
|
134
|
+
// the innermost loop. Cell indices are bounded by CELLS_PER_AXIS plus the overshoot of a
|
|
135
|
+
// box wider than the container, so 4096 per axis cannot collide.
|
|
136
|
+
function cellKey(ix,iy,iz){return (ix*4096+iy)*4096+iz}
|
|
137
|
+
function makeIndex(d){return {cx:Math.max(1,ceilDiv(Math.max(1,d[0]),CELLS_PER_AXIS)),
|
|
138
|
+
cy:Math.max(1,ceilDiv(Math.max(1,d[1]),CELLS_PER_AXIS)),
|
|
139
|
+
cz:Math.max(1,ceilDiv(Math.max(1,d[2]),CELLS_PER_AXIS)),
|
|
140
|
+
cells:new Map(),seen:[],gen:0}}
|
|
141
|
+
function cellRange(index,box){return [
|
|
142
|
+
Math.floor(box.x/index.cx),ceilDiv(Math.max(box.x+box.d[0],box.x+1),index.cx),
|
|
143
|
+
Math.floor(box.y/index.cy),ceilDiv(Math.max(box.y+box.d[1],box.y+1),index.cy),
|
|
144
|
+
Math.floor(box.z/index.cz),ceilDiv(Math.max(box.z+box.d[2],box.z+1),index.cz)]}
|
|
145
|
+
function indexAdd(index,position,box){const [ix1,ix2,iy1,iy2,iz1,iz2]=cellRange(index,box);
|
|
146
|
+
index.seen[position]=0;
|
|
147
|
+
for(let ix=ix1;ix<ix2;ix++)for(let iy=iy1;iy<iy2;iy++)for(let iz=iz1;iz<iz2;iz++){
|
|
148
|
+
const key=cellKey(ix,iy,iz),bucket=index.cells.get(key);
|
|
149
|
+
if(bucket)bucket.push(position);else index.cells.set(key,[position])}}
|
|
150
|
+
function copyIndex(index){return {cx:index.cx,cy:index.cy,cz:index.cz,
|
|
151
|
+
cells:new Map([...index.cells].map(([key,bucket])=>[key,bucket.slice()])),
|
|
152
|
+
seen:index.seen.slice(),gen:index.gen}}
|
|
153
|
+
function comparePoints(a,b){return a[2]-b[2]||a[1]-b[1]||a[0]-b[0]}
|
|
154
|
+
function insertPoint(points,point){let low=0,high=points.length;
|
|
155
|
+
while(low<high){const mid=(low+high)>>1;if(comparePoints(points[mid],point)<=0)low=mid+1;else high=mid}
|
|
156
|
+
points.splice(low,0,point)}
|
|
157
|
+
// A candidate point that falls inside a placed box can never host anything again: any
|
|
158
|
+
// positively-sized item originating there overlaps that box. Retiring those keeps the
|
|
159
|
+
// candidate list bounded instead of letting it grow with every placement, which is what
|
|
160
|
+
// left the fallback evaluating two orders of magnitude more points per item than Python
|
|
161
|
+
//. The half-open test matches `intersects`.
|
|
162
|
+
function retirePointsInside(points,box){const x2=box.x+box.d[0],y2=box.y+box.d[1],z2=box.z+box.d[2];
|
|
163
|
+
let write=0;
|
|
164
|
+
for(let read=0;read<points.length;read++){const p=points[read];
|
|
165
|
+
if(p[0]>=box.x&&p[0]<x2&&p[1]>=box.y&&p[1]<y2&&p[2]>=box.z&&p[2]<z2)continue;
|
|
166
|
+
points[write++]=p}
|
|
167
|
+
points.length=write}
|
|
168
|
+
function pointsFrom(placement){const {x,y,z,ed,item}=placement;
|
|
169
|
+
return item.nesting==null
|
|
170
|
+
?[[x+ed[0],y,z],[x,y+ed[1],z],[x,y,z+ed[2]]]
|
|
171
|
+
:[[x+ed[0],y,z],[x,y+ed[1],z],[x,y,z+ed[2]],[x,y,z+ed[2]-item.nesting]]}
|
|
172
|
+
function centreOfMassOffsetPpm(container,placements,clearance=0){let total=0n,wx=0n,wy=0n;
|
|
173
|
+
for(const p of placements){const w=BigInt(p.item.w);total+=w;wx+=w*BigInt(2*(p.x+clearance)+p.pd[0]);wy+=w*BigInt(2*(p.y+clearance)+p.pd[1])}
|
|
174
|
+
if(total===0n)return 0;const length=BigInt(container.d[0]),width=BigInt(container.d[1]);
|
|
175
|
+
const x=(wx-total*length)<0n?total*length-wx:wx-total*length,y=(wy-total*width)<0n?total*width-wy:wy-total*width;
|
|
176
|
+
return Number((x*1000000n/(total*length))>(y*1000000n/(total*width))?x*1000000n/(total*length):y*1000000n/(total*width))}
|
|
177
|
+
function axleReactions(container,placements,extra=null){if(container.axleSpec==null)return null;
|
|
178
|
+
const [front,rear]=container.axleSpec;let total=BigInt(container.tare),weighted=BigInt(container.tare)*BigInt(container.d[0]);
|
|
179
|
+
for(const p of placements){const w=BigInt(p.item.w);total+=w;weighted+=w*BigInt(2*p.x+p.ed[0])}
|
|
180
|
+
if(extra){const w=BigInt(extra.item.w);total+=w;weighted+=w*BigInt(2*extra.x+extra.ed[0])}
|
|
181
|
+
const denominator=2n*BigInt(rear.position-front.position);
|
|
182
|
+
return {denominator,front:2n*total*BigInt(rear.position)-weighted,rear:weighted-2n*total*BigInt(front.position)}}
|
|
183
|
+
function axleOverloaded(container,placements,extra=null){const reaction=axleReactions(container,placements,extra);if(reaction==null)return false;
|
|
184
|
+
const [front,rear]=container.axleSpec;
|
|
185
|
+
return (front.max!=null&&reaction.front>BigInt(front.max)*reaction.denominator)
|
|
186
|
+
||(rear.max!=null&&reaction.rear>BigInt(rear.max)*reaction.denominator)}
|
|
187
|
+
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}
|
|
188
|
+
function placementDimensions(placement){return placement.ed??placement.d}
|
|
189
|
+
function placementItemType(placement){return placement.itemType??placement.item?.raw?.id??null}
|
|
190
|
+
function placementNesting(placement){return placement.nesting??placement.item?.nesting??null}
|
|
191
|
+
function sameNestingColumn(left,right){const leftNesting=placementNesting(left),rightNesting=placementNesting(right);
|
|
192
|
+
const leftType=placementItemType(left),rightType=placementItemType(right);
|
|
193
|
+
if(leftType==null||rightType==null||leftType!==rightType||leftNesting==null||rightNesting==null||leftNesting!==rightNesting)return false;
|
|
194
|
+
const ld=placementDimensions(left),rd=placementDimensions(right);
|
|
195
|
+
return left.x===right.x&&left.y===right.y&&left.x+ld[0]===right.x+rd[0]&&left.y+ld[1]===right.y+rd[1]}
|
|
196
|
+
// One candidate's exact direct supporters in O(n). A nested predecessor replaces only
|
|
197
|
+
// shadowed face contacts from its own type/footprint column; unrelated face supporters
|
|
198
|
+
// retain their original order and semantics.
|
|
199
|
+
function directSupporters(candidate,placed){const dimensions=placementDimensions(candidate);let predecessor=null;
|
|
200
|
+
for(const other of placed){if(other===candidate||other.z>=candidate.z||!sameNestingColumn(other,candidate))continue;
|
|
201
|
+
if(predecessor==null||other.z>=predecessor.z)predecessor=other}
|
|
202
|
+
if(predecessor!=null&&predecessor.z+placementDimensions(predecessor)[2]-candidate.z!==placementNesting(predecessor))predecessor=null;
|
|
203
|
+
const supporters=[];
|
|
204
|
+
for(const other of placed){if(other===candidate)continue;const otherDimensions=placementDimensions(other);
|
|
205
|
+
if(other.z+otherDimensions[2]!==candidate.z)continue;
|
|
206
|
+
const area=overlapXY({x:other.x,y:other.y,z:other.z,d:otherDimensions},{x:candidate.x,y:candidate.y,z:candidate.z,d:dimensions});
|
|
207
|
+
if(area<=0||(predecessor!=null&&sameNestingColumn(other,candidate)))continue;
|
|
208
|
+
supporters.push({placement:other,area})}
|
|
209
|
+
if(predecessor!=null)supporters.push({placement:predecessor,area:dimensions[0]*dimensions[1]});
|
|
210
|
+
return supporters}
|
|
211
|
+
// Exact BigInt long division rounded to 8 places, ties to even, matching
|
|
212
|
+
// Python's Decimal.quantize default context -- not a float divide, which is neither
|
|
213
|
+
// exact for large tick counts nor consistent with the other three engines' rule.
|
|
214
|
+
function decimalString(v,divisor,places=8){
|
|
215
|
+
let ticks=typeof v==='bigint'?v:BigInt(Math.trunc(v));const negative=ticks<0n;if(negative)ticks=-ticks;const div=BigInt(divisor);
|
|
216
|
+
let whole=ticks/div,remainder=ticks%div,digits='';
|
|
217
|
+
for(let i=0;i<places;i++){remainder*=10n;digits+=(remainder/div).toString();remainder%=div}
|
|
218
|
+
const lastOdd=digits.length?Number(digits[digits.length-1])%2===1:whole%2n===1n;
|
|
219
|
+
if(remainder*2n>div||(remainder*2n===div&&lastOdd)){
|
|
220
|
+
const chars=digits.split('');let i=chars.length-1;
|
|
221
|
+
for(;i>=0&&chars[i]==='9';i--)chars[i]='0';
|
|
222
|
+
if(i>=0)chars[i]=String(Number(chars[i])+1);else whole+=1n;
|
|
223
|
+
digits=chars.join('')
|
|
224
|
+
}
|
|
225
|
+
digits=digits.replace(/0+$/,'');
|
|
226
|
+
const text=digits===''?whole.toString():`${whole}.${digits}`;
|
|
227
|
+
return text==='0'?'0':(negative?'-':'')+text
|
|
228
|
+
}
|
|
229
|
+
function outLength(v,u){return {ticks:v,value:decimalString(v,LEN[u]),unit:u}}
|
|
230
|
+
function outDims(d,u){return {length:outLength(d[0],u),width:outLength(d[1],u),height:outLength(d[2],u)}}
|
|
231
|
+
function outPoint(p,u){return {x:outLength(p.x,u),y:outLength(p.y,u),z:outLength(p.z,u)}}
|
|
232
|
+
// JSON has no BigInt scalar, while the established result schema exposes `ticks` as a
|
|
233
|
+
// number. Keep the exact integer through every calculation and comparison, render the
|
|
234
|
+
// decimal value from it, and convert only the compatibility `ticks` field here.
|
|
235
|
+
function outWeight(v,u){const exact=typeof v==='bigint'?v:BigInt(v);return {ticks:Number(exact),value:decimalString(exact,WT[u]),unit:u}}
|
|
236
|
+
function proofForReason(reason,details=[]){const level=['no_compatible_container_dimensions','payload_exceeded','rotation_restricted','policy_rule'].includes(reason)?'proven':['time_limit','effort_limit'].includes(reason)?'unknown_due_to_limit':['no_feasible_placement','search_exhausted','exact_search_incomplete'].includes(reason)?'observed':'inferred';return {level,observations:[{code:reason,count:1,details}]}}
|
|
237
|
+
function catalogVersionsUsed(raw=[]){
|
|
238
|
+
if(!Array.isArray(raw))throw new TypeError('catalog_versions_used must be an array');
|
|
239
|
+
const required=['catalog_id','effective_at','resolved_at','version'],seen=new Set();
|
|
240
|
+
return raw.map((reference,index)=>{
|
|
241
|
+
if(reference===null||typeof reference!=='object'||Array.isArray(reference)||JSON.stringify(Object.keys(reference).sort())!==JSON.stringify(required))throw new TypeError(`catalog_versions_used[${index}] must contain exactly the canonical fields`);
|
|
242
|
+
if(typeof reference.catalog_id!=='string'||reference.catalog_id.length===0)throw new TypeError(`catalog_versions_used[${index}].catalog_id must be non-empty`);
|
|
243
|
+
if(seen.has(reference.catalog_id))throw new TypeError(`catalog_versions_used contains ambiguous duplicate ${JSON.stringify(reference.catalog_id)}`);
|
|
244
|
+
seen.add(reference.catalog_id);
|
|
245
|
+
for(const [field,minimum] of [['version',1],['effective_at',0],['resolved_at',0]])if(!Number.isInteger(reference[field])||reference[field]<minimum)throw new TypeError(`catalog_versions_used[${index}].${field} must be >= ${minimum}`);
|
|
246
|
+
return {...reference};
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
export const REASON_MESSAGES=Object.freeze({
|
|
250
|
+
no_compatible_container_dimensions:'does not fit inside any offered container in any rotation',
|
|
251
|
+
rotation_restricted:'would fit in some container with more rotations allowed, but not with the rotations this item permits',
|
|
252
|
+
payload_exceeded:'exceeds the maximum payload of every offered container',
|
|
253
|
+
policy_rule:'is forbidden from every offered container by a policy rule the request declared -- the rule and version are in the details',
|
|
254
|
+
no_eligible_container:'shares no eligible container tag with any offered container',
|
|
255
|
+
time_limit:'was not reached before the configured time limit expired',
|
|
256
|
+
effort_limit:'was not reached before the configured effort budget was exhausted',
|
|
257
|
+
group_cannot_fit_together:'belongs to a group that could not all be placed together',
|
|
258
|
+
insufficient_support:'would fit geometrically, but only by resting on support the minimum support ratio forbids',
|
|
259
|
+
no_feasible_placement:'found no feasible placement in the containers offered, for a reason the search could not further isolate',
|
|
260
|
+
search_exhausted:'was not placed before the configured search strategies were exhausted',
|
|
261
|
+
exact_search_incomplete:'was not placed because the exact search ended before proving a final answer',
|
|
262
|
+
container_inventory_exhausted:'requires another compatible container, but the declared inventory is exhausted',
|
|
263
|
+
});
|
|
264
|
+
export function explainReason(reason){
|
|
265
|
+
const message=REASON_MESSAGES[reason];
|
|
266
|
+
if(message===undefined){const error=new RangeError(`no explanation registered for reason code ${JSON.stringify(reason)}`);error.code='unknown_reason';error.reason=reason;throw error}
|
|
267
|
+
return message;
|
|
268
|
+
}
|
|
269
|
+
export function explanationForUnpackedItem(item){
|
|
270
|
+
return {message_key:`packvium.unpacked.${item.reason}`,arguments:{item_id:item.item_id,evidence_level:item.proof?.level??'',details:(item.details??[]).join('; ')},default_message:explainReason(item.reason)};
|
|
271
|
+
}
|
|
272
|
+
export function explainUnpackedItem(item){
|
|
273
|
+
const descriptor=explanationForUnpackedItem(item),prefix={proven:'Proven: ','unknown_due_to_limit':'Unknown (limit reached): ',observed:'Observed: ',inferred:'Inferred: '}[item.proof?.level]??'',details=item.details?.length?` (${item.details.join('; ')})`:'';
|
|
274
|
+
return `${item.item_id}: ${prefix}${descriptor.default_message}${details}`;
|
|
275
|
+
}
|
|
276
|
+
export function aggregateTermination(starts,error=false){if(!Array.isArray(starts)||starts.length===0)throw new Error('termination aggregation requires at least one start record');const selected=starts.filter(start=>start.selected);if(selected.length!==1)throw new Error('termination aggregation requires exactly one selected start');const anyStartTruncated=starts.some(start=>start.truncated),allRequiredStartsCompleted=starts.every(start=>start.completed),winningStartTruncated=selected[0].truncated,globalDeadlineReached=starts.some(start=>start.global_deadline_reached);return {code:error?'error':winningStartTruncated||globalDeadlineReached?'time_limit':'complete',any_start_truncated:anyStartTruncated,all_required_starts_completed:allRequiredStartsCompleted,winning_start_truncated:winningStartTruncated,global_deadline_reached:globalDeadlineReached,starts}}
|
|
277
|
+
export class Deadline{constructor(limitMs,clock=Date.now){this.clock=clock;this.started=clock();this.limitMs=Math.max(1,limitMs)}elapsedMs(){return this.clock()-this.started}remainingMs(){return this.limitMs-this.elapsedMs()}expired(){return this.remainingMs()<=0}}
|
|
278
|
+
|
|
279
|
+
// Exact floor(baseArea * ratio / 1) with the ratio held at parts per million, so support
|
|
280
|
+
// is decided on integers rather than a float comparison against an epsilon.
|
|
281
|
+
function requiredArea(baseArea,ratioPpm){const whole=Math.floor(baseArea/SUPPORT_SCALE),rest=baseArea%SUPPORT_SCALE;return whole*ratioPpm+Math.floor(rest*ratioPpm/SUPPORT_SCALE)}
|
|
282
|
+
|
|
283
|
+
// Weight borne by each box once everything above it is accounted for. Boxes are settled
|
|
284
|
+
// from the top down and each one pushes its own weight plus its accumulated load onto the
|
|
285
|
+
// faces it touches, split by contact area.
|
|
286
|
+
// Direct support graph in expected O(n log n + e), where e is the number of nearby
|
|
287
|
+
// same-plane candidates returned by the broad phase. Nested instances are grouped by
|
|
288
|
+
// type and exact XY footprint, then sorted once so each instance transfers load to its
|
|
289
|
+
// adjacent predecessor instead of a non-adjacent face it happens to touch. Worst case
|
|
290
|
+
// remains O(n^2) when the physical contact graph itself is dense.
|
|
291
|
+
function contactGraph(boxes){const graph=buildContactGraph(boxes,overlapXY),groupKeys=boxes.map(()=>null),groups=new Map();
|
|
292
|
+
boxes.forEach((box,index)=>{if(box.itemType==null||box.nesting==null)return;
|
|
293
|
+
const key=JSON.stringify([box.itemType,box.nesting,box.x,box.y,box.d[0],box.d[1]]);groupKeys[index]=key;
|
|
294
|
+
if(!groups.has(key))groups.set(key,[]);groups.get(key).push(index)});
|
|
295
|
+
const predecessors=new Map();
|
|
296
|
+
for(const indices of groups.values()){indices.sort((left,right)=>boxes[left].z-boxes[right].z||left-right);
|
|
297
|
+
for(let offset=1;offset<indices.length;offset++){const lower=indices[offset-1],upper=indices[offset];
|
|
298
|
+
if(boxes[lower].z+boxes[lower].d[2]-boxes[upper].z===boxes[lower].nesting)predecessors.set(upper,lower)}}
|
|
299
|
+
if(predecessors.size===0)return graph;
|
|
300
|
+
for(const [upper,lower] of predecessors){const key=groupKeys[upper];
|
|
301
|
+
const supports=graph.supporters[upper].filter(([candidate])=>groupKeys[candidate]!==key);
|
|
302
|
+
const edge=[lower,overlapXY(boxes[lower],boxes[upper])],position=supports.findIndex(([candidate])=>candidate>lower);
|
|
303
|
+
if(position===-1)supports.push(edge);else supports.splice(position,0,edge);
|
|
304
|
+
graph.supporters[upper]=supports}
|
|
305
|
+
graph.children=boxes.map(()=>[]);
|
|
306
|
+
graph.supporters.forEach((supports,upper)=>supports.forEach(([lower])=>graph.children[lower].push(upper)));
|
|
307
|
+
return graph}
|
|
308
|
+
|
|
309
|
+
function constraintBox(placement){return {x:placement.x,y:placement.y,z:placement.z,d:placement.ed,w:placement.item.w,
|
|
310
|
+
maxTop:placement.item.maxTop,maxStacked:placement.item.maxStacked,itemType:placement.item.raw.id,nesting:placement.item.nesting,
|
|
311
|
+
stopIndex:placement.item.stopIndex}}
|
|
312
|
+
|
|
313
|
+
function topLoads(boxes,graph=contactGraph(boxes)){const loads=boxes.map(()=>0n);
|
|
314
|
+
const order=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);
|
|
315
|
+
for(const upper of order){const supports=graph.supporters[upper];let total=0n;
|
|
316
|
+
for(const [,area] of supports)total+=BigInt(area);
|
|
317
|
+
if(total===0n)continue;
|
|
318
|
+
// Both operands are individually safe integers, but their product need not be.
|
|
319
|
+
// Keeping the whole distribution in BigInt makes floor(weight * area / total)
|
|
320
|
+
// exact and also prevents rounding errors from accumulating down a tall stack.
|
|
321
|
+
// Conversion to Number is deferred to `outWeight`, the existing JSON boundary.
|
|
322
|
+
const downward=BigInt(boxes[upper].w)+loads[upper];let assigned=0n;
|
|
323
|
+
supports.forEach(([i,area],n)=>{const share=n===supports.length-1?downward-assigned:downward*BigInt(area)/total;assigned+=share;loads[i]+=share})}
|
|
324
|
+
return loads}
|
|
325
|
+
|
|
326
|
+
function overloaded(boxes,loads=null){if(boxes.every(b=>b.maxTop==null))return false;if(loads==null)loads=topLoads(boxes);
|
|
327
|
+
return boxes.some((b,i)=>b.maxTop!=null&&loads[i]>BigInt(b.maxTop))}
|
|
328
|
+
function supportChildren(boxes,graph=null){return (graph??contactGraph(boxes)).children}
|
|
329
|
+
// Guarded like `overloaded` above: the transitive walk is unnecessary when no item
|
|
330
|
+
// declares a limit at all, which is the common case.
|
|
331
|
+
// The guard is a short circuit, not a semantic change -- with every `maxStacked` null the
|
|
332
|
+
// predicate below returns false for each box anyway.
|
|
333
|
+
function stackLimitsExceeded(boxes,graph=null){if(boxes.every(b=>b.maxStacked==null))return false;
|
|
334
|
+
const children=supportChildren(boxes,graph);
|
|
335
|
+
return boxes.some((box,root)=>{if(box.maxStacked==null)return false;const seen=new Set(),pending=[...children[root]];while(pending.length){const i=pending.pop();if(!seen.has(i)){seen.add(i);pending.push(...children[i])}}return seen.size>box.maxStacked})}
|
|
336
|
+
function stackDensityExceeded(boxes,maxDensity,loads=null){if(maxDensity==null)return false;if(loads==null)loads=topLoads(boxes);const squareMetre=16000000n*16000000n;
|
|
337
|
+
return boxes.some((box,i)=>(BigInt(box.w)+loads[i])*squareMetre>BigInt(maxDensity)*BigInt(box.d[0])*BigInt(box.d[1]))}
|
|
338
|
+
function groundContactAllowed(candidate,placed,supports=null){const rule=candidate.item.groundRule;if(candidate.z===0||rule==null||rule==='free')return true;
|
|
339
|
+
const box={x:candidate.x,y:candidate.y,z:candidate.z,d:placementDimensions(candidate)},supporters=supports??directSupporters(candidate,placed);
|
|
340
|
+
if(rule==='single')return supporters.length===1;if(rule==='multiple')return supporters.length>=2;
|
|
341
|
+
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}
|
|
342
|
+
function routeContactAllowed(candidate,placed,supports){
|
|
343
|
+
// An item without a declared stop rides the whole route. Infinity is the shared
|
|
344
|
+
// PHP/Python/Rust contract. Check only the new relations, as the existing scene was
|
|
345
|
+
// already valid; the one same-column face above may need an O(n) predecessor lookup
|
|
346
|
+
// to distinguish a real face support from a shadowed non-adjacent nested contact.
|
|
347
|
+
const candidateStop=candidate.item.stopIndex??Infinity;
|
|
348
|
+
if(supports.some(({placement})=>candidateStop>(placement.item.stopIndex??Infinity)))return false;
|
|
349
|
+
const dimensions=placementDimensions(candidate);let scene=null;
|
|
350
|
+
for(const upper of placed){const upperDimensions=placementDimensions(upper),upperStop=upper.item.stopIndex??Infinity;
|
|
351
|
+
if(validNesting(candidate,upper)&&candidate.z<upper.z){if(upperStop>candidateStop)return false;continue}
|
|
352
|
+
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;
|
|
353
|
+
if(sameNestingColumn(candidate,upper)){
|
|
354
|
+
if(scene==null)scene=[...placed,candidate];
|
|
355
|
+
if(!directSupporters(upper,scene).some(support=>support.placement===candidate))continue;
|
|
356
|
+
}
|
|
357
|
+
if(upperStop>candidateStop)return false;
|
|
358
|
+
}
|
|
359
|
+
return true}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Decides whether one box may be added to a container.
|
|
363
|
+
*
|
|
364
|
+
* The fallback used to check only the container walls, obstacles, collisions and the
|
|
365
|
+
* floor rule, and then reported `support_ratio: 1` and `top_load: 0` regardless. Every
|
|
366
|
+
* physical rule the schema accepts is enforced here instead: a result that claims to
|
|
367
|
+
* honour a rule it ignored is worse than no result at all.
|
|
368
|
+
*/
|
|
369
|
+
function allowed(candidate,placed,container,globalSupportPpm,metrics){
|
|
370
|
+
const box={x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed};
|
|
371
|
+
if(candidate.item.raw.must_be_on_floor&&box.z!==0)return false;
|
|
372
|
+
const tags=candidate.item.tags,bad=candidate.item.incompatible;
|
|
373
|
+
for(const p of placed){
|
|
374
|
+
if(bad.some(t=>p.item.tags.includes(t))||p.item.incompatible.some(t=>tags.includes(t)))return false;
|
|
375
|
+
const other={x:p.x,y:p.y,z:p.z,d:p.ed};
|
|
376
|
+
if(overlapXY(other,box)<=0)continue;
|
|
377
|
+
if(other.z+other.d[2]===box.z&&!p.item.stackable)return false;
|
|
378
|
+
// Sliding underneath is a stacking decision too: nothing may go below an item so
|
|
379
|
+
// that the item comes to rest on a box that refuses to carry anything.
|
|
380
|
+
if(other.z===box.z+box.d[2]&&!candidate.item.stackable)return false;
|
|
381
|
+
if(validNesting(candidate,p)){
|
|
382
|
+
const [lower]=candidate.z<=p.z?[candidate,p]:[p,candidate];
|
|
383
|
+
if(!lower.item.stackable)return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
metrics.support_checks++;
|
|
387
|
+
const ratio=Math.max(globalSupportPpm,candidate.item.supportPpm);
|
|
388
|
+
const supports=box.z===0?[]:directSupporters(candidate,placed);
|
|
389
|
+
if(supports.some(({placement})=>placement.item.stackable===false))return false;
|
|
390
|
+
if(box.z!==0&&ratio>0){
|
|
391
|
+
const area=supports.reduce((total,support)=>total+support.area,0);
|
|
392
|
+
if(area<requiredArea(box.d[0]*box.d[1],ratio))return false;
|
|
393
|
+
}
|
|
394
|
+
// constraintBox mirrors item.maxTop/maxStacked verbatim, so both gates are
|
|
395
|
+
// decidable from the items alone; when neither fires, the three skipped checks
|
|
396
|
+
// return false for every box anyway, and building n+1 boxes per feasible
|
|
397
|
+
// candidate was pure allocation.
|
|
398
|
+
const needsLoads=container.maxStackDensity!=null||candidate.item.maxTop!=null||placed.some(p=>p.item.maxTop!=null);
|
|
399
|
+
const needsGraph=needsLoads||candidate.item.maxStacked!=null||placed.some(p=>p.item.maxStacked!=null);
|
|
400
|
+
if(!needsGraph)return groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports);
|
|
401
|
+
const boxes=[...placed.map(constraintBox),constraintBox(candidate)];
|
|
402
|
+
const graph=contactGraph(boxes),loads=needsLoads?topLoads(boxes,graph):null;
|
|
403
|
+
return !overloaded(boxes,loads)&&!stackLimitsExceeded(boxes,graph)&&!stackDensityExceeded(boxes,container.maxStackDensity,loads)
|
|
404
|
+
&&groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function supportRatioOf(placement,placed){
|
|
408
|
+
if(placement.z===0)return 1;
|
|
409
|
+
const dimensions=placementDimensions(placement),area=directSupporters(placement,placed).reduce((total,support)=>total+support.area,0);
|
|
410
|
+
return area/(dimensions[0]*dimensions[1]);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function compareScore(left,right){for(let i=0;i<Math.max(left.length,right.length);i++){const difference=(left[i]??0)-(right[i]??0);if(difference!==0)return difference}return 0}
|
|
414
|
+
|
|
415
|
+
function uniqueRotations(dimensions,rotations){
|
|
416
|
+
const seen=new Set(),choices=[];
|
|
417
|
+
for(const rotation of rotations){
|
|
418
|
+
const physical=rotate(dimensions,rotation),key=physical.join(':');
|
|
419
|
+
if(!seen.has(key)){seen.add(key);choices.push([rotation,physical])}
|
|
420
|
+
}
|
|
421
|
+
return choices
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function latticeCentreOfMassOffsetPpm(summary,inner){
|
|
425
|
+
if(summary.weight===0||summary.count===0)return 0;
|
|
426
|
+
const perLayer=summary.nx*summary.ny,fullLayers=Math.floor(summary.count/perLayer),remainder=summary.count%perLayer;
|
|
427
|
+
const rows=Math.floor(remainder/summary.nx),columns=remainder%summary.nx,triangular=n=>BigInt(n)*BigInt(n-1)/2n;
|
|
428
|
+
const sumX=BigInt(fullLayers*summary.ny+rows)*triangular(summary.nx)+triangular(columns);
|
|
429
|
+
const sumY=BigInt(fullLayers*summary.nx)*triangular(summary.ny)+BigInt(summary.nx)*triangular(rows)+BigInt(columns*rows);
|
|
430
|
+
const count=BigInt(summary.count),weight=BigInt(summary.weight),clearance=BigInt(summary.clearance);
|
|
431
|
+
const doubledX=weight*(2n*BigInt(summary.envelope[0])*sumX+count*(2n*clearance+BigInt(summary.physical[0])));
|
|
432
|
+
const doubledY=weight*(2n*BigInt(summary.envelope[1])*sumY+count*(2n*clearance+BigInt(summary.physical[1])));
|
|
433
|
+
const total=weight*count,x=BigInt(inner[0]),y=BigInt(inner[1]);
|
|
434
|
+
const offsetX=(doubledX-total*x)<0n?total*x-doubledX:doubledX-total*x;
|
|
435
|
+
const offsetY=(doubledY-total*y)<0n?total*y-doubledY:doubledY-total*y;
|
|
436
|
+
return Number((offsetX*1000000n/(total*x))>(offsetY*1000000n/(total*y))?offsetX*1000000n/(total*x):offsetY*1000000n/(total*y))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* O(c*r) regular-lattice fast path, where c is the number of container templates
|
|
441
|
+
* actually consumed and r <= 6 is the number of physical rotations. The returned
|
|
442
|
+
* result is O(c), independent of item quantity: per-instance coordinates are encoded
|
|
443
|
+
* by `lattice_summary` and can be reconstructed on demand.
|
|
444
|
+
*/
|
|
445
|
+
/** Lexicographic ordering over equal-length ranking keys. Entries compare pairwise, so
|
|
446
|
+
* a key may mix numbers and strings as long as each position is consistent. */
|
|
447
|
+
function lessThan(left,right){
|
|
448
|
+
for(let i=0;i<left.length;i++){
|
|
449
|
+
if(left[i]<right[i])return true;
|
|
450
|
+
if(left[i]>right[i])return false;
|
|
451
|
+
}
|
|
452
|
+
return false
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Reject an item the contract does not admit.
|
|
457
|
+
*
|
|
458
|
+
* Called once per declared item type before any solver runs, so both the general search
|
|
459
|
+
* and the compact lattice path admit exactly the same requests. Keeping this inside the
|
|
460
|
+
* general path's own item-building loop is what let the two disagree.
|
|
461
|
+
*/
|
|
462
|
+
function admitItem(raw,u){
|
|
463
|
+
const d=dims(raw.dimensions,u),nesting=raw.nesting_height==null?null:scalar(raw.nesting_height,u,LEN);
|
|
464
|
+
if(nesting!=null&&(nesting<0||nesting>=d[2]))throw new RangeError("nesting_height must be at least zero and strictly less than the item's own height");
|
|
465
|
+
if(raw.max_stacked_items!=null&&(!Number.isSafeInteger(raw.max_stacked_items)||raw.max_stacked_items<1))throw new RangeError('max_stacked_items must be a positive safe integer');
|
|
466
|
+
if(raw.stop_index!=null&&(!Number.isSafeInteger(raw.stop_index)||raw.stop_index<0))throw new RangeError('stop_index must be a non-negative safe integer');
|
|
467
|
+
if(raw.value!=null&&(!Number.isSafeInteger(raw.value)||raw.value<0))throw new RangeError('value must be a non-negative safe integer');
|
|
468
|
+
if(raw.ground_contact_rule!=null&&!['free','covered','single','multiple'].includes(raw.ground_contact_rule))throw new RangeError('ground_contact_rule must be free, covered, single or multiple');
|
|
469
|
+
if(raw.eligible_container_tags!=null&&(!Array.isArray(raw.eligible_container_tags)||raw.eligible_container_tags.some(tag=>typeof tag!=='string')))throw new TypeError('eligible_container_tags must be an array of strings');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The `count` placements of one regular lattice, in fill order (x fastest, then y, then
|
|
474
|
+
* layer).
|
|
475
|
+
*
|
|
476
|
+
* Every quantity here is closed-form, and that is the point rather than an optimisation
|
|
477
|
+
* detail: the general path derives support by scanning the placements already made and
|
|
478
|
+
* top load by walking the support graph, both `O(n)` per placement, which is exactly the
|
|
479
|
+
* `O(n^2)` this path exists to avoid. In a lattice neither scan can tell you anything you
|
|
480
|
+
* do not already know -- every cell has identical footprint, so an item is either on the
|
|
481
|
+
* floor or fully seated on the one below it, and the load above it is however many items
|
|
482
|
+
* of its own column were actually placed.
|
|
483
|
+
*/
|
|
484
|
+
function latticePlacements({best,count,firstIndex,raw,weight,clear,ou,ow}){
|
|
485
|
+
const perLayer=best.nx*best.ny,placements=[];
|
|
486
|
+
for(let index=0;index<count;index++){
|
|
487
|
+
const ix=index%best.nx,iy=Math.floor(index/best.nx)%best.ny,iz=Math.floor(index/perLayer);
|
|
488
|
+
// Items strictly above this one in its own column, counting only those the container
|
|
489
|
+
// actually received -- the last layer is partial whenever count is not a multiple of
|
|
490
|
+
// perLayer, and charging this item for cells nobody filled would overstate the load.
|
|
491
|
+
const above=Math.floor((count-1-index)/perLayer);
|
|
492
|
+
placements.push({
|
|
493
|
+
item_id:`${raw.id}#${firstIndex+index}`,item_type:raw.id,
|
|
494
|
+
position:outPoint({x:ix*best.envelope[0]+clear,y:iy*best.envelope[1]+clear,z:iz*best.envelope[2]+clear},ou),
|
|
495
|
+
dimensions:outDims(best.physical,ou),orientation:best.rotation,
|
|
496
|
+
support_ratio:(1).toFixed(6),top_load:outWeight(BigInt(above)*BigInt(weight),ow),
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
return placements
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortExceeded,effortRemaining,deadline}){
|
|
503
|
+
// Keep this path opt-in until its capacity-first container choice is
|
|
504
|
+
// proven objective-equivalent to the general path. Admission, effort accounting,
|
|
505
|
+
// injected deadlines and metrics now have direct parity tests; objective quality is
|
|
506
|
+
// the only reason not to use it for coordinate-bearing requests. Materialising the
|
|
507
|
+
// retained lattice in `latticePlacements` is O(count), while compact output remains
|
|
508
|
+
// O(c*r) and keeps regression-many-container-types below the scaling budget.
|
|
509
|
+
const wantCoordinates=req.configuration?.require_placement_coordinates!==false;
|
|
510
|
+
if((solverAlias!=null&&solverAlias!=='grid')||req.items?.length!==1)return null;
|
|
511
|
+
const raw=req.items[0],quantity=raw.quantity??1;
|
|
512
|
+
if(!Number.isSafeInteger(quantity)||quantity<1||raw.group!=null||(raw.tags??[]).length||(raw.incompatible_tags??[]).length
|
|
513
|
+
||(raw.eligible_container_tags??[]).length||raw.max_stacked_items!=null||raw.nesting_height!=null
|
|
514
|
+
||!['free',null,undefined].includes(raw.ground_contact_rule))return null;
|
|
515
|
+
const itemDimensions=dims(raw.dimensions,u),weight=scalar(raw.weight??0,'g',WT);
|
|
516
|
+
const rotations=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT));
|
|
517
|
+
if(!Array.isArray(rotations)||rotations.some(rotation=>ROT[rotation]===undefined))return null;
|
|
518
|
+
const templates=[];
|
|
519
|
+
for(const container of req.containers??[]){
|
|
520
|
+
if((container.obstacles??[]).length||container.axles!=null||(container.void_fill_reserve_ratio??0)>0
|
|
521
|
+
||Object.keys(container.tag_limits??{}).length||container.max_stack_density!=null)return null;
|
|
522
|
+
const inner=dims(container.inner_dimensions,u),outer=container.outer_dimensions?dims(container.outer_dimensions,u):inner;
|
|
523
|
+
templates.push({...container,d:inner,outerD:outer,max:container.max_payload==null?null:scalar(container.max_payload,'g',WT),
|
|
524
|
+
tare:scalar(container.tare_weight??0,'g',WT),rate:parseRateTable(container.rate_table)})
|
|
525
|
+
}
|
|
526
|
+
// `lowest_landed_cost` shares this key with `shipping_cost` deliberately: when a
|
|
527
|
+
// container is opened its final billed weight is not yet known, so the tariff has
|
|
528
|
+
// nothing to price. Billable weight is the monotone proxy the key already used, and
|
|
529
|
+
// the finished answer is priced exactly below.
|
|
530
|
+
templates.sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost'
|
|
531
|
+
?dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))
|
|
532
|
+
:(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d)));
|
|
533
|
+
let remaining=quantity,sequence=0,scoreCost=0,scoreUnused=0,scoreHeight=0,scoreBillable=0,scoreLanded=0,scoreAchievedHeight=0;
|
|
534
|
+
const containers=[],maxContainers=req.configuration?.max_containers??Infinity;
|
|
535
|
+
// Per-unit capacity depends only on (item, template), both fixed for the
|
|
536
|
+
// whole solve, so it is computed once per template rather than recomputed on
|
|
537
|
+
// every copy opened. Picking greedily by capacity each round -- instead of
|
|
538
|
+
// exhausting one (cost, volume)-cheapest template's entire inventory before a
|
|
539
|
+
// roomier template is ever tried -- is what stops this from opening ten
|
|
540
|
+
// single-item containers of the cheapest template when one larger template
|
|
541
|
+
// would have held them all.
|
|
542
|
+
// Whether the item's own geometry ever fits a container, before any payload,
|
|
543
|
+
// max_items or stacking cap is applied. Zero capacity has several causes and they are
|
|
544
|
+
// different answers to a caller: a crate too big for every box is a proven dimensional
|
|
545
|
+
// rejection, while one that fits but is too heavy is a payload rejection. Collapsing
|
|
546
|
+
// them lost that distinction, and the independent validator caught it.
|
|
547
|
+
let dimensionsFitSomewhere=false;
|
|
548
|
+
const plans=templates.map(template=>{
|
|
549
|
+
let best=null;
|
|
550
|
+
for(const [rotation,physical] of uniqueRotations(itemDimensions,rotations)){
|
|
551
|
+
const envelope=physical.map(edge=>edge+2*clear),nx=Math.floor(template.d[0]/envelope[0]),ny=Math.floor(template.d[1]/envelope[1]);
|
|
552
|
+
let nz=Math.floor(template.d[2]/envelope[2]);
|
|
553
|
+
if(nx>0&&ny>0&&nz>0)dimensionsFitSomewhere=true;
|
|
554
|
+
if(raw.must_be_on_floor||raw.stackable===false)nz=Math.min(nz,1);
|
|
555
|
+
if(raw.max_top_load!=null&&weight>0){
|
|
556
|
+
const safeLayers=BigInt(scalar(raw.max_top_load,'g',WT))/BigInt(weight)+1n;
|
|
557
|
+
if(safeLayers<BigInt(nz))nz=Number(safeLayers)
|
|
558
|
+
}
|
|
559
|
+
let capacity=Math.min(Number.MAX_SAFE_INTEGER,nx*ny*nz);
|
|
560
|
+
if(template.max_items!=null)capacity=Math.min(capacity,template.max_items);
|
|
561
|
+
if(template.max!=null&&weight>0)capacity=Math.min(capacity,Math.floor(template.max/weight));
|
|
562
|
+
// Among rotations that hold the same number, prefer the one that needs
|
|
563
|
+
// fewer layers for the items actually being placed -- a lower centre of load is
|
|
564
|
+
// the objective's fifth key, and ranking rotations by capacity alone left it to
|
|
565
|
+
// chance, stacking higher than the general path on 21 fixtures (caught by the
|
|
566
|
+
// native quality budget). Layers for the real count, not footprint area in the
|
|
567
|
+
// abstract: with one item every rotation needs one layer, so this ties and the
|
|
568
|
+
// declared orientation survives instead of the item being rotated for nothing.
|
|
569
|
+
// Capacity counts only up to what is actually being placed. A rotation that could
|
|
570
|
+
// hold thirty when eight remain is not better than one that holds eight -- the
|
|
571
|
+
// surplus buys nothing and, when it is bought by stacking, costs the objective's
|
|
572
|
+
// fifth key. Ranking by raw capacity made that trade invisibly and stacked higher
|
|
573
|
+
// than the general path on 21 fixtures.
|
|
574
|
+
// Then the stack top itself, which is what the objective's fifth key measures.
|
|
575
|
+
// Without it the tie fell through to the rotation *name*: on gen-max-top-load-01
|
|
576
|
+
// a 50x50x30 crate ties on capacity and layers, and 'HLW' sorts before 'LWH', so
|
|
577
|
+
// the 50 mm edge stood vertical and the load sat higher than the same items laid
|
|
578
|
+
// on their 30 mm side. Alphabetical order is not a packing preference.
|
|
579
|
+
// Rank by the stack *top*, not the layer count. They disagree, and the objective
|
|
580
|
+
// measures the top: a 950x1510x2300 crate in a 2690-high container fits sixteen
|
|
581
|
+
// per box either as one layer standing 2300 tall or as two layers of eight lying
|
|
582
|
+
// 950 tall. Fewer layers looks tidier and is worse -- 2300 against 1900 -- and
|
|
583
|
+
// ranking by layer count chose it, costing 1710036 where the general path scored
|
|
584
|
+
// 1059478 (caught by the native quality budget on
|
|
585
|
+
// regression-multi-container-quantity-threshold).
|
|
586
|
+
const useful=Math.min(capacity,quantity);
|
|
587
|
+
const layers=nx*ny>0?Math.ceil(useful/(nx*ny)):0;
|
|
588
|
+
const key=[-useful,layers*envelope[2],Number(volume(envelope)),rotation];
|
|
589
|
+
// Lexicographic over the whole key, including the rotation name as the final
|
|
590
|
+
// tie-break: a hand-rolled chain that stopped at the third element left the last
|
|
591
|
+
// one to iteration order, which is deterministic but says so nowhere.
|
|
592
|
+
if(best==null||lessThan(key,best.key))best={key,rotation,physical,envelope,nx,ny,nz,capacity}
|
|
593
|
+
}
|
|
594
|
+
return {template,best,opened:0,available:template.quantity??Infinity};
|
|
595
|
+
}).filter(plan=>plan.best!=null&&plan.best.capacity>0);
|
|
596
|
+
// The deadline is honoured here for the same reason it is in the general
|
|
597
|
+
// path, even though this one is closed-form and fast: an already-expired injected
|
|
598
|
+
// clock must produce a time_limit answer rather than a complete one, and a caller
|
|
599
|
+
// cannot tell which path served the request.
|
|
600
|
+
let timeLimitReached=deadline!=null&&deadline.expired();
|
|
601
|
+
while(remaining>0&&containers.length<maxContainers&&!effortExceeded()&&!timeLimitReached){
|
|
602
|
+
// Rank templates by what the objective actually rewards, not by raw
|
|
603
|
+
// capacity. Capacity-first reads as "the biggest container wins", which minimises
|
|
604
|
+
// container count and then stops caring -- on regression-many-container-types it
|
|
605
|
+
// chose a box left half empty, scoring 500000 on unused volume where Python scored
|
|
606
|
+
// 23437. The objective is lexicographic (unpacked, containers, cost, unused
|
|
607
|
+
// volume, stack height), so the closed-form stand-in for it is: how many copies of
|
|
608
|
+
// this template would finish the remaining items, then cost, then how much of the
|
|
609
|
+
// one being opened is left empty, then how high it stacks. Every term is already
|
|
610
|
+
// computed per template, so this is a comparator change, not new work per round.
|
|
611
|
+
const rank=plan=>{
|
|
612
|
+
const capacity=plan.best.capacity,count=Math.min(capacity,remaining);
|
|
613
|
+
const inner=volume(plan.template.d),used=volume(plan.best.physical)*BigInt(count);
|
|
614
|
+
const unusedPpm=inner>0n?Number((inner-used)*1000000n/inner):0;
|
|
615
|
+
const layers=Math.ceil(count/(plan.best.nx*plan.best.ny));
|
|
616
|
+
const heightPpm=plan.template.d[2]>0
|
|
617
|
+
?Number(BigInt(layers*plan.best.envelope[2])*1000000n/BigInt(plan.template.d[2])):0;
|
|
618
|
+
// `-count` is the *last* discriminator, after every key the objective itself
|
|
619
|
+
// ranks by. Placed any earlier it re-created the defect this ranking exists to
|
|
620
|
+
// remove: ahead of unused volume and height it chose a taller stack on
|
|
621
|
+
// regression-multi-container-quantity-threshold. Last, it only settles ties the
|
|
622
|
+
// objective is indifferent to -- ten unit cubes against boxes holding 1..7 tie on
|
|
623
|
+
// containers, cost, unused volume and height, and template order took the box
|
|
624
|
+
// holding five, leaving a remainder of five no box fits exactly. Packing more when
|
|
625
|
+
// nothing else distinguishes the choice cannot leave a worse remainder.
|
|
626
|
+
return [Math.ceil(remaining/capacity),plan.template.cost_minor??0,unusedPpm,heightPpm,
|
|
627
|
+
-count,templates.indexOf(plan.template)];
|
|
628
|
+
};
|
|
629
|
+
const plan=plans.filter(p=>p.opened<p.available).map(p=>({p,key:rank(p)}))
|
|
630
|
+
.sort((a,b)=>{for(let i=0;i<a.key.length;i++)if(a.key[i]!==b.key[i])return a.key[i]-b.key[i];return 0})
|
|
631
|
+
.map(entry=>entry.p)[0];
|
|
632
|
+
if(plan==null)break;
|
|
633
|
+
plan.opened++;
|
|
634
|
+
const template=plan.template,best=plan.best;
|
|
635
|
+
{
|
|
636
|
+
// A compact result represents each placement without iterating over it, but each
|
|
637
|
+
// placement still consumes the same three public effort counters as the general
|
|
638
|
+
// path. Bound the closed-form batch by the smallest remaining allowance so this
|
|
639
|
+
// optimisation cannot jump across a caller's exact deterministic limit.
|
|
640
|
+
const count=Math.min(best.capacity,remaining,effortRemaining());
|
|
641
|
+
if(count<=0)break;
|
|
642
|
+
const layers=Math.ceil(count/(best.nx*best.ny));
|
|
643
|
+
const summary={...best,count,weight,clearance:clear};
|
|
644
|
+
const used=volume(best.physical)*BigInt(count),payload=weight*count;
|
|
645
|
+
const firstIndex=quantity-remaining+1;
|
|
646
|
+
sequence++;remaining-=count;metrics.search_nodes_expanded+=count;
|
|
647
|
+
metrics.candidate_points_considered+=count;metrics.orientations_considered+=count;metrics.feasible_candidates+=count;
|
|
648
|
+
containers.push({
|
|
649
|
+
id:`${template.id}#${sequence}`,container_type:template.id,inner_dimensions:outDims(template.d,ou),
|
|
650
|
+
outer_dimensions:outDims(template.outerD,ou),payload_weight:outWeight(payload,ow),
|
|
651
|
+
gross_weight:outWeight(payload+template.tare,ow),used_volume_ticks3:used.toString(),
|
|
652
|
+
volume_utilization:(Number(used)/Number(volume(template.d))).toFixed(6),
|
|
653
|
+
centre_of_mass_offset_ppm:latticeCentreOfMassOffsetPpm(summary,template.d),
|
|
654
|
+
void_fill_reserve_ticks3:'0',
|
|
655
|
+
placements:wantCoordinates?latticePlacements({best,count,firstIndex,raw,weight,clear,ou,ow}):[],
|
|
656
|
+
...(wantCoordinates?{}:{lattice_summary:{
|
|
657
|
+
item_type:raw.id,orientation:best.rotation,physical_dimensions:outDims(best.physical,ou),
|
|
658
|
+
envelope_dimensions:outDims(best.envelope,ou),nx:best.nx,ny:best.ny,layers_used:layers,
|
|
659
|
+
layer_step:outLength(best.envelope[2],ou),count,
|
|
660
|
+
}}),
|
|
661
|
+
});
|
|
662
|
+
scoreCost+=template.cost_minor??0;
|
|
663
|
+
const inner=volume(template.d);if(inner>0n)scoreUnused+=Number((inner-used)*1000000n/inner);
|
|
664
|
+
if(template.d[2]>0)scoreHeight+=Number(BigInt(layers*best.envelope[2])*1000000n/BigInt(template.d[2]));
|
|
665
|
+
scoreAchievedHeight+=layers*best.envelope[2];
|
|
666
|
+
if(objective==='shipping_cost'||objective==='lowest_landed_cost'){
|
|
667
|
+
const billed=Math.max(payload+template.tare,dimensionalWeight(template.outerD));
|
|
668
|
+
if(objective==='shipping_cost')scoreBillable+=billed;else scoreLanded=addLanded(scoreLanded,template,billed);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if(deadline!=null&&deadline.expired())timeLimitReached=true;
|
|
672
|
+
if(remaining===0||containers.length>=maxContainers)break
|
|
673
|
+
}
|
|
674
|
+
// Same reason vocabulary and precedence the general path uses: an item left behind
|
|
675
|
+
// because the clock or the effort budget ran out did not fail to fit, and reporting it
|
|
676
|
+
// as exhausted inventory would send a caller looking for a container that was never
|
|
677
|
+
// the problem.
|
|
678
|
+
const effortLimitReached=effortExceeded();
|
|
679
|
+
// An item no template can hold under any permitted rotation did not run out of
|
|
680
|
+
// inventory -- there was never a container for it. `plans` drops every zero-capacity
|
|
681
|
+
// template before the loop starts, so an empty `plans` with nothing opened is exactly
|
|
682
|
+
// that case, and reporting it as exhausted inventory would send a caller looking for
|
|
683
|
+
// more of a container that could never have worked. Same code and proof level the
|
|
684
|
+
// general path uses.
|
|
685
|
+
// Only a genuine dimensional rejection claims that code. Anything else that leaves
|
|
686
|
+
// `plans` empty -- a payload or max_items cap driving capacity to zero -- is not
|
|
687
|
+
// something the caller fixes by finding a bigger box, and this path has no evidence to
|
|
688
|
+
// name the real cause, so it falls back to the general path rather than guessing.
|
|
689
|
+
// `rotation_restricted` and `no_compatible_container_dimensions` are different claims
|
|
690
|
+
// and the validator checks which one holds: the first says the item would have fitted
|
|
691
|
+
// had its own `allowed_rotations` not forbidden the orientation that works, the second
|
|
692
|
+
// says no orientation fits at all. Deciding between them needs the unrestricted set,
|
|
693
|
+
// so it is computed here rather than guessed from the restricted pass above.
|
|
694
|
+
const fitsUnrestricted=(()=>{
|
|
695
|
+
for(const template of templates)
|
|
696
|
+
for(const [,physical] of uniqueRotations(itemDimensions,Object.keys(ROT))){
|
|
697
|
+
const envelope=physical.map(edge=>edge+2*clear);
|
|
698
|
+
if(template.d[0]>=envelope[0]&&template.d[1]>=envelope[1]&&template.d[2]>=envelope[2])return true;
|
|
699
|
+
}
|
|
700
|
+
return false;
|
|
701
|
+
})();
|
|
702
|
+
const nothingPlaceable=plans.length===0&&containers.length===0;
|
|
703
|
+
// Only a proven dimensional rejection claims that code. A payload or max_items cap
|
|
704
|
+
// driving capacity to zero is not something a caller fixes with a bigger box, and this
|
|
705
|
+
// path has no evidence to name the real cause, so it defers to the general path.
|
|
706
|
+
const noCompatibleContainer=nothingPlaceable&&!dimensionsFitSomewhere&&!fitsUnrestricted;
|
|
707
|
+
const rotationRestricted=nothingPlaceable&&!dimensionsFitSomewhere&&fitsUnrestricted;
|
|
708
|
+
if(nothingPlaceable&&dimensionsFitSomewhere)return null;
|
|
709
|
+
const unpacked=[];for(let index=quantity-remaining+1;index<=quantity;index++){
|
|
710
|
+
const reason=timeLimitReached?'time_limit':effortLimitReached?'effort_limit'
|
|
711
|
+
:noCompatibleContainer?'no_compatible_container_dimensions':rotationRestricted?'rotation_restricted':'container_inventory_exhausted',details=[];
|
|
712
|
+
unpacked.push({item_id:`${raw.id}#${index}`,item_type:raw.id,reason,details,proof:proofForReason(reason,details)})
|
|
713
|
+
}
|
|
714
|
+
const complete=remaining===0,solverName=solverAlias?`${solverAlias}:javascript_fallback`:'javascript_fallback';
|
|
715
|
+
const truncated=timeLimitReached||effortLimitReached;
|
|
716
|
+
const starts=[{id:solverName,started:true,completed:!truncated,truncated,selected:true,global_deadline_reached:timeLimitReached}],termination=aggregateTermination(starts);
|
|
717
|
+
if(effortLimitReached&&!timeLimitReached)termination.code='effort_limit';
|
|
718
|
+
const defaultScore=[remaining,containers.length,scoreCost,scoreUnused,scoreHeight];
|
|
719
|
+
const score=objective==='lowest_cost'?[defaultScore[0],defaultScore[2],defaultScore[1],defaultScore[3],defaultScore[4]]
|
|
720
|
+
:objective==='shipping_cost'?[defaultScore[0],scoreBillable,defaultScore[1],defaultScore[3],defaultScore[4]]
|
|
721
|
+
:objective==='lowest_landed_cost'?[defaultScore[0],scoreLanded,defaultScore[1],defaultScore[3],defaultScore[4]]
|
|
722
|
+
:objective==='open_dimension_height'?[defaultScore[0],scoreAchievedHeight,defaultScore[1],defaultScore[2],defaultScore[3]]
|
|
723
|
+
:objective==='maximum_value'?[defaultScore[0],remaining*(raw.value??0),defaultScore[1],defaultScore[2],defaultScore[3]]:defaultScore;
|
|
724
|
+
return {status:complete?'feasible':timeLimitReached?'time_limit':'best_found',feasibility:{code:complete?'feasible':'unknown'},termination,
|
|
725
|
+
optimality:{code:complete?'not_proven':'best_found'},complete,objective,
|
|
726
|
+
algorithm:{profile:req.configuration?.solver_profile??'balanced',solver:solverName,duration_ms:0,seed:req.configuration?.seed??42,
|
|
727
|
+
time_limit_reached:timeLimitReached,effort_limit_reached:effortLimitReached,candidates_evaluated:metrics.feasible_candidates,
|
|
728
|
+
placements_attempted:metrics.orientations_considered,metrics},
|
|
729
|
+
summary:{container_count:containers.length,packed_item_count:quantity-remaining,unpacked_item_count:remaining},
|
|
730
|
+
score,containers,unpacked_items:unpacked,catalog_versions_used:catalogVersionsUsed(req.catalog_versions_used),
|
|
731
|
+
warnings:['JavaScript fallback is active; build the Rust addon for the native portfolio'],alternatives:[]}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Fisher-Yates driven by a 32-bit xorshift, so a start's ordering is a pure function of
|
|
735
|
+
// the request's own `seed` and the start index -- no wall clock, no Math.random.
|
|
736
|
+
function seededOrder(items,seed,startIndex){
|
|
737
|
+
const ordered=items.slice();
|
|
738
|
+
let state=(seed^(startIndex*0x9e3779b1))>>>0||0x9e3779b1;
|
|
739
|
+
const next=()=>{state^=state<<13;state>>>=0;state^=state>>>17;state^=state<<5;state>>>=0;return state};
|
|
740
|
+
for(let i=ordered.length-1;i>0;i--){const j=next()%(i+1);[ordered[i],ordered[j]]=[ordered[j],ordered[i]]}
|
|
741
|
+
return ordered
|
|
742
|
+
}
|
|
743
|
+
// A portfolio's reported effort is what the whole portfolio spent, not what its winner
|
|
744
|
+
// spent. Each run here is a separate `packFallback` call with its own metrics closure, so
|
|
745
|
+
// without this the record understates the work by the number of runs.
|
|
746
|
+
function withPortfolioEffort(winner,runs){
|
|
747
|
+
const metrics={};
|
|
748
|
+
for(const key of Object.keys(winner.algorithm.metrics))
|
|
749
|
+
metrics[key]=runs.reduce((total,run)=>total+run.algorithm.metrics[key],0);
|
|
750
|
+
return {...winner.algorithm,metrics,
|
|
751
|
+
candidates_evaluated:runs.reduce((total,run)=>total+run.algorithm.candidates_evaluated,0),
|
|
752
|
+
placements_attempted:runs.reduce((total,run)=>total+run.algorithm.placements_attempted,0)}
|
|
753
|
+
}
|
|
754
|
+
function startRecordId(solverAlias,index){
|
|
755
|
+
const solver=solverAlias?`${solverAlias}:javascript_fallback`:'javascript_fallback';
|
|
756
|
+
return index===0?solver:`${solver}:seeded_${index}`
|
|
757
|
+
}
|
|
758
|
+
function unstartedRecord(solverAlias,index,globalDeadlineReached){return {
|
|
759
|
+
id:startRecordId(solverAlias,index),started:false,completed:false,truncated:false,selected:false,
|
|
760
|
+
global_deadline_reached:globalDeadlineReached,
|
|
761
|
+
}}
|
|
762
|
+
export function packFallback(req,clock=Date.now,solverAlias=null,startIndex=null,sharedDeadline=null){rejectUnsupported(req);
|
|
763
|
+
const requestedSolvers=req.configuration?.solvers??[],knownSolvers=['grid','extreme_points','homogeneous_blocks','layer','maximal_spaces','exact_small'];
|
|
764
|
+
if(!Array.isArray(requestedSolvers)||requestedSolvers.some(name=>!knownSolvers.includes(name)))throw new RangeError(`unknown solver; expected one of ${knownSolvers.join(', ')}`);
|
|
765
|
+
const exactItemLimit=req.configuration?.exact_item_limit??7;
|
|
766
|
+
const requestedItemCount=(req.items??[]).reduce((total,item)=>total+(item.quantity??1),0);
|
|
767
|
+
if(requestedSolvers.includes('exact_small')&&requestedItemCount>exactItemLimit)throw new RangeError('exact-small item limit exceeded');
|
|
768
|
+
const effort=req.configuration?.effort_budget??null;
|
|
769
|
+
for(const [name,value] of Object.entries(effort??{}))if(!Number.isSafeInteger(value)||value<=0)throw new RangeError(`effort_budget.${name} must be a positive safe integer`);
|
|
770
|
+
const multiStartOrders=req.configuration?.multi_start_orders??1;
|
|
771
|
+
if(!Number.isSafeInteger(multiStartOrders)||multiStartOrders<1)throw new RangeError('multi_start_orders must be a positive safe integer');
|
|
772
|
+
const restartLimit=effort?.max_restarts??Number.MAX_SAFE_INTEGER;
|
|
773
|
+
// Every recursive solver/start shares one absolute deadline. Resetting it per child made
|
|
774
|
+
// a k-start request consume up to k*time_limit_ms while still reporting one portfolio
|
|
775
|
+
// deadline, which is both a determinism and an observability defect.
|
|
776
|
+
const deadline=sharedDeadline??new Deadline(req.configuration?.time_limit_ms??1000,clock);
|
|
777
|
+
if(solverAlias===null&&requestedSolvers.length===0&&(req.configuration?.solver_profile??'balanced')==='quality'){
|
|
778
|
+
const child={...req,configuration:{...(req.configuration??{}),solvers:['homogeneous_blocks','extreme_points','maximal_spaces','layer']}};
|
|
779
|
+
return packFallback(child,clock,null,null,deadline)
|
|
780
|
+
}
|
|
781
|
+
if(solverAlias===null&&requestedSolvers.length){
|
|
782
|
+
let remainingStarts=restartLimit;
|
|
783
|
+
const plans=[];
|
|
784
|
+
for(const name of requestedSolvers){
|
|
785
|
+
const count=Math.min(name==='homogeneous_blocks'?1:multiStartOrders,remainingStarts);
|
|
786
|
+
if(count<1)break;
|
|
787
|
+
plans.push({name,count});remainingStarts-=count;
|
|
788
|
+
}
|
|
789
|
+
const runs=[];
|
|
790
|
+
for(const plan of plans){
|
|
791
|
+
if(runs.length&&deadline.expired())break;
|
|
792
|
+
const childRequest={...req,configuration:{...(req.configuration??{}),solvers:[],multi_start_orders:plan.count}};
|
|
793
|
+
runs.push(packFallback(childRequest,clock,plan.name,null,deadline));
|
|
794
|
+
}
|
|
795
|
+
let winnerIndex=0;for(let index=1;index<runs.length;index++)if(compareScore(runs[index].score,runs[winnerIndex].score)<0)winnerIndex=index;
|
|
796
|
+
const winner=runs[winnerIndex],globalDeadlineReached=deadline.expired();
|
|
797
|
+
const starts=runs.flatMap((run,index)=>run.termination.starts.map(start=>({...start,
|
|
798
|
+
selected:index===winnerIndex&&start.selected,
|
|
799
|
+
global_deadline_reached:start.global_deadline_reached||globalDeadlineReached,
|
|
800
|
+
})));
|
|
801
|
+
for(const plan of plans.slice(runs.length))
|
|
802
|
+
for(let index=0;index<plan.count;index++)starts.push(unstartedRecord(plan.name,index,globalDeadlineReached));
|
|
803
|
+
winner.termination=aggregateTermination(starts);
|
|
804
|
+
winner.algorithm=withPortfolioEffort(winner,runs);
|
|
805
|
+
const alternativeLimit=Math.max(0,(req.configuration?.alternatives??3)-1);
|
|
806
|
+
winner.alternatives=runs.filter((_,index)=>index!==winnerIndex).sort((a,b)=>compareScore(a.score,b.score)).slice(0,alternativeLimit);
|
|
807
|
+
return winner;
|
|
808
|
+
}
|
|
809
|
+
// This value used to be accepted and never read: raising it produced no extra
|
|
810
|
+
// work and no extra start record, so a caller asking for eight restarts got one. Each
|
|
811
|
+
// start now genuinely re-solves against its own deterministic ordering and the best
|
|
812
|
+
// score wins, which is what `termination.starts` has always claimed to describe.
|
|
813
|
+
if(startIndex===null&&multiStartOrders>1){
|
|
814
|
+
const plannedStarts=Math.min(multiStartOrders,restartLimit),runs=[];
|
|
815
|
+
const stagedPlanSearch=(req.configuration?.solver_profile??'balanced')==='quality'&&(req.configuration?.container_plan_beam_width??16)>1;
|
|
816
|
+
for(let index=0;index<plannedStarts;index++){
|
|
817
|
+
if(runs.length&&deadline.expired())break;
|
|
818
|
+
const child=stagedPlanSearch?{...req,configuration:{...(req.configuration??{}),max_candidates_per_item:1,container_plan_beam_width:1,container_plan_node_limit:1}}:req;
|
|
819
|
+
runs.push(packFallback(child,clock,solverAlias,index,deadline));
|
|
820
|
+
}
|
|
821
|
+
if(stagedPlanSearch)for(let index=0;index<2;index++){if(deadline.expired())break;runs.push(packFallback(req,clock,solverAlias,index,deadline))}
|
|
822
|
+
let winnerIndex=0;for(let index=1;index<runs.length;index++)if(compareScore(runs[index].score,runs[winnerIndex].score)<0)winnerIndex=index;
|
|
823
|
+
const winner=runs[winnerIndex],globalDeadlineReached=deadline.expired();
|
|
824
|
+
const plannedRecords=plannedStarts+(stagedPlanSearch?2:0);
|
|
825
|
+
const starts=Array.from({length:plannedRecords},(_,index)=>index<runs.length?{
|
|
826
|
+
...runs[index].termination.starts[0],id:startRecordId(solverAlias,index),selected:index===winnerIndex,
|
|
827
|
+
global_deadline_reached:runs[index].termination.starts[0].global_deadline_reached||globalDeadlineReached,
|
|
828
|
+
}:unstartedRecord(solverAlias,index,globalDeadlineReached));
|
|
829
|
+
winner.termination=aggregateTermination(starts);
|
|
830
|
+
winner.algorithm=withPortfolioEffort(winner,runs);
|
|
831
|
+
if(winnerIndex>0)winner.algorithm={...winner.algorithm,solver:`${winner.algorithm.solver}:seeded_${winnerIndex}`};
|
|
832
|
+
return winner;
|
|
833
|
+
}
|
|
834
|
+
const u=req.units?.length??'mm',ou=req.output?.length_unit??u,ow=req.output?.weight_unit??'g',clear=scalar(req.configuration?.clearance??0,u,LEN);
|
|
835
|
+
const objective=req.configuration?.objective??'default';if(!['default','lowest_cost','shipping_cost','lowest_landed_cost','open_dimension_height','maximum_value'].includes(objective))throw new RangeError(`unknown objective ${JSON.stringify(objective)}`);
|
|
836
|
+
const dimDivisor=req.configuration?.dimensional_weight_divisor??null,dimLengthUnit=req.configuration?.dimensional_weight_length_unit??'in',dimWeightUnit=req.configuration?.dimensional_weight_weight_unit??'lb';
|
|
837
|
+
if(dimDivisor!=null&&(!Number.isSafeInteger(dimDivisor)||dimDivisor<=0))throw new RangeError('dimensional_weight_divisor must be a positive safe integer');
|
|
838
|
+
// Both objectives price the same billed weight, so both need the divisor. There is no
|
|
839
|
+
// library-chosen default: a wrong guess would silently misprice every shipment.
|
|
840
|
+
if((objective==='shipping_cost'||objective==='lowest_landed_cost')&&dimDivisor==null)throw new RangeError(`the ${objective} objective requires configuration.dimensional_weight_divisor`);
|
|
841
|
+
// Rating some containers and not others would rank a priced packing against an unpriced
|
|
842
|
+
// one as though the unpriced were free. Checked before either solver path runs.
|
|
843
|
+
if(objective==='lowest_landed_cost'){
|
|
844
|
+
const unrated=(req.containers??[]).filter(c=>c?.rate_table==null).map(c=>c?.id);
|
|
845
|
+
if(unrated.length)throw new RangeError(`the lowest_landed_cost objective requires a rate_table on every container; ${JSON.stringify(unrated[0])} has none`);
|
|
846
|
+
}
|
|
847
|
+
if(!['mm','cm','m','in','ft'].includes(dimLengthUnit))throw new RangeError('dimensional_weight_length_unit must be mm, cm, m, in or ft');
|
|
848
|
+
if(!['mg','g','kg','oz','lb'].includes(dimWeightUnit))throw new RangeError('dimensional_weight_weight_unit must be mg, g, kg, oz or lb');
|
|
849
|
+
const dimensionalWeight=d=>Number(volume(d)*BigInt(WT[dimWeightUnit])/(BigInt(LEN[dimLengthUnit])**3n*BigInt(dimDivisor??1)));
|
|
850
|
+
const globalSupportPpm=Math.round((req.configuration?.minimum_support_ratio??0)*SUPPORT_SCALE);
|
|
851
|
+
const maxCandidatePoints=Math.max(16,req.configuration?.max_candidate_points??4096);
|
|
852
|
+
const qualityProfile=(req.configuration?.solver_profile??'balanced')==='quality';
|
|
853
|
+
const maxCandidatesPerItem=req.configuration?.max_candidates_per_item??(qualityProfile?16:1);
|
|
854
|
+
const containerPlanBeamWidth=req.configuration?.container_plan_beam_width??(qualityProfile?16:1);
|
|
855
|
+
const containerPlanNodeLimit=req.configuration?.container_plan_node_limit??(qualityProfile?100000:1);
|
|
856
|
+
for(const [name,value] of Object.entries({max_candidates_per_item:maxCandidatesPerItem,container_plan_beam_width:containerPlanBeamWidth,container_plan_node_limit:containerPlanNodeLimit}))
|
|
857
|
+
if(!Number.isSafeInteger(value)||value<1)throw new RangeError(`${name} must be a positive safe integer`);
|
|
858
|
+
let timeLimitReached=false;
|
|
859
|
+
const metrics={candidate_points_considered:0,orientations_considered:0,feasible_candidates:0,collision_checks:0,support_checks:0,space_partitions:0,search_nodes_expanded:0};
|
|
860
|
+
const candidateEffortExceeded=()=>effort!=null&&(
|
|
861
|
+
(effort.max_candidates_evaluated!=null&&metrics.feasible_candidates>=effort.max_candidates_evaluated)
|
|
862
|
+
||(effort.max_placement_attempts!=null&&metrics.orientations_considered>=effort.max_placement_attempts)),
|
|
863
|
+
effortExceeded=()=>candidateEffortExceeded()||(effort!=null&&effort.max_search_nodes!=null&&metrics.search_nodes_expanded>=effort.max_search_nodes);
|
|
864
|
+
const effortRemaining=()=>effort==null?Number.MAX_SAFE_INTEGER:Math.min(
|
|
865
|
+
effort.max_candidates_evaluated==null?Number.MAX_SAFE_INTEGER:effort.max_candidates_evaluated-metrics.feasible_candidates,
|
|
866
|
+
effort.max_placement_attempts==null?Number.MAX_SAFE_INTEGER:effort.max_placement_attempts-metrics.orientations_considered,
|
|
867
|
+
effort.max_search_nodes==null?Number.MAX_SAFE_INTEGER:effort.max_search_nodes-metrics.search_nodes_expanded,
|
|
868
|
+
);
|
|
869
|
+
// Admission runs before either solver path, not inside the general one.
|
|
870
|
+
// These checks used to live in the item-building loop below, which the compact lattice
|
|
871
|
+
// path returns before -- so a request that reached that path was admitted under weaker
|
|
872
|
+
// rules than every other request. A negative `Item.value` was accepted and ignored
|
|
873
|
+
// whenever a caller set `require_placement_coordinates: false`, which the acceptance
|
|
874
|
+
// rules forbid in as many words.
|
|
875
|
+
for(const raw of req.items??[])admitItem(raw,u);
|
|
876
|
+
// Admission for the rule set too, and for the same reason: a malformed rule reaching a
|
|
877
|
+
// solver path would be dropped there, and a request that packs in a way its own policy
|
|
878
|
+
// forbids is exactly what the contract exists to prevent.
|
|
879
|
+
const policyRules=parsePolicy(req.policy);
|
|
880
|
+
// A fast path skips the constraint checks below, so it is gated on there being no
|
|
881
|
+
// rule at all rather than on which fields a rule happens to read. Every rule form keys
|
|
882
|
+
// on item tags, which both fast paths already exclude -- but that is a property of
|
|
883
|
+
// today's forms, and a path allowed to reason about it is one that silently stops
|
|
884
|
+
// honouring a rule the day a form stops keying on tags.
|
|
885
|
+
// This path commits to one container type from a monotone billed-weight proxy
|
|
886
|
+
// with no alternative to compare against, unlike the general search below, which prices
|
|
887
|
+
// every candidate exactly via the same scoring this path's own proxy approximates. A
|
|
888
|
+
// rate table's bracket step or minimum charge can make that proxy disagree with the real
|
|
889
|
+
// price, so this path can settle on an unpriceable container over a priced one before
|
|
890
|
+
// pricing ever entered the decision -- the same reason it already stands down for a
|
|
891
|
+
// registered policy rule.
|
|
892
|
+
const compact=(policyRules.length||objective==='lowest_landed_cost')?null:compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortExceeded,effortRemaining,deadline});
|
|
893
|
+
if(compact!==null)return compact;
|
|
894
|
+
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);
|
|
895
|
+
for(let i=1;i<=(raw.quantity??1);i++)items.push({raw,d,w,rots,id:`${raw.id}#${i}`,
|
|
896
|
+
stackable:raw.stackable!==false,maxTop:raw.max_top_load==null?null:scalar(raw.max_top_load,'g',WT),
|
|
897
|
+
supportPpm:Math.round((raw.minimum_support_ratio??0)*SUPPORT_SCALE),priority:raw.priority??0,
|
|
898
|
+
tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null,
|
|
899
|
+
nesting,maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null,
|
|
900
|
+
stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],value:raw.value??0})}
|
|
901
|
+
// Priority is a preference, not a guarantee: it leads the ordering so a caller can
|
|
902
|
+
// bias the search, but ties (the default, priority 0 for all items) fall through to
|
|
903
|
+
// the volume key unchanged.
|
|
904
|
+
items.sort((a,b)=>{
|
|
905
|
+
const priority=b.priority-a.priority;if(priority)return priority;
|
|
906
|
+
// Under `maximum_value` the second objective key is the value left behind, so the
|
|
907
|
+
// most valuable item must get first refusal on the space. This single pass has no
|
|
908
|
+
// portfolio to select a better-scoring arrangement from, which makes the ordering
|
|
909
|
+
// the only place it can search for the objective it reports. Every value
|
|
910
|
+
// defaults to 0, so an unset-value request keeps the ordering below untouched.
|
|
911
|
+
if(objective==='maximum_value'){const value=b.value-a.value;if(value)return value}
|
|
912
|
+
// A route is unloaded last-in-first-out, so the later a stop, the earlier its items
|
|
913
|
+
// must be loaded to end up underneath. An item with no stop rides the whole route and
|
|
914
|
+
// loads with the last one. Every stop is Infinity when nothing declares one, so an
|
|
915
|
+
// unrouted request keeps the ordering below untouched.
|
|
916
|
+
{const stop=(b.stopIndex??Infinity)-(a.stopIndex??Infinity);if(stop)return stop}
|
|
917
|
+
if(qualityProfile&&(startIndex===null||startIndex===0))return Math.max(...a.d)-Math.max(...b.d)||Number(volume(a.d)-volume(b.d))||a.id.localeCompare(b.id);
|
|
918
|
+
if(qualityProfile&&startIndex===1)return Number(volume(a.d)-volume(b.d))||Math.max(...a.d)-Math.max(...b.d)||a.id.localeCompare(b.id);
|
|
919
|
+
if(solverAlias==='layer')return (b.d[2]-a.d[2])||(b.d[0]*b.d[1]-a.d[0]*a.d[1])||a.id.localeCompare(b.id);
|
|
920
|
+
if(solverAlias==='maximal_spaces')return (Math.max(...b.d)-Math.max(...a.d))||Number(volume(b.d)-volume(a.d))||a.id.localeCompare(b.id);
|
|
921
|
+
// `exact_small` deliberately has no ordering of its own. It used to sort by id, which
|
|
922
|
+
// was harmless while it was greedy-in-disguise and actively harmful once the search
|
|
923
|
+
// became real: smallest-first is the worst descent order, so the first branch failed to
|
|
924
|
+
// pack everything and the bound never pruned.
|
|
925
|
+
return Number(volume(b.d)-volume(a.d))||a.id.localeCompare(b.id);
|
|
926
|
+
});
|
|
927
|
+
// Start 0 is the ordering above, so a single-start request is byte-identical to what it
|
|
928
|
+
// produced before restarts existed; every later start re-solves a shuffle of it.
|
|
929
|
+
if(startIndex!==null&&startIndex>0&&(!qualityProfile||startIndex>2))items.splice(0,items.length,...seededOrder(items,req.configuration?.seed??42,startIndex));
|
|
930
|
+
const templates=req.containers.map(c=>{const d=dims(c.inner_dimensions,u),axleSpec=c.axles==null?null:c.axles.map(a=>({position:scalar(a.position,u,LEN),max:a.max_load==null?null:scalar(a.max_load,'g',WT)}));
|
|
931
|
+
if(axleSpec&&(axleSpec.length!==2||axleSpec[0].position>=axleSpec[1].position||axleSpec[0].position<0||axleSpec[1].position>d[0]))throw new RangeError('axles must be [front, rear] inside the container');
|
|
932
|
+
if(c.void_fill_reserve_ratio!=null&&(typeof c.void_fill_reserve_ratio!=='number'||!Number.isFinite(c.void_fill_reserve_ratio)))throw new TypeError('void_fill_reserve_ratio must be a finite number');
|
|
933
|
+
const reservePpm=Math.round((c.void_fill_reserve_ratio??0)*SUPPORT_SCALE);if(reservePpm<0||reservePpm>SUPPORT_SCALE)throw new RangeError('void_fill_reserve_ratio must be between 0 and 1');
|
|
934
|
+
if(c.tag_limits!=null&&(typeof c.tag_limits!=='object'||Array.isArray(c.tag_limits)||Object.values(c.tag_limits).some(limit=>!Number.isSafeInteger(limit)||limit<1)))throw new RangeError('tag_limits must map strings to positive safe integers');
|
|
935
|
+
const maxStackDensity=c.max_stack_density==null?null:scalar(c.max_stack_density,'g',WT);if(maxStackDensity!=null&&maxStackDensity<0)throw new RangeError('max_stack_density must be non-negative');
|
|
936
|
+
const outerD=c.outer_dimensions?dims(c.outer_dimensions,u):d;
|
|
937
|
+
// innerVolume/reserve are pure functions of the immutable template, hoisted out of
|
|
938
|
+
// candidatesFor's innermost (point x rotation) loop where they were recomputed as
|
|
939
|
+
// fresh BigInts per orientation.
|
|
940
|
+
return {...c,d,outerD,max:c.max_payload==null?null:scalar(c.max_payload,'g',WT),tare:scalar(c.tare_weight??0,'g',WT),axleSpec,reservePpm,
|
|
941
|
+
innerVolume:volume(d),reserve:volume(d)*BigInt(reservePpm)/BigInt(SUPPORT_SCALE),
|
|
942
|
+
rate:parseRateTable(c.rate_table),tagLimits:c.tag_limits??{},maxStackDensity,
|
|
943
|
+
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))));
|
|
944
|
+
const inventory=new Map(templates.map(c=>[c.id,c.quantity??Infinity])),remaining=[...items],packed=[];let seq=0;
|
|
945
|
+
const maxContainers=req.configuration?.max_containers??Infinity;
|
|
946
|
+
// Trial-packs `itemsRemaining` into one instance of `tmpl`, batching group members
|
|
947
|
+
// together so they land in one container or none of them do. Returns the resulting
|
|
948
|
+
// state (possibly with no placements, if nothing fit) and whatever did not fit.
|
|
949
|
+
// Metrics/timeLimitReached accumulate into the shared closures even for a trial
|
|
950
|
+
// that is ultimately discarded, matching Python's `_across_containers`/PHP's
|
|
951
|
+
// `acrossContainers`, which pay for the same per-template work regardless of which
|
|
952
|
+
// template wins.
|
|
953
|
+
// Group members travel together: one container takes all of them or none. Batching them
|
|
954
|
+
// here is what both the greedy pass and the exact search branch over.
|
|
955
|
+
const batchesOf=itemsRemaining=>{const batches=[],taken=new Set();
|
|
956
|
+
for(const item of itemsRemaining){if(taken.has(item))continue;
|
|
957
|
+
if(item.group===null){batches.push([item]);taken.add(item);continue}
|
|
958
|
+
const batch=itemsRemaining.filter(other=>other.group===item.group&&!taken.has(other));
|
|
959
|
+
batch.forEach(o=>taken.add(o));batches.push(batch)}
|
|
960
|
+
return batches};
|
|
961
|
+
// Every feasible (point, rotation) for `item`, ordered by the solver's own candidate
|
|
962
|
+
// score, best first. The greedy path asks for one and takes it; the exact search asks for
|
|
963
|
+
// all of them and branches on each, which is the only difference between the two
|
|
964
|
+
//. Ties keep insertion order, which is the sorted point order crossed with the
|
|
965
|
+
// item's declared rotations -- deterministic without a tiebreak key.
|
|
966
|
+
const candidatesFor=(tmpl,item,state,points,index,used,width)=>{
|
|
967
|
+
if(item.eligibleTags.length&&!item.eligibleTags.some(tag=>(tmpl.tags??[]).includes(tag)))return [];
|
|
968
|
+
if(item.tags.some(tag=>tmpl.tagLimits[tag]!=null&&state.placements.filter(p=>p.item.tags.includes(tag)).length>=tmpl.tagLimits[tag]))return [];
|
|
969
|
+
// Every rule form is a statement about this item, this container and what the container
|
|
970
|
+
// already holds -- none of them depends on where in the container the item would go, so
|
|
971
|
+
// the check belongs here beside the eligibility and tag-limit gates rather than inside
|
|
972
|
+
// the point loop, and costs O(m + r) per (template, item) instead of per candidate.
|
|
973
|
+
if(policyRules.length&&policyRejection(policyRules,item.tags,tmpl.tags??[],tagOccurrences(state.placements))!==null)return [];
|
|
974
|
+
const found=[];
|
|
975
|
+
const candidates=points.length>maxCandidatePoints?points.slice(0,maxCandidatePoints):points;
|
|
976
|
+
candidatePoints:for(const pt of candidates){if(candidateEffortExceeded())break;metrics.candidate_points_considered++;for(const r of item.rots){if(candidateEffortExceeded())break candidatePoints;metrics.orientations_considered++;if(deadline.expired()){timeLimitReached=true;break candidatePoints}const pd=rotate(item.d,r),ed=pd.map(x=>x+2*clear),box={x:pt[0],y:pt[1],z:pt[2],d:ed};
|
|
977
|
+
if(ed.some((x,k)=>pt[k]+x>tmpl.d[k]))continue;
|
|
978
|
+
if(tmpl.max!=null&&state.payload+item.w>tmpl.max)continue;
|
|
979
|
+
if(tmpl.max_items!=null&&state.placements.length>=tmpl.max_items)continue;
|
|
980
|
+
const tentative={x:pt[0],y:pt[1],z:pt[2],pd,ed,r,item};
|
|
981
|
+
if(used+usedVolumeDelta(state.placements,tentative)+tmpl.reserve>tmpl.innerVolume)continue;
|
|
982
|
+
let collision=false;
|
|
983
|
+
for(const obstacle of tmpl.obs){metrics.collision_checks++;if(intersects(box,obstacle)){collision=true;break}}
|
|
984
|
+
// Broad phase: visit only the placements sharing a cell with `box`, stamping each
|
|
985
|
+
// so a placement spanning several cells is narrow-phase-checked once. A generation
|
|
986
|
+
// counter does that without allocating a set per candidate orientation.
|
|
987
|
+
if(!collision){const [ix1,ix2,iy1,iy2,iz1,iz2]=cellRange(index,box),stamp=++index.gen,tentativeBox={x:pt[0],y:pt[1],z:pt[2],pd,ed,r,item};
|
|
988
|
+
scan:for(let ix=ix1;ix<ix2;ix++)for(let iy=iy1;iy<iy2;iy++)for(let iz=iz1;iz<iz2;iz++){
|
|
989
|
+
const bucket=index.cells.get(cellKey(ix,iy,iz));if(!bucket)continue;
|
|
990
|
+
for(const position of bucket){if(index.seen[position]===stamp)continue;index.seen[position]=stamp;
|
|
991
|
+
const placed=state.placements[position];metrics.collision_checks++;
|
|
992
|
+
if(intersects(box,{x:placed.x,y:placed.y,z:placed.z,d:placed.ed})&&!validNesting(tentativeBox,placed)){collision=true;break scan}}}}
|
|
993
|
+
if(collision)continue;
|
|
994
|
+
const candidate={x:pt[0],y:pt[1],z:pt[2],pd,ed,r,item};
|
|
995
|
+
if(axleOverloaded(tmpl,state.placements,candidate))continue;
|
|
996
|
+
if(!allowed(candidate,state.placements,tmpl,globalSupportPpm,metrics))continue;
|
|
997
|
+
metrics.feasible_candidates++;
|
|
998
|
+
const score=solverAlias==='grid'
|
|
999
|
+
?pt[2]*1e12+pt[1]*1e6+pt[0]
|
|
1000
|
+
:solverAlias==='layer'
|
|
1001
|
+
?(pt[2]+ed[2])*1e12+pt[2]*1e8+pt[1]*1e4+pt[0]
|
|
1002
|
+
:solverAlias==='maximal_spaces'
|
|
1003
|
+
?(pt[0]+ed[0])+(pt[1]+ed[1])+(pt[2]+ed[2])*1e6
|
|
1004
|
+
:(pt[2]+ed[2])*1e9+(pt[1]+ed[1])*1e4+pt[0]+ed[0];
|
|
1005
|
+
if(width===1){if(!found.length||score<found[0].score)found[0]={score,...candidate};continue}
|
|
1006
|
+
found.push({score,...candidate})}}
|
|
1007
|
+
if(width===1)return found;
|
|
1008
|
+
found.sort((a,b)=>a.score-b.score);
|
|
1009
|
+
return width==null?found:found.slice(0,width)
|
|
1010
|
+
};
|
|
1011
|
+
const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const state={tmpl,placements:[],payload:0};const next=[];
|
|
1012
|
+
// Running `usedVolume` of `state.placements`, maintained incrementally rather than
|
|
1013
|
+
// recomputed per candidate. Kept local to this call, not on `state`, so it
|
|
1014
|
+
// cannot leak into the packed container the caller spreads.
|
|
1015
|
+
let used=0n;
|
|
1016
|
+
const points=[[0,0,0],...tmpl.obs.flatMap(o=>[[o.x+o.d[0],o.y,o.z],[o.x,o.y+o.d[1],o.z],[o.x,o.y,o.z+o.d[2]]])].sort(comparePoints);
|
|
1017
|
+
const index=makeIndex(tmpl.d);
|
|
1018
|
+
for(const batch of batchesOf(itemsRemaining)){
|
|
1019
|
+
const snapshotPlacements=state.placements.slice(),snapshotPayload=state.payload,snapshotUsed=used;
|
|
1020
|
+
const snapshotPoints=batch.length>1?points.slice():null,snapshotIndex=batch.length>1?copyIndex(index):null;let ok=true;
|
|
1021
|
+
for(const item of batch){
|
|
1022
|
+
if(deadline.expired()){timeLimitReached=true;ok=false;break}
|
|
1023
|
+
if(effortExceeded()){ok=false;break}
|
|
1024
|
+
metrics.search_nodes_expanded++;
|
|
1025
|
+
const best=candidatesFor(tmpl,item,state,points,index,used,1)[0];
|
|
1026
|
+
if(!best){ok=false;break}
|
|
1027
|
+
state.payload+=item.w;used+=usedVolumeDelta(state.placements,best);state.placements.push(best);
|
|
1028
|
+
indexAdd(index,state.placements.length-1,{x:best.x,y:best.y,z:best.z,d:best.ed});
|
|
1029
|
+
retirePointsInside(points,{x:best.x,y:best.y,z:best.z,d:best.ed});
|
|
1030
|
+
for(const point of pointsFrom(best))insertPoint(points,point)}
|
|
1031
|
+
if(!ok){state.placements=snapshotPlacements;state.payload=snapshotPayload;used=snapshotUsed;
|
|
1032
|
+
if(snapshotPoints)points.splice(0,points.length,...snapshotPoints);
|
|
1033
|
+
if(snapshotIndex){index.cells=snapshotIndex.cells;index.seen=snapshotIndex.seen}next.push(...batch)}}
|
|
1034
|
+
return {state,next,used}
|
|
1035
|
+
};
|
|
1036
|
+
const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{
|
|
1037
|
+
const batches=batchesOf(itemsRemaining);
|
|
1038
|
+
const fresh=()=>({state:{tmpl,placements:[],payload:0},used:0n,
|
|
1039
|
+
points:[[0,0,0],...tmpl.obs.flatMap(o=>[[o.x+o.d[0],o.y,o.z],[o.x,o.y+o.d[1],o.z],[o.x,o.y,o.z+o.d[2]]])].sort(comparePoints),
|
|
1040
|
+
index:makeIndex(tmpl.d),unplaced:[]});
|
|
1041
|
+
const clone=node=>({state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used,
|
|
1042
|
+
points:node.points.slice(),index:copyIndex(node.index),unplaced:node.unplaced.slice()});
|
|
1043
|
+
const place=(node,candidate)=>{node.state.payload+=candidate.item.w;node.used+=usedVolumeDelta(node.state.placements,candidate);
|
|
1044
|
+
node.state.placements.push(candidate);indexAdd(node.index,node.state.placements.length-1,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed});
|
|
1045
|
+
retirePointsInside(node.points,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed});for(const point of pointsFrom(candidate))insertPoint(node.points,point)};
|
|
1046
|
+
const sortCosts=costs=>costs.sort((a,b)=>a<b?-1:a>b?1:0);
|
|
1047
|
+
const maxCount=(sortedCosts,capacity)=>{let used=0n,count=0;for(const cost of sortedCosts){if(used+cost>capacity)break;used+=cost;count++}return count};
|
|
1048
|
+
// `future` is the same array for every comparison inside one `expansions.sort(...)`
|
|
1049
|
+
// call, so its sorted volume/weight arrays are hoisted by the caller and
|
|
1050
|
+
// passed in here -- falls back to sorting on the fly for the trivial future=[] call
|
|
1051
|
+
// sites below, which never reach the sort's cost. Reused arrays are read-only:
|
|
1052
|
+
// `maxCount` no longer sorts in place.
|
|
1053
|
+
const lowerBound=(node,future,sortedVolumes=null,sortedWeights=null)=>{let possible=future.length;
|
|
1054
|
+
if(!future.some(item=>item.nesting!=null))possible=Math.min(possible,maxCount(sortedVolumes??sortCosts(future.map(item=>volume(item.d))),volume(tmpl.d)-node.used));
|
|
1055
|
+
if(tmpl.max!=null)possible=Math.min(possible,maxCount(sortedWeights??sortCosts(future.map(item=>BigInt(item.w))),BigInt(Math.max(0,tmpl.max-node.state.payload))));
|
|
1056
|
+
return node.unplaced.length+future.length-possible};
|
|
1057
|
+
const compareNode=(a,b,future=[],sortedVolumes=null,sortedWeights=null)=>{let difference=lowerBound(a,future,sortedVolumes,sortedWeights)-lowerBound(b,future,sortedVolumes,sortedWeights);if(difference)return difference;
|
|
1058
|
+
difference=a.unplaced.length-b.unplaced.length;if(difference)return difference;
|
|
1059
|
+
difference=b.state.placements.length-a.state.placements.length;if(difference)return difference;
|
|
1060
|
+
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);
|
|
1061
|
+
if(az!==bz)return az-bz;if(a.used!==b.used)return a.used>b.used?-1:1;
|
|
1062
|
+
const signature=node=>node.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|');return signature(a).localeCompare(signature(b))};
|
|
1063
|
+
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;
|
|
1064
|
+
for(let position=0;position<batches.length;position++){
|
|
1065
|
+
const batch=batches[position],future=batches.slice(position+1).flat(),expansions=[];let exhausted=false;
|
|
1066
|
+
for(const node of beam){if(nodes>=containerPlanNodeLimit||deadline.expired()||effortExceeded()){exhausted=true;break}nodes++;metrics.search_nodes_expanded++;
|
|
1067
|
+
const children=[];
|
|
1068
|
+
if(batch.length===1){for(const candidate of candidatesFor(tmpl,batch[0],node.state,node.points,node.index,node.used,maxCandidatesPerItem)){
|
|
1069
|
+
const child=clone(node);place(child,candidate);children.push(child)}}else{
|
|
1070
|
+
const child=clone(node);let accepted=true;for(const item of batch){const candidate=candidatesFor(tmpl,item,child.state,child.points,child.index,child.used,1)[0];if(!candidate){accepted=false;break}place(child,candidate)}if(accepted)children.push(child)}
|
|
1071
|
+
expansions.push(...children);const skipped=clone(node);skipped.unplaced.push(...batch);expansions.push(skipped)}
|
|
1072
|
+
// The incumbent is only ever compared and returned (state + unplaced); it never
|
|
1073
|
+
// re-enters the beam, so cloning the candidate points and spatial index for it
|
|
1074
|
+
// was pure allocation per expansion.
|
|
1075
|
+
for(const node of expansions){const complete={state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used,unplaced:[...node.unplaced,...future]};if(compareNode(complete,incumbent)<0)incumbent=complete}
|
|
1076
|
+
if(!expansions.length||exhausted)break;
|
|
1077
|
+
const futureVolumes=future.some(item=>item.nesting!=null)?null:sortCosts(future.map(item=>volume(item.d)));
|
|
1078
|
+
const futureWeights=tmpl.max!=null?sortCosts(future.map(item=>BigInt(item.w))):null;
|
|
1079
|
+
expansions.sort((a,b)=>compareNode(a,b,future,futureVolumes,futureWeights));beam=expansions.slice(0,containerPlanBeamWidth)}
|
|
1080
|
+
beam.sort((a,b)=>compareNode(a,b));if(beam.length&&compareNode(beam[0],incumbent)<0)incumbent=beam[0];
|
|
1081
|
+
return {state:incumbent.state,next:incumbent.unplaced}
|
|
1082
|
+
};
|
|
1083
|
+
// Depth-first branch and bound over the same group batches, mirroring Python's
|
|
1084
|
+
// `ExactSmallSolver` and PHP's: place a batch at one of its feasible candidates, or skip
|
|
1085
|
+
// it, and abandon any branch whose remaining items cannot beat the best packing already
|
|
1086
|
+
// found. This alias used to only reorder items and run the greedy pass above,
|
|
1087
|
+
// then label the answer `exact_small` -- on a three-item bin-packing instance that
|
|
1088
|
+
// left twice as many items behind as the three real engines while claiming to be exact.
|
|
1089
|
+
//
|
|
1090
|
+
// Exact only for the discrete candidate model and the item-count objective, which is why
|
|
1091
|
+
// the result still reports `best_found` rather than a global optimality claim, exactly as
|
|
1092
|
+
// the reference engines do. Bounded by `exact_item_limit`, already enforced at admission.
|
|
1093
|
+
const packExactIntoTemplate=(tmpl,itemsRemaining)=>{
|
|
1094
|
+
const batches=batchesOf(itemsRemaining);
|
|
1095
|
+
const freshWork=()=>({state:{tmpl,placements:[],payload:0},used:0n,
|
|
1096
|
+
points:[[0,0,0],...tmpl.obs.flatMap(o=>[[o.x+o.d[0],o.y,o.z],[o.x,o.y+o.d[1],o.z],[o.x,o.y,o.z+o.d[2]]])].sort(comparePoints),
|
|
1097
|
+
index:makeIndex(tmpl.d)});
|
|
1098
|
+
const cloneWork=w=>({state:{tmpl,placements:w.state.placements.slice(),payload:w.state.payload},
|
|
1099
|
+
used:w.used,points:w.points.slice(),index:copyIndex(w.index)});
|
|
1100
|
+
const placeInto=(w,candidate)=>{
|
|
1101
|
+
w.state.payload+=candidate.item.w;w.used+=usedVolumeDelta(w.state.placements,candidate);
|
|
1102
|
+
w.state.placements.push(candidate);
|
|
1103
|
+
indexAdd(w.index,w.state.placements.length-1,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed});
|
|
1104
|
+
retirePointsInside(w.points,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed});
|
|
1105
|
+
for(const point of pointsFrom(candidate))insertPoint(w.points,point)};
|
|
1106
|
+
// One child per feasible candidate for a lone item; a group is all-or-nothing, so it
|
|
1107
|
+
// contributes at most one child placed greedily member by member.
|
|
1108
|
+
const childrenOf=(w,batch)=>{
|
|
1109
|
+
if(batch.length===1)return candidatesFor(tmpl,batch[0],w.state,w.points,w.index,w.used,null)
|
|
1110
|
+
.map(candidate=>{const child=cloneWork(w);placeInto(child,candidate);return child});
|
|
1111
|
+
const child=cloneWork(w);
|
|
1112
|
+
for(const item of batch){
|
|
1113
|
+
const best=candidatesFor(tmpl,item,child.state,child.points,child.index,child.used,1)[0];
|
|
1114
|
+
if(!best)return [];
|
|
1115
|
+
placeInto(child,best)}
|
|
1116
|
+
return [child]};
|
|
1117
|
+
// Rank every incumbent by the same complete objective vector returned to the caller.
|
|
1118
|
+
// Equal-count branches can still improve cost, used volume, height, landed cost or
|
|
1119
|
+
// value, so item count alone is not a sufficient exact-search tie-break. Computing a
|
|
1120
|
+
// key is O(n^2) because `planScore` includes nesting-aware used volume; with n bounded
|
|
1121
|
+
// by `exact_item_limit`, this stays inside the solver's existing O(B^n) search bound.
|
|
1122
|
+
// Equal vectors deliberately keep the first incumbent. DFS order is deterministic and
|
|
1123
|
+
// the public contract ranks solutions by the objective vector, not by a private string
|
|
1124
|
+
// made from placement coordinates. This matches Rust and makes the admissible >= cut
|
|
1125
|
+
// below sound: an equal-score subtree cannot change the selected result.
|
|
1126
|
+
const rankedWork=work=>{const placed=new Set(work.state.placements.map(p=>p.item.id));
|
|
1127
|
+
const next=itemsRemaining.filter(item=>!placed.has(item.id));
|
|
1128
|
+
const packed=work.state.placements.length?[work.state]:[];
|
|
1129
|
+
return planScore({packed,remaining:next})};
|
|
1130
|
+
const profiles=new Map(itemsRemaining.map(item=>[item.id,{
|
|
1131
|
+
volume:volume(item.d),weight:item.w,
|
|
1132
|
+
minimumHeight:Math.min(...item.rots.map(rotation=>rotate(item.d,rotation)[2]+2*clear)),
|
|
1133
|
+
}]));
|
|
1134
|
+
const suffixItems=Array.from({length:batches.length+1},()=>[]);
|
|
1135
|
+
for(let index=batches.length-1;index>=0;index--)suffixItems[index]=[...batches[index],...suffixItems[index+1]];
|
|
1136
|
+
const ascendingBigInts=values=>values.sort((a,b)=>a<b?-1:a>b?1:0);
|
|
1137
|
+
// Lexicographic lower bound for every descendant of `work`. Volume and payload cap
|
|
1138
|
+
// how many suffix items can still be placed. If that count can tie the incumbent, the
|
|
1139
|
+
// largest possible used volume and the smallest possible stack top / billed weight
|
|
1140
|
+
// bound the remaining objective terms. Nesting disables the additive-volume terms.
|
|
1141
|
+
// Per node this is O(n log n) time and O(n) temporary space, strictly below the
|
|
1142
|
+
// existing O(n^2) canonical score computation and inside the exponential search tree.
|
|
1143
|
+
const optimisticCompletionScore=(work,future)=>{
|
|
1144
|
+
if(work.state.placements.length===0&&future.length===0)return [0,0,0,0,0];
|
|
1145
|
+
const containerVolume=volume(tmpl.d),nestingInvolved=work.state.placements.some(p=>p.item.nesting!=null)||future.some(item=>item.nesting!=null);
|
|
1146
|
+
const volumes=ascendingBigInts(future.map(item=>profiles.get(item.id).volume));
|
|
1147
|
+
let placeable=future.length,smallestVolumeSum=volumes.reduce((sum,value)=>sum+value,0n);
|
|
1148
|
+
if(!nestingInvolved&&containerVolume>0n){
|
|
1149
|
+
placeable=0;smallestVolumeSum=0n;
|
|
1150
|
+
for(const itemVolume of volumes){if(work.used+smallestVolumeSum+itemVolume>containerVolume)break;smallestVolumeSum+=itemVolume;placeable++}
|
|
1151
|
+
}
|
|
1152
|
+
if(tmpl.max!=null){
|
|
1153
|
+
const weights=future.map(item=>profiles.get(item.id).weight).sort((a,b)=>a-b);
|
|
1154
|
+
let count=0,total=0;const capacity=Math.max(0,tmpl.max-work.state.payload);
|
|
1155
|
+
for(const weight of weights){if(total+weight>capacity)break;total+=weight;count++}
|
|
1156
|
+
placeable=Math.min(placeable,count);
|
|
1157
|
+
}
|
|
1158
|
+
// Payload can tighten the count after the volume pass. Every equal-count floor
|
|
1159
|
+
// below must then use the smallest volumes for that final count; retaining the
|
|
1160
|
+
// earlier, longer prefix would overstate the necessary height and be inadmissible.
|
|
1161
|
+
smallestVolumeSum=volumes.slice(0,placeable).reduce((sum,value)=>sum+value,0n);
|
|
1162
|
+
const permanentlySkipped=itemsRemaining.length-work.state.placements.length-future.length;
|
|
1163
|
+
const unpackedFloor=permanentlySkipped+future.length-placeable;
|
|
1164
|
+
if(placeable===0&&work.state.placements.length===0)return [unpackedFloor,0,0,0,0];
|
|
1165
|
+
const largestVolumeSum=volumes.slice(Math.max(0,volumes.length-placeable)).reduce((sum,value)=>sum+value,0n);
|
|
1166
|
+
const usedCeiling=work.used+largestVolumeSum<(containerVolume>work.used?containerVolume:work.used)
|
|
1167
|
+
?work.used+largestVolumeSum:(containerVolume>work.used?containerVolume:work.used);
|
|
1168
|
+
const unused=containerVolume>0n?Number((containerVolume-usedCeiling)*1000000n/containerVolume):0;
|
|
1169
|
+
let minimumHeight=work.state.placements.reduce((top,p)=>Math.max(top,p.z+p.ed[2]),0);
|
|
1170
|
+
const footprint=BigInt(tmpl.d[0])*BigInt(tmpl.d[1]);
|
|
1171
|
+
if(!nestingInvolved&&footprint>0n){
|
|
1172
|
+
const volumeHeight=Number((work.used+smallestVolumeSum+footprint-1n)/footprint);
|
|
1173
|
+
minimumHeight=Math.max(minimumHeight,volumeHeight)
|
|
1174
|
+
}
|
|
1175
|
+
if(placeable===future.length)minimumHeight=Math.max(minimumHeight,...future.map(item=>profiles.get(item.id).minimumHeight));
|
|
1176
|
+
const height=tmpl.d[2]>0?Math.floor(minimumHeight*1000000/tmpl.d[2]):0;
|
|
1177
|
+
const weights=future.map(item=>profiles.get(item.id).weight).sort((a,b)=>a-b);
|
|
1178
|
+
const grossWeight=weights.slice(0,placeable).reduce((sum,value)=>sum+value,work.state.payload+tmpl.tare);
|
|
1179
|
+
const billable=objective==='shipping_cost'||objective==='lowest_landed_cost'?Math.max(grossWeight,dimensionalWeight(tmpl.outerD)):0;
|
|
1180
|
+
const landed=objective==='lowest_landed_cost'?addLanded(0,tmpl,billable):0;
|
|
1181
|
+
const cost=tmpl.cost_minor??0;
|
|
1182
|
+
if(objective==='lowest_cost')return [unpackedFloor,cost,1,unused,height];
|
|
1183
|
+
if(objective==='shipping_cost')return [unpackedFloor,billable,1,unused,height];
|
|
1184
|
+
if(objective==='lowest_landed_cost')return [unpackedFloor,landed,1,unused,height];
|
|
1185
|
+
if(objective==='open_dimension_height')return [unpackedFloor,minimumHeight,1,cost,unused];
|
|
1186
|
+
if(objective==='maximum_value')return [unpackedFloor,0,1,cost,unused];
|
|
1187
|
+
return [unpackedFloor,1,cost,unused,height]
|
|
1188
|
+
};
|
|
1189
|
+
let best=freshWork(),bestRank=rankedWork(best);
|
|
1190
|
+
const completeLowerBound=optimisticCompletionScore(freshWork(),suffixItems[0]);
|
|
1191
|
+
const dfs=(depth,work,reachable)=>{
|
|
1192
|
+
if(deadline.expired()){timeLimitReached=true;return}
|
|
1193
|
+
if(effortExceeded())return;
|
|
1194
|
+
metrics.search_nodes_expanded++;
|
|
1195
|
+
const unpackedHere=itemsRemaining.length-work.state.placements.length;
|
|
1196
|
+
if(unpackedHere<=bestRank[0]){const workRank=rankedWork(work);if(compareScore(workRank,bestRank)<0){best=work;bestRank=workRank}}
|
|
1197
|
+
if(compareScore(bestRank,completeLowerBound)===0)return;
|
|
1198
|
+
if(depth>=batches.length)return;
|
|
1199
|
+
if(work.state.placements.length+reachable<best.state.placements.length)return;
|
|
1200
|
+
if(compareScore(optimisticCompletionScore(work,suffixItems[depth]),bestRank)>=0)return;
|
|
1201
|
+
const batch=batches[depth],rest=reachable-batch.length;
|
|
1202
|
+
for(const child of childrenOf(work,batch)){
|
|
1203
|
+
dfs(depth+1,child,rest);
|
|
1204
|
+
if(compareScore(bestRank,completeLowerBound)===0)return;
|
|
1205
|
+
if(deadline.expired()){timeLimitReached=true;return}
|
|
1206
|
+
if(effortExceeded())return}
|
|
1207
|
+
dfs(depth+1,work,rest)};
|
|
1208
|
+
dfs(0,freshWork(),itemsRemaining.length);
|
|
1209
|
+
const placed=new Set(best.state.placements.map(p=>p.item.id));
|
|
1210
|
+
return {state:best.state,next:itemsRemaining.filter(i=>!placed.has(i.id))}
|
|
1211
|
+
};
|
|
1212
|
+
// Deterministic solid single-type block search. Candidate enumeration is
|
|
1213
|
+
// O(B*S*T*R*X*Y) time and O(S+n) space; the shared deadline/effort counters and
|
|
1214
|
+
// containerPlanNodeLimit bound it exactly as in Python, PHP and Rust.
|
|
1215
|
+
const homogeneousBlocksSupported=()=>policyRules.length===0&&templates.every(t=>!t.obs.length&&t.axleSpec==null
|
|
1216
|
+
&&Object.keys(t.tagLimits).length===0&&t.maxStackDensity==null&&t.reservePpm===0)
|
|
1217
|
+
&&items.every(i=>i.group==null&&!i.tags.length&&!i.incompatible.length&&!i.eligibleTags.length
|
|
1218
|
+
&&i.stackable&&!i.raw.must_be_on_floor&&i.maxTop==null&&i.maxStacked==null
|
|
1219
|
+
&&i.supportPpm===0&&(i.groundRule==null||i.groundRule==='free')&&i.nesting==null&&i.stopIndex==null);
|
|
1220
|
+
const compareBlockValue=(a,b)=>typeof a==='bigint'?(a<b?-1:a>b?1:0):typeof a==='string'?a.localeCompare(b):a-b;
|
|
1221
|
+
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};
|
|
1222
|
+
const containsSpace=(outer,inner)=>outer.x<=inner.x&&outer.y<=inner.y&&outer.z<=inner.z
|
|
1223
|
+
&&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];
|
|
1224
|
+
// `spaces` is containment-free (a single whole space or this function's own output),
|
|
1225
|
+
// so a survivor untouched by the carve can neither contain nor be contained by
|
|
1226
|
+
// another survivor, and a new slab cannot contain one either (the slab's own parent
|
|
1227
|
+
// could not); only new slabs need the dominance scan: O(new*s) instead of O(s^2).
|
|
1228
|
+
const subtractBlock=(spaces,box)=>{metrics.space_partitions+=spaces.length;const out=[],fresh=new Set();
|
|
1229
|
+
const push=(x,y,z,X,Y,Z)=>{if(X>x&&Y>y&&Z>z){const part={x,y,z,d:[X-x,Y-y,Z-z]};out.push(part);fresh.add(part)}};
|
|
1230
|
+
for(const space of spaces){const X=space.x+space.d[0],Y=space.y+space.d[1],Z=space.z+space.d[2],bX=box.x+box.d[0],bY=box.y+box.d[1],bZ=box.z+box.d[2];
|
|
1231
|
+
if(!intersects(space,box)){out.push(space);continue}
|
|
1232
|
+
push(space.x,space.y,space.z,Math.min(X,box.x),Y,Z);push(Math.max(space.x,bX),space.y,space.z,X,Y,Z);
|
|
1233
|
+
push(space.x,space.y,space.z,X,Math.min(Y,box.y),Z);push(space.x,Math.max(space.y,bY),space.z,X,Y,Z);
|
|
1234
|
+
push(space.x,space.y,space.z,X,Y,Math.min(Z,box.z));push(space.x,space.y,Math.max(space.z,bZ),X,Y,Z)}
|
|
1235
|
+
const unique=new Map(out.map(space=>[[space.x,space.y,space.z,...space.d].join(':'),space]));
|
|
1236
|
+
const kept=[...unique.values()].sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||(volume(a.d)>volume(b.d)?-1:1));
|
|
1237
|
+
let result=kept.filter((space,index)=>!fresh.has(space)||!kept.some((other,otherIndex)=>index!==otherIndex&&containsSpace(other,space)));
|
|
1238
|
+
if(result.length>256)result=result.sort((a,b)=>volume(a.d)>volume(b.d)?-1:volume(a.d)<volume(b.d)?1:a.z-b.z||a.y-b.y||a.x-b.x).slice(0,256).sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x);
|
|
1239
|
+
return result};
|
|
1240
|
+
const blockMode=(tmpl,itemsRemaining,volumeFirst)=>{let spaces=[{x:0,y:0,z:0,d:tmpl.d.slice()}],nodes=0,reached=false;
|
|
1241
|
+
const byType=new Map();for(const item of itemsRemaining){const values=byType.get(item.raw.id)??[];values.push(item);byType.set(item.raw.id,values)}
|
|
1242
|
+
const state={tmpl,placements:[],payload:0};
|
|
1243
|
+
while(spaces.length&&[...byType.values()].some(values=>values.length)){
|
|
1244
|
+
let best=null;
|
|
1245
|
+
spacesLoop:for(const space of spaces)for(const itemId of [...byType.keys()].sort()){
|
|
1246
|
+
const available=byType.get(itemId);if(!available.length)continue;const prototype=available[0];
|
|
1247
|
+
let capacity=available.length;if(tmpl.max_items!=null)capacity=Math.min(capacity,tmpl.max_items-state.placements.length);
|
|
1248
|
+
if(tmpl.max!=null&&prototype.w>0)capacity=Math.min(capacity,Math.floor(Math.max(0,tmpl.max-state.payload)/prototype.w));
|
|
1249
|
+
if(capacity<=0)continue;
|
|
1250
|
+
const seen=new Set();for(const rotation of prototype.rots){const pd=rotate(prototype.d,rotation),physicalKey=pd.join(':');if(seen.has(physicalKey))continue;seen.add(physicalKey);
|
|
1251
|
+
const ed=pd.map(edge=>edge+2*clear),maximumX=Math.floor(space.d[0]/ed[0]),maximumY=Math.floor(space.d[1]/ed[1]),maximumZ=Math.floor(space.d[2]/ed[2]);
|
|
1252
|
+
for(let nx=1;nx<=Math.min(maximumX,capacity);nx++)for(let ny=1;ny<=Math.min(maximumY,Math.floor(capacity/nx));ny++){
|
|
1253
|
+
if(nodes>=containerPlanNodeLimit||deadline.expired()||effortExceeded()){reached=true;break spacesLoop}
|
|
1254
|
+
nodes++;metrics.search_nodes_expanded++;const nz=Math.min(maximumZ,Math.floor(capacity/(nx*ny)));if(nz<=0)continue;
|
|
1255
|
+
const count=nx*ny*nz,used=BigInt(count)*volume(pd),fill=used*1000000n/volume(space.d);
|
|
1256
|
+
const lead=volumeFirst?[-used,-fill,-BigInt(count)]:[-BigInt(count),-fill,-used];
|
|
1257
|
+
const key=[...lead,space.z,space.y,space.x,itemId,rotation,nx,ny,nz],candidate={space,itemId,rotation,pd,ed,nx,ny,nz,count,key};
|
|
1258
|
+
if(best==null||compareBlockKey(key,best.key)<0)best=candidate}}}
|
|
1259
|
+
if(best==null||reached)break;
|
|
1260
|
+
const available=byType.get(best.itemId),chosen=available.splice(0,best.count);let index=0;
|
|
1261
|
+
for(let z=0;z<best.nz;z++)for(let y=0;y<best.ny;y++)for(let x=0;x<best.nx;x++){
|
|
1262
|
+
const placement={x:best.space.x+x*best.ed[0],y:best.space.y+y*best.ed[1],z:best.space.z+z*best.ed[2],pd:best.pd,ed:best.ed,r:best.rotation,item:chosen[index++]};
|
|
1263
|
+
state.placements.push(placement);state.payload+=placement.item.w;metrics.feasible_candidates++;metrics.orientations_considered++}
|
|
1264
|
+
spaces=subtractBlock(spaces,{x:best.space.x,y:best.space.y,z:best.space.z,d:[best.nx*best.ed[0],best.ny*best.ed[1],best.nz*best.ed[2]]})}
|
|
1265
|
+
const next=[...byType.keys()].sort().flatMap(key=>byType.get(key));return {state,next,reached}}
|
|
1266
|
+
const packBlocksIntoTemplate=(tmpl,itemsRemaining)=>{if(!homogeneousBlocksSupported())return tryPackIntoTemplate(tmpl,itemsRemaining);
|
|
1267
|
+
let best=null;for(const volumeFirst of [false,true]){if(deadline.expired()){timeLimitReached=true;break}const candidate=blockMode(tmpl,itemsRemaining,volumeFirst);if(candidate.reached&&deadline.expired())timeLimitReached=true;
|
|
1268
|
+
const used=usedVolume(candidate.state.placements),top=candidate.state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0),signature=candidate.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|'),key=[candidate.next.length,-used,top,signature];
|
|
1269
|
+
if(best==null||compareBlockKey(key,best.key)<0)best={...candidate,key}}
|
|
1270
|
+
return best??{state:{tmpl,placements:[],payload:0},next:itemsRemaining.slice()}}
|
|
1271
|
+
const packIntoTemplate=solverAlias==='exact_small'?packExactIntoTemplate:solverAlias==='homogeneous_blocks'?packBlocksIntoTemplate:containerPlanBeamWidth>1?packBeamIntoTemplate:tryPackIntoTemplate;
|
|
1272
|
+
const planScore=(plan,unpacked=plan.remaining)=>{let cost=0,unused=0,height=0,billable=0,landed=0,achieved=0;
|
|
1273
|
+
for(const state of plan.packed){cost+=state.tmpl.cost_minor??0;const inner=volume(state.tmpl.d),used=usedVolume(state.placements);unused+=Number((inner-used)*1000000n/inner);
|
|
1274
|
+
const top=state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0);height+=Number(BigInt(top)*1000000n/BigInt(state.tmpl.d[2]));achieved+=top;
|
|
1275
|
+
if(objective==='shipping_cost'||objective==='lowest_landed_cost'){
|
|
1276
|
+
const billed=Math.max(state.payload+state.tmpl.tare,dimensionalWeight(state.tmpl.outerD));
|
|
1277
|
+
if(objective==='shipping_cost')billable+=billed;else landed=addLanded(landed,state.tmpl,billed)}}
|
|
1278
|
+
const base=[unpacked.length,plan.packed.length,cost,unused,height];
|
|
1279
|
+
if(objective==='lowest_cost')return [base[0],base[2],base[1],base[3],base[4]];
|
|
1280
|
+
if(objective==='shipping_cost')return [base[0],billable,base[1],base[3],base[4]];
|
|
1281
|
+
if(objective==='lowest_landed_cost')return [base[0],landed,base[1],base[3],base[4]];
|
|
1282
|
+
if(objective==='open_dimension_height')return [base[0],achieved,base[1],base[2],base[3]];
|
|
1283
|
+
if(objective==='maximum_value')return [base[0],unpacked.reduce((sum,item)=>sum+(item.value??0),0),base[1],base[2],base[3]];
|
|
1284
|
+
return base};
|
|
1285
|
+
const additionalContainerBound=plan=>{const available=templates.filter(t=>plan.inventory.get(t.id)>0);if(!plan.remaining.length||!available.length)return 0;let lower=0;
|
|
1286
|
+
if(!plan.remaining.some(item=>item.nesting!=null)){const capacity=available.reduce((best,t)=>volume(t.d)>best?volume(t.d):best,0n),required=plan.remaining.reduce((sum,item)=>sum+volume(item.d),0n);if(capacity>0n)lower=Math.max(lower,Number((required+capacity-1n)/capacity))}
|
|
1287
|
+
if(available.every(t=>t.max!=null)){const capacity=Math.max(...available.map(t=>t.max)),required=plan.remaining.reduce((sum,item)=>sum+item.w,0);if(capacity>0)lower=Math.max(lower,Math.ceil(required/capacity))}return lower};
|
|
1288
|
+
const planBound=(plan)=>{const key=planScore(plan,[]),index=objective==='default'?1:2;key[index]+=additionalContainerBound(plan);return key};
|
|
1289
|
+
if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){
|
|
1290
|
+
const initial={packed:[],remaining:remaining.slice(),inventory:new Map(inventory),seq:0};let beam=[initial],incumbent=initial,planNodes=0;
|
|
1291
|
+
while(beam.length&&planNodes<containerPlanNodeLimit){const expansions=[];let exhausted=false;
|
|
1292
|
+
for(const plan of beam){if(!plan.remaining.length||plan.packed.length>=maxContainers){if(compareScore(planScore(plan),planScore(incumbent))<0)incumbent=plan;continue}
|
|
1293
|
+
for(const tmpl of templates){if(planNodes>=containerPlanNodeLimit)break;if(deadline.expired()||effortExceeded()){exhausted=true;break}if(plan.inventory.get(tmpl.id)<=0)continue;planNodes++;
|
|
1294
|
+
const trial=packIntoTemplate(tmpl,plan.remaining);if(!trial.state.placements.length)continue;const nextInventory=new Map(plan.inventory);nextInventory.set(tmpl.id,nextInventory.get(tmpl.id)-1);
|
|
1295
|
+
const child={packed:[...plan.packed,{...trial.state,seq:plan.seq+1}],remaining:trial.next,inventory:nextInventory,seq:plan.seq+1};expansions.push(child);if(compareScore(planScore(child),planScore(incumbent))<0)incumbent=child}
|
|
1296
|
+
if(exhausted)break}
|
|
1297
|
+
if(exhausted||!expansions.length)break;
|
|
1298
|
+
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('|')}`;
|
|
1299
|
+
const previous=dominant.get(signature);if(!previous||compareScore(planScore(plan,[]),planScore(previous,[]))<0)dominant.set(signature,plan)}
|
|
1300
|
+
beam=[...dominant.values()].sort((a,b)=>compareScore(planBound(a),planBound(b))||a.remaining.map(i=>i.id).join('|').localeCompare(b.remaining.map(i=>i.id).join('|'))).slice(0,containerPlanBeamWidth)}
|
|
1301
|
+
packed.push(...incumbent.packed);remaining.splice(0,remaining.length,...incumbent.remaining);seq=incumbent.seq;
|
|
1302
|
+
}else while(remaining.length&&packed.length<maxContainers){
|
|
1303
|
+
if(deadline.expired()){timeLimitReached=true;break}
|
|
1304
|
+
if(effortExceeded())break;
|
|
1305
|
+
const eligible=templates.filter(c=>inventory.get(c.id)>0&&remaining.some(i=>(!i.eligibleTags.length||i.eligibleTags.some(tag=>(c.tags??[]).includes(tag)))&&i.rots.some(r=>{const d=rotate(i.d,r).map(x=>x+2*clear);return d.every((x,k)=>x<=c.d[k])})));
|
|
1306
|
+
if(!eligible.length)break;
|
|
1307
|
+
// Evaluate every eligible template against the same `remaining` items
|
|
1308
|
+
// and commit to whichever placed the most, rather than committing up front to
|
|
1309
|
+
// the cheapest/smallest eligible template regardless of how few of the
|
|
1310
|
+
// remaining items it can actually hold -- ten single-item containers of the
|
|
1311
|
+
// cheapest template when one larger template would have held them all.
|
|
1312
|
+
let winner=null,winnerScore=null;
|
|
1313
|
+
for(const tmpl of eligible){
|
|
1314
|
+
if(deadline.expired()){timeLimitReached=true;break}
|
|
1315
|
+
const trial=packIntoTemplate(tmpl,remaining);
|
|
1316
|
+
if(!trial.state.placements.length)continue;
|
|
1317
|
+
let score,better;
|
|
1318
|
+
if(solverAlias==='exact_small'){
|
|
1319
|
+
score=planScore({packed:[trial.state],remaining:trial.next});
|
|
1320
|
+
const comparison=winnerScore==null?-1:compareScore(score,winnerScore);
|
|
1321
|
+
better=comparison<0||(comparison===0&&tmpl.id<winner.tmpl.id)
|
|
1322
|
+
}else{
|
|
1323
|
+
const used=usedVolume(trial.state.placements),innerVol=volume(tmpl.d);
|
|
1324
|
+
score=[-trial.state.placements.length,tmpl.cost_minor??0,Number(innerVol-used),tmpl.id];
|
|
1325
|
+
better=winnerScore==null||score[0]<winnerScore[0]
|
|
1326
|
+
||(score[0]===winnerScore[0]&&(score[1]<winnerScore[1]
|
|
1327
|
+
||(score[1]===winnerScore[1]&&(score[2]<winnerScore[2]
|
|
1328
|
+
||(score[2]===winnerScore[2]&&score[3]<winnerScore[3])))))
|
|
1329
|
+
}
|
|
1330
|
+
if(better){winner={tmpl,...trial};winnerScore=score}
|
|
1331
|
+
}
|
|
1332
|
+
if(!winner)break;
|
|
1333
|
+
inventory.set(winner.tmpl.id,inventory.get(winner.tmpl.id)-1);
|
|
1334
|
+
seq++;packed.push({...winner.state,seq});
|
|
1335
|
+
remaining.splice(0,remaining.length,...winner.next);
|
|
1336
|
+
}
|
|
1337
|
+
const containers=packed.map(c=>{const loads=topLoads(c.placements.map(constraintBox)),used=usedVolume(c.placements);
|
|
1338
|
+
const reaction=axleReactions(c.tmpl,c.placements);
|
|
1339
|
+
return {id:`${c.tmpl.id}#${c.seq}`,container_type:c.tmpl.id,inner_dimensions:outDims(c.tmpl.d,ou),outer_dimensions:outDims(c.tmpl.outer_dimensions?dims(c.tmpl.outer_dimensions,u):c.tmpl.d,ou),payload_weight:outWeight(c.payload,ow),gross_weight:outWeight(c.payload+c.tmpl.tare,ow),used_volume_ticks3:used.toString(),volume_utilization:(Number(used)/Number(volume(c.tmpl.d))).toFixed(6),centre_of_mass_offset_ppm:centreOfMassOffsetPpm(c.tmpl,c.placements,clear),...(reaction==null?{}:{axle_reactions:{basis:'gross',denominator:reaction.denominator.toString(),front_numerator:reaction.front.toString(),rear_numerator:reaction.rear.toString()}}),void_fill_reserve_ticks3:(volume(c.tmpl.d)*BigInt(c.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)).toString(),placements:c.placements.map((p,i)=>({item_id:p.item.id,item_type:p.item.raw.id,position:outPoint({x:p.x+clear,y:p.y+clear,z:p.z+clear},ou),dimensions:outDims(p.pd,ou),orientation:p.r,support_ratio:supportRatioOf(p,c.placements).toFixed(6),top_load:outWeight(loads[i],ow)}))}});
|
|
1340
|
+
const fitsWithRotations=(i,rots)=>templates.some(c=>rots.some(r=>{const d=rotate(i.d,r).map(x=>x+2*clear);return d.every((edge,k)=>edge<=c.d[k])}));
|
|
1341
|
+
const unpacked=remaining.map(i=>{
|
|
1342
|
+
// Same geometric check with every physical orientation allowed, not only the
|
|
1343
|
+
// item's own restricted set: distinguishes "genuinely too big in any rotation"
|
|
1344
|
+
// from "this exact rotation restriction, and only it, rules every container out"
|
|
1345
|
+
//. Both are pure geometry, so both are provable without a complete search.
|
|
1346
|
+
const fitsAnyRotation=fitsWithRotations(i,Object.keys(ROT)),dimensionFit=fitsWithRotations(i,i.rots),weightFit=templates.some(c=>c.max==null||i.w<=c.max);
|
|
1347
|
+
const eligibleFit=!i.eligibleTags.length||templates.some(c=>i.eligibleTags.some(tag=>(c.tags??[]).includes(tag)));
|
|
1348
|
+
// Ranked after the geometric proofs: an item too big for every container is impossible
|
|
1349
|
+
// whatever a policy says, and naming the policy first would send a caller to change the
|
|
1350
|
+
// wrong thing.
|
|
1351
|
+
const cited=policyRules.length?provesUnplaceable(policyRules,i.tags,templates):null;
|
|
1352
|
+
const reason=!fitsAnyRotation?'no_compatible_container_dimensions':!dimensionFit?'rotation_restricted':!weightFit?'payload_exceeded':!eligibleFit?'no_eligible_container':cited!==null?'policy_rule':timeLimitReached?'time_limit':effortExceeded()?'effort_limit':i.group!==null?'group_cannot_fit_together':'search_exhausted',details=reason==='policy_rule'?[cited]:[];
|
|
1353
|
+
return {item_id:i.id,item_type:i.raw.id,reason,details,proof:proofForReason(reason,details)}
|
|
1354
|
+
});
|
|
1355
|
+
// Canonical objective vector — see docs/OBJECTIVE.md. Five lexicographic keys,
|
|
1356
|
+
// ascending, lower is better. Ratios are floored per container in BigInt at
|
|
1357
|
+
// parts-per-million scale so Python, PHP, Rust and this fallback agree exactly.
|
|
1358
|
+
const SCORE_SCALE=1000000n;let scoreCost=0,scoreUnused=0,scoreHeight=0,scoreBillable=0,scoreLanded=0,scoreAchievedHeight=0;
|
|
1359
|
+
for(const c of packed){scoreCost+=c.tmpl.cost_minor??0;
|
|
1360
|
+
const inner=volume(c.tmpl.d),used=usedVolume(c.placements);
|
|
1361
|
+
if(inner>0n)scoreUnused+=Number((inner-used)*SCORE_SCALE/inner);
|
|
1362
|
+
const top=c.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0),innerHeight=BigInt(c.tmpl.d[2]);
|
|
1363
|
+
scoreAchievedHeight+=top;
|
|
1364
|
+
if(innerHeight>0n)scoreHeight+=Number(BigInt(top)*SCORE_SCALE/innerHeight);
|
|
1365
|
+
if(objective==='shipping_cost'||objective==='lowest_landed_cost'){
|
|
1366
|
+
const billed=Math.max(c.payload+c.tmpl.tare,dimensionalWeight(c.tmpl.outerD));
|
|
1367
|
+
if(objective==='shipping_cost')scoreBillable+=billed;else scoreLanded=addLanded(scoreLanded,c.tmpl,billed);}}
|
|
1368
|
+
const status=unpacked.length?(timeLimitReached?'time_limit':'best_found'):'feasible',complete=!unpacked.length,effortLimitReached=effortExceeded();
|
|
1369
|
+
const solverName=solverAlias?`${solverAlias}:javascript_fallback`:'javascript_fallback';
|
|
1370
|
+
const starts=[{id:solverName,started:true,completed:!timeLimitReached&&!effortLimitReached,truncated:timeLimitReached||effortLimitReached,selected:true,global_deadline_reached:timeLimitReached}],termination=aggregateTermination(starts);if(effortLimitReached&&!timeLimitReached)termination.code='effort_limit';
|
|
1371
|
+
const scoreValueForgone=remaining.reduce((sum,i)=>sum+(i.value??0),0);
|
|
1372
|
+
const defaultScore=[unpacked.length,containers.length,scoreCost,scoreUnused,scoreHeight],score=objective==='lowest_cost'?[defaultScore[0],defaultScore[2],defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='shipping_cost'?[defaultScore[0],scoreBillable,defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='lowest_landed_cost'?[defaultScore[0],scoreLanded,defaultScore[1],defaultScore[3],defaultScore[4]]:objective==='open_dimension_height'?[defaultScore[0],scoreAchievedHeight,defaultScore[1],defaultScore[2],defaultScore[3]]:objective==='maximum_value'?[defaultScore[0],scoreValueForgone,defaultScore[1],defaultScore[2],defaultScore[3]]:defaultScore;
|
|
1373
|
+
return {status,feasibility:{code:complete?'feasible':'unknown'},termination,optimality:{code:complete?'not_proven':'best_found'},complete,objective,algorithm:{profile:req.configuration?.solver_profile??'balanced',solver:solverName,duration_ms:0,seed:req.configuration?.seed??42,time_limit_reached:timeLimitReached,effort_limit_reached:effortLimitReached,candidates_evaluated:metrics.feasible_candidates,placements_attempted:metrics.orientations_considered,metrics},summary:{container_count:containers.length,packed_item_count:items.length-unpacked.length,unpacked_item_count:unpacked.length},score,containers,unpacked_items:unpacked,catalog_versions_used:catalogVersionsUsed(req.catalog_versions_used),warnings:['JavaScript fallback is active; build the Rust addon for the native portfolio'],alternatives:[]}}
|
|
1374
|
+
|
|
1375
|
+
function resultTicks(value,name){
|
|
1376
|
+
const ticks=value&&typeof value==='object'&&Number.isSafeInteger(value.ticks)?value.ticks:null;
|
|
1377
|
+
if(ticks==null)throw new TypeError(`${name} must contain exact integer ticks`);
|
|
1378
|
+
return ticks
|
|
1379
|
+
}
|
|
1380
|
+
function rebalanceContext(req,result){
|
|
1381
|
+
rejectUnsupported(req);
|
|
1382
|
+
const unit=req.units?.length??'mm',clear=scalar(req.configuration?.clearance??0,unit,LEN);
|
|
1383
|
+
const globalSupportPpm=Math.round((req.configuration?.minimum_support_ratio??0)*SUPPORT_SCALE);
|
|
1384
|
+
const items=new Map();
|
|
1385
|
+
for(const raw of req.items??[]){
|
|
1386
|
+
const d=dims(raw.dimensions,unit),w=scalar(raw.weight??0,'g',WT);
|
|
1387
|
+
for(let sequence=1;sequence<=(raw.quantity??1);sequence++)items.set(`${raw.id}#${sequence}`,{
|
|
1388
|
+
raw,d,w,id:`${raw.id}#${sequence}`,
|
|
1389
|
+
rots:raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)),
|
|
1390
|
+
stackable:raw.stackable!==false,maxTop:raw.max_top_load==null?null:scalar(raw.max_top_load,'g',WT),
|
|
1391
|
+
supportPpm:Math.round((raw.minimum_support_ratio??0)*SUPPORT_SCALE),
|
|
1392
|
+
tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null,
|
|
1393
|
+
nesting:raw.nesting_height==null?null:scalar(raw.nesting_height,unit,LEN),
|
|
1394
|
+
maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null,
|
|
1395
|
+
stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],
|
|
1396
|
+
})
|
|
1397
|
+
}
|
|
1398
|
+
const templates=new Map((req.containers??[]).map(raw=>{
|
|
1399
|
+
const d=dims(raw.inner_dimensions,unit);
|
|
1400
|
+
const axleSpec=raw.axles==null?null:raw.axles.map(axle=>({
|
|
1401
|
+
position:scalar(axle.position,unit,LEN),
|
|
1402
|
+
max:axle.max_load==null?null:scalar(axle.max_load,'g',WT),
|
|
1403
|
+
}));
|
|
1404
|
+
const template={...raw,d,outerD:raw.outer_dimensions?dims(raw.outer_dimensions,unit):d,
|
|
1405
|
+
max:raw.max_payload==null?null:scalar(raw.max_payload,'g',WT),
|
|
1406
|
+
tare:scalar(raw.tare_weight??0,'g',WT),axleSpec,
|
|
1407
|
+
reservePpm:Math.round((raw.void_fill_reserve_ratio??0)*SUPPORT_SCALE),
|
|
1408
|
+
tagLimits:raw.tag_limits??{},
|
|
1409
|
+
maxStackDensity:raw.max_stack_density==null?null:scalar(raw.max_stack_density,'g',WT),
|
|
1410
|
+
obs:(raw.obstacles??[]).flatMap(obstacle=>[obstacle,...(obstacle.additional_boxes??[])]).map(obstacle=>({
|
|
1411
|
+
x:scalar(obstacle.origin?.x??0,unit,LEN),y:scalar(obstacle.origin?.y??0,unit,LEN),
|
|
1412
|
+
z:scalar(obstacle.origin?.z??0,unit,LEN),d:dims(obstacle.dimensions,unit),
|
|
1413
|
+
})),
|
|
1414
|
+
};
|
|
1415
|
+
return [raw.id,template]
|
|
1416
|
+
}));
|
|
1417
|
+
const states=(result.containers??[]).map((container,index)=>{
|
|
1418
|
+
const template=templates.get(container.container_type);
|
|
1419
|
+
if(!template)throw new TypeError(`containers[${index}].container_type is not in the request`);
|
|
1420
|
+
return {publicContainer:container,tmpl:template,placements:(container.placements??[]).map((placement,pindex)=>{
|
|
1421
|
+
const item=items.get(placement.item_id);
|
|
1422
|
+
if(!item)throw new TypeError(`containers[${index}].placements[${pindex}].item_id is not in the request`);
|
|
1423
|
+
const position={
|
|
1424
|
+
x:resultTicks(placement.position?.x,`placements[${pindex}].position.x`),
|
|
1425
|
+
y:resultTicks(placement.position?.y,`placements[${pindex}].position.y`),
|
|
1426
|
+
z:resultTicks(placement.position?.z,`placements[${pindex}].position.z`),
|
|
1427
|
+
};
|
|
1428
|
+
const pd=[
|
|
1429
|
+
resultTicks(placement.dimensions?.length,`placements[${pindex}].dimensions.length`),
|
|
1430
|
+
resultTicks(placement.dimensions?.width,`placements[${pindex}].dimensions.width`),
|
|
1431
|
+
resultTicks(placement.dimensions?.height,`placements[${pindex}].dimensions.height`),
|
|
1432
|
+
];
|
|
1433
|
+
return {x:position.x-clear,y:position.y-clear,z:position.z-clear,pd,ed:pd.map(edge=>edge+2*clear),
|
|
1434
|
+
r:placement.orientation,item,publicPlacement:placement}
|
|
1435
|
+
})}
|
|
1436
|
+
});
|
|
1437
|
+
return {items,states,clear,globalSupportPpm,policyRules:parsePolicy(req.policy)}
|
|
1438
|
+
}
|
|
1439
|
+
function rebalanceValid(context,result){
|
|
1440
|
+
const expected=new Set(context.items.keys()),seen=new Set(),groups=new Map();
|
|
1441
|
+
for(const state of context.states){
|
|
1442
|
+
let payload=0;
|
|
1443
|
+
const tagCounts=new Map();
|
|
1444
|
+
for(let index=0;index<state.placements.length;index++){
|
|
1445
|
+
const placement=state.placements[index],id=placement.item.id;
|
|
1446
|
+
if(seen.has(id)||!expected.has(id))return false;seen.add(id);payload+=placement.item.w;
|
|
1447
|
+
if(!placement.item.rots.includes(placement.r))return false;
|
|
1448
|
+
if(placement.item.group!=null){
|
|
1449
|
+
const prior=groups.get(placement.item.group);
|
|
1450
|
+
if(prior!=null&&prior!==state.publicContainer.id)return false;
|
|
1451
|
+
groups.set(placement.item.group,state.publicContainer.id)
|
|
1452
|
+
}
|
|
1453
|
+
if(placement.item.eligibleTags.length&&!placement.item.eligibleTags.some(tag=>(state.tmpl.tags??[]).includes(tag)))return false;
|
|
1454
|
+
for(const tag of placement.item.tags)tagCounts.set(tag,(tagCounts.get(tag)??0)+1);
|
|
1455
|
+
const box={x:placement.x,y:placement.y,z:placement.z,d:placement.ed};
|
|
1456
|
+
if(box.x<0||box.y<0||box.z<0||box.x+box.d[0]>state.tmpl.d[0]||box.y+box.d[1]>state.tmpl.d[1]||box.z+box.d[2]>state.tmpl.d[2])return false;
|
|
1457
|
+
if(state.tmpl.obs.some(obstacle=>intersects(box,obstacle)))return false;
|
|
1458
|
+
for(const other of state.placements.slice(index+1)){
|
|
1459
|
+
if(intersects(box,{x:other.x,y:other.y,z:other.z,d:other.ed})&&!validNesting(placement,other))return false
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
if(state.tmpl.max!=null&&payload>state.tmpl.max)return false;
|
|
1463
|
+
if(state.tmpl.max_items!=null&&state.placements.length>state.tmpl.max_items)return false;
|
|
1464
|
+
for(const [tag,maximum] of Object.entries(state.tmpl.tagLimits))if((tagCounts.get(tag)??0)>maximum)return false;
|
|
1465
|
+
if(usedVolume(state.placements)+volume(state.tmpl.d)*BigInt(state.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)>volume(state.tmpl.d))return false;
|
|
1466
|
+
if(axleOverloaded(state.tmpl,state.placements))return false;
|
|
1467
|
+
const placed=[];
|
|
1468
|
+
for(const candidate of [...state.placements].sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||a.item.id.localeCompare(b.item.id))){
|
|
1469
|
+
if(!allowed(candidate,placed,state.tmpl,context.globalSupportPpm,{support_checks:0}))return false;
|
|
1470
|
+
// A move the rules forbid must fail the same check a placement did. Replaying the
|
|
1471
|
+
// container in this order is what makes a cap or a segregation answerable at all:
|
|
1472
|
+
// both are statements about what an item joins, so they need a partial container to
|
|
1473
|
+
// be asked about, and this loop already builds one.
|
|
1474
|
+
if(context.policyRules.length&&policyRejection(context.policyRules,candidate.item.tags,state.tmpl.tags??[],tagOccurrences(placed))!==null)return false;
|
|
1475
|
+
placed.push(candidate)
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
for(const unpacked of result.unpacked_items??[]){
|
|
1479
|
+
if(seen.has(unpacked.item_id)||!expected.has(unpacked.item_id))return false;
|
|
1480
|
+
seen.add(unpacked.item_id)
|
|
1481
|
+
}
|
|
1482
|
+
return seen.size===expected.size
|
|
1483
|
+
}
|
|
1484
|
+
function rebalanceCandidatePoints(state){
|
|
1485
|
+
const keys=new Set(['0,0,0']);
|
|
1486
|
+
for(const placement of state.placements){
|
|
1487
|
+
keys.add(`${placement.x+placement.ed[0]},${placement.y},${placement.z}`);
|
|
1488
|
+
keys.add(`${placement.x},${placement.y+placement.ed[1]},${placement.z}`);
|
|
1489
|
+
keys.add(`${placement.x},${placement.y},${placement.z+placement.ed[2]}`);
|
|
1490
|
+
if(placement.item.nesting!=null)keys.add(`${placement.x},${placement.y},${placement.z+placement.ed[2]-placement.item.nesting}`)
|
|
1491
|
+
}
|
|
1492
|
+
return [...keys].map(key=>key.split(',').map(Number)).sort((a,b)=>a[2]-b[2]||a[1]-b[1]||a[0]-b[0])
|
|
1493
|
+
}
|
|
1494
|
+
function publicRebalancedContainers(req,context){
|
|
1495
|
+
const outputLength=req.output?.length_unit??req.units?.length??'mm',outputWeight=req.output?.weight_unit??'g';
|
|
1496
|
+
return context.states.map(state=>{
|
|
1497
|
+
const loads=topLoads(state.placements.map(constraintBox));
|
|
1498
|
+
const payload=state.placements.reduce((total,placement)=>total+placement.item.w,0);
|
|
1499
|
+
const used=usedVolume(state.placements),reaction=axleReactions(state.tmpl,state.placements);
|
|
1500
|
+
const container={...state.publicContainer,payload_weight:outWeight(payload,outputWeight),
|
|
1501
|
+
gross_weight:outWeight(payload+state.tmpl.tare,outputWeight),used_volume_ticks3:used.toString(),
|
|
1502
|
+
volume_utilization:(Number(used)/Number(volume(state.tmpl.d))).toFixed(6),
|
|
1503
|
+
centre_of_mass_offset_ppm:centreOfMassOffsetPpm(state.tmpl,state.placements,context.clear),
|
|
1504
|
+
placements:state.placements.map((placement,index)=>({...placement.publicPlacement,
|
|
1505
|
+
position:outPoint({x:placement.x+context.clear,y:placement.y+context.clear,z:placement.z+context.clear},outputLength),
|
|
1506
|
+
dimensions:outDims(placement.pd,outputLength),orientation:placement.r,
|
|
1507
|
+
support_ratio:supportRatioOf(placement,state.placements).toFixed(6),
|
|
1508
|
+
top_load:outWeight(loads[index],outputWeight),
|
|
1509
|
+
})),
|
|
1510
|
+
};
|
|
1511
|
+
if(reaction==null)delete container.axle_reactions;
|
|
1512
|
+
else container.axle_reactions={basis:'gross',denominator:reaction.denominator.toString(),front_numerator:reaction.front.toString(),rear_numerator:reaction.rear.toString()};
|
|
1513
|
+
return container
|
|
1514
|
+
})
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Opt-in, independently checked payload rebalancing for an existing packing.
|
|
1518
|
+
* A complete trial scene is validated before each atomic relocation is committed.
|
|
1519
|
+
*/
|
|
1520
|
+
export function rebalanceWeight(req,result,{maxMoves=64}={}){
|
|
1521
|
+
if(!Number.isSafeInteger(maxMoves)||maxMoves<0)throw new RangeError('maxMoves must be a non-negative safe integer');
|
|
1522
|
+
const context=rebalanceContext(req,result),moves=[];
|
|
1523
|
+
if(!rebalanceValid(context,result))throw new TypeError('result is not a valid packing of this request');
|
|
1524
|
+
for(let moveNumber=0;moveNumber<maxMoves;moveNumber++){
|
|
1525
|
+
if(context.states.length<2)break;
|
|
1526
|
+
const weights=context.states.map(state=>state.placements.reduce((total,placement)=>total+placement.item.w,0));
|
|
1527
|
+
const spread=Math.max(...weights)-Math.min(...weights);if(spread<=0)break;
|
|
1528
|
+
const sourceIndex=weights.reduce((best,weight,index)=>weight>weights[best]?index:best,0);
|
|
1529
|
+
const placements=context.states[sourceIndex].placements.map((_,index)=>index)
|
|
1530
|
+
.sort((a,b)=>context.states[sourceIndex].placements[b].item.w-context.states[sourceIndex].placements[a].item.w);
|
|
1531
|
+
const destinations=context.states.map((_,index)=>index).filter(index=>index!==sourceIndex).sort((a,b)=>weights[a]-weights[b]);
|
|
1532
|
+
let committed=null;
|
|
1533
|
+
search:for(const placementIndex of placements){
|
|
1534
|
+
const moving=context.states[sourceIndex].placements[placementIndex],weight=moving.item.w;if(weight<=0)continue;
|
|
1535
|
+
for(const destinationIndex of destinations){
|
|
1536
|
+
const projected=[...weights];projected[sourceIndex]-=weight;projected[destinationIndex]+=weight;
|
|
1537
|
+
if(Math.max(...projected)-Math.min(...projected)>=spread)continue;
|
|
1538
|
+
for(const [x,y,z] of rebalanceCandidatePoints(context.states[destinationIndex])){
|
|
1539
|
+
const trial=cloneValue(context.states);
|
|
1540
|
+
const [relocated]=trial[sourceIndex].placements.splice(placementIndex,1);
|
|
1541
|
+
relocated.x=x;relocated.y=y;relocated.z=z;trial[destinationIndex].placements.push(relocated);
|
|
1542
|
+
const originalStates=context.states;context.states=trial;
|
|
1543
|
+
if(rebalanceValid(context,result)){
|
|
1544
|
+
committed={item_id:moving.item.id,from_container_id:originalStates[sourceIndex].publicContainer.id,to_container_id:originalStates[destinationIndex].publicContainer.id};
|
|
1545
|
+
break search
|
|
1546
|
+
}
|
|
1547
|
+
context.states=originalStates
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if(committed==null)break;
|
|
1552
|
+
moves.push(committed)
|
|
1553
|
+
}
|
|
1554
|
+
return {containers:publicRebalancedContainers(req,context),moves,improved:moves.length>0}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Loading and unloading are deliberately separate public graphs:
|
|
1558
|
+
// loading follows supporters from an empty scene, unloading follows children from the
|
|
1559
|
+
// complete scene. All coordinates are exact integer ticks within JS's safe range.
|
|
1560
|
+
export const ALL_DIRECTIONS=Object.freeze(['+x','-x','+y','-y','+z','-z']);
|
|
1561
|
+
export class InvalidDirectionError extends RangeError{
|
|
1562
|
+
constructor(direction){super(`unknown movement direction ${JSON.stringify(direction)}; expected one of ${ALL_DIRECTIONS.join(', ')}`);this.name='InvalidDirectionError';this.code='invalid_direction';this.direction=direction}
|
|
1563
|
+
}
|
|
1564
|
+
export class SequenceError extends Error{
|
|
1565
|
+
constructor(stuck){super(`no safe order exists: placements ${[...stuck].sort((a,b)=>a-b).join(', ')} are mutually blocking`);this.name='SequenceError';this.code='sequence_stuck';this.stuck=[...stuck].sort((a,b)=>a-b)}
|
|
1566
|
+
}
|
|
1567
|
+
export class SequenceReplayError extends Error{
|
|
1568
|
+
constructor(index,step,reason){super(`step ${step}: placement ${index} is not safe there (${reason})`);this.name='SequenceReplayError';this.code='sequence_replay';this.index=index;this.step=step;this.reason=reason}
|
|
1569
|
+
}
|
|
1570
|
+
export class SequenceWarning{
|
|
1571
|
+
constructor(code,index,messageKey,arguments_={}){this.code=code;this.index=index;this.message_key=messageKey;
|
|
1572
|
+
this.arguments=Object.fromEntries(Object.entries(arguments_).sort(([a],[b])=>a.localeCompare(b)));Object.freeze(this.arguments);Object.freeze(this)}
|
|
1573
|
+
toJSON(){return {code:this.code,index:this.index,message_key:this.message_key,arguments:this.arguments}}
|
|
1574
|
+
}
|
|
1575
|
+
function sequenceInteger(value,name){if(!Number.isSafeInteger(value))throw new RangeError(`${name} must be a safe integer tick count`);return value}
|
|
1576
|
+
function sequenceDimensions(raw,name='dimensions'){return {
|
|
1577
|
+
length:sequenceInteger(raw.length,`${name}.length`),width:sequenceInteger(raw.width,`${name}.width`),height:sequenceInteger(raw.height,`${name}.height`)}}
|
|
1578
|
+
function sequenceBox(raw,index){const origin=raw.origin??{x:raw.x,y:raw.y,z:raw.z},dimensions=raw.dimensions??{length:raw.length,width:raw.width,height:raw.height};
|
|
1579
|
+
const d=sequenceDimensions(dimensions,`boxes[${index}].dimensions`);
|
|
1580
|
+
return {x:sequenceInteger(origin.x,`boxes[${index}].origin.x`),y:sequenceInteger(origin.y,`boxes[${index}].origin.y`),z:sequenceInteger(origin.z,`boxes[${index}].origin.z`),d}}
|
|
1581
|
+
function sequenceInputs(boxes,container){if(!Array.isArray(boxes))throw new TypeError('boxes must be an array');return [boxes.map(sequenceBox),sequenceDimensions(container,'container')]}
|
|
1582
|
+
function validateDirections(directions){for(const direction of directions)if(!ALL_DIRECTIONS.includes(direction))throw new InvalidDirectionError(direction)}
|
|
1583
|
+
function overlapAreaXY(a,b){return Math.max(0,Math.min(a.x+a.d.length,b.x+b.d.length)-Math.max(a.x,b.x))*Math.max(0,Math.min(a.y+a.d.width,b.y+b.d.width)-Math.max(a.y,b.y))}
|
|
1584
|
+
function sequenceIntersects(a,b){return a.x<b.x+b.d.length&&a.x+a.d.length>b.x&&a.y<b.y+b.d.width&&a.y+a.d.width>b.y&&a.z<b.z+b.d.height&&a.z+a.d.height>b.z}
|
|
1585
|
+
function loadingDependencies(boxes){return boxes.map((upper,upperIndex)=>boxes.flatMap((lower,lowerIndex)=>
|
|
1586
|
+
lowerIndex!==upperIndex&&lower.z+lower.d.height===upper.z&&overlapAreaXY(lower,upper)>0?[lowerIndex]:[]))}
|
|
1587
|
+
function unloadingDependencies(boxes){const loading=loadingDependencies(boxes),result=boxes.map(()=>[]);loading.forEach((supporters,upper)=>supporters.forEach(lower=>result[lower].push(upper)));return result}
|
|
1588
|
+
function graphAcyclic(dependsOn){const visiting=new Set(),visited=new Set();function visit(node){if(visited.has(node))return true;if(visiting.has(node)||node<0||node>=dependsOn.length)return false;visiting.add(node);for(const dependency of dependsOn[node])if(!visit(dependency))return false;visiting.delete(node);visited.add(node);return true}return dependsOn.every((_,index)=>visit(index))}
|
|
1589
|
+
export class LoadingDependencyGraph{
|
|
1590
|
+
constructor(dependsOn){this.dependsOn=dependsOn.map(dependencies=>Object.freeze([...dependencies].sort((a,b)=>a-b)));Object.freeze(this.dependsOn)}
|
|
1591
|
+
static build(boxes){const [normalized]=sequenceInputs(boxes,{length:0,width:0,height:0});return new LoadingDependencyGraph(loadingDependencies(normalized))}
|
|
1592
|
+
isAcyclic(){return graphAcyclic(this.dependsOn)}
|
|
1593
|
+
}
|
|
1594
|
+
export class UnloadingDependencyGraph{
|
|
1595
|
+
constructor(dependsOn){this.dependsOn=dependsOn.map(dependencies=>Object.freeze([...dependencies].sort((a,b)=>a-b)));Object.freeze(this.dependsOn)}
|
|
1596
|
+
static build(boxes){const [normalized]=sequenceInputs(boxes,{length:0,width:0,height:0});return new UnloadingDependencyGraph(unloadingDependencies(normalized))}
|
|
1597
|
+
isAcyclic(){return graphAcyclic(this.dependsOn)}
|
|
1598
|
+
}
|
|
1599
|
+
function sweptVolume(box,container,direction){let x1=box.x,y1=box.y,z1=box.z,x2=box.x+box.d.length,y2=box.y+box.d.width,z2=box.z+box.d.height;
|
|
1600
|
+
if(direction==='+x'){x1=x2;x2=container.length}else if(direction==='-x'){x2=x1;x1=0}
|
|
1601
|
+
else if(direction==='+y'){y1=y2;y2=container.width}else if(direction==='-y'){y2=y1;y1=0}
|
|
1602
|
+
else if(direction==='+z'){z1=z2;z2=container.height}else if(direction==='-z'){z2=z1;z1=0}
|
|
1603
|
+
else throw new InvalidDirectionError(direction);return [x1,y1,z1,x2,y2,z2]}
|
|
1604
|
+
function clearDirection(index,boxes,present,container,directions){directionLoop:for(const direction of directions){const [x1,y1,z1,x2,y2,z2]=sweptVolume(boxes[index],container,direction);
|
|
1605
|
+
for(const otherIndex of present){if(otherIndex===index)continue;const other=boxes[otherIndex];if(x1<other.x+other.d.length&&other.x<x2&&y1<other.y+other.d.width&&other.y<y2&&z1<other.z+other.d.height&&other.z<z2)continue directionLoop}
|
|
1606
|
+
return direction}return null}
|
|
1607
|
+
function blockingIndices(index,boxes,present,container,direction){const [x1,y1,z1,x2,y2,z2]=sweptVolume(boxes[index],container,direction),blocked=[];
|
|
1608
|
+
for(const otherIndex of present){if(otherIndex===index)continue;const other=boxes[otherIndex];
|
|
1609
|
+
if(x1<other.x+other.d.length&&other.x<x2&&y1<other.y+other.d.width&&other.y<y2&&z1<other.z+other.d.height&&other.z<z2)blocked.push(otherIndex)}
|
|
1610
|
+
return blocked}
|
|
1611
|
+
function validatePermutation(boxes,order){const sorted=[...order].sort((a,b)=>a-b);if(sorted.length!==boxes.length||sorted.some((value,index)=>value!==index))throw new SequenceReplayError(-1,-1,'order is not a permutation of every placement index exactly once')}
|
|
1612
|
+
function validateSequenceBox(index,step,boxes,present,container){const box=boxes[index];if(box.x<0||box.y<0||box.z<0||box.x+box.d.length>container.length||box.y+box.d.width>container.width||box.z+box.d.height>container.height)throw new SequenceReplayError(index,step,'placement is outside the container');
|
|
1613
|
+
if([...present].some(other=>other!==index&&sequenceIntersects(box,boxes[other])))throw new SequenceReplayError(index,step,'placement collides with an already present placement')}
|
|
1614
|
+
function replayRemovalNormalized(boxes,container,order,directions){validatePermutation(boxes,order);const dependencies=unloadingDependencies(boxes),present=new Set(boxes.map((_,index)=>index));
|
|
1615
|
+
order.forEach((index,step)=>{validateSequenceBox(index,step,boxes,present,container);if(dependencies[index].some(dependency=>present.has(dependency)))throw new SequenceReplayError(index,step,'something still resting on it has not been removed yet');if(clearDirection(index,boxes,present,container,directions)==null)throw new SequenceReplayError(index,step,'no allowed direction is clear of the remaining placements');present.delete(index)})}
|
|
1616
|
+
function replayLoadingNormalized(boxes,container,order,directions){validatePermutation(boxes,order);const dependencies=loadingDependencies(boxes),present=new Set();
|
|
1617
|
+
order.forEach((index,step)=>{validateSequenceBox(index,step,boxes,present,container);if(dependencies[index].some(dependency=>!present.has(dependency)))throw new SequenceReplayError(index,step,'a supporter has not been loaded yet');if(clearDirection(index,boxes,present,container,directions)==null)throw new SequenceReplayError(index,step,'no allowed direction is clear of what has already been loaded');present.add(index)})}
|
|
1618
|
+
export function replayRemovalOrder(boxes,container,order,directions=ALL_DIRECTIONS){validateDirections(directions);const [normalized,dimensions]=sequenceInputs(boxes,container);replayRemovalNormalized(normalized,dimensions,order,directions)}
|
|
1619
|
+
export function replayLoadingOrder(boxes,container,order,directions=ALL_DIRECTIONS){validateDirections(directions);const [normalized,dimensions]=sequenceInputs(boxes,container);replayLoadingNormalized(normalized,dimensions,order,directions)}
|
|
1620
|
+
export function safeRemovalOrder(boxes,container,directions=ALL_DIRECTIONS){validateDirections(directions);const [normalized,dimensions]=sequenceInputs(boxes,container),dependencies=unloadingDependencies(normalized),present=new Set(normalized.map((_,index)=>index)),order=[];
|
|
1621
|
+
while(present.size){const chosen=[...present].sort((a,b)=>a-b).find(index=>dependencies[index].every(dependency=>!present.has(dependency))&&clearDirection(index,normalized,present,dimensions,directions)!=null);if(chosen==null)throw new SequenceError(present);order.push(chosen);present.delete(chosen)}
|
|
1622
|
+
replayRemovalNormalized(normalized,dimensions,order,directions);return order}
|
|
1623
|
+
export function safeLoadingOrder(boxes,container,directions=ALL_DIRECTIONS){const order=safeRemovalOrder(boxes,container,directions).reverse();const [normalized,dimensions]=sequenceInputs(boxes,container);replayLoadingNormalized(normalized,dimensions,order,directions);return order}
|
|
1624
|
+
export function safeLoadingOrderForPlacements(placements,container,directions=ALL_DIRECTIONS){const dimensions=container.dimensions??container;const order=safeLoadingOrder(placements,dimensions,directions);verifyLoadingPrefixBusinessRules(placements,order,container);return order}
|
|
1625
|
+
export function safeRemovalOrderWithEvidence(boxes,container,directions=ALL_DIRECTIONS){const order=safeRemovalOrder(boxes,container,directions),[normalized,dimensions]=sequenceInputs(boxes,container),dependencies=unloadingDependencies(normalized),present=new Set(normalized.map((_,index)=>index));
|
|
1626
|
+
return order.map(index=>{const step={index,direction:clearDirection(index,normalized,present,dimensions,directions),depends_on:[...dependencies[index]]};present.delete(index);return step})}
|
|
1627
|
+
export function safeLoadingOrderWithEvidence(boxes,container,directions=ALL_DIRECTIONS){const order=safeLoadingOrder(boxes,container,directions),[normalized,dimensions]=sequenceInputs(boxes,container),dependencies=loadingDependencies(normalized),present=new Set();
|
|
1628
|
+
return order.map(index=>{const step={index,direction:clearDirection(index,normalized,present,dimensions,directions),depends_on:[...dependencies[index]]};present.add(index);return step})}
|
|
1629
|
+
export function placementReachability(boxes,container,stops=null,directions=ALL_DIRECTIONS){
|
|
1630
|
+
validateDirections(directions);const [normalized,dimensions]=sequenceInputs(boxes,container);
|
|
1631
|
+
if(stops!=null&&(!Array.isArray(stops)||stops.length!==normalized.length))throw new RangeError('stops must contain exactly one entry per placement');
|
|
1632
|
+
const route=stops??normalized.map(()=>null),present=new Set(normalized.map((_,index)=>index)),dependencies=unloadingDependencies(normalized);
|
|
1633
|
+
const due=route.filter(stop=>stop!=null),earliest=due.length?Math.min(...due):null;
|
|
1634
|
+
return normalized.map((box,index)=>{
|
|
1635
|
+
const blockedBySupport=dependencies[index].filter(dependency=>present.has(dependency));
|
|
1636
|
+
const blockedByRoute=route[index]!=null&&earliest!=null&&route[index]!==earliest
|
|
1637
|
+
?[...present].filter(other=>other!==index&&route[other]!=null&&route[other]<route[index]):[];
|
|
1638
|
+
const clear=clearDirection(index,normalized,present,dimensions,directions);
|
|
1639
|
+
const blockedByNeighbors=clear==null&&directions.length
|
|
1640
|
+
?[...new Set(directions.flatMap(direction=>blockingIndices(index,normalized,present,dimensions,direction)))].sort((a,b)=>a-b):[];
|
|
1641
|
+
return {index,reachable:blockedBySupport.length===0&&blockedByRoute.length===0&&clear!=null,
|
|
1642
|
+
blocked_by_support:blockedBySupport,blocked_by_neighbors:blockedByNeighbors,blocked_by_route:blockedByRoute}
|
|
1643
|
+
})
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// Independently reuse the exact constraint calculations already proven for a
|
|
1647
|
+
// finished scene (overloaded/stackLimitsExceeded/stackDensityExceeded/groundContactAllowed,
|
|
1648
|
+
// the same functions the solve-time `allowed()` candidate check above uses) against every
|
|
1649
|
+
// loading *prefix*, not only the final state. Additive to replayLoadingOrder above rather
|
|
1650
|
+
// than a change to it, so every existing caller keeps working unmodified.
|
|
1651
|
+
//
|
|
1652
|
+
// `placements[i]` carries both geometry (`origin`/`dimensions`, the same shape
|
|
1653
|
+
// `safeLoadingOrder` accepts) and business-rule inputs (`weight`, `max_top_load`,
|
|
1654
|
+
// `max_stacked_items`, `stackable`, `ground_contact_rule`) -- request-schema field names,
|
|
1655
|
+
// since request-shaped objects are already this module's external contract style.
|
|
1656
|
+
//
|
|
1657
|
+
// Throws SequenceReplayError at the first step whose prefix violates a limit, pinned to
|
|
1658
|
+
// that step even though these rules only ever accumulate as loading proceeds (a violation
|
|
1659
|
+
// present at step k is also present in the final scene): identifying *which* addition
|
|
1660
|
+
// first broke a limit is strictly more useful than "the finished scene is invalid" alone.
|
|
1661
|
+
export function verifyLoadingPrefixBusinessRules(placements,order,container){
|
|
1662
|
+
validatePermutation(placements,order);
|
|
1663
|
+
const maxDensity=container.max_stack_density??null,present=[];
|
|
1664
|
+
order.forEach((index,step)=>{
|
|
1665
|
+
const raw=placements[index],origin=raw.origin??{x:raw.x,y:raw.y,z:raw.z};
|
|
1666
|
+
const dims=raw.dimensions??{length:raw.length,width:raw.width,height:raw.height};
|
|
1667
|
+
const d=[sequenceInteger(dims.length,`placements[${index}].dimensions.length`),
|
|
1668
|
+
sequenceInteger(dims.width,`placements[${index}].dimensions.width`),
|
|
1669
|
+
sequenceInteger(dims.height,`placements[${index}].dimensions.height`)];
|
|
1670
|
+
const box={x:sequenceInteger(origin.x,`placements[${index}].origin.x`),
|
|
1671
|
+
y:sequenceInteger(origin.y,`placements[${index}].origin.y`),
|
|
1672
|
+
z:sequenceInteger(origin.z,`placements[${index}].origin.z`),
|
|
1673
|
+
d,ed:d,w:raw.weight??0,maxTop:raw.max_top_load??null,maxStacked:raw.max_stacked_items??null,
|
|
1674
|
+
itemType:raw.item_type??null,nesting:raw.nesting_height==null?null:sequenceInteger(raw.nesting_height,`placements[${index}].nesting_height`),
|
|
1675
|
+
stackable:raw.stackable??true,item:{groundRule:raw.ground_contact_rule??null}};
|
|
1676
|
+
present.push(box);
|
|
1677
|
+
const needsLoads=maxDensity!=null||present.some(candidate=>candidate.maxTop!=null),graph=contactGraph(present),loads=needsLoads?topLoads(present,graph):null;
|
|
1678
|
+
if(overloaded(present,loads))throw new SequenceReplayError(index,step,'top_load_exceeded');
|
|
1679
|
+
if(stackLimitsExceeded(present,graph))throw new SequenceReplayError(index,step,'stacked_item_limit_exceeded');
|
|
1680
|
+
if(maxDensity!=null&&stackDensityExceeded(present,maxDensity,loads))throw new SequenceReplayError(index,step,'stack_density_exceeded');
|
|
1681
|
+
const children=supportChildren(present,graph);
|
|
1682
|
+
if(present.some((b,i)=>b.stackable===false&&children[i].length>0))throw new SequenceReplayError(index,step,'non_stackable_item_has_load');
|
|
1683
|
+
if(!groundContactAllowed(box,present.slice(0,-1)))throw new SequenceReplayError(index,step,'ground_contact_violation')
|
|
1684
|
+
})
|
|
1685
|
+
}
|