@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,EACjB,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,CAqBhC"}
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
- const unpacked = new Set(result.unpacked);
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
- if (!unpacked.has(rectangle.id))
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":"AAOA,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,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC1C,MAAM,YAAY,GAAmC,EAAE,CAAC;IACxD,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;YAAE,SAAS;QAC1C,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"}
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.0",
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.0",
42
- "@flighthq/types": "0.3.0"
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
- rects.push({ id: i, width: 1 + Math.floor(random() * 60), height: 1 + Math.floor(random() * 60) });
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
+ }