rig-c 2.24.1 → 2.26.0

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.
@@ -70,6 +70,7 @@ import {
70
70
  type MeshOutline,
71
71
  } from './mesh.ts';
72
72
  import { areaBand, triangleAreas } from './areaband.ts';
73
+ import { allocationContrast, boundaryNecessityOnce, deformLoad, gradeMax, minAngleP10, type AllocationArt, type AllocationPlant } from './meshallocation.ts';
73
74
  import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
74
75
  import { cropToSpineY } from './transform.ts';
75
76
 
@@ -236,13 +237,42 @@ export interface MeshReductionInput {
236
237
  /** Every linked mesh of the source (they inherit the new topology); empty when none. */
237
238
  linkedMeshes: AttachmentRef[];
238
239
  /**
239
- * Replay (issue #1268, §7 mechanism 2): stop after the n-th accepted step — a refinement insertion or a removal
240
- * taken, counted as `ReductionChanges.acceptedAt` counts them — and return the mesh the same call without this field
241
- * had at that step, byte for byte, terminated `replayed-to-accepted-step`. Left out = today's behaviour. A whole
242
- * number, 0 or more: 0 takes no step. A number the run never reaches leaves the run to end as it would have, and
243
- * that termination says so (`stopAfterAccepted` on it).
240
+ * Replay (issue #1268, §7 mechanism 2): stop after the n-th accepted operation — a refinement insertion, a removal
241
+ * or a boundary run taken, one each, counted as `ReductionChanges.acceptedAt` counts them (issue #1279) — and return
242
+ * the mesh the same call without this field had after it, byte for byte, terminated `replayed-to-accepted-step`.
243
+ * Left out = today's behaviour. A whole number, 0 or more: 0 takes no step. A number the run never reaches leaves
244
+ * the run to end as it would have, and that termination says so (`stopAfterAccepted` on it).
244
245
  */
245
246
  stopAfterAccepted?: number;
247
+ /**
248
+ * Boundary runs taken as steps (issue #1279, §8 option (iv)) — opt-in. Left out = no run is tried, and the call is
249
+ * the one it was before the field existed. Set, each removal pass first tries to replace a run of 2 to
250
+ * `maxVertices` consecutive surviving source-hull vertices of the outline with one chord, as one step held to every
251
+ * required row exactly as a single removal is, before that pass's single removals. `maxVertices` is a whole number,
252
+ * 2 or more, and has no default; `null` is refused like any value that is not that object.
253
+ */
254
+ boundaryRuns?: BoundaryRuns;
255
+ }
256
+
257
+ /** Issue #1279: the opt-in to boundary runs — the most consecutive outline vertices one chord may replace. */
258
+ export interface BoundaryRuns {
259
+ /** A whole number, 2 or more: the longest run tried. */
260
+ maxVertices: number;
261
+ }
262
+
263
+ /**
264
+ * Issue #1279: one accepted operation of a reduction, in the order taken — the entry `ReductionChanges.acceptedAt`
265
+ * carries for it and the unit `stopAfterAccepted` counts.
266
+ */
267
+ export interface AcceptedOperation {
268
+ /** The attempt number — `candidatesTried` as it stood when the operation was taken, 1-based. */
269
+ step: number;
270
+ /** A refinement insertion, one source vertex removed, or a boundary run replaced by one chord. */
271
+ kind: 'insertion' | 'removal' | 'boundary-run';
272
+ /** Vertices the operation inserted (an insertion: 1) or removed (a removal: 1; a boundary run: 2 or more). */
273
+ count: number;
274
+ /** The source indices it removed, in outline order for a run — each `null` in `indexMap`; empty for an insertion. */
275
+ sourceVertices: number[];
246
276
  }
247
277
 
248
278
  /**
@@ -282,6 +312,41 @@ export interface MeshMeasureInput {
282
312
  influences: InfluenceLimits | null;
283
313
  boneOrder: string[] | null;
284
314
  preset: { name: string; version: string } | null;
315
+ /**
316
+ * Issue #1280 (§8, [agreed, rig-parts#126] Q2): the motion amplitude the two weight-aware allocation rows read —
317
+ * `MQ_ALLOCATION_CONTRAST` and `MQ_DEFORM_LOAD`. Left out, it is not declared and those two rows are
318
+ * `not-measurable`, naming this field; `null` declares it absent, with the same result and a reason saying so (as a
319
+ * `null` art bound is declared absent, #1257). It declares no bound: every allocation row stays `undeclared`.
320
+ */
321
+ motionAmplitude?: MotionAmplitude | null;
322
+ }
323
+
324
+ /**
325
+ * Issue #1280: the amplitude a caller's motion moves a mesh's weight field by, per track, as the allocation rows read
326
+ * it. Nothing in it has a default; nothing in it gates anything.
327
+ */
328
+ export interface MotionAmplitude {
329
+ /** One entry per track the caller declares (an animation, a parameter axis — its name is echoed, never read). */
330
+ tracks: TrackAmplitude[];
331
+ /**
332
+ * G, px per px: how fast a declared need relaxes away from its source — the size a need allows at distance d is its
333
+ * own plus G × d (the form of a region's `grade`). Read by `MQ_ALLOCATION_CONTRAST` only. Finite, 0 or more.
334
+ */
335
+ gradation: number;
336
+ }
337
+
338
+ /** One track's amplitude: the pairs of bones a share moves between in it, each with θ, and the track's ε. */
339
+ export interface TrackAmplitude {
340
+ /** The caller's name for the track; echoed, never read. */
341
+ track: string;
342
+ /**
343
+ * Each pair of bones whose relative transform the track moves, and θ: the largest ‖M − I‖ of the relative linear
344
+ * part M over the track — 2 sin(α / 2) for a rotation by α (≈ α in radians for a small one), |s − 1| for a uniform
345
+ * scale by s. Dimensionless, finite, 0 or more. A pair is unordered; declared twice, the larger θ is read.
346
+ */
347
+ pairs: Array<{ bones: [string, string]; theta: number }>;
348
+ /** ε, drawing px: the chord error the track tolerates — what a deformation need is sized against. Finite, above 0. */
349
+ epsilon: number;
285
350
  }
286
351
 
287
352
  // ---------------------------------------------------------------------------
@@ -315,6 +380,18 @@ export interface MeasureRow {
315
380
  sampling?: { domain: string; count: number };
316
381
  /** Motion rows only (stage C, `src/meshcompare.ts`): how the row's value was taken over the schedule. */
317
382
  motion?: MotionRowDetail;
383
+ /** The five allocation rows only (issue #1280): what the value is a reading of, and what it was read from. */
384
+ allocation?: AllocationDetail;
385
+ }
386
+
387
+ /** Issue #1280: an allocation row's own fields. Written on those five rows, measured or not, and on no other. */
388
+ export interface AllocationDetail {
389
+ /** One sentence: what the value is a reading of — and, for `MQ_DEFORM_LOAD`, that it is not predicted motion. */
390
+ reading: string;
391
+ /** `MQ_ALLOCATION_CONTRAST` when measured: economy E, and the counts Δ and E are taken from. */
392
+ contrast?: { economy: number; dense: { vertices: number; removableAlone: number }; rest: { vertices: number; removableAlone: number } };
393
+ /** `MQ_BOUNDARY_NECESSARY` when measured: the shortcuts the search tested, and the bound n × (n − 2) it is held to. */
394
+ search?: { shortcutsTested: number; bound: number };
318
395
  }
319
396
 
320
397
  /** Correction 2: the spatial grid and the value's own granularity are two fields. */
@@ -411,11 +488,13 @@ export interface ReductionChanges {
411
488
  /** Every linked mesh of the source: each inherits the new topology. */
412
489
  linkedMeshes: AttachmentRef[];
413
490
  /**
414
- * Issue #1268: the attempt number — `candidatesTried` as it stood when the step was taken, 1-based — of every
415
- * accepted step, ascending: each refinement insertion and each removal, so its length is
416
- * `insertedVertices + removedVertices`. `stopAfterAccepted: k` replays to the step at `acceptedAt[k - 1]`.
491
+ * Issue #1268, one entry per accepted operation since issue #1279: each refinement insertion, each removal and each
492
+ * boundary run taken, in order, with the attempt it was taken at (`step`, strictly ascending), its kind, how many
493
+ * vertices it inserted or removed and which source vertices it removed. The counts sum to
494
+ * `insertedVertices + removedVertices`; with no boundary run every count is 1 and the length is that sum.
495
+ * `stopAfterAccepted: k` replays to the operation at `acceptedAt[k - 1]`.
417
496
  */
418
- acceptedAt: number[];
497
+ acceptedAt: AcceptedOperation[];
419
498
  }
420
499
 
421
500
  export interface MeshQualityReport {
@@ -461,6 +540,10 @@ export interface EffectiveSettings {
461
540
  budget: { maxCandidates: number } | null;
462
541
  /** A reduction's `stopAfterAccepted`, echoed when the input set it (issue #1268); absent otherwise. */
463
542
  stopAfterAccepted?: number;
543
+ /** A reduction's `boundaryRuns`, echoed when the input set it (issue #1279); absent otherwise. */
544
+ boundaryRuns?: BoundaryRuns;
545
+ /** A measurement's `motionAmplitude`, echoed when the input set it — `null` included (issue #1280); absent otherwise. */
546
+ motionAmplitude?: MotionAmplitude | null;
464
547
  }
465
548
 
466
549
  export interface MeshCounts {
@@ -713,6 +796,33 @@ function validateInput(input: MeshMeasureInput): void {
713
796
  if (input.preset !== null && (!isObject(input.preset) || typeof input.preset.name !== 'string' || typeof input.preset.version !== 'string')) {
714
797
  refuse('REDUCE_INPUT_MISSING', `${who}: preset is not { name, version }; required that, or null`);
715
798
  }
799
+ validateAmplitude(who, input.motionAmplitude);
800
+ }
801
+
802
+ /** Issue #1280: `motionAmplitude` left out or `null` is accepted as it stands; anything else is a `MotionAmplitude` in full. */
803
+ function validateAmplitude(who: string, amplitude: unknown): void {
804
+ if (amplitude === undefined || amplitude === null) return;
805
+ const shape = '{ tracks: [{ track, pairs: [{ bones: [a, b], theta }], epsilon }], gradation }';
806
+ if (!isObject(amplitude) || !Array.isArray(amplitude.tracks)) refuse('REDUCE_INPUT_MISSING', `${who}: motionAmplitude is ${JSON.stringify(amplitude)}; required ${shape}, null, or the field left out`);
807
+ if (!isFiniteNumber(amplitude.gradation) || amplitude.gradation < 0) {
808
+ refuse('REDUCE_INPUT_MISSING', `${who}: motionAmplitude.gradation is ${JSON.stringify(amplitude.gradation)}; required a finite number of px per px, 0 or more — no gradation is assumed`);
809
+ }
810
+ amplitude.tracks.forEach((track: unknown, i: number) => {
811
+ const at = `motionAmplitude.tracks[${i}]`;
812
+ if (!isObject(track) || typeof track.track !== 'string' || track.track === '' || !Array.isArray(track.pairs)) {
813
+ refuse('REDUCE_INPUT_MISSING', `${who}: ${at} is ${JSON.stringify(track)}; required { track: a non-empty name, pairs: a list, epsilon }`);
814
+ }
815
+ if (!isFiniteNumber(track.epsilon) || track.epsilon <= 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.epsilon is ${JSON.stringify(track.epsilon)}; required a finite number of px above 0`);
816
+ track.pairs.forEach((pair: unknown, j: number) => {
817
+ const here = `${at}.pairs[${j}]`;
818
+ const bones = isObject(pair) ? pair.bones : undefined;
819
+ if (!Array.isArray(bones) || bones.length !== 2 || bones.some((b) => typeof b !== 'string' || b === '') || bones[0] === bones[1]) {
820
+ refuse('REDUCE_INPUT_MISSING', `${who}: ${here}.bones is ${JSON.stringify(bones)}; required two different bone names`);
821
+ }
822
+ const theta = (pair as Record<string, unknown>).theta;
823
+ if (!isFiniteNumber(theta) || theta < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${here}.theta is ${JSON.stringify(theta)}; required a finite number, 0 or more (‖M − I‖ of the pair's relative linear part)`);
824
+ });
825
+ });
716
826
  }
717
827
 
718
828
  // ---------------------------------------------------------------------------
@@ -980,6 +1090,24 @@ export function edgeIsHeldByRegion(a: readonly [number, number], b: readonly [nu
980
1090
  return (last - first) * length > BAND_CONTACT_TOLERANCE;
981
1091
  }
982
1092
 
1093
+ /**
1094
+ * The density bound the regions hold the segment `a`–`b` to, as `regionRows` reads it for a mesh edge — the
1095
+ * smallest over every region that holds it (`edgeIsHeldByRegion`): `L0` where it meets the closed polygon, else
1096
+ * `L0 + grade·d` on the `r6` grid — or null when no region holds it. Read by `MQ_ALLOCATION_CONTRAST` (issue #1280)
1097
+ * for the edges a collapse would create. The regions are taken as given: the caller has refused any that
1098
+ * `regionRefusal` refuses. Internal, as `measureMeshQualityWith` is: on `rig-c/mesh` only through `export *`.
1099
+ */
1100
+ export function regionEdgeBound(a: readonly [number, number], b: readonly [number, number], regions: readonly RefinementRegion[]): number | null {
1101
+ let bound: number | null = null;
1102
+ for (const region of regions) {
1103
+ if (!edgeIsHeldByRegion(a, b, region)) continue;
1104
+ const d = segmentToPolygon(a, b, region.polygon);
1105
+ const here = d === 0 ? region.maxEdgeLength : r6(region.maxEdgeLength + region.grade * d);
1106
+ if (bound === null || here < bound) bound = here;
1107
+ }
1108
+ return bound;
1109
+ }
1110
+
983
1111
  /** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
984
1112
  function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
985
1113
  let d = Infinity;
@@ -1288,7 +1416,17 @@ function compareRows(a: MeasureRow, b: MeasureRow): number {
1288
1416
  */
1289
1417
  export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1290
1418
  validateInput(input);
1291
- return measureValidated(input, artRastersOf(input.art), null);
1419
+ return measureValidated(input, artRastersOf(input.art), null, null);
1420
+ }
1421
+
1422
+ /**
1423
+ * `measureMeshQuality` with a fault planted in its allocation rows (issue #1280, `AllocationPlant` in
1424
+ * `src/meshallocation.ts`) — the `mesh-quality` suite's negative controls, and with `omit-rows` the report without
1425
+ * those rows that the opt-out identity is held to. Internal, as `measureMeshQualityWith` is.
1426
+ */
1427
+ export function measureMeshQualityPlanted(input: MeshMeasureInput, plant: AllocationPlant): MeshQualityReport {
1428
+ validateInput(input);
1429
+ return measureValidated(input, artRastersOf(input.art), null, plant);
1292
1430
  }
1293
1431
 
1294
1432
  /**
@@ -1307,7 +1445,7 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1307
1445
  export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
1308
1446
  validateInput(input);
1309
1447
  checkArtRasters(input, rasters);
1310
- return measureValidated(input, rasters, null);
1448
+ return measureValidated(input, rasters, null, null);
1311
1449
  }
1312
1450
 
1313
1451
  /**
@@ -1324,7 +1462,7 @@ export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRast
1324
1462
  export function measureMeshQualityStep(input: MeshMeasureInput, steps: StepRasters): MeshQualityReport {
1325
1463
  validateInput(input);
1326
1464
  checkArtRasters(input, steps.rasters);
1327
- return measureValidated(input, steps.rasters, steps);
1465
+ return measureValidated(input, steps.rasters, steps, null);
1328
1466
  }
1329
1467
 
1330
1468
  /**
@@ -1400,7 +1538,7 @@ function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
1400
1538
  }
1401
1539
 
1402
1540
  /** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
1403
- function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null): MeshQualityReport {
1541
+ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null, plant: AllocationPlant | null): MeshQualityReport {
1404
1542
  rasters.tally.uses++;
1405
1543
  const { attachment, art, source, targets } = input;
1406
1544
  const { mask, threshold, frame } = art;
@@ -1439,6 +1577,9 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1439
1577
  const points: Pt[] = source.points;
1440
1578
  const hullPolygon: Pt[] = outline.walk.map((v) => points[v]);
1441
1579
 
1580
+ // --- allocation rows (issue #1280): undeclared or not-measurable, never required ------------
1581
+ if (plant !== 'omit-rows') rows.push(...allocationRows(input, outline, rasters, steps, plant));
1582
+
1442
1583
  // --- raster rows ---------------------------------------------------------
1443
1584
  const w = mask.width;
1444
1585
  const h = mask.height;
@@ -1590,6 +1731,131 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1590
1731
  return report(counts, { id: input.id, counts, geometry, motion: null, accepted }, null);
1591
1732
  }
1592
1733
 
1734
+ /** What each allocation row is a reading of — `AllocationDetail.reading`, one fixed sentence per code. */
1735
+ const ALLOCATION_READINGS: Record<string, string> = {
1736
+ MQ_GRADE:
1737
+ 'the largest |h_u − h_v| / L over the edges, h a vertex\'s mean incident edge length: how abruptly the local size changes, whether or not the density is justified',
1738
+ MQ_MIN_ANGLE_P10: 'the tenth percentile, nearest rank, of the triangles\' smallest angles: sliver fans, where MQ_MIN_ANGLE reads the single worst triangle',
1739
+ MQ_ALLOCATION_CONTRAST:
1740
+ 'Δ: the share of the dense vertices removable alone against the declared needs (silhouette, deformation under motionAmplitude, regions) minus that share among the rest; economy E is the share of all',
1741
+ MQ_DEFORM_LOAD:
1742
+ 'the largest L · Δshare · θ / 4 over the edges, Δshare the share of weight that moves across the edge: a bound on the chord error of the mesh\'s own weight field under motionAmplitude, read to locate the edge; it is not predicted motion and no difference to any reference (docs/MESH_REDUCTION.md §8)',
1743
+ MQ_BOUNDARY_NECESSARY:
1744
+ 'B*: the fewest vertices of the reference hull a closed outline can keep with every skipped run within maxBoundaryDeviation of its chord both ways and no art pixel centre cut off (overshoot is not tested); worst is the first hull vertex of this mesh not on that outline',
1745
+ };
1746
+
1747
+ /**
1748
+ * The five allocation rows (issue #1280; docs/MESH_REDUCTION.md §8): each `undeclared` with its value and worst
1749
+ * sample, or `not-measurable` with the sentence naming what is missing — never required, so none can block a step,
1750
+ * refuse a source, or count towards acceptance. `MQ_BOUNDARY_NECESSARY` is read against the reference hull once per
1751
+ * art rasters object (`boundaryNecessityOnce`), so a reduction computes it once per call.
1752
+ */
1753
+ function allocationRows(input: MeshMeasureInput, outline: MeshOutline, rasters: ArtRasters, steps: StepRasters | null, plant: AllocationPlant | null): Built[] {
1754
+ const { attachment, source, targets, art } = input;
1755
+ const who = `attachment ${nameOf(attachment)}`;
1756
+ if (plant === 'mutates-input') {
1757
+ const tris = source.triangles;
1758
+ for (let t = 0; t + 2 < tris.length; t += 3) [tris[t + 1], tris[t + 2]] = [tris[t + 2], tris[t + 1]];
1759
+ }
1760
+ const points: Pt[] = source.points;
1761
+ const out: Built[] = [];
1762
+ const row = (code: string, unit: MeasureRow['unit'], built: Built, detail: Omit<AllocationDetail, 'reading'> = {}): void => {
1763
+ built.row.allocation = { reading: ALLOCATION_READINGS[code], ...detail };
1764
+ if (plant === 'rows-required') built.required = true;
1765
+ out.push(built);
1766
+ };
1767
+ const spec = (code: string, unit: MeasureRow['unit']): RowSpec => ({ code, unit, region: null, attachment });
1768
+ const measured = (code: string, unit: MeasureRow['unit'], value: number, worst: WorstSample): Built => measuredRow(spec(code, unit), value, null, worst, false);
1769
+ const missing = (code: string, unit: MeasureRow['unit'], reason: string): Built => unmeasuredRow(spec(code, unit), 'not-measurable', reason, false);
1770
+
1771
+ // Geometry alone.
1772
+ const grade = gradeMax(points, source.triangles, plant);
1773
+ row('MQ_GRADE', 'ratio', grade.measured ? measured('MQ_GRADE', 'ratio', grade.value.value, { at: { edge: grade.value.edge } }) : missing('MQ_GRADE', 'ratio', `${who}: ${grade.reason}`));
1774
+ const p10 = minAngleP10(points, source.triangles, plant);
1775
+ row(
1776
+ 'MQ_MIN_ANGLE_P10',
1777
+ 'degrees',
1778
+ p10.measured ? measured('MQ_MIN_ANGLE_P10', 'degrees', p10.value.value, { at: { triangle: p10.value.triangle } }) : missing('MQ_MIN_ANGLE_P10', 'degrees', `${who}: ${p10.reason}`),
1779
+ );
1780
+
1781
+ // What the weight-aware and art-reading rows need.
1782
+ const amplitude = input.motionAmplitude;
1783
+ const amplitudeMissing =
1784
+ amplitude === undefined
1785
+ ? `${who}: motionAmplitude is not declared (the field is left out), so no motion amplitude sizes a deformation need; required { tracks, gradation } — no amplitude is assumed, and without one no reading of justification separates anything (§8)`
1786
+ : amplitude === null
1787
+ ? `${who}: motionAmplitude is declared absent (null), so no motion amplitude sizes a deformation need; required { tracks, gradation } for this row — no amplitude is assumed`
1788
+ : null;
1789
+ const unweighted = source.weights === null ? `${who}: source.weights is null — an unweighted mesh has no weight field for a motion to move` : null;
1790
+ const delta = targets.maxBoundaryDeviation;
1791
+ const noDelta = delta === null ? `${who}: targets.maxBoundaryDeviation is null, so no skip of the outline has a deviation to be held to` : null;
1792
+ const artCount = rasters.artCount();
1793
+ const fewArt =
1794
+ artCount < input.minArtSamples
1795
+ ? `${who} has ${artCount} art sample(s) at alpha >= ${art.threshold}; required at least ${input.minArtSamples} (minArtSamples, P9) — a row over fewer is not a measurement`
1796
+ : null;
1797
+ const allocationArt: AllocationArt = { mask: art.mask, bits: rasters.artBits(), scale: art.frame.pageScale };
1798
+
1799
+ // MQ_DEFORM_LOAD.
1800
+ const loadWhy = amplitudeMissing ?? unweighted;
1801
+ if (loadWhy !== null || amplitude === undefined || amplitude === null || source.weights === null) {
1802
+ row('MQ_DEFORM_LOAD', 'px', missing('MQ_DEFORM_LOAD', 'px', loadWhy ?? ''));
1803
+ } else {
1804
+ const load = deformLoad(points, source.triangles, source.weights, amplitude, plant);
1805
+ row(
1806
+ 'MQ_DEFORM_LOAD',
1807
+ 'px',
1808
+ load.measured
1809
+ ? measured('MQ_DEFORM_LOAD', 'px', load.value.value, load.value.edge === null ? NOTHING_WORSE : { at: { edge: load.value.edge } })
1810
+ : missing('MQ_DEFORM_LOAD', 'px', `${who}: ${load.reason}`),
1811
+ );
1812
+ }
1813
+
1814
+ // MQ_ALLOCATION_CONTRAST.
1815
+ const planted = plant === 'contrast-without-amplitude' && (amplitude === undefined || amplitude === null) ? { tracks: [], gradation: 0 } : null;
1816
+ const declared = planted ?? amplitude;
1817
+ let regionWhy: string | null = null;
1818
+ for (const region of targets.regions) {
1819
+ const refusal = regionRefusal(region, art.frame.pageScale, who, steps);
1820
+ if (refusal !== null) {
1821
+ regionWhy = `${who}: region "${region.name}" is refused (${refusal.code}), so its density cannot be read as a declared need`;
1822
+ break;
1823
+ }
1824
+ }
1825
+ const contrastWhy = (planted === null ? amplitudeMissing : null) ?? unweighted ?? noDelta ?? fewArt ?? regionWhy;
1826
+ if (contrastWhy !== null || declared === undefined || declared === null || source.weights === null || delta === null) {
1827
+ row('MQ_ALLOCATION_CONTRAST', 'fraction', missing('MQ_ALLOCATION_CONTRAST', 'fraction', contrastWhy ?? ''));
1828
+ } else {
1829
+ const ownHull = points.slice(0, outline.hull);
1830
+ const silhouette = boundaryNecessityOnce(rasters, ownHull, delta, allocationArt, null).path;
1831
+ const contrast = allocationContrast(points, source.triangles, outline.hull, source.weights, delta, allocationArt, targets.regions, declared, silhouette, plant);
1832
+ if (contrast.measured) {
1833
+ const c = contrast.value;
1834
+ row('MQ_ALLOCATION_CONTRAST', 'fraction', measured('MQ_ALLOCATION_CONTRAST', 'fraction', c.contrast, { at: { vertex: c.worstVertex } }), {
1835
+ contrast: { economy: c.economy, dense: c.dense, rest: c.rest },
1836
+ });
1837
+ } else {
1838
+ row('MQ_ALLOCATION_CONTRAST', 'fraction', missing('MQ_ALLOCATION_CONTRAST', 'fraction', `${who}: ${contrast.reason}`));
1839
+ }
1840
+ }
1841
+
1842
+ // MQ_BOUNDARY_NECESSARY.
1843
+ const reference = input.referenceHull;
1844
+ const noReference = reference === null ? `${who}: no referenceHull was given, so there is no source hull to find the necessary outline of` : null;
1845
+ const necessaryWhy = noReference ?? noDelta ?? fewArt;
1846
+ if (necessaryWhy !== null || reference === null || delta === null) {
1847
+ row('MQ_BOUNDARY_NECESSARY', 'count', missing('MQ_BOUNDARY_NECESSARY', 'count', necessaryWhy ?? ''));
1848
+ } else {
1849
+ const found = boundaryNecessityOnce(rasters, reference, delta, allocationArt, plant);
1850
+ const kept = new Set(found.path.map((i) => pointKey(reference[i])));
1851
+ const beyond = outline.walk.find((v) => !kept.has(pointKey(points[v])));
1852
+ row('MQ_BOUNDARY_NECESSARY', 'count', measured('MQ_BOUNDARY_NECESSARY', 'count', found.count, beyond === undefined ? NOTHING_WORSE : { at: { vertex: beyond } }), {
1853
+ search: { shortcutsTested: found.tests, bound: found.bound },
1854
+ });
1855
+ }
1856
+ return out;
1857
+ }
1858
+
1593
1859
  /** What one source states about itself: hull and interior from the outline, bindings from the weights. */
1594
1860
  function countsOf(source: SourceMesh, outline: MeshOutline): MeshCounts {
1595
1861
  const bindings = source.weights === null ? 0 : source.weights.reduce((s, v) => s + v.length, 0);
@@ -1901,6 +2167,7 @@ function effectiveOf(input: MeshMeasureInput): EffectiveSettings {
1901
2167
  boneOrder: input.boneOrder,
1902
2168
  schedule: null,
1903
2169
  budget: null,
2170
+ ...(input.motionAmplitude === undefined ? {} : { motionAmplitude: input.motionAmplitude }),
1904
2171
  };
1905
2172
  }
1906
2173
 
@@ -1990,6 +2257,8 @@ function effectiveJson(e: EffectiveSettings): Json {
1990
2257
  schedule: scheduleJson(e.schedule),
1991
2258
  budget: e.budget === null ? null : { maxCandidates: e.budget.maxCandidates },
1992
2259
  ...(e.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: e.stopAfterAccepted }),
2260
+ ...(e.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: e.boundaryRuns.maxVertices } }),
2261
+ ...(e.motionAmplitude === undefined ? {} : { motionAmplitude: amplitudeJson(e.motionAmplitude) }),
1993
2262
  };
1994
2263
  }
1995
2264
 
@@ -2033,9 +2302,32 @@ function rowJson(r: MeasureRow): Json {
2033
2302
  }
2034
2303
  if (r.sampling !== undefined) out.sampling = { domain: r.sampling.domain, count: r.sampling.count };
2035
2304
  if (r.motion !== undefined) out.motion = motionDetailJson(r.motion);
2305
+ if (r.allocation !== undefined) out.allocation = allocationDetailJson(r.allocation);
2036
2306
  return out;
2037
2307
  }
2038
2308
 
2309
+ function allocationDetailJson(a: AllocationDetail): Json {
2310
+ const out: { [key: string]: Json } = { reading: a.reading };
2311
+ if (a.contrast !== undefined) {
2312
+ const c = a.contrast;
2313
+ out.contrast = {
2314
+ economy: c.economy,
2315
+ dense: { vertices: c.dense.vertices, removableAlone: c.dense.removableAlone },
2316
+ rest: { vertices: c.rest.vertices, removableAlone: c.rest.removableAlone },
2317
+ };
2318
+ }
2319
+ if (a.search !== undefined) out.search = { shortcutsTested: a.search.shortcutsTested, bound: a.search.bound };
2320
+ return out;
2321
+ }
2322
+
2323
+ function amplitudeJson(a: MotionAmplitude | null): Json {
2324
+ if (a === null) return null;
2325
+ return {
2326
+ tracks: a.tracks.map((t) => ({ track: t.track, pairs: t.pairs.map((p) => ({ bones: [p.bones[0], p.bones[1]], theta: p.theta })), epsilon: t.epsilon })),
2327
+ gradation: a.gradation,
2328
+ };
2329
+ }
2330
+
2039
2331
  function sectionJson(s: EvidenceSection): { [key: string]: Json } {
2040
2332
  const m = s.summary;
2041
2333
  return {
@@ -2067,7 +2359,7 @@ function candidateJson(c: CandidateReport): Json {
2067
2359
  deformRemapped: k.deformRemapped.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key, droppedVertices: [...d.droppedVertices] })),
2068
2360
  deformReevaluated: k.deformReevaluated.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key })),
2069
2361
  linkedMeshes: k.linkedMeshes.map(attachmentJson),
2070
- acceptedAt: [...k.acceptedAt],
2362
+ acceptedAt: k.acceptedAt.map((a) => ({ step: a.step, kind: a.kind, count: a.count, sourceVertices: [...a.sourceVertices] })),
2071
2363
  };
2072
2364
  }
2073
2365
  return out;