@mark1russell7/layout-contract 0.2.14 → 0.2.15

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.15",
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,35 @@ 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
+
35
+ /**
36
+ * The supported values of a dimension (`CorpusDimension.supported`).
37
+ * 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`.
38
+ * A range without `min` or `max` keeps that end of the dimension.
39
+ */
40
+ export type SupportedValues = readonly ParamValue[] | SupportedRange;
41
+
42
+ /** The range form of `SupportedValues`: the least and the largest supported value of a numeric dimension. */
43
+ export interface SupportedRange {
44
+ readonly min?: number;
45
+ readonly max?: number;
22
46
  }
23
47
 
48
+ /** The name of the output field that lists the fields that `generate` of a registry removed. */
49
+ export const INVALIDATED_FIELD = "invalidated";
50
+
24
51
  /** The values of the dimensions of one input: one value for each name. */
25
52
  export type DimensionValues = Readonly<Record<string, ParamValue>>;
26
53
 
@@ -48,7 +75,8 @@ export interface GenerateContext {
48
75
 
49
76
  /**
50
77
  * 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.
78
+ * The registry gives `generate` the raw seed and the full dimension values.
79
+ * The registry changes the output only for a dimension with `invalidates` at a value other than its default.
52
80
  */
53
81
  export interface CorpusFamily<O = LayoutInput> {
54
82
  /** The name of the family, for example `rect-grid`. */
@@ -71,6 +99,12 @@ export interface CorpusFamily<O = LayoutInput> {
71
99
  readonly rngSeed?: (seed: number, dims: DimensionValues) => number;
72
100
  /** 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
101
  readonly familyBy?: readonly string[];
102
+ /**
103
+ * This function gives the hash of an output after the registry removes the fields of `invalidates`. The default is `documentHash` of `hash.ts`.
104
+ * The registry uses it only for an output with a `hash` field of text. The registry gives it an output of the family.
105
+ * Its parameter has the type `never`, so a family of each output type can be a `CorpusFamily<unknown>`.
106
+ */
107
+ readonly hash?: (output: never) => string;
74
108
  /** This function gives the input of a seed. `dims` has a value for each dimension, also for an inactive dimension. */
75
109
  generate(seed: number, dims: DimensionValues, context: GenerateContext): O;
76
110
  }
@@ -109,11 +143,14 @@ export interface FamilyRegistry<O = LayoutInput> {
109
143
  /** This method gives each family, in the order of registration. */
110
144
  families(): readonly CorpusFamily<O>[];
111
145
  /**
112
- * This method gives the full dimension values of an input. It throws a `RangeError` for an unknown dimension or a bad value.
146
+ * 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
147
  * 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
148
  */
115
149
  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. */
150
+ /**
151
+ * 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.
152
+ * Then the method removes those fields, adds their names to `invalidated` and calculates the `hash` again. Another output has no change.
153
+ */
117
154
  generate(name: string, seed: number, dims?: Readonly<Record<string, ParamValue>>, options?: GenerateOptions): O;
118
155
  /** This method gives the cell of an input: the `familyKind` of the family version and the value of each active dimension. */
119
156
  cellOf(name: string, dims?: Readonly<Record<string, ParamValue>>, version?: number): RegistryCell;
@@ -187,11 +224,123 @@ export const CORPUS_DIMENSIONS: Readonly<Record<string, CorpusDimension>> = Obje
187
224
  gap: range("gap", "real", { min: 0, max: 32, value: 0 }, "The free space between two items, in units.", [P, J]),
188
225
  });
189
226
 
190
- /** This function gives a dimension of `CORPUS_DIMENSIONS`, with the fields of `patch` over it. It throws a `RangeError` for an unknown name. */
227
+ /**
228
+ * This function gives a dimension of `CORPUS_DIMENSIONS`, with the fields of `patch` over it. It throws a `RangeError` for an unknown name.
229
+ * With `supported` or `invalidates` in the patch, it also throws a `RangeError` for a bad dimension, as `ownerDimension` does.
230
+ */
191
231
  export function sharedDimension(name: string, patch: Partial<CorpusDimension> = {}): CorpusDimension {
192
232
  const base = Object.hasOwn(CORPUS_DIMENSIONS, name) ? CORPUS_DIMENSIONS[name] : undefined;
193
233
  if (base === undefined) throw new RangeError(`The shared dimension table has no dimension "${name}".`);
194
- return Object.freeze({ ...base, ...patch, name });
234
+ const dim: CorpusDimension = Object.freeze({ ...base, ...patch, name });
235
+ if (patch.supported !== undefined || patch.invalidates !== undefined) throwProblems(`sharedDimension: the dimension "${name}"`, ownProblems(dim));
236
+ return dim;
237
+ }
238
+
239
+ /** The fields of a dimension that the owner declares: each field of `CorpusDimension` but the name. */
240
+ export type OwnerDimensionSpec = Omit<CorpusDimension, "name">;
241
+
242
+ /**
243
+ * This function gives a dimension that only its owner declares, for example `cut` of a family of `packing`.
244
+ * It throws a `RangeError` for a name of `CORPUS_DIMENSIONS`, the reserved name `kind`, or a bad default, level, `supported` or `invalidates`.
245
+ * The registry examines the `when` of the dimension, because it needs the earlier dimensions.
246
+ */
247
+ export function ownerDimension(name: string, spec: OwnerDimensionSpec): CorpusDimension {
248
+ const where = `ownerDimension: the dimension "${name}"`;
249
+ if (typeof name !== "string" || name === "") throw new RangeError("ownerDimension: the name of a dimension is a text that is not empty.");
250
+ if (name === "kind") throw new RangeError(`${where} has a reserved name: the cell of \`cellOf\` has the \`familyKind\` of the family in \`kind\`.`);
251
+ if (Object.hasOwn(CORPUS_DIMENSIONS, name)) throw new RangeError(`${where} is in the shared dimension table. Use sharedDimension("${name}") for it.`);
252
+ const dim: CorpusDimension = Object.freeze({ ...spec, name });
253
+ throwProblems(where, ownProblems(dim));
254
+ return dim;
255
+ }
256
+
257
+ /** This function throws a `RangeError` with each problem of a list. An empty list throws nothing. */
258
+ function throwProblems(where: string, problems: readonly string[]): void {
259
+ if (problems.length > 0) throw new RangeError(`${where} is not good: ${problems.join("; ")}.`);
260
+ }
261
+
262
+ /** 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. */
263
+ function inSupported(dim: CorpusDimension, value: ParamValue): boolean {
264
+ const s = dim.supported;
265
+ if (s === undefined) return true;
266
+ if (isSupportedList(s)) return s.some((v) => paramValue(dim, v) === value);
267
+ if (typeof value !== "number") return false;
268
+ return (s.min === undefined || value >= s.min) && (s.max === undefined || value <= s.max);
269
+ }
270
+
271
+ /** This function tells if `SupportedValues` has the list form. */
272
+ const isSupportedList = (s: SupportedValues): s is readonly ParamValue[] => Array.isArray(s);
273
+
274
+ /**
275
+ * 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.
276
+ * It gives `false` for a bad value, and for a value outside `supported`. Without `supported`, it gives `true` for each good value.
277
+ */
278
+ export function dimensionSupports(dim: CorpusDimension, value: unknown): boolean {
279
+ if (paramProblem(dim, value) !== undefined) return false;
280
+ return inSupported(dim, paramValue(dim, value as ParamValue));
281
+ }
282
+
283
+ /** This function gives the text of the supported values of a dimension, for a message. */
284
+ function supportedText(dim: CorpusDimension): string {
285
+ const s = dim.supported;
286
+ if (s === undefined) return "each value";
287
+ if (isSupportedList(s)) return s.map((v) => JSON.stringify(v)).join(", ");
288
+ return `the values from ${s.min ?? dim.min} to ${s.max ?? dim.max}`;
289
+ }
290
+
291
+ /** This function gives the problems of one dimension that need no other dimension: the default, the levels, `supported` and `invalidates`. */
292
+ function ownProblems(d: CorpusDimension): string[] {
293
+ const out: string[] = [];
294
+ const bad = paramProblem(d, d.default);
295
+ if (bad !== undefined) out.push(`the default of "${d.name}" is bad: ${bad}`);
296
+ for (const v of d.levels ?? []) {
297
+ const p = paramProblem(d, v);
298
+ if (p !== undefined) out.push(`a level of "${d.name}" is bad: ${p}`);
299
+ }
300
+ out.push(...supportProblems(d, bad === undefined));
301
+ out.push(...invalidatesProblems(d));
302
+ return out;
303
+ }
304
+
305
+ /** This function gives the problems of the `supported` values of a dimension. `goodDefault` tells that the default is a good value. */
306
+ function supportProblems(d: CorpusDimension, goodDefault: boolean): string[] {
307
+ const s = d.supported;
308
+ if (s === undefined) return [];
309
+ const out: string[] = [];
310
+ const numeric = d.kind === "int" || d.kind === "real";
311
+ if (isSupportedList(s)) {
312
+ if (numeric) return [`the supported values of "${d.name}" are a range with min and max, because the dimension is numeric`];
313
+ if (s.length === 0) return [`the supported values of "${d.name}" are an empty list`];
314
+ for (const v of s) {
315
+ const p = paramProblem(d, v);
316
+ if (p !== undefined) out.push(`a supported value of "${d.name}" is bad: ${p}`);
317
+ }
318
+ if (new Set(s).size !== s.length) out.push(`the supported values of "${d.name}" have a value two times`);
319
+ } else {
320
+ if (s === null || typeof s !== "object") return [`the supported values of "${d.name}" are a list or a range`];
321
+ if (!numeric) return [`the supported values of "${d.name}" are a list, because the dimension is not numeric`];
322
+ for (const end of [s.min, s.max]) {
323
+ if (end === undefined) continue;
324
+ const p = paramProblem(d, end);
325
+ if (p !== undefined) out.push(`an end of the supported range of "${d.name}" is bad: ${p}`);
326
+ }
327
+ 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`);
328
+ }
329
+ if (out.length > 0) return out;
330
+ if (goodDefault && !inSupported(d, paramValue(d, d.default))) out.push(`the default of "${d.name}" is not a supported value`);
331
+ 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`);
332
+ return out;
333
+ }
334
+
335
+ /** This function gives the problems of the `invalidates` fields of a dimension. */
336
+ function invalidatesProblems(d: CorpusDimension): string[] {
337
+ const list = d.invalidates;
338
+ if (list === undefined) return [];
339
+ if (!Array.isArray(list) || !list.every((f) => typeof f === "string" && f !== "")) return [`the invalidates of "${d.name}" is not a list of field names`];
340
+ const out: string[] = [];
341
+ if (new Set(list).size !== list.length) out.push(`the invalidates of "${d.name}" has a field two times`);
342
+ 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`);
343
+ return out;
195
344
  }
196
345
 
197
346
  /** 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 +373,40 @@ export function dimensionUnit(dim: SpaceParam, value: number): number {
224
373
  return (Math.log(v) - Math.log(min)) / (Math.log(max) - Math.log(min));
225
374
  }
226
375
 
376
+ /** 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`. */
377
+ function supportedSpan(dim: CorpusDimension): { readonly min: number | undefined; readonly max: number | undefined } {
378
+ const s = dim.supported;
379
+ const range = s === undefined || isSupportedList(s) ? {} : s;
380
+ return { min: range.min ?? dim.min, max: range.max ?? dim.max };
381
+ }
382
+
227
383
  /**
228
384
  * 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
385
  * With `steps`, a numeric dimension gives `steps` points from `min` to `max` on its scale. Else it gives its default.
386
+ * 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
387
  */
231
388
  export function levelsOf(dim: CorpusDimension, steps?: number): ParamValue[] {
232
389
  if (dim.levels !== undefined) return [...dim.levels];
233
- if (dim.kind === "enum" || dim.kind === "bool" || steps === undefined) return paramLevels(dim, 1);
390
+ if (dim.kind === "enum" || dim.kind === "bool") return paramLevels(dim, 1).filter((v) => inSupported(dim, paramValue(dim, v)));
391
+ if (steps === undefined) return paramLevels(dim, 1);
392
+ if (dim.supported !== undefined) {
393
+ const { supported: _supported, ...rest } = dim;
394
+ const span = supportedSpan(dim);
395
+ return levelsOf({ ...rest, ...(span.min === undefined ? {} : { min: span.min }), ...(span.max === undefined ? {} : { max: span.max }) }, steps);
396
+ }
234
397
  if (!fromZero(dim)) return paramLevels(dim, steps);
235
398
  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
399
  if (steps === 1) return [paramValue(dim, dim.default)];
237
400
  return [...new Set(Array.from({ length: steps }, (_, j) => dimensionAt(dim, j / (steps - 1))))];
238
401
  }
239
402
 
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`. */
403
+ /** This function gives the problems of the dimensions of a family: a repeated name, a bad default, level, `supported`, `invalidates` or `when`. */
241
404
  function dimensionProblems(dims: readonly CorpusDimension[]): string[] {
242
405
  const out: string[] = [];
243
406
  const names = dims.map((d) => d.name);
244
407
  dims.forEach((d, i) => {
245
408
  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
- }
409
+ out.push(...ownProblems(d));
252
410
  if (d.when === undefined) return;
253
411
  const j = names.indexOf(d.when.param);
254
412
  if (j < 0 || j >= i) out.push(`the when of "${d.name}" names "${d.when.param}", which is not an earlier dimension`);
@@ -261,9 +419,16 @@ function dimensionProblems(dims: readonly CorpusDimension[]): string[] {
261
419
  /**
262
420
  * 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
421
  * 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.
422
+ *
264
423
  * The function throws a `RangeError` for an unknown dimension or a bad value, also for a bad value of an inactive dimension.
424
+ * It also throws a `RangeError` for a value outside `supported`, with the text "not supported by" and `by`.
265
425
  */
266
- export function resolveDimensions(dimensions: readonly CorpusDimension[], dims: Readonly<Record<string, ParamValue>> = {}, where: string = "The family"): DimensionValues {
426
+ export function resolveDimensions(
427
+ dimensions: readonly CorpusDimension[],
428
+ dims: Readonly<Record<string, ParamValue>> = {},
429
+ where: string = "The family",
430
+ by: string = "the family",
431
+ ): DimensionValues {
267
432
  const known = new Set(dimensions.map((d) => d.name));
268
433
  for (const key of Object.keys(dims)) if (!known.has(key)) throw new RangeError(`${where} has no dimension "${key}".`);
269
434
  const active: Record<string, ParamValue> = {};
@@ -273,6 +438,9 @@ export function resolveDimensions(dimensions: readonly CorpusDimension[], dims:
273
438
  if (given !== undefined) {
274
439
  const bad = paramProblem(d, given);
275
440
  if (bad !== undefined) throw new RangeError(`${where}: the dimension "${d.name}" is bad: ${bad}.`);
441
+ if (!inSupported(d, paramValue(d, given))) {
442
+ 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)}.`);
443
+ }
276
444
  }
277
445
  const on = paramActive(d, active);
278
446
  const value = paramValue(d, on && given !== undefined ? given : d.default);
@@ -318,7 +486,7 @@ function countOf(dimensions: readonly CorpusDimension[], levels: readonly (reado
318
486
  }
319
487
 
320
488
  /** 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)[] {
489
+ function sweepLevels(dimensions: readonly CorpusDimension[], options: CellsOptions, where: string, by: string): (readonly ParamValue[] | undefined)[] {
322
490
  const names = new Set(dimensions.map((d) => d.name));
323
491
  for (const name of [...(options.sweep ?? []), ...Object.keys(options.levels ?? {})]) if (!names.has(name)) throw new RangeError(`${where} has no dimension "${name}".`);
324
492
  return dimensions.map((d) => {
@@ -327,11 +495,44 @@ function sweepLevels(dimensions: readonly CorpusDimension[], options: CellsOptio
327
495
  for (const v of lv) {
328
496
  const bad = paramProblem(d, v);
329
497
  if (bad !== undefined) throw new RangeError(`${where}: a level of "${d.name}" is bad: ${bad}.`);
498
+ if (!inSupported(d, paramValue(d, v))) throw new RangeError(`${where}: the level ${JSON.stringify(v)} of "${d.name}" is not supported by ${by}.`);
330
499
  }
331
500
  return lv;
332
501
  });
333
502
  }
334
503
 
504
+ /** This function gives the output fields that the values make wrong: the `invalidates` of each dimension with a value other than its default, sorted. */
505
+ function invalidFields(dimensions: readonly CorpusDimension[], full: DimensionValues): string[] {
506
+ const out = new Set<string>();
507
+ for (const d of dimensions) {
508
+ if (d.invalidates === undefined || d.invalidates.length === 0) continue;
509
+ if (full[d.name] !== paramValue(d, d.default)) for (const f of d.invalidates) out.add(f);
510
+ }
511
+ return [...out].sort(byCode);
512
+ }
513
+
514
+ /** This function compares two strings by their UTF-16 code units. */
515
+ const byCode = (x: string, y: string): number => (x < y ? -1 : x > y ? 1 : 0);
516
+
517
+ /**
518
+ * This function removes the fields of `fields` from an output, and adds their names to its field `invalidated`, sorted.
519
+ * 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.
520
+ */
521
+ function dropFields<O>(output: O, fields: readonly string[], hash: (output: O) => string, where: string): O {
522
+ if (fields.length === 0) return output;
523
+ 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.`);
524
+ const o = output as Record<string, unknown>;
525
+ const dropped = fields.filter((f) => Object.hasOwn(o, f));
526
+ if (dropped.length === 0) return output;
527
+ const before = o[INVALIDATED_FIELD];
528
+ const prior = Array.isArray(before) ? before.filter((f): f is string => typeof f === "string") : [];
529
+ const body: Record<string, unknown> = {};
530
+ for (const key of Object.keys(o)) if (key !== "hash" && key !== INVALIDATED_FIELD && !dropped.includes(key)) body[key] = o[key];
531
+ body[INVALIDATED_FIELD] = [...new Set([...prior, ...dropped])].sort(byCode);
532
+ if (typeof o["hash"] !== "string") return (Object.hasOwn(o, "hash") ? { ...body, hash: o["hash"] } : body) as O;
533
+ return { ...body, hash: hash(body as O) } as O;
534
+ }
535
+
335
536
  /** 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
537
  function activation(dimensions: readonly CorpusDimension[], dim: CorpusDimension): Record<string, ParamValue> {
337
538
  const out: Record<string, ParamValue> = {};
@@ -369,6 +570,9 @@ export function createFamilyRegistry<O = LayoutInput>(families: Iterable<CorpusF
369
570
  const { kind: _kind, ...rest } = dims;
370
571
  return rest;
371
572
  };
573
+ /** This function gives the full dimension values of an input of `f`. A message names the family as `familyKind` gives it. */
574
+ const resolveIn = (f: CorpusFamily<O>, dims: Readonly<Record<string, ParamValue>> | undefined): DimensionValues =>
575
+ resolveDimensions(f.dimensions, dims, label(f), familyKind(f.name, f.version));
372
576
  const streamOf = (f: CorpusFamily<O>, seed: number, full: DimensionValues): { readonly rng: RngFactory; readonly random: () => number } => {
373
577
  const rng = f.rng ?? DEFAULT_RNG;
374
578
  return { rng, random: rng(f.rngSeed === undefined ? seed : f.rngSeed(seed, full)) };
@@ -393,37 +597,40 @@ export function createFamilyRegistry<O = LayoutInput>(families: Iterable<CorpusF
393
597
  families: () => [...list],
394
598
  resolve(name, dims, version) {
395
599
  const f = find(name, version);
396
- return resolveDimensions(f.dimensions, ownDims(f, dims), label(f));
600
+ return resolveIn(f, ownDims(f, dims));
397
601
  },
398
602
  generate(name, seed, dims, options = {}) {
399
603
  checkSeed(seed);
400
604
  const f = find(name, options.version);
401
- const full = resolveDimensions(f.dimensions, ownDims(f, dims), label(f));
605
+ const full = resolveIn(f, ownDims(f, dims));
402
606
  const context: GenerateContext = { ...streamOf(f, seed, full), ...(options.set === undefined ? {} : { set: options.set }) };
403
- return f.generate(seed, full, context);
607
+ const output = f.generate(seed, full, context);
608
+ const hash = (f.hash ?? documentHash) as (o: O) => string;
609
+ return dropFields(output, invalidFields(f.dimensions, full), hash, label(f));
404
610
  },
405
611
  cellOf(name, dims, version) {
406
612
  const f = find(name, version);
407
- return { kind: familyKind(f.name, f.version), ...activeValues(f.dimensions, resolveDimensions(f.dimensions, ownDims(f, dims), label(f))) };
613
+ return { kind: familyKind(f.name, f.version), ...activeValues(f.dimensions, resolveIn(f, ownDims(f, dims))) };
408
614
  },
409
615
  *cells(name, options = {}) {
410
616
  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));
617
+ for (const values of sweepOf(f.dimensions, sweepLevels(f.dimensions, options, label(f), familyKind(f.name, f.version)))) yield resolveIn(f, values);
412
618
  },
413
619
  cellCount(name, options = {}) {
414
620
  const f = find(name, options.version);
415
- return countOf(f.dimensions, sweepLevels(f.dimensions, options, label(f)));
621
+ return countOf(f.dimensions, sweepLevels(f.dimensions, options, label(f), familyKind(f.name, f.version)));
416
622
  },
417
623
  boundaryCells(name, version) {
418
624
  const f = find(name, version);
419
- const base = resolveDimensions(f.dimensions, {}, label(f));
625
+ const base = resolveIn(f, {});
420
626
  const out: DimensionValues[] = [base];
421
627
  const seen = new Set([JSON.stringify(base)]);
422
628
  for (const d of f.dimensions) {
423
629
  if (d.kind !== "int" && d.kind !== "real") continue;
424
- for (const end of [d.min, d.max]) {
630
+ const span = supportedSpan(d);
631
+ for (const end of [span.min, span.max]) {
425
632
  if (end === undefined) continue;
426
- const cell = resolveDimensions(f.dimensions, { ...activation(f.dimensions, d), [d.name]: end }, label(f));
633
+ const cell = resolveIn(f, { ...activation(f.dimensions, d), [d.name]: end });
427
634
  const key = JSON.stringify(cell);
428
635
  if (!seen.has(key)) {
429
636
  seen.add(key);
@@ -437,7 +644,7 @@ export function createFamilyRegistry<O = LayoutInput>(families: Iterable<CorpusF
437
644
  const f = find(name, version);
438
645
  return (seed, dims) => {
439
646
  checkSeed(seed);
440
- return streamOf(f, seed, resolveDimensions(f.dimensions, ownDims(f, dims), label(f))).random;
647
+ return streamOf(f, seed, resolveIn(f, ownDims(f, dims))).random;
441
648
  };
442
649
  },
443
650
  familyBy() {
@@ -475,7 +682,7 @@ export interface ComposeOptions {
475
682
  /** The default is the owner of the base family. */
476
683
  readonly owner?: string;
477
684
  readonly meaning?: string;
478
- /** The hash of the composed input. The default is `documentHash` of `hash.ts`. */
685
+ /** 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
686
  readonly hash?: (input: LayoutInput) => string;
480
687
  }
481
688
 
@@ -507,6 +714,7 @@ export function composeFamily(base: CorpusFamily<LayoutInput>, steps: readonly C
507
714
  ...(base.rng === undefined ? {} : { rng: base.rng }),
508
715
  ...(base.rngSeed === undefined ? {} : { rngSeed: (seed: number, full: DimensionValues) => base.rngSeed!(seed, pick(base.dimensions, full)) }),
509
716
  ...(base.familyBy === undefined ? {} : { familyBy: base.familyBy }),
717
+ ...(options.hash === undefined ? {} : { hash: options.hash }),
510
718
  generate(seed: number, full: DimensionValues, context: GenerateContext): LayoutInput {
511
719
  let input = base.generate(seed, pick(base.dimensions, full), context);
512
720
  const parts: string[] = [];
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";
@@ -352,6 +352,7 @@ export function packHard(input: LayoutInput, output: PackOutput, tolerance: numb
352
352
  * This function gives the problems of an input file. An empty list tells that the file is good.
353
353
  * A problem is a bad input, not a break of a hard rule. Thus an engine refuses the input.
354
354
  * In `pairGaps`, an unknown box, a pair of one box, a repeated pair in either order, a bad `gap` and bad `lanes` are problems.
355
+ * A port with `along` and no `side`, with `x` or `y`, or with a value outside [0, 1] is a problem.
355
356
  */
356
357
  export function checkInput(input: LayoutInput): readonly string[] {
357
358
  const problems: string[] = [];
@@ -363,6 +364,7 @@ export function checkInput(input: LayoutInput): readonly string[] {
363
364
  if (box.locked === true && (box.x === undefined || box.y === undefined)) problems.push(`box ${box.id}: a locked box has no position`);
364
365
  const shape = shapeKindProblem(box);
365
366
  if (shape !== undefined) problems.push(shape);
367
+ for (const port of box.ports ?? []) problems.push(...alongProblems(box.id, port));
366
368
  }
367
369
  if (countPortsOffBorder(input.boxes) > 0) problems.push("a fixed port is not on the border of its box");
368
370
  for (const edge of input.edges ?? []) {
@@ -372,6 +374,18 @@ export function checkInput(input: LayoutInput): readonly string[] {
372
374
  return problems;
373
375
  }
374
376
 
377
+ /** 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. */
378
+ function alongProblems(boxId: string, port: Port): string[] {
379
+ if (port.along === undefined) return [];
380
+ const at = `box ${boxId} port ${port.id}`;
381
+ const problems: string[] = [];
382
+ if (port.side === undefined) problems.push(`${at}: a port with along has no side`);
383
+ if (typeof port.along !== "number" || !Number.isFinite(port.along) || port.along < 0 || port.along > 1)
384
+ problems.push(`${at}: the along value ${String(port.along)} is not a finite number from 0 to 1`);
385
+ if (port.x !== undefined || port.y !== undefined) problems.push(`${at}: a port with along also has x or y`);
386
+ return problems;
387
+ }
388
+
375
389
  /** This function gives the problems of the `pairGaps` of an input. `ids` has the ids of the boxes. */
376
390
  function pairGapProblems(pairGaps: LayoutInput["pairGaps"], ids: ReadonlySet<string>): string[] {
377
391
  if (pairGaps === undefined) return [];
package/src/turn.ts CHANGED
@@ -18,14 +18,20 @@ export function turnPoint(x: number, y: number, h: number): [number, number] {
18
18
  return [h - y, x];
19
19
  }
20
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`. */
21
+ /**
22
+ * 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`.
23
+ * The start of a side is its top end or its left end. Thus `left` goes to `top` and `right` goes to `bottom` with `along` changed to `1 - along`.
24
+ * `top` goes to `right` and `bottom` goes to `left` with the same `along`.
25
+ */
22
26
  export function turnPort(port: Port, h: number): Port {
23
- const { side, x, y, ...rest } = port;
27
+ const { side, x, y, along, ...rest } = port;
28
+ const flips = side === "left" || side === "right";
24
29
  return {
25
30
  ...rest,
26
31
  ...(side === undefined ? {} : { side: turnSide(side) }),
27
32
  ...(y === undefined ? {} : { x: h - y }),
28
33
  ...(x === undefined ? {} : { y: x }),
34
+ ...(along === undefined ? {} : { along: flips ? 1 - along : along }),
29
35
  };
30
36
  }
31
37