@mark1russell7/layout-contract 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +46 -2
- package/build/index.d.ts +421 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +584 -0
- package/build/index.js.map +1 -0
- package/package.json +57 -3
- package/src/geometry.ts +109 -0
- package/src/index.ts +9 -0
- package/src/input.ts +144 -0
- package/src/measures.ts +53 -0
- package/src/output.ts +58 -0
- package/src/record.ts +83 -0
- package/src/rules.ts +241 -0
- package/src/strategy.ts +20 -0
- package/src/tolerance.ts +25 -0
- package/src/turn.ts +67 -0
package/src/rules.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { boundsOf, grow, overlapOf, type Rect, type Region } from "./geometry.ts";
|
|
2
|
+
import type { LayoutBox, LayoutInput, Obstacle } from "./input.ts";
|
|
3
|
+
import { applyPack, type PackOutput } from "./output.ts";
|
|
4
|
+
import { DEFAULT_TOLERANCE, RECORD_EPS } from "./tolerance.ts";
|
|
5
|
+
|
|
6
|
+
/** This function gives the area of a placed box: its shape or its rectangle, at its position. A box without a position gives `undefined`. */
|
|
7
|
+
export function placedRect(box: LayoutBox): Rect | undefined {
|
|
8
|
+
if (box.x === undefined || box.y === undefined) return undefined;
|
|
9
|
+
if (box.shape === undefined) return { x: box.x, y: box.y, w: box.w, h: box.h };
|
|
10
|
+
const b = boundsOf(box.shape);
|
|
11
|
+
return { x: box.x + b.x, y: box.y + b.y, w: b.w, h: b.h };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* This function counts the pairs of placed boxes whose insides, with their margins, overlap by more than `tolerance` on each axis.
|
|
16
|
+
* A box with a shape counts as its bounding box. That area is larger, thus the count is safe.
|
|
17
|
+
*/
|
|
18
|
+
export function countOverlaps(boxes: readonly LayoutBox[], tolerance: number = DEFAULT_TOLERANCE.overlap): number {
|
|
19
|
+
const rects: Rect[] = [];
|
|
20
|
+
for (const box of boxes) {
|
|
21
|
+
const r = placedRect(box);
|
|
22
|
+
if (r !== undefined) rects.push(grow(r, box.margin));
|
|
23
|
+
}
|
|
24
|
+
rects.sort((a, b) => a.x - b.x);
|
|
25
|
+
let count = 0;
|
|
26
|
+
for (let i = 0; i < rects.length; i++) {
|
|
27
|
+
const a = rects[i]!;
|
|
28
|
+
for (let j = i + 1; j < rects.length; j++) {
|
|
29
|
+
const b = rects[j]!;
|
|
30
|
+
if (b.x >= a.x + a.w - tolerance) break;
|
|
31
|
+
const o = overlapOf(a, b);
|
|
32
|
+
if (o.x > tolerance && o.y > tolerance) count++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return count;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* This function counts the placed boxes that go out of the container.
|
|
40
|
+
* Each box with its margin must stay inside the container made smaller by `inset`. Only the fixed sides of the size count. A shaped container counts as its bounding box.
|
|
41
|
+
* An input without a container gives 0.
|
|
42
|
+
*/
|
|
43
|
+
export function countOutside(input: LayoutInput, tolerance: number = DEFAULT_TOLERANCE.overlap): number {
|
|
44
|
+
const c = input.container;
|
|
45
|
+
if (c === undefined) return 0;
|
|
46
|
+
const area = c.shape === undefined ? { x: 0, y: 0, w: c.w ?? Infinity, h: c.h ?? Infinity } : boundsOf(c.shape);
|
|
47
|
+
const inset = c.inset ?? 0;
|
|
48
|
+
const fixW = c.fixed === "size" || c.fixed === "width";
|
|
49
|
+
const fixH = c.fixed === "size" || c.fixed === "height";
|
|
50
|
+
let count = 0;
|
|
51
|
+
for (const box of input.boxes) {
|
|
52
|
+
const r0 = placedRect(box);
|
|
53
|
+
if (r0 === undefined) continue;
|
|
54
|
+
const r = grow(r0, box.margin);
|
|
55
|
+
const outX = r.x < area.x + inset - tolerance || r.x + r.w > area.x + area.w - inset + tolerance;
|
|
56
|
+
const outY = r.y < area.y + inset - tolerance || r.y + r.h > area.y + area.h - inset + tolerance;
|
|
57
|
+
if ((fixW && outX) || (fixH && outY)) count++;
|
|
58
|
+
}
|
|
59
|
+
return count;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** This function counts the fixed ports that are not on the border of their box, with the epsilon of a written file. A port without a side does not count. */
|
|
63
|
+
export function countPortsOffBorder(boxes: readonly LayoutBox[], eps: number = RECORD_EPS): number {
|
|
64
|
+
let count = 0;
|
|
65
|
+
for (const box of boxes) {
|
|
66
|
+
for (const p of box.ports ?? []) {
|
|
67
|
+
if (p.x === undefined || p.y === undefined || p.side === undefined) continue;
|
|
68
|
+
const along = p.side === "top" || p.side === "bottom" ? p.x : p.y;
|
|
69
|
+
const length = p.side === "top" || p.side === "bottom" ? box.w : box.h;
|
|
70
|
+
const across = { top: p.y, bottom: p.y - box.h, left: p.x, right: p.x - box.w }[p.side];
|
|
71
|
+
if (Math.abs(across) > eps || along < -eps || along > length + eps) count++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return count;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The count of the breaks of each hard rule of a pack. */
|
|
78
|
+
export interface PackHard {
|
|
79
|
+
readonly "no-overlap": number;
|
|
80
|
+
readonly "in-container": number;
|
|
81
|
+
readonly "locked-kept": number;
|
|
82
|
+
readonly complete: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** This function tells if the inside of `region` and the inside of `rect` have a common point. */
|
|
86
|
+
export function regionHitsRect(region: Region, rect: Rect): boolean {
|
|
87
|
+
if (rect.w <= 0 || rect.h <= 0) return false;
|
|
88
|
+
switch (region.kind) {
|
|
89
|
+
case "rect": {
|
|
90
|
+
const o = overlapOf(region, rect);
|
|
91
|
+
return o.x > 0 && o.y > 0;
|
|
92
|
+
}
|
|
93
|
+
case "circle":
|
|
94
|
+
return circleHitsRect(region.cx, region.cy, region.r, region.r, rect);
|
|
95
|
+
case "ellipse":
|
|
96
|
+
return circleHitsRect(region.cx, region.cy, region.rx, region.ry, rect);
|
|
97
|
+
case "polygon": {
|
|
98
|
+
const rings = [region.points, ...(region.holes ?? [])];
|
|
99
|
+
const inside = (x: number, y: number): boolean => x > rect.x && x < rect.x + rect.w && y > rect.y && y < rect.y + rect.h;
|
|
100
|
+
if (region.points.some(([x, y]) => inside(x, y))) return true;
|
|
101
|
+
for (const ring of rings) {
|
|
102
|
+
for (let i = 0; i < ring.length; i++) {
|
|
103
|
+
const a = ring[i]!;
|
|
104
|
+
const b = ring[(i + 1) % ring.length]!;
|
|
105
|
+
const m = clipMid(a[0], a[1], b[0], b[1], rect);
|
|
106
|
+
if (m !== undefined && inside(m[0], m[1])) return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return pointInRings(rect.x + rect.w / 2, rect.y + rect.h / 2, rings);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** An ellipse with the radii `rx` and `ry` scales to a unit circle. The rectangle scales with it. */
|
|
115
|
+
function circleHitsRect(cx: number, cy: number, rx: number, ry: number, rect: Rect): boolean {
|
|
116
|
+
if (rx <= 0 || ry <= 0) return false;
|
|
117
|
+
const x0 = (rect.x - cx) / rx;
|
|
118
|
+
const x1 = (rect.x + rect.w - cx) / rx;
|
|
119
|
+
const y0 = (rect.y - cy) / ry;
|
|
120
|
+
const y1 = (rect.y + rect.h - cy) / ry;
|
|
121
|
+
const dx = Math.max(x0, 0, -x1);
|
|
122
|
+
const dy = Math.max(y0, 0, -y1);
|
|
123
|
+
return dx * dx + dy * dy < 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** This function gives the middle of the part of a segment inside a rectangle, or `undefined` when that part is a point or empty. */
|
|
127
|
+
function clipMid(ax: number, ay: number, bx: number, by: number, r: Rect): readonly [number, number] | undefined {
|
|
128
|
+
let t0 = 0;
|
|
129
|
+
let t1 = 1;
|
|
130
|
+
const dx = bx - ax;
|
|
131
|
+
const dy = by - ay;
|
|
132
|
+
const edges: readonly (readonly [number, number])[] = [
|
|
133
|
+
[-dx, ax - r.x],
|
|
134
|
+
[dx, r.x + r.w - ax],
|
|
135
|
+
[-dy, ay - r.y],
|
|
136
|
+
[dy, r.y + r.h - ay],
|
|
137
|
+
];
|
|
138
|
+
for (const [p, q] of edges) {
|
|
139
|
+
if (p === 0) {
|
|
140
|
+
if (q < 0) return undefined;
|
|
141
|
+
} else if (p < 0) {
|
|
142
|
+
t0 = Math.max(t0, q / p);
|
|
143
|
+
} else {
|
|
144
|
+
t1 = Math.min(t1, q / p);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (t1 <= t0) return undefined;
|
|
148
|
+
const t = (t0 + t1) / 2;
|
|
149
|
+
return [ax + t * dx, ay + t * dy];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The even-odd rule over the outer ring and the holes. */
|
|
153
|
+
function pointInRings(x: number, y: number, rings: readonly (readonly (readonly [number, number])[])[]): boolean {
|
|
154
|
+
let inside = false;
|
|
155
|
+
for (const ring of rings) {
|
|
156
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
157
|
+
const [xi, yi] = ring[i]!;
|
|
158
|
+
const [xj, yj] = ring[j]!;
|
|
159
|
+
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return inside;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* This function counts the pairs of a placed box and an obstacle that overlap by more than `tolerance`.
|
|
167
|
+
* The box counts with its margin. A box with a shape counts as its bounding box.
|
|
168
|
+
*/
|
|
169
|
+
export function countObstacleHits(
|
|
170
|
+
boxes: readonly LayoutBox[],
|
|
171
|
+
obstacles: readonly Obstacle[] | undefined,
|
|
172
|
+
tolerance: number = DEFAULT_TOLERANCE.overlap,
|
|
173
|
+
): number {
|
|
174
|
+
if (obstacles === undefined || obstacles.length === 0) return 0;
|
|
175
|
+
let count = 0;
|
|
176
|
+
for (const box of boxes) {
|
|
177
|
+
const r0 = placedRect(box);
|
|
178
|
+
if (r0 === undefined) continue;
|
|
179
|
+
const r = grow(r0, box.margin);
|
|
180
|
+
const small = { x: r.x + tolerance, y: r.y + tolerance, w: r.w - 2 * tolerance, h: r.h - 2 * tolerance };
|
|
181
|
+
for (const o of obstacles) if (regionHitsRect(o.region, small)) count++;
|
|
182
|
+
}
|
|
183
|
+
return count;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* This function gives the count of each hard rule of the output of a packer.
|
|
188
|
+
* It applies the output with `applyPack`. Only the placed boxes and the locked boxes count for the overlaps and the container.
|
|
189
|
+
* `no-overlap` counts the pairs of boxes and the pairs of a box and an obstacle.
|
|
190
|
+
* A locked box without a placement, or with a move of more than `RECORD_EPS`, breaks `locked-kept`.
|
|
191
|
+
* A locked box without a position in the input is a bad input, not a break. `checkInput` finds it.
|
|
192
|
+
* Another box without a placement that is not in `overflow` breaks `complete`.
|
|
193
|
+
*/
|
|
194
|
+
export function packHard(input: LayoutInput, output: PackOutput, tolerance: number = DEFAULT_TOLERANCE.overlap): PackHard {
|
|
195
|
+
const after = applyPack(input, output);
|
|
196
|
+
const placed = new Set(output.placements.map((p) => p.id));
|
|
197
|
+
const out = new Set(output.overflow);
|
|
198
|
+
const was = new Map(input.boxes.map((b) => [b.id, b]));
|
|
199
|
+
let lockedMoved = 0;
|
|
200
|
+
let incomplete = 0;
|
|
201
|
+
const kept: LayoutBox[] = [];
|
|
202
|
+
for (const box of after.boxes) {
|
|
203
|
+
const old = was.get(box.id);
|
|
204
|
+
if (old?.locked === true) {
|
|
205
|
+
if (old.x === undefined || old.y === undefined) continue;
|
|
206
|
+
const moved = Math.abs(old.x - (box.x ?? old.x)) > RECORD_EPS || Math.abs(old.y - (box.y ?? old.y)) > RECORD_EPS;
|
|
207
|
+
if (!placed.has(box.id) || moved) lockedMoved++;
|
|
208
|
+
kept.push(box);
|
|
209
|
+
} else if (placed.has(box.id)) {
|
|
210
|
+
kept.push(box);
|
|
211
|
+
} else if (!out.has(box.id)) {
|
|
212
|
+
incomplete++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
"no-overlap": countOverlaps(kept, tolerance) + countObstacleHits(kept, after.obstacles, tolerance),
|
|
217
|
+
"in-container": countOutside({ ...after, boxes: kept }, tolerance),
|
|
218
|
+
"locked-kept": lockedMoved,
|
|
219
|
+
complete: incomplete,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* This function gives the problems of an input file. An empty list tells that the file is good.
|
|
225
|
+
* A problem is a bad input, not a break of a hard rule. Thus an engine refuses the input.
|
|
226
|
+
*/
|
|
227
|
+
export function checkInput(input: LayoutInput): readonly string[] {
|
|
228
|
+
const problems: string[] = [];
|
|
229
|
+
const ids = new Set<string>();
|
|
230
|
+
for (const box of input.boxes) {
|
|
231
|
+
if (ids.has(box.id)) problems.push(`box ${box.id}: the id is not unique`);
|
|
232
|
+
ids.add(box.id);
|
|
233
|
+
if (!(box.w > 0 && box.h > 0 && Number.isFinite(box.w) && Number.isFinite(box.h))) problems.push(`box ${box.id}: the size is not positive and finite`);
|
|
234
|
+
if (box.locked === true && (box.x === undefined || box.y === undefined)) problems.push(`box ${box.id}: a locked box has no position`);
|
|
235
|
+
}
|
|
236
|
+
if (countPortsOffBorder(input.boxes) > 0) problems.push("a fixed port is not on the border of its box");
|
|
237
|
+
for (const edge of input.edges ?? []) {
|
|
238
|
+
if (!ids.has(edge.source) || !ids.has(edge.target)) problems.push(`edge ${edge.id}: a box of the edge is not in the input`);
|
|
239
|
+
}
|
|
240
|
+
return problems;
|
|
241
|
+
}
|
package/src/strategy.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Supports } from "./record.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The common fields of a strategy of an engine. Each engine adds its own function, for example `pack` or `route`.
|
|
5
|
+
* `O` is the type of the options, and `S` is the type of `supports`.
|
|
6
|
+
*/
|
|
7
|
+
export interface StrategyInfo<O = unknown, S extends Supports = Supports> {
|
|
8
|
+
/** A stable name, for example `shelf`. The records use it. */
|
|
9
|
+
readonly id: string;
|
|
10
|
+
/** The family of the catalog of the engine. */
|
|
11
|
+
readonly family: string;
|
|
12
|
+
/** The ids of the catalog rows that the strategy builds. */
|
|
13
|
+
readonly catalog: readonly string[];
|
|
14
|
+
/** For each dimension of the taxonomy, the values that the strategy supports. */
|
|
15
|
+
readonly supports: S;
|
|
16
|
+
/** The default options. A record writes the options in full. */
|
|
17
|
+
readonly defaults: O;
|
|
18
|
+
/** The value is `true` when the result depends only on the input, the options and the seed. */
|
|
19
|
+
readonly deterministic: boolean;
|
|
20
|
+
}
|
package/src/tolerance.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The epsilon for the float compares in an engine. */
|
|
2
|
+
export const EPS = 1e-6;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The epsilon for the float compares on a written file. A file rounds each number to 3 decimals,
|
|
6
|
+
* so a difference of two values can move by up to 0.001.
|
|
7
|
+
*/
|
|
8
|
+
export const RECORD_EPS = 1e-3;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The tolerance of each hard rule, in layout units. A record writes the tolerance that its run used,
|
|
12
|
+
* in its field `tolerance`, next to `hard`.
|
|
13
|
+
*/
|
|
14
|
+
export interface Tolerance {
|
|
15
|
+
/** Two boxes with their margins break `no-overlap` when their insides overlap by more than this on each axis. */
|
|
16
|
+
readonly overlap: number;
|
|
17
|
+
/** A route breaks `no-pass-through` when a part longer than this is inside a box made this much smaller. */
|
|
18
|
+
readonly through: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The default tolerance of each rule. The packer is exact, so `overlap` is one unit of the rounding.
|
|
23
|
+
* Routes are sampled curves that follow the margin line, so `through` is larger.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_TOLERANCE: Tolerance = Object.freeze({ overlap: 0.001, through: 0.5 });
|
package/src/turn.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { SIDES, marginSides, type Margin, type Region, type Side } from "./geometry.ts";
|
|
2
|
+
import type { LayoutBox, Port } from "./input.ts";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The turn: 90 degrees clockwise, with the y axis down. The turned box has `w` and `h` swapped,
|
|
6
|
+
* and its top-left corner stays at the position of the box. A point (px, py) of a box of height h
|
|
7
|
+
* goes to (h - py, px). Thus the top-left corner goes to the top-right corner, and each side moves
|
|
8
|
+
* one step clockwise: top to right, right to bottom, bottom to left, left to top.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** This function gives the side after a turn of 90 degrees clockwise. */
|
|
12
|
+
export function turnSide(side: Side): Side {
|
|
13
|
+
return SIDES[(SIDES.indexOf(side) + 1) % 4]!;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** This function gives a point of a box of height `h` after the turn. */
|
|
17
|
+
export function turnPoint(x: number, y: number, h: number): [number, number] {
|
|
18
|
+
return [h - y, x];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** This function gives a port of a box of height `h` after the turn. Each value of the port turns, also a port with only `x` or only `y`. */
|
|
22
|
+
export function turnPort(port: Port, h: number): Port {
|
|
23
|
+
const { side, x, y, ...rest } = port;
|
|
24
|
+
return {
|
|
25
|
+
...rest,
|
|
26
|
+
...(side === undefined ? {} : { side: turnSide(side) }),
|
|
27
|
+
...(y === undefined ? {} : { x: h - y }),
|
|
28
|
+
...(x === undefined ? {} : { y: x }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** This function gives a margin after the turn: the value of each side moves one side clockwise. */
|
|
33
|
+
export function turnMargin(margin: Margin): Margin {
|
|
34
|
+
if (typeof margin === "number") return margin;
|
|
35
|
+
const m = marginSides(margin);
|
|
36
|
+
return { top: m.left, right: m.top, bottom: m.right, left: m.bottom };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** This function gives a region relative to the origin of a box of height `h` after the turn. */
|
|
40
|
+
export function turnRegion(region: Region, h: number): Region {
|
|
41
|
+
switch (region.kind) {
|
|
42
|
+
case "rect":
|
|
43
|
+
return { kind: "rect", x: h - region.y - region.h, y: region.x, w: region.h, h: region.w };
|
|
44
|
+
case "circle":
|
|
45
|
+
return { ...region, cx: h - region.cy, cy: region.cx };
|
|
46
|
+
case "ellipse":
|
|
47
|
+
return { kind: "ellipse", cx: h - region.cy, cy: region.cx, rx: region.ry, ry: region.rx };
|
|
48
|
+
case "polygon": {
|
|
49
|
+
const turn = (ring: readonly (readonly [number, number])[]): [number, number][] => ring.map(([x, y]) => turnPoint(x, y, h));
|
|
50
|
+
return region.holes === undefined
|
|
51
|
+
? { kind: "polygon", points: turn(region.points) }
|
|
52
|
+
: { kind: "polygon", points: turn(region.points), holes: region.holes.map(turn) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** This function gives a box after the turn: the size, the ports, the margin and the shape turn. The position stays. */
|
|
58
|
+
export function turnBox(box: LayoutBox): LayoutBox {
|
|
59
|
+
return {
|
|
60
|
+
...box,
|
|
61
|
+
w: box.h,
|
|
62
|
+
h: box.w,
|
|
63
|
+
...(box.ports === undefined ? {} : { ports: box.ports.map((p) => turnPort(p, box.h)) }),
|
|
64
|
+
...(box.margin === undefined ? {} : { margin: turnMargin(box.margin) }),
|
|
65
|
+
...(box.shape === undefined ? {} : { shape: turnRegion(box.shape, box.h) }),
|
|
66
|
+
};
|
|
67
|
+
}
|