Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/DISCUSSION_TEMPLATE/q-a.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ body:
id: package-version
attributes:
label: Package and version
placeholder: "@packvium/engine 1.9.0 (JavaScript)"
placeholder: "@packvium/engine 1.10.0 (JavaScript)"
validations:
required: true
- type: textarea
Expand Down
2 changes: 1 addition & 1 deletion .github/DISCUSSION_TEMPLATE/show-and-tell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ body:
id: package-version
attributes:
label: Package and version
placeholder: "@packvium/engine 1.9.0 (JavaScript)"
placeholder: "@packvium/engine 1.10.0 (JavaScript)"
validations:
required: false
- type: textarea
Expand Down
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ body:
id: version
attributes:
label: Package version
description: The exact version installed, for example 1.9.0.
placeholder: "1.9.0"
description: The exact version installed, for example 1.10.0.
placeholder: "1.10.0"
validations:
required: true
- type: input
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ body:
id: version
attributes:
label: Package version
placeholder: "1.9.0"
placeholder: "1.10.0"
validations:
required: true
- type: textarea
Expand Down
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,68 @@ What changed in `@packvium/engine` on npm, release by release. The format follow
[Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.10.0]

Nested packing, overhanging pallets, gusseted bags, 24 rotations for a hull, a declared currency
and an id on every placement.

### Added

- **`packNested({ levels, levelBeamWidth })`**, this package's first nested packer: each level is
a JSON request, and each packed container is handed up as one upright item. A `levelBeamWidth`
above 1 (default 1, the greedy chain) carries that many carton sets up a level, so a slightly
worse carton level can win by needing fewer pallets.
- **`container.pallet_overhang_limit`** is honoured instead of refused: a load may pass the deck by
the stated length at each end and width at each side. Positions are measured from the corner of
the overhang zone, only the deck bears weight, and the packed pallet gains
`achieved_dimensions`. Refused beside `access_directions`, `axles`, `made_to_measure`,
`obstacles`, a non-zero `void_fill_reserve_ratio` or any `stop_index`.
- **`deformable_boundary: { kind: 'gusseted_bag', side_gusset_depth }`**: a mailer whose side
gussets let the load's width plus its thickness reach the flat width plus the gusset depth.
- **18 signed rotations for a `convex_hull` item** (`-L-W+H` and the rest), 24 in all, in
`allowed_rotations`, a placement's `orientation`, `fixed_placements` and revision events. A
signed code on any other item is `invalid_request` / `invalid_value`.
- **An optional `currency`** on the request and on each `rate_table`, three capital letters,
echoed as the result's `currency`. It never changes the packing or the score; a rate table in
another currency is `not_allowed`, and one with no request currency is `missing_field`.
- **`placement_id` on every placement**: the canonical JSON of `[container id, x, y, z,
item_type, orientation]`, positions in ticks. A fixed placement or a locked revision event
may quote it, and the execution plan's placement reference copies it.
- **`optimality.lower_bound` and the gap under `open_dimension_height`, `maximum_value` and
`maximum_volume`**, besides `default` and `lowest_cost`; `validate` checks them.
- **A made-to-measure box may keep a `void_fill_reserve_ratio`**: when the box cut tight to the
load would break the reserve, a larger one is cut.
- **`stockCutBoxes`** in `revisions.js`, and the matching `artifact_required` revision issue.
- **`validate` reports `placement_id_mismatch`, `duplicate_placement_id` and
`currency_mismatch`.**

### Changed

- **Breaking for requests with a `units` key other than `length`:** it was ignored, so
`units.weight` silently read bare weights as grams; it is now `invalid_request` / `not_allowed`
at `/units/<key>`. Write the unit on each weight instead.
- **`deriveRevision` stocks the boxes a plan already cut:** a locked or verified placement in a
made-to-measure box used to derive a request every engine refused. The box now becomes a stock
container `<type>@<revision>#<instance>` at its cut size, and `verifyRevisionChain` checks that
revision only when given its approved artifact.
- **`keep_upright` narrows an explicit `allowed_rotations` list** in the fallback and in
`validate`, not only the default list.
- **The fallback breaks rotation ties by the rotation table's order**, not by string order; a hull
answer can move, and three now match the other engines.
- **On the native backend, containers are numbered per type** (`pair#1`, `large#1`) on every
path, as the fallback always did.
- **`FixedPlacementError` has a third reason, `invalid_value`**, for a `placement_id` that is not
the one its entry spells, or a signed orientation on an item that is not a hull.

### Fixed

- **An explicit rotation list emptied by `keep_upright` is refused at `allowed_rotations`**,
with the same named request error the independent validator checks.
- **Stocking cut boxes indexes artifact lookups once** and reports malformed consumed
machine fields as structured `invalid_revision` errors.
- **Nested beams keep fixed placements across container offers and handoffs** instead of
aborting when a different carried branch cannot supply a locked item or container.

## [1.9.0]

The fallback now checks what the addon checks, spends an effort budget the same way and reports
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ type: software
authors:
- name: "Packvium contributors"
website: "https://packvium.com"
version: "1.9.0"
version: "1.10.0"
license: MIT
url: "https://packvium.com"
repository-code: "https://github.com/toxakara/packvium-node"
Expand Down
44 changes: 29 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,13 @@ and `''` means the request as a whole. The message is `<code>: <field>: <detail>
every Packvium engine. Branch on `reason` and `field`; show the message to a person.

- `FixedPlacementError` extends it, with code `invalid_fixed_placement` and reason `malformed`
(`field` points at the bad value) or `cannot_hold` (the fixed items are not a valid packing on
their own; `field` is `/fixed_placements`). One `catch` for `InvalidRequestError` covers both.
(`field` points at the bad value), `invalid_value` (a `placement_id` that is not the one the
entry's own fields spell, or a signed orientation on an item that is not a `convex_hull`) or
`cannot_hold` (the fixed items are not a valid packing on their own; `field` is
`/fixed_placements`). One `catch` for `InvalidRequestError` covers all three.
- `UnsupportedFeatureError` is separate, with code `unsupported_feature` and a `fields` list: the
request uses a field the schema reserves but this engine does not implement yet, such as
`container.pallet_overhang_limit`. It is refused rather than silently ignored.
request uses a field, or a combination of fields, this engine does not implement yet, such as
`container.pallet_overhang_limit+axles`. It is refused rather than silently ignored.
- Numbers are judged by value, as the other engines judge them: `2.0` is the integer `2`, `2.5`
and `'2'` are not integers, and an integer above `Number.MAX_SAFE_INTEGER` (2^53 - 1) is
`above_maximum` -- also when it arrives as JSON text, which `JSON.parse` would otherwise round.
Expand Down Expand Up @@ -233,21 +235,22 @@ and execute without a project around it, and each prints what it teaches.
| File | What it shows |
| --- | --- |
| [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. |
| [`objectives.mjs`](examples/objectives.mjs) | Six of the seven objectives (all but `maximum_volume`) on scenes where they genuinely disagree about which container to open. |
| [`objectives.mjs`](examples/objectives.mjs) | Six of the seven objectives (all but `maximum_volume`) on scenes where they genuinely disagree about which container to open, a declared `currency` echoed on the answer, and the `optimality.lower_bound` five of them state. |
| [`constraints.mjs`](examples/constraints.mjs) | Rotations and `keep_upright`, support ratio, top load, stacking caps, incompatible tags and atomic groups — each with and without the rule — and `explainUnpackedItem` turning a refusal into a sentence. |
| [`limits.mjs`](examples/limits.mjs) | Wheel arches as `obstacles` with `additional_boxes`, `max_items` and `tag_limits` per container, and what happens when container stock or `max_containers` runs out. |
| [`limits.mjs`](examples/limits.mjs) | Wheel arches as `obstacles` with `additional_boxes`, `max_items` and `tag_limits` per container, what happens when container stock or `max_containers` runs out, a crate overhanging its pallet under `pallet_overhang_limit`, and a flat bag against a gusseted one. |
| [`trucking.mjs`](examples/trucking.mjs) | A multi-drop van: `stop_index` and a rear door in `access_directions`, two `axles` with load limits, and a safe loading order fed into `buildExecutionPlan`. |
| [`shapes.mjs`](examples/shapes.mjs) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, and a cushion that compresses under load until the crush limit refuses it. |
| [`shapes.mjs`](examples/shapes.mjs) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, two identical wedges that need one of the 18 signed rotations to share it, and a cushion that compresses under load until the crush limit refuses it. |
| [`units.mjs`](examples/units.mjs) | Why lengths travel as strings: fractional inches kept exact, one tick deciding a fit, and the point where a JavaScript number stops being exact and a quote is refused rather than rounded. |
| [`serialization.mjs`](examples/serialization.mjs) | `packJson` with JSON text in and out, runners-up through `alternatives`, and exactly which mistakes are refused and which are ignored. |
| [`reproducibility.mjs`](examples/reproducibility.mjs) | `effort_budget` against `time_limit_ms`: a budget that never binds, one that cuts the search short and still repeats exactly, and how to tell from the result which one stopped it. |
| [`errors.mjs`](examples/errors.mjs) | `InvalidRequestError`'s `code`, `reason`, `field` and message for each kind of bad request, JavaScript's integer range, `FixedPlacementError` and `UnsupportedFeatureError` — and a valid request that simply does not fit. |
| [`fixed_placements.mjs`](examples/fixed_placements.mjs) | Pack around items already loaded, lock half of an earlier plan by quoting its placements, and a fixed set that cannot hold. |
| [`fixed_placements.mjs`](examples/fixed_placements.mjs) | Pack around items already loaded, lock half of an earlier plan by quoting its placements and their `placement_id`, a fixed set that cannot hold, and an id that names another placement. |
| [`rebalancing.mjs`](examples/rebalancing.mjs) | `rebalanceWeight` evening out payload between packed crates, the moves it made, and `maxMoves`. |
| [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. |
| [`execution.mjs`](examples/execution.mjs) | Turn a result into dock instructions, and what "byte-identical" does and does not promise: the four adapters agree on any given result, while this engine is free to reach a different packing than Python does. |
| [`artifacts.mjs`](examples/artifacts.mjs) | Hand a result to a system with no engine: one document with the plan, geometry and the request that produced it, exported as CSV and a printable HTML work order, and a refusal for a format it does not know. |
| [`revisions.mjs`](examples/revisions.mjs) | Replan a half-loaded job: a missing item and a locked placement recorded against the approved plan, a replan that keeps the locked item in place, and a hash-chained record that notices an edit. |
| [`revisions.mjs`](examples/revisions.mjs) | Replan a half-loaded job: a missing item and a locked placement recorded against the approved plan, a replan that keeps the locked item in place, a hash-chained record that notices an edit, and a box cut to measure (with a void-fill reserve) that a revision turns into stock. |
| [`nested.mjs`](examples/nested.mjs) | `packNested`: cartons, then a pallet, and the carton set the greedy chain picks against the one `levelBeamWidth: 2` finds the pallet can take. |

```bash
node examples/basic.mjs
Expand Down Expand Up @@ -280,15 +283,26 @@ step's placement the way its scene item is named, to match one to the other.
## Features

- Exact, deterministic placement with no floating-point geometry decisions.
- Rotation, payload, stackability, support, clearance, obstacle and tag constraints.
- Rotation, payload, stackability, support, clearance, obstacle and tag constraints; a
`convex_hull` item may also take the 18 signed rotations (`-L-W+H` and the rest), 24 in all.
- Pallets a load may overhang (`pallet_overhang_limit`), flat and gusseted mailer bags
(`deformable_boundary`), and boxes cut to measure (`made_to_measure`), with a void-fill
reserve if you want one.
- A declared `currency` on the request and its rate tables, echoed on the result, so a price is
never read in the wrong money.
- A `placement_id` on every placement, the same string in every engine, to key a lock or match a
plan step to the box it places.
- Multiple container types and clear explanations for unpacked items.
- A proof, or the distance to one: under the `default` and `lowest_cost` objectives every result
states `optimality.lower_bound`; a complete result that attains it reports `optimal`, and any
other carries the gap to the bound.
- A proof, or the distance to one: under the `default`, `lowest_cost`, `open_dimension_height`,
`maximum_value` and `maximum_volume` objectives every result states `optimality.lower_bound`; a
complete result that attains it reports `proven_optimal`, and any other carries the gap to the bound.
- `validate(request, result)`, the reference validator, on every backend: a plain `npm install`
checks results without the compiled addon.
- JSON input/output through `pack()` or `packJson()`.
- Optional payload rebalancing with `rebalanceWeight()`.
- Nested packing with `packNested({ levels, levelBeamWidth })`: cartons, then pallets, each
level a JSON request; a width above 1 carries the best carton sets up a level so the pallet
decides between them.
- Loading and removal sequence helpers for already placed boxes.
- Execution plans through `buildExecutionPlan()` / `canonicalPlanJson()`: a work-order view of a
result, with solver facts separated from the text that cites them.
Expand Down Expand Up @@ -319,7 +333,7 @@ Documentation, the constraint reference and the benchmarks are at
| --- | --- | --- |
| Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) |
| PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) |
| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.9"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.10"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
| Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) |
| Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) |
| PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) |
Expand Down Expand Up @@ -350,7 +364,7 @@ CodeMeta form.
@software{packvium_node,
author = {{Packvium contributors}},
title = {Packvium for Node.js},
version = {1.9.0},
version = {1.10.0},
license = {MIT},
url = {https://packvium.com}
}
Expand Down
25 changes: 23 additions & 2 deletions artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const FORMAT = 'packvium-operational-artifact/v1';
* The engine's own name is deliberately not recorded: four correct builders naming themselves
* would emit four different documents.
*/
export const SUITE_VERSION = '1.9.0';
export const SUITE_VERSION = '1.10.0';

/** The deterministic part of `result.algorithm`; `duration_ms` is wall-clock time. */
const SOLVER_FIELDS = ['profile', 'solver', 'seed', 'time_limit_reached', 'effort_limit_reached'];
Expand Down Expand Up @@ -52,7 +52,7 @@ export function buildOperationalArtifact(request, result, { loadingOrders } = {}
if (!isMapping(request)) throw refusal('invalid_request', 'a request is a JSON object');
if (!isMapping(result)) throw refusal('invalid_result', 'a result is a JSON object');
requireObjects(result);
const plan = planOf(request, result, loadingOrders);
const plan = planOf(request, withoutPlacementIds(result), loadingOrders);

const containers = list(get(result, 'containers'), 'result.containers');
const { lengthUnit, weightUnit } = unitsOf(containers);
Expand Down Expand Up @@ -94,6 +94,27 @@ function planOf(request, result, loadingOrders) {
}
}

/**
* The result as the v1 artifact reads it: every placement without its 1.10 `placement_id`.
* v1 is closed, and its embedded plan predates the id; the id stays recoverable from what v1
* carries -- the canonical JSON of `containers[container_index].id` and the reference's
* `position_ticks`, `item_type` and `orientation` (docs/OPERATIONAL-ARTIFACTS.md). Read after
* `requireObjects`, so every entry is an object; copies only what it changes, O(P).
*/
function withoutPlacementIds(result) {
const containers = list(get(result, 'containers'), 'result.containers');
return {
...result,
containers: containers.map((container, index) => ({
...container,
placements: placementsOf(index, container).map((placement) => {
const { placement_id: _identifier, ...rest } = placement;
return rest;
}),
})),
};
}

// ------------------------------------------------------------------------------ provenance

function provenanceOf(request, result) {
Expand Down
12 changes: 12 additions & 0 deletions canonical-json.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ export function canonicalJson(value) {
return parts.join('');
}

/**
* `canonicalJson` of an array, except that a lone UTF-16 surrogate in a string is written as its
* lowercase `\udxxx` escape instead of refused, as `packvium._canonical_json.canonical_identifier`
* writes it. For an identifier built from caller-chosen ids (`placement_id`, 1.10): a request may
* name a container that way, and an answer must still name its placements. O(length).
*/
export function canonicalIdentifier(values) {
// ES2019's well-formed `JSON.stringify` escapes a lone surrogate as lowercase `\udxxx` and
// every other character exactly as `spellString` does.
return `[${values.map((value) => (typeof value === 'string' ? JSON.stringify(value) : canonicalJson(value))).join(',')}]`;
}

/**
* The integer a JSON value is, judged by value as every engine can judge it: `1.0` is `1`
* (JavaScript cannot tell them apart once the text is parsed); `true`, `"1"` and `1.5` are
Expand Down
2 changes: 1 addition & 1 deletion codemeta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"@type": "SoftwareSourceCode",
"name": "Packvium for Node.js",
"description": "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.",
"version": "1.9.0",
"version": "1.10.0",
"license": "https://spdx.org/licenses/MIT",
"author": [
{
Expand Down
Loading
Loading