@mark1russell7/packing 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@mark1russell7/packing",
4
- "version": "0.2.1",
4
+ "version": "0.2.3",
5
5
  "description": "Rectangle packing: shelf, guillotine and skyline strategies, push cascades and measures of a layout.",
6
6
  "keywords": [
7
7
  "rectangle-packing",
@@ -49,7 +49,7 @@
49
49
  "node": ">=22.12"
50
50
  },
51
51
  "dependencies": {
52
- "@mark1russell7/layout-contract": "^0.2.1"
52
+ "@mark1russell7/layout-contract": "^0.2.4"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@mark1russell7/cue": "^1.0.2",
package/src/adapters.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { EPS, type PackInput, type PackOutput } from "./contract.ts";
4
4
  import { boundsOf, buildFrame, now, outerOf, readFrame, rectsOf, viaAxes, type Entry } from "./frame.ts";
5
5
  import { packFree } from "./free.ts";
6
+ import { clearMoves, moveCount, MOVES } from "./moves.ts";
6
7
  import type { SortOrder } from "./order.ts";
7
8
  import { pack } from "./pack.ts";
8
9
  import { pushCascade } from "./push.ts";
@@ -59,9 +60,10 @@ function packFixedWidth(id: "rows" | "skyline" | "shelf", input: PackInput, opti
59
60
  const start = now();
60
61
  const frame = buildFrame(input);
61
62
  const rects = rectsOf(frame);
63
+ clearMoves();
62
64
  if (frame.w > 0) pack(rects, outerOf(frame), { strategy: id, sortOrder: options.sortOrder, space: spaceOf(input, frame) });
63
65
  const output = readFrame(input, frame, []);
64
- return { ...output, trace: [{ id, ms: now() - start, measures: { "pack.overflow": output.overflow.length } }] };
66
+ return { ...output, trace: [{ id, ms: now() - start, measures: { "pack.overflow": output.overflow.length, [MOVES[id]]: moveCount(MOVES[id]) } }] };
65
67
  }
66
68
 
67
69
  /** This function makes the strategy of one function of `pack`. */
@@ -150,7 +152,7 @@ export const logSweep: SizeSearch = {
150
152
  ...output,
151
153
  trace: [
152
154
  { id: "log-sweep", ms, measures: { "pack.search.widths": result.tried.length, "pack.search.ms": ms } },
153
- { id, ms: ms / runs, measures: { "pack.overflow": output.overflow.length } },
155
+ { id, ms: ms / runs, measures: { "pack.overflow": output.overflow.length, [MOVES[id]]: moveCount(MOVES[id]) } },
154
156
  ],
155
157
  };
156
158
  },
@@ -191,8 +193,9 @@ function pushFixedWidth(input: PackInput, options: PushStrategyOptions, previous
191
193
  // The cascade: each item in the order of its rank pushes the others, and then it stays
192
194
  const inPlay = frame.entries.filter((e) => !pending.includes(e)).map((e) => e.rect);
193
195
  const maxShoves = options.maxShovesPerItem * Math.max(1, inPlay.length);
196
+ let cascaded = 0;
194
197
  for (const e of positioned) {
195
- pushCascade(e.rect, inPlay, { gap: 0, maxShoves });
198
+ cascaded += pushCascade(e.rect, inPlay, { gap: 0, maxShoves }).length;
196
199
  e.rect.locked = true;
197
200
  }
198
201
 
@@ -234,7 +237,7 @@ function pushFixedWidth(input: PackInput, options: PushStrategyOptions, previous
234
237
  return {
235
238
  ...output,
236
239
  trace: [
237
- { id: "push", ms: pushMs, measures: { "pack.push.moved": moved.length } },
240
+ { id: "push", ms: pushMs, measures: { "pack.push.moved": moved.length, [MOVES.push]: cascaded } },
238
241
  { id: "skyline", ms: now() - skylineStart, measures: { "pack.push.replaced": pending.length, "pack.overflow": output.overflow.length } },
239
242
  ],
240
243
  };
package/src/check.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { boundsOf, regionHitsRect, translate, type PackHard, type Rect, type Region } from "@mark1russell7/layout-contract";
1
+ import { boundsOf, rectInRegion, regionHitsRect, translate, type ContainerTest, type PackHard, type Rect, type Region } from "@mark1russell7/layout-contract";
2
2
  import { DEFAULT_TOLERANCE, marginOf, RECORD_EPS, turnItem, type Item, type PackInput, type PackOutput, type Placement, type Tolerance } from "./contract.ts";
3
3
 
4
- export type { PackHard } from "@mark1russell7/layout-contract";
4
+ export type { ContainerTest, PackHard } from "@mark1russell7/layout-contract";
5
5
 
6
6
  /** A box with its margin: the left, top, right and bottom edges. */
7
7
  interface Box {
@@ -36,8 +36,17 @@ function inner(box: Box, tolerance: number): Rect {
36
36
  * The test of an obstacle is exact: a circle, an ellipse and a polygon with holes count only their insides, as in `packHard`.
37
37
  * The rules read a shape of the container or of an item as its bounding box (decision 5). The bounding box of an item
38
38
  * shape is relative to the position of the item. The result has no tolerance in it.
39
+ *
40
+ * With `options.exactContainer`, a shaped container with the size `fixed` also uses its true outline for `in-container`.
41
+ * A box then counts when `rectInRegion` of the contract tells that it is not in the shape, as in `packHard`.
42
+ * For that test, the box grows by the inset and becomes smaller by `tolerance.overlap` on each side. The default is `false`.
39
43
  */
40
- export function checkHard(input: PackInput, output: PackOutput, tolerance: Pick<Tolerance, "overlap"> = DEFAULT_TOLERANCE): PackHard {
44
+ export function checkHard(
45
+ input: PackInput,
46
+ output: PackOutput,
47
+ tolerance: Pick<Tolerance, "overlap"> = DEFAULT_TOLERANCE,
48
+ options: ContainerTest = {},
49
+ ): PackHard {
41
50
  const tol = tolerance.overlap;
42
51
  const placements = new Map<string, Placement>();
43
52
  for (const p of output.placements) if (!placements.has(p.id)) placements.set(p.id, p);
@@ -94,11 +103,13 @@ export function checkHard(input: PackInput, output: PackOutput, tolerance: Pick<
94
103
  const inset = container.inset ?? 0;
95
104
  const fixedW = container.fixed === "size" || container.fixed === "width";
96
105
  const fixedH = container.fixed === "size" || container.fixed === "height";
106
+ const exact = options.exactContainer === true && container.fixed === "size" ? container.shape : undefined;
97
107
  let outside = 0;
98
108
  for (const box of boxes) {
99
109
  const outX = fixedW && (box.l < x + inset - tol || box.r > x + w - inset + tol);
100
110
  const outY = fixedH && (box.t < y + inset - tol || box.b > y + h - inset + tol);
101
111
  if (outX || outY) outside += 1;
112
+ else if (exact !== undefined && !rectInRegion(exact, inner({ l: box.l - inset, t: box.t - inset, r: box.r + inset, b: box.b + inset }, tol))) outside += 1;
102
113
  }
103
114
  return { "no-overlap": overlaps, "in-container": outside, "locked-kept": lockedKept, complete };
104
115
  }
package/src/free.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { clearMoves } from "./moves.ts";
1
2
  import { pack, type PackLayoutOptions } from "./pack.ts";
2
3
  import { Rect } from "./rect.ts";
3
4
 
@@ -85,6 +86,8 @@ export function packFree<T>(rects: Rect<T>[], outer: Rect<T> = new Rect(), optio
85
86
 
86
87
  const tried: FreeWidthTry[] = [];
87
88
  const run = (width: number): FreeWidthTry => {
89
+ // The counts of the moves come from the last run: the run with the best width
90
+ clearMoves();
88
91
  Rect.resetPacked(rects);
89
92
  const box = new Rect<T>();
90
93
  box.position.setTo(outer.position);
package/src/index.ts CHANGED
@@ -69,7 +69,7 @@ export {
69
69
  export { logSweep, push, rows, shelf, skyline, type LayoutStrategyOptions, type PushStrategyOptions } from "./adapters.ts";
70
70
  export { fromRects, toRects } from "./convert.ts";
71
71
  export { packWith, strategies, strategy } from "./registry.ts";
72
- export { checkHard, type PackHard } from "./check.ts";
72
+ export { checkHard, type ContainerTest, type PackHard } from "./check.ts";
73
73
  export {
74
74
  canonicalJson,
75
75
  countCell,
package/src/measures.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import type { MeasureSpec as ContractMeasureSpec, Direction } from "@mark1russell7/layout-contract";
4
4
  import { RECORD_EPS, type PackInput, type PackOutput } from "./contract.ts";
5
5
  import { aspectError } from "./free.ts";
6
+ import { overflowBound } from "./overflow.ts";
6
7
  import { round3 } from "./layout-input.ts";
7
8
 
8
9
  /** The direction of a measure comes from the contract: `lower` when a lower value is better, `higher` when a higher value is better. */
@@ -12,7 +13,7 @@ export type { Direction } from "@mark1russell7/layout-contract";
12
13
  export type MeasureFamily = "density" | "aspect" | "order" | "raggedness" | "overflow" | "stability" | "determinism" | "speed";
13
14
 
14
15
  /** The id of a measure of the packer. The ids are those of `docs/FORMAT.md`, and `highways` uses the same ids. */
15
- export type MeasureId = "pack.area" | "pack.fill" | "pack.whitespace" | "pack.aspect" | "pack.aspectError" | "pack.order.inversions" | "pack.order.breaks" | "pack.raggedness" | "pack.overflow" | "pack.stability.moved" | "pack.stability.mean" | "pack.stability.q95" | "pack.deterministic" | "pack.search.widths" | "pack.search.ms";
16
+ export type MeasureId = "pack.area" | "pack.fill" | "pack.whitespace" | "pack.aspect" | "pack.aspectError" | "pack.order.inversions" | "pack.order.breaks" | "pack.raggedness" | "pack.overflow" | "pack.overflow.rate" | "pack.overflow.area" | "pack.overflow.forced" | "pack.overflow.forcedArea" | "pack.overflow.excess" | "pack.overflow.forcedAreaMin" | "pack.overflow.excessArea" | "pack.stability.moved" | "pack.stability.mean" | "pack.stability.q95" | "pack.deterministic" | "pack.search.widths" | "pack.search.ms";
16
17
 
17
18
  /**
18
19
  * The facts of one measure: its family, its unit, its direction and its range.
@@ -47,6 +48,13 @@ export interface LayoutMeasures {
47
48
  "pack.order.breaks": number;
48
49
  "pack.raggedness": number;
49
50
  "pack.overflow": number;
51
+ "pack.overflow.rate": number;
52
+ "pack.overflow.area": number;
53
+ "pack.overflow.forced": number;
54
+ "pack.overflow.forcedArea": number;
55
+ "pack.overflow.excess": number;
56
+ "pack.overflow.forcedAreaMin": number;
57
+ "pack.overflow.excessArea": number;
50
58
  "pack.search.widths": number;
51
59
  "pack.search.ms": number;
52
60
  }
@@ -74,29 +82,45 @@ const spec = (
74
82
  direction: Direction,
75
83
  min: number,
76
84
  max: number,
85
+ minEffect: MeasureSpec["minEffect"],
77
86
  guard?: { floor: number; tol: number },
78
- ): MeasureSpec => ({ id, family, unit, direction, min, max, ...guard });
87
+ ): MeasureSpec => ({ id, family, unit, direction, min, max, ...(minEffect === undefined ? {} : { minEffect }), ...guard });
88
+
89
+ /** The minimum effect of an area or a ratio of lengths: 1% of the larger value. */
90
+ const REL = Object.freeze({ rel: 0.01 });
79
91
 
80
92
  /**
81
93
  * The facts of each measure, by id. `docs/MEASURES.md` gives the full definitions. This record is the source of the floor
82
- * and the tolerance of each guard of the packer.
94
+ * and the tolerance of each guard of the packer, and of the minimum effect of each measure.
95
+ *
96
+ * - A count and a share of counts have the minimum effect 0. Thus each change of a count is an effect.
97
+ * - A share of an area from 0 to 1 has 0.005. A guard has its tolerance.
98
+ * - An area and a ratio of lengths have `{ rel: 0.01 }`: 1% of the larger value.
99
+ * - The time has no minimum effect, because the times of CI change each time.
83
100
  */
84
101
  export const MEASURE_SPECS: Readonly<Record<MeasureId, MeasureSpec>> = Object.freeze({
85
- "pack.area": spec("pack.area", "density", "square units", "lower", 0, Infinity),
86
- "pack.fill": spec("pack.fill", "density", "share", "higher", 0, 1),
87
- "pack.whitespace": spec("pack.whitespace", "density", "share", "lower", 0, 1),
88
- "pack.aspect": spec("pack.aspect", "aspect", "ratio", "lower", 1, Infinity),
89
- "pack.aspectError": spec("pack.aspectError", "aspect", "natural log", "lower", 0, Infinity, { floor: 0, tol: 0.01 }),
90
- "pack.order.inversions": spec("pack.order.inversions", "order", "share", "lower", 0, 1, { floor: 0, tol: 0 }),
91
- "pack.order.breaks": spec("pack.order.breaks", "order", "count", "lower", 0, Infinity),
92
- "pack.raggedness": spec("pack.raggedness", "raggedness", "share", "lower", 0, 1),
93
- "pack.overflow": spec("pack.overflow", "overflow", "count", "lower", 0, Infinity, { floor: 0, tol: 0 }),
94
- "pack.stability.moved": spec("pack.stability.moved", "stability", "share", "lower", 0, 1, { floor: 0, tol: 0.02 }),
95
- "pack.stability.mean": spec("pack.stability.mean", "stability", "container widths", "lower", 0, Infinity, { floor: 0, tol: 0.005 }),
96
- "pack.stability.q95": spec("pack.stability.q95", "stability", "container widths", "lower", 0, Infinity, { floor: 0, tol: 0.01 }),
97
- "pack.deterministic": spec("pack.deterministic", "determinism", "boolean", "higher", 0, 1),
98
- "pack.search.widths": spec("pack.search.widths", "speed", "count", "lower", 0, Infinity),
99
- "pack.search.ms": spec("pack.search.ms", "speed", "milliseconds", "lower", 0, Infinity),
102
+ "pack.area": spec("pack.area", "density", "square units", "lower", 0, Infinity, REL),
103
+ "pack.fill": spec("pack.fill", "density", "share", "higher", 0, 1, 0.005),
104
+ "pack.whitespace": spec("pack.whitespace", "density", "share", "lower", 0, 1, 0.005),
105
+ "pack.aspect": spec("pack.aspect", "aspect", "ratio", "lower", 1, Infinity, REL),
106
+ "pack.aspectError": spec("pack.aspectError", "aspect", "natural log", "lower", 0, Infinity, 0.01, { floor: 0, tol: 0.01 }),
107
+ "pack.order.inversions": spec("pack.order.inversions", "order", "share", "lower", 0, 1, 0, { floor: 0, tol: 0 }),
108
+ "pack.order.breaks": spec("pack.order.breaks", "order", "count", "lower", 0, Infinity, 0),
109
+ "pack.raggedness": spec("pack.raggedness", "raggedness", "share", "lower", 0, 1, 0.005),
110
+ "pack.overflow": spec("pack.overflow", "overflow", "count", "lower", 0, Infinity, 0, { floor: 0, tol: 0 }),
111
+ "pack.overflow.rate": spec("pack.overflow.rate", "overflow", "share", "lower", 0, 1, 0),
112
+ "pack.overflow.area": spec("pack.overflow.area", "overflow", "share", "lower", 0, 1, 0.005),
113
+ "pack.overflow.forced": spec("pack.overflow.forced", "overflow", "count", "lower", 0, Infinity, 0),
114
+ "pack.overflow.forcedArea": spec("pack.overflow.forcedArea", "overflow", "share", "lower", 0, 1, 0.005),
115
+ "pack.overflow.excess": spec("pack.overflow.excess", "overflow", "count", "lower", 0, Infinity, 0),
116
+ "pack.overflow.forcedAreaMin": spec("pack.overflow.forcedAreaMin", "overflow", "share", "lower", 0, 1, 0.005),
117
+ "pack.overflow.excessArea": spec("pack.overflow.excessArea", "overflow", "share", "lower", 0, 1, 0.005),
118
+ "pack.stability.moved": spec("pack.stability.moved", "stability", "share", "lower", 0, 1, 0.02, { floor: 0, tol: 0.02 }),
119
+ "pack.stability.mean": spec("pack.stability.mean", "stability", "container widths", "lower", 0, Infinity, 0.005, { floor: 0, tol: 0.005 }),
120
+ "pack.stability.q95": spec("pack.stability.q95", "stability", "container widths", "lower", 0, Infinity, 0.01, { floor: 0, tol: 0.01 }),
121
+ "pack.deterministic": spec("pack.deterministic", "determinism", "boolean", "higher", 0, 1, 0),
122
+ "pack.search.widths": spec("pack.search.widths", "speed", "count", "lower", 0, Infinity, 0),
123
+ "pack.search.ms": spec("pack.search.ms", "speed", "milliseconds", "lower", 0, Infinity, undefined),
100
124
  });
101
125
 
102
126
  /** A placed box: the rectangle of an item at its position, with its rank. */
@@ -183,6 +207,9 @@ function traceSum(output: PackOutput, id: string): number {
183
207
  * - The boxes are the placed items with a width and a height above 0. The hull is the smallest rectangle around them.
184
208
  * - An empty layout gives the area 0, the aspect 1 and the aspect error 0. Its fill is 0 and its whitespace is 1.
185
209
  * - The time `pack.search.ms` comes from the trace. It is not part of the determinism rule.
210
+ * - The overflow measures count each id once. `pack.overflow.forced` is the lower bound of `overflowBound`.
211
+ * - `pack.overflow.excess` is the overflow less the forced overflow. A container with no fixed side gives 0 for both.
212
+ * - `pack.overflow.forcedAreaMin` is a lower bound of the overflow area. `pack.overflow.excessArea` is the overflow area above it.
186
213
  *
187
214
  * @example
188
215
  * ```ts
@@ -225,6 +252,19 @@ export function measure(input: PackInput, output: PackOutput, options: MeasureOp
225
252
  const rights = rowsOf(boxes, tolerance).map((r) => r.right);
226
253
  const spread = rights.length > 0 ? Math.max(...rights) - Math.min(...rights) : 0;
227
254
 
255
+ // The overflow: the unplaced items, their area, and the part that no packer can place
256
+ const unplaced = new Set(output.overflow);
257
+ let itemArea = 0;
258
+ let unplacedArea = 0;
259
+ for (const item of input.items) {
260
+ const a = Math.max(0, item.w) * Math.max(0, item.h);
261
+ itemArea += a;
262
+ if (unplaced.has(item.id)) unplacedArea += a;
263
+ }
264
+ const bound = overflowBound(input);
265
+ const overflowArea = itemArea > 0 ? unplacedArea / itemArea : 0;
266
+ const forcedAreaMin = itemArea > 0 ? Math.min(1, bound.area / itemArea) : 0;
267
+
228
268
  return {
229
269
  "pack.area": hullArea,
230
270
  "pack.fill": fill,
@@ -234,12 +274,31 @@ export function measure(input: PackInput, output: PackOutput, options: MeasureOp
234
274
  "pack.order.inversions": pairs > 0 ? inversions / pairs : 0,
235
275
  "pack.order.breaks": breaks,
236
276
  "pack.raggedness": hull.w > 0 ? spread / hull.w : 0,
237
- "pack.overflow": new Set(output.overflow).size,
277
+ "pack.overflow": unplaced.size,
278
+ "pack.overflow.rate": input.items.length > 0 ? unplaced.size / input.items.length : 0,
279
+ "pack.overflow.area": overflowArea,
280
+ "pack.overflow.forced": bound.forced,
281
+ "pack.overflow.forcedArea": forcedAreaMin,
282
+ "pack.overflow.excess": Math.max(0, unplaced.size - bound.forced),
283
+ "pack.overflow.forcedAreaMin": forcedAreaMin,
284
+ "pack.overflow.excessArea": excessAreaOf(overflowArea, forcedAreaMin),
238
285
  "pack.search.widths": traceSum(output, "pack.search.widths"),
239
286
  "pack.search.ms": traceSum(output, "pack.search.ms"),
240
287
  };
241
288
  }
242
289
 
290
+ /** The float noise of `pack.overflow.excessArea`. A difference below 0 by this value or less becomes 0. */
291
+ const AREA_NOISE = 1e-9;
292
+
293
+ /**
294
+ * This function gives the overflow area above its lower bound. A value from `-AREA_NOISE` to 0 becomes 0. A value below
295
+ * `-AREA_NOISE` stays, because it shows a bug of the bound or of the packer.
296
+ */
297
+ function excessAreaOf(overflowArea: number, forcedAreaMin: number): number {
298
+ const excess = overflowArea - forcedAreaMin;
299
+ return excess < 0 && excess >= -AREA_NOISE ? 0 : excess;
300
+ }
301
+
243
302
  /**
244
303
  * This function gives the stability measures of a change pair: the run on the base input and the run on the changed
245
304
  * input. The items match by id. The values are not rounded.
package/src/moves.ts ADDED
@@ -0,0 +1,33 @@
1
+ // The counts of the characteristic move of each strategy (roadmap item k59, `docs/BENCH.md`, "Activation"). The
2
+ // packers count their move here, and the adapters write the count in `trace.measures`. The counts do not change a layout.
3
+ // The module is internal: the package root does not export it.
4
+
5
+ /** The id of the characteristic move of each strategy. The trace of each strategy has its id, also with the count 0. */
6
+ export const MOVES: Readonly<{ rows: "move.gapFill"; shelf: "move.stack"; skyline: "move.lowerWindow"; push: "move.cascade" }> = Object.freeze({
7
+ /** A rectangle that the gap fill of `packRow` places in a gap of a row: under a shorter neighbor, or at the row break. */
8
+ rows: "move.gapFill",
9
+ /** A rectangle that `packShelf` puts on an open shelf with a taller rectangle. The strip above it stays empty. */
10
+ shelf: "move.stack",
11
+ /** A rectangle that the skyline puts in a lower window, not in the leftmost window where it fits. */
12
+ skyline: "move.lowerWindow",
13
+ /** A rectangle that a push cascade moves. */
14
+ push: "move.cascade",
15
+ });
16
+
17
+ const counts = new Map<string, number>();
18
+
19
+ /** This function adds `n` to the count of a move. */
20
+ export function countMove(id: string, n = 1): void {
21
+ if (n > 0) counts.set(id, (counts.get(id) ?? 0) + n);
22
+ }
23
+
24
+ /** This function sets the count of a move to 0. Without an id, it sets each count to 0. */
25
+ export function clearMoves(id?: string): void {
26
+ if (id === undefined) counts.clear();
27
+ else counts.delete(id);
28
+ }
29
+
30
+ /** This function gives the count of a move since the last `clearMoves`. */
31
+ export function moveCount(id: string): number {
32
+ return counts.get(id) ?? 0;
33
+ }
@@ -0,0 +1,332 @@
1
+ // The forced overflow of roadmap item k59 (docs/MEASURES.md, "Overflow"): a lower bound of the count of items that no
2
+ // packer can place in a fixed container. `measure` uses it for `pack.overflow.forced` and `pack.overflow.forcedArea`.
3
+
4
+ import { boundsOf } from "@mark1russell7/layout-contract";
5
+ import { DEFAULT_TOLERANCE, type Item, type PackInput, type Region } from "./contract.ts";
6
+
7
+ /** A rectangle: its top-left corner and its size. */
8
+ interface Box {
9
+ x: number;
10
+ y: number;
11
+ w: number;
12
+ h: number;
13
+ }
14
+
15
+ /** A ring of a polygon. */
16
+ type Ring = readonly (readonly [number, number])[];
17
+
18
+ /** A region that the items cannot use: a fixed obstacle or a locked item. */
19
+ interface Blocked {
20
+ region: Region;
21
+ box: Box;
22
+ }
23
+
24
+ /** The lower bound of the overflow of one input. `overflowBound` gives it. */
25
+ export interface OverflowBound {
26
+ /** The count of items that no packer can place. It is a lower bound. */
27
+ readonly forced: number;
28
+ /** The area that no packer can place. It is a lower bound of the area of the unplaced items. */
29
+ readonly area: number;
30
+ /** The upper bound of the free area of a fixed container, or `Infinity` when a side is free. */
31
+ readonly free: number;
32
+ }
33
+
34
+ /** This function gives the area of a ring by the shoelace formula. The direction of the ring does not count. */
35
+ function ringArea(ring: Ring): number {
36
+ let sum = 0;
37
+ for (let i = 0; i < ring.length; i++) {
38
+ const a = ring[i]!;
39
+ const b = ring[(i + 1) % ring.length]!;
40
+ sum += a[0] * b[1] - b[0] * a[1];
41
+ }
42
+ return Math.abs(sum) / 2;
43
+ }
44
+
45
+ /**
46
+ * This function gives the area of a region that an item cannot use. A polygon loses the area of its holes. Thus a hole
47
+ * that is out of the polygon makes the area too small, and the free area stays an upper bound.
48
+ */
49
+ function blockedArea(region: Region): number {
50
+ switch (region.kind) {
51
+ case "rect":
52
+ return Math.max(0, region.w) * Math.max(0, region.h);
53
+ case "circle":
54
+ return Math.PI * region.r ** 2;
55
+ case "ellipse":
56
+ return Math.PI * region.rx * region.ry;
57
+ case "polygon": {
58
+ let area = ringArea(region.points);
59
+ for (const hole of region.holes ?? []) area -= ringArea(hole);
60
+ return Math.max(0, area);
61
+ }
62
+ }
63
+ }
64
+
65
+ /** This function tells if the insides of two rectangles have a common point. Two rectangles with a common edge do not. */
66
+ function boxesOverlap(a: Box, b: Box): boolean {
67
+ return Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 0 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0;
68
+ }
69
+
70
+ /** This function tells if rectangle `a` is in rectangle `b`. */
71
+ function boxIn(a: Box, b: Box): boolean {
72
+ return a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h;
73
+ }
74
+
75
+ /** This function gives the rectangle made smaller by `d` on each side. */
76
+ function shrink(b: Box, d: number): Box {
77
+ return { x: b.x + d, y: b.y + d, w: Math.max(0, b.w - 2 * d), h: Math.max(0, b.h - 2 * d) };
78
+ }
79
+
80
+ /** This function tells if a point is in a ring, by the even-odd rule. */
81
+ function pointInRing(x: number, y: number, ring: Ring): boolean {
82
+ let inside = false;
83
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
84
+ const [xi, yi] = ring[i]!;
85
+ const [xj, yj] = ring[j]!;
86
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
87
+ }
88
+ return inside;
89
+ }
90
+
91
+ /** This function tells if a segment has a part of a length above 0 in the open rectangle. The Liang-Barsky clip gives it. */
92
+ function segmentHitsBox(ax: number, ay: number, bx: number, by: number, r: Box): boolean {
93
+ let t0 = 0;
94
+ let t1 = 1;
95
+ const dx = bx - ax;
96
+ const dy = by - ay;
97
+ const clip = (p: number, q: number): boolean => {
98
+ if (p === 0) return q > 0;
99
+ const t = q / p;
100
+ if (p < 0) {
101
+ if (t > t1) return false;
102
+ if (t > t0) t0 = t;
103
+ } else {
104
+ if (t < t0) return false;
105
+ if (t < t1) t1 = t;
106
+ }
107
+ return true;
108
+ };
109
+ return clip(-dx, ax - r.x) && clip(dx, r.x + r.w - ax) && clip(-dy, ay - r.y) && clip(dy, r.y + r.h - ay) && t1 > t0;
110
+ }
111
+
112
+ /**
113
+ * This function tells if a rectangle is in a polygon with holes. No edge of a ring crosses the inside of the rectangle.
114
+ * No vertex is in it, and its center is in the polygon by the even-odd rule.
115
+ */
116
+ function boxInPolygon(b: Box, rings: readonly Ring[]): boolean {
117
+ for (const ring of rings) {
118
+ for (let i = 0; i < ring.length; i++) {
119
+ const p = ring[i]!;
120
+ const q = ring[(i + 1) % ring.length]!;
121
+ if (p[0] > b.x && p[0] < b.x + b.w && p[1] > b.y && p[1] < b.y + b.h) return false;
122
+ if (segmentHitsBox(p[0], p[1], q[0], q[1], b)) return false;
123
+ }
124
+ }
125
+ const cx = b.x + b.w / 2;
126
+ const cy = b.y + b.h / 2;
127
+ let inside = false;
128
+ for (const ring of rings) if (pointInRing(cx, cy, ring)) inside = !inside;
129
+ return inside;
130
+ }
131
+
132
+ /** This function tells if a rectangle is in a region of a container. A rectangle with no size is in no region. */
133
+ function boxInShape(b: Box, shape: Region): boolean {
134
+ if (!(b.w > 0 && b.h > 0)) return false;
135
+ // A small shrink keeps a rectangle on the outline in the shape
136
+ const s = shrink(b, 1e-9 * Math.max(1, b.w, b.h));
137
+ switch (shape.kind) {
138
+ case "rect":
139
+ return boxIn(b, { x: shape.x, y: shape.y, w: shape.w, h: shape.h });
140
+ case "circle":
141
+ case "ellipse": {
142
+ const rx = shape.kind === "circle" ? shape.r : shape.rx;
143
+ const ry = shape.kind === "circle" ? shape.r : shape.ry;
144
+ const inside = (x: number, y: number): boolean => ((x - shape.cx) / rx) ** 2 + ((y - shape.cy) / ry) ** 2 <= 1;
145
+ return inside(s.x, s.y) && inside(s.x + s.w, s.y) && inside(s.x, s.y + s.h) && inside(s.x + s.w, s.y + s.h);
146
+ }
147
+ case "polygon":
148
+ return boxInPolygon(s, [shape.points, ...(shape.holes ?? [])]);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * This function gives the area of a container shape. A hole counts only when its bounding box is in the outer ring.
154
+ * Also, that box has no common point with the box of a hole before it. Thus the area is not too small.
155
+ */
156
+ function shapeArea(shape: Region): number {
157
+ if (shape.kind !== "polygon") return blockedArea(shape);
158
+ let area = ringArea(shape.points);
159
+ const kept: Box[] = [];
160
+ for (const hole of shape.holes ?? []) {
161
+ const box = boundsOf({ kind: "polygon", points: hole });
162
+ if (!boxInPolygon(shrink(box, 1e-9 * Math.max(1, box.w, box.h)), [shape.points])) continue;
163
+ if (kept.some((k) => boxesOverlap(k, box))) continue;
164
+ kept.push(box);
165
+ area -= ringArea(hole);
166
+ }
167
+ return Math.max(0, area);
168
+ }
169
+
170
+ /**
171
+ * This function gives the area of the union of rectangles, each cut to the rectangle `clip`. A sweep over the x edges
172
+ * adds the merged y intervals of each slab.
173
+ */
174
+ function unionArea(boxes: readonly Box[], clip: Box): number {
175
+ const cut: Box[] = [];
176
+ for (const b of boxes) {
177
+ const x0 = Math.max(b.x, clip.x);
178
+ const y0 = Math.max(b.y, clip.y);
179
+ const x1 = Math.min(b.x + b.w, clip.x + clip.w);
180
+ const y1 = Math.min(b.y + b.h, clip.y + clip.h);
181
+ if (x1 > x0 && y1 > y0) cut.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
182
+ }
183
+ const xs = [...new Set(cut.flatMap((b) => [b.x, b.x + b.w]))].toSorted((a, b) => a - b);
184
+ let area = 0;
185
+ for (let i = 0; i + 1 < xs.length; i++) {
186
+ const left = xs[i]!;
187
+ const right = xs[i + 1]!;
188
+ const spans = cut
189
+ .filter((b) => b.x <= left && b.x + b.w >= right)
190
+ .map((b) => [b.y, b.y + b.h] as const)
191
+ .toSorted((p, q) => p[0] - q[0]);
192
+ let covered = 0;
193
+ let top = -Infinity;
194
+ let bottom = -Infinity;
195
+ for (const [s, e] of spans) {
196
+ if (s > bottom) {
197
+ covered += bottom - top > 0 ? bottom - top : 0;
198
+ top = s;
199
+ bottom = e;
200
+ } else bottom = Math.max(bottom, e);
201
+ }
202
+ covered += bottom - top > 0 ? bottom - top : 0;
203
+ area += covered * (right - left);
204
+ }
205
+ return area;
206
+ }
207
+
208
+ /** This function gives the rectangle of the container: the bounding box of its shape, or 0, 0, `w`, `h`. */
209
+ function containerBox(input: PackInput): Box {
210
+ const { container } = input;
211
+ if (container.shape !== undefined) return boundsOf(container.shape);
212
+ return { x: 0, y: 0, w: container.w ?? Infinity, h: container.h ?? Infinity };
213
+ }
214
+
215
+ /** This function tells if an item is locked at a position. Such an item is not a candidate of the overflow. */
216
+ const lockedAt = (item: Item): boolean => item.locked === true && item.x !== undefined && item.y !== undefined;
217
+
218
+ /** This function gives the rectangle of an item at its position: the bounding box of its shape, or its size. */
219
+ function itemBox(item: Item): Box {
220
+ const s = item.shape === undefined ? { x: 0, y: 0, w: item.w, h: item.h } : boundsOf(item.shape);
221
+ return { x: (item.x ?? 0) + s.x, y: (item.y ?? 0) + s.y, w: s.w, h: s.h };
222
+ }
223
+
224
+ /** This function gives the smallest area of an item: its rectangle, or the area of its shape when that is smaller. */
225
+ function itemArea(item: Item): number {
226
+ const rect = Math.max(0, item.w) * Math.max(0, item.h);
227
+ return item.shape === undefined ? rect : Math.min(rect, blockedArea(item.shape));
228
+ }
229
+
230
+ /**
231
+ * This function gives an upper bound of the free area of a container with a fixed size. The bound ignores the gap and
232
+ * the margins, thus it is not too small.
233
+ *
234
+ * - The container area is the inner rectangle (the container less the inset). A shape gives the smaller of that and its exact area.
235
+ * - A rectangle container loses the exact union of the fixed rectangle obstacles and the locked items in the inner rectangle.
236
+ * - A shape loses only the regions that are fully in it and in the inner rectangle, with no common point between their boxes.
237
+ * - An obstacle with `move` can go out of the way. Thus the bound does not subtract it.
238
+ */
239
+ export function freeAreaBound(input: PackInput): number {
240
+ const bounds = containerBox(input);
241
+ const inset = input.container.inset ?? 0;
242
+ const inner = shrink(bounds, inset);
243
+ if (!(inner.w > 0 && inner.h > 0)) return 0;
244
+ const blocked: Blocked[] = [];
245
+ for (const o of input.obstacles ?? []) if (o.move === undefined) blocked.push({ region: o.region, box: boundsOf(o.region) });
246
+ for (const item of input.items) {
247
+ if (!lockedAt(item)) continue;
248
+ const box = itemBox(item);
249
+ blocked.push({ region: { kind: "rect", ...box }, box });
250
+ }
251
+
252
+ const shape = input.container.shape;
253
+ const innerArea = inner.w * inner.h;
254
+ const inInner = (b: Box): boolean => b.w > 0 && b.h > 0 && boxIn(b, inner);
255
+ if (shape === undefined || shape.kind === "rect") {
256
+ const rects = blocked.filter((b) => b.region.kind === "rect");
257
+ let free = innerArea - unionArea(rects.map((b) => b.box), inner);
258
+ const kept: Box[] = [];
259
+ for (const b of blocked) {
260
+ if (b.region.kind === "rect" || !inInner(b.box)) continue;
261
+ if (rects.some((r) => boxesOverlap(r.box, b.box)) || kept.some((k) => boxesOverlap(k, b.box))) continue;
262
+ kept.push(b.box);
263
+ free -= blockedArea(b.region);
264
+ }
265
+ return Math.max(0, free);
266
+ }
267
+
268
+ let free = Math.min(innerArea, shapeArea(shape));
269
+ const kept: Box[] = [];
270
+ for (const b of blocked) {
271
+ if (!inInner(b.box) || !boxInShape(b.box, shape) || kept.some((k) => boxesOverlap(k, b.box))) continue;
272
+ kept.push(b.box);
273
+ free -= blockedArea(b.region);
274
+ }
275
+ return Math.max(0, free);
276
+ }
277
+
278
+ /**
279
+ * This function gives a lower bound of the overflow of an input: the items that no packer can place, and their area.
280
+ *
281
+ * - The candidates are the items that are not locked at a position.
282
+ * - A fixed side forces each candidate that is longer than the side less the inset, in each turn that the item permits.
283
+ * - A fixed size also forces the fewest other candidates that make the rest fit the free area of `freeAreaBound`. These are the largest ones.
284
+ * - The area of the bound is the area of the long items, plus the part of the area of the rest above the free area.
285
+ * - The margins and the gap do not count. A slack of the overlap tolerance along the edge of each candidate keeps the bound valid.
286
+ * - A container with no fixed side gives 0.
287
+ */
288
+ export function overflowBound(input: PackInput): OverflowBound {
289
+ const { container } = input;
290
+ const fixedW = container.fixed === "size" || container.fixed === "width";
291
+ const fixedH = container.fixed === "size" || container.fixed === "height";
292
+ if (!fixedW && !fixedH) return { forced: 0, area: 0, free: Infinity };
293
+ const tol = DEFAULT_TOLERANCE.overlap;
294
+ const bounds = containerBox(input);
295
+ const inset = container.inset ?? 0;
296
+ const maxW = fixedW ? bounds.w - 2 * inset + 2 * tol : Infinity;
297
+ const maxH = fixedH ? bounds.h - 2 * inset + 2 * tol : Infinity;
298
+
299
+ let forced = 0;
300
+ let area = 0;
301
+ let slack = 0;
302
+ const rest: number[] = [];
303
+ for (const item of input.items) {
304
+ if (lockedAt(item)) continue;
305
+ const box = itemBox(item);
306
+ const w = Math.min(item.w, box.w);
307
+ const h = Math.min(item.h, box.h);
308
+ const fits = (w <= maxW && h <= maxH) || (item.turnable === true && h <= maxW && w <= maxH);
309
+ const a = itemArea(item);
310
+ if (!fits) {
311
+ forced += 1;
312
+ area += a;
313
+ continue;
314
+ }
315
+ rest.push(a);
316
+ slack += 2 * tol * (Math.max(0, w) + Math.max(0, h));
317
+ }
318
+ if (!(fixedW && fixedH)) return { forced, area, free: Infinity };
319
+
320
+ const free = freeAreaBound(input);
321
+ // An item can go out of the container by the tolerance, thus the edge of the container adds a slack too
322
+ const room = free + slack + 2 * tol * (Math.max(0, bounds.w) + Math.max(0, bounds.h));
323
+ let sum = rest.reduce((s, a) => s + a, 0);
324
+ area += Math.max(0, sum - room);
325
+ // The largest items go first, so the count of the dropped items is the smallest count that fits the area
326
+ for (const a of rest.toSorted((p, q) => q - p)) {
327
+ if (sum <= room || !(a > 0)) break;
328
+ sum -= a;
329
+ forced += 1;
330
+ }
331
+ return { forced, area, free };
332
+ }