@mark1russell7/packing 0.2.0 → 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.0",
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/adapters.ts CHANGED
@@ -8,6 +8,7 @@ import { pack } from "./pack.ts";
8
8
  import { pushCascade } from "./push.ts";
9
9
  import { Rect } from "./rect.ts";
10
10
  import { packSkyline } from "./skyline.ts";
11
+ import { spaceOf } from "./space.ts";
11
12
  import { assertSupported, cellOf, UnsupportedCell, type PackContext, type SizeSearch, type Strategy, type Supports } from "./strategy.ts";
12
13
 
13
14
  /** The options of the strategies `rows`, `skyline` and `shelf`. */
@@ -26,10 +27,13 @@ export interface PushStrategyOptions {
26
27
  maxShovesPerItem: number;
27
28
  }
28
29
 
29
- /** The cells of `rows`, `skyline` and `shelf`, without the movement of the obstacles. */
30
+ /**
31
+ * The cells of `rows`, `skyline` and `shelf`, without the movement of the obstacles. The container layer of `src/space.ts`
32
+ * gives them each container shape.
33
+ */
30
34
  const LAYOUT_CELLS = {
31
35
  containerSize: ["fixed", "freeHeight", "freeWidth", "freeAspect", "freeArea"],
32
- containerShape: ["rect"],
36
+ containerShape: ["rect", "convex", "polygon", "circle", "ellipse", "holes"],
33
37
  itemShape: ["rect"],
34
38
  rotation: ["none"],
35
39
  groups: ["none"],
@@ -55,7 +59,7 @@ function packFixedWidth(id: "rows" | "skyline" | "shelf", input: PackInput, opti
55
59
  const start = now();
56
60
  const frame = buildFrame(input);
57
61
  const rects = rectsOf(frame);
58
- if (frame.w > 0) pack(rects, outerOf(frame), { strategy: id, sortOrder: options.sortOrder });
62
+ if (frame.w > 0) pack(rects, outerOf(frame), { strategy: id, sortOrder: options.sortOrder, space: spaceOf(input, frame) });
59
63
  const output = readFrame(input, frame, []);
60
64
  return { ...output, trace: [{ id, ms: now() - start, measures: { "pack.overflow": output.overflow.length } }] };
61
65
  }
@@ -91,17 +95,18 @@ function layoutStrategy(
91
95
  }
92
96
 
93
97
  /**
94
- * The strategy `rows` uses `packRows`: a guillotine row packing with a recursive gap fill. It supports no obstacle and
95
- * no locked item, because `packRows` does not go around them. Turnable items, groups and shapes throw `UnsupportedCell`.
98
+ * The strategy `rows` uses `packRows`: a guillotine row packing with a recursive gap fill. With obstacles, locked items or a
99
+ * container shape, the rows go around the holes of the free space and lose the gap fill. Turnable items, groups and item
100
+ * shapes throw `UnsupportedCell`.
96
101
  */
97
102
  export const rows: Strategy<LayoutStrategyOptions> = layoutStrategy("rows", "shelf", ["rows-gap-fill", "rows-input"], {
98
- obstacleMove: [],
103
+ obstacleMove: ["fixed"],
99
104
  order: ["free", "soft", "hard"],
100
105
  });
101
106
 
102
107
  /**
103
108
  * The strategy `skyline` uses `packSkyline`: each item goes to the lowest place where it fits. It packs around the
104
- * locked items and the fixed obstacles. Turnable items, groups and shapes throw `UnsupportedCell`.
109
+ * locked items and the fixed obstacles, in each container shape. Turnable items, groups and item shapes throw `UnsupportedCell`.
105
110
  */
106
111
  export const skyline: Strategy<LayoutStrategyOptions> = layoutStrategy("skyline", "skyline", ["skyline-bl"], {
107
112
  obstacleMove: ["fixed"],
@@ -110,7 +115,7 @@ export const skyline: Strategy<LayoutStrategyOptions> = layoutStrategy("skyline"
110
115
 
111
116
  /**
112
117
  * The strategy `shelf` uses `packShelf`: rows filled left to right, around the locked items and the fixed obstacles.
113
- * With the sort order `input`, the rows keep the rank order. Turnable items, groups and shapes throw `UnsupportedCell`.
118
+ * With the sort order `input`, the rows keep the rank order. Turnable items, groups and item shapes throw `UnsupportedCell`.
114
119
  */
115
120
  export const shelf: Strategy<LayoutStrategyOptions> = layoutStrategy("shelf", "shelf", ["shelf-nf", "shelf-nfdh"], {
116
121
  obstacleMove: ["fixed"],
@@ -133,6 +138,7 @@ export const logSweep: SizeSearch = {
133
138
  const result = packFree(rectsOf(frame), new Rect<number>(), {
134
139
  strategy: id,
135
140
  sortOrder: o.sortOrder,
141
+ space: spaceOf(input, frame),
136
142
  samples: o.samples,
137
143
  aspect: input.container.aspect ?? 1,
138
144
  aspectWeight: input.container.aspectWeight ?? 1,
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
@@ -1,7 +1,8 @@
1
1
  export { Vector } from "./vector.ts";
2
2
  export { Rect } from "./rect.ts";
3
3
  export { pack, packRows, type PackOptions, type PackLayoutOptions } from "./pack.ts";
4
- export { packSkyline, lowestWindow } from "./skyline.ts";
4
+ export { packSkyline, lowestWindow, type SkylineOptions } from "./skyline.ts";
5
+ export { freeSpace, type FreeSpace, type FreeSpan, type SpaceRect } from "./space.ts";
5
6
  export { packShelf, type ShelfOptions } from "./shelf.ts";
6
7
  export { packFree, aspectError, type FreeWidthOptions, type FreeWidthResult, type FreeWidthTry } from "./free.ts";
7
8
  export { compareRects, SORT_ORDERS, type SortOrder } from "./order.ts";
@@ -68,7 +69,7 @@ export {
68
69
  export { logSweep, push, rows, shelf, skyline, type LayoutStrategyOptions, type PushStrategyOptions } from "./adapters.ts";
69
70
  export { fromRects, toRects } from "./convert.ts";
70
71
  export { packWith, strategies, strategy } from "./registry.ts";
71
- export { checkHard, type PackHard } from "./check.ts";
72
+ export { checkHard, type ContainerTest, type PackHard } from "./check.ts";
72
73
  export {
73
74
  canonicalJson,
74
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
+ }
package/src/pack.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  import { compareRects, type SortOrder } from "./order.ts";
2
2
  import { Rect } from "./rect.ts";
3
- import { packShelf } from "./shelf.ts";
3
+ import { packRowsInSpace, packShelf } from "./shelf.ts";
4
4
  import { packSkyline } from "./skyline.ts";
5
+ import type { FreeSpace } from "./space.ts";
5
6
  import { Vector } from "./vector.ts";
6
7
 
7
8
  /** The options of `pack` and `packRows` */
8
9
  export interface PackOptions {
9
10
  /** The order of the rectangles before the packing. The default is `height-desc`. */
10
11
  sortOrder?: SortOrder;
12
+ /**
13
+ * The free space of the container: `freeSpace` of the input. With a space that is not plain, each strategy packs into
14
+ * the outline of the container and around its holes. The rows of `packRows` also go around the locked rectangles.
15
+ */
16
+ space?: FreeSpace;
11
17
  }
12
18
 
13
19
  /** The options of `pack` */
@@ -85,9 +91,14 @@ function packRow<T>(rects: Rect<T>[], outer: Rect<T>, order: SortOrder): boolean
85
91
  * This function packs rectangles into an outer rectangle of a given width with a guillotine row packing. It does
86
92
  * not change the width. Without `fixedSize`, the height grows until all rectangles fit. A `locked` rectangle keeps
87
93
  * its position, but the rows do not go around it. `pack` sets the width first and then uses this function.
94
+ *
95
+ * With `options.space`, the rows go around the locked rectangles and the holes of the space, inside its outline. A space
96
+ * that is plain, with no locked rectangle, gives the rows of 0.1.
88
97
  */
89
98
  export function packRows<T>(rects: Rect<T>[], outer: Rect<T>, options: PackOptions = {}): Rect<T> {
90
99
  const order = options.sortOrder ?? "height-desc";
100
+ const space = options.space;
101
+ if (space !== undefined && (!space.plain || rects.some((r) => r.locked))) return packRowsInSpace(rects, outer, order, space);
91
102
  for (const r of rects) if (r.locked) r.wasPacked = true;
92
103
  while (!packRow(rects, outer, order));
93
104
  return outer;