@mark1russell7/packing 0.2.1 → 0.2.2

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.2",
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/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/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.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,11 @@ 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;
50
56
  "pack.search.widths": number;
51
57
  "pack.search.ms": number;
52
58
  }
@@ -74,29 +80,43 @@ const spec = (
74
80
  direction: Direction,
75
81
  min: number,
76
82
  max: number,
83
+ minEffect: MeasureSpec["minEffect"],
77
84
  guard?: { floor: number; tol: number },
78
- ): MeasureSpec => ({ id, family, unit, direction, min, max, ...guard });
85
+ ): MeasureSpec => ({ id, family, unit, direction, min, max, ...(minEffect === undefined ? {} : { minEffect }), ...guard });
86
+
87
+ /** The minimum effect of an area or a ratio of lengths: 1% of the larger value. */
88
+ const REL = Object.freeze({ rel: 0.01 });
79
89
 
80
90
  /**
81
91
  * 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.
92
+ * and the tolerance of each guard of the packer, and of the minimum effect of each measure.
93
+ *
94
+ * - A count and a share of counts have the minimum effect 0. Thus each change of a count is an effect.
95
+ * - A share of an area from 0 to 1 has 0.005. A guard has its tolerance.
96
+ * - An area and a ratio of lengths have `{ rel: 0.01 }`: 1% of the larger value.
97
+ * - The time has no minimum effect, because the times of CI change each time.
83
98
  */
84
99
  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),
100
+ "pack.area": spec("pack.area", "density", "square units", "lower", 0, Infinity, REL),
101
+ "pack.fill": spec("pack.fill", "density", "share", "higher", 0, 1, 0.005),
102
+ "pack.whitespace": spec("pack.whitespace", "density", "share", "lower", 0, 1, 0.005),
103
+ "pack.aspect": spec("pack.aspect", "aspect", "ratio", "lower", 1, Infinity, REL),
104
+ "pack.aspectError": spec("pack.aspectError", "aspect", "natural log", "lower", 0, Infinity, 0.01, { floor: 0, tol: 0.01 }),
105
+ "pack.order.inversions": spec("pack.order.inversions", "order", "share", "lower", 0, 1, 0, { floor: 0, tol: 0 }),
106
+ "pack.order.breaks": spec("pack.order.breaks", "order", "count", "lower", 0, Infinity, 0),
107
+ "pack.raggedness": spec("pack.raggedness", "raggedness", "share", "lower", 0, 1, 0.005),
108
+ "pack.overflow": spec("pack.overflow", "overflow", "count", "lower", 0, Infinity, 0, { floor: 0, tol: 0 }),
109
+ "pack.overflow.rate": spec("pack.overflow.rate", "overflow", "share", "lower", 0, 1, 0),
110
+ "pack.overflow.area": spec("pack.overflow.area", "overflow", "share", "lower", 0, 1, 0.005),
111
+ "pack.overflow.forced": spec("pack.overflow.forced", "overflow", "count", "lower", 0, Infinity, 0),
112
+ "pack.overflow.forcedArea": spec("pack.overflow.forcedArea", "overflow", "share", "lower", 0, 1, 0.005),
113
+ "pack.overflow.excess": spec("pack.overflow.excess", "overflow", "count", "lower", 0, Infinity, 0),
114
+ "pack.stability.moved": spec("pack.stability.moved", "stability", "share", "lower", 0, 1, 0.02, { floor: 0, tol: 0.02 }),
115
+ "pack.stability.mean": spec("pack.stability.mean", "stability", "container widths", "lower", 0, Infinity, 0.005, { floor: 0, tol: 0.005 }),
116
+ "pack.stability.q95": spec("pack.stability.q95", "stability", "container widths", "lower", 0, Infinity, 0.01, { floor: 0, tol: 0.01 }),
117
+ "pack.deterministic": spec("pack.deterministic", "determinism", "boolean", "higher", 0, 1, 0),
118
+ "pack.search.widths": spec("pack.search.widths", "speed", "count", "lower", 0, Infinity, 0),
119
+ "pack.search.ms": spec("pack.search.ms", "speed", "milliseconds", "lower", 0, Infinity, undefined),
100
120
  });
101
121
 
102
122
  /** A placed box: the rectangle of an item at its position, with its rank. */
@@ -183,6 +203,8 @@ function traceSum(output: PackOutput, id: string): number {
183
203
  * - The boxes are the placed items with a width and a height above 0. The hull is the smallest rectangle around them.
184
204
  * - 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
205
  * - The time `pack.search.ms` comes from the trace. It is not part of the determinism rule.
206
+ * - The overflow measures count each id once. `pack.overflow.forced` is the lower bound of `overflowBound`.
207
+ * - `pack.overflow.excess` is the overflow less the forced overflow. A container with no fixed side gives 0 for both.
186
208
  *
187
209
  * @example
188
210
  * ```ts
@@ -225,6 +247,17 @@ export function measure(input: PackInput, output: PackOutput, options: MeasureOp
225
247
  const rights = rowsOf(boxes, tolerance).map((r) => r.right);
226
248
  const spread = rights.length > 0 ? Math.max(...rights) - Math.min(...rights) : 0;
227
249
 
250
+ // The overflow: the unplaced items, their area, and the part that no packer can place
251
+ const unplaced = new Set(output.overflow);
252
+ let itemArea = 0;
253
+ let unplacedArea = 0;
254
+ for (const item of input.items) {
255
+ const a = Math.max(0, item.w) * Math.max(0, item.h);
256
+ itemArea += a;
257
+ if (unplaced.has(item.id)) unplacedArea += a;
258
+ }
259
+ const bound = overflowBound(input);
260
+
228
261
  return {
229
262
  "pack.area": hullArea,
230
263
  "pack.fill": fill,
@@ -234,7 +267,12 @@ export function measure(input: PackInput, output: PackOutput, options: MeasureOp
234
267
  "pack.order.inversions": pairs > 0 ? inversions / pairs : 0,
235
268
  "pack.order.breaks": breaks,
236
269
  "pack.raggedness": hull.w > 0 ? spread / hull.w : 0,
237
- "pack.overflow": new Set(output.overflow).size,
270
+ "pack.overflow": unplaced.size,
271
+ "pack.overflow.rate": input.items.length > 0 ? unplaced.size / input.items.length : 0,
272
+ "pack.overflow.area": itemArea > 0 ? unplacedArea / itemArea : 0,
273
+ "pack.overflow.forced": bound.forced,
274
+ "pack.overflow.forcedArea": itemArea > 0 ? Math.min(1, bound.area / itemArea) : 0,
275
+ "pack.overflow.excess": Math.max(0, unplaced.size - bound.forced),
238
276
  "pack.search.widths": traceSum(output, "pack.search.widths"),
239
277
  "pack.search.ms": traceSum(output, "pack.search.ms"),
240
278
  };
@@ -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
+ }