@mark1russell7/layout-contract 0.2.14 → 0.2.16
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/README.md +15 -3
- package/build/index.d.ts +139 -11
- package/build/index.d.ts.map +1 -1
- package/build/index.js +395 -50
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/families.ts +264 -26
- package/src/geometry.ts +28 -0
- package/src/input.ts +34 -2
- package/src/rules.ts +18 -3
- package/src/shapes.ts +76 -8
- package/src/strategy-space.ts +56 -11
- package/src/turn.ts +8 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@mark1russell7/layout-contract",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.16",
|
|
5
5
|
"description": "The shared contract of the packing and highways layout engines: the input and record formats, the turn rule, margins, tolerances and hard rules.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"layout",
|
package/src/families.ts
CHANGED
|
@@ -19,8 +19,41 @@ export interface CorpusDimension extends SpaceParam {
|
|
|
19
19
|
readonly engines: readonly string[];
|
|
20
20
|
/** The values of a sweep. Without them, a sweep uses the values of an `enum`, `false` and `true` of a `bool`, or the default. */
|
|
21
21
|
readonly levels?: readonly ParamValue[];
|
|
22
|
+
/**
|
|
23
|
+
* The values that the family accepts. Without it, the family accepts each value of the dimension.
|
|
24
|
+
* A list is for an `enum` or a `bool`. A sub-range is for an `int` or a `real`.
|
|
25
|
+
* The default and each level are supported values. `resolve` and `generate` refuse a value that is not supported.
|
|
26
|
+
*/
|
|
27
|
+
readonly supported?: SupportedValues;
|
|
28
|
+
/**
|
|
29
|
+
* The output fields that a value other than the default makes wrong, for example `known`.
|
|
30
|
+
* Then `generate` of a registry removes these fields from the output, and lists them in `invalidated`.
|
|
31
|
+
*/
|
|
32
|
+
readonly invalidates?: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* The count of decimal places that the family uses for a value of a numeric dimension: a whole number of 0 or more.
|
|
35
|
+
* Only the `invalidates` compare reads it. The registry rounds the value and the default to it before the compare.
|
|
36
|
+
* The cells, the cell keys, `rngOf` and the value that `generate` gets do not change.
|
|
37
|
+
*/
|
|
38
|
+
readonly precision?: number;
|
|
22
39
|
}
|
|
23
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The supported values of a dimension (`CorpusDimension.supported`).
|
|
43
|
+
* The list form gives the values of an `enum` or a `bool`. The range form gives a sub-range of an `int` or a `real`.
|
|
44
|
+
* A range without `min` or `max` keeps that end of the dimension.
|
|
45
|
+
*/
|
|
46
|
+
export type SupportedValues = readonly ParamValue[] | SupportedRange;
|
|
47
|
+
|
|
48
|
+
/** The range form of `SupportedValues`: the least and the largest supported value of a numeric dimension. */
|
|
49
|
+
export interface SupportedRange {
|
|
50
|
+
readonly min?: number;
|
|
51
|
+
readonly max?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The name of the output field that lists the fields that `generate` of a registry removed. */
|
|
55
|
+
export const INVALIDATED_FIELD = "invalidated";
|
|
56
|
+
|
|
24
57
|
/** The values of the dimensions of one input: one value for each name. */
|
|
25
58
|
export type DimensionValues = Readonly<Record<string, ParamValue>>;
|
|
26
59
|
|
|
@@ -48,7 +81,8 @@ export interface GenerateContext {
|
|
|
48
81
|
|
|
49
82
|
/**
|
|
50
83
|
* A family of a corpus: a seeded generator with declared dimensions. The engine that owns it gives it to a registry.
|
|
51
|
-
* The registry gives `generate` the raw seed and the full dimension values.
|
|
84
|
+
* The registry gives `generate` the raw seed and the full dimension values.
|
|
85
|
+
* The registry changes the output only for a dimension with `invalidates` at a value other than its default.
|
|
52
86
|
*/
|
|
53
87
|
export interface CorpusFamily<O = LayoutInput> {
|
|
54
88
|
/** The name of the family, for example `rect-grid`. */
|
|
@@ -71,6 +105,12 @@ export interface CorpusFamily<O = LayoutInput> {
|
|
|
71
105
|
readonly rngSeed?: (seed: number, dims: DimensionValues) => number;
|
|
72
106
|
/** The dimensions of the family key of `familyBy`, after the kind of the cell. A dimension with `when` is not permitted. The default is no dimension. */
|
|
73
107
|
readonly familyBy?: readonly string[];
|
|
108
|
+
/**
|
|
109
|
+
* This function gives the hash of an output after the registry removes the fields of `invalidates`. The default is `documentHash` of `hash.ts`.
|
|
110
|
+
* The registry uses it only for an output with a `hash` field of text. The registry gives it an output of the family.
|
|
111
|
+
* Its parameter has the type `never`, so a family of each output type can be a `CorpusFamily<unknown>`.
|
|
112
|
+
*/
|
|
113
|
+
readonly hash?: (output: never) => string;
|
|
74
114
|
/** This function gives the input of a seed. `dims` has a value for each dimension, also for an inactive dimension. */
|
|
75
115
|
generate(seed: number, dims: DimensionValues, context: GenerateContext): O;
|
|
76
116
|
}
|
|
@@ -109,11 +149,14 @@ export interface FamilyRegistry<O = LayoutInput> {
|
|
|
109
149
|
/** This method gives each family, in the order of registration. */
|
|
110
150
|
families(): readonly CorpusFamily<O>[];
|
|
111
151
|
/**
|
|
112
|
-
* This method gives the full dimension values of an input. It throws a `RangeError` for an unknown dimension or a
|
|
152
|
+
* This method gives the full dimension values of an input. It throws a `RangeError` for an unknown dimension, a bad value or a value that is not supported.
|
|
113
153
|
* The methods with `dims` accept a cell of `cellOf`: they ignore a `kind` that is the `familyKind` of the family, and refuse another `kind`.
|
|
114
154
|
*/
|
|
115
155
|
resolve(name: string, dims?: Readonly<Record<string, ParamValue>>, version?: number): DimensionValues;
|
|
116
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* This method gives the input of a seed: the output of the family. A dimension with `invalidates` at a value other than its default changes it.
|
|
158
|
+
* Then the method removes those fields, adds their names to `invalidated` and calculates the `hash` again. Another output has no change.
|
|
159
|
+
*/
|
|
117
160
|
generate(name: string, seed: number, dims?: Readonly<Record<string, ParamValue>>, options?: GenerateOptions): O;
|
|
118
161
|
/** This method gives the cell of an input: the `familyKind` of the family version and the value of each active dimension. */
|
|
119
162
|
cellOf(name: string, dims?: Readonly<Record<string, ParamValue>>, version?: number): RegistryCell;
|
|
@@ -187,11 +230,144 @@ export const CORPUS_DIMENSIONS: Readonly<Record<string, CorpusDimension>> = Obje
|
|
|
187
230
|
gap: range("gap", "real", { min: 0, max: 32, value: 0 }, "The free space between two items, in units.", [P, J]),
|
|
188
231
|
});
|
|
189
232
|
|
|
190
|
-
/**
|
|
233
|
+
/**
|
|
234
|
+
* This function gives a dimension of `CORPUS_DIMENSIONS`, with the fields of `patch` over it. It throws a `RangeError` for an unknown name.
|
|
235
|
+
* With `supported`, `invalidates` or `precision` in the patch, it also throws a `RangeError` for a bad dimension, as `ownerDimension` does.
|
|
236
|
+
*/
|
|
191
237
|
export function sharedDimension(name: string, patch: Partial<CorpusDimension> = {}): CorpusDimension {
|
|
192
238
|
const base = Object.hasOwn(CORPUS_DIMENSIONS, name) ? CORPUS_DIMENSIONS[name] : undefined;
|
|
193
239
|
if (base === undefined) throw new RangeError(`The shared dimension table has no dimension "${name}".`);
|
|
194
|
-
|
|
240
|
+
const dim: CorpusDimension = Object.freeze({ ...base, ...patch, name });
|
|
241
|
+
if (patch.supported !== undefined || patch.invalidates !== undefined || patch.precision !== undefined)
|
|
242
|
+
throwProblems(`sharedDimension: the dimension "${name}"`, ownProblems(dim));
|
|
243
|
+
return dim;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The fields of a dimension that the owner declares: each field of `CorpusDimension` but the name. */
|
|
247
|
+
export type OwnerDimensionSpec = Omit<CorpusDimension, "name">;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* This function gives a dimension that only its owner declares, for example `cut` of a family of `packing`.
|
|
251
|
+
* It throws a `RangeError` for a name of `CORPUS_DIMENSIONS`, the reserved name `kind`, or a bad default, level, `supported`, `invalidates` or `precision`.
|
|
252
|
+
* The registry examines the `when` of the dimension, because it needs the earlier dimensions.
|
|
253
|
+
*/
|
|
254
|
+
export function ownerDimension(name: string, spec: OwnerDimensionSpec): CorpusDimension {
|
|
255
|
+
const where = `ownerDimension: the dimension "${name}"`;
|
|
256
|
+
if (typeof name !== "string" || name === "") throw new RangeError("ownerDimension: the name of a dimension is a text that is not empty.");
|
|
257
|
+
if (name === "kind") throw new RangeError(`${where} has a reserved name: the cell of \`cellOf\` has the \`familyKind\` of the family in \`kind\`.`);
|
|
258
|
+
if (Object.hasOwn(CORPUS_DIMENSIONS, name)) throw new RangeError(`${where} is in the shared dimension table. Use sharedDimension("${name}") for it.`);
|
|
259
|
+
const dim: CorpusDimension = Object.freeze({ ...spec, name });
|
|
260
|
+
throwProblems(where, ownProblems(dim));
|
|
261
|
+
return dim;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** This function throws a `RangeError` with each problem of a list. An empty list throws nothing. */
|
|
265
|
+
function throwProblems(where: string, problems: readonly string[]): void {
|
|
266
|
+
if (problems.length > 0) throw new RangeError(`${where} is not good: ${problems.join("; ")}.`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** This function tells if a value of the stable form of a dimension is in its `supported` values. Without `supported`, each value is in them. */
|
|
270
|
+
function inSupported(dim: CorpusDimension, value: ParamValue): boolean {
|
|
271
|
+
const s = dim.supported;
|
|
272
|
+
if (s === undefined) return true;
|
|
273
|
+
if (isSupportedList(s)) return s.some((v) => paramValue(dim, v) === value);
|
|
274
|
+
if (typeof value !== "number") return false;
|
|
275
|
+
return (s.min === undefined || value >= s.min) && (s.max === undefined || value <= s.max);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** This function tells if `SupportedValues` has the list form. */
|
|
279
|
+
const isSupportedList = (s: SupportedValues): s is readonly ParamValue[] => Array.isArray(s);
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* This function tells if a family accepts a value of a dimension. A site uses it to show the values that a family does not support.
|
|
283
|
+
* It gives `false` for a bad value, and for a value outside `supported`. Without `supported`, it gives `true` for each good value.
|
|
284
|
+
*/
|
|
285
|
+
export function dimensionSupports(dim: CorpusDimension, value: unknown): boolean {
|
|
286
|
+
if (paramProblem(dim, value) !== undefined) return false;
|
|
287
|
+
return inSupported(dim, paramValue(dim, value as ParamValue));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** This function gives the text of the supported values of a dimension, for a message. */
|
|
291
|
+
function supportedText(dim: CorpusDimension): string {
|
|
292
|
+
const s = dim.supported;
|
|
293
|
+
if (s === undefined) return "each value";
|
|
294
|
+
if (isSupportedList(s)) return s.map((v) => JSON.stringify(v)).join(", ");
|
|
295
|
+
return `the values from ${s.min ?? dim.min} to ${s.max ?? dim.max}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** This function gives the problems of one dimension that need no other dimension: the default, the levels, `supported`, `invalidates` and `precision`. */
|
|
299
|
+
function ownProblems(d: CorpusDimension): string[] {
|
|
300
|
+
const out: string[] = [];
|
|
301
|
+
const bad = paramProblem(d, d.default);
|
|
302
|
+
if (bad !== undefined) out.push(`the default of "${d.name}" is bad: ${bad}`);
|
|
303
|
+
for (const v of d.levels ?? []) {
|
|
304
|
+
const p = paramProblem(d, v);
|
|
305
|
+
if (p !== undefined) out.push(`a level of "${d.name}" is bad: ${p}`);
|
|
306
|
+
}
|
|
307
|
+
out.push(...supportProblems(d, bad === undefined));
|
|
308
|
+
out.push(...invalidatesProblems(d));
|
|
309
|
+
out.push(...precisionProblems(d));
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** This function gives the problems of the `supported` values of a dimension. `goodDefault` tells that the default is a good value. */
|
|
314
|
+
function supportProblems(d: CorpusDimension, goodDefault: boolean): string[] {
|
|
315
|
+
const s = d.supported;
|
|
316
|
+
if (s === undefined) return [];
|
|
317
|
+
const out: string[] = [];
|
|
318
|
+
const numeric = d.kind === "int" || d.kind === "real";
|
|
319
|
+
if (isSupportedList(s)) {
|
|
320
|
+
if (numeric) return [`the supported values of "${d.name}" are a range with min and max, because the dimension is numeric`];
|
|
321
|
+
if (s.length === 0) return [`the supported values of "${d.name}" are an empty list`];
|
|
322
|
+
for (const v of s) {
|
|
323
|
+
const p = paramProblem(d, v);
|
|
324
|
+
if (p !== undefined) out.push(`a supported value of "${d.name}" is bad: ${p}`);
|
|
325
|
+
}
|
|
326
|
+
if (new Set(s).size !== s.length) out.push(`the supported values of "${d.name}" have a value two times`);
|
|
327
|
+
} else {
|
|
328
|
+
if (s === null || typeof s !== "object") return [`the supported values of "${d.name}" are a list or a range`];
|
|
329
|
+
if (!numeric) return [`the supported values of "${d.name}" are a list, because the dimension is not numeric`];
|
|
330
|
+
for (const end of [s.min, s.max]) {
|
|
331
|
+
if (end === undefined) continue;
|
|
332
|
+
const p = paramProblem(d, end);
|
|
333
|
+
if (p !== undefined) out.push(`an end of the supported range of "${d.name}" is bad: ${p}`);
|
|
334
|
+
}
|
|
335
|
+
if (s.min !== undefined && s.max !== undefined && s.min > s.max) out.push(`the supported range of "${d.name}" has a min above its max`);
|
|
336
|
+
}
|
|
337
|
+
if (out.length > 0) return out;
|
|
338
|
+
if (goodDefault && !inSupported(d, paramValue(d, d.default))) out.push(`the default of "${d.name}" is not a supported value`);
|
|
339
|
+
for (const v of d.levels ?? []) if (paramProblem(d, v) === undefined && !inSupported(d, paramValue(d, v))) out.push(`the level ${JSON.stringify(v)} of "${d.name}" is not a supported value`);
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** This function gives the problems of the `invalidates` fields of a dimension. */
|
|
344
|
+
function invalidatesProblems(d: CorpusDimension): string[] {
|
|
345
|
+
const list = d.invalidates;
|
|
346
|
+
if (list === undefined) return [];
|
|
347
|
+
if (!Array.isArray(list) || !list.every((f) => typeof f === "string" && f !== "")) return [`the invalidates of "${d.name}" is not a list of field names`];
|
|
348
|
+
const out: string[] = [];
|
|
349
|
+
if (new Set(list).size !== list.length) out.push(`the invalidates of "${d.name}" has a field two times`);
|
|
350
|
+
for (const f of ["hash", INVALIDATED_FIELD]) if (list.includes(f)) out.push(`the invalidates of "${d.name}" has the field "${f}", which the registry writes`);
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** This function gives the problems of the `precision` of a dimension. A bad value and a dimension that is not numeric are problems. */
|
|
355
|
+
function precisionProblems(d: CorpusDimension): string[] {
|
|
356
|
+
const p: unknown = d.precision;
|
|
357
|
+
if (p === undefined) return [];
|
|
358
|
+
if (typeof p !== "number" || !Number.isInteger(p) || p < 0) return [`the precision of "${d.name}" is ${JSON.stringify(p)}, not a whole number of 0 or more`];
|
|
359
|
+
if (d.kind !== "int" && d.kind !== "real") return [`the precision of "${d.name}" is for a numeric dimension, but the dimension is a ${d.kind}`];
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** This function rounds a number to `places` decimal places: `Math.round(v * 10 ** places) / 10 ** places`. */
|
|
364
|
+
const roundTo = (v: number, places: number): number => Math.round(v * 10 ** places) / 10 ** places;
|
|
365
|
+
|
|
366
|
+
/** This function tells if a value is the default of a dimension. With `precision`, it compares the two numbers after it rounds them. */
|
|
367
|
+
function isDefault(d: CorpusDimension, value: ParamValue | undefined): boolean {
|
|
368
|
+
const def = paramValue(d, d.default);
|
|
369
|
+
if (d.precision === undefined || typeof value !== "number" || typeof def !== "number") return value === def;
|
|
370
|
+
return roundTo(value, d.precision) === roundTo(def, d.precision);
|
|
195
371
|
}
|
|
196
372
|
|
|
197
373
|
/** This function tells if a log scale of a range starts at 0. Then the scale is the log of `1 + x - min`. */
|
|
@@ -224,31 +400,40 @@ export function dimensionUnit(dim: SpaceParam, value: number): number {
|
|
|
224
400
|
return (Math.log(v) - Math.log(min)) / (Math.log(max) - Math.log(min));
|
|
225
401
|
}
|
|
226
402
|
|
|
403
|
+
/** This function gives the range of the supported values of a numeric dimension: its own `min` and `max` in place of the ends of `supported`. */
|
|
404
|
+
function supportedSpan(dim: CorpusDimension): { readonly min: number | undefined; readonly max: number | undefined } {
|
|
405
|
+
const s = dim.supported;
|
|
406
|
+
const range = s === undefined || isSupportedList(s) ? {} : s;
|
|
407
|
+
return { min: range.min ?? dim.min, max: range.max ?? dim.max };
|
|
408
|
+
}
|
|
409
|
+
|
|
227
410
|
/**
|
|
228
411
|
* This function gives the values of a sweep of a dimension. `levels` of the dimension come first. Then an `enum` gives its values, and a `bool` gives `false` and `true`.
|
|
229
412
|
* With `steps`, a numeric dimension gives `steps` points from `min` to `max` on its scale. Else it gives its default.
|
|
413
|
+
* With `supported`, an `enum` and a `bool` give only the supported values, and the points of a numeric dimension stay in the supported range.
|
|
230
414
|
*/
|
|
231
415
|
export function levelsOf(dim: CorpusDimension, steps?: number): ParamValue[] {
|
|
232
416
|
if (dim.levels !== undefined) return [...dim.levels];
|
|
233
|
-
if (dim.kind === "enum" || dim.kind === "bool"
|
|
417
|
+
if (dim.kind === "enum" || dim.kind === "bool") return paramLevels(dim, 1).filter((v) => inSupported(dim, paramValue(dim, v)));
|
|
418
|
+
if (steps === undefined) return paramLevels(dim, 1);
|
|
419
|
+
if (dim.supported !== undefined) {
|
|
420
|
+
const { supported: _supported, ...rest } = dim;
|
|
421
|
+
const span = supportedSpan(dim);
|
|
422
|
+
return levelsOf({ ...rest, ...(span.min === undefined ? {} : { min: span.min }), ...(span.max === undefined ? {} : { max: span.max }) }, steps);
|
|
423
|
+
}
|
|
234
424
|
if (!fromZero(dim)) return paramLevels(dim, steps);
|
|
235
425
|
if (!Number.isInteger(steps) || steps < 1) throw new RangeError(`The steps of a sweep are a whole number of 1 or more, not ${steps}.`);
|
|
236
426
|
if (steps === 1) return [paramValue(dim, dim.default)];
|
|
237
427
|
return [...new Set(Array.from({ length: steps }, (_, j) => dimensionAt(dim, j / (steps - 1))))];
|
|
238
428
|
}
|
|
239
429
|
|
|
240
|
-
/** This function gives the problems of the dimensions of a family: a repeated name, a bad default,
|
|
430
|
+
/** This function gives the problems of the dimensions of a family: a repeated name, a bad default, level, `supported`, `invalidates` or `when`. */
|
|
241
431
|
function dimensionProblems(dims: readonly CorpusDimension[]): string[] {
|
|
242
432
|
const out: string[] = [];
|
|
243
433
|
const names = dims.map((d) => d.name);
|
|
244
434
|
dims.forEach((d, i) => {
|
|
245
435
|
if (names.indexOf(d.name) !== i) out.push(`the dimension "${d.name}" is in the list two times`);
|
|
246
|
-
|
|
247
|
-
if (bad !== undefined) out.push(`the default of "${d.name}" is bad: ${bad}`);
|
|
248
|
-
for (const v of d.levels ?? []) {
|
|
249
|
-
const p = paramProblem(d, v);
|
|
250
|
-
if (p !== undefined) out.push(`a level of "${d.name}" is bad: ${p}`);
|
|
251
|
-
}
|
|
436
|
+
out.push(...ownProblems(d));
|
|
252
437
|
if (d.when === undefined) return;
|
|
253
438
|
const j = names.indexOf(d.when.param);
|
|
254
439
|
if (j < 0 || j >= i) out.push(`the when of "${d.name}" names "${d.when.param}", which is not an earlier dimension`);
|
|
@@ -261,9 +446,16 @@ function dimensionProblems(dims: readonly CorpusDimension[]): string[] {
|
|
|
261
446
|
/**
|
|
262
447
|
* This function gives the full dimension values of an input. A missing dimension gets its default. An inactive dimension of `when` also gets its default.
|
|
263
448
|
* Thus a default that the caller gives, a missing value and an inactive value give the same values. A real number gets its stable form.
|
|
449
|
+
*
|
|
264
450
|
* The function throws a `RangeError` for an unknown dimension or a bad value, also for a bad value of an inactive dimension.
|
|
451
|
+
* It also throws a `RangeError` for a value outside `supported`, with the text "not supported by" and `by`.
|
|
265
452
|
*/
|
|
266
|
-
export function resolveDimensions(
|
|
453
|
+
export function resolveDimensions(
|
|
454
|
+
dimensions: readonly CorpusDimension[],
|
|
455
|
+
dims: Readonly<Record<string, ParamValue>> = {},
|
|
456
|
+
where: string = "The family",
|
|
457
|
+
by: string = "the family",
|
|
458
|
+
): DimensionValues {
|
|
267
459
|
const known = new Set(dimensions.map((d) => d.name));
|
|
268
460
|
for (const key of Object.keys(dims)) if (!known.has(key)) throw new RangeError(`${where} has no dimension "${key}".`);
|
|
269
461
|
const active: Record<string, ParamValue> = {};
|
|
@@ -273,6 +465,9 @@ export function resolveDimensions(dimensions: readonly CorpusDimension[], dims:
|
|
|
273
465
|
if (given !== undefined) {
|
|
274
466
|
const bad = paramProblem(d, given);
|
|
275
467
|
if (bad !== undefined) throw new RangeError(`${where}: the dimension "${d.name}" is bad: ${bad}.`);
|
|
468
|
+
if (!inSupported(d, paramValue(d, given))) {
|
|
469
|
+
throw new RangeError(`${where}: the value ${JSON.stringify(given)} of the dimension "${d.name}" is not supported by ${by}. The supported values are ${supportedText(d)}.`);
|
|
470
|
+
}
|
|
276
471
|
}
|
|
277
472
|
const on = paramActive(d, active);
|
|
278
473
|
const value = paramValue(d, on && given !== undefined ? given : d.default);
|
|
@@ -318,7 +513,7 @@ function countOf(dimensions: readonly CorpusDimension[], levels: readonly (reado
|
|
|
318
513
|
}
|
|
319
514
|
|
|
320
515
|
/** This function gives the levels of each dimension of a sweep. A dimension outside the sweep has `undefined`, so it keeps its default. */
|
|
321
|
-
function sweepLevels(dimensions: readonly CorpusDimension[], options: CellsOptions, where: string): (readonly ParamValue[] | undefined)[] {
|
|
516
|
+
function sweepLevels(dimensions: readonly CorpusDimension[], options: CellsOptions, where: string, by: string): (readonly ParamValue[] | undefined)[] {
|
|
322
517
|
const names = new Set(dimensions.map((d) => d.name));
|
|
323
518
|
for (const name of [...(options.sweep ?? []), ...Object.keys(options.levels ?? {})]) if (!names.has(name)) throw new RangeError(`${where} has no dimension "${name}".`);
|
|
324
519
|
return dimensions.map((d) => {
|
|
@@ -327,11 +522,47 @@ function sweepLevels(dimensions: readonly CorpusDimension[], options: CellsOptio
|
|
|
327
522
|
for (const v of lv) {
|
|
328
523
|
const bad = paramProblem(d, v);
|
|
329
524
|
if (bad !== undefined) throw new RangeError(`${where}: a level of "${d.name}" is bad: ${bad}.`);
|
|
525
|
+
if (!inSupported(d, paramValue(d, v))) throw new RangeError(`${where}: the level ${JSON.stringify(v)} of "${d.name}" is not supported by ${by}.`);
|
|
330
526
|
}
|
|
331
527
|
return lv;
|
|
332
528
|
});
|
|
333
529
|
}
|
|
334
530
|
|
|
531
|
+
/**
|
|
532
|
+
* This function gives the output fields that the values make wrong: the `invalidates` of each dimension with a value other than its default, sorted.
|
|
533
|
+
* A dimension with `precision` compares the rounded value with the rounded default.
|
|
534
|
+
*/
|
|
535
|
+
function invalidFields(dimensions: readonly CorpusDimension[], full: DimensionValues): string[] {
|
|
536
|
+
const out = new Set<string>();
|
|
537
|
+
for (const d of dimensions) {
|
|
538
|
+
if (d.invalidates === undefined || d.invalidates.length === 0) continue;
|
|
539
|
+
if (!isDefault(d, full[d.name])) for (const f of d.invalidates) out.add(f);
|
|
540
|
+
}
|
|
541
|
+
return [...out].sort(byCode);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** This function compares two strings by their UTF-16 code units. */
|
|
545
|
+
const byCode = (x: string, y: string): number => (x < y ? -1 : x > y ? 1 : 0);
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* This function removes the fields of `fields` from an output, and adds their names to its field `invalidated`, sorted.
|
|
549
|
+
* Only a field of the output counts. The `hash` of text gets the value of `hash` again. An output with none of the fields is the same object.
|
|
550
|
+
*/
|
|
551
|
+
function dropFields<O>(output: O, fields: readonly string[], hash: (output: O) => string, where: string): O {
|
|
552
|
+
if (fields.length === 0) return output;
|
|
553
|
+
if (output === null || typeof output !== "object" || Array.isArray(output)) throw new RangeError(`${where} invalidates the fields ${fields.join(", ")}, but its output is not an object.`);
|
|
554
|
+
const o = output as Record<string, unknown>;
|
|
555
|
+
const dropped = fields.filter((f) => Object.hasOwn(o, f));
|
|
556
|
+
if (dropped.length === 0) return output;
|
|
557
|
+
const before = o[INVALIDATED_FIELD];
|
|
558
|
+
const prior = Array.isArray(before) ? before.filter((f): f is string => typeof f === "string") : [];
|
|
559
|
+
const body: Record<string, unknown> = {};
|
|
560
|
+
for (const key of Object.keys(o)) if (key !== "hash" && key !== INVALIDATED_FIELD && !dropped.includes(key)) body[key] = o[key];
|
|
561
|
+
body[INVALIDATED_FIELD] = [...new Set([...prior, ...dropped])].sort(byCode);
|
|
562
|
+
if (typeof o["hash"] !== "string") return (Object.hasOwn(o, "hash") ? { ...body, hash: o["hash"] } : body) as O;
|
|
563
|
+
return { ...body, hash: hash(body as O) } as O;
|
|
564
|
+
}
|
|
565
|
+
|
|
335
566
|
/** This function gives the values that make a dimension active: the first value of its `when`, and the values that make that dimension active. */
|
|
336
567
|
function activation(dimensions: readonly CorpusDimension[], dim: CorpusDimension): Record<string, ParamValue> {
|
|
337
568
|
const out: Record<string, ParamValue> = {};
|
|
@@ -369,6 +600,9 @@ export function createFamilyRegistry<O = LayoutInput>(families: Iterable<CorpusF
|
|
|
369
600
|
const { kind: _kind, ...rest } = dims;
|
|
370
601
|
return rest;
|
|
371
602
|
};
|
|
603
|
+
/** This function gives the full dimension values of an input of `f`. A message names the family as `familyKind` gives it. */
|
|
604
|
+
const resolveIn = (f: CorpusFamily<O>, dims: Readonly<Record<string, ParamValue>> | undefined): DimensionValues =>
|
|
605
|
+
resolveDimensions(f.dimensions, dims, label(f), familyKind(f.name, f.version));
|
|
372
606
|
const streamOf = (f: CorpusFamily<O>, seed: number, full: DimensionValues): { readonly rng: RngFactory; readonly random: () => number } => {
|
|
373
607
|
const rng = f.rng ?? DEFAULT_RNG;
|
|
374
608
|
return { rng, random: rng(f.rngSeed === undefined ? seed : f.rngSeed(seed, full)) };
|
|
@@ -393,37 +627,40 @@ export function createFamilyRegistry<O = LayoutInput>(families: Iterable<CorpusF
|
|
|
393
627
|
families: () => [...list],
|
|
394
628
|
resolve(name, dims, version) {
|
|
395
629
|
const f = find(name, version);
|
|
396
|
-
return
|
|
630
|
+
return resolveIn(f, ownDims(f, dims));
|
|
397
631
|
},
|
|
398
632
|
generate(name, seed, dims, options = {}) {
|
|
399
633
|
checkSeed(seed);
|
|
400
634
|
const f = find(name, options.version);
|
|
401
|
-
const full =
|
|
635
|
+
const full = resolveIn(f, ownDims(f, dims));
|
|
402
636
|
const context: GenerateContext = { ...streamOf(f, seed, full), ...(options.set === undefined ? {} : { set: options.set }) };
|
|
403
|
-
|
|
637
|
+
const output = f.generate(seed, full, context);
|
|
638
|
+
const hash = (f.hash ?? documentHash) as (o: O) => string;
|
|
639
|
+
return dropFields(output, invalidFields(f.dimensions, full), hash, label(f));
|
|
404
640
|
},
|
|
405
641
|
cellOf(name, dims, version) {
|
|
406
642
|
const f = find(name, version);
|
|
407
|
-
return { kind: familyKind(f.name, f.version), ...activeValues(f.dimensions,
|
|
643
|
+
return { kind: familyKind(f.name, f.version), ...activeValues(f.dimensions, resolveIn(f, ownDims(f, dims))) };
|
|
408
644
|
},
|
|
409
645
|
*cells(name, options = {}) {
|
|
410
646
|
const f = find(name, options.version);
|
|
411
|
-
for (const values of sweepOf(f.dimensions, sweepLevels(f.dimensions, options, label(f)))) yield
|
|
647
|
+
for (const values of sweepOf(f.dimensions, sweepLevels(f.dimensions, options, label(f), familyKind(f.name, f.version)))) yield resolveIn(f, values);
|
|
412
648
|
},
|
|
413
649
|
cellCount(name, options = {}) {
|
|
414
650
|
const f = find(name, options.version);
|
|
415
|
-
return countOf(f.dimensions, sweepLevels(f.dimensions, options, label(f)));
|
|
651
|
+
return countOf(f.dimensions, sweepLevels(f.dimensions, options, label(f), familyKind(f.name, f.version)));
|
|
416
652
|
},
|
|
417
653
|
boundaryCells(name, version) {
|
|
418
654
|
const f = find(name, version);
|
|
419
|
-
const base =
|
|
655
|
+
const base = resolveIn(f, {});
|
|
420
656
|
const out: DimensionValues[] = [base];
|
|
421
657
|
const seen = new Set([JSON.stringify(base)]);
|
|
422
658
|
for (const d of f.dimensions) {
|
|
423
659
|
if (d.kind !== "int" && d.kind !== "real") continue;
|
|
424
|
-
|
|
660
|
+
const span = supportedSpan(d);
|
|
661
|
+
for (const end of [span.min, span.max]) {
|
|
425
662
|
if (end === undefined) continue;
|
|
426
|
-
const cell =
|
|
663
|
+
const cell = resolveIn(f, { ...activation(f.dimensions, d), [d.name]: end });
|
|
427
664
|
const key = JSON.stringify(cell);
|
|
428
665
|
if (!seen.has(key)) {
|
|
429
666
|
seen.add(key);
|
|
@@ -437,7 +674,7 @@ export function createFamilyRegistry<O = LayoutInput>(families: Iterable<CorpusF
|
|
|
437
674
|
const f = find(name, version);
|
|
438
675
|
return (seed, dims) => {
|
|
439
676
|
checkSeed(seed);
|
|
440
|
-
return streamOf(f, seed,
|
|
677
|
+
return streamOf(f, seed, resolveIn(f, ownDims(f, dims))).random;
|
|
441
678
|
};
|
|
442
679
|
},
|
|
443
680
|
familyBy() {
|
|
@@ -475,7 +712,7 @@ export interface ComposeOptions {
|
|
|
475
712
|
/** The default is the owner of the base family. */
|
|
476
713
|
readonly owner?: string;
|
|
477
714
|
readonly meaning?: string;
|
|
478
|
-
/** The hash of the composed input. The default is `documentHash` of `hash.ts`. */
|
|
715
|
+
/** The hash of the composed input. The default is `documentHash` of `hash.ts`. The composed family also gives it to the registry in `hash`. */
|
|
479
716
|
readonly hash?: (input: LayoutInput) => string;
|
|
480
717
|
}
|
|
481
718
|
|
|
@@ -507,6 +744,7 @@ export function composeFamily(base: CorpusFamily<LayoutInput>, steps: readonly C
|
|
|
507
744
|
...(base.rng === undefined ? {} : { rng: base.rng }),
|
|
508
745
|
...(base.rngSeed === undefined ? {} : { rngSeed: (seed: number, full: DimensionValues) => base.rngSeed!(seed, pick(base.dimensions, full)) }),
|
|
509
746
|
...(base.familyBy === undefined ? {} : { familyBy: base.familyBy }),
|
|
747
|
+
...(options.hash === undefined ? {} : { hash: options.hash }),
|
|
510
748
|
generate(seed: number, full: DimensionValues, context: GenerateContext): LayoutInput {
|
|
511
749
|
let input = base.generate(seed, pick(base.dimensions, full), context);
|
|
512
750
|
const parts: string[] = [];
|
package/src/geometry.ts
CHANGED
|
@@ -80,6 +80,34 @@ export function boundsOf(region: Region): Rect {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* This function gives the exact area of a region. A `rect` gives `w` by `h`, a circle gives `PI * r * r`, and an ellipse gives `PI * rx * ry`.
|
|
85
|
+
* A polygon gives the absolute value of the shoelace formula, less the area of each hole. A ring with fewer than 3 points has the area 0.
|
|
86
|
+
*/
|
|
87
|
+
export function regionArea(region: Region): number {
|
|
88
|
+
switch (region.kind) {
|
|
89
|
+
case "rect":
|
|
90
|
+
return region.w * region.h;
|
|
91
|
+
case "circle":
|
|
92
|
+
return Math.PI * region.r * region.r;
|
|
93
|
+
case "ellipse":
|
|
94
|
+
return Math.PI * region.rx * region.ry;
|
|
95
|
+
case "polygon":
|
|
96
|
+
return (region.holes ?? []).reduce((area, hole) => area - ringArea(hole), ringArea(region.points));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** This function gives the area of one ring of a polygon: the absolute value of the shoelace formula. */
|
|
101
|
+
function ringArea(ring: readonly (readonly [number, number])[]): number {
|
|
102
|
+
let twice = 0;
|
|
103
|
+
for (let i = 0; i < ring.length; i++) {
|
|
104
|
+
const [ax, ay] = ring[i]!;
|
|
105
|
+
const [bx, by] = ring[(i + 1) % ring.length]!;
|
|
106
|
+
twice += ax * by - bx * ay;
|
|
107
|
+
}
|
|
108
|
+
return Math.abs(twice) / 2;
|
|
109
|
+
}
|
|
110
|
+
|
|
83
111
|
/** This function gives the region moved by `dx` and `dy`. */
|
|
84
112
|
export function translate(region: Region, dx: number, dy: number): Region {
|
|
85
113
|
switch (region.kind) {
|
package/src/input.ts
CHANGED
|
@@ -1,16 +1,43 @@
|
|
|
1
|
-
import type { Margin, Region, Side } from "./geometry.ts";
|
|
1
|
+
import type { Margin, Point, Region, Side } from "./geometry.ts";
|
|
2
2
|
import type { ShapeKind } from "./shapes.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A port of a box: a place where edges go into the box.
|
|
6
6
|
* With `x` and `y`, the port is fixed on the border of the box, relative to its top-left corner.
|
|
7
|
-
*
|
|
7
|
+
* With `side` and `along`, the port is fixed at a fraction of its side.
|
|
8
|
+
* With only a side, the router places the port on that side. Without a side, the router picks the side.
|
|
8
9
|
*/
|
|
9
10
|
export interface Port {
|
|
10
11
|
readonly id: string;
|
|
11
12
|
readonly side?: Side;
|
|
12
13
|
readonly x?: number;
|
|
13
14
|
readonly y?: number;
|
|
15
|
+
/**
|
|
16
|
+
* The place of the port on its `side`: a fraction from 0 to 1, from the start of the side.
|
|
17
|
+
* The start of `left` and `right` is the top end. The start of `top` and `bottom` is the left end.
|
|
18
|
+
* It needs a `side`. A port with `along` has no `x` and no `y`.
|
|
19
|
+
*/
|
|
20
|
+
readonly along?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* This function gives the point of a port on the border of its box, relative to the top-left corner of the box.
|
|
25
|
+
* A port with `x` and `y` gives that point. A port with `side` and `along` gives the point at that fraction of the side.
|
|
26
|
+
* Another port gives `undefined`, because the router picks its point.
|
|
27
|
+
*/
|
|
28
|
+
export function portPoint(box: Pick<LayoutBox, "w" | "h">, port: Port): Point | undefined {
|
|
29
|
+
if (port.x !== undefined && port.y !== undefined) return { x: port.x, y: port.y };
|
|
30
|
+
if (port.side === undefined || port.along === undefined) return undefined;
|
|
31
|
+
switch (port.side) {
|
|
32
|
+
case "top":
|
|
33
|
+
return { x: port.along * box.w, y: 0 };
|
|
34
|
+
case "bottom":
|
|
35
|
+
return { x: port.along * box.w, y: box.h };
|
|
36
|
+
case "left":
|
|
37
|
+
return { x: 0, y: port.along * box.h };
|
|
38
|
+
case "right":
|
|
39
|
+
return { x: box.w, y: port.along * box.h };
|
|
40
|
+
}
|
|
14
41
|
}
|
|
15
42
|
|
|
16
43
|
/** A box of an input. In the packer, its name is item. */
|
|
@@ -148,6 +175,11 @@ export interface LayoutInput {
|
|
|
148
175
|
*/
|
|
149
176
|
readonly base?: string;
|
|
150
177
|
readonly change?: "add" | "remove" | "resize" | "reorder" | "packed";
|
|
178
|
+
/**
|
|
179
|
+
* The fields that a corpus registry removed, because a dimension value made them wrong (`CorpusDimension.invalidates`).
|
|
180
|
+
* The names are sorted. An input with no removed field has no `invalidated`.
|
|
181
|
+
*/
|
|
182
|
+
readonly invalidated?: readonly string[];
|
|
151
183
|
}
|
|
152
184
|
|
|
153
185
|
/** The input of a packer: a `layout-input/1` file without the edges. */
|
package/src/rules.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { boundsOf, grow, overlapOf, type Rect, type Region } from "./geometry.ts";
|
|
2
|
-
import type { LayoutBox, LayoutInput, Obstacle, PairGap } from "./input.ts";
|
|
2
|
+
import type { LayoutBox, LayoutInput, Obstacle, PairGap, Port } from "./input.ts";
|
|
3
3
|
import { applyPack, type PackOutput } from "./output.ts";
|
|
4
4
|
import { SHAPE_KIND_IDS, shapeKindProblem, type ShapeSupport } from "./shapes.ts";
|
|
5
5
|
import { DEFAULT_TOLERANCE, EPS, RECORD_EPS } from "./tolerance.ts";
|
|
@@ -132,11 +132,12 @@ export interface PackHard {
|
|
|
132
132
|
/**
|
|
133
133
|
* The hard rules of a pack, with the shape kinds that each rule supports.
|
|
134
134
|
* `no-overlap` and `in-container` test the bounding box of a shape. That count is exact only for a `rect`.
|
|
135
|
+
* For the other kinds, the count is safe: a shape is inside its bounding box, so the count is an upper bound. A count of 0 tells that the rule has no break.
|
|
135
136
|
* `locked-kept` and `complete` read only the positions and the ids, so they support each kind.
|
|
136
137
|
*/
|
|
137
138
|
export const PACK_HARD_RULES: readonly ShapeSupport<keyof PackHard>[] = Object.freeze([
|
|
138
|
-
{ id: "no-overlap", shapes: ["rect"] },
|
|
139
|
-
{ id: "in-container", shapes: ["rect"] },
|
|
139
|
+
{ id: "no-overlap", shapes: ["rect"], safe: ["polygon", "circle", "free"] },
|
|
140
|
+
{ id: "in-container", shapes: ["rect"], safe: ["polygon", "circle", "free"] },
|
|
140
141
|
{ id: "locked-kept", shapes: SHAPE_KIND_IDS },
|
|
141
142
|
{ id: "complete", shapes: SHAPE_KIND_IDS },
|
|
142
143
|
] as const);
|
|
@@ -352,6 +353,7 @@ export function packHard(input: LayoutInput, output: PackOutput, tolerance: numb
|
|
|
352
353
|
* This function gives the problems of an input file. An empty list tells that the file is good.
|
|
353
354
|
* A problem is a bad input, not a break of a hard rule. Thus an engine refuses the input.
|
|
354
355
|
* In `pairGaps`, an unknown box, a pair of one box, a repeated pair in either order, a bad `gap` and bad `lanes` are problems.
|
|
356
|
+
* A port with `along` and no `side`, with `x` or `y`, or with a value outside [0, 1] is a problem.
|
|
355
357
|
*/
|
|
356
358
|
export function checkInput(input: LayoutInput): readonly string[] {
|
|
357
359
|
const problems: string[] = [];
|
|
@@ -363,6 +365,7 @@ export function checkInput(input: LayoutInput): readonly string[] {
|
|
|
363
365
|
if (box.locked === true && (box.x === undefined || box.y === undefined)) problems.push(`box ${box.id}: a locked box has no position`);
|
|
364
366
|
const shape = shapeKindProblem(box);
|
|
365
367
|
if (shape !== undefined) problems.push(shape);
|
|
368
|
+
for (const port of box.ports ?? []) problems.push(...alongProblems(box.id, port));
|
|
366
369
|
}
|
|
367
370
|
if (countPortsOffBorder(input.boxes) > 0) problems.push("a fixed port is not on the border of its box");
|
|
368
371
|
for (const edge of input.edges ?? []) {
|
|
@@ -372,6 +375,18 @@ export function checkInput(input: LayoutInput): readonly string[] {
|
|
|
372
375
|
return problems;
|
|
373
376
|
}
|
|
374
377
|
|
|
378
|
+
/** This function gives the problems of the `along` of a port: no side, a value outside [0, 1], or `x` or `y` with it. A port without `along` has no problem. */
|
|
379
|
+
function alongProblems(boxId: string, port: Port): string[] {
|
|
380
|
+
if (port.along === undefined) return [];
|
|
381
|
+
const at = `box ${boxId} port ${port.id}`;
|
|
382
|
+
const problems: string[] = [];
|
|
383
|
+
if (port.side === undefined) problems.push(`${at}: a port with along has no side`);
|
|
384
|
+
if (typeof port.along !== "number" || !Number.isFinite(port.along) || port.along < 0 || port.along > 1)
|
|
385
|
+
problems.push(`${at}: the along value ${String(port.along)} is not a finite number from 0 to 1`);
|
|
386
|
+
if (port.x !== undefined || port.y !== undefined) problems.push(`${at}: a port with along also has x or y`);
|
|
387
|
+
return problems;
|
|
388
|
+
}
|
|
389
|
+
|
|
375
390
|
/** This function gives the problems of the `pairGaps` of an input. `ids` has the ids of the boxes. */
|
|
376
391
|
function pairGapProblems(pairGaps: LayoutInput["pairGaps"], ids: ReadonlySet<string>): string[] {
|
|
377
392
|
if (pairGaps === undefined) return [];
|