@flighthq/binpack 0.3.0 → 0.3.1-next.1041.35b950c
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.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"explainUnpackedRectangles.d.ts","sourceRoot":"","sources":["../src/explainUnpackedRectangles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,iBAAiB,
|
|
1
|
+
{"version":3,"file":"explainUnpackedRectangles.d.ts","sourceRoot":"","sources":["../src/explainUnpackedRectangles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,iBAAiB,EAEjB,4BAA4B,EAE7B,MAAM,0BAA0B,CAAC;AAalC,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,SAAS,QAAQ,CAAC,iBAAiB,CAAC,EAAE,EAClD,OAAO,CAAC,EAAE,QAAQ,CAAC,cAAc,CAAC,GACjC,4BAA4B,EAAE,CAiChC"}
|
|
@@ -16,11 +16,24 @@ export function explainUnpackedRectangles(rectangles, options) {
|
|
|
16
16
|
const result = packRectangles(rectangles, options);
|
|
17
17
|
if (result.unpacked.length === 0)
|
|
18
18
|
return [];
|
|
19
|
-
|
|
19
|
+
// `unpacked` is a MULTISET, not a set. packRectangles treats two rectangles sharing an id as distinct
|
|
20
|
+
// pieces, so an id appears once per piece that failed — and matching on mere membership emitted an
|
|
21
|
+
// entry for every rectangle carrying an unpacked id, which is two explanations for one failure when
|
|
22
|
+
// only one of a duplicate pair fits. Consuming a count keeps the entry count equal to the failure
|
|
23
|
+
// count, which is what the contract above promises.
|
|
24
|
+
//
|
|
25
|
+
// WHICH duplicate a given entry describes is input order, and that is the honest limit: the packer
|
|
26
|
+
// sorts by area before placing and reports bare ids, so an id cannot identify a piece the packer
|
|
27
|
+
// treats as distinct. The count is right; the attribution between same-id pieces is positional.
|
|
28
|
+
const outstanding = new Map();
|
|
29
|
+
for (const id of result.unpacked)
|
|
30
|
+
outstanding.set(id, (outstanding.get(id) ?? 0) + 1);
|
|
20
31
|
const explanations = [];
|
|
21
32
|
for (const rectangle of rectangles) {
|
|
22
|
-
|
|
33
|
+
const remaining = outstanding.get(rectangle.id) ?? 0;
|
|
34
|
+
if (remaining === 0)
|
|
23
35
|
continue;
|
|
36
|
+
outstanding.set(rectangle.id, remaining - 1);
|
|
24
37
|
explanations.push({
|
|
25
38
|
id: rectangle.id,
|
|
26
39
|
reason: getUnpackedReason(rectangle, usableWidth, usableHeight, allowRotation),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"explainUnpackedRectangles.js","sourceRoot":"","sources":["../src/explainUnpackedRectangles.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"explainUnpackedRectangles.js","sourceRoot":"","sources":["../src/explainUnpackedRectangles.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,2BAA2B,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAE/E,0GAA0G;AAC1G,2GAA2G;AAC3G,sFAAsF;AACtF,EAAE;AACF,wGAAwG;AACxG,wGAAwG;AACxG,kFAAkF;AAClF,EAAE;AACF,+FAA+F;AAC/F,MAAM,UAAU,yBAAyB,CACvC,UAAkD,EAClD,OAAkC;IAElC,MAAM,MAAM,GAAG,OAAO,EAAE,MAAM,IAAI,CAAC,CAAC;IACpC,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,KAAK,CAAC;IACtD,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,QAAQ,IAAI,2BAA2B,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;IACpF,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,SAAS,IAAI,2BAA2B,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;IAEtF,MAAM,MAAM,GAAG,cAAc,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IACnD,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAE5C,sGAAsG;IACtG,mGAAmG;IACnG,oGAAoG;IACpG,kGAAkG;IAClG,oDAAoD;IACpD,EAAE;IACF,mGAAmG;IACnG,iGAAiG;IACjG,gGAAgG;IAChG,MAAM,WAAW,GAAG,IAAI,GAAG,EAAuB,CAAC;IACnD,KAAK,MAAM,EAAE,IAAI,MAAM,CAAC,QAAQ;QAAE,WAAW,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACtF,MAAM,YAAY,GAAmC,EAAE,CAAC;IACxD,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;QACrD,IAAI,SAAS,KAAK,CAAC;YAAE,SAAS;QAC9B,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,EAAE,SAAS,GAAG,CAAC,CAAC,CAAC;QAC7C,YAAY,CAAC,IAAI,CAAC;YAChB,EAAE,EAAE,SAAS,CAAC,EAAE;YAChB,MAAM,EAAE,iBAAiB,CAAC,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,CAAC;YAC9E,WAAW;YACX,YAAY;SACb,CAAC,CAAC;IACL,CAAC;IACD,OAAO,YAAY,CAAC;AACtB,CAAC;AAED,yGAAyG;AACzG,qGAAqG;AACrG,+BAA+B;AAC/B,SAAS,iBAAiB,CACxB,SAAsC,EACtC,WAAmB,EACnB,YAAoB,EACpB,aAAsB;IAEtB,IAAI,WAAW,IAAI,CAAC,IAAI,YAAY,IAAI,CAAC;QAAE,OAAO,iBAAiB,CAAC;IACpE,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,WAAW,EAAE,YAAY,CAAC,EAAE,CAAC;QAC9E,IAAI,CAAC,aAAa;YAAE,OAAO,WAAW,CAAC;QACvC,gGAAgG;QAChG,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,KAAK,EAAE,WAAW,EAAE,YAAY,CAAC;YAAE,OAAO,WAAW,CAAC;IACpG,CAAC;IACD,OAAO,cAAc,CAAC;AACxB,CAAC;AAED,SAAS,UAAU,CAAC,KAAa,EAAE,MAAc,EAAE,WAAmB,EAAE,YAAoB;IAC1F,OAAO,KAAK,IAAI,WAAW,IAAI,MAAM,IAAI,YAAY,CAAC;AACxD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flighthq/binpack",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1-next.1041.35b950c",
|
|
4
4
|
"author": "Joshua Granick and other contributors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@flighthq/geometry": "0.3.
|
|
42
|
-
"@flighthq/types": "0.3.
|
|
41
|
+
"@flighthq/geometry": "0.3.1-next.1041.35b950c",
|
|
42
|
+
"@flighthq/types": "0.3.1-next.1041.35b950c"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"typescript": "^5.3.0"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { RectangleId } from '@flighthq/types/contract';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
|
|
3
4
|
import { explainUnpackedRectangles } from './explainUnpackedRectangles';
|
|
5
|
+
import { packRectangles } from './packRectangles';
|
|
4
6
|
|
|
5
7
|
describe('explainUnpackedRectangles', () => {
|
|
6
8
|
it('returns nothing when everything was placed', () => {
|
|
@@ -54,3 +56,72 @@ describe('explainUnpackedRectangles', () => {
|
|
|
54
56
|
expect(explanation).toMatchObject({ id: 'a', usableWidth: 90, usableHeight: 90 });
|
|
55
57
|
});
|
|
56
58
|
});
|
|
59
|
+
|
|
60
|
+
describe('explainUnpackedRectangles cross-checked against packRectangles', () => {
|
|
61
|
+
// The two functions must agree about the same input: an explanation per failure, no more and no less.
|
|
62
|
+
// They can disagree because they identify pieces differently — the packer treats two rectangles sharing
|
|
63
|
+
// an id as DISTINCT and reports bare ids, so a membership test cannot tell one failure from two.
|
|
64
|
+
it('returns exactly one entry per unpacked piece when two rectangles share an id', () => {
|
|
65
|
+
const rectangles = [
|
|
66
|
+
{ id: 'a', width: 60, height: 60 },
|
|
67
|
+
{ id: 'a', width: 60, height: 60 },
|
|
68
|
+
];
|
|
69
|
+
const options = { growable: false, maxHeight: 64, maxWidth: 64 } as const;
|
|
70
|
+
|
|
71
|
+
const result = packRectangles(rectangles, options);
|
|
72
|
+
// One of the pair fits and the other does not, which is the case a set-based match gets wrong.
|
|
73
|
+
expect(result.placements).toHaveLength(1);
|
|
74
|
+
expect(result.unpacked).toEqual(['a']);
|
|
75
|
+
|
|
76
|
+
expect(explainUnpackedRectangles(rectangles, options)).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns one entry per failure when BOTH duplicates fail', () => {
|
|
80
|
+
const rectangles = [
|
|
81
|
+
{ id: 'a', width: 500, height: 500 },
|
|
82
|
+
{ id: 'a', width: 500, height: 500 },
|
|
83
|
+
];
|
|
84
|
+
const options = { growable: false, maxHeight: 64, maxWidth: 64 } as const;
|
|
85
|
+
|
|
86
|
+
expect(packRectangles(rectangles, options).unpacked).toEqual(['a', 'a']);
|
|
87
|
+
expect(explainUnpackedRectangles(rectangles, options)).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('agrees with packRectangles across seeded inputs, counting ids rather than assuming uniqueness', () => {
|
|
91
|
+
for (let seed = 1; seed <= 30; seed += 1) {
|
|
92
|
+
const random = makeRandom(seed);
|
|
93
|
+
const count = 1 + Math.floor(random() * 12);
|
|
94
|
+
const rectangles = Array.from({ length: count }, (_, index) => ({
|
|
95
|
+
// Ids collide on purpose: half the seeds draw from a pool smaller than the rectangle count, so
|
|
96
|
+
// duplicate ids are the common case here rather than the exotic one.
|
|
97
|
+
id: seed % 2 === 0 ? `id-${index % 3}` : `id-${index}`,
|
|
98
|
+
width: 1 + Math.floor(random() * 80),
|
|
99
|
+
height: 1 + Math.floor(random() * 80),
|
|
100
|
+
}));
|
|
101
|
+
const options = { growable: false, maxHeight: 96, maxWidth: 96 } as const;
|
|
102
|
+
const label = `seed ${seed}`;
|
|
103
|
+
|
|
104
|
+
const unpacked = packRectangles(rectangles, options).unpacked;
|
|
105
|
+
const explained = explainUnpackedRectangles(rectangles, options);
|
|
106
|
+
|
|
107
|
+
expect(explained.length, label).toBe(unpacked.length);
|
|
108
|
+
expect(countById(explained.map((entry) => entry.id)), label).toEqual(countById(unpacked));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function countById(ids: readonly RectangleId[]): Record<string, number> {
|
|
114
|
+
const counts: Record<string, number> = {};
|
|
115
|
+
for (const id of ids) counts[String(id)] = (counts[String(id)] ?? 0) + 1;
|
|
116
|
+
return counts;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A small deterministic PRNG, so a failure names a seed a reader can re-run rather than a shape that
|
|
120
|
+
// vanishes on the next run.
|
|
121
|
+
function makeRandom(seed: number): () => number {
|
|
122
|
+
let state = seed * 1103515245 + 12345;
|
|
123
|
+
return () => {
|
|
124
|
+
state = (state * 1103515245 + 12345) % 2147483648;
|
|
125
|
+
return state / 2147483648;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PackableRectangle, PackedRectangle } from '@flighthq/types/contract';
|
|
1
|
+
import type { PackableRectangle, PackedRectangle, RectangleId } from '@flighthq/types/contract';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { BIN_PACK_DEFAULT_MAX_EXTENT, getPackResultOccupancy, packRectangles } from './packRectangles';
|
|
@@ -286,7 +286,13 @@ describe('packRectangles properties', () => {
|
|
|
286
286
|
const count = 1 + Math.floor(random() * 25);
|
|
287
287
|
const rects: PackableRectangle[] = [];
|
|
288
288
|
for (let i = 0; i < count; i++) {
|
|
289
|
-
|
|
289
|
+
// Every fifth seed draws ids from a pool smaller than the rectangle count, so DUPLICATE IDS are
|
|
290
|
+
// exercised rather than left exotic — the packer treats same-id rectangles as distinct pieces,
|
|
291
|
+
// and duplicates are exactly where an identity check and a count check stop agreeing. Same-id
|
|
292
|
+
// pieces are given the same size so a size assertion keyed by id still names one shape.
|
|
293
|
+
const id = seed % 5 === 0 ? i % 3 : i;
|
|
294
|
+
const size = makeRandom(seed * 1000 + (id as number));
|
|
295
|
+
rects.push({ id, width: 1 + Math.floor(size() * 60), height: 1 + Math.floor(size() * 60) });
|
|
290
296
|
}
|
|
291
297
|
const padding = Math.floor(random() * 4);
|
|
292
298
|
const border = Math.floor(random() * 4);
|
|
@@ -296,8 +302,34 @@ describe('packRectangles properties', () => {
|
|
|
296
302
|
const result = packRectangles(rects, options);
|
|
297
303
|
const label = `seed ${seed} (${heuristic})`;
|
|
298
304
|
|
|
299
|
-
// Every input is accounted for exactly once.
|
|
305
|
+
// Every input is accounted for exactly once — BY IDENTITY, not by count. A count is satisfied by
|
|
306
|
+
// placing one piece twice and losing another, which is the corruption this package can actually
|
|
307
|
+
// produce and the one that reaches an atlas as two sprites sharing pixels.
|
|
300
308
|
expect(result.placements.length + result.unpacked.length, label).toBe(rects.length);
|
|
309
|
+
expect(countIds([...result.placements.map((placement) => placement.id), ...result.unpacked]), label).toEqual(
|
|
310
|
+
countIds(rects.map((rect) => rect.id)),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Every placement carries ITS OWN size, swapped exactly when it says it was turned. A packer that
|
|
314
|
+
// reports `rotated` without swapping places correctly and describes the placement wrongly, and the
|
|
315
|
+
// overlap and bounds assertions below both pass for it.
|
|
316
|
+
const sizes = new Map<number, { height: number; width: number }>();
|
|
317
|
+
for (const rect of rects) sizes.set(rect.id as number, { height: rect.height, width: rect.width });
|
|
318
|
+
for (const placement of result.placements) {
|
|
319
|
+
const source = sizes.get(placement.id as number)!;
|
|
320
|
+
const expected = placement.rotated
|
|
321
|
+
? { height: source.width, width: source.height }
|
|
322
|
+
: { height: source.height, width: source.width };
|
|
323
|
+
expect({ height: placement.height, width: placement.width }, `${label}: ${String(placement.id)}`).toEqual(
|
|
324
|
+
expected,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The occupancy helper must describe the placements it is derived from, not a stale or parallel
|
|
329
|
+
// accounting of them.
|
|
330
|
+
const placedArea = result.placements.reduce((total, placement) => total + placement.width * placement.height, 0);
|
|
331
|
+
const reportedArea = result.width * result.height;
|
|
332
|
+
expect(getPackResultOccupancy(result), label).toBeCloseTo(reportedArea > 0 ? placedArea / reportedArea : 0, 12);
|
|
301
333
|
|
|
302
334
|
for (const placement of result.placements) {
|
|
303
335
|
// Inside the bin, honouring the border on every side.
|
|
@@ -326,3 +358,9 @@ describe('packRectangles properties', () => {
|
|
|
326
358
|
}
|
|
327
359
|
});
|
|
328
360
|
});
|
|
361
|
+
|
|
362
|
+
function countIds(ids: readonly RectangleId[]): Record<string, number> {
|
|
363
|
+
const counts: Record<string, number> = {};
|
|
364
|
+
for (const id of ids) counts[String(id)] = (counts[String(id)] ?? 0) + 1;
|
|
365
|
+
return counts;
|
|
366
|
+
}
|