@packvium/engine 0.1.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fallback.js CHANGED
@@ -1,4 +1,4 @@
1
- import { buildContactGraph } from './contact-graph.js';
1
+ import { appendContactBox, buildContactGraph } from './contact-graph.js';
2
2
  import { parsePolicy, policyRejection, provesUnplaceable, tagOccurrences } from './policy.js';
3
3
 
4
4
  const LEN={mm:16000,cm:160000,m:16000000,in:406400,inch:406400,inches:406400,ft:4876800,tick:1,ticks:1};
@@ -20,9 +20,20 @@ const UNSUPPORTED={
20
20
  // container, which none of the per-entry loops below would ever see.
21
21
  request:[],
22
22
  configuration:[],
23
+ // `hull_vertices`, `compression_ratio` and `max_compression_pressure_kpa` left this list
24
+ // in , the last engine to gain both the solver behaviour and the independent
25
+ // validation the staged rollout requires.
23
26
  item:[],
24
27
  container:[],
25
28
  obstacle:[],
29
+ // `item.shape_type` values this engine does not implement. Presence is the
30
+ // wrong test for this one field: `rigid_cuboid` is the default and is implemented, so a
31
+ // caller that spells the default out must be served, not refused. What is unimplemented
32
+ // is a *value*, and the refusal names it -- packing a `convex_hull` item as its bounding
33
+ // box would return a plan that looks valid and does not physically fit.
34
+ // Empty since : this engine implements every value the schema defines. The guard
35
+ // stays because the next reserved value will need it.
36
+ shapeType:[],
26
37
  };
27
38
  // The admission boundary for staged public-field rollouts, exported so a test can assert
28
39
  // that what the lists name is exactly what the guard refuses -- the counterpart of
@@ -91,6 +102,8 @@ function rejectUnsupported(req){const fields=[];
91
102
  for(const key of UNSUPPORTED.container)if(hasOwn(raw,key))fields.push(`container.${key}`);
92
103
  for(const obstacle of raw.obstacles??[])for(const key of UNSUPPORTED.obstacle)if(hasOwn(obstacle,key))fields.push(`obstacle.${key}`);
93
104
  }
105
+ for(const raw of req.items??[]){const shape=raw?.shape_type;
106
+ if(typeof shape==='string'&&UNSUPPORTED.shapeType.includes(shape))fields.push(`item.shape_type=${shape}`)}
94
107
  if(fields.length)throw new UnsupportedFeatureError([...new Set(fields)].sort());
95
108
  }
96
109
  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 +113,558 @@ function dims(v,u){return [scalar(v.length,u,LEN),scalar(v.width,u,LEN),scalar(v
100
113
  function rotate(d,r){return ROT[r].map(i=>d[i])}
101
114
  function volume(d){return BigInt(d[0])*BigInt(d[1])*BigInt(d[2])}
102
115
  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}
116
+
117
+ // ---------------------------------------------------------------- irregular geometry
118
+ //
119
+ // The rule is fixed by docs/IRREGULAR-ITEMS.md. Every product here is a `BigInt`, and not for
120
+ // tidiness: a separating axis is a cross product of two edge vectors, so its components grow
121
+ // as the square of a coordinate and a projection grows as the cube. At the shared coordinate
122
+ // cap a cross product reaches 8e16 and a projection 2.4e25, while a JavaScript number is exact
123
+ // only to 2^53 ~ 9e15. Both would silently lose precision, and a collision predicate that
124
+ // rounds returns a plan that validates and does not fit. Rust carries the same arithmetic in
125
+ // `i128`; PHP needs a decimal-string fallback; here `BigInt` is already the house answer, used
126
+ // for load distribution since the first port.
127
+
128
+ /** Largest vertex coordinate a hull may carry, in ticks -- 6.25 m. Shared with every engine:
129
+ * they must refuse the same hulls or they disagree about which requests are legal. */
130
+ const MAX_HULL_COORDINATE=100000000;
131
+ const UNIT_AXES=[[1n,0n,0n],[0n,1n,0n],[0n,0n,1n]];
132
+ const sub3=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]];
133
+ 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]];
134
+ const dot3=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
135
+ const big3=v=>[BigInt(v[0]),BigInt(v[1]),BigInt(v[2])];
136
+ 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}
137
+ /** Divide out the gcd and fix the sign, so parallel axes collapse to one entry. `null` for the
138
+ * zero vector: a cross product of parallel directions names no axis, an ordinary outcome. */
139
+ function primitiveAxis(v){
140
+ const g=bigGcd(bigGcd(v[0],v[1]),v[2]);
141
+ if(g===0n)return null;
142
+ const r=[v[0]/g,v[1]/g,v[2]/g];
143
+ const lead=r.find(x=>x!==0n);
144
+ return lead>0n?r:[-r[0],-r[1],-r[2]];
145
+ }
146
+ const axisKey=v=>`${v[0]},${v[1]},${v[2]}`;
147
+ /** Lexicographic order on the vertex vector itself. `axisKey` is for identity, never order. */
148
+ const compareVertices=(l,r)=>{
149
+ for(let i=0;i<3;i++)if(l[i]!==r[i])return l[i]<r[i]?-1:1;
150
+ return 0;
151
+ };
152
+ /** Canonicalise an authored vertex list or refuse a hull with no interior. A zero-volume hull
153
+ * is separated from everything on its own normal, so it would pass through every other item
154
+ * and still be reported as a valid placement. */
155
+ function hullValidate(vertices){
156
+ const points=vertices.map(v=>[Number(v[0]),Number(v[1]),Number(v[2])]);
157
+ if(points.length<4)throw new RangeError(`a convex hull needs at least 4 vertices, got ${points.length}`);
158
+ if(new Set(points.map(axisKey)).size!==points.length)throw new RangeError('convex hull vertices must be unique');
159
+ if(points.some(v=>v.some(c=>Math.abs(c)>MAX_HULL_COORDINATE)))
160
+ throw new RangeError(`convex hull coordinates must stay within ${MAX_HULL_COORDINATE} ticks`);
161
+ const b=points.map(big3);
162
+ 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++)
163
+ if(dot3(sub3(b[l],b[i]),cross3(sub3(b[j],b[i]),sub3(b[k],b[i])))!==0n)return points;
164
+ throw new RangeError('convex hull vertices are coplanar and enclose no volume');
165
+ }
166
+ /** Does the plane through `origin` with normal `axis` leave every vertex on one side? */
167
+ function isSupporting(points,origin,axis){
168
+ const offset=dot3(origin,axis);let above=false,below=false;
169
+ for(const v of points){const side=dot3(v,axis)-offset;
170
+ if(side>0n)above=true;else if(side<0n)below=true;
171
+ if(above&&below)return false}
172
+ return true;
173
+ }
174
+ /** Corners of one planar convex face, in cyclic order seen from outside.
175
+ *
176
+ * The vertices sharing a supporting plane are not all corners of the polygon they lie on: one
177
+ * can sit inside the face or part-way along an edge, and fanning over the raw set triangulates
178
+ * the wrong region -- the surface then fails to close and the volume is wrong. Gift-wrapping
179
+ * keeps only the corners, resolving collinear candidates to the farthest so an edge-interior
180
+ * vertex is walked past rather than doubled back through. */
181
+ function windFace(face,outward){
182
+ // By the vertex vector, never its decimal encoding: the walk is only correct because it
183
+ // starts from a corner, which it earns by starting from the smallest vertex under a genuine
184
+ // linear order. String order is not one -- "10,0,0" sorts before "9,0,0" -- so it can name a
185
+ // vertex lying inside the face and make the walk emit a segment that is not a hull edge.
186
+ const sorted=[...face].sort(compareVertices);
187
+ const ordered=[sorted[0]];let current=sorted[0];
188
+ for(let step=0;step<sorted.length;step++){
189
+ let next=null;
190
+ for(const candidate of sorted){
191
+ if(axisKey(candidate)===axisKey(current))continue;
192
+ if(next===null){next=candidate;continue}
193
+ const turn=dot3(cross3(sub3(next,current),sub3(candidate,current)),outward);
194
+ const reach=dot3(sub3(candidate,current),sub3(candidate,current));
195
+ const held=dot3(sub3(next,current),sub3(next,current));
196
+ if(turn<0n||(turn===0n&&reach>held))next=candidate;
197
+ }
198
+ if(next===null||axisKey(next)===axisKey(sorted[0]))break;
199
+ ordered.push(next);current=next;
200
+ }
201
+ return ordered;
202
+ }
203
+ /** Every face of the hull, each as its own corners in outward cyclic order.
204
+ *
205
+ * One walk, because the faces answer two questions at once: the volume needs them wound
206
+ * consistently, and the hull's edges are the consecutive corner pairs of the same walk. A
207
+ * plane carrying fewer than three vertices is an edge or a corner of the hull, not a face,
208
+ * and carries no edge its two adjoining faces do not already carry. */
209
+ function woundFaces(points,faceAxes){
210
+ const faces=[];
211
+ for(const axis of faceAxes)for(const outward of [axis,[-axis[0],-axis[1],-axis[2]]]){
212
+ let extreme=null;
213
+ for(const v of points){const value=dot3(v,outward);if(extreme===null||value>extreme)extreme=value}
214
+ const face=points.filter(v=>dot3(v,outward)===extreme);
215
+ if(face.length<3)continue;
216
+ faces.push(windFace(face,outward));
217
+ }
218
+ return faces;
219
+ }
220
+ /** Exact volume in cubic ticks, by the divergence theorem over the hull's own faces. */
221
+ function hullVolume(faces){
222
+ let six=0n;
223
+ for(const ordered of faces){
224
+ const apex=ordered[0];
225
+ for(let i=1;i+1<ordered.length;i++)six+=dot3(apex,cross3(ordered[i],ordered[i+1]));
226
+ }
227
+ const magnitude=six<0n?-six:six;
228
+ return magnitude/6n;
229
+ }
230
+ /** Directions of the hull's real edges, deduplicated and canonical.
231
+ *
232
+ * Every edge of a convex polyhedron is shared by exactly two faces, so walking each wound
233
+ * face and taking its consecutive corner pairs -- closing the cycle -- reaches all of them.
234
+ * The separating-axis theorem asks for exactly these, not for every vertex pair.
235
+ *
236
+ * The distinction is the whole cost of the predicate. A hull has at most `3v - 6` edges but
237
+ * `v(v - 1) / 2` vertex pairs, and the axis set is the *product* of two hulls' sets, so the
238
+ * gap squares: on a 20-vertex hull, 1351 candidate axes rather than 15616. Vertex pairs were
239
+ * never wrong, only a superset -- a pair that is not an edge names a direction no face can
240
+ * separate along, so it can add an axis but never remove one. */
241
+ function hullEdges(faces){
242
+ const edges=new Map();
243
+ for(const ordered of faces)for(let i=0;i<ordered.length;i++){
244
+ const axis=primitiveAxis(sub3(ordered[(i+1)%ordered.length],ordered[i]));
245
+ if(axis)edges.set(axisKey(axis),axis);
246
+ }
247
+ return [...edges.values()].sort(compareVertices);
248
+ }
249
+ /** A hull's separating axes and volume in its own local frame, computed once per shape. */
250
+ function hullShape(vertices){
251
+ const points=hullValidate(vertices).map(big3);
252
+ const faces=new Map();
253
+ for(let i=0;i<points.length;i++)for(let j=i+1;j<points.length;j++){
254
+ for(let k=j+1;k<points.length;k++){
255
+ // A triple whose plane cuts through the solid is not a face, and its normal separates
256
+ // nothing the real face normals do not.
257
+ const axis=primitiveAxis(cross3(sub3(points[j],points[i]),sub3(points[k],points[i])));
258
+ if(axis&&isSupporting(points,points[i],axis))faces.set(axisKey(axis),axis);
259
+ }
260
+ }
261
+ const faceAxes=[...faces.values()].sort(compareVertices);
262
+ const wound=woundFaces(points,faceAxes);
263
+ return {v:points,faces:faceAxes,edges:hullEdges(wound),volume:hullVolume(wound)};
264
+ }
265
+ /** A copied, string-exact view used only by this module's direct tests.
266
+ *
267
+ * `fallback.js` is an internal package file: package.json exposes only `index.js`, which does
268
+ * not re-export this function. Keeping the probe beside the algorithm lets the suite assert
269
+ * edge identity and ordering without widening `@packvium/native`'s public API or handing a
270
+ * mutable cached shape to a caller. */
271
+ export function __inspectHullShapeForTests(vertices){
272
+ const shape=hullShape(vertices),copy=axes=>axes.map(axis=>axis.map(value=>value.toString()));
273
+ return {volume:shape.volume.toString(),faceAxes:copy(shape.faces),edgeDirections:copy(shape.edges)};
274
+ }
275
+ /** How many rotated hulls stay resident, before the memo is dropped and refilled. A request is
276
+ * bounded by its distinct hull items times the six orientations, so this holds far more than
277
+ * any request the solver is sized for -- and bounded, rather than growing for the life of the
278
+ * process. */
279
+ const SHAPE_CACHE_ENTRIES=1024;
280
+ const shapeCache=new Map();
281
+ /** The rotated hull of one item in one orientation, built at most once.
282
+ *
283
+ * A hull depends on the item and the orientation and on nothing about where a candidate sits,
284
+ * but the collision predicate was rebuilding it on every call -- `O(v^4)` work inside an
285
+ * `O(n^2)` loop. Measured on the two-wedge fixture: 78 builds for two items, where twelve are
286
+ * the floor.
287
+ *
288
+ * Memoisation is safe here in the way it is not in general: the shape is never mutated after
289
+ * it is built, the key is the whole of what determines the value, and callers only project
290
+ * through it. Determinism is untouched -- this changes how often the answer is computed,
291
+ * never what it is. */
292
+ function shapeFor(vertices,rotation){
293
+ const key=rotation+'|'+vertices.map(v=>v.join(',')).join(';');
294
+ const found=shapeCache.get(key);
295
+ if(found!==undefined)return found;
296
+ const shape=hullShape(hullRotate(vertices,rotation));
297
+ if(shapeCache.size>=SHAPE_CACHE_ENTRIES)shapeCache.clear();
298
+ shapeCache.set(key,shape);
299
+ return shape;
300
+ }
301
+ /** A cuboid, built without searching for its own faces: both sets are the three unit axes. */
302
+ /** Lower bounds on the objective vector.
303
+ *
304
+ * The mathematics is fixed by docs/OPTIMALITY-CERTIFICATES.md. This is an independent
305
+ * implementation written from that document, and `conformance/scene/objective-bounds.json`
306
+ * holds it to the same vectors Python computes on 380 cases from the golden corpus.
307
+ *
308
+ * asks only for soundness -- the bound must never exceed the achieved objective --
309
+ * because this engine is not held to placement equality. That freedom does not extend to a
310
+ * bound: it is a function of the *request*, so there is no room for a legitimately different
311
+ * answer, and this port is held to equality because equality is achievable and stronger.
312
+ *
313
+ * `BigInt` throughout for volumes. A one-metre cube is 4.1e21 cubic ticks, past what a
314
+ * double represents exactly, and the widest intermediate multiplies a summed volume by 1e6.
315
+ * Counts, weights, costs and the parts-per-million keys come back to `Number` only once the
316
+ * arithmetic has reduced them to that scale.
317
+ *
318
+ * `O(n log n + c log c)` for `n` instances and `c` container types: one sort of the volumes,
319
+ * one of the weights, one of the per-unit costs. No geometry is touched. */
320
+ const BOUND_PPM=1000000n;
321
+ /** Every sum in the bound path must stay below this.
322
+ *
323
+ * Declared rather than inherited. This engine's `Number` stops being exact past 2^53, PHP's
324
+ * integers silently become doubles on overflow, Python's are unbounded and Rust's `i128`
325
+ * wraps -- so if each refused at its own limit the four would disagree about which requests
326
+ * are answerable. Keys 3 and 4 multiply a summed volume by `PPM`, so `10^30 * 10^6` sits
327
+ * about 170-fold inside an `i128`. Everything guarded here is `BigInt`, because a ceiling a
328
+ * representation cannot hold is a ceiling it cannot enforce. */
329
+ const MAX_BOUND_SUM=10n**30n;
330
+ // Results cross the JSON/Number boundary. Intermediates may use the wider ceiling above,
331
+ // but every returned key must fit exactly in every binding before it becomes a Number.
332
+ const MAX_BOUND_VALUE=2n**53n-1n;
333
+ /** A sum in the bound path exceeded the declared ceiling. Structured rather than a number:
334
+ * a bound that is quietly wrong is worse than none, because it will be believed. */
335
+ export class BoundOverflowError extends Error{
336
+ constructor(quantity,ceiling=MAX_BOUND_SUM,subject='sum'){
337
+ super(`${quantity} ${subject} is past the ${ceiling} ceiling the bound path declares`);
338
+ this.name='BoundOverflowError';
339
+ }
340
+ }
341
+ function boundGuard(total,quantity){
342
+ if(total>MAX_BOUND_SUM)throw new BoundOverflowError(quantity);
343
+ return total;
344
+ }
345
+ function boundOutput(value,quantity){
346
+ if(value>MAX_BOUND_VALUE){
347
+ throw new BoundOverflowError(quantity,MAX_BOUND_VALUE,'bound');
348
+ }
349
+ return Number(value);
350
+ }
351
+ /** Can this item take up less room than its declared dimensions?
352
+ *
353
+ * Three ways, and each breaks the same argument -- that nominal volumes sum to something a
354
+ * solution must carry. A nested item sinks into the one below it; a `convex_hull` occupies
355
+ * its hull and leaves the rest of its bounding box free; a `compressible` item gives up
356
+ * height under load. The design document named only the first until a soundness test over
357
+ * the corpus found the omission. */
358
+ function occupiesLessThanItsBox(item){
359
+ if(item.nestingHeight!=null)return true;
360
+ return item.shapeType==='convex_hull'||item.shapeType==='compressible';
361
+ }
362
+ const boundCeilDiv=(a,b)=>(a+b-1n)/b;
363
+ /** The largest n such that the n smallest values sum to at most the capacity. Smallest first
364
+ * is the whole soundness argument: the cheapest units maximise how many fit, so this
365
+ * over-estimates what any real packing achieves and the bound under-estimates. */
366
+ function boundFit(ascending,capacity){
367
+ if(capacity===null)return ascending.length;
368
+ let used=0n;
369
+ for(let taken=0;taken<ascending.length;taken++){
370
+ used+=ascending[taken];
371
+ if(used>capacity)return taken;
372
+ }
373
+ return ascending.length;
374
+ }
375
+ /** Sum of limit*quantity, or null when any limit or inventory is undeclared. `zeroIsHarmless`
376
+ * is the volume rule: a container with no usable volume adds nothing however many there
377
+ * are, so an unlimited quantity only unbounds the total when the type holds something. */
378
+ function boundCapacity(values,quantities,zeroIsHarmless){
379
+ let total=0n;
380
+ for(let i=0;i<values.length;i++){
381
+ if(values[i]===null)return null;
382
+ if(quantities[i]===null){
383
+ if(zeroIsHarmless&&values[i]<=0n)continue;
384
+ return null;
385
+ }
386
+ total=boundGuard(total+values[i]*quantities[i],'container capacity');
387
+ }
388
+ return total;
389
+ }
390
+ /** The largest declared limit, or null if any type declares none: one unlimited type makes
391
+ * the maximum unbounded and every term conditioned on it vacuous. */
392
+ function boundFiniteMax(values){
393
+ let best=null;
394
+ for(const value of values){
395
+ if(value===null)return null;
396
+ best=best===null||value>best?value:best;
397
+ }
398
+ return best;
399
+ }
400
+ /** Every bound, from the numbers the formulas consume -- the shape the shared scene records,
401
+ * so this port is checked without reimplementing a request parser. `shrinks` is taken as
402
+ * given; whether this engine decides it correctly is asserted separately. */
403
+ function objectiveBounds(instances,containers){
404
+ const volumes=instances.map(i=>i.volume).sort((a,b)=>a<b?-1:a>b?1:0);
405
+ const weights=instances.map(i=>i.weight).sort((a,b)=>a<b?-1:a>b?1:0);
406
+ const shrinks=instances.some(i=>i.shrinks);
407
+ const count=instances.length;
408
+ const usable=containers.map(c=>c.usable),inner=containers.map(c=>c.inner);
409
+ const quantities=containers.map(c=>c.quantity);
410
+
411
+ // The a-priori check, once, on the way in. Every later product is bounded by these totals
412
+ // times PPM, so guarding them here is what makes the rest safe by derivation.
413
+ boundGuard(volumes.reduce((a,b)=>a+b,0n),'instance volume');
414
+ boundGuard(weights.reduce((a,b)=>a+b,0n),'instance weight');
415
+ for(const container of containers){
416
+ boundGuard(container.usable,'container capacity');
417
+ boundGuard(container.costMinor,'opening cost');
418
+ }
419
+
420
+ let placeable=count;
421
+ if(!shrinks)placeable=Math.min(placeable,boundFit(volumes,boundCapacity(usable,quantities,true)));
422
+ placeable=Math.min(placeable,boundFit(weights,boundCapacity(containers.map(c=>c.payload),quantities,false)));
423
+ const slotCapacity=boundCapacity(containers.map(c=>c.maxItems),quantities,false);
424
+ if(slotCapacity!==null)placeable=Math.min(placeable,Number(slotCapacity));
425
+ const unpacked=count-placeable,placed=placeable;
426
+
427
+ let opened=0;
428
+ if(placed>0&&containers.length){
429
+ opened=1;
430
+ if(!shrinks){
431
+ const largest=usable.reduce((a,b)=>b>a?b:a,0n);
432
+ if(largest>0n)opened=Math.max(opened,Number(boundCeilDiv(volumes.slice(0,placed).reduce((a,b)=>a+b,0n),largest)));
433
+ }
434
+ const payload=boundFiniteMax(containers.map(c=>c.payload));
435
+ if(payload!==null&&payload>0n)opened=Math.max(opened,Number(boundCeilDiv(weights.slice(0,placed).reduce((a,b)=>a+b,0n),payload)));
436
+ const slots=boundFiniteMax(containers.map(c=>c.maxItems));
437
+ if(slots!==null&&slots>0n)opened=Math.max(opened,Number(boundCeilDiv(BigInt(placed),slots)));
438
+ }
439
+
440
+ let cost=0n;
441
+ if(opened>0){
442
+ const available=[];
443
+ for(const c of containers){
444
+ const repeat=c.quantity===null?opened:Math.min(Number(c.quantity),opened);
445
+ for(let taken=0;taken<repeat;taken++)available.push(c.costMinor);
446
+ }
447
+ available.sort((a,b)=>a<b?-1:a>b?1:0);
448
+ cost=available.slice(0,opened).reduce((a,b)=>a+b,0n);
449
+ }
450
+
451
+ let unused=0n;
452
+ if(!shrinks&&opened>0&&containers.length){
453
+ const smallest=inner.reduce((a,b)=>b<a?b:a,inner[0]);
454
+ if(smallest>0n){
455
+ const largestPlaced=placed>0?volumes.slice(volumes.length-placed).reduce((a,b)=>a+b,0n):0n;
456
+ // In BigInt until it is clamped: `largestPlaced * PPM` can reach 10^36, which a
457
+ // `Number` would round rather than carry.
458
+ const filled=boundCeilDiv(largestPlaced*BOUND_PPM,smallest);
459
+ const raw=BigInt(opened)*BOUND_PPM-filled-BigInt(opened-1);
460
+ unused=raw>0n?raw:0n;
461
+ }
462
+ }
463
+
464
+ let height=0n;
465
+ if(!shrinks&&opened>0&&containers.length){
466
+ const widest=containers.map(c=>c.baseArea).reduce((a,b)=>b>a?b:a,0n);
467
+ const tallest=containers.map(c=>c.height).reduce((a,b)=>b>a?b:a,0n);
468
+ if(widest>0n&&tallest>0n){
469
+ const required=placed>0?boundCeilDiv(volumes.slice(0,placed).reduce((a,b)=>a+b,0n),widest):0n;
470
+ const raw=required*BOUND_PPM/tallest-BigInt(opened-1);
471
+ height=raw>0n?raw:0n;
472
+ }
473
+ }
474
+ return [
475
+ boundOutput(BigInt(unpacked),'unpacked count'),
476
+ boundOutput(BigInt(opened),'container count'),
477
+ boundOutput(boundGuard(cost,'opening cost'),'opening cost'),
478
+ boundOutput(unused,'unused volume'),
479
+ boundOutput(height,'stack height'),
480
+ ];
481
+ }
482
+ /** Exposed for the cross-language scene test only, like `__inspectHullShapeForTests`: the
483
+ * bounds are internal until a contract freeze decides whether a caller ever sees a gap. */
484
+ export function __objectiveBoundsForTests(instances,containers){return objectiveBounds(instances,containers);}
485
+ export function __occupiesLessThanItsBoxForTests(item){return occupiesLessThanItsBox(item);}
486
+ function boxShape(dx,dy,dz){
487
+ const v=[];
488
+ 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]);
489
+ return {v,faces:UNIT_AXES,edges:UNIT_AXES,volume:BigInt(dx)*BigInt(dy)*BigInt(dz)};
490
+ }
491
+ /** Reorient a hull the way a rotation reorients its box, never mirroring it.
492
+ *
493
+ * Three of the six rotations are odd permutations of the coordinate axes. On a cuboid that is
494
+ * invisible; on a hull a bare permutation returns the item's mirror image, a shape the caller
495
+ * does not own. One axis therefore changes sign when the permutation is odd. */
496
+ function hullRotate(vertices,code){
497
+ const axes=ROT[code];
498
+ let inversions=0;
499
+ for(let i=0;i<3;i++)for(let j=i+1;j<3;j++)if(axes[i]>axes[j])inversions++;
500
+ const sign=inversions%2?-1:1;
501
+ const turned=vertices.map(v=>[sign*v[axes[0]],v[axes[1]],v[axes[2]]]);
502
+ const low=[0,1,2].map(a=>Math.min(...turned.map(v=>v[a])));
503
+ return turned.map(v=>[v[0]-low[0],v[1]-low[1],v[2]-low[2]]);
504
+ }
505
+ function separatingAxes(left,right){
506
+ const axes=new Map();
507
+ for(const axis of [...left.faces,...right.faces])axes.set(axisKey(axis),axis);
508
+ for(const l of left.edges)for(const r of right.edges){
509
+ const axis=primitiveAxis(cross3(l,r));
510
+ if(axis)axes.set(axisKey(axis),axis);
511
+ }
512
+ return [...axes.values()];
513
+ }
514
+ /** Do two placed hulls overlap with positive volume? Touching is contact, not collision: the
515
+ * comparison is `<=`, matching the half-open convention cuboids already use. */
516
+ function hullsCollide(left,leftOrigin,right,rightOrigin){
517
+ const lo=big3(leftOrigin),ro=big3(rightOrigin);
518
+ for(const axis of separatingAxes(left,right)){
519
+ const project=shape=>{let low=null,high=null;
520
+ for(const v of shape.v){const value=dot3(v,axis);
521
+ if(low===null||value<low)low=value;if(high===null||value>high)high=value}
522
+ return [low,high]};
523
+ const [ll,lh]=project(left),[rl,rh]=project(right);
524
+ const ls=dot3(lo,axis),rs=dot3(ro,axis);
525
+ if(lh+ls<=rl+rs||rh+rs<=ll+ls)return false;
526
+ }
527
+ return true;
528
+ }
529
+
530
+ // ---------------------------------------------------------------- compression
531
+
532
+ const COMPRESSION_PPM=1000000n;
533
+ const GRAVITY_NUMERATOR=980665n,GRAVITY_DENOMINATOR=100000n,PASCALS_PER_KPA=1000n;
534
+ /** Exact pressure in kPa from the cumulative mass above an item, over its footprint. Reduced,
535
+ * so the divisor in the height formula stays small and two engines agreeing on the value
536
+ * cannot disagree on the representation. */
537
+ function appliedPressure(loadTicks,footprintTicks){
538
+ const metre=BigInt(LEN.mm)*1000n;
539
+ const n=BigInt(loadTicks)*GRAVITY_NUMERATOR*metre*metre;
540
+ const d=BigInt(WT.kg)*GRAVITY_DENOMINATOR*PASCALS_PER_KPA*BigInt(footprintTicks);
541
+ const g=bigGcd(n,d)||1n;
542
+ return {n:n/g,d:d/g};
543
+ }
544
+ /** Cross multiplication, so the inclusive boundary is decided without ever dividing. */
545
+ const pressureExceeds=(pressure,limitKpa)=>pressure.n>BigInt(limitKpa)*pressure.d;
546
+ /** Occupied height under load, rounded up, never below one tick. Rounding up keeps a discrete
547
+ * packer honest; the one-tick floor stops a fully compressible item reaching zero height,
548
+ * where it would slip past collision and support invariants entirely. */
549
+ function effectiveHeight(heightTicks,ratioPpm,limitKpa,pressure){
550
+ if(limitKpa===0)return heightTicks;
551
+ const divisor=BigInt(limitKpa)*COMPRESSION_PPM*pressure.d;
552
+ const retained=divisor-BigInt(ratioPpm)*pressure.n;
553
+ const rounded=(BigInt(heightTicks)*retained+divisor-1n)/divisor;
554
+ return Number(rounded>1n?rounded:1n);
555
+ }
556
+ /** The published ratio rule, `floor(ratio * 1000000 + 0.5)`, applied once at the boundary so
557
+ * the float a caller supplied never reaches the geometry. */
558
+ function ratioToPpm(ratio){
559
+ if(!(ratio>=0&&ratio<=1))throw new RangeError('compression_ratio must be between zero and one');
560
+ return Math.floor(ratio*1000000+0.5);
561
+ }
103
562
  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
563
  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
564
  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
- function usedVolume(placements){let total=placements.reduce((s,p)=>s+volume(p.pd),0n),overlap=0n;
565
+ /** This placement's rotated hull, or `null` when its box is the honest answer.
566
+ *
567
+ * `null` for every `rigid_cuboid` and for three cases that fall back to the box, always
568
+ * over-reserving space: a clearance has inflated the envelope past the physical box and a
569
+ * margin around a hull is not a hull; the item is on a route, where the sequence replay
570
+ * reasons with box sweeps only and packing tighter than it can verify would produce
571
+ * arrangements the engine then calls unloadable. */
572
+ function placedHull(placement){
573
+ const item=placement.item;
574
+ if(item.shapeType!=='convex_hull'||item.stopIndex!=null)return null;
575
+ if(placement.ed[0]!==placement.pd[0]||placement.ed[1]!==placement.pd[1]||placement.ed[2]!==placement.pd[2])return null;
576
+ return shapeFor(item.hullVertices,placement.r);
577
+ }
578
+ /** Do two placed items actually overlap? The axis-aligned envelope test is the broad phase and
579
+ * stays mandatory; this refines its answer only when a hull is one of the two solids. */
580
+ function solidsOverlap(leftShape,leftBox,rightShape,rightBox){
581
+ if(leftShape===null&&rightShape===null)return true;
582
+ return hullsCollide(
583
+ leftShape??boxShape(leftBox.d[0],leftBox.d[1],leftBox.d[2]),[leftBox.x,leftBox.y,leftBox.z],
584
+ rightShape??boxShape(rightBox.d[0],rightBox.d[1],rightBox.d[2]),[rightBox.x,rightBox.y,rightBox.z]);
585
+ }
586
+ /** Space one placement actually takes, which is its box only if it is one.
587
+ *
588
+ * A `convex_hull` item occupies its hull: counting the bounding box is not a conservative
589
+ * approximation of utilisation but a wrong number, putting two interlocking wedges at 200% of
590
+ * a crate. A `compressible` item occupies the height left after the load it reports. */
591
+ function occupiedVolume(placement,loadTicks=0){
592
+ const item=placement.item;
593
+ // Route and clearance can make collision conservatively use the envelope; neither changes
594
+ // the physical solid used for utilisation and void-fill reserve accounting.
595
+ if(item.shapeType==='convex_hull')return shapeFor(item.hullVertices,placement.r).volume;
596
+ if(item.maxCompressionKpa==null)return volume(placement.pd);
597
+ const footprint=placement.pd[0]*placement.pd[1];
598
+ // The load is passed in rather than read off the placement: this engine computes top loads
599
+ // at reporting time and never stores them, so a placement field would have been silently
600
+ // zero and nothing would ever have compressed.
601
+ const pressure=appliedPressure(loadTicks,footprint);
602
+ // A crushed item has no meaningful occupied volume, and the arrangement is already invalid
603
+ // -- the crush check refuses it and the validator reports it.
604
+ if(pressureExceeds(pressure,item.maxCompressionKpa))return volume(placement.pd);
605
+ return BigInt(footprint)*BigInt(effectiveHeight(placement.pd[2],item.compressionPpm,item.maxCompressionKpa,pressure));
606
+ }
607
+ /** First compressible box carrying more pressure than it declared it can take.
608
+ *
609
+ * Deliberately shaped like `overloaded` and reading the same propagated loads: the two answer
610
+ * one question in two currencies -- a mass the box below must bear, against a pressure the
611
+ * item itself must survive. An item can pass one and fail the other, so both are asked. */
612
+ function crushed(boxes,loads=null){
613
+ if(boxes.every(b=>b.maxCompressionKpa==null))return false;
614
+ if(loads==null)loads=topLoads(boxes);
615
+ return boxes.some((b,i)=>{
616
+ if(b.maxCompressionKpa==null)return false;
617
+ const footprint=b.d[0]*b.d[1];
618
+ return pressureExceeds(appliedPressure(Number(loads[i]),footprint),b.maxCompressionKpa);
619
+ });
620
+ }
621
+ /** Parse and admit an item's shape, or refuse it with the reason.
622
+ *
623
+ * Coordinates go through the length scale, which refuses a negative value, so a hull crossing
624
+ * the wire is authored as non-negative offsets from the corner of its own bounding box. The
625
+ * admission rule spans four fields at once -- which are required, which are forbidden, and
626
+ * what the survivors must agree with -- and mirrors the other three engines exactly. */
627
+ function parseShape(raw,d,unit,nesting){
628
+ const shapeType=raw.shape_type??'rigid_cuboid';
629
+ if(!['rigid_cuboid','convex_hull','compressible'].includes(shapeType))
630
+ throw new RangeError(`item.shape_type ${shapeType} is not a known shape`);
631
+ const hullVertices=raw.hull_vertices==null?null:raw.hull_vertices.map(v=>
632
+ [scalar(v.x,unit,LEN),scalar(v.y,unit,LEN),scalar(v.z,unit,LEN)]);
633
+ const compressionPpm=raw.compression_ratio==null?null:ratioToPpm(raw.compression_ratio);
634
+ const maxCompressionKpa=raw.max_compression_pressure_kpa==null?null:Number(raw.max_compression_pressure_kpa);
635
+ const foreign=shapeType==='convex_hull'
636
+ ?[['compression_ratio',compressionPpm],['max_compression_pressure_kpa',maxCompressionKpa]]
637
+ :shapeType==='compressible'?[['hull_vertices',hullVertices]]
638
+ :[['hull_vertices',hullVertices],['compression_ratio',compressionPpm],['max_compression_pressure_kpa',maxCompressionKpa]];
639
+ for(const [name,value] of foreign)
640
+ if(value!=null)throw new RangeError(`${name} is not part of a ${shapeType} item`);
641
+ // Both rewrite occupied height. Choosing an order silently would give four engines four
642
+ // contracts, so the interaction is refused until a task defines it.
643
+ if(nesting!=null&&shapeType!=='rigid_cuboid')
644
+ throw new RangeError(`nesting_height with shape_type ${shapeType} is not supported yet`);
645
+ if(shapeType==='convex_hull'){
646
+ if(hullVertices===null)throw new RangeError('a convex_hull item requires hull_vertices');
647
+ const points=hullValidate(hullVertices);
648
+ for(let axis=0;axis<3;axis++){
649
+ const span=Math.max(...points.map(v=>v[axis]))-Math.min(...points.map(v=>v[axis]));
650
+ // `dimensions` stays the broad phase and the candidate-generation envelope, so a hull
651
+ // poking out of it would be collision-tested against space never reserved.
652
+ if(span>d[axis])throw new RangeError('hull_vertices span does not fit inside dimensions');
653
+ }
654
+ }
655
+ if(shapeType==='compressible'){
656
+ if(compressionPpm===null||maxCompressionKpa===null)
657
+ throw new RangeError('a compressible item requires both compression_ratio and max_compression_pressure_kpa');
658
+ if(maxCompressionKpa<0)throw new RangeError('max_compression_pressure_kpa cannot be negative');
659
+ }
660
+ return {shapeType,hullVertices,compressionPpm,maxCompressionKpa};
661
+ }
662
+ function usedVolume(placements){
663
+ // Compression needs the cumulative mass above each item, which is the same propagation the
664
+ // reported `top_load` uses -- one traversal, read twice.
665
+ const loads=placements.some(p=>p.item.maxCompressionKpa!=null)
666
+ ?topLoads(placements.map(constraintBox)):null;
667
+ let total=placements.reduce((s,p,i)=>s+occupiedVolume(p,loads===null?0:Number(loads[i])),0n),overlap=0n;
107
668
  for(let i=0;i<placements.length;i++)for(let j=i+1;j<placements.length;j++)if(validNesting(placements[i],placements[j]))
108
669
  overlap+=BigInt(placements[i].item.nesting)*BigInt(placements[i].ed[0])*BigInt(placements[i].ed[1]);
109
670
  return total-overlap}
@@ -113,7 +674,7 @@ function usedVolume(placements){let total=placements.reduce((s,p)=>s+volume(p.pd
113
674
  // what is already there -- O(n) where `usedVolume` is O(n^2). The search calls this once
114
675
  // per candidate orientation, which is what made the whole solve super-linear in item
115
676
  // count before.
116
- function usedVolumeDelta(placements,tentative){let delta=volume(tentative.pd);
677
+ function usedVolumeDelta(placements,tentative){let delta=occupiedVolume(tentative);
117
678
  for(const placed of placements)if(validNesting(placed,tentative))
118
679
  delta-=BigInt(placed.item.nesting)*BigInt(placed.ed[0])*BigInt(placed.ed[1]);
119
680
  return delta}
@@ -162,6 +723,21 @@ function insertPoint(points,point){let low=0,high=points.length;
162
723
  // candidate list bounded instead of letting it grow with every placement, which is what
163
724
  // left the fallback evaluating two orders of magnitude more points per item than Python
164
725
  //. The half-open test matches `intersects`.
726
+ /** Retire the points a placement covers -- unless it is a hull.
727
+ *
728
+ * Retiring a point because it falls inside a solid's box assumes the box *is* the solid. For a
729
+ * hull it is not: a placement origin is a corner of a bounding box, and a hull leaves most of
730
+ * that box -- including, for a wedge, the origin itself -- available to the next item. Pruning
731
+ * them first would mean the engine could describe an interlocking pack it could never propose,
732
+ * and the exact collision test would be correct and never consulted.
733
+ *
734
+ * One wrapper rather than a guard at each call site: the Rust port found a *second* place that
735
+ * treated a box as the solid, and a single entry point is what makes a third impossible to
736
+ * forget. */
737
+ function retirePointsForPlacement(points,placement){
738
+ if(placedHull(placement)!==null)return;
739
+ retirePointsInside(points,{x:placement.x,y:placement.y,z:placement.z,d:placement.ed});
740
+ }
165
741
  function retirePointsInside(points,box){const x2=box.x+box.d[0],y2=box.y+box.d[1],z2=box.z+box.d[2];
166
742
  let write=0;
167
743
  for(let read=0;read<points.length;read++){const p=points[read];
@@ -311,6 +887,10 @@ function contactGraph(boxes){const graph=buildContactGraph(boxes,overlapXY),grou
311
887
 
312
888
  function constraintBox(placement){return {x:placement.x,y:placement.y,z:placement.z,d:placement.ed,w:placement.item.w,
313
889
  maxTop:placement.item.maxTop,maxStacked:placement.item.maxStacked,itemType:placement.item.raw.id,nesting:placement.item.nesting,
890
+ // Load propagation already computes the cumulative mass above every box, which is exactly
891
+ // the numerator the pressure model needs, so the crush check rides the graph that is built
892
+ // anyway rather than a second one.
893
+ maxCompressionKpa:placement.item.maxCompressionKpa,compressionPpm:placement.item.compressionPpm,
314
894
  stopIndex:placement.item.stopIndex}}
315
895
 
316
896
  function topLoads(boxes,graph=contactGraph(boxes)){const loads=boxes.map(()=>0n);
@@ -369,7 +949,101 @@ function routeContactAllowed(candidate,placed,supports){
369
949
  * physical rule the schema accepts is enforced here instead: a result that claims to
370
950
  * honour a rule it ignored is worse than no result at all.
371
951
  */
372
- function allowed(candidate,placed,container,globalSupportPpm,metrics){
952
+ // `loadBase` is a thunk, not a graph: the caller knows the placed boxes cannot move for
953
+ // this item's whole candidate sweep, but most candidates never reach the load rules at
954
+ // all, and building a base none of them asks for would be pure cost. It yields null
955
+ // whenever the delta does not apply -- see `candidatesFor`.
956
+ // Dimensions reach this rule in two shapes and both are legitimate: the solver carries them
957
+ // as `[length, width, height]`, while a caller holding a request or a fixture carries the
958
+ // named object. `sweptVolume` reads the named form, and an array silently answers `3` for
959
+ // `.length` -- so normalising here is not tidiness. Before 's review this predicate
960
+ // returned the opposite verdict for the same scene depending on which shape it was handed,
961
+ // and nothing caught it because no request path supplies a direction list yet.
962
+ const namedDimensions=value=>Array.isArray(value)
963
+ ?{length:value[0],width:value[1],height:value[2]}:value;
964
+ const innerDimensions=container=>namedDimensions(container.d!==undefined?container.d:container);
965
+ // Only position and envelope size matter to a corridor, so the box is built here rather than
966
+ // through `constraintBox`, which also carries load, nesting and item type -- none of which
967
+ // this rule reads, and all of which an embedder would have to supply to call it.
968
+ const corridorBox=p=>({x:p.x,y:p.y,z:p.z,d:namedDimensions(p.ed)});
969
+
970
+ /**
971
+ * The corridors open in one immutable placement state.
972
+ *
973
+ * Built once per candidate sweep and reused by every candidate: the placed boxes cannot move
974
+ * while one item is being placed, so the placed-versus-placed intersections give the same
975
+ * answer every time. Construction is `O(m^2 * |D|)` and each candidate then costs
976
+ * `O(m * |D|)`, matching what the Rust core does with the same state. Rebuilding per
977
+ * candidate would make switching the doors on cost `O(m^2 * |D|)` for every candidate -- the
978
+ * hoist exists so that wiring the field later does not also have to repair a hot loop.
979
+ *
980
+ * The base is keyed to one candidate stop, so it is valid for exactly one item's sweep.
981
+ */
982
+ export function stopAccessibilityBase(candidateStop,placed,container,directions){
983
+ const stop=candidateStop??Infinity,stops=placed.map(p=>p.item.stopIndex??Infinity);
984
+ // No doors is the default on every request path, and one distinct stop means nothing is
985
+ // due before anything else. Either way no corridor can be wrongly blocked. Checked over
986
+ // the candidate too, or the first placement into an empty container would skip a check it
987
+ // should make.
988
+ if(!directions||directions.length===0||stops.every(each=>each===stop))
989
+ return {inert:true,stop,stops,directions:[],inner:null,boxes:[],clear:[]};
990
+ const inner=innerDimensions(container),boxes=placed.map(corridorBox);
991
+ const clear=boxes.map((box,index)=>stops[index]===Infinity
992
+ // Never unloaded, so it needs no door of its own -- it only ever blocks.
993
+ ?[]
994
+ :directions.map(direction=>sweptVolume(box,inner,direction))
995
+ .filter(sweep=>!boxes.some((other,position)=>position!==index
996
+ &&stops[position]>stops[index]&&sweptHits(sweep,other))));
997
+ return {inert:false,stop,stops,directions:[...directions],inner,boxes,clear};
998
+ }
999
+
1000
+ function accessibleAgainst(base,candidateBox){
1001
+ if(base.inert)return true;
1002
+ // Every already-placed item due before the candidate must keep a door the candidate does
1003
+ // not take.
1004
+ for(let index=0;index<base.clear.length;index++){
1005
+ if(base.stop<=base.stops[index])continue;
1006
+ if(!base.clear[index].some(sweep=>!sweptHits(sweep,candidateBox)))return false;
1007
+ }
1008
+ // An item riding the whole route is never unloaded, so it needs no door of its own.
1009
+ if(base.stop===Infinity)return true;
1010
+ return base.directions.some(direction=>{
1011
+ const sweep=sweptVolume(candidateBox,base.inner,direction);
1012
+ return !base.boxes.some((other,index)=>base.stops[index]>base.stop&&sweptHits(sweep,other));
1013
+ });
1014
+ }
1015
+
1016
+ // The horizontal half of route order: nothing due later may stand between an earlier item
1017
+ // and a door. `routeContactAllowed` above enforces the vertical half -- nothing
1018
+ // due later may rest *above* something due earlier. Both are necessary and neither implies
1019
+ // the other; docs/STOP-ACCESSIBILITY.md derives the rule and the post-validator's
1020
+ // whole-scene replay stays the sufficient check.
1021
+ //
1022
+ // Inert unless the caller supplies exit directions. The request schema has no field for
1023
+ // them, and assuming all six walls open would enforce a rule true of no real vehicle and
1024
+ // nearly vacuous besides -- a box is almost always free through *some* face. This engine
1025
+ // has no programmatic config path, so the request path always passes the empty list and an
1026
+ // embedder reaches the rule by calling this function directly, which is as close as
1027
+ // JavaScript gets to the config field Python, PHP and Rust carry.
1028
+ //
1029
+ // The blocker set is `{q : s(q) > s(p)}` -- strictly later. Same-stop items are excluded
1030
+ // because the order within a stop is free: whichever is in the way comes off first.
1031
+ //
1032
+ // One implementation, not two: this builds the base and asks it, so the exported predicate
1033
+ // and the solver's hot path cannot drift apart.
1034
+ export function stopAccessible(candidate,placed,container,directions){
1035
+ return accessibleAgainst(
1036
+ stopAccessibilityBase(candidate.item.stopIndex,placed,container,directions),
1037
+ corridorBox(candidate));
1038
+ }
1039
+
1040
+ // Half-open on every axis, matching the box intersection test, so a box flush against
1041
+ // another's exit face is not standing in its way.
1042
+ function sweptHits([sx1,sy1,sz1,sx2,sy2,sz2],box){
1043
+ return sx1<box.x+box.d.length&&box.x<sx2&&sy1<box.y+box.d.width&&box.y<sy2
1044
+ &&sz1<box.z+box.d.height&&box.z<sz2}
1045
+
1046
+ function allowed(candidate,placed,container,globalSupportPpm,metrics,loadBase=null,accessBase=null){
373
1047
  const box={x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed};
374
1048
  if(candidate.item.raw.must_be_on_floor&&box.z!==0)return false;
375
1049
  const tags=candidate.item.tags,bad=candidate.item.incompatible;
@@ -398,13 +1072,22 @@ function allowed(candidate,placed,container,globalSupportPpm,metrics){
398
1072
  // decidable from the items alone; when neither fires, the three skipped checks
399
1073
  // return false for every box anyway, and building n+1 boxes per feasible
400
1074
  // candidate was pure allocation.
401
- const needsLoads=container.maxStackDensity!=null||candidate.item.maxTop!=null||placed.some(p=>p.item.maxTop!=null);
1075
+ const needsLoads=container.maxStackDensity!=null||candidate.item.maxTop!=null||placed.some(p=>p.item.maxTop!=null)
1076
+ ||candidate.item.maxCompressionKpa!=null||placed.some(p=>p.item.maxCompressionKpa!=null);
402
1077
  const needsGraph=needsLoads||candidate.item.maxStacked!=null||placed.some(p=>p.item.maxStacked!=null);
403
- if(!needsGraph)return groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports);
404
- const boxes=[...placed.map(constraintBox),constraintBox(candidate)];
405
- const graph=contactGraph(boxes),loads=needsLoads?topLoads(boxes,graph):null;
406
- return !overloaded(boxes,loads)&&!stackLimitsExceeded(boxes,graph)&&!stackDensityExceeded(boxes,container.maxStackDensity,loads)
407
- &&groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports);
1078
+ if(!needsGraph)return groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports)
1079
+ &&(accessBase===null||accessibleAgainst(accessBase,corridorBox(candidate)));
1080
+ // With a base for this sweep, both the box list and the graph come from it by
1081
+ // appending one box, rather than each candidate rebuilding both from every placement.
1082
+ // The two paths are required to agree exactly, which is what `contact-graph`'s append
1083
+ // property test holds them to.
1084
+ const candidateBox=constraintBox(candidate),base=loadBase===null?null:loadBase();
1085
+ const boxes=base===null?[...placed.map(constraintBox),candidateBox]:[...base.boxes,candidateBox];
1086
+ const graph=base===null?contactGraph(boxes):appendContactBox(base,candidateBox,overlapXY);
1087
+ const loads=needsLoads?topLoads(boxes,graph):null;
1088
+ return !overloaded(boxes,loads)&&!crushed(boxes,loads)&&!stackLimitsExceeded(boxes,graph)&&!stackDensityExceeded(boxes,container.maxStackDensity,loads)
1089
+ &&groundContactAllowed(candidate,placed,supports)&&routeContactAllowed(candidate,placed,supports)
1090
+ &&(accessBase===null||accessibleAgainst(accessBase,corridorBox(candidate)));
408
1091
  }
409
1092
 
410
1093
  function supportRatioOf(placement,placed){
@@ -469,6 +1152,10 @@ function admitItem(raw,u){
469
1152
  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
1153
  if(raw.value!=null&&(!Number.isSafeInteger(raw.value)||raw.value<0))throw new RangeError('value must be a non-negative safe integer');
471
1154
  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');
1155
+ // The shape rules belong here for the reason this function exists: the compact lattice path
1156
+ // never builds an `items` entry, so an admission living only in the general path's item loop
1157
+ // would let the two disagree about which requests are legal.
1158
+ parseShape(raw,d,u,nesting);
472
1159
  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
1160
  }
474
1161
 
@@ -514,6 +1201,12 @@ function compactGridResult(req,{u,ou,ow,clear,objective,dimensionalWeight,solver
514
1201
  const raw=req.items[0],quantity=raw.quantity??1;
515
1202
  if(!Number.isSafeInteger(quantity)||quantity<1||raw.group!=null||(raw.tags??[]).length||(raw.incompatible_tags??[]).length
516
1203
  ||(raw.eligible_container_tags??[]).length||raw.max_stacked_items!=null||raw.nesting_height!=null
1204
+ // The lattice is closed-form over boxes: it counts cells from envelope extents and reports
1205
+ // volume from its own summary. It can see neither a hull -- it would tile bounding boxes
1206
+ // and call the result exact -- nor pressure, so a compressible column would be sized
1207
+ // without ever asking whether its base survives, and reported uncompressed. The general
1208
+ // search checks both per candidate.
1209
+ ||(raw.shape_type!=null&&raw.shape_type!=='rigid_cuboid')
517
1210
  ||!['free',null,undefined].includes(raw.ground_contact_rule))return null;
518
1211
  const itemDimensions=dims(raw.dimensions,u),weight=scalar(raw.weight??0,'g',WT);
519
1212
  const rotations=raw.allowed_rotations??(raw.keep_upright?['LWH','WLH']:Object.keys(ROT));
@@ -920,7 +1613,8 @@ const items=[];for(const raw of req.items){const d=dims(raw.dimensions,u),w=scal
920
1613
  supportPpm:Math.round((raw.minimum_support_ratio??0)*SUPPORT_SCALE),priority:raw.priority??0,
921
1614
  tags:raw.tags??[],incompatible:raw.incompatible_tags??[],group:raw.group??null,
922
1615
  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})}
1616
+ stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],value:raw.value??0,
1617
+ ...parseShape(raw,d,u,nesting)})}
924
1618
  // Priority is a preference, not a guarantee: it leads the ordering so a caller can
925
1619
  // bias the search, but ties (the default, priority 0 for all items) fall through to
926
1620
  // the volume key unchanged.
@@ -994,6 +1688,34 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{
994
1688
  // the check belongs here beside the eligibility and tag-limit gates rather than inside
995
1689
  // the point loop, and costs O(m + r) per (template, item) instead of per candidate.
996
1690
  if(policyRules.length&&policyRejection(policyRules,item.tags,tmpl.tags??[],tagOccurrences(state.placements))!==null)return [];
1691
+ // The placed boxes do not move for the whole of this item's candidate sweep, so
1692
+ // their contact graph is built once here -- on first demand, since most candidates never
1693
+ // reach a load rule -- and every candidate appends to it instead of rebuilding.
1694
+ //
1695
+ // Nesting is excluded: a nesting predecessor *replaces* the face edges of everything in
1696
+ // its column, so one new placement can rewrite edges arbitrarily far from itself and the
1697
+ // delta is no longer local. Nesting keeps the from-scratch path.
1698
+ const nestingPresent=item.nesting!=null||state.placements.some(p=>p.item.nesting!=null);
1699
+ let loadBaseGraph;
1700
+ const loadBase=nestingPresent?null:()=>{
1701
+ if(loadBaseGraph===undefined){
1702
+ // The cell must cover every box hashed into the broad phase or queried against it,
1703
+ // and the candidate is a new item that may be wider than anything placed -- so the
1704
+ // hint comes from this item's own rotations, which are known here.
1705
+ const widest=Math.max(1,...item.rots.flatMap(r=>{const pd=rotate(item.d,r);
1706
+ return [pd[0]+2*clear,pd[1]+2*clear]}));
1707
+ loadBaseGraph=buildContactGraph(state.placements.map(constraintBox),overlapXY,widest);
1708
+ }
1709
+ return loadBaseGraph;
1710
+ };
1711
+ // The same argument, for the other rule that reads the whole placed scene. The
1712
+ // doors are empty on every request path today, so this base is inert and costs one pass
1713
+ // over the stops -- it is built here rather than inside `allowed` so that wiring the
1714
+ // field through later does not silently turn an O(m*|D|) check into O(m^2*|D|) per
1715
+ // candidate.
1716
+ const accessBase=stopAccessibilityBase(item.stopIndex,state.placements,tmpl,[]);
1717
+ const compressionSensitive=item.shapeType==='compressible'
1718
+ ||state.placements.some(placement=>placement.item.shapeType==='compressible');
997
1719
  const found=[];
998
1720
  const candidates=points.length>maxCandidatePoints?points.slice(0,maxCandidatePoints):points;
999
1721
  candidatePoints:for(const pt of candidates){if(candidateEffortExceeded())break;metrics.candidate_points_considered++;for(const r of item.rots){if(candidateEffortExceeded())break candidatePoints;metrics.orientations_considered++;if(deadline.expired()){timeLimitReached=true;break candidatePoints}const pd=rotate(item.d,r),ed=pd.map(x=>x+2*clear),box={x:pt[0],y:pt[1],z:pt[2],d:ed};
@@ -1001,9 +1723,12 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{
1001
1723
  if(tmpl.max!=null&&state.payload+item.w>tmpl.max)continue;
1002
1724
  if(tmpl.max_items!=null&&state.placements.length>=tmpl.max_items)continue;
1003
1725
  const tentative={x:pt[0],y:pt[1],z:pt[2],pd,ed,r,item};
1004
- if(used+usedVolumeDelta(state.placements,tentative)+tmpl.reserve>tmpl.innerVolume)continue;
1726
+ if(!compressionSensitive&&used+usedVolumeDelta(state.placements,tentative)+tmpl.reserve>tmpl.innerVolume)continue;
1005
1727
  let collision=false;
1006
- for(const obstacle of tmpl.obs){metrics.collision_checks++;if(intersects(box,obstacle)){collision=true;break}}
1728
+ const candidateShape=item.shapeType==='convex_hull'&&item.stopIndex==null&&clear===0
1729
+ ?shapeFor(item.hullVertices,r):null;
1730
+ for(const obstacle of tmpl.obs){metrics.collision_checks++;
1731
+ if(intersects(box,obstacle)&&solidsOverlap(candidateShape,box,null,obstacle)){collision=true;break}}
1007
1732
  // Broad phase: visit only the placements sharing a cell with `box`, stamping each
1008
1733
  // so a placement spanning several cells is narrow-phase-checked once. A generation
1009
1734
  // counter does that without allocating a set per candidate orientation.
@@ -1012,11 +1737,23 @@ const candidatesFor=(tmpl,item,state,points,index,used,width)=>{
1012
1737
  const bucket=index.cells.get(cellKey(ix,iy,iz));if(!bucket)continue;
1013
1738
  for(const position of bucket){if(index.seen[position]===stamp)continue;index.seen[position]=stamp;
1014
1739
  const placed=state.placements[position];metrics.collision_checks++;
1015
- if(intersects(box,{x:placed.x,y:placed.y,z:placed.z,d:placed.ed})&&!validNesting(tentativeBox,placed)){collision=true;break scan}}}}
1740
+ const placedBox={x:placed.x,y:placed.y,z:placed.z,d:placed.ed};
1741
+ if(intersects(box,placedBox)&&!validNesting(tentativeBox,placed)
1742
+ // The axis-aligned test is the broad phase and stays mandatory. Only when a hull
1743
+ // is one of the two solids does the exact test get to overrule it, so a request of
1744
+ // ordinary boxes never reaches the hull path at all.
1745
+ &&solidsOverlap(candidateShape,box,placedHull(placed),placedBox)){collision=true;break scan}}}}
1016
1746
  if(collision)continue;
1017
1747
  const candidate={x:pt[0],y:pt[1],z:pt[2],pd,ed,r,item};
1018
1748
  if(axleOverloaded(tmpl,state.placements,candidate))continue;
1019
- if(!allowed(candidate,state.placements,tmpl,globalSupportPpm,metrics))continue;
1749
+ if(!allowed(candidate,state.placements,tmpl,globalSupportPpm,metrics,loadBase,accessBase))continue;
1750
+ // With zero load the candidate is at its largest, and appending it can only shrink
1751
+ // existing compressible supports. If that upper bound fits, the exact support-graph
1752
+ // refresh cannot reject it; only a candidate near the reserve boundary pays the
1753
+ // non-local calculation. Ordinary requests retain the incremental O(1) path above.
1754
+ if(compressionSensitive){const upperBound=used+occupiedVolume(tentative);
1755
+ if(upperBound+tmpl.reserve>tmpl.innerVolume
1756
+ &&usedVolume([...state.placements,tentative])+tmpl.reserve>tmpl.innerVolume)continue}
1020
1757
  metrics.feasible_candidates++;
1021
1758
  const score=solverAlias==='grid'
1022
1759
  ?pt[2]*1e12+pt[1]*1e6+pt[0]
@@ -1047,9 +1784,13 @@ const tryPackIntoTemplate=(tmpl,itemsRemaining)=>{const state={tmpl,placements:[
1047
1784
  metrics.search_nodes_expanded++;
1048
1785
  const best=candidatesFor(tmpl,item,state,points,index,used,1)[0];
1049
1786
  if(!best){ok=false;break}
1050
- state.payload+=item.w;used+=usedVolumeDelta(state.placements,best);state.placements.push(best);
1787
+ state.payload+=item.w;
1788
+ const compressionSensitive=item.shapeType==='compressible'
1789
+ ||state.placements.some(placement=>placement.item.shapeType==='compressible');
1790
+ used=compressionSensitive?usedVolume([...state.placements,best]):used+usedVolumeDelta(state.placements,best);
1791
+ state.placements.push(best);
1051
1792
  indexAdd(index,state.placements.length-1,{x:best.x,y:best.y,z:best.z,d:best.ed});
1052
- retirePointsInside(points,{x:best.x,y:best.y,z:best.z,d:best.ed});
1793
+ retirePointsForPlacement(points,best);
1053
1794
  for(const point of pointsFrom(best))insertPoint(points,point)}
1054
1795
  if(!ok){state.placements=snapshotPlacements;state.payload=snapshotPayload;used=snapshotUsed;
1055
1796
  if(snapshotPoints)points.splice(0,points.length,...snapshotPoints);
@@ -1063,9 +1804,12 @@ const packBeamIntoTemplate=(tmpl,itemsRemaining)=>{
1063
1804
  index:makeIndex(tmpl.d),unplaced:[]});
1064
1805
  const clone=node=>({state:{tmpl,placements:node.state.placements.slice(),payload:node.state.payload},used:node.used,
1065
1806
  points:node.points.slice(),index:copyIndex(node.index),unplaced:node.unplaced.slice()});
1066
- const place=(node,candidate)=>{node.state.payload+=candidate.item.w;node.used+=usedVolumeDelta(node.state.placements,candidate);
1807
+ const place=(node,candidate)=>{node.state.payload+=candidate.item.w;
1808
+ const compressionSensitive=candidate.item.shapeType==='compressible'
1809
+ ||node.state.placements.some(placement=>placement.item.shapeType==='compressible');
1810
+ node.used=compressionSensitive?usedVolume([...node.state.placements,candidate]):node.used+usedVolumeDelta(node.state.placements,candidate);
1067
1811
  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
- retirePointsInside(node.points,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed});for(const point of pointsFrom(candidate))insertPoint(node.points,point)};
1812
+ retirePointsForPlacement(node.points,candidate);for(const point of pointsFrom(candidate))insertPoint(node.points,point)};
1069
1813
  const sortCosts=costs=>costs.sort((a,b)=>a<b?-1:a>b?1:0);
1070
1814
  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
1815
  // `future` is the same array for every comparison inside one `expansions.sort(...)`
@@ -1124,7 +1868,7 @@ const packExactIntoTemplate=(tmpl,itemsRemaining)=>{
1124
1868
  w.state.payload+=candidate.item.w;w.used+=usedVolumeDelta(w.state.placements,candidate);
1125
1869
  w.state.placements.push(candidate);
1126
1870
  indexAdd(w.index,w.state.placements.length-1,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed});
1127
- retirePointsInside(w.points,{x:candidate.x,y:candidate.y,z:candidate.z,d:candidate.ed});
1871
+ retirePointsForPlacement(w.points,candidate);
1128
1872
  for(const point of pointsFrom(candidate))insertPoint(w.points,point)};
1129
1873
  // One child per feasible candidate for a lone item; a group is all-or-nothing, so it
1130
1874
  // contributes at most one child placed greedily member by member.
@@ -1454,6 +2198,8 @@ function rebalanceContext(req,result){
1454
2198
  nesting:raw.nesting_height==null?null:scalar(raw.nesting_height,unit,LEN),
1455
2199
  maxStacked:raw.max_stacked_items??null,groundRule:raw.ground_contact_rule??null,
1456
2200
  stopIndex:raw.stop_index??null,eligibleTags:raw.eligible_container_tags??[],
2201
+ ...parseShape(raw,dims(raw.dimensions,unit),unit,
2202
+ raw.nesting_height==null?null:scalar(raw.nesting_height,unit,LEN)),
1457
2203
  })
1458
2204
  }
1459
2205
  const templates=new Map((req.containers??[]).map(raw=>{