@packvium/engine 0.1.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/SECURITY.md +2 -2
- package/contact-graph.js +143 -29
- package/examples/constraints.mjs +97 -0
- package/examples/shapes.mjs +155 -0
- package/examples/units.mjs +115 -0
- package/fallback.js +925 -59
- package/index.js +52 -12
- package/package.json +4 -4
package/fallback.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { buildContactGraph } from './contact-graph.js';
|
|
1
|
+
import { appendContactBox, buildContactGraph } from './contact-graph.js';
|
|
2
|
+
import { compareCodePoints } from './commerce-model.js';
|
|
2
3
|
import { parsePolicy, policyRejection, provesUnplaceable, tagOccurrences } from './policy.js';
|
|
3
4
|
|
|
4
5
|
const LEN={mm:16000,cm:160000,m:16000000,in:406400,inch:406400,inches:406400,ft:4876800,tick:1,ticks:1};
|
|
@@ -20,9 +21,25 @@ const UNSUPPORTED={
|
|
|
20
21
|
// container, which none of the per-entry loops below would ever see.
|
|
21
22
|
request:[],
|
|
22
23
|
configuration:[],
|
|
24
|
+
// `hull_vertices`, `compression_ratio` and `max_compression_pressure_kpa` left this list
|
|
25
|
+
// in, the last engine to gain both the solver behaviour and the independent
|
|
26
|
+
// validation the staged rollout requires.
|
|
23
27
|
item:[],
|
|
24
|
-
|
|
28
|
+
// `pallet_overhang_limit` was reserved in the schema by at the 1.1.0 contract
|
|
29
|
+
// freeze and is refused everywhere until an engine implements it from a request: a field
|
|
30
|
+
// a caller can set and the solver ignores is worse than a refusal.
|
|
31
|
+
// `access_directions` left this list in, which wired the reserved field through
|
|
32
|
+
// to the stop-accessibility rule in all four engines at once.
|
|
33
|
+
container:['pallet_overhang_limit'],
|
|
25
34
|
obstacle:[],
|
|
35
|
+
// `item.shape_type` values this engine does not implement. Presence is the
|
|
36
|
+
// wrong test for this one field: `rigid_cuboid` is the default and is implemented, so a
|
|
37
|
+
// caller that spells the default out must be served, not refused. What is unimplemented
|
|
38
|
+
// is a *value*, and the refusal names it -- packing a `convex_hull` item as its bounding
|
|
39
|
+
// box would return a plan that looks valid and does not physically fit.
|
|
40
|
+
// Empty since: this engine implements every value the schema defines. The guard
|
|
41
|
+
// stays because the next reserved value will need it.
|
|
42
|
+
shapeType:[],
|
|
26
43
|
};
|
|
27
44
|
// The admission boundary for staged public-field rollouts, exported so a test can assert
|
|
28
45
|
// that what the lists name is exactly what the guard refuses -- the counterpart of
|
|
@@ -91,6 +108,8 @@ function rejectUnsupported(req){const fields=[];
|
|
|
91
108
|
for(const key of UNSUPPORTED.container)if(hasOwn(raw,key))fields.push(`container.${key}`);
|
|
92
109
|
for(const obstacle of raw.obstacles??[])for(const key of UNSUPPORTED.obstacle)if(hasOwn(obstacle,key))fields.push(`obstacle.${key}`);
|
|
93
110
|
}
|
|
111
|
+
for(const raw of req.items??[]){const shape=raw?.shape_type;
|
|
112
|
+
if(typeof shape==='string'&&UNSUPPORTED.shapeType.includes(shape))fields.push(`item.shape_type=${shape}`)}
|
|
94
113
|
if(fields.length)throw new UnsupportedFeatureError([...new Set(fields)].sort());
|
|
95
114
|
}
|
|
96
115
|
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]}
|
|
@@ -100,10 +119,558 @@ function dims(v,u){return [scalar(v.length,u,LEN),scalar(v.width,u,LEN),scalar(v
|
|
|
100
119
|
function rotate(d,r){return ROT[r].map(i=>d[i])}
|
|
101
120
|
function volume(d){return BigInt(d[0])*BigInt(d[1])*BigInt(d[2])}
|
|
102
121
|
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}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------- irregular geometry
|
|
124
|
+
//
|
|
125
|
+
// The rule is fixed by docs/IRREGULAR-ITEMS.md. Every product here is a `BigInt`, and not for
|
|
126
|
+
// tidiness: a separating axis is a cross product of two edge vectors, so its components grow
|
|
127
|
+
// as the square of a coordinate and a projection grows as the cube. At the shared coordinate
|
|
128
|
+
// cap a cross product reaches 8e16 and a projection 2.4e25, while a JavaScript number is exact
|
|
129
|
+
// only to 2^53 ~ 9e15. Both would silently lose precision, and a collision predicate that
|
|
130
|
+
// rounds returns a plan that validates and does not fit. Rust carries the same arithmetic in
|
|
131
|
+
// `i128`; PHP needs a decimal-string fallback; here `BigInt` is already the house answer, used
|
|
132
|
+
// for load distribution since the first port.
|
|
133
|
+
|
|
134
|
+
/** Largest vertex coordinate a hull may carry, in ticks -- 6.25 m. Shared with every engine:
|
|
135
|
+
* they must refuse the same hulls or they disagree about which requests are legal. */
|
|
136
|
+
const MAX_HULL_COORDINATE=100000000;
|
|
137
|
+
const UNIT_AXES=[[1n,0n,0n],[0n,1n,0n],[0n,0n,1n]];
|
|
138
|
+
const sub3=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]];
|
|
139
|
+
const cross3=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
|
|
140
|
+
const dot3=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
|
|
141
|
+
const big3=v=>[BigInt(v[0]),BigInt(v[1]),BigInt(v[2])];
|
|
142
|
+
function bigGcd(a,b){a=a<0n?-a:a;b=b<0n?-b:b;while(b){const t=a%b;a=b;b=t}return a}
|
|
143
|
+
/** Divide out the gcd and fix the sign, so parallel axes collapse to one entry. `null` for the
|
|
144
|
+
* zero vector: a cross product of parallel directions names no axis, an ordinary outcome. */
|
|
145
|
+
function primitiveAxis(v){
|
|
146
|
+
const g=bigGcd(bigGcd(v[0],v[1]),v[2]);
|
|
147
|
+
if(g===0n)return null;
|
|
148
|
+
const r=[v[0]/g,v[1]/g,v[2]/g];
|
|
149
|
+
const lead=r.find(x=>x!==0n);
|
|
150
|
+
return lead>0n?r:[-r[0],-r[1],-r[2]];
|
|
151
|
+
}
|
|
152
|
+
const axisKey=v=>`${v[0]},${v[1]},${v[2]}`;
|
|
153
|
+
/** Lexicographic order on the vertex vector itself. `axisKey` is for identity, never order. */
|
|
154
|
+
const compareVertices=(l,r)=>{
|
|
155
|
+
for(let i=0;i<3;i++)if(l[i]!==r[i])return l[i]<r[i]?-1:1;
|
|
156
|
+
return 0;
|
|
157
|
+
};
|
|
158
|
+
/** Canonicalise an authored vertex list or refuse a hull with no interior. A zero-volume hull
|
|
159
|
+
* is separated from everything on its own normal, so it would pass through every other item
|
|
160
|
+
* and still be reported as a valid placement. */
|
|
161
|
+
function hullValidate(vertices){
|
|
162
|
+
const points=vertices.map(v=>[Number(v[0]),Number(v[1]),Number(v[2])]);
|
|
163
|
+
if(points.length<4)throw new RangeError(`a convex hull needs at least 4 vertices, got ${points.length}`);
|
|
164
|
+
if(new Set(points.map(axisKey)).size!==points.length)throw new RangeError('convex hull vertices must be unique');
|
|
165
|
+
if(points.some(v=>v.some(c=>Math.abs(c)>MAX_HULL_COORDINATE)))
|
|
166
|
+
throw new RangeError(`convex hull coordinates must stay within ${MAX_HULL_COORDINATE} ticks`);
|
|
167
|
+
const b=points.map(big3);
|
|
168
|
+
for(let i=0;i<b.length;i++)for(let j=i+1;j<b.length;j++)for(let k=j+1;k<b.length;k++)for(let l=k+1;l<b.length;l++)
|
|
169
|
+
if(dot3(sub3(b[l],b[i]),cross3(sub3(b[j],b[i]),sub3(b[k],b[i])))!==0n)return points;
|
|
170
|
+
throw new RangeError('convex hull vertices are coplanar and enclose no volume');
|
|
171
|
+
}
|
|
172
|
+
/** Does the plane through `origin` with normal `axis` leave every vertex on one side? */
|
|
173
|
+
function isSupporting(points,origin,axis){
|
|
174
|
+
const offset=dot3(origin,axis);let above=false,below=false;
|
|
175
|
+
for(const v of points){const side=dot3(v,axis)-offset;
|
|
176
|
+
if(side>0n)above=true;else if(side<0n)below=true;
|
|
177
|
+
if(above&&below)return false}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
/** Corners of one planar convex face, in cyclic order seen from outside.
|
|
181
|
+
*
|
|
182
|
+
* The vertices sharing a supporting plane are not all corners of the polygon they lie on: one
|
|
183
|
+
* can sit inside the face or part-way along an edge, and fanning over the raw set triangulates
|
|
184
|
+
* the wrong region -- the surface then fails to close and the volume is wrong. Gift-wrapping
|
|
185
|
+
* keeps only the corners, resolving collinear candidates to the farthest so an edge-interior
|
|
186
|
+
* vertex is walked past rather than doubled back through. */
|
|
187
|
+
function windFace(face,outward){
|
|
188
|
+
// By the vertex vector, never its decimal encoding: the walk is only correct because it
|
|
189
|
+
// starts from a corner, which it earns by starting from the smallest vertex under a genuine
|
|
190
|
+
// linear order. String order is not one -- "10,0,0" sorts before "9,0,0" -- so it can name a
|
|
191
|
+
// vertex lying inside the face and make the walk emit a segment that is not a hull edge.
|
|
192
|
+
const sorted=[...face].sort(compareVertices);
|
|
193
|
+
const ordered=[sorted[0]];let current=sorted[0];
|
|
194
|
+
for(let step=0;step<sorted.length;step++){
|
|
195
|
+
let next=null;
|
|
196
|
+
for(const candidate of sorted){
|
|
197
|
+
if(axisKey(candidate)===axisKey(current))continue;
|
|
198
|
+
if(next===null){next=candidate;continue}
|
|
199
|
+
const turn=dot3(cross3(sub3(next,current),sub3(candidate,current)),outward);
|
|
200
|
+
const reach=dot3(sub3(candidate,current),sub3(candidate,current));
|
|
201
|
+
const held=dot3(sub3(next,current),sub3(next,current));
|
|
202
|
+
if(turn<0n||(turn===0n&&reach>held))next=candidate;
|
|
203
|
+
}
|
|
204
|
+
if(next===null||axisKey(next)===axisKey(sorted[0]))break;
|
|
205
|
+
ordered.push(next);current=next;
|
|
206
|
+
}
|
|
207
|
+
return ordered;
|
|
208
|
+
}
|
|
209
|
+
/** Every face of the hull, each as its own corners in outward cyclic order.
|
|
210
|
+
*
|
|
211
|
+
* One walk, because the faces answer two questions at once: the volume needs them wound
|
|
212
|
+
* consistently, and the hull's edges are the consecutive corner pairs of the same walk. A
|
|
213
|
+
* plane carrying fewer than three vertices is an edge or a corner of the hull, not a face,
|
|
214
|
+
* and carries no edge its two adjoining faces do not already carry. */
|
|
215
|
+
function woundFaces(points,faceAxes){
|
|
216
|
+
const faces=[];
|
|
217
|
+
for(const axis of faceAxes)for(const outward of [axis,[-axis[0],-axis[1],-axis[2]]]){
|
|
218
|
+
let extreme=null;
|
|
219
|
+
for(const v of points){const value=dot3(v,outward);if(extreme===null||value>extreme)extreme=value}
|
|
220
|
+
const face=points.filter(v=>dot3(v,outward)===extreme);
|
|
221
|
+
if(face.length<3)continue;
|
|
222
|
+
faces.push(windFace(face,outward));
|
|
223
|
+
}
|
|
224
|
+
return faces;
|
|
225
|
+
}
|
|
226
|
+
/** Exact volume in cubic ticks, by the divergence theorem over the hull's own faces. */
|
|
227
|
+
function hullVolume(faces){
|
|
228
|
+
let six=0n;
|
|
229
|
+
for(const ordered of faces){
|
|
230
|
+
const apex=ordered[0];
|
|
231
|
+
for(let i=1;i+1<ordered.length;i++)six+=dot3(apex,cross3(ordered[i],ordered[i+1]));
|
|
232
|
+
}
|
|
233
|
+
const magnitude=six<0n?-six:six;
|
|
234
|
+
return magnitude/6n;
|
|
235
|
+
}
|
|
236
|
+
/** Directions of the hull's real edges, deduplicated and canonical.
|
|
237
|
+
*
|
|
238
|
+
* Every edge of a convex polyhedron is shared by exactly two faces, so walking each wound
|
|
239
|
+
* face and taking its consecutive corner pairs -- closing the cycle -- reaches all of them.
|
|
240
|
+
* The separating-axis theorem asks for exactly these, not for every vertex pair.
|
|
241
|
+
*
|
|
242
|
+
* The distinction is the whole cost of the predicate. A hull has at most `3v - 6` edges but
|
|
243
|
+
* `v(v - 1) / 2` vertex pairs, and the axis set is the *product* of two hulls' sets, so the
|
|
244
|
+
* gap squares: on a 20-vertex hull, 1351 candidate axes rather than 15616. Vertex pairs were
|
|
245
|
+
* never wrong, only a superset -- a pair that is not an edge names a direction no face can
|
|
246
|
+
* separate along, so it can add an axis but never remove one. */
|
|
247
|
+
function hullEdges(faces){
|
|
248
|
+
const edges=new Map();
|
|
249
|
+
for(const ordered of faces)for(let i=0;i<ordered.length;i++){
|
|
250
|
+
const axis=primitiveAxis(sub3(ordered[(i+1)%ordered.length],ordered[i]));
|
|
251
|
+
if(axis)edges.set(axisKey(axis),axis);
|
|
252
|
+
}
|
|
253
|
+
return [...edges.values()].sort(compareVertices);
|
|
254
|
+
}
|
|
255
|
+
/** A hull's separating axes and volume in its own local frame, computed once per shape. */
|
|
256
|
+
function hullShape(vertices){
|
|
257
|
+
const points=hullValidate(vertices).map(big3);
|
|
258
|
+
const faces=new Map();
|
|
259
|
+
for(let i=0;i<points.length;i++)for(let j=i+1;j<points.length;j++){
|
|
260
|
+
for(let k=j+1;k<points.length;k++){
|
|
261
|
+
// A triple whose plane cuts through the solid is not a face, and its normal separates
|
|
262
|
+
// nothing the real face normals do not.
|
|
263
|
+
const axis=primitiveAxis(cross3(sub3(points[j],points[i]),sub3(points[k],points[i])));
|
|
264
|
+
if(axis&&isSupporting(points,points[i],axis))faces.set(axisKey(axis),axis);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const faceAxes=[...faces.values()].sort(compareVertices);
|
|
268
|
+
const wound=woundFaces(points,faceAxes);
|
|
269
|
+
return {v:points,faces:faceAxes,edges:hullEdges(wound),volume:hullVolume(wound)};
|
|
270
|
+
}
|
|
271
|
+
/** A copied, string-exact view used only by this module's direct tests.
|
|
272
|
+
*
|
|
273
|
+
* `fallback.js` is an internal package file: package.json exposes only `index.js`, which does
|
|
274
|
+
* not re-export this function. Keeping the probe beside the algorithm lets the suite assert
|
|
275
|
+
* edge identity and ordering without widening `@packvium/native`'s public API or handing a
|
|
276
|
+
* mutable cached shape to a caller. */
|
|
277
|
+
export function __inspectHullShapeForTests(vertices){
|
|
278
|
+
const shape=hullShape(vertices),copy=axes=>axes.map(axis=>axis.map(value=>value.toString()));
|
|
279
|
+
return {volume:shape.volume.toString(),faceAxes:copy(shape.faces),edgeDirections:copy(shape.edges)};
|
|
280
|
+
}
|
|
281
|
+
/** How many rotated hulls stay resident, before the memo is dropped and refilled. A request is
|
|
282
|
+
* bounded by its distinct hull items times the six orientations, so this holds far more than
|
|
283
|
+
* any request the solver is sized for -- and bounded, rather than growing for the life of the
|
|
284
|
+
* process. */
|
|
285
|
+
const SHAPE_CACHE_ENTRIES=1024;
|
|
286
|
+
const shapeCache=new Map();
|
|
287
|
+
/** The rotated hull of one item in one orientation, built at most once.
|
|
288
|
+
*
|
|
289
|
+
* A hull depends on the item and the orientation and on nothing about where a candidate sits,
|
|
290
|
+
* but the collision predicate was rebuilding it on every call -- `O(v^4)` work inside an
|
|
291
|
+
* `O(n^2)` loop. Measured on the two-wedge fixture: 78 builds for two items, where twelve are
|
|
292
|
+
* the floor.
|
|
293
|
+
*
|
|
294
|
+
* Memoisation is safe here in the way it is not in general: the shape is never mutated after
|
|
295
|
+
* it is built, the key is the whole of what determines the value, and callers only project
|
|
296
|
+
* through it. Determinism is untouched -- this changes how often the answer is computed,
|
|
297
|
+
* never what it is. */
|
|
298
|
+
function shapeFor(vertices,rotation){
|
|
299
|
+
const key=rotation+'|'+vertices.map(v=>v.join(',')).join(';');
|
|
300
|
+
const found=shapeCache.get(key);
|
|
301
|
+
if(found!==undefined)return found;
|
|
302
|
+
const shape=hullShape(hullRotate(vertices,rotation));
|
|
303
|
+
if(shapeCache.size>=SHAPE_CACHE_ENTRIES)shapeCache.clear();
|
|
304
|
+
shapeCache.set(key,shape);
|
|
305
|
+
return shape;
|
|
306
|
+
}
|
|
307
|
+
/** A cuboid, built without searching for its own faces: both sets are the three unit axes. */
|
|
308
|
+
/** Lower bounds on the objective vector.
|
|
309
|
+
*
|
|
310
|
+
* The mathematics is fixed by docs/OPTIMALITY-CERTIFICATES.md. This is an independent
|
|
311
|
+
* implementation written from that document, and `conformance/scene/objective-bounds.json`
|
|
312
|
+
* holds it to the same vectors Python computes on 380 cases from the golden corpus.
|
|
313
|
+
*
|
|
314
|
+
* asks only for soundness -- the bound must never exceed the achieved objective --
|
|
315
|
+
* because this engine is not held to placement equality. That freedom does not extend to a
|
|
316
|
+
* bound: it is a function of the *request*, so there is no room for a legitimately different
|
|
317
|
+
* answer, and this port is held to equality because equality is achievable and stronger.
|
|
318
|
+
*
|
|
319
|
+
* `BigInt` throughout for volumes. A one-metre cube is 4.1e21 cubic ticks, past what a
|
|
320
|
+
* double represents exactly, and the widest intermediate multiplies a summed volume by 1e6.
|
|
321
|
+
* Counts, weights, costs and the parts-per-million keys come back to `Number` only once the
|
|
322
|
+
* arithmetic has reduced them to that scale.
|
|
323
|
+
*
|
|
324
|
+
* `O(n log n + c log c)` for `n` instances and `c` container types: one sort of the volumes,
|
|
325
|
+
* one of the weights, one of the per-unit costs. No geometry is touched. */
|
|
326
|
+
const BOUND_PPM=1000000n;
|
|
327
|
+
/** Every sum in the bound path must stay below this.
|
|
328
|
+
*
|
|
329
|
+
* Declared rather than inherited. This engine's `Number` stops being exact past 2^53, PHP's
|
|
330
|
+
* integers silently become doubles on overflow, Python's are unbounded and Rust's `i128`
|
|
331
|
+
* wraps -- so if each refused at its own limit the four would disagree about which requests
|
|
332
|
+
* are answerable. Keys 3 and 4 multiply a summed volume by `PPM`, so `10^30 * 10^6` sits
|
|
333
|
+
* about 170-fold inside an `i128`. Everything guarded here is `BigInt`, because a ceiling a
|
|
334
|
+
* representation cannot hold is a ceiling it cannot enforce. */
|
|
335
|
+
const MAX_BOUND_SUM=10n**30n;
|
|
336
|
+
// Results cross the JSON/Number boundary. Intermediates may use the wider ceiling above,
|
|
337
|
+
// but every returned key must fit exactly in every binding before it becomes a Number.
|
|
338
|
+
const MAX_BOUND_VALUE=2n**53n-1n;
|
|
339
|
+
/** A sum in the bound path exceeded the declared ceiling. Structured rather than a number:
|
|
340
|
+
* a bound that is quietly wrong is worse than none, because it will be believed. */
|
|
341
|
+
export class BoundOverflowError extends Error{
|
|
342
|
+
constructor(quantity,ceiling=MAX_BOUND_SUM,subject='sum'){
|
|
343
|
+
super(`${quantity} ${subject} is past the ${ceiling} ceiling the bound path declares`);
|
|
344
|
+
this.name='BoundOverflowError';
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function boundGuard(total,quantity){
|
|
348
|
+
if(total>MAX_BOUND_SUM)throw new BoundOverflowError(quantity);
|
|
349
|
+
return total;
|
|
350
|
+
}
|
|
351
|
+
function boundOutput(value,quantity){
|
|
352
|
+
if(value>MAX_BOUND_VALUE){
|
|
353
|
+
throw new BoundOverflowError(quantity,MAX_BOUND_VALUE,'bound');
|
|
354
|
+
}
|
|
355
|
+
return Number(value);
|
|
356
|
+
}
|
|
357
|
+
/** Can this item take up less room than its declared dimensions?
|
|
358
|
+
*
|
|
359
|
+
* Three ways, and each breaks the same argument -- that nominal volumes sum to something a
|
|
360
|
+
* solution must carry. A nested item sinks into the one below it; a `convex_hull` occupies
|
|
361
|
+
* its hull and leaves the rest of its bounding box free; a `compressible` item gives up
|
|
362
|
+
* height under load. The design document named only the first until a soundness test over
|
|
363
|
+
* the corpus found the omission. */
|
|
364
|
+
function occupiesLessThanItsBox(item){
|
|
365
|
+
if(item.nestingHeight!=null)return true;
|
|
366
|
+
return item.shapeType==='convex_hull'||item.shapeType==='compressible';
|
|
367
|
+
}
|
|
368
|
+
const boundCeilDiv=(a,b)=>(a+b-1n)/b;
|
|
369
|
+
/** The largest n such that the n smallest values sum to at most the capacity. Smallest first
|
|
370
|
+
* is the whole soundness argument: the cheapest units maximise how many fit, so this
|
|
371
|
+
* over-estimates what any real packing achieves and the bound under-estimates. */
|
|
372
|
+
function boundFit(ascending,capacity){
|
|
373
|
+
if(capacity===null)return ascending.length;
|
|
374
|
+
let used=0n;
|
|
375
|
+
for(let taken=0;taken<ascending.length;taken++){
|
|
376
|
+
used+=ascending[taken];
|
|
377
|
+
if(used>capacity)return taken;
|
|
378
|
+
}
|
|
379
|
+
return ascending.length;
|
|
380
|
+
}
|
|
381
|
+
/** Sum of limit*quantity, or null when any limit or inventory is undeclared. `zeroIsHarmless`
|
|
382
|
+
* is the volume rule: a container with no usable volume adds nothing however many there
|
|
383
|
+
* are, so an unlimited quantity only unbounds the total when the type holds something. */
|
|
384
|
+
function boundCapacity(values,quantities,zeroIsHarmless){
|
|
385
|
+
let total=0n;
|
|
386
|
+
for(let i=0;i<values.length;i++){
|
|
387
|
+
if(values[i]===null)return null;
|
|
388
|
+
if(quantities[i]===null){
|
|
389
|
+
if(zeroIsHarmless&&values[i]<=0n)continue;
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
total=boundGuard(total+values[i]*quantities[i],'container capacity');
|
|
393
|
+
}
|
|
394
|
+
return total;
|
|
395
|
+
}
|
|
396
|
+
/** The largest declared limit, or null if any type declares none: one unlimited type makes
|
|
397
|
+
* the maximum unbounded and every term conditioned on it vacuous. */
|
|
398
|
+
function boundFiniteMax(values){
|
|
399
|
+
let best=null;
|
|
400
|
+
for(const value of values){
|
|
401
|
+
if(value===null)return null;
|
|
402
|
+
best=best===null||value>best?value:best;
|
|
403
|
+
}
|
|
404
|
+
return best;
|
|
405
|
+
}
|
|
406
|
+
/** Every bound, from the numbers the formulas consume -- the shape the shared scene records,
|
|
407
|
+
* so this port is checked without reimplementing a request parser. `shrinks` is taken as
|
|
408
|
+
* given; whether this engine decides it correctly is asserted separately. */
|
|
409
|
+
function objectiveBounds(instances,containers){
|
|
410
|
+
const volumes=instances.map(i=>i.volume).sort((a,b)=>a<b?-1:a>b?1:0);
|
|
411
|
+
const weights=instances.map(i=>i.weight).sort((a,b)=>a<b?-1:a>b?1:0);
|
|
412
|
+
const shrinks=instances.some(i=>i.shrinks);
|
|
413
|
+
const count=instances.length;
|
|
414
|
+
const usable=containers.map(c=>c.usable),inner=containers.map(c=>c.inner);
|
|
415
|
+
const quantities=containers.map(c=>c.quantity);
|
|
416
|
+
|
|
417
|
+
// The a-priori check, once, on the way in. Every later product is bounded by these totals
|
|
418
|
+
// times PPM, so guarding them here is what makes the rest safe by derivation.
|
|
419
|
+
boundGuard(volumes.reduce((a,b)=>a+b,0n),'instance volume');
|
|
420
|
+
boundGuard(weights.reduce((a,b)=>a+b,0n),'instance weight');
|
|
421
|
+
for(const container of containers){
|
|
422
|
+
boundGuard(container.usable,'container capacity');
|
|
423
|
+
boundGuard(container.costMinor,'opening cost');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let placeable=count;
|
|
427
|
+
if(!shrinks)placeable=Math.min(placeable,boundFit(volumes,boundCapacity(usable,quantities,true)));
|
|
428
|
+
placeable=Math.min(placeable,boundFit(weights,boundCapacity(containers.map(c=>c.payload),quantities,false)));
|
|
429
|
+
const slotCapacity=boundCapacity(containers.map(c=>c.maxItems),quantities,false);
|
|
430
|
+
if(slotCapacity!==null)placeable=Math.min(placeable,Number(slotCapacity));
|
|
431
|
+
const unpacked=count-placeable,placed=placeable;
|
|
432
|
+
|
|
433
|
+
let opened=0;
|
|
434
|
+
if(placed>0&&containers.length){
|
|
435
|
+
opened=1;
|
|
436
|
+
if(!shrinks){
|
|
437
|
+
const largest=usable.reduce((a,b)=>b>a?b:a,0n);
|
|
438
|
+
if(largest>0n)opened=Math.max(opened,Number(boundCeilDiv(volumes.slice(0,placed).reduce((a,b)=>a+b,0n),largest)));
|
|
439
|
+
}
|
|
440
|
+
const payload=boundFiniteMax(containers.map(c=>c.payload));
|
|
441
|
+
if(payload!==null&&payload>0n)opened=Math.max(opened,Number(boundCeilDiv(weights.slice(0,placed).reduce((a,b)=>a+b,0n),payload)));
|
|
442
|
+
const slots=boundFiniteMax(containers.map(c=>c.maxItems));
|
|
443
|
+
if(slots!==null&&slots>0n)opened=Math.max(opened,Number(boundCeilDiv(BigInt(placed),slots)));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let cost=0n;
|
|
447
|
+
if(opened>0){
|
|
448
|
+
const available=[];
|
|
449
|
+
for(const c of containers){
|
|
450
|
+
const repeat=c.quantity===null?opened:Math.min(Number(c.quantity),opened);
|
|
451
|
+
for(let taken=0;taken<repeat;taken++)available.push(c.costMinor);
|
|
452
|
+
}
|
|
453
|
+
available.sort((a,b)=>a<b?-1:a>b?1:0);
|
|
454
|
+
cost=available.slice(0,opened).reduce((a,b)=>a+b,0n);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let unused=0n;
|
|
458
|
+
if(!shrinks&&opened>0&&containers.length){
|
|
459
|
+
const smallest=inner.reduce((a,b)=>b<a?b:a,inner[0]);
|
|
460
|
+
if(smallest>0n){
|
|
461
|
+
const largestPlaced=placed>0?volumes.slice(volumes.length-placed).reduce((a,b)=>a+b,0n):0n;
|
|
462
|
+
// In BigInt until it is clamped: `largestPlaced * PPM` can reach 10^36, which a
|
|
463
|
+
// `Number` would round rather than carry.
|
|
464
|
+
const filled=boundCeilDiv(largestPlaced*BOUND_PPM,smallest);
|
|
465
|
+
const raw=BigInt(opened)*BOUND_PPM-filled-BigInt(opened-1);
|
|
466
|
+
unused=raw>0n?raw:0n;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let height=0n;
|
|
471
|
+
if(!shrinks&&opened>0&&containers.length){
|
|
472
|
+
const widest=containers.map(c=>c.baseArea).reduce((a,b)=>b>a?b:a,0n);
|
|
473
|
+
const tallest=containers.map(c=>c.height).reduce((a,b)=>b>a?b:a,0n);
|
|
474
|
+
if(widest>0n&&tallest>0n){
|
|
475
|
+
const required=placed>0?boundCeilDiv(volumes.slice(0,placed).reduce((a,b)=>a+b,0n),widest):0n;
|
|
476
|
+
const raw=required*BOUND_PPM/tallest-BigInt(opened-1);
|
|
477
|
+
height=raw>0n?raw:0n;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return [
|
|
481
|
+
boundOutput(BigInt(unpacked),'unpacked count'),
|
|
482
|
+
boundOutput(BigInt(opened),'container count'),
|
|
483
|
+
boundOutput(boundGuard(cost,'opening cost'),'opening cost'),
|
|
484
|
+
boundOutput(unused,'unused volume'),
|
|
485
|
+
boundOutput(height,'stack height'),
|
|
486
|
+
];
|
|
487
|
+
}
|
|
488
|
+
/** Exposed for the cross-language scene test only, like `__inspectHullShapeForTests`: the
|
|
489
|
+
* bounds are internal until a contract freeze decides whether a caller ever sees a gap. */
|
|
490
|
+
export function __objectiveBoundsForTests(instances,containers){return objectiveBounds(instances,containers);}
|
|
491
|
+
export function __occupiesLessThanItsBoxForTests(item){return occupiesLessThanItsBox(item);}
|
|
492
|
+
function boxShape(dx,dy,dz){
|
|
493
|
+
const v=[];
|
|
494
|
+
for(const x of [0n,BigInt(dx)])for(const y of [0n,BigInt(dy)])for(const z of [0n,BigInt(dz)])v.push([x,y,z]);
|
|
495
|
+
return {v,faces:UNIT_AXES,edges:UNIT_AXES,volume:BigInt(dx)*BigInt(dy)*BigInt(dz)};
|
|
496
|
+
}
|
|
497
|
+
/** Reorient a hull the way a rotation reorients its box, never mirroring it.
|
|
498
|
+
*
|
|
499
|
+
* Three of the six rotations are odd permutations of the coordinate axes. On a cuboid that is
|
|
500
|
+
* invisible; on a hull a bare permutation returns the item's mirror image, a shape the caller
|
|
501
|
+
* does not own. One axis therefore changes sign when the permutation is odd. */
|
|
502
|
+
function hullRotate(vertices,code){
|
|
503
|
+
const axes=ROT[code];
|
|
504
|
+
let inversions=0;
|
|
505
|
+
for(let i=0;i<3;i++)for(let j=i+1;j<3;j++)if(axes[i]>axes[j])inversions++;
|
|
506
|
+
const sign=inversions%2?-1:1;
|
|
507
|
+
const turned=vertices.map(v=>[sign*v[axes[0]],v[axes[1]],v[axes[2]]]);
|
|
508
|
+
const low=[0,1,2].map(a=>Math.min(...turned.map(v=>v[a])));
|
|
509
|
+
return turned.map(v=>[v[0]-low[0],v[1]-low[1],v[2]-low[2]]);
|
|
510
|
+
}
|
|
511
|
+
function separatingAxes(left,right){
|
|
512
|
+
const axes=new Map();
|
|
513
|
+
for(const axis of [...left.faces,...right.faces])axes.set(axisKey(axis),axis);
|
|
514
|
+
for(const l of left.edges)for(const r of right.edges){
|
|
515
|
+
const axis=primitiveAxis(cross3(l,r));
|
|
516
|
+
if(axis)axes.set(axisKey(axis),axis);
|
|
517
|
+
}
|
|
518
|
+
return [...axes.values()];
|
|
519
|
+
}
|
|
520
|
+
/** Do two placed hulls overlap with positive volume? Touching is contact, not collision: the
|
|
521
|
+
* comparison is `<=`, matching the half-open convention cuboids already use. */
|
|
522
|
+
function hullsCollide(left,leftOrigin,right,rightOrigin){
|
|
523
|
+
const lo=big3(leftOrigin),ro=big3(rightOrigin);
|
|
524
|
+
for(const axis of separatingAxes(left,right)){
|
|
525
|
+
const project=shape=>{let low=null,high=null;
|
|
526
|
+
for(const v of shape.v){const value=dot3(v,axis);
|
|
527
|
+
if(low===null||value<low)low=value;if(high===null||value>high)high=value}
|
|
528
|
+
return [low,high]};
|
|
529
|
+
const [ll,lh]=project(left),[rl,rh]=project(right);
|
|
530
|
+
const ls=dot3(lo,axis),rs=dot3(ro,axis);
|
|
531
|
+
if(lh+ls<=rl+rs||rh+rs<=ll+ls)return false;
|
|
532
|
+
}
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---------------------------------------------------------------- compression
|
|
537
|
+
|
|
538
|
+
const COMPRESSION_PPM=1000000n;
|
|
539
|
+
const GRAVITY_NUMERATOR=980665n,GRAVITY_DENOMINATOR=100000n,PASCALS_PER_KPA=1000n;
|
|
540
|
+
/** Exact pressure in kPa from the cumulative mass above an item, over its footprint. Reduced,
|
|
541
|
+
* so the divisor in the height formula stays small and two engines agreeing on the value
|
|
542
|
+
* cannot disagree on the representation. */
|
|
543
|
+
function appliedPressure(loadTicks,footprintTicks){
|
|
544
|
+
const metre=BigInt(LEN.mm)*1000n;
|
|
545
|
+
const n=BigInt(loadTicks)*GRAVITY_NUMERATOR*metre*metre;
|
|
546
|
+
const d=BigInt(WT.kg)*GRAVITY_DENOMINATOR*PASCALS_PER_KPA*BigInt(footprintTicks);
|
|
547
|
+
const g=bigGcd(n,d)||1n;
|
|
548
|
+
return {n:n/g,d:d/g};
|
|
549
|
+
}
|
|
550
|
+
/** Cross multiplication, so the inclusive boundary is decided without ever dividing. */
|
|
551
|
+
const pressureExceeds=(pressure,limitKpa)=>pressure.n>BigInt(limitKpa)*pressure.d;
|
|
552
|
+
/** Occupied height under load, rounded up, never below one tick. Rounding up keeps a discrete
|
|
553
|
+
* packer honest; the one-tick floor stops a fully compressible item reaching zero height,
|
|
554
|
+
* where it would slip past collision and support invariants entirely. */
|
|
555
|
+
function effectiveHeight(heightTicks,ratioPpm,limitKpa,pressure){
|
|
556
|
+
if(limitKpa===0)return heightTicks;
|
|
557
|
+
const divisor=BigInt(limitKpa)*COMPRESSION_PPM*pressure.d;
|
|
558
|
+
const retained=divisor-BigInt(ratioPpm)*pressure.n;
|
|
559
|
+
const rounded=(BigInt(heightTicks)*retained+divisor-1n)/divisor;
|
|
560
|
+
return Number(rounded>1n?rounded:1n);
|
|
561
|
+
}
|
|
562
|
+
/** The published ratio rule, `floor(ratio * 1000000 + 0.5)`, applied once at the boundary so
|
|
563
|
+
* the float a caller supplied never reaches the geometry. */
|
|
564
|
+
function ratioToPpm(ratio){
|
|
565
|
+
if(!(ratio>=0&&ratio<=1))throw new RangeError('compression_ratio must be between zero and one');
|
|
566
|
+
return Math.floor(ratio*1000000+0.5);
|
|
567
|
+
}
|
|
103
568
|
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;
|
|
104
569
|
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;
|
|
105
570
|
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}
|
|
106
|
-
|
|
571
|
+
/** This placement's rotated hull, or `null` when its box is the honest answer.
|
|
572
|
+
*
|
|
573
|
+
* `null` for every `rigid_cuboid` and for three cases that fall back to the box, always
|
|
574
|
+
* over-reserving space: a clearance has inflated the envelope past the physical box and a
|
|
575
|
+
* margin around a hull is not a hull; the item is on a route, where the sequence replay
|
|
576
|
+
* reasons with box sweeps only and packing tighter than it can verify would produce
|
|
577
|
+
* arrangements the engine then calls unloadable. */
|
|
578
|
+
function placedHull(placement){
|
|
579
|
+
const item=placement.item;
|
|
580
|
+
if(item.shapeType!=='convex_hull'||item.stopIndex!=null)return null;
|
|
581
|
+
if(placement.ed[0]!==placement.pd[0]||placement.ed[1]!==placement.pd[1]||placement.ed[2]!==placement.pd[2])return null;
|
|
582
|
+
return shapeFor(item.hullVertices,placement.r);
|
|
583
|
+
}
|
|
584
|
+
/** Do two placed items actually overlap? The axis-aligned envelope test is the broad phase and
|
|
585
|
+
* stays mandatory; this refines its answer only when a hull is one of the two solids. */
|
|
586
|
+
function solidsOverlap(leftShape,leftBox,rightShape,rightBox){
|
|
587
|
+
if(leftShape===null&&rightShape===null)return true;
|
|
588
|
+
return hullsCollide(
|
|
589
|
+
leftShape??boxShape(leftBox.d[0],leftBox.d[1],leftBox.d[2]),[leftBox.x,leftBox.y,leftBox.z],
|
|
590
|
+
rightShape??boxShape(rightBox.d[0],rightBox.d[1],rightBox.d[2]),[rightBox.x,rightBox.y,rightBox.z]);
|
|
591
|
+
}
|
|
592
|
+
/** Space one placement actually takes, which is its box only if it is one.
|
|
593
|
+
*
|
|
594
|
+
* A `convex_hull` item occupies its hull: counting the bounding box is not a conservative
|
|
595
|
+
* approximation of utilisation but a wrong number, putting two interlocking wedges at 200% of
|
|
596
|
+
* a crate. A `compressible` item occupies the height left after the load it reports. */
|
|
597
|
+
function occupiedVolume(placement,loadTicks=0){
|
|
598
|
+
const item=placement.item;
|
|
599
|
+
// Route and clearance can make collision conservatively use the envelope; neither changes
|
|
600
|
+
// the physical solid used for utilisation and void-fill reserve accounting.
|
|
601
|
+
if(item.shapeType==='convex_hull')return shapeFor(item.hullVertices,placement.r).volume;
|
|
602
|
+
if(item.maxCompressionKpa==null)return volume(placement.pd);
|
|
603
|
+
const footprint=placement.pd[0]*placement.pd[1];
|
|
604
|
+
// The load is passed in rather than read off the placement: this engine computes top loads
|
|
605
|
+
// at reporting time and never stores them, so a placement field would have been silently
|
|
606
|
+
// zero and nothing would ever have compressed.
|
|
607
|
+
const pressure=appliedPressure(loadTicks,footprint);
|
|
608
|
+
// A crushed item has no meaningful occupied volume, and the arrangement is already invalid
|
|
609
|
+
// -- the crush check refuses it and the validator reports it.
|
|
610
|
+
if(pressureExceeds(pressure,item.maxCompressionKpa))return volume(placement.pd);
|
|
611
|
+
return BigInt(footprint)*BigInt(effectiveHeight(placement.pd[2],item.compressionPpm,item.maxCompressionKpa,pressure));
|
|
612
|
+
}
|
|
613
|
+
/** First compressible box carrying more pressure than it declared it can take.
|
|
614
|
+
*
|
|
615
|
+
* Deliberately shaped like `overloaded` and reading the same propagated loads: the two answer
|
|
616
|
+
* one question in two currencies -- a mass the box below must bear, against a pressure the
|
|
617
|
+
* item itself must survive. An item can pass one and fail the other, so both are asked. */
|
|
618
|
+
function crushed(boxes,loads=null){
|
|
619
|
+
if(boxes.every(b=>b.maxCompressionKpa==null))return false;
|
|
620
|
+
if(loads==null)loads=topLoads(boxes);
|
|
621
|
+
return boxes.some((b,i)=>{
|
|
622
|
+
if(b.maxCompressionKpa==null)return false;
|
|
623
|
+
const footprint=b.d[0]*b.d[1];
|
|
624
|
+
return pressureExceeds(appliedPressure(Number(loads[i]),footprint),b.maxCompressionKpa);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/** Parse and admit an item's shape, or refuse it with the reason.
|
|
628
|
+
*
|
|
629
|
+
* Coordinates go through the length scale, which refuses a negative value, so a hull crossing
|
|
630
|
+
* the wire is authored as non-negative offsets from the corner of its own bounding box. The
|
|
631
|
+
* admission rule spans four fields at once -- which are required, which are forbidden, and
|
|
632
|
+
* what the survivors must agree with -- and mirrors the other three engines exactly. */
|
|
633
|
+
function parseShape(raw,d,unit,nesting){
|
|
634
|
+
const shapeType=raw.shape_type??'rigid_cuboid';
|
|
635
|
+
if(!['rigid_cuboid','convex_hull','compressible'].includes(shapeType))
|
|
636
|
+
throw new RangeError(`item.shape_type ${shapeType} is not a known shape`);
|
|
637
|
+
const hullVertices=raw.hull_vertices==null?null:raw.hull_vertices.map(v=>
|
|
638
|
+
[scalar(v.x,unit,LEN),scalar(v.y,unit,LEN),scalar(v.z,unit,LEN)]);
|
|
639
|
+
const compressionPpm=raw.compression_ratio==null?null:ratioToPpm(raw.compression_ratio);
|
|
640
|
+
const maxCompressionKpa=raw.max_compression_pressure_kpa==null?null:Number(raw.max_compression_pressure_kpa);
|
|
641
|
+
const foreign=shapeType==='convex_hull'
|
|
642
|
+
?[['compression_ratio',compressionPpm],['max_compression_pressure_kpa',maxCompressionKpa]]
|
|
643
|
+
:shapeType==='compressible'?[['hull_vertices',hullVertices]]
|
|
644
|
+
:[['hull_vertices',hullVertices],['compression_ratio',compressionPpm],['max_compression_pressure_kpa',maxCompressionKpa]];
|
|
645
|
+
for(const [name,value] of foreign)
|
|
646
|
+
if(value!=null)throw new RangeError(`${name} is not part of a ${shapeType} item`);
|
|
647
|
+
// Both rewrite occupied height. Choosing an order silently would give four engines four
|
|
648
|
+
// contracts, so the interaction is refused until a task defines it.
|
|
649
|
+
if(nesting!=null&&shapeType!=='rigid_cuboid')
|
|
650
|
+
throw new RangeError(`nesting_height with shape_type ${shapeType} is not supported yet`);
|
|
651
|
+
if(shapeType==='convex_hull'){
|
|
652
|
+
if(hullVertices===null)throw new RangeError('a convex_hull item requires hull_vertices');
|
|
653
|
+
const points=hullValidate(hullVertices);
|
|
654
|
+
for(let axis=0;axis<3;axis++){
|
|
655
|
+
const span=Math.max(...points.map(v=>v[axis]))-Math.min(...points.map(v=>v[axis]));
|
|
656
|
+
// `dimensions` stays the broad phase and the candidate-generation envelope, so a hull
|
|
657
|
+
// poking out of it would be collision-tested against space never reserved.
|
|
658
|
+
if(span>d[axis])throw new RangeError('hull_vertices span does not fit inside dimensions');
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if(shapeType==='compressible'){
|
|
662
|
+
if(compressionPpm===null||maxCompressionKpa===null)
|
|
663
|
+
throw new RangeError('a compressible item requires both compression_ratio and max_compression_pressure_kpa');
|
|
664
|
+
if(maxCompressionKpa<0)throw new RangeError('max_compression_pressure_kpa cannot be negative');
|
|
665
|
+
}
|
|
666
|
+
return {shapeType,hullVertices,compressionPpm,maxCompressionKpa};
|
|
667
|
+
}
|
|
668
|
+
function usedVolume(placements){
|
|
669
|
+
// Compression needs the cumulative mass above each item, which is the same propagation the
|
|
670
|
+
// reported `top_load` uses -- one traversal, read twice.
|
|
671
|
+
const loads=placements.some(p=>p.item.maxCompressionKpa!=null)
|
|
672
|
+
?topLoads(placements.map(constraintBox)):null;
|
|
673
|
+
let total=placements.reduce((s,p,i)=>s+occupiedVolume(p,loads===null?0:Number(loads[i])),0n),overlap=0n;
|
|
107
674
|
for(let i=0;i<placements.length;i++)for(let j=i+1;j<placements.length;j++)if(validNesting(placements[i],placements[j]))
|
|
108
675
|
overlap+=BigInt(placements[i].item.nesting)*BigInt(placements[i].ed[0])*BigInt(placements[i].ed[1]);
|
|
109
676
|
return total-overlap}
|
|
@@ -113,7 +680,7 @@ function usedVolume(placements){let total=placements.reduce((s,p)=>s+volume(p.pd
|
|
|
113
680
|
// what is already there -- O(n) where `usedVolume` is O(n^2). The search calls this once
|
|
114
681
|
// per candidate orientation, which is what made the whole solve super-linear in item
|
|
115
682
|
// count before.
|
|
116
|
-
function usedVolumeDelta(placements,tentative){let delta=
|
|
683
|
+
function usedVolumeDelta(placements,tentative){let delta=occupiedVolume(tentative);
|
|
117
684
|
for(const placed of placements)if(validNesting(placed,tentative))
|
|
118
685
|
delta-=BigInt(placed.item.nesting)*BigInt(placed.ed[0])*BigInt(placed.ed[1]);
|
|
119
686
|
return delta}
|
|
@@ -162,6 +729,21 @@ function insertPoint(points,point){let low=0,high=points.length;
|
|
|
162
729
|
// candidate list bounded instead of letting it grow with every placement, which is what
|
|
163
730
|
// left the fallback evaluating two orders of magnitude more points per item than Python
|
|
164
731
|
//. The half-open test matches `intersects`.
|
|
732
|
+
/** Retire the points a placement covers -- unless it is a hull.
|
|
733
|
+
*
|
|
734
|
+
* Retiring a point because it falls inside a solid's box assumes the box *is* the solid. For a
|
|
735
|
+
* hull it is not: a placement origin is a corner of a bounding box, and a hull leaves most of
|
|
736
|
+
* that box -- including, for a wedge, the origin itself -- available to the next item. Pruning
|
|
737
|
+
* them first would mean the engine could describe an interlocking pack it could never propose,
|
|
738
|
+
* and the exact collision test would be correct and never consulted.
|
|
739
|
+
*
|
|
740
|
+
* One wrapper rather than a guard at each call site: the Rust port found a *second* place that
|
|
741
|
+
* treated a box as the solid, and a single entry point is what makes a third impossible to
|
|
742
|
+
* forget. */
|
|
743
|
+
function retirePointsForPlacement(points,placement){
|
|
744
|
+
if(placedHull(placement)!==null)return;
|
|
745
|
+
retirePointsInside(points,{x:placement.x,y:placement.y,z:placement.z,d:placement.ed});
|
|
746
|
+
}
|
|
165
747
|
function retirePointsInside(points,box){const x2=box.x+box.d[0],y2=box.y+box.d[1],z2=box.z+box.d[2];
|
|
166
748
|
let write=0;
|
|
167
749
|
for(let read=0;read<points.length;read++){const p=points[read];
|
|
@@ -188,6 +770,10 @@ function axleOverloaded(container,placements,extra=null){const reaction=axleReac
|
|
|
188
770
|
return (front.max!=null&&reaction.front>BigInt(front.max)*reaction.denominator)
|
|
189
771
|
||(rear.max!=null&&reaction.rear>BigInt(rear.max)*reaction.denominator)}
|
|
190
772
|
function overlapXY(a,b){const dx=Math.max(0,Math.min(a.x+a.d[0],b.x+b.d[0])-Math.max(a.x,b.x));const dy=Math.max(0,Math.min(a.y+a.d[1],b.y+b.d[1])-Math.max(a.y,b.y));return dx*dy}
|
|
773
|
+
// `overlapXY` on a placement's own fields: the same arithmetic without first copying the
|
|
774
|
+
// placement into a `{x,y,z,d}` box, which the candidate sweep did once per comparison.
|
|
775
|
+
function footprintOverlap(placement,x,y,length,width){const d=placementDimensions(placement);
|
|
776
|
+
const dx=Math.max(0,Math.min(placement.x+d[0],x+length)-Math.max(placement.x,x));const dy=Math.max(0,Math.min(placement.y+d[1],y+width)-Math.max(placement.y,y));return dx*dy}
|
|
191
777
|
function placementDimensions(placement){return placement.ed??placement.d}
|
|
192
778
|
function placementItemType(placement){return placement.itemType??placement.item?.raw?.id??null}
|
|
193
779
|
function placementNesting(placement){return placement.nesting??placement.item?.nesting??null}
|
|
@@ -199,7 +785,16 @@ function sameNestingColumn(left,right){const leftNesting=placementNesting(left),
|
|
|
199
785
|
// One candidate's exact direct supporters in O(n). A nested predecessor replaces only
|
|
200
786
|
// shadowed face contacts from its own type/footprint column; unrelated face supporters
|
|
201
787
|
// retain their original order and semantics.
|
|
202
|
-
function directSupporters(candidate,placed){const dimensions=placementDimensions(candidate);let predecessor=null;
|
|
788
|
+
function directSupporters(candidate,placed,topPlane=null){const dimensions=placementDimensions(candidate);let predecessor=null;
|
|
789
|
+
// With the scene bucketed by top face, a non-nesting candidate reads only the placements
|
|
790
|
+
// whose top is its own base: no predecessor can exist and no supporter is shadowed, so
|
|
791
|
+
// the full scan below reduces to its second loop over that one bucket, in the same order.
|
|
792
|
+
if(topPlane!==null&&placementNesting(candidate)==null){const supporters=[],level=topPlane.get(candidate.z);
|
|
793
|
+
if(level===undefined)return supporters;
|
|
794
|
+
for(const other of level){if(other===candidate)continue;
|
|
795
|
+
const area=footprintOverlap(other,candidate.x,candidate.y,dimensions[0],dimensions[1]);
|
|
796
|
+
if(area>0)supporters.push({placement:other,area})}
|
|
797
|
+
return supporters}
|
|
203
798
|
for(const other of placed){if(other===candidate||other.z>=candidate.z||!sameNestingColumn(other,candidate))continue;
|
|
204
799
|
if(predecessor==null||other.z>=predecessor.z)predecessor=other}
|
|
205
800
|
if(predecessor!=null&&predecessor.z+placementDimensions(predecessor)[2]-candidate.z!==placementNesting(predecessor))predecessor=null;
|
|
@@ -311,10 +906,27 @@ function contactGraph(boxes){const graph=buildContactGraph(boxes,overlapXY),grou
|
|
|
311
906
|
|
|
312
907
|
function constraintBox(placement){return {x:placement.x,y:placement.y,z:placement.z,d:placement.ed,w:placement.item.w,
|
|
313
908
|
maxTop:placement.item.maxTop,maxStacked:placement.item.maxStacked,itemType:placement.item.raw.id,nesting:placement.item.nesting,
|
|
909
|
+
// Load propagation already computes the cumulative mass above every box, which is exactly
|
|
910
|
+
// the numerator the pressure model needs, so the crush check rides the graph that is built
|
|
911
|
+
// anyway rather than a second one.
|
|
912
|
+
maxCompressionKpa:placement.item.maxCompressionKpa,compressionPpm:placement.item.compressionPpm,
|
|
314
913
|
stopIndex:placement.item.stopIndex}}
|
|
315
914
|
|
|
316
|
-
|
|
317
|
-
|
|
915
|
+
// The order boxes settle in: highest top first, then highest base, then index. A strict
|
|
916
|
+
// total order, so the permutation it yields is unique -- which is what lets a candidate
|
|
917
|
+
// sweep insert one box into the scene's settled order rather than sort per candidate.
|
|
918
|
+
function settleOrder(boxes){return boxes.map((b,i)=>i).sort((a,b)=>(boxes[b].z+boxes[b].d[2])-(boxes[a].z+boxes[a].d[2])||boxes[b].z-boxes[a].z||a-b)}
|
|
919
|
+
// `settleOrder(boxes)` given the settled order of every box but the last. The last box has
|
|
920
|
+
// the highest index, so it follows every box it ties with, and the order is monotone in
|
|
921
|
+
// (top, base), so its slot is a binary search: O(n) for the copy against O(n log n) for the
|
|
922
|
+
// sort the load path used to pay per candidate.
|
|
923
|
+
function settleOrderWith(baseOrder,boxes){const last=boxes.length-1,top=boxes[last].z+boxes[last].d[2],base=boxes[last].z;
|
|
924
|
+
let low=0,high=baseOrder.length;
|
|
925
|
+
while(low<high){const mid=(low+high)>>1,box=boxes[baseOrder[mid]],boxTop=box.z+box.d[2];
|
|
926
|
+
if(top>boxTop||(top===boxTop&&base>box.z))high=mid;else low=mid+1}
|
|
927
|
+
const order=baseOrder.slice(0,low);order.push(last);for(let i=low;i<baseOrder.length;i++)order.push(baseOrder[i]);
|
|
928
|
+
return order}
|
|
929
|
+
function topLoads(boxes,graph=contactGraph(boxes),order=settleOrder(boxes)){const loads=boxes.map(()=>0n);
|
|
318
930
|
for(const upper of order){const supports=graph.supporters[upper];let total=0n;
|
|
319
931
|
for(const [,area] of supports)total+=BigInt(area);
|
|
320
932
|
if(total===0n)continue;
|
|
@@ -342,7 +954,7 @@ function groundContactAllowed(candidate,placed,supports=null){const rule=candida
|
|
|
342
954
|
const box={x:candidate.x,y:candidate.y,z:candidate.z,d:placementDimensions(candidate)},supporters=supports??directSupporters(candidate,placed);
|
|
343
955
|
if(rule==='single')return supporters.length===1;if(rule==='multiple')return supporters.length>=2;
|
|
344
956
|
if(rule==='covered'){const corners=[[box.x,box.y],[box.x+box.d[0],box.y],[box.x,box.y+box.d[1]],[box.x+box.d[0],box.y+box.d[1]]];return corners.every(([x,y])=>supporters.some(({placement})=>{const d=placementDimensions(placement);return placement.x<=x&&x<=placement.x+d[0]&&placement.y<=y&&y<=placement.y+d[1]}))}return true}
|
|
345
|
-
function routeContactAllowed(candidate,placed,supports){
|
|
957
|
+
function routeContactAllowed(candidate,placed,supports,sweep=null){
|
|
346
958
|
// An item without a declared stop rides the whole route. Infinity is the shared
|
|
347
959
|
// PHP/Python/Rust contract. Check only the new relations, as the existing scene was
|
|
348
960
|
// already valid; the one same-column face above may need an O(n) predecessor lookup
|
|
@@ -350,6 +962,18 @@ function routeContactAllowed(candidate,placed,supports){
|
|
|
350
962
|
const candidateStop=candidate.item.stopIndex??Infinity;
|
|
351
963
|
if(supports.some(({placement})=>candidateStop>(placement.item.stopIndex??Infinity)))return false;
|
|
352
964
|
const dimensions=placementDimensions(candidate);let scene=null;
|
|
965
|
+
if(sweep!==null){
|
|
966
|
+
// Every comparison below is `stop > Infinity` when nothing on the route declares a
|
|
967
|
+
// stop, so the rule cannot refuse and the scan is skipped for the request that has no
|
|
968
|
+
// route at all -- which is every request that is not a multi-drop route.
|
|
969
|
+
if(candidateStop===Infinity&&!sweep.placedStops)return true;
|
|
970
|
+
// A non-nesting candidate is never a nested predecessor, so only the placements whose
|
|
971
|
+
// base is its top can rest on it: read that one bucket instead of the whole scene.
|
|
972
|
+
if(candidate.item.nesting==null){const level=sweep.bottomPlane().get(candidate.z+dimensions[2]);
|
|
973
|
+
if(level!==undefined)for(const upper of level)
|
|
974
|
+
if(footprintOverlap(upper,candidate.x,candidate.y,dimensions[0],dimensions[1])>0&&(upper.item.stopIndex??Infinity)>candidateStop)return false;
|
|
975
|
+
return true}
|
|
976
|
+
}
|
|
353
977
|
for(const upper of placed){const upperDimensions=placementDimensions(upper),upperStop=upper.item.stopIndex??Infinity;
|
|
354
978
|
if(validNesting(candidate,upper)&&candidate.z<upper.z){if(upperStop>candidateStop)return false;continue}
|
|
355
979
|
if(candidate.z+dimensions[2]!==upper.z||overlapXY({x:candidate.x,y:candidate.y,z:candidate.z,d:dimensions},{x:upper.x,y:upper.y,z:upper.z,d:upperDimensions})<=0)continue;
|
|
@@ -369,12 +993,133 @@ function routeContactAllowed(candidate,placed,supports){
|
|
|
369
993
|
* physical rule the schema accepts is enforced here instead: a result that claims to
|
|
370
994
|
* honour a rule it ignored is worse than no result at all.
|
|
371
995
|
*/
|
|
372
|
-
|
|
996
|
+
// `loadBase` is a thunk, not a graph: the caller knows the placed boxes cannot move for
|
|
997
|
+
// this item's whole candidate sweep, but most candidates never reach the load rules at
|
|
998
|
+
// all, and building a base none of them asks for would be pure cost. It yields null
|
|
999
|
+
// whenever the delta does not apply -- see `candidatesFor`.
|
|
1000
|
+
// Dimensions reach this rule in two shapes and both are legitimate: the solver carries them
|
|
1001
|
+
// as `[length, width, height]`, while a caller holding a request or a fixture carries the
|
|
1002
|
+
// named object. `sweptVolume` reads the named form, and an array silently answers `3` for
|
|
1003
|
+
// `.length` -- so normalising here is not tidiness. Before 's review this predicate
|
|
1004
|
+
// returned the opposite verdict for the same scene depending on which shape it was handed,
|
|
1005
|
+
// and nothing caught it because no request path supplies a direction list yet.
|
|
1006
|
+
const namedDimensions=value=>Array.isArray(value)
|
|
1007
|
+
?{length:value[0],width:value[1],height:value[2]}:value;
|
|
1008
|
+
const innerDimensions=container=>namedDimensions(container.d!==undefined?container.d:container);
|
|
1009
|
+
// Only position and envelope size matter to a corridor, so the box is built here rather than
|
|
1010
|
+
// through `constraintBox`, which also carries load, nesting and item type -- none of which
|
|
1011
|
+
// this rule reads, and all of which an embedder would have to supply to call it.
|
|
1012
|
+
const corridorBox=p=>({x:p.x,y:p.y,z:p.z,d:namedDimensions(p.ed)});
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* The corridors open in one immutable placement state.
|
|
1016
|
+
*
|
|
1017
|
+
* Built once per candidate sweep and reused by every candidate: the placed boxes cannot move
|
|
1018
|
+
* while one item is being placed, so the placed-versus-placed intersections give the same
|
|
1019
|
+
* answer every time. Construction is `O(m^2 * |D|)` and each candidate then costs
|
|
1020
|
+
* `O(m * |D|)`, matching what the Rust core does with the same state. Rebuilding per
|
|
1021
|
+
* candidate would make switching the doors on cost `O(m^2 * |D|)` for every candidate -- the
|
|
1022
|
+
* hoist exists so that wiring the field later does not also have to repair a hot loop.
|
|
1023
|
+
*
|
|
1024
|
+
* The base is keyed to one candidate stop, so it is valid for exactly one item's sweep.
|
|
1025
|
+
*/
|
|
1026
|
+
export function stopAccessibilityBase(candidateStop,placed,container,directions){
|
|
1027
|
+
const stop=candidateStop??Infinity,stops=placed.map(p=>p.item.stopIndex??Infinity);
|
|
1028
|
+
// No doors is the default on every request path, and one distinct stop means nothing is
|
|
1029
|
+
// due before anything else. Either way no corridor can be wrongly blocked. Checked over
|
|
1030
|
+
// the candidate too, or the first placement into an empty container would skip a check it
|
|
1031
|
+
// should make.
|
|
1032
|
+
if(!directions||directions.length===0||stops.every(each=>each===stop))
|
|
1033
|
+
return {inert:true,stop,stops,directions:[],inner:null,boxes:[],clear:[]};
|
|
1034
|
+
const inner=innerDimensions(container),boxes=placed.map(corridorBox);
|
|
1035
|
+
const clear=boxes.map((box,index)=>stops[index]===Infinity
|
|
1036
|
+
// Never unloaded, so it needs no door of its own -- it only ever blocks.
|
|
1037
|
+
?[]
|
|
1038
|
+
:directions.map(direction=>sweptVolume(box,inner,direction))
|
|
1039
|
+
.filter(sweep=>!boxes.some((other,position)=>position!==index
|
|
1040
|
+
&&stops[position]>stops[index]&&sweptHits(sweep,other))));
|
|
1041
|
+
return {inert:false,stop,stops,directions:[...directions],inner,boxes,clear};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function accessibleAgainst(base,candidateBox){
|
|
1045
|
+
if(base.inert)return true;
|
|
1046
|
+
// Every already-placed item due before the candidate must keep a door the candidate does
|
|
1047
|
+
// not take.
|
|
1048
|
+
for(let index=0;index<base.clear.length;index++){
|
|
1049
|
+
if(base.stop<=base.stops[index])continue;
|
|
1050
|
+
if(!base.clear[index].some(sweep=>!sweptHits(sweep,candidateBox)))return false;
|
|
1051
|
+
}
|
|
1052
|
+
// An item riding the whole route is never unloaded, so it needs no door of its own.
|
|
1053
|
+
if(base.stop===Infinity)return true;
|
|
1054
|
+
return base.directions.some(direction=>{
|
|
1055
|
+
const sweep=sweptVolume(candidateBox,base.inner,direction);
|
|
1056
|
+
return !base.boxes.some((other,index)=>base.stops[index]>base.stop&&sweptHits(sweep,other));
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// The horizontal half of route order: nothing due later may stand between an earlier item
|
|
1061
|
+
// and a door. `routeContactAllowed` above enforces the vertical half -- nothing
|
|
1062
|
+
// due later may rest *above* something due earlier. Both are necessary and neither implies
|
|
1063
|
+
// the other; docs/STOP-ACCESSIBILITY.md derives the rule and the post-validator's
|
|
1064
|
+
// whole-scene replay stays the sufficient check.
|
|
1065
|
+
//
|
|
1066
|
+
// Inert unless the container supplies exit directions. `container.access_directions` is
|
|
1067
|
+
// canonicalised into `tmpl.doors` by the request decoder; omitting it preserves the
|
|
1068
|
+
// pre-1.1.0 behaviour instead of pretending all six walls are doors. JavaScript has no
|
|
1069
|
+
// separate programmatic configuration object, so the per-container request field is its
|
|
1070
|
+
// only activation path.
|
|
1071
|
+
//
|
|
1072
|
+
// The blocker set is `{q : s(q) > s(p)}` -- strictly later. Same-stop items are excluded
|
|
1073
|
+
// because the order within a stop is free: whichever is in the way comes off first.
|
|
1074
|
+
//
|
|
1075
|
+
// One implementation, not two: this builds the base and asks it, so the exported predicate
|
|
1076
|
+
// and the solver's hot path cannot drift apart.
|
|
1077
|
+
export function stopAccessible(candidate,placed,container,directions){
|
|
1078
|
+
return accessibleAgainst(
|
|
1079
|
+
stopAccessibilityBase(candidate.item.stopIndex,placed,container,directions),
|
|
1080
|
+
corridorBox(candidate));
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Half-open on every axis, matching the box intersection test, so a box flush against
|
|
1084
|
+
// another's exit face is not standing in its way.
|
|
1085
|
+
function sweptHits([sx1,sy1,sz1,sx2,sy2,sz2],box){
|
|
1086
|
+
return sx1<box.x+box.d.length&&box.x<sx2&&sy1<box.y+box.d.width&&box.y<sy2
|
|
1087
|
+
&&sz1<box.z+box.d.height&&box.z<sz2}
|
|
1088
|
+
|
|
1089
|
+
// What one candidate sweep knows about its item and the placed scene before any position is
|
|
1090
|
+
// tried. `allowed` used to rediscover each of these with its own pass over `placed` for every
|
|
1091
|
+
// feasible candidate -- the tag conflict, four "does anything here declare X" gates and the
|
|
1092
|
+
// supporter scan, on a scene that cannot change until the sweep commits -- which is where
|
|
1093
|
+
// the profile put a fifth of the whole solve. Built once per (template, item) sweep, in
|
|
1094
|
+
// O(n); the plane buckets are built on first demand because a floor candidate never asks.
|
|
1095
|
+
// A caller without a sweep (the rebalance replay) builds one per call and loses nothing.
|
|
1096
|
+
function sweepContext(item,placed){
|
|
1097
|
+
const tags=item.tags,bad=item.incompatible;
|
|
1098
|
+
let tagConflict=false,anyUnstackable=false,placedMaxTop=false,placedCompressible=false,placedMaxStacked=false,placedStops=false;
|
|
1099
|
+
for(const p of placed){const other=p.item;
|
|
1100
|
+
if(bad.some(t=>other.tags.includes(t))||other.incompatible.some(t=>tags.includes(t)))tagConflict=true;
|
|
1101
|
+
if(!other.stackable)anyUnstackable=true;
|
|
1102
|
+
if(other.maxTop!=null)placedMaxTop=true;
|
|
1103
|
+
if(other.maxCompressionKpa!=null)placedCompressible=true;
|
|
1104
|
+
if(other.maxStacked!=null)placedMaxStacked=true;
|
|
1105
|
+
if(other.stopIndex!=null)placedStops=true}
|
|
1106
|
+
let byTop=null,byBottom=null;
|
|
1107
|
+
return {tagConflict,anyUnstackable,placedMaxTop,placedCompressible,placedMaxStacked,placedStops,
|
|
1108
|
+
// Buckets keep `placed` order, which is the order every supporter list is contracted to.
|
|
1109
|
+
topPlane(){if(byTop===null)byTop=bucketByPlane(placed,p=>p.z+placementDimensions(p)[2]);return byTop},
|
|
1110
|
+
bottomPlane(){if(byBottom===null)byBottom=bucketByPlane(placed,p=>p.z);return byBottom}}}
|
|
1111
|
+
function bucketByPlane(placed,planeOf){const buckets=new Map();
|
|
1112
|
+
for(const p of placed){const plane=planeOf(p),bucket=buckets.get(plane);if(bucket)bucket.push(p);else buckets.set(plane,[p])}
|
|
1113
|
+
return buckets}
|
|
1114
|
+
function allowed(candidate,placed,container,globalSupportPpm,metrics,loadBase=null,accessBase=null,sweep=null){
|
|
373
1115
|
const box={x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed};
|
|
374
1116
|
if(candidate.item.raw.must_be_on_floor&&box.z!==0)return false;
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
1117
|
+
const scene=sweep??sweepContext(candidate.item,placed);
|
|
1118
|
+
if(scene.tagConflict)return false;
|
|
1119
|
+
// Face-to-face contact can only refuse when one of the pair declines to carry, and the
|
|
1120
|
+
// nesting rule only when the candidate nests. A non-nesting candidate consults the two
|
|
1121
|
+
// planes it can touch; anything else walks the scene exactly as before.
|
|
1122
|
+
if(candidate.item.nesting!=null||sweep===null){if(scene.anyUnstackable||!candidate.item.stackable||candidate.item.nesting!=null)for(const p of placed){
|
|
378
1123
|
const other={x:p.x,y:p.y,z:p.z,d:p.ed};
|
|
379
1124
|
if(overlapXY(other,box)<=0)continue;
|
|
380
1125
|
if(other.z+other.d[2]===box.z&&!p.item.stackable)return false;
|
|
@@ -385,10 +1130,15 @@ function allowed(candidate,placed,container,globalSupportPpm,metrics){
|
|
|
385
1130
|
const [lower]=candidate.z<=p.z?[candidate,p]:[p,candidate];
|
|
386
1131
|
if(!lower.item.stackable)return false;
|
|
387
1132
|
}
|
|
1133
|
+
}}else{
|
|
1134
|
+
if(scene.anyUnstackable){const level=sweep.topPlane().get(box.z);
|
|
1135
|
+
if(level!==undefined)for(const p of level)if(!p.item.stackable&&footprintOverlap(p,box.x,box.y,box.d[0],box.d[1])>0)return false}
|
|
1136
|
+
if(!candidate.item.stackable){const level=sweep.bottomPlane().get(box.z+box.d[2]);
|
|
1137
|
+
if(level!==undefined)for(const p of level)if(footprintOverlap(p,box.x,box.y,box.d[0],box.d[1])>0)return false}
|
|
388
1138
|
}
|
|
389
1139
|
metrics.support_checks++;
|
|
390
1140
|
const ratio=Math.max(globalSupportPpm,candidate.item.supportPpm);
|
|
391
|
-
const supports=box.z===0?[]:directSupporters(candidate,placed);
|
|
1141
|
+
const supports=box.z===0?[]:directSupporters(candidate,placed,sweep===null?null:sweep.topPlane());
|
|
392
1142
|
if(supports.some(({placement})=>placement.item.stackable===false))return false;
|
|
393
1143
|
if(box.z!==0&&ratio>0){
|
|
394
1144
|
const area=supports.reduce((total,support)=>total+support.area,0);
|
|
@@ -398,13 +1148,22 @@ function allowed(candidate,placed,container,globalSupportPpm,metrics){
|
|
|
398
1148
|
// decidable from the items alone; when neither fires, the three skipped checks
|
|
399
1149
|
// return false for every box anyway, and building n+1 boxes per feasible
|
|
400
1150
|
// candidate was pure allocation.
|
|
401
|
-
const needsLoads=container.maxStackDensity!=null||candidate.item.maxTop!=null||
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
1151
|
+
const needsLoads=container.maxStackDensity!=null||candidate.item.maxTop!=null||scene.placedMaxTop
|
|
1152
|
+
||candidate.item.maxCompressionKpa!=null||scene.placedCompressible;
|
|
1153
|
+
const needsGraph=needsLoads||candidate.item.maxStacked!=null||scene.placedMaxStacked;
|
|
1154
|
+
if(!needsGraph)return groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports,scene)
|
|
1155
|
+
&&(accessBase===null||accessibleAgainst(accessBase,corridorBox(candidate)));
|
|
1156
|
+
// With a base for this sweep, both the box list and the graph come from it by
|
|
1157
|
+
// appending one box, rather than each candidate rebuilding both from every placement.
|
|
1158
|
+
// The two paths are required to agree exactly, which is what `contact-graph`'s append
|
|
1159
|
+
// property test holds them to.
|
|
1160
|
+
const candidateBox=constraintBox(candidate),base=loadBase===null?null:loadBase();
|
|
1161
|
+
const boxes=base===null?[...placed.map(constraintBox),candidateBox]:[...base.graph.boxes,candidateBox];
|
|
1162
|
+
const graph=base===null?contactGraph(boxes):appendContactBox(base.graph,candidateBox,overlapXY);
|
|
1163
|
+
const loads=!needsLoads?null:base===null?topLoads(boxes,graph):topLoads(boxes,graph,settleOrderWith(base.order,boxes));
|
|
1164
|
+
return !overloaded(boxes,loads)&&!crushed(boxes,loads)&&!stackLimitsExceeded(boxes,graph)&&!stackDensityExceeded(boxes,container.maxStackDensity,loads)
|
|
1165
|
+
&&groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports,scene)
|
|
1166
|
+
&&(accessBase===null||accessibleAgainst(accessBase,corridorBox(candidate)));
|
|
408
1167
|
}
|
|
409
1168
|
|
|
410
1169
|
function supportRatioOf(placement,placed){
|
|
@@ -469,6 +1228,10 @@ function admitItem(raw,u){
|
|
|
469
1228
|
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');
|
|
470
1229
|
if(raw.value!=null&&(!Number.isSafeInteger(raw.value)||raw.value<0))throw new RangeError('value must be a non-negative safe integer');
|
|
471
1230
|
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');
|
|
1231
|
+
// The shape rules belong here for the reason this function exists: the compact lattice path
|
|
1232
|
+
// never builds an `items` entry, so an admission living only in the general path's item loop
|
|
1233
|
+
// would let the two disagree about which requests are legal.
|
|
1234
|
+
parseShape(raw,d,u,nesting);
|
|
472
1235
|
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');
|
|
473
1236
|
}
|
|
474
1237
|
|
|
@@ -514,6 +1277,12 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver
|
|
|
514
1277
|
const raw=req.items[0],quantity=raw.quantity??1;
|
|
515
1278
|
if(!Number.isSafeInteger(quantity)||quantity<1||raw.group!=null||(raw.tags??[]).length||(raw.incompatible_tags??[]).length
|
|
516
1279
|
||(raw.eligible_container_tags??[]).length||raw.max_stacked_items!=null||raw.nesting_height!=null
|
|
1280
|
+
// The lattice is closed-form over boxes: it counts cells from envelope extents and reports
|
|
1281
|
+
// volume from its own summary. It can see neither a hull -- it would tile bounding boxes
|
|
1282
|
+
// and call the result exact -- nor pressure, so a compressible column would be sized
|
|
1283
|
+
// without ever asking whether its base survives, and reported uncompressed. The general
|
|
1284
|
+
// search checks both per candidate.
|
|
1285
|
+
||(raw.shape_type!=null&&raw.shape_type!=='rigid_cuboid')
|
|
517
1286
|
||!['free',null,undefined].includes(raw.ground_contact_rule))return null;
|
|
518
1287
|
const itemDimensions=dims(raw.dimensions,u),weight=scalar(raw.weight??0,'g',WT);
|
|
519
1288
|
const rotations=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT));
|
|
@@ -769,6 +1538,15 @@ function unstartedRecord(solverAlias,index,globalDeadlineReached){return {
|
|
|
769
1538
|
global_deadline_reached:globalDeadlineReached,
|
|
770
1539
|
}}
|
|
771
1540
|
export function packFallback(req,clock=Date.now,solverAlias=null,startIndex=null,sharedDeadline=null){rejectUnsupported(req);
|
|
1541
|
+
// Admit the doors here, beside the other request-admission checks, and not
|
|
1542
|
+
// where they are canonicalised. The container template is built after the
|
|
1543
|
+
// uniform-lattice fast path has already returned, so validating there let a request
|
|
1544
|
+
// that took that path name a wall this engine has never heard of and be answered --
|
|
1545
|
+
// while Python, PHP and Rust refused the same request. The corpus could not see it:
|
|
1546
|
+
// the schema's own enum rejects a bad direction before any engine is asked, so the
|
|
1547
|
+
// divergence was reachable only from a library call, which is exactly how an
|
|
1548
|
+
// embedder reaches this engine.
|
|
1549
|
+
for(const container of req.containers??[])validateDirections(container.access_directions??[]);
|
|
772
1550
|
const requestedSolvers=req.configuration?.solvers??[],knownSolvers=['grid','extreme_points','homogeneous_blocks','layer','maximal_spaces','exact_small'];
|
|
773
1551
|
if(!Array.isArray(requestedSolvers)||requestedSolvers.some(name=>!knownSolvers.includes(name)))throw new RangeError(`unknown solver; expected one of ${knownSolvers.join(', ')}`);
|
|
774
1552
|
const exactItemLimit=req.configuration?.exact_item_limit??7;
|
|
@@ -783,7 +1561,7 @@ const restartLimit=effort?.max_restarts??Number.MAX_SAFE_INTEGER;
|
|
|
783
1561
|
// a k-start request consume up to k*time_limit_ms while still reporting one portfolio
|
|
784
1562
|
// deadline, which is both a determinism and an observability defect.
|
|
785
1563
|
const deadline=sharedDeadline??new Deadline(req.configuration?.time_limit_ms??1000,clock);
|
|
786
|
-
//
|
|
1564
|
+
// second review: the lowest_landed_cost refusal fires once, at the single
|
|
787
1565
|
// outermost frame, on the packing actually selected for return -- the same choke point
|
|
788
1566
|
// Rust, Python and PHP refuse at. A child solver/start run instead hands its result
|
|
789
1567
|
// back sentinel and all, so a portfolio sibling with a priceable answer is not aborted
|
|
@@ -825,7 +1603,7 @@ if(solverAlias===null&&requestedSolvers.length){
|
|
|
825
1603
|
winner.termination=aggregateTermination(starts);
|
|
826
1604
|
winner.algorithm=withPortfolioEffort(winner,runs);
|
|
827
1605
|
const alternativeLimit=Math.max(0,(req.configuration?.alternatives??3)-1);
|
|
828
|
-
// The sentinel is a search device, never an answer -- alternatives included (
|
|
1606
|
+
// The sentinel is a search device, never an answer -- alternatives included (review).
|
|
829
1607
|
winner.alternatives=runs.filter((run,index)=>index!==winnerIndex&&!run.unpriceableDetail).sort((a,b)=>compareScore(a.score,b.score)).slice(0,alternativeLimit);
|
|
830
1608
|
return finalizeOutermost(winner);
|
|
831
1609
|
}
|
|
@@ -915,15 +1693,25 @@ const policyRules=parsePolicy(req.policy);
|
|
|
915
1693
|
const compact=(policyRules.length||objective==='lowest_landed_cost')?null:compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solverAlias,metrics,effortExceeded,effortRemaining,deadline});
|
|
916
1694
|
if(compact!==null)return compact;
|
|
917
1695
|
const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scalar(raw.weight??0,'g',WT),rots=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT)),nesting=raw.nesting_height==null?null:scalar(raw.nesting_height,u,LEN);
|
|
918
|
-
|
|
1696
|
+
// The ordering keys below are functions of the item alone, computed once per type here
|
|
1697
|
+
// rather than as fresh BigInts on both sides of every comparison the sort makes.
|
|
1698
|
+
const vol=volume(d),longest=Math.max(...d);
|
|
1699
|
+
for(let i=1;i<=(raw.quantity??1);i++)items.push({raw,d,w,rots,vol,longest,id:`${raw.id}#${i}`,
|
|
919
1700
|
stackable:raw.stackable!==false,maxTop:raw.max_top_load==null?null:scalar(raw.max_top_load,'g',WT),
|
|
920
1701
|
supportPpm:Math.round((raw.minimum_support_ratio??0)*SUPPORT_SCALE),priority:raw.priority??0,
|
|
921
1702
|
tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null,
|
|
922
1703
|
nesting,maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null,
|
|
923
|
-
stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],value:raw.value??0
|
|
1704
|
+
stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],value:raw.value??0,
|
|
1705
|
+
...parseShape(raw,d,u,nesting)})}
|
|
924
1706
|
// Priority is a preference, not a guarantee: it leads the ordering so a caller can
|
|
925
1707
|
// bias the search, but ties (the default, priority 0 for all items) fall through to
|
|
926
1708
|
// the volume key unchanged.
|
|
1709
|
+
// Identifiers use the same Unicode-code-point order as Python, PHP, Rust and the commerce
|
|
1710
|
+
// API. Locale collation is host-dependent, while JavaScript's relational/default order is
|
|
1711
|
+
// UTF-16-code-unit order; both violate the cross-platform determinism contract outside
|
|
1712
|
+
// ASCII. Only the sign of a key matters to the sort, so the volume key compares BigInts
|
|
1713
|
+
// directly instead of materialising their difference.
|
|
1714
|
+
const compareId=compareCodePoints,ascendingVolume=(a,b)=>a.vol<b.vol?-1:a.vol>b.vol?1:0;
|
|
927
1715
|
items.sort((a,b)=>{
|
|
928
1716
|
const priority=b.priority-a.priority;if(priority)return priority;
|
|
929
1717
|
// Under `maximum_value` the second objective key is the value left behind, so the
|
|
@@ -937,15 +1725,15 @@ items.sort((a,b)=>{
|
|
|
937
1725
|
// loads with the last one. Every stop is Infinity when nothing declares one, so an
|
|
938
1726
|
// unrouted request keeps the ordering below untouched.
|
|
939
1727
|
{const stop=(b.stopIndex??Infinity)-(a.stopIndex??Infinity);if(stop)return stop}
|
|
940
|
-
if(qualityProfile&&(startIndex===null||startIndex===0))return
|
|
941
|
-
if(qualityProfile&&startIndex===1)return
|
|
942
|
-
if(solverAlias==='layer')return (b.d[2]-a.d[2])||(b.d[0]*b.d[1]-a.d[0]*a.d[1])||a.id
|
|
943
|
-
if(solverAlias==='maximal_spaces')return (
|
|
1728
|
+
if(qualityProfile&&(startIndex===null||startIndex===0))return a.longest-b.longest||ascendingVolume(a,b)||compareId(a.id,b.id);
|
|
1729
|
+
if(qualityProfile&&startIndex===1)return ascendingVolume(a,b)||a.longest-b.longest||compareId(a.id,b.id);
|
|
1730
|
+
if(solverAlias==='layer')return (b.d[2]-a.d[2])||(b.d[0]*b.d[1]-a.d[0]*a.d[1])||compareId(a.id,b.id);
|
|
1731
|
+
if(solverAlias==='maximal_spaces')return (b.longest-a.longest)||ascendingVolume(b,a)||compareId(a.id,b.id);
|
|
944
1732
|
// `exact_small` deliberately has no ordering of its own. It used to sort by id, which
|
|
945
1733
|
// was harmless while it was greedy-in-disguise and actively harmful once the search
|
|
946
1734
|
// became real: smallest-first is the worst descent order, so the first branch failed to
|
|
947
1735
|
// pack everything and the bound never pruned.
|
|
948
|
-
return
|
|
1736
|
+
return ascendingVolume(b,a)||compareId(a.id,b.id);
|
|
949
1737
|
});
|
|
950
1738
|
// Start 0 is the ordering above, so a single-start request is byte-identical to what it
|
|
951
1739
|
// produced before restarts existed; every later start re-solves a shuffle of it.
|
|
@@ -960,7 +1748,13 @@ const templates=req.containers.map(c=>{const d=dims(c.inner_dimensions,u),axleSp
|
|
|
960
1748
|
// innerVolume/reserve are pure functions of the immutable template, hoisted out of
|
|
961
1749
|
// candidatesFor's innermost (point x rotation) loop where they were recomputed as
|
|
962
1750
|
// fresh BigInts per orientation.
|
|
963
|
-
|
|
1751
|
+
// The walls this container may be unloaded through. Canonicalised into
|
|
1752
|
+
// ALL_DIRECTIONS order and deduplicated rather than kept as given, so two callers naming
|
|
1753
|
+
// the same doors in a different order search identically -- the same normalisation the
|
|
1754
|
+
// Python, PHP and Rust decoders apply, and the reason all four agree on the answer.
|
|
1755
|
+
validateDirections(c.access_directions??[]);
|
|
1756
|
+
const doors=Object.freeze(ALL_DIRECTIONS.filter(d=>(c.access_directions??[]).includes(d)));
|
|
1757
|
+
return {...c,d,outerD,doors,max:c.max_payload==null?null:scalar(c.max_payload,'g',WT),tare:scalar(c.tare_weight??0,'g',WT),axleSpec,reservePpm,
|
|
964
1758
|
innerVolume:volume(d),reserve:volume(d)*BigInt(reservePpm)/BigInt(SUPPORT_SCALE),
|
|
965
1759
|
rate:parseRateTable(c.rate_table),tagLimits:c.tag_limits??{},maxStackDensity,
|
|
966
1760
|
obs:(c.obstacles??[]).flatMap(o=>[o,...(o.additional_boxes??[])]).map(o=>({x:scalar(o.origin?.x??0,u,LEN),y:scalar(o.origin?.y??0,u,LEN),z:scalar(o.origin?.z??0,u,LEN),d:dims(o.dimensions,u)}))}}).sort((a,b)=>objective==='shipping_cost'||objective==='lowest_landed_cost'?(dimensionalWeight(a.outerD)-dimensionalWeight(b.outerD)||(a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))):((a.cost_minor??0)-(b.cost_minor??0)||Number(volume(a.d)-volume(b.d))));
|
|
@@ -994,37 +1788,100 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{
|
|
|
994
1788
|
// the check belongs here beside the eligibility and tag-limit gates rather than inside
|
|
995
1789
|
// the point loop, and costs O(m + r) per (template, item) instead of per candidate.
|
|
996
1790
|
if(policyRules.length&&policyRejection(policyRules,item.tags,tmpl.tags??[],tagOccurrences(state.placements))!==null)return [];
|
|
1791
|
+
// The placed boxes do not move for the whole of this item's candidate sweep, so
|
|
1792
|
+
// their contact graph is built once here -- on first demand, since most candidates never
|
|
1793
|
+
// reach a load rule -- and every candidate appends to it instead of rebuilding.
|
|
1794
|
+
//
|
|
1795
|
+
// Nesting is excluded: a nesting predecessor *replaces* the face edges of everything in
|
|
1796
|
+
// its column, so one new placement can rewrite edges arbitrarily far from itself and the
|
|
1797
|
+
// delta is no longer local. Nesting keeps the from-scratch path.
|
|
1798
|
+
const nestingPresent=item.nesting!=null||state.placements.some(p=>p.item.nesting!=null);
|
|
1799
|
+
let loadBaseGraph;
|
|
1800
|
+
const loadBase=nestingPresent?null:()=>{
|
|
1801
|
+
if(loadBaseGraph===undefined){
|
|
1802
|
+
// The cell must cover every box hashed into the broad phase or queried against it,
|
|
1803
|
+
// and the candidate is a new item that may be wider than anything placed -- so the
|
|
1804
|
+
// hint comes from this item's own rotations, which are known here.
|
|
1805
|
+
const widest=Math.max(1,...item.rots.flatMap(r=>{const pd=rotate(item.d,r);
|
|
1806
|
+
return [pd[0]+2*clear,pd[1]+2*clear]}));
|
|
1807
|
+
const graph=buildContactGraph(state.placements.map(constraintBox),overlapXY,widest);
|
|
1808
|
+
// The settled order of the base scene, sorted once here: each candidate then slots
|
|
1809
|
+
// itself in rather than re-sorting the scene (`settleOrderWith`).
|
|
1810
|
+
loadBaseGraph={graph,order:settleOrder(graph.boxes)};
|
|
1811
|
+
}
|
|
1812
|
+
return loadBaseGraph;
|
|
1813
|
+
};
|
|
1814
|
+
// The same argument, for the other rule that reads the whole placed scene. The
|
|
1815
|
+
// doors now come from the container, which is why the hoist mattered: switching the field
|
|
1816
|
+
// on inside `allowed` would have turned an O(m*|D|) check into O(m^2*|D|) per candidate.
|
|
1817
|
+
// A container that states no doors leaves the base inert at the cost of one pass over the
|
|
1818
|
+
// stops, which is what every request that is not a multi-drop route pays.
|
|
1819
|
+
const accessBase=stopAccessibilityBase(item.stopIndex,state.placements,tmpl,tmpl.doors);
|
|
1820
|
+
const compressionSensitive=item.shapeType==='compressible'
|
|
1821
|
+
||state.placements.some(placement=>placement.item.shapeType==='compressible');
|
|
997
1822
|
const found=[];
|
|
998
1823
|
const candidates=points.length>maxCandidatePoints?points.slice(0,maxCandidatePoints):points;
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1824
|
+
// Everything that is a function of (item, rotation) alone -- the rotated envelope, the
|
|
1825
|
+
// volume it occupies, its hull -- is computed once per sweep here rather than once per
|
|
1826
|
+
// point, the class of waste docs/PERFORMANCE-PRACTICE.md ranks first. The volume gate is
|
|
1827
|
+
// also a function of `used`, fixed for the sweep, so it collapses to one boolean per
|
|
1828
|
+
// orientation; only a nesting item keeps the per-position delta, because what it can
|
|
1829
|
+
// nest into depends on where it lands. The payload and count gates depend on nothing
|
|
1830
|
+
// the point loop changes. Every gate is pure, so the order they are asked in is free.
|
|
1831
|
+
const placements=state.placements,nests=item.nesting!=null,sweep=sweepContext(item,placements);
|
|
1832
|
+
const payloadBlocked=tmpl.max!=null&&state.payload+item.w>tmpl.max,countBlocked=tmpl.max_items!=null&&placements.length>=tmpl.max_items;
|
|
1833
|
+
const exactHull=item.shapeType==='convex_hull'&&item.stopIndex==null&&clear===0;
|
|
1834
|
+
const orientations=item.rots.map(r=>{const pd=rotate(item.d,r),ed=pd.map(x=>x+2*clear);
|
|
1835
|
+
return {r,pd,ed,shape:exactHull?shapeFor(item.hullVertices,r):null,
|
|
1836
|
+
volumeBlocked:!compressionSensitive&&!nests&&used+occupiedVolume({pd,r,item})+tmpl.reserve>tmpl.innerVolume}});
|
|
1837
|
+
const [innerX,innerY,innerZ]=tmpl.d,obstacles=tmpl.obs;
|
|
1838
|
+
candidatePoints:for(const pt of candidates){if(candidateEffortExceeded())break;metrics.candidate_points_considered++;const x=pt[0],y=pt[1],z=pt[2];
|
|
1839
|
+
for(const orientation of orientations){if(candidateEffortExceeded())break candidatePoints;metrics.orientations_considered++;if(deadline.expired()){timeLimitReached=true;break candidatePoints}
|
|
1840
|
+
const {r,pd,ed}=orientation,x2=x+ed[0],y2=y+ed[1],z2=z+ed[2];
|
|
1841
|
+
if(x2>innerX||y2>innerY||z2>innerZ)continue;
|
|
1842
|
+
if(payloadBlocked||countBlocked||orientation.volumeBlocked)continue;
|
|
1843
|
+
const candidate={x,y,z,pd,ed,r,item};
|
|
1844
|
+
if(nests&&!compressionSensitive&&used+usedVolumeDelta(placements,candidate)+tmpl.reserve>tmpl.innerVolume)continue;
|
|
1005
1845
|
let collision=false;
|
|
1006
|
-
|
|
1846
|
+
const candidateShape=orientation.shape,box={x,y,z,d:ed};
|
|
1847
|
+
for(const obstacle of obstacles){metrics.collision_checks++;
|
|
1848
|
+
if(intersects(box,obstacle)&&solidsOverlap(candidateShape,box,null,obstacle)){collision=true;break}}
|
|
1007
1849
|
// Broad phase: visit only the placements sharing a cell with `box`, stamping each
|
|
1008
1850
|
// so a placement spanning several cells is narrow-phase-checked once. A generation
|
|
1009
1851
|
// counter does that without allocating a set per candidate orientation.
|
|
1010
|
-
if(!collision){const [ix1,ix2,iy1,iy2,iz1,iz2]=cellRange(index,box),stamp=++index.gen
|
|
1852
|
+
if(!collision){const [ix1,ix2,iy1,iy2,iz1,iz2]=cellRange(index,box),stamp=++index.gen;
|
|
1011
1853
|
scan:for(let ix=ix1;ix<ix2;ix++)for(let iy=iy1;iy<iy2;iy++)for(let iz=iz1;iz<iz2;iz++){
|
|
1012
1854
|
const bucket=index.cells.get(cellKey(ix,iy,iz));if(!bucket)continue;
|
|
1013
1855
|
for(const position of bucket){if(index.seen[position]===stamp)continue;index.seen[position]=stamp;
|
|
1014
|
-
const placed=
|
|
1015
|
-
|
|
1856
|
+
const placed=placements[position],pe=placed.ed;metrics.collision_checks++;
|
|
1857
|
+
// `intersects` on the placement's own fields: copying each visited placement into
|
|
1858
|
+
// a box first was one allocation per narrow-phase check, a million per solve. The
|
|
1859
|
+
// nesting exemption can only apply to a nesting candidate, and the box the exact
|
|
1860
|
+
// test needs is built only once an envelope overlap has been found.
|
|
1861
|
+
if(x<placed.x+pe[0]&&x2>placed.x&&y<placed.y+pe[1]&&y2>placed.y&&z<placed.z+pe[2]&&z2>placed.z
|
|
1862
|
+
&&!(nests&&validNesting(candidate,placed))
|
|
1863
|
+
// The axis-aligned test is the broad phase and stays mandatory. Only when a hull
|
|
1864
|
+
// is one of the two solids does the exact test get to overrule it, so a request of
|
|
1865
|
+
// ordinary boxes never reaches the hull path at all.
|
|
1866
|
+
&&solidsOverlap(candidateShape,box,placedHull(placed),{x:placed.x,y:placed.y,z:placed.z,d:pe})){collision=true;break scan}}}}
|
|
1016
1867
|
if(collision)continue;
|
|
1017
|
-
|
|
1018
|
-
if(
|
|
1019
|
-
|
|
1868
|
+
if(axleOverloaded(tmpl,placements,candidate))continue;
|
|
1869
|
+
if(!allowed(candidate,placements,tmpl,globalSupportPpm,metrics,loadBase,accessBase,sweep))continue;
|
|
1870
|
+
// With zero load the candidate is at its largest, and appending it can only shrink
|
|
1871
|
+
// existing compressible supports. If that upper bound fits, the exact support-graph
|
|
1872
|
+
// refresh cannot reject it; only a candidate near the reserve boundary pays the
|
|
1873
|
+
// non-local calculation. Ordinary requests retain the incremental O(1) path above.
|
|
1874
|
+
if(compressionSensitive){const upperBound=used+occupiedVolume(candidate);
|
|
1875
|
+
if(upperBound+tmpl.reserve>tmpl.innerVolume
|
|
1876
|
+
&&usedVolume([...placements,candidate])+tmpl.reserve>tmpl.innerVolume)continue}
|
|
1020
1877
|
metrics.feasible_candidates++;
|
|
1021
1878
|
const score=solverAlias==='grid'
|
|
1022
|
-
?
|
|
1879
|
+
?z*1e12+y*1e6+x
|
|
1023
1880
|
:solverAlias==='layer'
|
|
1024
|
-
?
|
|
1881
|
+
?z2*1e12+z*1e8+y*1e4+x
|
|
1025
1882
|
:solverAlias==='maximal_spaces'
|
|
1026
|
-
?
|
|
1027
|
-
:
|
|
1883
|
+
?x2+y2+z2*1e6
|
|
1884
|
+
:z2*1e9+y2*1e4+x2;
|
|
1028
1885
|
if(width===1){if(!found.length||score<found[0].score)found[0]={score,...candidate};continue}
|
|
1029
1886
|
found.push({score,...candidate})}}
|
|
1030
1887
|
if(width===1)return found;
|
|
@@ -1047,9 +1904,13 @@ const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const state={tmpl,placements:[
|
|
|
1047
1904
|
metrics.search_nodes_expanded++;
|
|
1048
1905
|
const best=candidatesFor(tmpl,item,state,points,index,used,1)[0];
|
|
1049
1906
|
if(!best){ok=false;break}
|
|
1050
|
-
state.payload+=item.w;
|
|
1907
|
+
state.payload+=item.w;
|
|
1908
|
+
const compressionSensitive=item.shapeType==='compressible'
|
|
1909
|
+
||state.placements.some(placement=>placement.item.shapeType==='compressible');
|
|
1910
|
+
used=compressionSensitive?usedVolume([...state.placements,best]):used+usedVolumeDelta(state.placements,best);
|
|
1911
|
+
state.placements.push(best);
|
|
1051
1912
|
indexAdd(index,state.placements.length-1,{x:best.x,y:best.y,z:best.z,d:best.ed});
|
|
1052
|
-
|
|
1913
|
+
retirePointsForPlacement(points,best);
|
|
1053
1914
|
for(const point of pointsFrom(best))insertPoint(points,point)}
|
|
1054
1915
|
if(!ok){state.placements=snapshotPlacements;state.payload=snapshotPayload;used=snapshotUsed;
|
|
1055
1916
|
if(snapshotPoints)points.splice(0,points.length,...snapshotPoints);
|
|
@@ -1063,9 +1924,12 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{
|
|
|
1063
1924
|
index:makeIndex(tmpl.d),unplaced:[]});
|
|
1064
1925
|
const clone=node=>({state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used,
|
|
1065
1926
|
points:node.points.slice(),index:copyIndex(node.index),unplaced:node.unplaced.slice()});
|
|
1066
|
-
const place=(node,candidate)=>{node.state.payload+=candidate.item.w;
|
|
1927
|
+
const place=(node,candidate)=>{node.state.payload+=candidate.item.w;
|
|
1928
|
+
const compressionSensitive=candidate.item.shapeType==='compressible'
|
|
1929
|
+
||node.state.placements.some(placement=>placement.item.shapeType==='compressible');
|
|
1930
|
+
node.used=compressionSensitive?usedVolume([...node.state.placements,candidate]):node.used+usedVolumeDelta(node.state.placements,candidate);
|
|
1067
1931
|
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});
|
|
1068
|
-
|
|
1932
|
+
retirePointsForPlacement(node.points,candidate);for(const point of pointsFrom(candidate))insertPoint(node.points,point)};
|
|
1069
1933
|
const sortCosts=costs=>costs.sort((a,b)=>a<b?-1:a>b?1:0);
|
|
1070
1934
|
const maxCount=(sortedCosts,capacity)=>{let used=0n,count=0;for(const cost of sortedCosts){if(used+cost>capacity)break;used+=cost;count++}return count};
|
|
1071
1935
|
// `future` is the same array for every comparison inside one `expansions.sort(...)`
|
|
@@ -1082,7 +1946,7 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{
|
|
|
1082
1946
|
difference=b.state.placements.length-a.state.placements.length;if(difference)return difference;
|
|
1083
1947
|
const az=a.state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0),bz=b.state.placements.reduce((z,p)=>Math.max(z,p.z+p.ed[2]),0);
|
|
1084
1948
|
if(az!==bz)return az-bz;if(a.used!==b.used)return a.used>b.used?-1:1;
|
|
1085
|
-
const signature=node=>node.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|');return signature(a)
|
|
1949
|
+
const signature=node=>node.state.placements.map(p=>`${p.item.id}@${p.x},${p.y},${p.z}`).join('|');return compareCodePoints(signature(a),signature(b))};
|
|
1086
1950
|
const greedy=tryPackIntoTemplate(tmpl,itemsRemaining);let beam=[fresh()],incumbent=fresh();incumbent.state=greedy.state;incumbent.used=greedy.used;incumbent.unplaced=greedy.next;let nodes=0;
|
|
1087
1951
|
for(let position=0;position<batches.length;position++){
|
|
1088
1952
|
const batch=batches[position],future=batches.slice(position+1).flat(),expansions=[];let exhausted=false;
|
|
@@ -1124,7 +1988,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{
|
|
|
1124
1988
|
w.state.payload+=candidate.item.w;w.used+=usedVolumeDelta(w.state.placements,candidate);
|
|
1125
1989
|
w.state.placements.push(candidate);
|
|
1126
1990
|
indexAdd(w.index,w.state.placements.length-1,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed});
|
|
1127
|
-
|
|
1991
|
+
retirePointsForPlacement(w.points,candidate);
|
|
1128
1992
|
for(const point of pointsFrom(candidate))insertPoint(w.points,point)};
|
|
1129
1993
|
// One child per feasible candidate for a lone item; a group is all-or-nothing, so it
|
|
1130
1994
|
// contributes at most one child placed greedily member by member.
|
|
@@ -1243,7 +2107,7 @@ const homogeneousBlocksSupported=()=>policyRules.length===0&&templates.every(t=>
|
|
|
1243
2107
|
&&items.every(i=>i.group==null&&!i.tags.length&&!i.incompatible.length&&!i.eligibleTags.length
|
|
1244
2108
|
&&i.stackable&&!i.raw.must_be_on_floor&&i.maxTop==null&&i.maxStacked==null
|
|
1245
2109
|
&&i.supportPpm===0&&(i.groundRule==null||i.groundRule==='free')&&i.nesting==null&&i.stopIndex==null);
|
|
1246
|
-
const compareBlockValue=(a,b)=>typeof a==='bigint'?(a<b?-1:a>b?1:0):typeof a==='string'?a
|
|
2110
|
+
const compareBlockValue=(a,b)=>typeof a==='bigint'?(a<b?-1:a>b?1:0):typeof a==='string'?compareCodePoints(a,b):a-b;
|
|
1247
2111
|
const compareBlockKey=(a,b)=>{for(let i=0;i<a.length;i++){const difference=compareBlockValue(a[i],b[i]);if(difference)return difference}return 0};
|
|
1248
2112
|
const containsSpace=(outer,inner)=>outer.x<=inner.x&&outer.y<=inner.y&&outer.z<=inner.z
|
|
1249
2113
|
&&outer.x+outer.d[0]>=inner.x+inner.d[0]&&outer.y+outer.d[1]>=inner.y+inner.d[1]&&outer.z+outer.d[2]>=inner.z+inner.d[2];
|
|
@@ -1323,7 +2187,7 @@ if(containerPlanBeamWidth>1&&solverAlias!=='exact_small'){
|
|
|
1323
2187
|
if(exhausted||!expansions.length)break;
|
|
1324
2188
|
const dominant=new Map();for(const plan of expansions){const signature=`${plan.remaining.map(i=>i.id).join('|')}::${[...plan.inventory.entries()].sort().map(([k,v])=>`${k}:${v}`).join('|')}`;
|
|
1325
2189
|
const previous=dominant.get(signature);if(!previous||compareScore(planScore(plan,[]),planScore(previous,[]))<0)dominant.set(signature,plan)}
|
|
1326
|
-
beam=[...dominant.values()].sort((a,b)=>compareScore(planBound(a),planBound(b))||a.remaining.map(i=>i.id).join('|')
|
|
2190
|
+
beam=[...dominant.values()].sort((a,b)=>compareScore(planBound(a),planBound(b))||compareCodePoints(a.remaining.map(i=>i.id).join('|'),b.remaining.map(i=>i.id).join('|'))).slice(0,containerPlanBeamWidth)}
|
|
1327
2191
|
packed.push(...incumbent.packed);remaining.splice(0,remaining.length,...incumbent.remaining);seq=incumbent.seq;
|
|
1328
2192
|
}else while(remaining.length&&packed.length<maxContainers){
|
|
1329
2193
|
if(deadline.expired()){timeLimitReached=true;break}
|
|
@@ -1411,7 +2275,7 @@ for(const c of packed){scoreCost+=c.tmpl.cost_minor??0;
|
|
|
1411
2275
|
// to invent -- so the refusal fires, but once, at the outermost frame, on the packing
|
|
1412
2276
|
// actually selected for return: a portfolio sibling with a priceable answer must not be
|
|
1413
2277
|
// aborted by this run's refusal. Rust, Python and PHP refuse at the same single choke
|
|
1414
|
-
// point (
|
|
2278
|
+
// point (second review). The detail rides the result as a non-enumerable property
|
|
1415
2279
|
// below, a search device that never serializes.
|
|
1416
2280
|
let unpriceableDetail=null;
|
|
1417
2281
|
if(objective==='lowest_landed_cost')for(const c of packed){
|
|
@@ -1454,6 +2318,8 @@ function rebalanceContext(req,result){
|
|
|
1454
2318
|
nesting:raw.nesting_height==null?null:scalar(raw.nesting_height,unit,LEN),
|
|
1455
2319
|
maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null,
|
|
1456
2320
|
stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],
|
|
2321
|
+
...parseShape(raw,dims(raw.dimensions,unit),unit,
|
|
2322
|
+
raw.nesting_height==null?null:scalar(raw.nesting_height,unit,LEN)),
|
|
1457
2323
|
})
|
|
1458
2324
|
}
|
|
1459
2325
|
const templates=new Map((req.containers??[]).map(raw=>{
|
|
@@ -1526,7 +2392,7 @@ function rebalanceValid(context,result){
|
|
|
1526
2392
|
if(usedVolume(state.placements)+volume(state.tmpl.d)*BigInt(state.tmpl.reservePpm)/BigInt(SUPPORT_SCALE)>volume(state.tmpl.d))return false;
|
|
1527
2393
|
if(axleOverloaded(state.tmpl,state.placements))return false;
|
|
1528
2394
|
const placed=[];
|
|
1529
|
-
for(const candidate of [...state.placements].sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||a.item.id
|
|
2395
|
+
for(const candidate of [...state.placements].sort((a,b)=>a.z-b.z||a.y-b.y||a.x-b.x||compareCodePoints(a.item.id,b.item.id))){
|
|
1530
2396
|
if(!allowed(candidate,placed,state.tmpl,context.globalSupportPpm,{support_checks:0}))return false;
|
|
1531
2397
|
// A move the rules forbid must fail the same check a placement did. Replaying the
|
|
1532
2398
|
// container in this order is what makes a cap or a segregation answerable at all:
|
|
@@ -1588,7 +2454,7 @@ export function rebalanceWeight(req,result,{maxMoves=64}={}){
|
|
|
1588
2454
|
}
|
|
1589
2455
|
const context=rebalanceContext(req,result),moves=[];
|
|
1590
2456
|
if(!rebalanceValid(context,result))throw new TypeError('result is not a valid packing of this request');
|
|
1591
|
-
//
|
|
2457
|
+
// second review: under `lowest_landed_cost` a move is a re-pricing -- shifting
|
|
1592
2458
|
// payload can push a destination past its rate table's last bracket, leaving the
|
|
1593
2459
|
// "balanced" packing with no published price. States are priced with the same helpers
|
|
1594
2460
|
// the packer bills with: an unpriceable input is refused up front in the standard
|
|
@@ -1661,7 +2527,7 @@ export class SequenceReplayError extends Error{
|
|
|
1661
2527
|
}
|
|
1662
2528
|
export class SequenceWarning{
|
|
1663
2529
|
constructor(code,index,messageKey,arguments_={}){this.code=code;this.index=index;this.message_key=messageKey;
|
|
1664
|
-
this.arguments=Object.fromEntries(Object.entries(arguments_).sort(([a],[b])=>a
|
|
2530
|
+
this.arguments=Object.fromEntries(Object.entries(arguments_).sort(([a],[b])=>compareCodePoints(a,b)));Object.freeze(this.arguments);Object.freeze(this)}
|
|
1665
2531
|
toJSON(){return {code:this.code,index:this.index,message_key:this.message_key,arguments:this.arguments}}
|
|
1666
2532
|
}
|
|
1667
2533
|
function sequenceInteger(value,name){if(!Number.isSafeInteger(value))throw new RangeError(`${name} must be a safe integer tick count`);return value}
|