@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/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.14",
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. It does not change the output, and it does not calculate a hash.
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 bad value.
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
- /** This method gives the input of a seed. The output is the output of the family, with no change. */
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
- /** This function gives a dimension of `CORPUS_DIMENSIONS`, with the fields of `patch` over it. It throws a `RangeError` for an unknown name. */
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
- return Object.freeze({ ...base, ...patch, name });
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" || steps === undefined) return paramLevels(dim, 1);
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, a bad level or a bad `when`. */
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
- const bad = paramProblem(d, d.default);
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(dimensions: readonly CorpusDimension[], dims: Readonly<Record<string, ParamValue>> = {}, where: string = "The family"): DimensionValues {
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 resolveDimensions(f.dimensions, ownDims(f, dims), label(f));
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 = resolveDimensions(f.dimensions, ownDims(f, dims), label(f));
635
+ const full = resolveIn(f, ownDims(f, dims));
402
636
  const context: GenerateContext = { ...streamOf(f, seed, full), ...(options.set === undefined ? {} : { set: options.set }) };
403
- return f.generate(seed, full, context);
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, resolveDimensions(f.dimensions, ownDims(f, dims), label(f))) };
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 resolveDimensions(f.dimensions, values, label(f));
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 = resolveDimensions(f.dimensions, {}, label(f));
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
- for (const end of [d.min, d.max]) {
660
+ const span = supportedSpan(d);
661
+ for (const end of [span.min, span.max]) {
425
662
  if (end === undefined) continue;
426
- const cell = resolveDimensions(f.dimensions, { ...activation(f.dimensions, d), [d.name]: end }, label(f));
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, resolveDimensions(f.dimensions, ownDims(f, dims), label(f))).random;
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
- * Without them, the router places the port on its `side`. Without a side, the router picks the side.
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 [];