@rig-c/parts 0.0.0-stage → 1.5.4

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
package/src/propose.ts ADDED
@@ -0,0 +1,2540 @@
1
+ /**
2
+ * The propose stage: bone origins, mesh candidate lists, regions and a default
3
+ * idle, read off the assembled parts — and the overlay a person or an agent
4
+ * corrects them against.
5
+ *
6
+ * ⭐ **Roles come from each part's See-through tag** (`parts.json` `from`),
7
+ * never from the part's name. A plan may call the skirt layer anything; what
8
+ * makes it the skirt is that it came from `bottomwear`. The rules, by tag:
9
+ *
10
+ * - `face` (head run first) -> `hip`/`chest`/`neck`/`head` — with no face
11
+ * part, a box derived from the head run's `front hair`/`back hair` and
12
+ * `neck`, said in a note (issue #76); `eyewhite-r/-l`
13
+ * -> `eye_r`/`eye_l`, the blink's `eyes` group — no eyewhite, no blink, and
14
+ * no eyebrow, no `brows` in it, each said in a note (issue #35); `irides-*`
15
+ * and `eyelash-*` ride their side's eye bone, or `head` with a note when
16
+ * that side has no eyewhite (issue #45); `mouth` -> `mouth` at the centroid of its biggest blob
17
+ * (stray pixels inflate a box); `eyebrow-*` -> `brow_r`/`brow_l` by POSITION
18
+ * left or right of the eye axis, never by tag, because the head run has been
19
+ * observed to swap the two brow tags.
20
+ * - `bottomwear` -> `hip` at its top edge + three skirt chains — unless that
21
+ * hip would sit above `HIP_MIN_FRACTION` of the figure's height (a long
22
+ * robe tagged `bottomwear` starts at the collar): then `hip` is the waist,
23
+ * the narrowest torso row between the shoulder line and the figure's
24
+ * middle, and 0.32 of the figure height when no row narrows enough.
25
+ * - `topwear` -> two outer-robe chains, only when it hangs below the hip line;
26
+ * a side whose link falls off the art is dropped.
27
+ * - `handwear-*` -> two blobs: one sleeve chain per blob, its mesh taking that
28
+ * chain and a chest stub at the shoulder; one blob: both chains and the chest
29
+ * midline, split at the eye axis — unless the blob is clasped hands (at the
30
+ * axis and at most half the shoulders' width), which ride `hip` as a region.
31
+ * - `front hair` -> three fringe chains + one lock chain per strand that hangs
32
+ * 0.3 face heights below the chin.
33
+ * - `back hair` -> one `bun` bone if it ends above the shoulders, else two
34
+ * hanging chains.
35
+ * - `headwear` / `earwear` -> the biggest layer of the tag gets a rigid bone
36
+ * and a pendant chain where the part narrows (dropped if any chain point is
37
+ * off the art); any other layer of the tag rides the head as a region. A
38
+ * layer with hanging strands ({@link Strand}) gets one pendulum chain per
39
+ * strand instead of the single pendant chain, and one note naming every
40
+ * strand and what became of it — a strand left without a chain is never
41
+ * silent (issue #24).
42
+ * - anything no rule claims rides its nearest trunk bone as a region —
43
+ * including every `painting:` patch (`assemble.patches`), which has no tag,
44
+ * so no rule above ever reads it: it is never a mesh or a chain, it moves
45
+ * no bone the rules place, and the note says the bone is the config's to
46
+ * name in `regions.<name>`.
47
+ *
48
+ * What it cannot know is written into `notes` when it guessed, and otherwise
49
+ * left to the person correcting: things hidden inside a layer (a sash tail
50
+ * painted into the skirt), hair that is none of the shapes above, whether an
51
+ * accessory swings.
52
+ *
53
+ * Port of the reference `landmarks.py` (`propose`, `expand`, `lint`,
54
+ * `compare`, `draw`). Every rule, constant and evaluation order is the
55
+ * reference's, including three Python behaviours `src/pyfmt.ts` reproduces
56
+ * (`round` to even, `or` treating 0.0 as missing, negative slice starts).
57
+ * Seven departures, each stated where it happens: a missing face is refused
58
+ * rather than crashing — unless the head run's hair and neck are there, when
59
+ * the face box is derived from them by measured ratios and a note says so
60
+ * (issue #76, {@link faceBoxOf}); the eye regions are keyed by the eyewhite
61
+ * part's own name rather than by the literal `eyewhite_r`; the long-robe hip,
62
+ * the clasped-hands region (issues #22, #23), an eyeless side's irides and
63
+ * lashes on `head` (issue #45) and the face-less fallback, which fire on
64
+ * neither published example — both proposals are byte-identical with and
65
+ * without them; and `lint`'s two torso lines, which the reference did not
66
+ * have. An eighth is opt-in and the reference had nothing like it: external
67
+ * keypoints (`propose --keypoints`, issue #75, `src/keypoints.ts`). Given a
68
+ * person's joints, a joint with a position places `neck`, `hip` (the midpoint
69
+ * of the two hip joints), the chest (its rule, along neck -> hip) and each
70
+ * sleeve chain (shoulder -> elbow, tip at the wrist) as given; a joint without
71
+ * one leaves the bone to the rule above, and a note per joint says which. The
72
+ * eye bones, `head` and the face box are measurements off the parts and are
73
+ * never moved by a joint. `lint` then reads the torso and the sleeves by the
74
+ * relations the joints declare instead of by screen y. Without joints every
75
+ * value, note and LINT line is the one written before the option existed.
76
+ *
77
+ * Issue #86 adds what the proposer and lint looked at, and changes nothing
78
+ * they wrote: {@link proposeWithBasis} records, at each placement, what the
79
+ * bone rests on ({@link BoneBasis}; `basis.json`, `src/diagnostics.ts`), and
80
+ * {@link lint} returns per bone which checks read it or why none did
81
+ * ({@link BoneCoverage}), with the roles `src/structure.ts` derives.
82
+ *
83
+ * Coordinates are rig pixels, y down, origin top-left — the parts' own space.
84
+ */
85
+ import { existsSync } from 'node:fs';
86
+ import { join } from 'node:path';
87
+ import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
88
+ import { type BoneEntry, type ConfigConstraint, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
89
+ import { PartsError, type Problem, refuseIfAny } from './errors.ts';
90
+ import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
91
+ import { PAINTING_RUN, type PartRecord, type PartsFile, readParts } from './parts.ts';
92
+ import { connectedComponents } from './raster/components.ts';
93
+ import { dilate, morphGradient } from './raster/morph.ts';
94
+ import { readPng } from './raster/png.ts';
95
+ import { resize } from './raster/resize.ts';
96
+ import { type Mask, newMask, newRaster, type Raster } from './raster/types.ts';
97
+ import { npMean, npMedian, npPercentile, pyFixed, pyInt, pyIntList, pyRepr, pyRound, pyStrList } from './pyfmt.ts';
98
+ import type { JointState, RigJoint, RigJoints } from './keypoints.ts';
99
+ import { KEYPOINT_NAMES, type KeypointName } from './skeleton.ts';
100
+ import { configRoles, type RoleOf } from './structure.ts';
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // the output shape — config-shaped, key order as the reference writes it
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export interface ProposedSingleTrack {
107
+ bone: string;
108
+ prop: 'rotate' | 'translatey' | 'scalex';
109
+ amp: number;
110
+ period: number;
111
+ phase: number;
112
+ base?: number;
113
+ }
114
+
115
+ export interface ProposedChainTrack {
116
+ chain: string;
117
+ amps: number[];
118
+ period: number;
119
+ phase: number;
120
+ lag: number;
121
+ }
122
+
123
+ /** `brows` and `brow_drop` are absent together when no eyebrow part exists: rigc refuses a group with no members. */
124
+ export interface ProposedBlink {
125
+ t: number;
126
+ eyes: string[];
127
+ brows?: string[];
128
+ squash: number;
129
+ brow_drop?: number;
130
+ still?: Record<string, { row: number; bone: string }>;
131
+ }
132
+
133
+ /** The tags whose parts make the eye bones, the blink's `eyes` group. Nothing else does: irides and lashes ride those bones as regions, or `head` on a side with none. */
134
+ export const EYE_GROUP_TAGS: readonly string[] = ['eyewhite-r', 'eyewhite-l'];
135
+ /** The tags whose parts make the brow bones, the blink's `brows` group. */
136
+ export const BROW_GROUP_TAGS: readonly string[] = ['eyebrow-r', 'eyebrow-l'];
137
+
138
+ export interface Proposal {
139
+ bones: BoneEntry[];
140
+ meshes: Record<string, MeshSpec>;
141
+ regions: Record<string, string>;
142
+ motion: {
143
+ duration: number;
144
+ tracks: Array<ProposedSingleTrack | ProposedChainTrack>;
145
+ /** Absent when no part feeds the `eyes` group ({@link EYE_GROUP_TAGS}): a blink with nothing to blink is not written. */
146
+ blink?: ProposedBlink;
147
+ };
148
+ notes: string[];
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // what each proposed bone rests on (issue #86)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * What a placement rests on, in four kinds:
157
+ *
158
+ * - `joint`: a coordinate supplied from outside — a keypoint file's joint
159
+ * (`--keypoints`), recorded with its state, its `listed` flag and its score
160
+ * exactly as the file declared them, and its position in rig px through the
161
+ * overlay's map;
162
+ * - `mask`: a measurement off a part's opaque pixels or its box (which part,
163
+ * which measure);
164
+ * - `ratio`: a rule of proportion — a share of the face box, of the figure's
165
+ * height or of a part's — with each constant that sets the value;
166
+ * - `derived`: computed from other bones' positions alone (which bones).
167
+ *
168
+ * A placement that also reads a constant or another bone names those too; the
169
+ * kind is what the value is read off. No confidence is computed for any of
170
+ * them, and no kind is a judgement of quality.
171
+ */
172
+ export type BasisKind = 'joint' | 'mask' | 'ratio' | 'derived';
173
+
174
+ export const BASIS_KINDS: readonly BasisKind[] = ['joint', 'mask', 'ratio', 'derived'];
175
+
176
+ /** The frame quantities several rules read, described once in the basis file's `frame`. */
177
+ export type FrameKey = 'face_box' | 'eye_axis' | 'eye_line' | 'figure' | 'torso';
178
+
179
+ export const FRAME_KEYS: readonly FrameKey[] = ['face_box', 'eye_axis', 'eye_line', 'figure', 'torso'];
180
+
181
+ /** A joint a placement used, as the keypoint file declared it; `at` is rig px (the overlay's map applied). */
182
+ export interface BasisJoint {
183
+ name: KeypointName;
184
+ state: JointState;
185
+ listed: boolean;
186
+ /** The producer's score, when the file carried one; `null` when it did not. Never computed here. */
187
+ score: number | null;
188
+ at: [number, number];
189
+ }
190
+
191
+ export interface BasisConstant {
192
+ name: string;
193
+ value: number;
194
+ }
195
+
196
+ /** One coordinate pair's basis: an origin's, or a tip's. Key order is the file's. */
197
+ export interface Placement {
198
+ kind: BasisKind;
199
+ rule: string;
200
+ parts: string[];
201
+ joints: BasisJoint[];
202
+ bones: string[];
203
+ frame: FrameKey[];
204
+ constants: BasisConstant[];
205
+ /** The stand-in a rule took because what it reads was empty, or `null` when none was taken. */
206
+ fallback: string | null;
207
+ }
208
+
209
+ /** One proposed bone (a chain link is a bone): what its origin rests on and, where the proposal states its tip, what that rests on. */
210
+ export interface BoneBasis {
211
+ bone: string;
212
+ /** The bone entry it came from: its own name, or the chain it is a link of. */
213
+ entry: string;
214
+ origin: Placement;
215
+ /** `null` when the proposal states no tip of its own for this bone: a single bone without `tip`, or a chain link that is not the last (its tip is the next link's origin). */
216
+ tip: Placement | null;
217
+ }
218
+
219
+ /** How a frame quantity was reached, and off which parts. */
220
+ export interface FrameBasis {
221
+ rule: string;
222
+ parts: string[];
223
+ }
224
+
225
+ /** What `propose` records beside the proposal: the frame quantities and one record per proposed bone, in the proposal's bone order. */
226
+ export interface ProposalBasis {
227
+ frame: Record<FrameKey, FrameBasis>;
228
+ bones: BoneBasis[];
229
+ }
230
+
231
+ function placement(kind: BasisKind, rule: string, o: Partial<Omit<Placement, 'kind' | 'rule'>> = {}): Placement {
232
+ return { kind, rule, parts: o.parts ?? [], joints: o.joints ?? [], bones: o.bones ?? [], frame: o.frame ?? [], constants: o.constants ?? [], fallback: o.fallback ?? null };
233
+ }
234
+
235
+ function konst(name: string, value: number): BasisConstant {
236
+ return { name, value };
237
+ }
238
+
239
+ /**
240
+ * Hold the proposal to the config contract before it is written: it is copied
241
+ * into a config field by field, so a proposal the loader would refuse is a
242
+ * wrong file waiting to be pasted. The plan the loader needs is the parts
243
+ * themselves — `[name, run, tag]` from each part's `from` — so every part must
244
+ * come out as exactly one mesh or one region, every name must resolve, and
245
+ * every chain track must carry one amplitude per link. Throws the loader's own
246
+ * refusals.
247
+ */
248
+ export function checkProposal(P: PartSet, p: Proposal): void {
249
+ // A patch is not a plan entry; it goes back as the patches entry it came
250
+ // from, so the loader holds it to its own rule (a region, never a mesh).
251
+ // Its box, alpha and draw are the record's own box and placeholders the
252
+ // loader only type-checks: nothing here is written anywhere.
253
+ // A part whose `from` an earlier part already has is the piece of an
254
+ // `assemble.cuts` entry (issue #170) — the only way two parts share a layer
255
+ // — and goes back as a cut of that earlier part, its polygon the record's
256
+ // box, a placeholder the loader only type-checks like the patches' fields.
257
+ const run = P.recs.filter((r) => splitFrom(r.from)[0] !== PAINTING_RUN);
258
+ const painted = P.recs.filter((r) => splitFrom(r.from)[0] === PAINTING_RUN);
259
+ const firstOf = new Map<string, string>();
260
+ for (const r of run) if (!firstOf.has(r.from)) firstOf.set(r.from, r.name);
261
+ const planned = run.filter((r) => firstOf.get(r.from) === r.name);
262
+ const pieces = run.filter((r) => firstOf.get(r.from) !== r.name);
263
+ parseConfig({
264
+ key: 'proposal',
265
+ assemble: {
266
+ rig_scale: 1,
267
+ plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
268
+ patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
269
+ ...(pieces.length === 0
270
+ ? {}
271
+ : {
272
+ cuts: pieces.map((r) => ({
273
+ from: firstOf.get(r.from) as string,
274
+ into: r.name,
275
+ polygon: [
276
+ [r.x, r.y],
277
+ [r.x + r.w, r.y],
278
+ [r.x + r.w, r.y + r.h],
279
+ ],
280
+ draw: 'front',
281
+ overlap: 0,
282
+ })),
283
+ }),
284
+ },
285
+ bones: p.bones,
286
+ meshes: p.meshes,
287
+ regions: p.regions,
288
+ motion: p.motion,
289
+ });
290
+ }
291
+
292
+ /** Fixed key order, two-space indent, trailing newline: the same inputs write the same bytes. */
293
+ export function serializeProposal(p: Proposal): string {
294
+ return `${JSON.stringify(p, null, 2)}\n`;
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // the parts, as masks
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /** The assembled parts: `parts.json` plus `parts/<name>.png` beside it, every part as a full-canvas mask. */
302
+ export class PartSet {
303
+ readonly W: number;
304
+ readonly H: number;
305
+ readonly recs: readonly PartRecord[];
306
+ readonly images: ReadonlyMap<string, Raster>;
307
+ private readonly masks = new Map<string, Mask>();
308
+
309
+ constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
310
+ [this.W, this.H] = file.rig_size;
311
+ this.recs = file.parts;
312
+ this.images = images;
313
+ }
314
+
315
+ /** Parts whose `from` tag is exactly `tag`, optionally from one run, in parts.json order. A `painting:` patch has no tag and is never one. */
316
+ byTag(tag: string, run?: 'full' | 'head'): PartRecord[] {
317
+ return this.recs.filter((p) => {
318
+ const [r, t] = splitFrom(p.from);
319
+ return r !== PAINTING_RUN && t === tag && (run === undefined || r === run);
320
+ });
321
+ }
322
+
323
+ /** The See-through parts: every part but the `painting:` patches. */
324
+ layered(): PartRecord[] {
325
+ return this.recs.filter((p) => splitFrom(p.from)[0] !== PAINTING_RUN);
326
+ }
327
+
328
+ /** `byTag(tag, 'head') or byTag(tag)`: the head run's layer when it has one. */
329
+ headFirst(tag: string): PartRecord[] {
330
+ const head = this.byTag(tag, 'head');
331
+ return head.length > 0 ? head : this.byTag(tag);
332
+ }
333
+
334
+ /** Full-canvas mask of a part: its PNG's alpha above 8, placed at its box. */
335
+ alpha(p: PartRecord): Mask {
336
+ const cached = this.masks.get(p.name);
337
+ if (cached !== undefined) return cached;
338
+ const im = this.images.get(p.name) as Raster;
339
+ const m = newMask(this.W, this.H);
340
+ for (let y = 0; y < p.h; y++) {
341
+ for (let x = 0; x < p.w; x++) m.data[(p.y + y) * this.W + p.x + x] = im.data[(y * p.w + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
342
+ }
343
+ this.masks.set(p.name, m);
344
+ return m;
345
+ }
346
+ }
347
+
348
+ function splitFrom(from: string): [string, string] {
349
+ const i = from.indexOf(':');
350
+ return [from.slice(0, i), from.slice(i + 1)];
351
+ }
352
+
353
+ /**
354
+ * Read `parts.json` and every PNG it names. A PNG that is absent, or whose size
355
+ * is not its record's box, is refused by name — the reference would have
356
+ * crashed on the second and read garbage on neither.
357
+ */
358
+ export function readPartSet(dir: string): PartSet {
359
+ const file = readParts(join(dir, 'parts.json'));
360
+ const problems: Problem[] = [];
361
+ const images = new Map<string, Raster>();
362
+ for (const p of file.parts) {
363
+ const png = join(dir, 'parts', `${p.name}.png`);
364
+ if (!existsSync(png)) {
365
+ problems.push({ code: 'PROPOSE_PNG_PRESENT', object: `part "${p.name}"`, detail: `${png} does not exist; parts.json names it` });
366
+ continue;
367
+ }
368
+ const im = readPng(png);
369
+ if (im.width !== p.w || im.height !== p.h) {
370
+ problems.push({ code: 'PROPOSE_PNG_MATCHES_BOX', object: `part "${p.name}"`, detail: `parts/${p.name}.png is ${im.width}x${im.height}; its parts.json box is ${p.w}x${p.h}` });
371
+ continue;
372
+ }
373
+ images.set(p.name, im);
374
+ }
375
+ refuseIfAny(problems);
376
+ return new PartSet(file, images);
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // mask measurements, in numpy's slicing semantics
381
+ // ---------------------------------------------------------------------------
382
+
383
+ /** Python's `a[start:stop]` bounds over a length, negative indices included. */
384
+ function pySlice(start: number, stop: number, len: number): [number, number] {
385
+ const norm = (i: number): number => (i < 0 ? Math.max(0, len + i) : Math.min(i, len));
386
+ const s = norm(start);
387
+ const e = norm(stop);
388
+ return [s, Math.max(s, e)];
389
+ }
390
+
391
+ /** Columns (sorted) holding a set pixel in rows `mask[r0:r1]`. */
392
+ function colsAny(m: Mask, r0: number, r1: number): number[] {
393
+ const [a, b] = pySlice(r0, r1, m.height);
394
+ const hit = new Uint8Array(m.width);
395
+ for (let y = a; y < b; y++) for (let x = 0; x < m.width; x++) if (m.data[y * m.width + x]) hit[x] = 1;
396
+ const out: number[] = [];
397
+ for (let x = 0; x < m.width; x++) if (hit[x]) out.push(x);
398
+ return out;
399
+ }
400
+
401
+ /** Rows (sorted) holding a set pixel. */
402
+ function rowsAny(m: Mask): number[] {
403
+ const out: number[] = [];
404
+ for (let y = 0; y < m.height; y++) {
405
+ for (let x = 0; x < m.width; x++) {
406
+ if (m.data[y * m.width + x]) {
407
+ out.push(y);
408
+ break;
409
+ }
410
+ }
411
+ }
412
+ return out;
413
+ }
414
+
415
+ function sideOf(xs: number[], side: 'r' | 'l', axis: number): number[] {
416
+ return side === 'r' ? xs.filter((x) => x < axis) : xs.filter((x) => x >= axis);
417
+ }
418
+
419
+ /**
420
+ * The x of the mask in a horizontal band at y (`half` rows each way): the
421
+ * column-count centroid, or `frac` of the way between the band's edges. `null`
422
+ * when the band is empty — the reference's `None`.
423
+ */
424
+ function bandX(m: Mask, y: number, opts: { half?: number; frac?: number; side?: 'r' | 'l'; axis?: number } = {}): number | null {
425
+ const half = opts.half ?? 12;
426
+ const y0 = Math.max(0, pyInt(y - half));
427
+ const y1 = pyInt(y + half);
428
+ const [a, b] = pySlice(y0, y1, m.height);
429
+ const counts = new Float64Array(m.width);
430
+ for (let yy = a; yy < b; yy++) for (let x = 0; x < m.width; x++) counts[x] += m.data[yy * m.width + x];
431
+ let xs: number[] = [];
432
+ for (let x = 0; x < m.width; x++) if (counts[x] > 0) xs.push(x);
433
+ if (opts.side !== undefined) xs = sideOf(xs, opts.side, opts.axis as number);
434
+ if (xs.length === 0) return null;
435
+ if (opts.frac === undefined) {
436
+ let num = 0;
437
+ let den = 0;
438
+ for (const x of xs) {
439
+ num += x * counts[x];
440
+ den += counts[x];
441
+ }
442
+ return num / den;
443
+ }
444
+ return xs[0] + opts.frac * (xs[xs.length - 1] - xs[0]);
445
+ }
446
+
447
+ /** Python's `v or fallback` on a float that may be `None`: 0.0 is falsy there, and so it is here. */
448
+ function or(v: number | null, fallback: number): number {
449
+ return v === null || v === 0 ? fallback : v;
450
+ }
451
+
452
+ function center(p: PartRecord): [number, number] {
453
+ return [p.x + p.w / 2, p.y + p.h / 2];
454
+ }
455
+
456
+ function rnd(p: readonly [number, number]): Point {
457
+ return [pyRound(p[0]), pyRound(p[1])];
458
+ }
459
+
460
+ function isOn(m: Mask, x: number, y: number): boolean {
461
+ const xi = pyInt(x);
462
+ const yi = pyInt(y);
463
+ return yi >= 0 && yi < m.height && xi >= 0 && xi < m.width && m.data[yi * m.width + xi] === 1;
464
+ }
465
+
466
+ function union(a: Mask, b: Mask): Mask {
467
+ const out = newMask(a.width, a.height);
468
+ for (let i = 0; i < out.data.length; i++) out.data[i] = a.data[i] | b.data[i];
469
+ return out;
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // the proposer
474
+ // ---------------------------------------------------------------------------
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // the torso, read off the figure
478
+ // ---------------------------------------------------------------------------
479
+
480
+ /**
481
+ * The hip may not sit above this fraction of the figure's height, measured
482
+ * from its top. Below it the proposer takes `bottomwear`'s top edge as the
483
+ * waist; above it that edge is the collar of a long robe and the waist is read
484
+ * off the silhouette. `lint` holds any config to the same line. The published
485
+ * examples' hips sit at 0.436 (`demo`) and 0.342 (`sample`) of their figures;
486
+ * a figure four heads tall has its whole head in the top quarter.
487
+ */
488
+ export const HIP_MIN_FRACTION = 0.25;
489
+
490
+ /** A row is a waist when the torso there is at most this fraction of the shoulders' width (the examples' torsos narrow to 0.648 of their shoulders in `demo` and 0.710 in `sample`). */
491
+ export const WAIST_MAX_OF_SHOULDERS = 0.8;
492
+
493
+ /**
494
+ * One handwear blob is clasped hands, not two sleeves, when it is at most this
495
+ * fraction of the shoulders' width: two sleeves that hang from the shoulders
496
+ * are at least as wide as them (the examples' one-blob sleeves are 1.506 and
497
+ * 1.580 shoulder widths).
498
+ */
499
+ export const CLASPED_MAX_OF_SHOULDERS = 0.5;
500
+
501
+ /** ... and when its centre is within this fraction of the shoulders' width of the eye axis. */
502
+ export const CLASPED_AXIS_OF_SHOULDERS = 0.25;
503
+
504
+ /**
505
+ * The tags whose layers make the torso's silhouette: the body without hair,
506
+ * hands, head parts, tail, wings or held objects — the layers whose width at
507
+ * a row is the body's width there.
508
+ */
509
+ export const TORSO_TAGS = ['neck', 'neckwear', 'topwear', 'bottomwear', 'legwear', 'footwear'] as const;
510
+
511
+ /** The first and last rows holding any part's pixel. */
512
+ function figureExtent(P: PartSet): { top: number; bot: number } {
513
+ // The layers' figure: a patch adds none of its own, so adding one moves no bone.
514
+ let fig = newMask(P.W, P.H);
515
+ for (const p of P.layered()) fig = union(fig, P.alpha(p));
516
+ const ys = rowsAny(fig);
517
+ return { top: ys[0], bot: ys[ys.length - 1] };
518
+ }
519
+
520
+ /** Per row, the leftmost and rightmost torso pixel (-1 on an empty row). */
521
+ function torsoRows(P: PartSet): { lo: Int32Array; hi: Int32Array } {
522
+ const lo = new Int32Array(P.H).fill(-1);
523
+ const hi = new Int32Array(P.H).fill(-1);
524
+ for (const p of P.layered()) {
525
+ if (!(TORSO_TAGS as readonly string[]).includes(splitFrom(p.from)[1])) continue;
526
+ const m = P.alpha(p);
527
+ for (let y = p.y; y < p.y + p.h; y++) {
528
+ for (let x = p.x; x < p.x + p.w; x++) {
529
+ if (!m.data[y * P.W + x]) continue;
530
+ if (lo[y] < 0 || x < lo[y]) lo[y] = x;
531
+ if (x > hi[y]) hi[y] = x;
532
+ }
533
+ }
534
+ }
535
+ return { lo, hi };
536
+ }
537
+
538
+ interface TorsoRow {
539
+ y: number;
540
+ width: number;
541
+ cx: number;
542
+ }
543
+
544
+ function rowAt(t: { lo: Int32Array; hi: Int32Array }, y: number): TorsoRow | null {
545
+ return t.lo[y] < 0 ? null : { y, width: t.hi[y] - t.lo[y] + 1, cx: (t.lo[y] + t.hi[y]) / 2 };
546
+ }
547
+
548
+ /** The widest torso row within one face height below the neck (the first, on a tie): the shoulder line. */
549
+ function shoulderOf(t: { lo: Int32Array; hi: Int32Array }, neckY: number, fh: number): TorsoRow | null {
550
+ let best: TorsoRow | null = null;
551
+ for (let y = Math.max(0, Math.ceil(neckY)); y <= Math.min(t.lo.length - 1, Math.floor(neckY + fh)); y++) {
552
+ const r = rowAt(t, y);
553
+ if (r !== null && (best === null || r.width > best.width)) best = r;
554
+ }
555
+ return best;
556
+ }
557
+
558
+ /**
559
+ * The narrowest torso row from the shoulder line down to `bandEnd` (the
560
+ * figure's middle: below it legs are narrower than any waist — the sample's
561
+ * legs are 89 px wide at y 1046, its waist 125 px at y 435), when it is a waist at all.
562
+ */
563
+ function waistOf(t: { lo: Int32Array; hi: Int32Array }, shoulders: TorsoRow, bandEnd: number): TorsoRow | null {
564
+ let best: TorsoRow | null = null;
565
+ for (let y = shoulders.y; y <= Math.min(t.lo.length - 1, Math.floor(bandEnd)); y++) {
566
+ const r = rowAt(t, y);
567
+ if (r !== null && (best === null || r.width < best.width)) best = r;
568
+ }
569
+ return best !== null && best.width <= WAIST_MAX_OF_SHOULDERS * shoulders.width ? best : null;
570
+ }
571
+
572
+ /**
573
+ * The note saying the handwear is clasped hands, or `null` when it is not:
574
+ * every handwear part together is ONE connected component over 500 px (the
575
+ * floor the sleeve rule applies to a part), at most
576
+ * `CLASPED_MAX_OF_SHOULDERS` of the shoulder width wide, centred within
577
+ * `CLASPED_AXIS_OF_SHOULDERS` of it of the eye axis. No shoulder line
578
+ * measured -> `null`, and the sleeve rule runs as before.
579
+ */
580
+ function claspedOf(P: PartSet, hw: readonly PartRecord[], shoulders: TorsoRow | null, axis: number): string | null {
581
+ if (shoulders === null) return null;
582
+ let mask = newMask(P.W, P.H);
583
+ for (const p of hw) mask = union(mask, P.alpha(p));
584
+ const cc = connectedComponents(mask, 8);
585
+ const blobs = cc.stats.slice(1).filter((st) => st.area > 500);
586
+ if (blobs.length !== 1) return null;
587
+ const b = blobs[0];
588
+ const cx = b.left + b.width / 2;
589
+ if (b.width > CLASPED_MAX_OF_SHOULDERS * shoulders.width || Math.abs(cx - axis) > CLASPED_AXIS_OF_SHOULDERS * shoulders.width) return null;
590
+ return (
591
+ `handwear is one blob (${hw.map((p) => p.name).join(', ')}) ${b.width}x${b.height} px centred at x=${pyFixed(cx, 0)}: ` +
592
+ `at most ${CLASPED_MAX_OF_SHOULDERS} of the shoulder width (${shoulders.width} px at y=${shoulders.y}) and within ${CLASPED_AXIS_OF_SHOULDERS} of it of the eye axis x=${pyFixed(axis, 0)}, ` +
593
+ 'so clasped hands: a region on hip, no sleeve chains (sleeves painted inside another layer move with it)'
594
+ );
595
+ }
596
+
597
+ /** The region role of each tag that has one. Brows are overridden by position below. */
598
+ const TAG_REGION: Readonly<Record<string, string>> = {
599
+ face: 'head',
600
+ 'ears-r': 'head',
601
+ 'ears-l': 'head',
602
+ mouth: 'mouth',
603
+ nose: 'head',
604
+ 'irides-r': 'eye_r',
605
+ 'irides-l': 'eye_l',
606
+ 'eyelash-r': 'eye_r',
607
+ 'eyelash-l': 'eye_l',
608
+ 'eyewhite-r': 'eye_r',
609
+ 'eyewhite-l': 'eye_l',
610
+ 'eyebrow-r': 'brow_r',
611
+ 'eyebrow-l': 'brow_l',
612
+ footwear: 'root',
613
+ };
614
+
615
+ /**
616
+ * A hanging strand: a connected sub-shape (8-connected) of an accessory's mask,
617
+ * searched below the part's body — in its pendant rows, the run of rows at the
618
+ * bottom narrower than {@link PENDANT_NARROW} of the widest row — or over the
619
+ * whole part when the part is all pendant. It is a strand when it is at least
620
+ * {@link STRAND_ASPECT} times as tall as it is wide, at least
621
+ * {@link STRAND_SHARE} of the part's height, and at least
622
+ * {@link PENDANT_MIN_ROWS} rows tall.
623
+ *
624
+ * Measured on the public examples' two accessories (the sub-shapes this
625
+ * definition reads, height/width): `demo` `hairpin` (head:headwear, 133 rows)
626
+ * has two in its pendant rows, 22/41 = 0.54 and 41/46 = 0.89 (the bows' tips,
627
+ * the second carrying the star dangle); `demo` `earring` (full:earwear, all
628
+ * pendant, 29 rows) has two, 26/25 = 1.04 and 28/26 = 1.08. None reaches 3,
629
+ * so neither proposal changes; `sample` has no headwear or earwear.
630
+ */
631
+ export interface Strand {
632
+ /** Column centroid, rig px. */
633
+ x: number;
634
+ /** First and last row, rig px, inclusive. */
635
+ top: number;
636
+ bottom: number;
637
+ /** Bounding-box width, px. */
638
+ width: number;
639
+ /** The strand alone, full canvas. */
640
+ mask: Mask;
641
+ }
642
+
643
+ /** Pendant rows are the rows at the bottom narrower than this share of the widest row — the reference's 35 %. */
644
+ export const PENDANT_NARROW = 0.35;
645
+ /** A pendant (or strand) shorter than this many rows gets no chain — the reference's tassel floor. */
646
+ export const PENDANT_MIN_ROWS = 10;
647
+ /** A strand is at least this many times as tall as it is wide. */
648
+ export const STRAND_ASPECT = 3;
649
+ /** A strand is at least this share of its part's height. */
650
+ export const STRAND_SHARE = 0.2;
651
+ /** A pendant chain's second link sits this far down it — the reference's tassel. */
652
+ export const STRAND_LINK_AT = 0.45;
653
+ /** A pendant chain's two link amplitudes, degrees — the reference's tassel, which every strand chain takes. */
654
+ export const STRAND_AMPS: readonly [number, number] = [4.0, 7.0];
655
+
656
+ /** An accessory's opaque pixels per row, and where its pendant rows start (`j`, part rows) and how many there are. */
657
+ function pendantRows(P: PartSet, p: PartRecord): { rows: number[]; j: number; pendH: number } {
658
+ const im = P.images.get(p.name) as Raster;
659
+ const rows: number[] = [];
660
+ for (let y = 0; y < p.h; y++) {
661
+ let n = 0;
662
+ for (let x = 0; x < p.w; x++) n += im.data[(y * p.w + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
663
+ rows.push(n);
664
+ }
665
+ const wmax = Math.max(...rows);
666
+ // The pendant: the longest run of rows at the bottom whose width is under 35 % of the widest row.
667
+ const narrow = rows.map((n) => n < PENDANT_NARROW * wmax);
668
+ let j = rows.length - 1;
669
+ while (j > 0 && narrow[j - 1] && rows[j - 1] > 0) j--;
670
+ return { rows, j, pendH: rows.length - j };
671
+ }
672
+
673
+ /** The strands of `p` in its rows from `fromRow` down, left to right. See {@link Strand}. */
674
+ export function hangingStrands(P: PartSet, p: PartRecord, fromRow: number): Strand[] {
675
+ const im = P.images.get(p.name) as Raster;
676
+ const local = newMask(p.w, p.h);
677
+ for (let y = fromRow; y < p.h; y++) for (let x = 0; x < p.w; x++) local.data[y * p.w + x] = im.data[(y * p.w + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
678
+ const cc = connectedComponents(local, 8);
679
+ const out: Strand[] = [];
680
+ for (let i = 1; i < cc.count; i++) {
681
+ const st = cc.stats[i];
682
+ if (st.height < STRAND_ASPECT * st.width || st.height < STRAND_SHARE * p.h || st.height < PENDANT_MIN_ROWS) continue;
683
+ const mask = newMask(P.W, P.H);
684
+ for (let y = st.top; y < st.top + st.height; y++) {
685
+ for (let x = st.left; x < st.left + st.width; x++) if (cc.labels[y * p.w + x] === i) mask.data[(p.y + y) * P.W + p.x + x] = 1;
686
+ }
687
+ out.push({ x: p.x + st.cx, top: p.y + st.top, bottom: p.y + st.top + st.height - 1, width: st.width, mask });
688
+ }
689
+ // Stable: two strands at one x keep their scan order.
690
+ return out.sort((a, b) => a.x - b.x);
691
+ }
692
+
693
+ /** The one note a part with strands gets: every strand's figures, and which chain each got or why none. */
694
+ function strandNote(p: PartRecord, strands: readonly Strand[], chained: readonly string[], dropped: readonly Strand[], why: string): string {
695
+ const n = strands.length;
696
+ const at = `x=${strands.map((s) => pyFixed(s.x, 0)).join(',')}, y ${strands.map((s) => `${s.top}-${s.bottom}`).join(',')}, width ${strands.map((s) => s.width).join(',')}`;
697
+ const head = `${p.name} (${p.from}): ${n} hanging strand${n === 1 ? '' : 's'} at ${at}`;
698
+ if (chained.length === 0) return `${head} -- no chain proposed (${why})`;
699
+ const made = `pendulum chain${chained.length === 1 ? '' : 's'} ${chained.join(', ')}`;
700
+ if (dropped.length === 0) return `${head} -> ${made}`;
701
+ return `${head} -> ${made}; no chain proposed at x=${dropped.map((s) => pyFixed(s.x, 0)).join(',')} (${why})`;
702
+ }
703
+
704
+ // ---------------------------------------------------------------------------
705
+ // the face box, or one derived from the head run when no face part exists (issue #76)
706
+ // ---------------------------------------------------------------------------
707
+
708
+ /**
709
+ * The ratios the face-less fallback derives a face box with. `L`, the span,
710
+ * runs from the top of the head run's hair ({@link FACELESS_HAIR_TAGS}, the
711
+ * highest of them) to the top of its `neck`. Measured on the two public
712
+ * examples' assembled parts (`examples/<key>/expected/parts.json`; demo: hair
713
+ * top y 67, neck top y 191, face 361,80 109x133; sample: hair top y 80, neck
714
+ * top y 194, face 368,98 94x121) — a sample of two; the mean of the two, to
715
+ * three places, is used:
716
+ *
717
+ * - `heightOfSpan` — face height / L: demo 133/124 = 1.073, sample
718
+ * 121/114 = 1.061.
719
+ * - `topOfSpan` — (face top − hair top) / L: demo 13/124 = 0.105, sample
720
+ * 18/114 = 0.158.
721
+ * - `widthOfHeight` — face width / face height: demo 109/133 = 0.820,
722
+ * sample 94/121 = 0.777.
723
+ *
724
+ * The box is centred on the neck part's centre x (demo 417 against the face's
725
+ * 415.5, sample 411 against 415). Rejected for the top: the union of every
726
+ * head-run part, which the demo's headwear lifts to y 20 — its face height /
727
+ * span is 0.778 there against the sample's 1.061.
728
+ */
729
+ export interface FacelessRatios {
730
+ heightOfSpan: number;
731
+ topOfSpan: number;
732
+ widthOfHeight: number;
733
+ }
734
+
735
+ /** The measured means; see {@link FacelessRatios}. */
736
+ export const FACELESS_RATIOS: Readonly<FacelessRatios> = { heightOfSpan: 1.067, topOfSpan: 0.131, widthOfHeight: 0.798 };
737
+
738
+ /** The head-run tags the face-less fallback reads the top of the head from. */
739
+ export const FACELESS_HAIR_TAGS: readonly string[] = ['front hair', 'back hair'];
740
+
741
+ /** The rules a face's features feed, by the tags each reads: the fallback note names each whose tags are all absent. */
742
+ const FACE_FEATURE_RULES: ReadonlyArray<readonly [readonly string[], string]> = [
743
+ [EYE_GROUP_TAGS, 'the eye bones, the eye axis, the blink and its still pieces'],
744
+ [BROW_GROUP_TAGS, 'the brow bones'],
745
+ [['mouth'], 'the mouth bone'],
746
+ ];
747
+
748
+ /** A face box `[x0, y0, w, h]` in rig px, and the note saying how it was derived (`null` when it is the face part's own box). */
749
+ export interface FaceBox {
750
+ box: [number, number, number, number];
751
+ note: string | null;
752
+ /** The parts the box was read off: the face part, or the hair and neck it was derived from. */
753
+ parts: string[];
754
+ }
755
+
756
+ /**
757
+ * The face box every rule scales by: the head run's `face` part (the full
758
+ * run's when the head run has none), as the reference read it; or, with no
759
+ * face part at all, one derived from the head run's hair and neck by
760
+ * `ratios` ({@link FACELESS_RATIOS}), with a note naming what it read, what
761
+ * it derived and which face-reading rules have nothing to read. Refuses
762
+ * `PROPOSE_FACE_PRESENT` when there is neither a face part nor what the
763
+ * fallback reads — never a box from nothing.
764
+ */
765
+ export function faceBoxOf(P: PartSet, ratios: Readonly<FacelessRatios> = FACELESS_RATIOS): FaceBox {
766
+ const faces = P.headFirst('face');
767
+ if (faces.length > 0) return { box: [faces[0].x, faces[0].y, faces[0].w, faces[0].h], note: null, parts: [faces[0].name] };
768
+ const hair = P.layered().filter((p) => splitFrom(p.from)[0] === 'head' && FACELESS_HAIR_TAGS.includes(splitFrom(p.from)[1]));
769
+ const neck = P.byTag('neck', 'head');
770
+ const refuse = (why: string): never => {
771
+ const tags = [...new Set(P.recs.map((p) => p.from))].join(', ');
772
+ throw new PartsError([
773
+ {
774
+ code: 'PROPOSE_FACE_PRESENT',
775
+ object: 'parts.json',
776
+ detail:
777
+ `holds no part from a "face" layer (found: ${tags}); head, neck, the eye axis and the face height every other rule scales by all come from it, ` +
778
+ `and the face-less fallback, which derives a face box from the head run's hair (${FACELESS_HAIR_TAGS.map((t) => `"${t}"`).join(' or ')}) and its "neck", cannot: ${why}`,
779
+ },
780
+ ]);
781
+ };
782
+ if (hair.length === 0 && neck.length === 0) refuse('the head run has neither');
783
+ if (hair.length === 0) refuse(`the head run has a neck (${neck[0].name}) but no hair part`);
784
+ if (neck.length === 0) refuse(`the head run has hair (${hair.map((p) => p.name).join(', ')}) but no "neck" part`);
785
+ const n = neck[0];
786
+ const top = Math.min(...hair.map((p) => p.y));
787
+ const span = n.y - top;
788
+ if (span <= 0) refuse(`the hair's top, y=${top}, is not above the top of ${n.name}, y=${n.y}, so there is no span to scale`);
789
+ const fh = ratios.heightOfSpan * span;
790
+ const fw = ratios.widthOfHeight * fh;
791
+ const cx = n.x + n.w / 2;
792
+ const box: [number, number, number, number] = [cx - fw / 2, top + ratios.topOfSpan * span, fw, fh];
793
+ const nothing = FACE_FEATURE_RULES.filter(([ts]) => ts.every((t) => P.byTag(t).length === 0)).map(([ts, rule]) => `${rule} (no ${ts.join(' or ')} part)`);
794
+ const note =
795
+ `no face part: face box derived from the head run's hair (${hair.map((p) => p.name).join(', ')}; top y=${top}) and neck (${n.name}; top y=${n.y}, centre x=${pyFixed(cx, 1)}), a span of ${span} px — ` +
796
+ `height ${ratios.heightOfSpan} of the span, top ${ratios.topOfSpan} of it below the hair's top, width ${ratios.widthOfHeight} of the height, centred on the neck: ` +
797
+ `x ${pyFixed(box[0], 1)}, y ${pyFixed(box[1], 1)}, ${pyFixed(fw, 1)}x${pyFixed(fh, 1)} (ratios measured on the two public examples); ` +
798
+ 'head, neck, the eye line when there is no eyewhite and every face-height scale are read off this guess, so correct those bones against the overlay' +
799
+ (nothing.length === 0 ? '' : `; nothing to read for ${nothing.join(', ')}`) +
800
+ // Measured: the sample with only its face removed opens 587 px behind its shut eyes, the demo 0 (its hair is under them).
801
+ (EYE_GROUP_TAGS.some((t) => P.byTag(t).length > 0) ? "; the blink shuts the eyes over no face part, so check's BLINK_NO_HOLE says whether anything shows through" : '');
802
+ return { box, note, parts: [...hair.map((p) => p.name), n.name] };
803
+ }
804
+
805
+ const SIDES = [
806
+ ['r', 1],
807
+ ['l', -1],
808
+ ] as const;
809
+
810
+ /**
811
+ * Propose bones, meshes, regions and an idle from the parts. With `joints`
812
+ * (`propose --keypoints`, issue #75), a joint the file gives a position is
813
+ * used as given in place of the body-ratio rule for the bone it places, and
814
+ * every joint the proposer could use gets one note saying how it was used or
815
+ * which rule stood in; without it every value and note is the one this
816
+ * function wrote before the parameter existed.
817
+ */
818
+ export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null = null): Proposal {
819
+ return proposeWithBasis(P, joints, wristSnap).proposal;
820
+ }
821
+
822
+ /**
823
+ * {@link propose}, and beside the proposal what each bone it places rests on
824
+ * (issue #86): recorded at each placement below, from the values that placed
825
+ * it — no bone's value is touched by the recording, and `propose` returns the
826
+ * same proposal, byte for byte, as before the record existed.
827
+ *
828
+ * `wristSnap` (`propose --wrist-snap`, issue #199) is read only with
829
+ * `joints`: see {@link armsFromJoints}. `null` is the built-in tolerance
830
+ * alone, and every value and note is the one written before it existed.
831
+ */
832
+ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: number | null = null): { proposal: Proposal; basis: ProposalBasis } {
833
+ const bones: BoneEntry[] = [];
834
+ const records: BoneBasis[] = [];
835
+ // No prototype: a part may be named anything file-safe, "__proto__" included.
836
+ const meshes = Object.create(null) as Record<string, MeshSpec>;
837
+ const regions = Object.create(null) as Record<string, string>;
838
+ const tracks: Array<ProposedSingleTrack | ProposedChainTrack> = [];
839
+ const notes: string[] = [];
840
+
841
+ // The face part's box, or with none one derived from the head run's hair
842
+ // and neck (issue #76), said in the first note.
843
+ const faceBox = faceBoxOf(P);
844
+ const [fx0, fy0, fw, fh] = faceBox.box;
845
+ if (faceBox.note !== null) notes.push(faceBox.note);
846
+ const ew: Record<'r' | 'l', PartRecord | null> = { r: P.byTag('eyewhite-r')[0] ?? null, l: P.byTag('eyewhite-l')[0] ?? null };
847
+ const eyes = new Map<'r' | 'l', [number, number]>();
848
+ for (const s of ['r', 'l'] as const) {
849
+ const p = ew[s];
850
+ if (p !== null) eyes.set(s, center(p));
851
+ }
852
+ const axis = eyes.size === 2 ? ((eyes.get('r') as [number, number])[0] + (eyes.get('l') as [number, number])[0]) / 2 : fx0 + fw / 2;
853
+ const eyeY = eyes.size > 0 ? npMean([...eyes.values()].map((e) => e[1])) : fy0 + 0.5 * fh;
854
+ const eyeParts = (['r', 'l'] as const).flatMap((s) => (ew[s] === null ? [] : [(ew[s] as PartRecord).name]));
855
+ const torsoParts = P.layered().filter((p) => (TORSO_TAGS as readonly string[]).includes(splitFrom(p.from)[1]));
856
+ const frame: Record<FrameKey, FrameBasis> = {
857
+ face_box: { rule: faceBox.note === null ? `the box of ${faceBox.parts[0]}, the face part` : "derived from the head run's hair and neck by the measured ratios (the first note)", parts: faceBox.parts },
858
+ eye_axis: eyes.size === 2 ? { rule: "half way between the centres of the two eyewhites' boxes", parts: eyeParts } : { rule: 'the centre x of the face box (there are not two eyewhites)', parts: faceBox.parts },
859
+ eye_line: eyes.size > 0 ? { rule: "the mean y of the eyewhites' box centres", parts: eyeParts } : { rule: 'half way down the face box (no eyewhite)', parts: faceBox.parts },
860
+ figure: { rule: "the first and last rows holding any layered part's pixel", parts: P.layered().map((p) => p.name) },
861
+ torso: { rule: `per row, the leftmost and rightmost pixel of the layers tagged ${TORSO_TAGS.join(', ')}`, parts: torsoParts.map((p) => p.name) },
862
+ };
863
+ // A joint as the file declared it, for the record.
864
+ const jointRef = (n: KeypointName): BasisJoint => {
865
+ const j = (joints as RigJoints).joints[n];
866
+ return { name: n, state: j.state, listed: j.listed, score: j.score, at: j.at as [number, number] };
867
+ };
868
+ // Python's `v or fallback`, with the stand-in said when it is taken.
869
+ const orSaid = (v: number | null, fallback: number, why: string, sink: { fallback: string | null }): number => {
870
+ if (v === null || v === 0) sink.fallback = why;
871
+ return or(v, fallback);
872
+ };
873
+ const chin = fy0 + fh;
874
+ const head: [number, number] = [axis, fy0 + 0.88 * fh];
875
+ const neckp = P.headFirst('neck');
876
+ let neckY = neckp.length > 0 ? (chin + neckp[0].y + neckp[0].h) / 2 : chin + 0.12 * fh;
877
+ // Issue #75: an observed neck joint is the neck bone, used as given; the
878
+ // rule's y is replaced by the joint's everywhere the rules read the neck.
879
+ const at = (n: KeypointName): [number, number] | null => (joints === undefined ? null : joints.joints[n].at);
880
+ const neckRule = neckp.length > 0 ? `half way from the chin to the bottom of ${neckp[0].name}, on the eye axis` : '0.12 face heights below the chin, on the eye axis';
881
+ const neckJ = at('neck');
882
+ let neckAt: [number, number] = [axis, neckY];
883
+ let neckWhy =
884
+ neckp.length > 0
885
+ ? placement('ratio', `half way from the chin (the face box's bottom) to the bottom of ${neckp[0].name}, on the eye axis`, { parts: [neckp[0].name], frame: ['face_box', 'eye_axis'], constants: [konst('share_chin_to_neck_bottom', 0.5)] })
886
+ : placement('ratio', "0.12 face heights below the chin (the face box's bottom), on the eye axis", { frame: ['face_box', 'eye_axis'], constants: [konst('face_heights_below_chin', 0.12)], fallback: 'no neck part' });
887
+ if (neckJ !== null) {
888
+ neckAt = neckJ;
889
+ neckY = neckJ[1];
890
+ neckWhy = placement('joint', `the neck joint, ${usedAs((joints as RigJoints).joints.neck)}`, { joints: [jointRef('neck')] });
891
+ }
892
+ const bw = P.byTag('bottomwear');
893
+ const tw = P.byTag('topwear');
894
+ const { top: ftop, bot: fbot } = figureExtent(P);
895
+ const torso = torsoRows(P);
896
+ const shoulders = shoulderOf(torso, neckY, fh);
897
+ let hip: [number, number];
898
+ let hipRule = '';
899
+ let hipWhy: Placement;
900
+ const rHipJ = at('r_hip');
901
+ const lHipJ = at('l_hip');
902
+ const hipFromJoints = rHipJ !== null && lHipJ !== null;
903
+ if (rHipJ !== null && lHipJ !== null) {
904
+ hip = [(rHipJ[0] + lHipJ[0]) / 2, (rHipJ[1] + lHipJ[1]) / 2];
905
+ hipWhy = placement('joint', 'the midpoint of the r_hip and l_hip joints — a point between two joints, not a joint', { joints: [jointRef('r_hip'), jointRef('l_hip')] });
906
+ } else if (bw.length > 0 && bw[0].y + 0.14 * fh >= ftop + HIP_MIN_FRACTION * (fbot - ftop)) {
907
+ hip = [bw[0].x + bw[0].w / 2, bw[0].y + 0.14 * fh];
908
+ hipRule = `the top of ${bw[0].name} (bottomwear) + 0.14 face heights`;
909
+ hipWhy = placement('ratio', `the top of ${bw[0].name} (bottomwear) + 0.14 face heights, at the centre x of its box`, { parts: [bw[0].name], frame: ['face_box'], constants: [konst('face_heights_below_top', 0.14)] });
910
+ } else if (bw.length === 0) {
911
+ hip = [axis, ftop + 0.32 * (fbot - ftop)];
912
+ hipRule = '0.32 of the figure height, on the eye axis (no bottomwear)';
913
+ hipWhy = placement('ratio', '0.32 of the figure height below its top row, on the eye axis', { frame: ['figure', 'eye_axis'], constants: [konst('of_figure_height', 0.32)], fallback: 'no bottomwear part' });
914
+ notes.push('no bottomwear: hip from 0.32 of figure height');
915
+ } else {
916
+ // A long under-robe tagged bottomwear starts at the collar: its top edge
917
+ // is not the waist, and a hip read off it sits at the shoulders with the
918
+ // chest above it by half a neck. The waist is read off the figure instead.
919
+ const limit = ftop + HIP_MIN_FRACTION * (fbot - ftop);
920
+ notes.push(
921
+ `hip: ${bw[0].name} (${bw[0].from}) starts at y=${bw[0].y}, so its top + 0.14 face heights (y=${pyFixed(bw[0].y + 0.14 * fh, 0)}) is above ${HIP_MIN_FRACTION} of the figure height (y=${pyFixed(limit, 0)}, figure y ${ftop}..${fbot}): its top edge is not the waist`,
922
+ );
923
+ const waist = shoulders === null ? null : waistOf(torso, shoulders, ftop + 0.5 * (fbot - ftop));
924
+ if (shoulders !== null && waist !== null) {
925
+ hip = [waist.cx, waist.y + 0.14 * fh];
926
+ hipRule = 'the waist (the narrowest torso row) + 0.14 face heights';
927
+ hipWhy = placement('ratio', "the waist — the narrowest torso row from the shoulder line (the widest torso row within one face height below the neck) down to the figure's middle — + 0.14 face heights, at that row's centre x", {
928
+ bones: ['neck'],
929
+ frame: ['torso', 'figure', 'face_box'],
930
+ constants: [konst('face_heights_below_waist', 0.14), konst('waist_max_of_shoulders', WAIST_MAX_OF_SHOULDERS)],
931
+ });
932
+ notes.push(`hip from the waist: silhouette narrowest at y=${waist.y} (width ${waist.width} px, shoulders ${shoulders.width} px at y=${shoulders.y}), hip 0.14 face heights below it`);
933
+ } else {
934
+ hip = [axis, ftop + 0.32 * (fbot - ftop)];
935
+ hipRule = '0.32 of the figure height, on the eye axis (no waist found)';
936
+ hipWhy = placement('ratio', '0.32 of the figure height below its top row, on the eye axis', { frame: ['figure', 'eye_axis'], constants: [konst('of_figure_height', 0.32)], fallback: 'no waist found' });
937
+ const why =
938
+ shoulders === null
939
+ ? `no torso layer (${TORSO_TAGS.join(', ')}) in the shoulder band y ${pyFixed(neckY, 0)}..${pyFixed(neckY + fh, 0)}`
940
+ : `the silhouette never narrows to ${WAIST_MAX_OF_SHOULDERS} of the shoulders (${shoulders.width} px at y=${shoulders.y}) above the figure's middle, y=${pyFixed(ftop + 0.5 * (fbot - ftop), 0)}`;
941
+ notes.push(`hip from 0.32 of figure height (no waist found: ${why})`);
942
+ }
943
+ }
944
+ // The chest keeps its rule, half way from the neck to the hip; when either
945
+ // end is a joint the half way is taken along the line between them, not
946
+ // down the screen, because the torso need not be upright.
947
+ const chestAlong = neckJ !== null || hipFromJoints;
948
+ const chest: [number, number] = chestAlong ? [(neckAt[0] + hip[0]) / 2, (neckAt[1] + hip[1]) / 2] : [hip[0], neckY + 0.5 * (hip[1] - neckY)];
949
+ // Each placement hands its basis with its value: one record per bone, in the order the bones are written.
950
+ const B = (name: string, parent: string, at: readonly [number, number], why: Placement, tip?: readonly [number, number], tipWhy?: Placement): void => {
951
+ bones.push(tip === undefined ? { name, parent, at: rnd(at) } : { name, parent, at: rnd(at), tip: rnd(tip) });
952
+ records.push({ bone: name, entry: name, origin: why, tip: tipWhy ?? null });
953
+ };
954
+ const C = (chain: string, parent: string, pts: ReadonlyArray<readonly [number, number]>, tip: readonly [number, number], why: readonly Placement[], tipWhy: Placement): void => {
955
+ bones.push({ chain, parent, points: pts.map(rnd), tip: rnd(tip) });
956
+ pts.forEach((_, k) => records.push({ bone: `${chain}${k}`, entry: chain, origin: why[k], tip: k === pts.length - 1 ? tipWhy : null }));
957
+ };
958
+ B('hip', 'root', hip, hipWhy);
959
+ B(
960
+ 'chest',
961
+ 'hip',
962
+ chest,
963
+ placement('derived', chestAlong ? 'half way from neck to hip, along the line between them' : "at hip's x, half way from neck's y down to hip's y", { bones: ['neck', 'hip'], constants: [konst('share_neck_to_hip', 0.5)] }),
964
+ );
965
+ B('neck', 'chest', neckAt, neckWhy);
966
+ B('head', 'neck', head, placement('ratio', '0.88 face heights below the top of the face box, on the eye axis', { frame: ['face_box', 'eye_axis'], constants: [konst('face_heights_below_top', 0.88)] }));
967
+ for (const s of ['r', 'l'] as const) {
968
+ const e = eyes.get(s);
969
+ if (e !== undefined) {
970
+ B(`eye_${s}`, 'head', e, placement('mask', `the centre of ${(ew[s] as PartRecord).name}'s box`, { parts: [(ew[s] as PartRecord).name] }));
971
+ // The reference keyed this by the literal "eyewhite_<s>"; keyed by the
972
+ // part's own name it is the same entry whenever that is the name, and
973
+ // not a region for a part that does not exist when it is not.
974
+ regions[(ew[s] as PartRecord).name] = `eye_${s}`;
975
+ }
976
+ }
977
+ // Brows by POSITION, not by tag: the head run has swapped the two brow tags
978
+ // and lost one side, and a tag-bound brow bone then sits on the other eye.
979
+ const browOf = new Map<string, string>();
980
+ const bySide = new Map<'r' | 'l', PartRecord[]>();
981
+ for (const b of [...P.byTag('eyebrow-r'), ...P.byTag('eyebrow-l')]) {
982
+ const side = center(b)[0] < axis ? 'r' : 'l';
983
+ if (!bySide.has(side)) bySide.set(side, []);
984
+ (bySide.get(side) as PartRecord[]).push(b);
985
+ browOf.set(b.name, `brow_${side}`);
986
+ if (splitFrom(b.from)[1] !== `eyebrow-${side}`) notes.push(`${b.name} (${b.from}) sits on the ${side} side of the eye axis: bound to brow_${side}`);
987
+ }
988
+ for (const s of ['r', 'l'] as const) {
989
+ const group = bySide.get(s);
990
+ if (group !== undefined) {
991
+ const cs = group.map(center);
992
+ B(
993
+ `brow_${s}`,
994
+ 'head',
995
+ [npMean(cs.map((c) => c[0])), npMean(cs.map((c) => c[1]))],
996
+ placement('mask', `the mean of the box centres of the eyebrow parts on the ${s} side of the eye axis`, { parts: group.map((p) => p.name), frame: ['eye_axis'] }),
997
+ );
998
+ }
999
+ }
1000
+ const mouth = P.headFirst('mouth');
1001
+ if (mouth.length > 0) {
1002
+ // The centroid of the biggest blob: stray pixels inflate the box.
1003
+ const cc = connectedComponents(P.alpha(mouth[0]), 8);
1004
+ if (cc.count > 1) {
1005
+ let best = 1;
1006
+ for (let i = 2; i < cc.count; i++) if (cc.stats[i].area > cc.stats[best].area) best = i;
1007
+ B('mouth', 'head', [cc.stats[best].cx, cc.stats[best].cy], placement('mask', `the centroid of the biggest 8-connected blob of ${mouth[0].name}`, { parts: [mouth[0].name] }));
1008
+ } else B('mouth', 'head', center(mouth[0]), placement('mask', `the centre of ${mouth[0].name}'s box`, { parts: [mouth[0].name], fallback: 'its mask has no opaque pixel, so no blob' }));
1009
+ }
1010
+ // An eye bone comes only from an eyewhite (EYE_GROUP_TAGS). An iris or a
1011
+ // lash on a side with none has no eye bone to ride, and writing its region
1012
+ // as `eye_<s>` anyway named a bone that does not exist — refused by the
1013
+ // loader as the symptom (`config.regions.<part> — is "eye_r"`), never the
1014
+ // cause (issue #45). Such a part rides `head` — the bone the eye bone would
1015
+ // hang from and the one the face beside it already rides — so no value is
1016
+ // invented, the proposal stays one a person can correct, and the note names
1017
+ // the cause.
1018
+ const eyeless: string[] = [];
1019
+ const eyelessSides = new Set<'r' | 'l'>();
1020
+ for (const p of P.layered()) {
1021
+ const t = splitFrom(p.from)[1];
1022
+ let role: string | undefined = TAG_REGION[t];
1023
+ if (browOf.has(p.name)) role = browOf.get(p.name);
1024
+ const side = role === 'eye_r' ? 'r' : role === 'eye_l' ? 'l' : null;
1025
+ if (side !== null && !eyes.has(side)) {
1026
+ role = 'head';
1027
+ eyeless.push(`${p.name} (${p.from})`);
1028
+ eyelessSides.add(side);
1029
+ }
1030
+ if (role !== undefined) regions[p.name] = role;
1031
+ }
1032
+ if (eyeless.length > 0) {
1033
+ const sides = (['r', 'l'] as const).filter((s) => eyelessSides.has(s));
1034
+ notes.push(
1035
+ `no eyewhite part for ${sides.map((s) => `eye_${s}`).join(', ')} (looked for: ${sides.map((s) => `eyewhite-${s}`).join(', ')}): ${eyeless.join(', ')} ${eyeless.length === 1 ? 'is' : 'are'} placed on head as ${eyeless.length === 1 ? 'a region' : 'regions'}, rigid with the head and not blinking — an eye bone comes only from an eyewhite`,
1036
+ );
1037
+ }
1038
+ // A blink group rigc is handed must name a member (rigc refuses `group
1039
+ // "eyes" declares no members`), so a group with none is not written, and a
1040
+ // blink with no eyes is not written at all: a value with nothing to act on
1041
+ // would be invented. Each omission is a note naming the tags looked for.
1042
+ const eyeBonesProposed = (['r', 'l'] as const).filter((s) => eyes.has(s)).map((s) => `eye_${s}`);
1043
+ const browBonesProposed = (['r', 'l'] as const).filter((s) => bySide.has(s)).map((s) => `brow_${s}`);
1044
+ let blink: ProposedBlink | undefined;
1045
+ if (eyeBonesProposed.length === 0) {
1046
+ const brows = browBonesProposed.length === 0 ? '' : `; ${browBonesProposed.join(', ')} ${browBonesProposed.length === 1 ? 'is' : 'are'} placed and nothing drops ${browBonesProposed.length === 1 ? 'it' : 'them'}`;
1047
+ notes.push(`no blink: no eyewhite part (looked for: ${EYE_GROUP_TAGS.join(', ')}), so the blink's eyes group would name no bone${brows}`);
1048
+ } else {
1049
+ blink = browBonesProposed.length === 0 ? { t: 2.3, eyes: eyeBonesProposed, squash: 0.12 } : { t: 2.3, eyes: eyeBonesProposed, brows: browBonesProposed, squash: 0.12, brow_drop: 1.2 };
1050
+ const still = lashStills(P, ew, notes);
1051
+ if (Object.keys(still).length > 0) blink.still = still;
1052
+ if (browBonesProposed.length === 0) notes.push(`blink without brows: no eyebrow part (looked for: ${BROW_GROUP_TAGS.join(', ')}), so the blink has no brows group and no brow_drop`);
1053
+ }
1054
+ tracks.push(
1055
+ { bone: 'chest', prop: 'translatey', amp: 1.3, period: 4.0, phase: 0.0, base: 1.3 },
1056
+ { bone: 'chest', prop: 'scalex', amp: 0.004, period: 4.0, phase: 0.0, base: 1.004 },
1057
+ { bone: 'head', prop: 'rotate', amp: 0.9, period: 4.0, phase: 0.12 },
1058
+ { bone: 'neck', prop: 'rotate', amp: 0.35, period: 4.0, phase: 0.08 },
1059
+ );
1060
+
1061
+ const gridR = (p: PartRecord): [number, number] => {
1062
+ const g = pyInt(Math.min(36, Math.max(5, pyRound(Math.min(p.w, p.h) / 10))));
1063
+ return [g, Math.max(3, pyInt(pyRound(g * 0.42)))];
1064
+ };
1065
+
1066
+ // ---- back hair
1067
+ for (const p of P.headFirst('back hair')) {
1068
+ const [g, r] = gridR(p);
1069
+ const top = p.y;
1070
+ const bot = p.y + p.h;
1071
+ if (bot < neckY + 0.5 * fh) {
1072
+ B(
1073
+ 'bun',
1074
+ 'head',
1075
+ [axis, top + 0.36 * p.h],
1076
+ placement('ratio', `0.36 of ${p.name}'s height below its top, on the eye axis`, { parts: [p.name], frame: ['eye_axis'], constants: [konst('of_part_height_below_top', 0.36)] }),
1077
+ [axis, bot - 0.1 * p.h],
1078
+ placement('ratio', `0.1 of ${p.name}'s height above its bottom, on the eye axis`, { parts: [p.name], frame: ['eye_axis'], constants: [konst('of_part_height_above_bottom', 0.1)] }),
1079
+ );
1080
+ meshes[p.name] = { grid: g, r, segments: [['head', rnd(head), rnd([axis, top + 13])], 'bun'] };
1081
+ tracks.push({ bone: 'bun', prop: 'rotate', amp: 0.8, period: 4.0, phase: 0.2 });
1082
+ } else {
1083
+ const mask = P.alpha(p);
1084
+ const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
1085
+ for (const [s, sgn] of SIDES) {
1086
+ const y0 = eyeY;
1087
+ const why: Placement[] = [];
1088
+ const pts: Array<[number, number]> = [0, 1, 2].map((k) => {
1089
+ const w = placement('mask', `link ${k}: the centroid x of ${p.name}'s art on the ${s} side of the eye axis, in the 24-row band at ${k}/4 of the way from the eye line to its bottom`, {
1090
+ parts: [p.name],
1091
+ frame: ['eye_axis', 'eye_line'],
1092
+ constants: [konst('share_eye_line_to_bottom', k / 4)],
1093
+ });
1094
+ why.push(w);
1095
+ return [orSaid(bandX(mask, y0 + (k * (bot - y0)) / 4, { side: s, axis }), axis, 'no art on that side in the band: the eye axis x', w), y0 + (k * (bot - y0)) / 4];
1096
+ });
1097
+ C(`hairback_${s}`, 'head', pts, [pts[pts.length - 1][0], bot - 4], why, placement('mask', `4 px above the bottom of ${p.name}, at the last link's x`, { parts: [p.name], bones: [`hairback_${s}2`] }));
1098
+ segs.push(`hairback_${s}`);
1099
+ tracks.push({ chain: `hairback_${s}`, amps: [sgn * 0.6, sgn * 1.4, sgn * 2.4], period: 4.0, phase: 0.2, lag: 0.08 });
1100
+ }
1101
+ meshes[p.name] = { grid: g, r, segments: segs };
1102
+ }
1103
+ }
1104
+
1105
+ // ---- front hair: fringe + locks
1106
+ for (const p of P.headFirst('front hair')) {
1107
+ const [g, r] = gridR(p);
1108
+ const mask = P.alpha(p);
1109
+ const top = p.y;
1110
+ const segs: Segment[] = [['head', rnd(head), rnd([axis, top])]];
1111
+ const browY = eyeY - 0.18 * fh;
1112
+ for (const [tag, f, dx] of [
1113
+ ['bang_r', 0.25, -10],
1114
+ ['bang_c', 0.5, 0],
1115
+ ['bang_l', 0.75, 10],
1116
+ ] as const) {
1117
+ const x0 = fx0 + f * fw;
1118
+ C(
1119
+ tag,
1120
+ 'head',
1121
+ [
1122
+ [x0, top + 13],
1123
+ [x0 + dx, (top + 13 + browY) / 2 + 6],
1124
+ ],
1125
+ [x0 + dx * 1.8, browY + 19],
1126
+ [
1127
+ placement('ratio', `link 0: ${f} of the face box's width from its left, 13 px below the top of ${p.name}`, { parts: [p.name], frame: ['face_box'], constants: [konst('of_face_width', f), konst('px_below_part_top', 13)] }),
1128
+ placement('ratio', `link 1: ${dx} px across from link 0, 6 px below half way from link 0's row to the brow line (0.18 face heights above the eye line)`, {
1129
+ parts: [p.name],
1130
+ frame: ['face_box', 'eye_line'],
1131
+ constants: [konst('px_across', dx), konst('px_below_half_way', 6), konst('brow_line_face_heights_above_eye_line', 0.18)],
1132
+ }),
1133
+ ],
1134
+ placement('ratio', `${dx * 1.8} px across from link 0 (1.8 times link 1's offset), 19 px below the brow line (0.18 face heights above the eye line)`, {
1135
+ frame: ['face_box', 'eye_line'],
1136
+ constants: [konst('times_link_1_offset', 1.8), konst('px_below_brow_line', 19), konst('brow_line_face_heights_above_eye_line', 0.18)],
1137
+ }),
1138
+ );
1139
+ segs.push(tag);
1140
+ const a = tag === 'bang_c' ? 0.8 : 1.0;
1141
+ tracks.push({ chain: tag, amps: [a, Number((a * 2.2).toFixed(4))], period: 4.0, phase: 0.18, lag: 0.1 });
1142
+ }
1143
+ const below = newMask(P.W, P.H);
1144
+ below.data.set(mask.data);
1145
+ const [c0, c1] = pySlice(0, pyInt(chin) + 4, P.H);
1146
+ below.data.fill(0, c0 * P.W, c1 * P.W);
1147
+ const cc = connectedComponents(below, 8);
1148
+ let k = 0;
1149
+ for (let i = 1; i < cc.count; i++) {
1150
+ const st = cc.stats[i];
1151
+ if (st.height < 0.3 * fh) continue;
1152
+ const comp = newMask(P.W, P.H);
1153
+ for (let j = 0; j < comp.data.length; j++) comp.data[j] = cc.labels[j] === i ? 1 : 0;
1154
+ const bot = st.top + st.height;
1155
+ const y0 = eyeY - 0.22 * fh;
1156
+ const span = bot + 5 - y0;
1157
+ const cm = newMask(P.W, P.H);
1158
+ for (let y = 0; y < P.H; y++) {
1159
+ for (let x = 0; x < P.W; x++) {
1160
+ const j = y * P.W + x;
1161
+ cm.data[j] = comp.data[j] | (mask.data[j] & (Math.abs(x - st.cx) < 25 ? 1 : 0));
1162
+ }
1163
+ }
1164
+ const name = k === 0 ? 'lock' : `lock${k}_`;
1165
+ const why: Placement[] = [];
1166
+ const pts: Array<[number, number]> = [0, 1, 2, 3].map((j) => {
1167
+ const w = placement(
1168
+ 'mask',
1169
+ `link ${j}: the centroid x of the strand of ${p.name} that hangs below the chin (with ${p.name}'s art within 25 px of the strand's centre x), in the 24-row band at ${j}/4 of the way from 0.22 face heights above the eye line to 5 px below the strand's bottom`,
1170
+ { parts: [p.name], frame: ['face_box', 'eye_line'], constants: [konst('share_of_span', j / 4), konst('face_heights_above_eye_line', 0.22)] },
1171
+ );
1172
+ why.push(w);
1173
+ return [orSaid(bandX(cm, y0 + (j * span) / 4), st.cx, "no art in the band: the strand's centre x", w), y0 + (j * span) / 4];
1174
+ });
1175
+ const lockTip = placement('mask', `5 px below the strand's bottom, at the strand's centroid x in the band 6 px above its bottom`, { parts: [p.name] });
1176
+ C(name, 'head', pts, [orSaid(bandX(comp, bot - 6), pts[pts.length - 1][0], "no art in the band: the last link's x", lockTip), bot + 5], why, lockTip);
1177
+ segs.push(name);
1178
+ tracks.push({ chain: name, amps: [0.8, 1.6, 2.6, 3.6], period: 4.0, phase: 0.16, lag: 0.07 });
1179
+ k++;
1180
+ }
1181
+ meshes[p.name] = { grid: g, r, segments: segs };
1182
+ }
1183
+
1184
+ // ---- accessories: rigid body + pendant chain where the part narrows, or one pendulum chain per hanging strand
1185
+ for (const [tag, bname, par] of [
1186
+ ['headwear', 'hairpin', 'head'],
1187
+ ['earwear', 'earring', 'head'],
1188
+ ] as const) {
1189
+ // Every part with the tag (a plan may take both runs' layers): the biggest
1190
+ // gets the bone and chain, the rest ride the head rigidly. A stable sort,
1191
+ // as Python's `sorted` is.
1192
+ const cands = [...P.byTag(tag)].sort((a, b) => b.opaque_px - a.opaque_px);
1193
+ for (const q of cands.slice(1)) {
1194
+ regions[q.name] = 'head';
1195
+ notes.push(`${q.name} (${q.from}): second ${tag} layer, rigid on head`);
1196
+ // A region cannot swing, so strands found on it are only said.
1197
+ const pr = pendantRows(P, q);
1198
+ const strands = hangingStrands(P, q, tag === 'earwear' || pr.pendH > 0.8 * q.h ? 0 : pr.j);
1199
+ if (strands.length > 0) notes.push(strandNote(q, strands, [], strands, 'it rides the head as a region'));
1200
+ }
1201
+ for (const p of cands.slice(0, 1)) {
1202
+ const [g, r] = gridR(p);
1203
+ const fullMask = dilate(P.alpha(p), 7);
1204
+ const onArt = (pts: ReadonlyArray<readonly [number, number]>): boolean => pts.every((q) => isOn(fullMask, q[0], q[1]));
1205
+ const im = P.images.get(p.name) as Raster;
1206
+ const own = (x: number, y: number): number => (im.data[(y * p.w + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE ? 1 : 0);
1207
+ const { rows, j, pendH } = pendantRows(P, p);
1208
+ const bodyRows = j > 0 ? j : rows.length;
1209
+ const colsIn = (r0: number, r1: number): number[] => {
1210
+ const out: number[] = [];
1211
+ for (let x = 0; x < p.w; x++) {
1212
+ for (let y = r0; y < r1; y++) {
1213
+ if (own(x, y)) {
1214
+ out.push(x);
1215
+ break;
1216
+ }
1217
+ }
1218
+ }
1219
+ return out;
1220
+ };
1221
+ const byx = colsIn(0, bodyRows);
1222
+ const allPendant = tag === 'earwear' || pendH > 0.8 * rows.length;
1223
+ // Strands: searched below the body, or over the whole part when it is all pendant.
1224
+ const strands = hangingStrands(P, p, allPendant ? 0 : j);
1225
+ // One pendulum chain per strand, in place of the part's single pendant
1226
+ // chain: a chain at the mean x of several strands hangs between them.
1227
+ const strandChains = (parent: string): { segs: Segment[]; chained: string[]; dropped: Strand[] } => {
1228
+ const segs: Segment[] = [];
1229
+ const chained: string[] = [];
1230
+ const dropped: Strand[] = [];
1231
+ strands.forEach((st, k) => {
1232
+ const h = st.bottom - st.top + 1;
1233
+ const why: Placement[] = [];
1234
+ const pts: Array<[number, number]> = [st.top, st.top + h * STRAND_LINK_AT].map((y, i) => {
1235
+ const w = placement('mask', i === 0 ? `link 0: the strand's centroid x in the band at its top row` : `link 1: the strand's centroid x in the band ${STRAND_LINK_AT} of its height below its top row`, {
1236
+ parts: [p.name],
1237
+ constants: i === 0 ? [] : [konst('of_strand_height', STRAND_LINK_AT)],
1238
+ });
1239
+ why.push(w);
1240
+ return [orSaid(bandX(st.mask, y), st.x, "no art in the band: the strand's centre x", w), y];
1241
+ });
1242
+ const tipWhy = placement('mask', "the strand's bottom row, at its centroid x in the band there", { parts: [p.name] });
1243
+ const tip: [number, number] = [orSaid(bandX(st.mask, st.bottom), pts[pts.length - 1][0], "no art in the band: the last link's x", tipWhy), st.bottom];
1244
+ if (!onArt([...pts, tip])) {
1245
+ dropped.push(st);
1246
+ return;
1247
+ }
1248
+ const name = `${bname}_strand${k}_`;
1249
+ C(name, parent, pts, tip, why, tipWhy);
1250
+ // The head stub at the strand's top is what an all-pendant part hangs from, as the single pendant chain's mesh does.
1251
+ if (parent === par) segs.push(['head', rnd([pts[0][0], st.top - 7]), rnd([pts[0][0], st.top + 1])]);
1252
+ segs.push(name);
1253
+ chained.push(name);
1254
+ tracks.push({ chain: name, amps: [...STRAND_AMPS], period: 2.0, phase: 0.2, lag: 0.12 });
1255
+ });
1256
+ return { segs, chained, dropped };
1257
+ };
1258
+ if (allPendant && strands.length > 0) {
1259
+ const sc = strandChains(par);
1260
+ notes.push(strandNote(p, strands, sc.chained, sc.dropped, 'a chain down it would run off the art'));
1261
+ if (sc.chained.length > 0) {
1262
+ delete regions[p.name];
1263
+ meshes[p.name] = { grid: g, r, segments: sc.segs };
1264
+ continue;
1265
+ }
1266
+ }
1267
+ if (allPendant) {
1268
+ // All pendant: a chain from the part's own top.
1269
+ const topRows = Math.min(p.h, Math.max(4, Math.floor(rows.length / 5)));
1270
+ const cols = colsIn(0, topRows);
1271
+ const x = p.x + (cols.length > 0 ? npMean(cols) : p.w / 2);
1272
+ const topWhy = placement('mask', `the mean opaque column of ${p.name}'s top ${topRows} rows, 3 px below its top`, {
1273
+ parts: [p.name],
1274
+ constants: [konst('px_below_top', 3)],
1275
+ fallback: cols.length > 0 ? null : 'no opaque column in those rows: the centre x of its box',
1276
+ });
1277
+ if (!onArt([[x, p.y + 3]])) {
1278
+ B(bname, par, [x, p.y + 3], topWhy);
1279
+ delete regions[p.name];
1280
+ meshes[p.name] = { grid: g, r, segments: [[bname, rnd([x, p.y]), rnd([x, p.y + p.h])]] };
1281
+ notes.push(`${p.name}: pendant top is off the art -> rigid bone, no chain`);
1282
+ continue;
1283
+ }
1284
+ C(bname, par, [[x, p.y + 3]], [x, p.y + p.h - 1], [topWhy], placement('mask', `the bottom row of ${p.name}, at link 0's x`, { parts: [p.name] }));
1285
+ meshes[p.name] = { grid: g, r, segments: [['head', rnd([x, p.y - 7]), rnd([x, p.y + 1])], bname] };
1286
+ tracks.push({ chain: bname, amps: [5.0], period: 2.0, phase: 0.25, lag: 0.0 });
1287
+ continue;
1288
+ }
1289
+ if (byx.length === 0) {
1290
+ // The reference's `byx.min()` raises here; a refusal names what it could not measure.
1291
+ refuseIfAny([
1292
+ {
1293
+ code: 'PROPOSE_ACCESSORY_BODY',
1294
+ object: `part "${p.name}" (${p.from})`,
1295
+ detail: `has no opaque pixel above its pendant rows (${pendH} of ${rows.length} rows from the bottom); a body to hang the ${bname} bone on is required`,
1296
+ },
1297
+ ]);
1298
+ }
1299
+ const by: number[] = [];
1300
+ for (let y = 0; y < bodyRows; y++) if (rows[y] > 0) by.push(y);
1301
+ const cy = p.y + npMean(by);
1302
+ const xl = p.x + byx[0];
1303
+ const xr = p.x + byx[byx.length - 1];
1304
+ // The pendant's lower half: clear of the body's fringe.
1305
+ const lowFrom = pendH > 0 ? j + Math.floor(pendH / 2) : 0;
1306
+ const lowCols: number[] = [];
1307
+ for (let y = lowFrom; y < p.h; y++) for (let x = 0; x < p.w; x++) if (own(x, y)) lowCols.push(x);
1308
+ const px = p.x + (lowCols.length > 0 ? npMean(lowCols) : p.w / 2);
1309
+ const near = Math.abs(px - xl) < Math.abs(px - xr) ? xl : xr;
1310
+ const far = near === xl ? xr : xl;
1311
+ const start: [number, number] = [near + (px - near) * 0.6 + (near === xl ? 47 : -47), cy];
1312
+ B(
1313
+ bname,
1314
+ par,
1315
+ start,
1316
+ placement('mask', `0.6 of the way from the near edge of ${p.name}'s body to its pendant's mean opaque column, 47 px further in, at the body's mean opaque row`, {
1317
+ parts: [p.name],
1318
+ constants: [konst('share_near_edge_to_pendant', 0.6), konst('px_further_in', 47)],
1319
+ fallback: lowCols.length > 0 ? null : "no opaque pixel in the pendant's lower half: the centre x of its box as the pendant's column",
1320
+ }),
1321
+ [far, cy],
1322
+ placement('mask', `the far edge of ${p.name}'s body, at its mean opaque row`, { parts: [p.name] }),
1323
+ );
1324
+ // The body's two fixed segments come first and stay: they are what holds the body still while a strand swings.
1325
+ const segs: Segment[] = [
1326
+ [bname, rnd(start), rnd([far, cy])],
1327
+ [bname, rnd([near, cy - 12]), rnd([start[0], cy + 18])],
1328
+ ];
1329
+ if (strands.length > 0) {
1330
+ // The body's full width at its mean row: without it the body's near side
1331
+ // is held only by the reference's two segments, and a strand's pull
1332
+ // reaches it (measured on the synthetic crown: 1.07 px off its bone
1333
+ // without this segment, 0.55 px with it).
1334
+ segs.push([bname, rnd([xl, cy]), rnd([xr, cy])]);
1335
+ const sc = strandChains(bname);
1336
+ segs.push(...sc.segs);
1337
+ notes.push(strandNote(p, strands, sc.chained, sc.dropped, 'a chain down it would run off the art'));
1338
+ meshes[p.name] = { grid: g, r, segments: segs };
1339
+ continue;
1340
+ }
1341
+ const tpts: Array<[number, number]> = [
1342
+ [px, p.y + j],
1343
+ [px, p.y + j + pendH * 0.45],
1344
+ [px, p.y + p.h - 1],
1345
+ ];
1346
+ if (pendH >= PENDANT_MIN_ROWS && !onArt(tpts)) {
1347
+ notes.push(`${p.name}: pendant chain would run off the art -> no tassel chain (add one by hand if it swings)`);
1348
+ } else if (pendH >= PENDANT_MIN_ROWS) {
1349
+ const y0 = p.y + j;
1350
+ const lowFallback = lowCols.length > 0 ? null : "no opaque pixel in the pendant's lower half: the centre x of its box";
1351
+ C(
1352
+ `${bname}_tassel`,
1353
+ bname,
1354
+ [
1355
+ [px, y0],
1356
+ [px, y0 + pendH * 0.45],
1357
+ ],
1358
+ [px, p.y + p.h - 1],
1359
+ [
1360
+ placement('mask', `link 0: the mean opaque column of the lower half of ${p.name}'s pendant, at the pendant's top row`, { parts: [p.name], fallback: lowFallback }),
1361
+ placement('mask', `link 1: the same column, 0.45 of the pendant's height below its top row`, { parts: [p.name], constants: [konst('of_pendant_height', 0.45)], fallback: lowFallback }),
1362
+ ],
1363
+ placement('mask', `the bottom row of ${p.name}, at the same column`, { parts: [p.name], fallback: lowFallback }),
1364
+ );
1365
+ segs.push(`${bname}_tassel`);
1366
+ tracks.push({ chain: `${bname}_tassel`, amps: [...STRAND_AMPS], period: 2.0, phase: 0.2, lag: 0.12 });
1367
+ }
1368
+ meshes[p.name] = { grid: g, r, segments: segs };
1369
+ }
1370
+ }
1371
+
1372
+ // ---- neck mesh
1373
+ for (const p of P.headFirst('neck')) {
1374
+ const [g, r] = gridR(p);
1375
+ meshes[p.name] = {
1376
+ grid: g,
1377
+ r,
1378
+ segments: [
1379
+ ['chest', rnd([axis, neckY + 18]), rnd([axis, chest[1]])],
1380
+ ['head', rnd(head), rnd([axis, head[1] - 50])],
1381
+ ],
1382
+ };
1383
+ }
1384
+
1385
+ // ---- sleeves
1386
+ const hw = [...P.byTag('handwear-r'), ...P.byTag('handwear-l')].filter((p) => p.opaque_px > 500);
1387
+ const sides = new Map<'r' | 'l', PartRecord[]>();
1388
+ for (const p of hw) {
1389
+ const s = center(p)[0] < axis ? 'r' : 'l';
1390
+ if (!sides.has(s)) sides.set(s, []);
1391
+ (sides.get(s) as PartRecord[]).push(p);
1392
+ }
1393
+ const clasped = hw.length > 0 && !(hw.length === 2 && sides.size === 2) ? claspedOf(P, hw, shoulders, axis) : null;
1394
+ // Issue #75: arms from joints, when every handwear part carries the wrist of
1395
+ // an arm whose shoulder, elbow and wrist are all given; otherwise the rule
1396
+ // below places every sleeve chain, and the notes say why.
1397
+ const armPlan = joints !== undefined && clasped === null && hw.length > 0 ? armsFromJoints(P, hw, joints, wristSnap) : null;
1398
+ let sleeveRule = '';
1399
+ if (clasped !== null) {
1400
+ sleeveRule = 'the clasped-hands rule (the handwear is a region on hip, with no sleeve chain)';
1401
+ // One blob at the midline, narrower than the shoulders: the hands, clasped
1402
+ // in front. The sleeves are inside another layer and move with it; two
1403
+ // sleeve chains on this blob would stand on one vertical line.
1404
+ for (const p of hw) regions[p.name] = 'hip';
1405
+ notes.push(clasped);
1406
+ } else if (armPlan !== null && armPlan.arms !== null) {
1407
+ // Each chain runs shoulder -> elbow with its tip at the wrist: two links,
1408
+ // the upper arm and the forearm, each with the amplitude the two-blob rule
1409
+ // gives its link of the same index. Its part's mesh takes the chain and a
1410
+ // chest stub at the shoulder, the two-blob rule's 16 px stub laid along
1411
+ // the upper arm instead of down the screen.
1412
+ for (const a of armPlan.arms) {
1413
+ const sgn = a.side === 'r' ? 1 : -1;
1414
+ const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs((joints as RigJoints).joints[n])}`, { joints: [jointRef(n)] });
1415
+ C(`sleeve_${a.side}`, 'chest', [a.shoulder, a.elbow], a.wrist, [used(`${a.side}_shoulder`), used(`${a.side}_elbow`)], used(`${a.side}_wrist`));
1416
+ tracks.push({ chain: `sleeve_${a.side}`, amps: [sgn * 0.2, sgn * 0.6], period: 4.0, phase: 0.22 + (a.side === 'l' ? 0.05 : 0), lag: 0.08 });
1417
+ }
1418
+ for (const p of hw) {
1419
+ const [g, r] = gridR(p);
1420
+ const segs: Segment[] = [];
1421
+ for (const a of armPlan.arms.filter((q) => q.part === p)) {
1422
+ const len = Math.hypot(a.elbow[0] - a.shoulder[0], a.elbow[1] - a.shoulder[1]);
1423
+ const d: [number, number] = [(a.elbow[0] - a.shoulder[0]) / len, (a.elbow[1] - a.shoulder[1]) / len];
1424
+ segs.push(['chest', rnd([a.shoulder[0] - 14 * d[0], a.shoulder[1] - 14 * d[1]]), rnd([a.shoulder[0] + 2 * d[0], a.shoulder[1] + 2 * d[1]])], `sleeve_${a.side}`);
1425
+ }
1426
+ meshes[p.name] = { grid: g, r, segments: segs };
1427
+ }
1428
+ } else if (hw.length === 2 && sides.size === 2) {
1429
+ sleeveRule = 'the two-blob sleeve rule (one chain per handwear blob, down its rows)';
1430
+ // Arms apart: two blobs. Each mesh gets ONLY its own sleeve chain and a
1431
+ // short chest segment at its shoulder; the one-blob recipe made hanging
1432
+ // hands breathe with the sternum.
1433
+ for (const [s, sgn] of SIDES) {
1434
+ const p = (sides.get(s) as PartRecord[])[0];
1435
+ const mask = P.alpha(p);
1436
+ const ys = rowsAny(mask);
1437
+ const top = ys[0];
1438
+ const bot = ys[ys.length - 1];
1439
+ const y0 = top + 15;
1440
+ const span = bot - 6 - y0;
1441
+ const why: Placement[] = [];
1442
+ const pts: Array<[number, number]> = [0, 1, 2].map((k) => {
1443
+ const w = placement('mask', `link ${k}: the centroid x of ${p.name}'s art in the 24-row band at ${k}/3 of the way from 15 px below its top row to 6 px above its bottom row`, {
1444
+ parts: [p.name],
1445
+ constants: [konst('share_of_span', k / 3)],
1446
+ });
1447
+ why.push(w);
1448
+ return [orSaid(bandX(mask, y0 + (k * span) / 3), center(p)[0], 'no art in the band: the centre x of its box', w), y0 + (k * span) / 3];
1449
+ });
1450
+ const tipWhy = placement('mask', `3 px above ${p.name}'s bottom row, at its centroid x in the band 6 px above that row`, { parts: [p.name] });
1451
+ C(`sleeve_${s}`, 'chest', pts, [orSaid(bandX(mask, bot - 6), pts[pts.length - 1][0], "no art in the band: the last link's x", tipWhy), bot - 3], why, tipWhy);
1452
+ tracks.push({ chain: `sleeve_${s}`, amps: [sgn * 0.2, sgn * 0.6, sgn * 1.2], period: 4.0, phase: 0.22 + (s === 'l' ? 0.05 : 0), lag: 0.08 });
1453
+ const [g, r] = gridR(p);
1454
+ const sx = pts[0][0];
1455
+ meshes[p.name] = { grid: g, r, segments: [['chest', rnd([sx, y0 - 14]), rnd([sx, y0 + 2])], `sleeve_${s}`] };
1456
+ }
1457
+ notes.push('handwear is two blobs: one sleeve chain per mesh, chest only at the shoulder');
1458
+ } else if (hw.length > 0) {
1459
+ sleeveRule = `the one-blob sleeve rule (both chains in one mesh, split at the eye axis x=${pyFixed(axis, 0)})`;
1460
+ const segs: Segment[] = [['chest', rnd([chest[0], chest[1] - 40]), rnd([chest[0], hip[1]])]];
1461
+ let mask = newMask(P.W, P.H);
1462
+ for (const p of hw) mask = union(mask, P.alpha(p));
1463
+ const ys = rowsAny(mask);
1464
+ const bot = ys[ys.length - 1];
1465
+ for (const [s, sgn] of SIDES) {
1466
+ const span = bot - chest[1];
1467
+ // The outer half of the sleeve on that side, away from the axis.
1468
+ const pts: Array<[number, number]> = [];
1469
+ const why: Placement[] = [];
1470
+ const hwNames = hw.map((q) => q.name);
1471
+ for (let k = 0; k < 3; k++) {
1472
+ const y = chest[1] + (k * span) / 3;
1473
+ const xs = sideOf(colsAny(mask, pyInt(y) - 12, pyInt(y) + 12), s, axis);
1474
+ const share = k === 0 ? 0.22 : 0.5 * (1 - 0.35 * k);
1475
+ why.push(
1476
+ placement('mask', `link ${k}: ${share} of the way from the outer to the inner edge of the handwear on the ${s} side of the eye axis, in the 24-row band at ${k}/3 of the way from chest's y down to the handwear's bottom row`, {
1477
+ parts: hwNames,
1478
+ bones: ['chest'],
1479
+ frame: ['eye_axis'],
1480
+ constants: [konst('share_outer_to_inner', share), konst('share_of_span', k / 3)],
1481
+ fallback: xs.length === 0 ? 'no handwear on that side in the band: the eye axis x' : null,
1482
+ }),
1483
+ );
1484
+ if (xs.length === 0) {
1485
+ pts.push([axis, y]);
1486
+ continue;
1487
+ }
1488
+ const outer = s === 'r' ? xs[0] : xs[xs.length - 1];
1489
+ const inner = s === 'r' ? xs[xs.length - 1] : xs[0];
1490
+ // k = 0 is the shoulder, not the clasped hands.
1491
+ pts.push([outer + (k === 0 ? 0.22 : 0.5 * (1 - 0.35 * k)) * (inner - outer), y]);
1492
+ }
1493
+ const xs = sideOf(colsAny(mask, bot - 12, bot), s, axis);
1494
+ const tipx = xs.length > 0 ? (xs[0] + xs[xs.length - 1]) / 2 : pts[pts.length - 1][0];
1495
+ C(
1496
+ `sleeve_${s}`,
1497
+ 'chest',
1498
+ pts,
1499
+ [tipx, bot - 3],
1500
+ why,
1501
+ placement('mask', `3 px above the handwear's bottom row, half way across its art on the ${s} side of the eye axis in the bottom 12 rows`, {
1502
+ parts: hwNames,
1503
+ frame: ['eye_axis'],
1504
+ fallback: xs.length > 0 ? null : "no handwear on that side in those rows: the last link's x",
1505
+ }),
1506
+ );
1507
+ segs.push(`sleeve_${s}`);
1508
+ tracks.push({ chain: `sleeve_${s}`, amps: [sgn * 0.25, sgn * 0.9, sgn * 1.8], period: 4.0, phase: 0.22 + (s === 'l' ? 0.05 : 0), lag: 0.08 });
1509
+ }
1510
+ for (const p of hw) {
1511
+ const [g, r] = gridR(p);
1512
+ meshes[p.name] = { grid: g, r, segments: segs };
1513
+ }
1514
+ if (hw.length === 1) notes.push(`handwear is one blob (${hw[0].name}): both sleeves in one mesh, split at the eye axis x=${pyFixed(axis, 0)}`);
1515
+ }
1516
+
1517
+ // ---- outer robe (topwear)
1518
+ for (const p of tw) {
1519
+ const [g, r] = gridR(p);
1520
+ const mask = P.alpha(p);
1521
+ const top = chest[1] - 0.3 * fh;
1522
+ const bot = p.y + p.h;
1523
+ const segs: Segment[] = [
1524
+ ['chest', rnd([chest[0], p.y]), rnd([chest[0], hip[1] - 20])],
1525
+ ['hip', rnd([chest[0], hip[1] - 20]), rnd([chest[0], hip[1] + 100])],
1526
+ ];
1527
+ if (bw.length > 0 && bot < hip[1] + 0.25 * fh) {
1528
+ // A bodice that stops at the waist: robe chains down to the hem would fall off the art.
1529
+ meshes[p.name] = { grid: g, r, segments: segs };
1530
+ notes.push(`${p.name} ends at y=${bot} (hip ${pyFixed(hip[1], 0)}): no robe chains -- add a sash chain by hand if one hangs`);
1531
+ continue;
1532
+ }
1533
+ // The LINT tolerance; the reference dilated it once per side, to the same mask.
1534
+ const near = dilate(mask, 31);
1535
+ for (const [s, sgn] of SIDES) {
1536
+ const pts: Array<[number, number]> = [];
1537
+ const why: Placement[] = [];
1538
+ for (let k = 0; k < 4; k++) {
1539
+ const y = top + (k * (bot - 0.1 * (bot - top) - top)) / 3.3;
1540
+ const xs = sideOf(colsAny(mask, pyInt(y) - 12, pyInt(y) + 12), s, axis);
1541
+ const outer = xs.length > 0 ? (s === 'r' ? xs[0] : xs[xs.length - 1]) : axis;
1542
+ pts.push([outer + 0.2 * (axis - outer), y]);
1543
+ why.push(
1544
+ placement('mask', `link ${k}: 0.2 of the way from ${p.name}'s outer edge on the ${s} side to the eye axis, in the 24-row band ${k}/3.3 of the way from 0.3 face heights above chest's y to 0.1 of that span above its bottom`, {
1545
+ parts: [p.name],
1546
+ bones: ['chest'],
1547
+ frame: ['face_box', 'eye_axis'],
1548
+ constants: [konst('share_edge_to_axis', 0.2), konst('face_heights_above_chest', 0.3), konst('step_divisor', 3.3), konst('share_of_span_above_bottom', 0.1)],
1549
+ fallback: xs.length > 0 ? null : 'no art on that side in the band: the eye axis x',
1550
+ }),
1551
+ );
1552
+ }
1553
+ // The flare just above the hem.
1554
+ const xs2 = sideOf(colsAny(mask, bot - 60, bot - 20), s, axis);
1555
+ const o2 = xs2.length > 0 ? (s === 'r' ? xs2[0] : xs2[xs2.length - 1]) : pts[pts.length - 1][0];
1556
+ const tip: [number, number] = [o2 + 0.15 * (axis - o2), bot - 5];
1557
+ const tipWhy = placement('mask', `5 px above ${p.name}'s bottom, 0.15 of the way from its outer edge on the ${s} side (rows 60 to 20 px above its bottom) to the eye axis`, {
1558
+ parts: [p.name],
1559
+ frame: ['eye_axis'],
1560
+ constants: [konst('share_edge_to_axis', 0.15)],
1561
+ fallback: xs2.length > 0 ? null : "no art on that side in those rows: the last link's x as the edge",
1562
+ });
1563
+ if (!pts.every((q) => isOn(near, q[0], q[1]))) {
1564
+ notes.push(`robe_${s}: a link falls off ${p.name} (the layer narrows above its hem) -> dropped`);
1565
+ continue;
1566
+ }
1567
+ C(`robe_${s}`, 'chest', pts, tip, why, tipWhy);
1568
+ segs.push(`robe_${s}`);
1569
+ tracks.push({ chain: `robe_${s}`, amps: [sgn * 0.12, sgn * 0.3, sgn * 0.6, sgn * 1.1], period: 4.0, phase: 0.25 + (s === 'l' ? 0.04 : 0), lag: 0.07 });
1570
+ }
1571
+ meshes[p.name] = { grid: g, r, segments: segs };
1572
+ }
1573
+
1574
+ // ---- skirt (bottomwear)
1575
+ for (const p of bw) {
1576
+ const [g, r] = gridR(p);
1577
+ const mask = P.alpha(p);
1578
+ const bot = p.y + p.h;
1579
+ const y0 = hip[1] + 20;
1580
+ const segs: Segment[] = [
1581
+ ['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
1582
+ ['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
1583
+ ];
1584
+ for (const [tag, f, ph] of [
1585
+ ['skirt_r', 0.3, 0.3],
1586
+ ['skirt_c', 0.5, 0.34],
1587
+ ['skirt_l', 0.7, 0.38],
1588
+ ] as const) {
1589
+ const why: Placement[] = [];
1590
+ const pts: Array<[number, number]> = [0, 1, 2].map((k) => {
1591
+ const w = placement('mask', `link ${k}: ${f} of the way across ${p.name}'s art in the 24-row band at ${k}/3 of the way from 20 px below hip's y to 4 px above its bottom`, {
1592
+ parts: [p.name],
1593
+ bones: ['hip'],
1594
+ constants: [konst('share_across', f), konst('share_of_span', k / 3)],
1595
+ });
1596
+ why.push(w);
1597
+ return [orSaid(bandX(mask, y0 + (k * (bot - 4 - y0)) / 3, { frac: f }), p.x + f * p.w, `no art in the band: ${f} of its box's width`, w), y0 + (k * (bot - 4 - y0)) / 3];
1598
+ });
1599
+ const tipWhy = placement('mask', `4 px above ${p.name}'s bottom, ${f} of the way across its art in the band 10 px above its bottom`, { parts: [p.name], constants: [konst('share_across', f)] });
1600
+ C(tag, 'hip', pts, [orSaid(bandX(mask, bot - 10, { frac: f }), pts[pts.length - 1][0], "no art in the band: the last link's x", tipWhy), bot - 4], why, tipWhy);
1601
+ segs.push(tag);
1602
+ tracks.push({ chain: tag, amps: [0.15, 0.35, 0.7], period: 4.0, phase: ph, lag: 0.07 });
1603
+ }
1604
+ meshes[p.name] = { grid: g, r, segments: segs };
1605
+ }
1606
+
1607
+ // Anything no rule claimed rides its nearest trunk bone rigidly, so the rig stage never meets an unassigned part.
1608
+ for (const p of P.recs) {
1609
+ if (!(p.name in meshes) && !(p.name in regions)) {
1610
+ const cy = center(p)[1];
1611
+ regions[p.name] = cy < neckY ? 'head' : cy < hip[1] ? 'chest' : 'hip';
1612
+ if (splitFrom(p.from)[0] === PAINTING_RUN) {
1613
+ notes.push(`${p.name} (${p.from}): a painting patch has no tag, so no rule -> region on ${regions[p.name]}, the nearest trunk bone; config.regions.${p.name} is the bone it rides`);
1614
+ } else notes.push(`${p.name} (${p.from}): no rule -> region on ${regions[p.name]}`);
1615
+ }
1616
+ }
1617
+ if (joints !== undefined) {
1618
+ notes.push(
1619
+ ...jointNotes(joints, {
1620
+ neckRule,
1621
+ hipRule,
1622
+ chest: chestAlong ? { neck: neckAt, hip } : null,
1623
+ sleeveRule,
1624
+ armWhy: armPlan === null ? null : armPlan.why,
1625
+ arms: armPlan === null ? null : armPlan.arms,
1626
+ wristSnap,
1627
+ eyes: (['r', 'l'] as const).map((s) => {
1628
+ const e = eyes.get(s);
1629
+ return e === undefined ? null : { bone: `eye_${s}`, part: (ew[s] as PartRecord).name, at: e };
1630
+ }),
1631
+ }),
1632
+ );
1633
+ }
1634
+ return { proposal: { bones, meshes, regions, motion: blink === undefined ? { duration: 4.0, tracks } : { duration: 4.0, tracks, blink }, notes }, basis: { frame, bones: records } };
1635
+ }
1636
+
1637
+ // ---------------------------------------------------------------------------
1638
+ // external keypoints (issue #75)
1639
+ // ---------------------------------------------------------------------------
1640
+
1641
+ /**
1642
+ * How far a wrist joint may lie from a handwear part's art and still be on
1643
+ * it: the LINT's own tolerance, the part's mask dilated this many px each way
1644
+ * (a `2 * 15 + 1` square). `propose --wrist-snap <px>` (issue #199) extends
1645
+ * it for a wrist on no part; it never narrows it.
1646
+ */
1647
+ export const WRIST_ON_PART_PX = 15;
1648
+
1649
+ /**
1650
+ * One arm placed from joints: its side (the subject's), its three joints in
1651
+ * rig px, the handwear part whose art holds its wrist, and `snap` — `null`
1652
+ * when the wrist is within {@link WRIST_ON_PART_PX} of that part's art, else
1653
+ * the distance `--wrist-snap` bound it across.
1654
+ */
1655
+ interface JointArm {
1656
+ side: 'r' | 'l';
1657
+ shoulder: [number, number];
1658
+ elbow: [number, number];
1659
+ wrist: [number, number];
1660
+ part: PartRecord;
1661
+ snap: number | null;
1662
+ }
1663
+
1664
+ /**
1665
+ * The distance from the pixel holding `at` (truncated, as {@link isOn} reads
1666
+ * it) to the nearest pixel of `m`'s art by the larger of the x and y gaps
1667
+ * (Chebyshev) — the measure of the dilation that holds a wrist within
1668
+ * {@link WRIST_ON_PART_PX}, so a wrist this far off its part is on the part's
1669
+ * mask dilated `2 * d + 1`. Infinity on a mask with no art.
1670
+ */
1671
+ function artDistance(m: Mask, at: readonly [number, number]): number {
1672
+ const xi = pyInt(at[0]);
1673
+ const yi = pyInt(at[1]);
1674
+ let best = Infinity;
1675
+ for (let y = 0; y < m.height; y++) {
1676
+ const dy = Math.abs(y - yi);
1677
+ if (dy >= best) continue;
1678
+ for (let x = 0; x < m.width; x++) {
1679
+ if (m.data[y * m.width + x] !== 1) continue;
1680
+ const d = Math.max(dy, Math.abs(x - xi));
1681
+ if (d < best) best = d;
1682
+ }
1683
+ }
1684
+ return best;
1685
+ }
1686
+
1687
+ /**
1688
+ * The arms the sleeve chains are placed from, or why the sleeve rule places
1689
+ * them instead. An arm is complete when its shoulder, elbow and wrist all
1690
+ * have a position (and the shoulder and elbow are two points); a handwear
1691
+ * part carries an arm when the wrist lies on its art within the LINT's own
1692
+ * tolerance (the mask dilated 15 px each way). The joints are used only when
1693
+ * every complete arm's wrist is on exactly one part and every handwear part
1694
+ * carries one: a part left to the rule beside parts placed from joints would
1695
+ * be read by two rules at once. Sides come from the joints, not from the eye
1696
+ * axis, so a figure seen from behind or lying across the axis is paired by
1697
+ * where its wrists are.
1698
+ *
1699
+ * Issue #199: a wrist on no part within that tolerance is measured to every
1700
+ * part by the same Chebyshev distance ({@link artDistance}). With
1701
+ * `wristSnap` (an integer above {@link WRIST_ON_PART_PX}, the CLI's to hold)
1702
+ * it is bound to the one nearest part when that part is at most `wristSnap`
1703
+ * px away; two parts tied nearest bind neither, as a wrist on two parts
1704
+ * does not. Otherwise the why names the nearest part and its distance, the
1705
+ * tolerance that would have bound it.
1706
+ */
1707
+ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints, wristSnap: number | null): { arms: JointArm[] | null; why: string | null } {
1708
+ const complete: Array<Omit<JointArm, 'part' | 'snap'>> = [];
1709
+ const gaps: string[] = [];
1710
+ for (const s of ['r', 'l'] as const) {
1711
+ const sh = joints.joints[`${s}_shoulder`].at;
1712
+ const el = joints.joints[`${s}_elbow`].at;
1713
+ const wr = joints.joints[`${s}_wrist`].at;
1714
+ if (sh === null || el === null || wr === null) {
1715
+ const absent = [sh === null ? `${s}_shoulder` : null, el === null ? `${s}_elbow` : null, wr === null ? `${s}_wrist` : null].filter((n): n is string => n !== null);
1716
+ gaps.push(`${absent.join(', ')} ${absent.length === 1 ? 'has' : 'have'} no position`);
1717
+ } else if (sh[0] === el[0] && sh[1] === el[1]) gaps.push(`${s}_shoulder and ${s}_elbow are one point, so the upper arm has no direction`);
1718
+ else complete.push({ side: s, shoulder: sh, elbow: el, wrist: wr });
1719
+ }
1720
+ if (complete.length === 0) return { arms: null, why: `no arm has its shoulder, elbow and wrist all given (${gaps.join('; ')})` };
1721
+ const near = new Map(hw.map((p) => [p, dilate(P.alpha(p), 2 * WRIST_ON_PART_PX + 1)]));
1722
+ const arms: JointArm[] = [];
1723
+ for (const a of complete) {
1724
+ const on = hw.filter((p) => isOn(near.get(p) as Mask, a.wrist[0], a.wrist[1]));
1725
+ if (on.length > 1) return { arms: null, why: `${a.side}_wrist at ${fmtAt(a.wrist)} lies on the art of ${on.length} handwear parts (${on.map((p) => p.name).join(', ')}), so it does not say which sleeve it ends` };
1726
+ if (on.length === 1) {
1727
+ arms.push({ ...a, part: on[0], snap: null });
1728
+ continue;
1729
+ }
1730
+ const far = hw.map((p) => ({ p, d: artDistance(P.alpha(p), a.wrist) }));
1731
+ const d = Math.min(...far.map((q) => q.d));
1732
+ const nearest = far.filter((q) => q.d === d).map((q) => q.p);
1733
+ if (wristSnap !== null && d <= wristSnap && nearest.length === 1) {
1734
+ arms.push({ ...a, part: nearest[0], snap: d });
1735
+ continue;
1736
+ }
1737
+ const gap = 'by the larger of the x and y gaps';
1738
+ const reach = wristSnap === null ? `--wrist-snap ${Math.max(d, WRIST_ON_PART_PX + 1)} would bind it` : `beyond --wrist-snap ${wristSnap}`;
1739
+ const nearestIs =
1740
+ nearest.length === 1
1741
+ ? `the nearest is ${nearest[0].name}, ${d} px from its art ${gap} (${reach})`
1742
+ : `the nearest are ${nearest.map((p) => p.name).join(' and ')}, each ${d} px from its art ${gap}, so no tolerance says which`;
1743
+ return { arms: null, why: `${a.side}_wrist at ${fmtAt(a.wrist)} lies on the art of no handwear part within ${WRIST_ON_PART_PX} px, so it does not say which sleeve it ends; ${nearestIs}` };
1744
+ }
1745
+ const idle = hw.filter((p) => !arms.some((a) => a.part === p));
1746
+ if (idle.length > 0) {
1747
+ const why = gaps.length > 0 ? ` (${gaps.join('; ')})` : '';
1748
+ return { arms: null, why: `${idle.map((p) => p.name).join(', ')} ${idle.length === 1 ? 'carries' : 'carry'} the wrist of no arm whose three joints are given${why}` };
1749
+ }
1750
+ return { arms, why: null };
1751
+ }
1752
+
1753
+ /** A rig-px point as the notes and LINT lines print it: one decimal place. */
1754
+ function fmtAt(p: readonly [number, number]): string {
1755
+ return `[${pyFixed(p[0], 1)}, ${pyFixed(p[1], 1)}]`;
1756
+ }
1757
+
1758
+ /** How a joint is said: its state, where (rig px) when given, and the producer's score when it carries one. */
1759
+ function said(n: KeypointName, j: RigJoint): string {
1760
+ const score = j.score === null ? '' : ` (score ${JSON.stringify(j.score)}, the producer's)`;
1761
+ if (j.at === null) return j.state === 'occluded' ? `${n} occluded with no position${score}` : `${n} missing${j.listed ? '' : ' (not listed)'}${score}`;
1762
+ return `${n} ${j.state} at ${fmtAt(j.at)}${score}`;
1763
+ }
1764
+
1765
+ /** What a used joint was used as: given, or the producer's estimate. */
1766
+ function usedAs(j: RigJoint): string {
1767
+ return j.state === 'observed' ? 'used as given' : "used as the producer's estimate";
1768
+ }
1769
+
1770
+ interface JointNoteFacts {
1771
+ neckRule: string;
1772
+ hipRule: string;
1773
+ /** The ends the chest was placed half way between, when either is a joint. */
1774
+ chest: { neck: [number, number]; hip: [number, number] } | null;
1775
+ sleeveRule: string;
1776
+ armWhy: string | null;
1777
+ arms: JointArm[] | null;
1778
+ /** `propose --wrist-snap`, or `null` without it. */
1779
+ wristSnap: number | null;
1780
+ eyes: ReadonlyArray<{ bone: string; part: string; at: [number, number] } | null>;
1781
+ }
1782
+
1783
+ /**
1784
+ * The notes `--keypoints` adds, after every note the rules wrote: a header
1785
+ * naming the file's person, source and map; one line per joint the proposer
1786
+ * could use — used as given for bone X, used as the producer's estimate for
1787
+ * bone X, or a rule placing bone X in its stead, with the rule named; the
1788
+ * distance from an eye joint to an eye bone measured off its eyewhite, with
1789
+ * no bar; and one line naming every other joint and its state, so no joint
1790
+ * is left unsaid and none is shown as anything it is not.
1791
+ */
1792
+ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
1793
+ const J = joints.joints;
1794
+ const out: string[] = [];
1795
+ const [w, h] = joints.painting;
1796
+ const [W, H] = joints.rig;
1797
+ out.push(
1798
+ `keypoints: person "${joints.person}" from ${JSON.stringify(joints.source)}, painting ${w}x${h} px to the ${W}x${H} rig by the overlay's map (x * ${W}/${w}, y * ${H}/${h}); ` +
1799
+ 'a joint with a position places its bone in place of the rule, a joint without one leaves the bone to its rule, and no bone is authored from a joint that no rule places',
1800
+ );
1801
+ const told = new Set<KeypointName>();
1802
+ const tell = (n: KeypointName, line: string): void => {
1803
+ told.add(n);
1804
+ out.push(line);
1805
+ };
1806
+ tell('neck', J.neck.at !== null ? `${said('neck', J.neck)}: ${usedAs(J.neck)} for bone neck` : `${said('neck', J.neck)}: the rule "${f.neckRule}" placed bone neck instead`);
1807
+ const hipsGiven = J.r_hip.at !== null && J.l_hip.at !== null;
1808
+ for (const [n, other] of [
1809
+ ['r_hip', 'l_hip'],
1810
+ ['l_hip', 'r_hip'],
1811
+ ] as const) {
1812
+ if (hipsGiven) tell(n, `${said(n, J[n])}: ${usedAs(J[n])} for bone hip, the midpoint of r_hip and l_hip — a point between two joints, not a joint`);
1813
+ else if (J[n].at !== null) tell(n, `${said(n, J[n])}: not used — bone hip is the midpoint of r_hip and l_hip, and ${other} has no position; the rule "${f.hipRule}" placed bone hip instead`);
1814
+ else tell(n, `${said(n, J[n])}: the rule "${f.hipRule}" placed bone hip instead`);
1815
+ }
1816
+ if (f.chest !== null) out.push(`chest: its rule, half way from neck to hip, taken along the line from neck ${fmtAt(f.chest.neck)} to hip ${fmtAt(f.chest.hip)} rather than down the screen`);
1817
+ const standIn = f.sleeveRule === '' ? 'no handwear part is over 500 px, so no sleeve chain is placed' : `${f.sleeveRule} stands in`;
1818
+ for (const s of ['r', 'l'] as const) {
1819
+ const arm = f.arms?.find((a) => a.side === s) ?? null;
1820
+ for (const [joint, role] of [
1821
+ [`${s}_shoulder`, 'link 0'],
1822
+ [`${s}_elbow`, 'link 1'],
1823
+ [`${s}_wrist`, 'the tip'],
1824
+ ] as const) {
1825
+ const j = J[joint];
1826
+ const on = arm === null ? '' : arm.snap === null ? `the wrist lies on ${arm.part.name}` : `the wrist is bound to ${arm.part.name} by --wrist-snap`;
1827
+ if (arm !== null) tell(joint, `${said(joint, j)}: ${usedAs(j)} for chain sleeve_${s}, ${role} (${on})`);
1828
+ else if (j.at !== null && f.armWhy !== null) tell(joint, `${said(joint, j)}: not used — ${f.armWhy}; ${standIn}`);
1829
+ else if (j.at !== null) tell(joint, `${said(joint, j)}: not used — ${standIn}`);
1830
+ else tell(joint, `${said(joint, j)}: ${standIn}`);
1831
+ }
1832
+ // Issue #199: which wrist the declared tolerance bound, to which part, and how far.
1833
+ if (arm !== null && arm.snap !== null) {
1834
+ out.push(
1835
+ `wrist snap: ${s}_wrist at ${fmtAt(arm.wrist)} lies on no handwear part within ${WRIST_ON_PART_PX} px; --wrist-snap ${f.wristSnap} bound it to ${arm.part.name}, the nearest, ${arm.snap} px from its art by the larger of the x and y gaps`,
1836
+ );
1837
+ }
1838
+ }
1839
+ // Issue #199: two chains on one part are said, not left to share its mesh unremarked.
1840
+ const both = f.arms !== null && f.arms.length === 2 && f.arms[0].part === f.arms[1].part ? f.arms[0].part.name : null;
1841
+ if (both !== null) {
1842
+ out.push(
1843
+ `one handwear part holds both wrists: r_wrist and l_wrist both end on ${both}, so chains sleeve_r and sleeve_l share its mesh and its art bends with both arms; ` +
1844
+ `to separate them, cut one sleeve out of ${both} with an assemble.cuts entry (a polygon round it) and propose again`,
1845
+ );
1846
+ }
1847
+ (['r', 'l'] as const).forEach((s, i) => {
1848
+ const n = `${s}_eye` as const;
1849
+ const j = J[n];
1850
+ if (j.at === null) return;
1851
+ const e = f.eyes[i];
1852
+ if (e === null) tell(n, `${said(n, j)}: no bone takes it — an eye bone comes only from an eyewhite part`);
1853
+ else tell(n, `${said(n, j)}: bone ${e.bone} is measured off ${e.part} (its box centre ${fmtAt(e.at)}) and is not moved; the joint is ${pyFixed(Math.hypot(j.at[0] - e.at[0], j.at[1] - e.at[1]), 1)} px from it`);
1854
+ });
1855
+ const rest = KEYPOINT_NAMES.filter((n) => !told.has(n));
1856
+ if (rest.length > 0) out.push(`no rule reads ${rest.map((n) => said(n, J[n])).join(', ')} — no bone is authored from them`);
1857
+ return out;
1858
+ }
1859
+
1860
+ // ---------------------------------------------------------------------------
1861
+ // the lash that carries the crease (issue #26)
1862
+ // ---------------------------------------------------------------------------
1863
+
1864
+ /**
1865
+ * A lash reaching more than this share of its own height above its eyewhite's
1866
+ * top is noted. Measured on the two public examples: their four lashes reach
1867
+ * 4-6 px above the eyewhite, 17-26 % of their height (demo 26 % / 17 %,
1868
+ * sample 18 % / 23 %), which is the lash line itself. The bar sits above that
1869
+ * with a 9-point margin; the character issue #26 reported is not in this tree,
1870
+ * so no figure of its crease is quoted here.
1871
+ */
1872
+ export const LASH_ABOVE_SHARE_BAR = 0.35;
1873
+
1874
+ /**
1875
+ * A lash more than this many times the height of its pair is noted. The two
1876
+ * public examples' pairs are 24 / 23 px (1.04) and 22 / 22 px (1.00); the
1877
+ * pair issue #26 reported, 36x24 against 30x17, is 24 / 17 px (1.41).
1878
+ */
1879
+ export const LASH_PAIR_RATIO_BAR = 1.2;
1880
+
1881
+ function pct(v: number): string {
1882
+ return `${Math.round(v * 100)} %`;
1883
+ }
1884
+
1885
+ /**
1886
+ * The blink squashes each eye's parts about the eye bone, so anything painted
1887
+ * into the eyelash layer above the lid — a double-eyelid crease — is squashed
1888
+ * with it. A lash that reaches far above its eyewhite, or is much taller than
1889
+ * its pair, is noted; when some row between its top and the eyewhite's top
1890
+ * carries no art across the lash's whole width, the rows above the lowest such
1891
+ * row are proposed as a still piece (`motion.blink.still`) on the eye bone's
1892
+ * parent, `head`. The cut must be a clear row because the rig stage refuses
1893
+ * any other (`RIG_STILL_ROW_CLEAR`).
1894
+ */
1895
+ function lashStills(P: PartSet, ew: Record<'r' | 'l', PartRecord | null>, notes: string[]): Record<string, { row: number; bone: string }> {
1896
+ const out: Record<string, { row: number; bone: string }> = {};
1897
+ const lash: Record<'r' | 'l', PartRecord | null> = { r: P.byTag('eyelash-r')[0] ?? null, l: P.byTag('eyelash-l')[0] ?? null };
1898
+ for (const s of ['r', 'l'] as const) {
1899
+ const la = lash[s];
1900
+ const eye = ew[s];
1901
+ if (la === null || eye === null) continue;
1902
+ const pair = lash[s === 'r' ? 'l' : 'r'];
1903
+ const above = eye.y - la.y;
1904
+ const share = above / la.h;
1905
+ const ratio = pair === null ? null : la.h / pair.h;
1906
+ const tall = share > LASH_ABOVE_SHARE_BAR;
1907
+ const odd = ratio !== null && ratio > LASH_PAIR_RATIO_BAR;
1908
+ if (!tall && !odd) continue;
1909
+ const im = P.images.get(la.name) as Raster;
1910
+ let row: number | null = null;
1911
+ for (let y = eye.y - 1; y > la.y && row === null; y--) {
1912
+ const r = y - la.y;
1913
+ if (r >= la.h) continue;
1914
+ let clear = true;
1915
+ for (let x = 0; x < la.w && clear; x++) if (im.data[(r * la.w + x) * 4 + 3] > 0) clear = false;
1916
+ if (clear) row = y;
1917
+ }
1918
+ const why = [
1919
+ `reaches ${above} px above ${eye.name}'s top, ${pct(share)} of its ${la.h} px height (noted above ${pct(LASH_ABOVE_SHARE_BAR)})`,
1920
+ ...(pair === null || ratio === null ? [] : [`is ${pyFixed(ratio, 2)}x the height of ${pair.name}, ${pair.h} px (noted above ${pyFixed(LASH_PAIR_RATIO_BAR, 2)}x)`]),
1921
+ ].join(' and ');
1922
+ if (row !== null) {
1923
+ out[la.name] = { row, bone: 'head' };
1924
+ notes.push(`${la.name} (${la.from}) ${why}: the blink would squash whatever is painted there, a crease included — rows above ${row} (a row with no art) split off as ${la.name}_still on head (motion.blink.still), which the blink does not move`);
1925
+ } else {
1926
+ notes.push(
1927
+ `${la.name} (${la.from}) ${why}: the blink squashes whatever is painted there, a crease included — no row between its top and the lid is clear across its width, so it is not split; if a crease is painted in, clear a row between it and the lash line and set motion.blink.still by hand`,
1928
+ );
1929
+ }
1930
+ }
1931
+ return out;
1932
+ }
1933
+
1934
+ // ---------------------------------------------------------------------------
1935
+ // expand, lint, compare
1936
+ // ---------------------------------------------------------------------------
1937
+
1938
+ export interface ExpandedBone {
1939
+ parent: string;
1940
+ x: number;
1941
+ y: number;
1942
+ }
1943
+
1944
+ /** Config bones -> every bone by name (chain links as `<chain><i>`), plus the polylines to draw. */
1945
+ export function expand(bones: readonly BoneEntry[]): { byName: Map<string, ExpandedBone>; chains: Array<[string, Point[]]> } {
1946
+ const byName = new Map<string, ExpandedBone>();
1947
+ const chains: Array<[string, Point[]]> = [];
1948
+ for (const e of bones) {
1949
+ if ('chain' in e) {
1950
+ let previous = e.parent;
1951
+ e.points.forEach((q, i) => {
1952
+ const n = `${e.chain}${i}`;
1953
+ byName.set(n, { parent: i === 0 ? e.parent : previous, x: q[0], y: q[1] });
1954
+ previous = n;
1955
+ });
1956
+ chains.push([e.chain, [...e.points, e.tip]]);
1957
+ } else {
1958
+ byName.set(e.name, { parent: e.parent, x: e.at[0], y: e.at[1] });
1959
+ if (e.tip !== undefined) chains.push([e.name, [e.at, e.tip]]);
1960
+ }
1961
+ }
1962
+ return { byName, chains };
1963
+ }
1964
+
1965
+ /** A chain link off the art of a mesh it is a candidate for. */
1966
+ export interface OffArtFinding {
1967
+ kind: 'off-art';
1968
+ bone: string;
1969
+ mesh: string;
1970
+ at: [number, number];
1971
+ }
1972
+
1973
+ /** The hip at or above the chest: breathing and every skirt chain hang from the wrong end of the torso. */
1974
+ export interface HipAboveChestFinding {
1975
+ kind: 'hip-not-below-chest';
1976
+ hip: [number, number];
1977
+ chest: [number, number];
1978
+ }
1979
+
1980
+ /** The hip above `HIP_MIN_FRACTION` of the figure's height: a hip at the shoulders. */
1981
+ export interface HipTooHighFinding {
1982
+ kind: 'hip-too-high';
1983
+ hip: [number, number];
1984
+ /** The lowest y the hip may take, `figure.top + HIP_MIN_FRACTION * (figure.bot - figure.top)`. */
1985
+ limit: number;
1986
+ figure: { top: number; bot: number };
1987
+ }
1988
+
1989
+ /** With joints: the chest bone does not fall strictly between the neck joint and the hip joints' midpoint along the line from one to the other. */
1990
+ export interface ChestNotBetweenFinding {
1991
+ kind: 'chest-not-between';
1992
+ chest: [number, number];
1993
+ /** Where the chest falls along neck -> hips, 0 at the neck joint and 1 at the hips' midpoint. */
1994
+ t: number;
1995
+ neck: [number, number];
1996
+ hips: [number, number];
1997
+ }
1998
+
1999
+ /** With joints: the hip bone does not fall further along neck -> hips than the chest bone — the joint-frame form of `hip-not-below-chest`. */
2000
+ export interface HipNotPastChestFinding {
2001
+ kind: 'hip-not-past-chest';
2002
+ hip: [number, number];
2003
+ chest: [number, number];
2004
+ tHip: number;
2005
+ tChest: number;
2006
+ }
2007
+
2008
+ /** With joints: the hip bone is nearer the neck joint than the hips' midpoint — the joint-frame form of `hip-too-high`. */
2009
+ export interface HipNearerNeckFinding {
2010
+ kind: 'hip-nearer-neck';
2011
+ hip: [number, number];
2012
+ neck: [number, number];
2013
+ hips: [number, number];
2014
+ dNeck: number;
2015
+ dHips: number;
2016
+ }
2017
+
2018
+ /** With joints: a sleeve chain's link or tip does not fall further along its shoulder -> wrist line than the one before it. */
2019
+ export interface ChainNotAdvancingFinding {
2020
+ kind: 'chain-not-advancing';
2021
+ /** `<chain><i>`, or `<chain> tip`. */
2022
+ point: string;
2023
+ at: [number, number];
2024
+ /** Px along the joints' line from the root joint. */
2025
+ t: number;
2026
+ previous: string;
2027
+ tPrevious: number;
2028
+ fromJoint: KeypointName;
2029
+ toJoint: KeypointName;
2030
+ }
2031
+
2032
+ export type LintFinding = OffArtFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding;
2033
+
2034
+ /** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
2035
+ export interface LintBasis {
2036
+ person: string;
2037
+ torso: { read: true; neck: [number, number]; hips: [number, number] } | { read: false; why: string };
2038
+ chains: Array<{ chain: string; joints: [KeypointName, KeypointName]; line: { from: [number, number]; to: [number, number] } | null; why: string | null }>;
2039
+ }
2040
+
2041
+ /** What a check could not look at, said rather than skipped. */
2042
+ export interface LintResult {
2043
+ findings: LintFinding[];
2044
+ /** Meshes naming no part in parts.json: nothing to lint them against. */
2045
+ unknownMeshes: string[];
2046
+ /** Single bones the torso checks read (`hip`, `chest`) that the bones do not hold: those checks did not run. */
2047
+ missingTorsoBones: string[];
2048
+ /** Present only under `--keypoints`: the rule set that read the torso and the chains. */
2049
+ basis?: LintBasis;
2050
+ /** 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). */
2051
+ coverage: BoneCoverage[];
2052
+ }
2053
+
2054
+ /** What lint's coverage says of a bone: read and named by no LINT line, read and named by one, or read by no check. */
2055
+ export type CoverageOutcome = 'checked, clean' | 'checked, LINT' | 'not checked';
2056
+
2057
+ export const COVERAGE_OUTCOMES: readonly CoverageOutcome[] = ['checked, clean', 'checked, LINT', 'not checked'];
2058
+
2059
+ /**
2060
+ * One bone's coverage (issue #86). `read` names each check that read it — a
2061
+ * finding kind, and for the off-art check the mesh — and `lint` counts the
2062
+ * LINT lines that name it. With `read` empty, `why` says why no check did:
2063
+ * its role, the kind of segment that names it, a mesh that names no part, a
2064
+ * torso bone absent, a chain the joints declare no line for. A role is
2065
+ * {@link configRoles}' — read off the spec, never off a name — and `null`
2066
+ * only when the spec handed to `lint` carries no `regions` and `motion`.
2067
+ */
2068
+ export interface BoneCoverage {
2069
+ bone: string;
2070
+ role: RoleOf | null;
2071
+ read: string[];
2072
+ lint: number;
2073
+ why: string[];
2074
+ }
2075
+
2076
+ /** A coverage record's outcome. */
2077
+ export function coverageOutcome(c: BoneCoverage): CoverageOutcome {
2078
+ return c.read.length === 0 ? 'not checked' : c.lint > 0 ? 'checked, LINT' : 'checked, clean';
2079
+ }
2080
+
2081
+ /**
2082
+ * Chain links that sit off the art of a mesh they are a candidate for
2083
+ * (the part's mask dilated 15 px each way). A chain point in the background
2084
+ * still weights by distance, but it is almost always a mis-pick.
2085
+ *
2086
+ * As in the reference, only CHAIN segments are linted: a string segment names
2087
+ * the links `<segment><digits>`, so a single bone named as a segment has none,
2088
+ * and an explicit `[bone, from, to]` segment is a line, not an origin.
2089
+ *
2090
+ * Then the torso, which the reference did not lint: a single bone `hip` that
2091
+ * is not below a single bone `chest` (larger y), and a `hip` above
2092
+ * `HIP_MIN_FRACTION` of the figure's height (every part's union, top row to
2093
+ * bottom row). Either one is a rig whose breath and skirt hang from the
2094
+ * shoulders, and a mesh check cannot see it.
2095
+ *
2096
+ * With `joints` (`--keypoints`, issue #75) the torso is read by the relations
2097
+ * the joints declare instead of by screen y, wherever the neck and both hip
2098
+ * joints have a position: along the line from the neck joint to the hips'
2099
+ * midpoint, the chest must fall strictly between them, the hip past the
2100
+ * chest, and the hip nearer the hips than the neck — so a seated or lying
2101
+ * figure is held to its own torso, not to "larger y is lower". Each sleeve
2102
+ * chain is read along its side's shoulder -> wrist: every link and the tip
2103
+ * must fall further along that line than the one before. No relation carries
2104
+ * a threshold; each is a definition. The off-art check is unchanged.
2105
+ *
2106
+ * Issue #86: beside the findings, `coverage` holds one record per bone of the
2107
+ * spec — the checks that read it, recorded as each check runs, or why none
2108
+ * did (its role, the segment that names it, a region, a mesh naming no part,
2109
+ * a torso bone absent, a chain the joints declare no line for). No check is
2110
+ * widened: an off-art check on single bones and explicit segments' ends is
2111
+ * not run, and the coverage says those bones are not checked.
2112
+ */
2113
+ export function lint(
2114
+ P: PartSet,
2115
+ spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[] },
2116
+ joints?: RigJoints,
2117
+ ): LintResult {
2118
+ const { byName } = expand(spec.bones);
2119
+ const parts = new Map(P.recs.map((p) => [p.name, p]));
2120
+ const findings: LintFinding[] = [];
2121
+ const unknownMeshes: string[] = [];
2122
+ // Issue #86: which check read which bone, recorded where each check reads it.
2123
+ const read = new Map<string, string[]>([...byName.keys()].map((n) => [n, []]));
2124
+ const readBy = (n: string, check: string): void => {
2125
+ const list = read.get(n);
2126
+ if (list !== undefined && !list.includes(check)) list.push(check);
2127
+ };
2128
+ for (const [mname, m] of Object.entries(spec.meshes)) {
2129
+ const part = parts.get(mname);
2130
+ if (part === undefined) {
2131
+ unknownMeshes.push(mname);
2132
+ continue;
2133
+ }
2134
+ const mask = dilate(P.alpha(part), 31);
2135
+ const names: string[] = [];
2136
+ for (const sp of m.segments) {
2137
+ if (typeof sp !== 'string') continue;
2138
+ for (const n of byName.keys()) if (n.startsWith(sp) && /^[0-9]+$/.test(n.slice(sp.length))) names.push(n);
2139
+ }
2140
+ for (const n of names) {
2141
+ const b = byName.get(n) as ExpandedBone;
2142
+ const x = pyInt(b.x);
2143
+ const y = pyInt(b.y);
2144
+ readBy(n, `off-art on mesh ${pyRepr(mname)}`);
2145
+ if (!isOn(mask, x, y)) findings.push({ kind: 'off-art', bone: n, mesh: mname, at: [x, y] });
2146
+ }
2147
+ }
2148
+ const single = (n: string): [number, number] | null => {
2149
+ const e = spec.bones.find((b) => 'name' in b && b.name === n);
2150
+ return e !== undefined && 'name' in e ? [e.at[0], e.at[1]] : null;
2151
+ };
2152
+ const hip = single('hip');
2153
+ const chest = single('chest');
2154
+ const missingTorsoBones = [hip === null ? 'hip' : null, chest === null ? 'chest' : null].filter((n): n is string => n !== null);
2155
+ // Issue #75: with joints, where the torso joints are given, the torso is
2156
+ // read along the line they declare and the two screen-y rules do not run.
2157
+ const basis = joints === undefined ? undefined : lintBasis(spec.bones, joints);
2158
+ const T = basis !== undefined && basis.torso.read ? basis.torso : null;
2159
+ if (T !== null) {
2160
+ const v: [number, number] = [T.hips[0] - T.neck[0], T.hips[1] - T.neck[1]];
2161
+ const L2 = v[0] * v[0] + v[1] * v[1];
2162
+ const along = (p: readonly [number, number]): number => ((p[0] - T.neck[0]) * v[0] + (p[1] - T.neck[1]) * v[1]) / L2;
2163
+ if (chest !== null) readBy('chest', 'chest-not-between');
2164
+ if (hip !== null && chest !== null) for (const n of ['hip', 'chest']) readBy(n, 'hip-not-past-chest');
2165
+ if (hip !== null) readBy('hip', 'hip-nearer-neck');
2166
+ if (chest !== null && !(along(chest) > 0 && along(chest) < 1)) findings.push({ kind: 'chest-not-between', chest, t: along(chest), neck: T.neck, hips: T.hips });
2167
+ if (hip !== null && chest !== null && along(hip) <= along(chest)) findings.push({ kind: 'hip-not-past-chest', hip, chest, tHip: along(hip), tChest: along(chest) });
2168
+ if (hip !== null) {
2169
+ const dNeck = Math.hypot(hip[0] - T.neck[0], hip[1] - T.neck[1]);
2170
+ const dHips = Math.hypot(hip[0] - T.hips[0], hip[1] - T.hips[1]);
2171
+ if (dNeck < dHips) findings.push({ kind: 'hip-nearer-neck', hip, neck: T.neck, hips: T.hips, dNeck, dHips });
2172
+ }
2173
+ } else {
2174
+ if (hip !== null && chest !== null) for (const n of ['hip', 'chest']) readBy(n, 'hip-not-below-chest');
2175
+ if (hip !== null) readBy('hip', 'hip-too-high');
2176
+ if (hip !== null && chest !== null && hip[1] <= chest[1]) findings.push({ kind: 'hip-not-below-chest', hip, chest });
2177
+ if (hip !== null) {
2178
+ const figure = figureExtent(P);
2179
+ const limit = figure.top + HIP_MIN_FRACTION * (figure.bot - figure.top);
2180
+ if (hip[1] < limit) findings.push({ kind: 'hip-too-high', hip, limit, figure });
2181
+ }
2182
+ }
2183
+ if (basis !== undefined) {
2184
+ for (const c of basis.chains) {
2185
+ if (c.line === null) continue;
2186
+ const { from, to } = c.line;
2187
+ const len = Math.hypot(to[0] - from[0], to[1] - from[1]);
2188
+ const e = spec.bones.find((b) => 'chain' in b && b.chain === c.chain);
2189
+ if (e === undefined || !('chain' in e)) continue;
2190
+ const pts: Array<[string, Point]> = [...e.points.map((q, i): [string, Point] => [`${c.chain}${i}`, q]), [`${c.chain} tip`, e.tip]];
2191
+ e.points.forEach((_, i) => readBy(`${c.chain}${i}`, `chain-not-advancing along ${c.joints[0]} -> ${c.joints[1]}`));
2192
+ const proj = pts.map(([, q]) => ((q[0] - from[0]) * (to[0] - from[0]) + (q[1] - from[1]) * (to[1] - from[1])) / len);
2193
+ for (let i = 1; i < pts.length; i++) {
2194
+ if (proj[i] <= proj[i - 1]) findings.push({ kind: 'chain-not-advancing', point: pts[i][0], at: pts[i][1], t: proj[i], previous: pts[i - 1][0], tPrevious: proj[i - 1], fromJoint: c.joints[0], toJoint: c.joints[1] });
2195
+ }
2196
+ }
2197
+ }
2198
+ const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
2199
+ return basis === undefined ? { findings, unknownMeshes, missingTorsoBones, coverage } : { findings, unknownMeshes, missingTorsoBones, basis, coverage };
2200
+ }
2201
+
2202
+ /** 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. */
2203
+ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
2204
+ switch (f.kind) {
2205
+ case 'off-art':
2206
+ return [f.bone];
2207
+ case 'hip-not-below-chest':
2208
+ case 'hip-not-past-chest':
2209
+ return ['hip', 'chest'];
2210
+ case 'hip-too-high':
2211
+ case 'hip-nearer-neck':
2212
+ return ['hip'];
2213
+ case 'chest-not-between':
2214
+ return ['chest'];
2215
+ case 'chain-not-advancing': {
2216
+ if (!f.point.endsWith(' tip')) return [f.point];
2217
+ const chain = f.point.slice(0, -' tip'.length);
2218
+ const e = bones.find((b) => 'chain' in b && b.chain === chain);
2219
+ return e !== undefined && 'chain' in e ? [`${chain}${e.points.length - 1}`] : [];
2220
+ }
2221
+ }
2222
+ }
2223
+
2224
+ /**
2225
+ * Per bone: the checks that read it (recorded in `lint` as each check ran),
2226
+ * the LINT lines naming it, and — for a bone no check read — why, from what
2227
+ * names it in the spec. The off-art check reads only the links of a chain a
2228
+ * string segment names, so a single bone named as a segment (it has no
2229
+ * links) and an explicit `[bone, from, to]` segment (a line, not an origin)
2230
+ * are not read, and a region or a still piece binds its bone rigidly with
2231
+ * nothing to read. A control or a target binds nothing, so may sit off the
2232
+ * art by design: its role is the reason, never a finding.
2233
+ */
2234
+ function lintCoverage(
2235
+ spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[] },
2236
+ byName: ReadonlyMap<string, ExpandedBone>,
2237
+ read: ReadonlyMap<string, string[]>,
2238
+ findings: readonly LintFinding[],
2239
+ unknownMeshes: readonly string[],
2240
+ basis: LintBasis | undefined,
2241
+ ): BoneCoverage[] {
2242
+ const roles =
2243
+ spec.regions !== undefined && spec.motion !== undefined ? configRoles({ bones: [...spec.bones], meshes: { ...spec.meshes }, regions: { ...spec.regions }, motion: spec.motion, constraints: spec.constraints }) : null;
2244
+ const chains = new Map<string, number>();
2245
+ for (const e of spec.bones) if ('chain' in e) chains.set(e.chain, e.points.length);
2246
+ const linked = (n: string): string | null => {
2247
+ for (const [c, k] of chains) if (n.startsWith(c) && /^[0-9]+$/.test(n.slice(c.length)) && Number(n.slice(c.length)) < k) return c;
2248
+ return null;
2249
+ };
2250
+ const named = new Map<string, number>();
2251
+ for (const f of findings) for (const b of findingBones(f, spec.bones)) named.set(b, (named.get(b) ?? 0) + 1);
2252
+ const out: BoneCoverage[] = [];
2253
+ for (const bone of byName.keys()) {
2254
+ const r = read.get(bone) ?? [];
2255
+ const role = roles === null ? null : (roles.get(bone) ?? null);
2256
+ const why: string[] = [];
2257
+ if (r.length === 0) {
2258
+ const chain = linked(bone);
2259
+ if (role === null) why.push('its role is not read: the spec handed to lint carries no regions and motion');
2260
+ else if (role.role !== 'deforms') {
2261
+ const byDesign = role.role === 'control' || role.role === 'target' ? `, and a ${role.role} may sit off the art by design` : '';
2262
+ why.push(`its role is ${role.role} (${role.why}): it binds nothing, so there is no art to hold it to${byDesign}`);
2263
+ }
2264
+ // What names it, grouped by kind: one reason per kind, every place named.
2265
+ const explicit: string[] = [];
2266
+ const single: string[] = [];
2267
+ const partless: string[] = [];
2268
+ for (const [mname, m] of Object.entries(spec.meshes)) {
2269
+ m.segments.forEach((sp, i) => {
2270
+ const at = `meshes.${mname}.segments[${i}]`;
2271
+ if (typeof sp !== 'string') {
2272
+ if (sp[0] === bone) explicit.push(at);
2273
+ } else if (sp === bone && chain === null) single.push(at);
2274
+ else if (chain !== null && sp === chain && unknownMeshes.includes(mname)) partless.push(`${at} (mesh ${JSON.stringify(mname)})`);
2275
+ });
2276
+ }
2277
+ const regions = Object.entries(spec.regions ?? {}).filter(([, b]) => b === bone).map(([part]) => `regions.${part}`);
2278
+ const stills = Object.entries(spec.motion?.blink?.still ?? {}).filter(([, st]) => st.bone === bone).map(([part]) => `motion.blink.still.${part}`);
2279
+ const s = (list: readonly string[], one: string, many: string): string => (list.length === 1 ? one : many);
2280
+ if (single.length > 0) why.push(`named as a single bone by ${single.join(', ')}: a single bone has no links, and the off-art check reads only the links <segment><digits> of a chain`);
2281
+ if (explicit.length > 0) {
2282
+ why.push(`bound by the explicit [bone, from, to] ${s(explicit, 'segment', 'segments')} ${explicit.join(', ')}: a segment is a line, not an origin, and the off-art check reads origins`);
2283
+ }
2284
+ if (partless.length > 0) why.push(`its chain is named by ${partless.join(', ')}, which ${s(partless, 'names', 'name')} no part in parts.json`);
2285
+ if (regions.length > 0) why.push(`bound rigidly by ${regions.join(', ')}: no check reads a region's bone`);
2286
+ if (stills.length > 0) why.push(`bound rigidly by ${stills.join(', ')}: no check reads a still piece's bone`);
2287
+ if (bone === 'chest' && !byName.has('hip')) why.push('the torso checks read chest beside a single bone "hip", which the bones do not hold');
2288
+ const c = basis?.chains.find((x) => x.chain === chain && x.line === null);
2289
+ if (c !== undefined) why.push(`lint chain ${c.chain}: not read against the joints (${c.why})`);
2290
+ if (why.length === 0) why.push('no mesh segment names it as a chain link of a mesh with a part, and no torso check reads it');
2291
+ }
2292
+ out.push({ bone, role, read: [...r], lint: named.get(bone) ?? 0, why });
2293
+ }
2294
+ return out;
2295
+ }
2296
+
2297
+ /** The sleeve chains a keypoint file can be read against, and the joints each runs between. */
2298
+ const ARM_CHAINS: ReadonlyArray<readonly [string, KeypointName, KeypointName]> = [
2299
+ ['sleeve_r', 'r_shoulder', 'r_wrist'],
2300
+ ['sleeve_l', 'l_shoulder', 'l_wrist'],
2301
+ ];
2302
+
2303
+ /**
2304
+ * What the joints let LINT read: the torso along the line from the neck joint
2305
+ * to the midpoint of the hip joints, when all three have a position and the
2306
+ * two ends are two points; and each sleeve chain (by its name, as `propose`
2307
+ * writes it) along its side's shoulder -> wrist, when both have a position.
2308
+ */
2309
+ function lintBasis(bones: readonly BoneEntry[], joints: RigJoints): LintBasis {
2310
+ const J = joints.joints;
2311
+ const absent = (['neck', 'r_hip', 'l_hip'] as const).filter((n) => J[n].at === null);
2312
+ let torso: LintBasis['torso'];
2313
+ if (absent.length > 0) torso = { read: false, why: `${absent.map((n) => said(n, J[n])).join(', ')}` };
2314
+ else {
2315
+ const neck = J.neck.at as [number, number];
2316
+ const r = J.r_hip.at as [number, number];
2317
+ const l = J.l_hip.at as [number, number];
2318
+ const hips: [number, number] = [(r[0] + l[0]) / 2, (r[1] + l[1]) / 2];
2319
+ // Two joints at one point declare no line: every share of the way along it would divide by zero.
2320
+ torso = neck[0] === hips[0] && neck[1] === hips[1] ? { read: false, why: `neck and the midpoint of r_hip and l_hip are one point, ${fmtAt(neck)}, so they declare no line` } : { read: true, neck, hips };
2321
+ }
2322
+ const chains = ARM_CHAINS.map(([chain, a, b]): LintBasis['chains'][number] => {
2323
+ if (!bones.some((e) => 'chain' in e && e.chain === chain)) return { chain, joints: [a, b], line: null, why: `no chain named ${chain}` };
2324
+ const from = J[a].at;
2325
+ const to = J[b].at;
2326
+ if (from === null || to === null) return { chain, joints: [a, b], line: null, why: [from === null ? said(a, J[a]) : null, to === null ? said(b, J[b]) : null].filter((s) => s !== null).join(', ') };
2327
+ if (from[0] === to[0] && from[1] === to[1]) return { chain, joints: [a, b], line: null, why: `${a} and ${b} are one point, ${fmtAt(from)}, so they declare no line` };
2328
+ return { chain, joints: [a, b], line: { from, to }, why: null };
2329
+ });
2330
+ return { person: joints.person, torso, chains };
2331
+ }
2332
+
2333
+ /**
2334
+ * The lines `propose --keypoints` prints before its LINT lines: which rule set
2335
+ * read the torso and on what basis, and which chains were read against which
2336
+ * joints. Without `--keypoints` nothing is printed, and the rule set is the
2337
+ * screen's, as it always was.
2338
+ */
2339
+ export function basisLines(b: LintBasis): string[] {
2340
+ const out: string[] = [];
2341
+ if (b.torso.read) {
2342
+ out.push(
2343
+ `lint rule set: joints (person "${b.person}") — the torso is read along the line from the neck joint ${fmtAt(b.torso.neck)} to the midpoint of r_hip and l_hip ${fmtAt(b.torso.hips)}: ` +
2344
+ 'the chest strictly between them, the hip past the chest, and the hip nearer the hips than the neck; the two screen-y torso rules do not run, and the off-art check is unchanged',
2345
+ );
2346
+ } else out.push(`lint rule set: screen — the torso joints do not declare a line (${b.torso.why}), so the hip is read by screen y as without keypoints; the off-art check is unchanged`);
2347
+ for (const c of b.chains) {
2348
+ out.push(c.line === null ? `lint chain ${c.chain}: not read against the joints (${c.why})` : `lint chain ${c.chain}: each link and the tip must advance from ${c.joints[0]} ${fmtAt(c.line.from)} toward ${c.joints[1]} ${fmtAt(c.line.to)}`);
2349
+ }
2350
+ return out;
2351
+ }
2352
+
2353
+ /**
2354
+ * The LINT line. The off-art line is the reference's, verbatim:
2355
+ * `LINT sleeve_r0 at [335, 330] is off the art of mesh 'sleeves'`; the
2356
+ * torso and chain lines keep its shape, `LINT <bone> at [x, y] …`.
2357
+ */
2358
+ export function lintLine(f: LintFinding): string {
2359
+ switch (f.kind) {
2360
+ case 'off-art':
2361
+ return `LINT ${f.bone} at ${pyIntList(f.at)} is off the art of mesh ${pyRepr(f.mesh)}`;
2362
+ case 'hip-not-below-chest':
2363
+ return `LINT hip at ${pyIntList(f.hip)} is not below chest at ${pyIntList(f.chest)}: the hip must have the larger y, or breathing and the skirt hang from the shoulders`;
2364
+ case 'hip-too-high':
2365
+ return `LINT hip at ${pyIntList(f.hip)} is above ${HIP_MIN_FRACTION} of the figure height (figure y ${f.figure.top}..${f.figure.bot}, so hip y must be at least ${pyFixed(f.limit, 1)}): a hip at the shoulders`;
2366
+ case 'chest-not-between':
2367
+ return `LINT chest at ${pyIntList(f.chest)} is not between the neck joint ${fmtAt(f.neck)} and the midpoint of the hip joints ${fmtAt(f.hips)}: it falls at ${pyFixed(f.t, 2)} of the way from one to the other, and strictly between 0 and 1 is required`;
2368
+ case 'hip-not-past-chest':
2369
+ 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`;
2370
+ case 'hip-nearer-neck':
2371
+ 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`;
2372
+ case 'chain-not-advancing':
2373
+ 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`;
2374
+ }
2375
+ }
2376
+
2377
+ export interface Comparison {
2378
+ rows: Array<[string, number]>;
2379
+ onlyConfig: string[];
2380
+ onlyProposal: string[];
2381
+ }
2382
+
2383
+ /** Per bone the two share, in the config's order: the distance between the proposal's origin and the config's, in rig px. */
2384
+ export function compare(proposal: readonly BoneEntry[], config: readonly BoneEntry[]): Comparison {
2385
+ const a = expand(proposal).byName;
2386
+ const b = expand(config).byName;
2387
+ const rows: Array<[string, number]> = [];
2388
+ for (const [n, cb] of b) {
2389
+ const pb = a.get(n);
2390
+ if (pb !== undefined) rows.push([n, Math.hypot(pb.x - cb.x, pb.y - cb.y)]);
2391
+ }
2392
+ return { rows, onlyConfig: [...b.keys()].filter((n) => !a.has(n)), onlyProposal: [...a.keys()].filter((n) => !b.has(n)) };
2393
+ }
2394
+
2395
+ /** The reference's compare printout, line for line. */
2396
+ export function compareLines(c: Comparison): string[] {
2397
+ const out = c.rows.map(([n, v]) => ` ${n.padEnd(12)} ${pyFixed(v, 1).padStart(6)} px`);
2398
+ const d = c.rows.map((r) => r[1]);
2399
+ if (d.length > 0) {
2400
+ out.push(
2401
+ `matched ${d.length} bones: median ${pyFixed(npMedian(d), 1)} px, mean ${pyFixed(npMean(d), 1)}, p90 ${pyFixed(npPercentile(d, 90), 1)}, max ${pyFixed(Math.max(...d), 1)}; ` +
2402
+ `<=10 px ${d.filter((v) => v <= 10).length}, <=25 px ${d.filter((v) => v <= 25).length}`,
2403
+ );
2404
+ } else out.push('matched 0 bones: the proposal and the config share no bone name, so there is no distance to state');
2405
+ out.push(`in config only (hand-added): ${pyStrList(c.onlyConfig)}`);
2406
+ out.push(`in proposal only: ${pyStrList(c.onlyProposal)}`);
2407
+ return out;
2408
+ }
2409
+
2410
+ // ---------------------------------------------------------------------------
2411
+ // the overlay
2412
+ // ---------------------------------------------------------------------------
2413
+
2414
+ const PALETTE: ReadonlyArray<readonly [number, number, number]> = [
2415
+ [230, 25, 75],
2416
+ [60, 180, 75],
2417
+ [0, 130, 200],
2418
+ [245, 130, 48],
2419
+ [145, 30, 180],
2420
+ [70, 190, 190],
2421
+ [240, 50, 230],
2422
+ [128, 128, 0],
2423
+ ];
2424
+
2425
+ function setPx(r: Raster, x: number, y: number, c: readonly [number, number, number]): void {
2426
+ if (x < 0 || y < 0 || x >= r.width || y >= r.height) return;
2427
+ const i = (y * r.width + x) * 4;
2428
+ r.data[i] = c[0];
2429
+ r.data[i + 1] = c[1];
2430
+ r.data[i + 2] = c[2];
2431
+ r.data[i + 3] = 255;
2432
+ }
2433
+
2434
+ function text(r: Raster, s: string, x0: number, y0: number, c: readonly [number, number, number]): void {
2435
+ const plot = (x: number, y: number): void => setPx(r, x, y, c);
2436
+ let x = Math.round(x0);
2437
+ const y = Math.round(y0);
2438
+ for (const ch of s) {
2439
+ if (ch === '_') for (let i = 0; i < 5; i++) plot(x + i, y + GLYPH_H - 1);
2440
+ else if (ch !== ' ') drawText(ch, x, y, 1, plot);
2441
+ x += 6;
2442
+ }
2443
+ }
2444
+
2445
+ /** A line `width` px thick: a square brush stepped along the segment one pixel at a time. */
2446
+ function line(r: Raster, a: readonly [number, number], b: readonly [number, number], width: number, c: readonly [number, number, number]): void {
2447
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]))));
2448
+ const lo = -Math.floor((width - 1) / 2);
2449
+ for (let i = 0; i <= steps; i++) {
2450
+ const x = Math.round(a[0] + ((b[0] - a[0]) * i) / steps);
2451
+ const y = Math.round(a[1] + ((b[1] - a[1]) * i) / steps);
2452
+ for (let dy = lo; dy < lo + width; dy++) for (let dx = lo; dx < lo + width; dx++) setPx(r, x + dx, y + dy, c);
2453
+ }
2454
+ }
2455
+
2456
+ function dot(r: Raster, cx: number, cy: number): void {
2457
+ for (let dy = -4; dy <= 4; dy++) {
2458
+ for (let dx = -4; dx <= 4; dx++) {
2459
+ const d = Math.hypot(dx, dy);
2460
+ if (d <= 3.5) setPx(r, Math.round(cx) + dx, Math.round(cy) + dy, [255, 0, 0]);
2461
+ else if (d <= 4.5) setPx(r, Math.round(cx) + dx, Math.round(cy) + dy, [0, 0, 0]);
2462
+ }
2463
+ }
2464
+ }
2465
+
2466
+ /** A window of `src`, black where it reaches outside — what PIL's `crop` gives for a box past the edge. */
2467
+ function cropPadded(src: Raster, x0: number, y0: number, x1: number, y1: number): Raster {
2468
+ const out = newRaster(x1 - x0, y1 - y0);
2469
+ for (let y = 0; y < out.height; y++) {
2470
+ for (let x = 0; x < out.width; x++) {
2471
+ const sx = x + x0;
2472
+ const sy = y + y0;
2473
+ const o = (y * out.width + x) * 4;
2474
+ out.data[o + 3] = 255;
2475
+ if (sx < 0 || sy < 0 || sx >= src.width || sy >= src.height) continue;
2476
+ const i = (sy * src.width + sx) * 4;
2477
+ out.data[o] = src.data[i];
2478
+ out.data[o + 1] = src.data[i + 1];
2479
+ out.data[o + 2] = src.data[i + 2];
2480
+ }
2481
+ }
2482
+ return out;
2483
+ }
2484
+
2485
+ /**
2486
+ * The overlay: the painting resized to the rig and blended 55 % over white,
2487
+ * every part's outline in a rotating palette, a 50 px grid labelled every
2488
+ * 100 px, chains as blue polylines, every bone origin as a red dot with its
2489
+ * name, and the title bottom right. `head` is a 2x crop around the `head`
2490
+ * bone (300x320 rig px to 600x640), or null when no bone is named `head`.
2491
+ *
2492
+ * Layout, palette, blend, grid and crop are the reference's; two deviations,
2493
+ * both stated: labels are drawn in rig-c's 5x7 bitmap font (upper case)
2494
+ * rather than PIL's default font, and thick lines and dots are this module's
2495
+ * own raster rather than PIL's `ImageDraw` — the overlay is for reading, and
2496
+ * no claim is made that it matches the reference pixel for pixel.
2497
+ */
2498
+ export function drawLandmarks(P: PartSet, source: Raster, bones: readonly BoneEntry[], title: string): { full: Raster; head: Raster | null } {
2499
+ const opaque = newRaster(source.width, source.height);
2500
+ for (let i = 0; i < source.width * source.height; i++) {
2501
+ opaque.data.set(source.data.subarray(i * 4, i * 4 + 3), i * 4);
2502
+ opaque.data[i * 4 + 3] = 255;
2503
+ }
2504
+ const src = resize(opaque, P.W, P.H, 'lanczos3');
2505
+ const im = newRaster(P.W, P.H);
2506
+ // PIL's Image.blend(white, src, 0.55): in1 + alpha * (in2 - in1) in float32, truncated.
2507
+ const alpha = Math.fround(0.55);
2508
+ for (let i = 0; i < P.W * P.H; i++) {
2509
+ for (let c = 0; c < 3; c++) im.data[i * 4 + c] = Math.trunc(Math.fround(255 + Math.fround(alpha * (src.data[i * 4 + c] - 255))));
2510
+ im.data[i * 4 + 3] = 255;
2511
+ }
2512
+ P.recs.forEach((p, i) => {
2513
+ const edge = morphGradient(P.alpha(p), 3);
2514
+ const c = PALETTE[i % PALETTE.length];
2515
+ for (let j = 0; j < edge.data.length; j++) if (edge.data[j]) setPx(im, j % P.W, Math.floor(j / P.W), c);
2516
+ });
2517
+ for (let x = 0; x < P.W; x += 50) {
2518
+ line(im, [x, 0], [x, P.H], 1, x % 100 ? [200, 200, 200] : [150, 150, 150]);
2519
+ if (x % 100 === 0) text(im, String(x), x + 2, 2, [80, 80, 80]);
2520
+ }
2521
+ for (let y = 0; y < P.H; y += 50) {
2522
+ line(im, [0, y], [P.W, y], 1, y % 100 ? [200, 200, 200] : [150, 150, 150]);
2523
+ if (y % 100 === 0) text(im, String(y), 2, y + 2, [80, 80, 80]);
2524
+ }
2525
+ const { byName, chains } = expand(bones);
2526
+ for (const [, pts] of chains) for (let k = 1; k < pts.length; k++) line(im, pts[k - 1], pts[k], 2, [0, 90, 255]);
2527
+ for (const [n, b] of byName) {
2528
+ dot(im, b.x, b.y);
2529
+ text(im, n, b.x + 6, b.y - 6, [0, 0, 0]);
2530
+ }
2531
+ text(im, title, P.W - 260, P.H - 16, [0, 0, 0]);
2532
+ const hb = bones.find((b) => 'name' in b && b.name === 'head');
2533
+ let headCrop: Raster | null = null;
2534
+ if (hb !== undefined && 'name' in hb) {
2535
+ const [hx, hy] = hb.at;
2536
+ const box = cropPadded(im, pyInt(hx - 150), Math.max(0, pyInt(hy - 190)), pyInt(hx + 150), pyInt(hy + 130));
2537
+ headCrop = box.width > 0 && box.height > 0 ? resize(box, 600, 640, 'lanczos3') : null;
2538
+ }
2539
+ return { full: im, head: headCrop };
2540
+ }