@rig-c/parts 1.11.0 → 1.13.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.
package/src/propose.ts CHANGED
@@ -136,6 +136,8 @@ import { configRoles, type RoleOf } from './structure.ts';
136
136
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
137
137
  import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
138
138
  import { edgeLength } from './length.ts';
139
+ import { chainSqueezes, type SqueezeFinding } from './squeeze.ts';
140
+ import { TEXTURE_STRETCH_CEILING } from './check.ts';
139
141
 
140
142
  // ---------------------------------------------------------------------------
141
143
  // the output shape — config-shaped, key order as the reference writes it
@@ -2905,7 +2907,10 @@ export interface ChainNotAdvancingFinding {
2905
2907
  toJoint: KeypointName;
2906
2908
  }
2907
2909
 
2908
- export type LintFinding = OffArtFinding | OffLockFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding;
2910
+ /** Issue #314: two chains on one lattice whose own swing closes or opens an edge between them past check's TEXTURE_STRETCH bar (`src/squeeze.ts`). */
2911
+ export type SqueezeLintFinding = { kind: 'squeeze' } & SqueezeFinding;
2912
+
2913
+ export type LintFinding = OffArtFinding | OffLockFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding | SqueezeLintFinding;
2909
2914
 
2910
2915
  /** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
2911
2916
  export interface LintBasis {
@@ -2923,6 +2928,8 @@ export interface LintResult {
2923
2928
  lockUnread: string[];
2924
2929
  /** Single bones the torso checks read (`hip`, `chest`) that the bones do not hold: those checks did not run. */
2925
2930
  missingTorsoBones: string[];
2931
+ /** Issue #314: the pairs of keyed chains on one mesh the squeeze check could not build a lattice for, and why. Empty when it read every pair or had none. */
2932
+ squeezeUnread: string[];
2926
2933
  /** Present only under `--keypoints`: the rule set that read the torso and the chains. */
2927
2934
  basis?: LintBasis;
2928
2935
  /** One record per bone of the spec, in its bone order (chain links as `<chain><i>`): which checks read it, or why none did (issue #86). */
@@ -3130,8 +3137,21 @@ export function lint(
3130
3137
  }
3131
3138
  }
3132
3139
  }
3140
+ // Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
3141
+ // to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
3142
+ const sq = chainSqueezes({ bones: spec.bones, meshes: spec.meshes, motion: spec.motion }, new Map(P.recs.filter((p) => P.images.has(p.name)).map((p) => [p.name, { x: p.x, y: p.y, image: P.images.get(p.name) as Raster }])));
3143
+ for (const { mesh, a, b } of sq.read) {
3144
+ for (const [c, other] of [[a, b], [b, a]]) {
3145
+ const e = spec.bones.find((x) => 'chain' in x && x.chain === c);
3146
+ if (e !== undefined && 'chain' in e) e.points.forEach((_, i) => readBy(`${c}${i}`, `squeeze with ${other} on mesh ${pyRepr(mesh)}`));
3147
+ }
3148
+ }
3149
+ for (const f of sq.findings) findings.push({ kind: 'squeeze', ...f });
3150
+ const squeezeUnread = sq.unread;
3133
3151
  const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
3134
- return basis === undefined ? { findings, unknownMeshes, lockUnread, missingTorsoBones, coverage } : { findings, unknownMeshes, lockUnread, missingTorsoBones, basis, coverage };
3152
+ return basis === undefined
3153
+ ? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, coverage }
3154
+ : { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, basis, coverage };
3135
3155
  }
3136
3156
 
3137
3157
  /** The bones a finding names: the off-art link; hip and chest for a relation between them; a chain's last link for its tip. */
@@ -3148,6 +3168,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
3148
3168
  return ['hip'];
3149
3169
  case 'chest-not-between':
3150
3170
  return ['chest'];
3171
+ case 'squeeze':
3172
+ return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
3151
3173
  case 'chain-not-advancing': {
3152
3174
  if (!f.point.endsWith(' tip')) return [f.point];
3153
3175
  const chain = f.point.slice(0, -' tip'.length);
@@ -3313,11 +3335,42 @@ export function lintLine(f: LintFinding): string {
3313
3335
  return `LINT hip at ${pyIntList(f.hip)} is not past chest at ${pyIntList(f.chest)} on the way from the neck joint to the hip joints (hip at ${pyFixed(f.tHip, 2)}, chest at ${pyFixed(f.tChest, 2)} of the way): breathing and the skirt hang from the wrong end of the torso`;
3314
3336
  case 'hip-nearer-neck':
3315
3337
  return `LINT hip at ${pyIntList(f.hip)} is nearer the neck joint ${fmtAt(f.neck)} (${pyFixed(f.dNeck, 1)} px) than the midpoint of the hip joints ${fmtAt(f.hips)} (${pyFixed(f.dHips, 1)} px): a hip at the shoulders`;
3338
+ case 'squeeze':
3339
+ return squeezeLine(f);
3316
3340
  case 'chain-not-advancing':
3317
3341
  return `LINT ${f.point} at ${pyIntList(f.at)} does not advance from ${f.fromJoint} toward ${f.toJoint}: it falls ${pyFixed(f.t, 1)} px along that line and ${f.previous} ${pyFixed(f.tPrevious, 1)} px, and each must fall further than the one before`;
3318
3342
  }
3319
3343
  }
3320
3344
 
3345
+ /**
3346
+ * The squeeze LINT line (issue #314): the pair and the mesh, the worst edge with both swinging and the bone each of
3347
+ * its two vertices rides most, the bar it is held to (check's), each chain swung alone, the tips' distance and the
3348
+ * amplitudes, and the remedies whose figure passes the bar — one sign when the amplitudes are opposed, dropping a
3349
+ * chain when the other alone passes — or, when none does, that the amplitudes are too large for this lattice.
3350
+ */
3351
+ function squeezeLine(f: SqueezeLintFinding): string {
3352
+ const e = f.both.at;
3353
+ const C = TEXTURE_STRETCH_CEILING;
3354
+ const rides = f.vertices.map((v) => (v.weights.length === 0 ? `vertex ${v.vertex} rides no bone` : `vertex ${v.vertex} rides ${v.weights[0].bone} ${v.weights[0].weight}`)).join(', ');
3355
+ const amps = (c: { amps: readonly number[] }): string => `[${c.amps.join(', ')}]`;
3356
+ const remedies: string[] = [];
3357
+ if (f.turned !== null && f.turned <= C) remedies.push(`give both the same sign (then ${pyFixed(f.turned, 3)})`);
3358
+ if (f.aloneA <= C && f.aloneB <= C) remedies.push('drop one chain');
3359
+ else if (f.aloneA <= C) remedies.push(`drop ${f.b.chain}, which alone is past the bar too`);
3360
+ else if (f.aloneB <= C) remedies.push(`drop ${f.a.chain}, which alone is past the bar too`);
3361
+ const said =
3362
+ f.turned !== null
3363
+ ? `their amplitudes are opposed (${f.a.chain} ${amps(f.a)}, ${f.b.chain} ${amps(f.b)}), so their tips swing towards each other and apart`
3364
+ : `their amplitudes are ${f.a.chain} ${amps(f.a)} and ${f.b.chain} ${amps(f.b)}`;
3365
+ const remedy = remedies.length > 0 ? remedies.join(', or ') : 'neither one sign nor either chain alone passes the bar: lower the amplitudes';
3366
+ return (
3367
+ `LINT ${f.a.chain} and ${f.b.chain} on mesh ${pyRepr(f.mesh)} ${e.ratio >= 1 ? 'stretch' : 'squeeze'} it: swung together, every other bone held, ` +
3368
+ `edge ${e.edge.join('-')} of triangle ${e.triangle} ${e.ratio >= 1 ? 'opens' : 'closes'} to ${pyFixed(e.ratio, 3)} times its rest length at idle frame ${e.frame} (max(ratio, 1/ratio) ${pyFixed(f.both.severity, 3)}; ` +
3369
+ `<= ${C} is required, check's TEXTURE_STRETCH bar) — ${rides}; alone, ${f.a.chain} reads ${pyFixed(f.aloneA, 3)} and ${f.b.chain} ${pyFixed(f.aloneB, 3)}. ` +
3370
+ `Their tips are ${pyFixed(f.tipGap, 1)} px apart and ${said}: ${remedy}`
3371
+ );
3372
+ }
3373
+
3321
3374
  export interface Comparison {
3322
3375
  rows: Array<[string, number]>;
3323
3376
  onlyConfig: string[];
package/src/rig.ts CHANGED
@@ -153,13 +153,13 @@ import {
153
153
  } from './automesh.ts';
154
154
  import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
155
155
  import { type LocalInfluence, localInfluences } from './localweights.ts';
156
- import { artCoverage, ART_ALPHA, latticeMesh, ONE_LOOP_PASSES } from './mesh.ts';
156
+ import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
157
157
  import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
158
158
  import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
159
159
  import { EYE_GROUP_TAGS } from './propose.ts';
160
160
  import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
161
161
  import { pyRound } from './round.ts';
162
- import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
162
+ import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
163
163
  import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
164
164
  import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
165
165
 
@@ -650,6 +650,10 @@ export interface LatticeMeshReport {
650
650
  exponent?: number;
651
651
  /** The cap and floor form the mesh declared, as declared (issue #284); absent when it declared none and ran at 4 and the cut. */
652
652
  influences?: MeshInfluences;
653
+ /** The `stray` the mesh declared (issue #303); absent when it declared none. */
654
+ stray?: number;
655
+ /** Only beside a declared `stray` (issue #303): the islands it left out of the lattice, their art px, and the part's art px. */
656
+ stray_cleared?: StrayCleared;
653
657
  /** Only under `rule: "heat"` (issue #161): the rule and the solve it ran. Absent otherwise, so a distance mesh writes the row it always wrote. */
654
658
  heat?: HeatRow;
655
659
  }
@@ -1745,7 +1749,11 @@ export function buildRig(
1745
1749
  meshReport.push(row.report);
1746
1750
  continue;
1747
1751
  }
1748
- const art = alphaAbove(img, ART_ALPHA);
1752
+ const allArt = alphaAbove(img, ART_ALPHA);
1753
+ // issue #303: under a declared `stray`, the islands it leaves out are cleared from the art the lattice and its heat
1754
+ // silhouette are built over; absent, `art` is `allArt` itself and nothing downstream moves.
1755
+ const stray = mesh.stray === undefined ? null : latticeStray(allArt, mesh.stray);
1756
+ const art = stray === null ? allArt : stray.art;
1749
1757
  const lm = latticeMesh(p.name, art, mesh.grid, maxLoopPasses);
1750
1758
  if ('code' in lm) {
1751
1759
  problems.push(lm);
@@ -1755,7 +1763,12 @@ export function buildRig(
1755
1763
  const ox = p.x - PAD;
1756
1764
  const oy = p.y - PAD;
1757
1765
  // issue #161: under rule "heat", the field over the lattice's art (alpha above 8) with its holes filled.
1758
- const heat = mesh.rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, latticeSilhouette(art), segs, ox, oy) : null;
1766
+ // issue #303: an island no bone reaches is worded against the lattice's own `stray` when it is dust.
1767
+ const dust = (): HeatDust => {
1768
+ const read = stray ?? latticeStray(allArt, 0);
1769
+ return { field: `config.meshes.${p.name}.stray`, declared: mesh.stray ?? null, artPixels: read.artPixels, largest: read.largest };
1770
+ };
1771
+ const heat = mesh.rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, latticeSilhouette(art), segs, ox, oy, HEAT_LIMITS, dust) : null;
1759
1772
  if (Array.isArray(heat)) {
1760
1773
  problems.push(...heat);
1761
1774
  continue;
@@ -1803,10 +1816,11 @@ export function buildRig(
1803
1816
  bones: [...new Set(segs.map((s) => s.bone))].sort(),
1804
1817
  max_influences: maxInfl,
1805
1818
  mean_influences: pyRound(infl / lm.vertices.length, 2),
1806
- art_coverage: pyRound(artCoverage(lm, art), 5),
1819
+ art_coverage: pyRound(artCoverage(lm, allArt), 5),
1807
1820
  grid: mesh.grid,
1808
1821
  ...(mesh.exponent === undefined ? {} : { exponent: mesh.exponent }),
1809
1822
  ...(mesh.influences === undefined ? {} : { influences: mesh.influences }),
1823
+ ...(stray === null ? {} : { stray: mesh.stray as number, stray_cleared: { islands: stray.islands, pixels: stray.pixels, art_pixels: stray.artPixels } }),
1810
1824
  ...(heat === null ? {} : { heat: heatRow(heat) }),
1811
1825
  });
1812
1826
  }
package/src/squeeze.ts ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Two chains on one lattice that the idle swings into each other (issue #314).
3
+ *
4
+ * A pair of chains a mesh's `segments` both name is a squeeze when their own
5
+ * swing, every other bone held at the setup pose, closes (or opens) a lattice
6
+ * edge between the art each one carries past the bar `check`'s
7
+ * `TEXTURE_STRETCH` holds every edge to ({@link TEXTURE_STRETCH_CEILING}, on
8
+ * max(ratio, 1/ratio)). Read here from what `propose` and `--from-config` hold —
9
+ * the parts, the bones, the mesh's spec and the idle's chain tracks — so the
10
+ * squeeze is named before anything is built.
11
+ *
12
+ * What is built is the rig stage's own lattice and weights, by the rig stage's
13
+ * own functions: the part's image padded by {@link PAD}, its art (alpha above
14
+ * {@link ART_ALPHA}, less what a declared `stray` leaves out) cut into the
15
+ * lattice by `latticeMesh`, and each vertex weighted by `influences` under the
16
+ * mesh's `r`, `exponent` and `influences`, rounded as the lattice rounds — so
17
+ * the vertex and triangle numbers are the ones `rig.json` and `check` use. Only
18
+ * the lattice mode under the distance rule is read; a contour or automatic mesh,
19
+ * or `rule: "heat"`, is said not read, by name.
20
+ *
21
+ * Each chain's links are posed as the idle's sine states them —
22
+ * `amps[i] * sin(2 pi (t / period - (phase + lag * i)))` degrees on link `i`,
23
+ * `src/motion.ts` — turned down the chain (a link carries every link below it),
24
+ * at the frames `check` renders: `t = f / IDLE_FPS` for every whole frame up to
25
+ * the idle's duration. Every other bone stays where the setup pose has it, so a
26
+ * figure is the pair's own: the head's roll or the breath carry both chains
27
+ * alike and are not in it. A vertex moves as linear blend skinning moves it,
28
+ * each weight times where that bone's motion takes the vertex. The edge ratios
29
+ * are `check`'s own reader on these poses ({@link stretchFigures}).
30
+ *
31
+ * The pair is named when both swinging together pass the bar, with each
32
+ * chain swung alone beside it — so the remedy "drop one chain" carries the
33
+ * figure the other is left with, and a chain past the bar on its own reads as
34
+ * that. With the two chains' amplitudes opposed link for link (every `amps[i]`
35
+ * of one the other sign of the other's), the same reading is taken with the
36
+ * second chain's signs turned, so the remedy "give both the same sign" carries
37
+ * its own figure too. A remedy whose figure is past the bar is not offered.
38
+ */
39
+ import { TEXTURE_STRETCH_CEILING } from './check.ts';
40
+ import type { BoneEntry, MeshSpec, Motion, Point } from './config.ts';
41
+ import { stretchFigures, stretchSeverity, type StretchAt } from './instruments.ts';
42
+ import { edgeLength } from './length.ts';
43
+ import { ART_ALPHA, latticeMesh, latticeStray } from './mesh.ts';
44
+ import { IDLE_FPS, TIME_SLACK } from './motion.ts';
45
+ import { pad } from './raster/composite.ts';
46
+ import { alphaAbove, type Raster } from './raster/types.ts';
47
+ import { latticeShares, PAD, roundShares } from './rig.ts';
48
+ import { DISTANCE_EXPONENT, influences, meshLimits, type Segment } from './weights.ts';
49
+
50
+ /** One chain of the pair as the finding prints it: its name, its `amps` as written, and its tip. */
51
+ export interface SqueezeChain {
52
+ chain: string;
53
+ amps: number[];
54
+ tip: Point;
55
+ }
56
+
57
+ /** The worst edge of one reading: where, its ratio, and max(ratio, 1/ratio). */
58
+ export interface SqueezeEdge {
59
+ at: StretchAt;
60
+ severity: number;
61
+ }
62
+
63
+ /** A pair named: both chains, the mesh, the worst edge with both swinging, and each alone (and turned, when opposed). */
64
+ export interface SqueezeFinding {
65
+ mesh: string;
66
+ a: SqueezeChain;
67
+ b: SqueezeChain;
68
+ /** The two tips' distance at the setup pose, rig px. */
69
+ tipGap: number;
70
+ both: SqueezeEdge;
71
+ /** The two vertices of `both`'s edge, each with its two largest weights (bone, weight as rig.json writes it), and how many it has. */
72
+ vertices: Array<{ vertex: number; weights: Array<{ bone: string; weight: number }>; of: number }>;
73
+ /** The worst edge with the first chain swung alone, and with the second. */
74
+ aloneA: number;
75
+ aloneB: number;
76
+ /** Both opposed link for link: the worst edge with the second chain's signs turned. Null when they are not opposed. */
77
+ turned: number | null;
78
+ }
79
+
80
+ export interface SqueezeResult {
81
+ findings: SqueezeFinding[];
82
+ /** Pairs on one mesh this did not read, and why. */
83
+ unread: string[];
84
+ /** Every pair read: the mesh and the two chains. */
85
+ read: Array<{ mesh: string; a: string; b: string }>;
86
+ }
87
+
88
+ type ChainTrackLike = { chain: string; amps: number[]; period: number; phase: number; lag: number };
89
+
90
+ /** Each frame's time, as `check` renders them: every whole frame at {@link IDLE_FPS} up to the idle's duration. */
91
+ function frameTimes(duration: number): number[] {
92
+ const out: number[] = [];
93
+ for (let f = 0; f / IDLE_FPS <= duration + TIME_SLACK; f++) out.push(f / IDLE_FPS);
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * A chain's posed links at time `t`: per link, its setup origin, its posed origin, and the turn it carries (degrees,
99
+ * the sum of its own and every link above it). Positive is Spine's counter-clockwise (y up), which on the crop's y-down
100
+ * page turns a vector (dx, dy) to (dx cos + dy sin, -dx sin + dy cos).
101
+ */
102
+ function posedLinks(pts: readonly Point[], track: ChainTrackLike, sign: 1 | -1, t: number): Array<{ rest: Point; at: [number, number]; turn: number }> {
103
+ const out: Array<{ rest: Point; at: [number, number]; turn: number }> = [];
104
+ let at: [number, number] = [pts[0][0], pts[0][1]];
105
+ let turn = 0;
106
+ for (let i = 0; i + 1 < pts.length; i++) {
107
+ const amp = i < track.amps.length ? track.amps[i] : 0;
108
+ turn += sign * amp * Math.sin(2 * Math.PI * (t / track.period - (track.phase + track.lag * i)));
109
+ out.push({ rest: pts[i], at, turn });
110
+ const rad = (turn * Math.PI) / 180;
111
+ const dx = pts[i + 1][0] - pts[i][0];
112
+ const dy = pts[i + 1][1] - pts[i][1];
113
+ at = [at[0] + dx * Math.cos(rad) + dy * Math.sin(rad), at[1] - dx * Math.sin(rad) + dy * Math.cos(rad)];
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** Where a bone's motion takes the point `v`: a link turns it about its origin and carries it to the link's posed origin. */
119
+ function moved(link: { rest: Point; at: [number, number]; turn: number }, v: readonly [number, number]): [number, number] {
120
+ const rad = (link.turn * Math.PI) / 180;
121
+ const dx = v[0] - link.rest[0];
122
+ const dy = v[1] - link.rest[1];
123
+ return [link.at[0] + dx * Math.cos(rad) + dy * Math.sin(rad), link.at[1] - dx * Math.sin(rad) + dy * Math.cos(rad)];
124
+ }
125
+
126
+ /**
127
+ * Every pair of chains a lattice mesh's `segments` name, both keyed by a chain track, read as the module doc says.
128
+ * `images` holds each part's PNG by name, its `box` its parts.json box (rig px).
129
+ */
130
+ export function chainSqueezes(
131
+ spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; motion?: Motion },
132
+ parts: ReadonlyMap<string, { x: number; y: number; image: Raster }>,
133
+ ): SqueezeResult {
134
+ const findings: SqueezeFinding[] = [];
135
+ const unread: string[] = [];
136
+ const read: SqueezeResult['read'] = [];
137
+ const motion = spec.motion;
138
+ if (motion === undefined) return { findings, unread, read };
139
+ const tracks = new Map<string, ChainTrackLike>();
140
+ for (const t of motion.tracks) if ('chain' in t) tracks.set(t.chain, t);
141
+ const chains = new Map<string, { points: Point[]; tip: Point; parent: string }>();
142
+ for (const e of spec.bones) if ('chain' in e) chains.set(e.chain, { points: e.points, tip: e.tip, parent: e.parent });
143
+ // Every bone's parent, chain links as <chain><i>, so a bone under a link is carried by it.
144
+ const parent = new Map<string, string>();
145
+ for (const e of spec.bones) {
146
+ if ('chain' in e) e.points.forEach((_, i) => parent.set(`${e.chain}${i}`, i === 0 ? e.parent : `${e.chain}${i - 1}`));
147
+ else parent.set(e.name, e.parent);
148
+ }
149
+ const linkOf = new Map<string, [string, number]>();
150
+ for (const [c, e] of chains) e.points.forEach((_, i) => linkOf.set(`${c}${i}`, [c, i]));
151
+ // The nearest link at or above a bone, among the chains in `moving`.
152
+ const carrier = (bone: string, moving: ReadonlySet<string>): [string, number] | null => {
153
+ const seen = new Set<string>();
154
+ for (let b: string | undefined = bone; b !== undefined && !seen.has(b); b = parent.get(b)) {
155
+ const l = linkOf.get(b);
156
+ if (l !== undefined && moving.has(l[0])) return l;
157
+ seen.add(b);
158
+ }
159
+ return null;
160
+ };
161
+ const times = frameTimes(motion.duration);
162
+
163
+ for (const [mname, m] of Object.entries(spec.meshes)) {
164
+ const keyed = m.segments.filter((s): s is string => typeof s === 'string' && chains.has(s) && tracks.has(s));
165
+ if (keyed.length < 2) continue;
166
+ const pairs: Array<[string, string]> = [];
167
+ for (let i = 0; i < keyed.length; i++) for (let j = i + 1; j < keyed.length; j++) pairs.push([keyed[i], keyed[j]]);
168
+ const names = pairs.map(([a, b]) => `${a} and ${b}`).join(', ');
169
+ if ('contour' in m || 'auto' in m) {
170
+ unread.push(`mesh ${JSON.stringify(mname)} (${names}) is a ${'contour' in m ? 'contour' : 'automatic'} mesh; only a lattice is built here`);
171
+ continue;
172
+ }
173
+ if (m.rule === 'heat') {
174
+ unread.push(`mesh ${JSON.stringify(mname)} (${names}) is weighted by rule "heat"; only the distance rule is read here`);
175
+ continue;
176
+ }
177
+ const part = parts.get(mname);
178
+ if (part === undefined) continue;
179
+ // The rig stage's segments: a chain is its links, each from its origin to the next link or the tip.
180
+ const segs: Segment[] = [];
181
+ let unresolved: string | null = null;
182
+ for (const sp of m.segments) {
183
+ if (typeof sp !== 'string') {
184
+ segs.push({ bone: sp[0], a: [sp[1][0], sp[1][1]], b: [sp[2][0], sp[2][1]] });
185
+ continue;
186
+ }
187
+ const c = chains.get(sp);
188
+ if (c !== undefined) {
189
+ const line = [...c.points, c.tip];
190
+ c.points.forEach((q, i) => segs.push({ bone: `${sp}${i}`, a: [q[0], q[1]], b: [line[i + 1][0], line[i + 1][1]] }));
191
+ continue;
192
+ }
193
+ const single = spec.bones.find((e) => 'name' in e && e.name === sp);
194
+ if (single === undefined || !('name' in single) || single.tip === undefined) {
195
+ unresolved = `its segment "${sp}" is not a chain or a single bone with a tip`;
196
+ break;
197
+ }
198
+ segs.push({ bone: sp, a: [single.at[0], single.at[1]], b: [single.tip[0], single.tip[1]] });
199
+ }
200
+ if (unresolved !== null) {
201
+ unread.push(`mesh ${JSON.stringify(mname)} (${names}): ${unresolved}, so it has no weights to read`);
202
+ continue;
203
+ }
204
+ const img = pad(part.image, PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
205
+ const allArt = alphaAbove(img, ART_ALPHA);
206
+ const art = m.stray === undefined ? allArt : latticeStray(allArt, m.stray).art;
207
+ const lm = latticeMesh(mname, art, m.grid);
208
+ if ('code' in lm) {
209
+ unread.push(`mesh ${JSON.stringify(mname)} (${names}): its lattice is refused (${lm.code}), so there is no triangle to read`);
210
+ continue;
211
+ }
212
+ const ox = part.x - PAD;
213
+ const oy = part.y - PAD;
214
+ const limits = meshLimits(m.influences);
215
+ const rest: Array<[number, number]> = lm.vertices.map(([x, y]) => [x + ox, y + oy]);
216
+ const weights = rest.map((p) => {
217
+ const shares = influences(p, segs, m.r, limits, m.exponent ?? DISTANCE_EXPONENT);
218
+ return shares.length === 0 ? [] : limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares);
219
+ });
220
+ const restFlat = rest.flatMap(([x, y]) => [x, y]);
221
+ const worst = (moving: ReadonlyMap<string, 1 | -1>): SqueezeEdge | null => {
222
+ const set = new Set(moving.keys());
223
+ const frames = times.map((t, index) => {
224
+ const posed = new Map<string, ReturnType<typeof posedLinks>>();
225
+ for (const [c, sign] of moving) {
226
+ const e = chains.get(c) as { points: Point[]; tip: Point };
227
+ posed.set(c, posedLinks([...e.points, e.tip], tracks.get(c) as ChainTrackLike, sign, t));
228
+ }
229
+ const vertices: number[] = [];
230
+ rest.forEach((v, vi) => {
231
+ let x = 0;
232
+ let y = 0;
233
+ for (const { bone, weight } of weights[vi]) {
234
+ const l = carrier(bone, set);
235
+ const to = l === null ? v : moved((posed.get(l[0]) as ReturnType<typeof posedLinks>)[l[1]], v);
236
+ x += weight * to[0];
237
+ y += weight * to[1];
238
+ }
239
+ vertices.push(x, y);
240
+ });
241
+ return { index, attachments: [{ slot: mname, attachment: mname, vertices }] };
242
+ });
243
+ const [fig] = stretchFigures([{ slot: mname, attachment: mname, vertices: restFlat, triangles: lm.triangles }], frames);
244
+ if (fig.max === null || fig.min === null) return null;
245
+ const sMax = stretchSeverity(fig.max.ratio);
246
+ const sMin = stretchSeverity(fig.min.ratio);
247
+ return sMin > sMax ? { at: fig.min, severity: sMin } : { at: fig.max, severity: sMax };
248
+ };
249
+ for (const [a, b] of pairs) {
250
+ read.push({ mesh: mname, a, b });
251
+ const both = worst(new Map([[a, 1], [b, 1]]));
252
+ if (both === null || both.severity <= TEXTURE_STRETCH_CEILING) continue;
253
+ const aloneA = (worst(new Map([[a, 1]])) as SqueezeEdge).severity;
254
+ const aloneB = (worst(new Map([[b, 1]])) as SqueezeEdge).severity;
255
+ const ta = tracks.get(a) as ChainTrackLike;
256
+ const tb = tracks.get(b) as ChainTrackLike;
257
+ const opposed = ta.amps.length === tb.amps.length && ta.amps.every((v, i) => v * tb.amps[i] < 0);
258
+ const ca = chains.get(a) as { tip: Point };
259
+ const cb = chains.get(b) as { tip: Point };
260
+ findings.push({
261
+ mesh: mname,
262
+ a: { chain: a, amps: [...ta.amps], tip: ca.tip },
263
+ b: { chain: b, amps: [...tb.amps], tip: cb.tip },
264
+ tipGap: edgeLength(ca.tip[0] - cb.tip[0], ca.tip[1] - cb.tip[1]),
265
+ both,
266
+ vertices: both.at.edge.map((vertex) => ({ vertex, weights: [...weights[vertex]].sort((p, q) => q.weight - p.weight).slice(0, 2), of: weights[vertex].length })),
267
+ aloneA,
268
+ aloneB,
269
+ turned: opposed ? (worst(new Map<string, 1 | -1>([[a, 1], [b, -1]])) as SqueezeEdge).severity : null,
270
+ });
271
+ }
272
+ }
273
+ return { findings, unread, read };
274
+ }