@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
@@ -0,0 +1,1145 @@
1
+ /**
2
+ * The instruments behind `check`'s six judgement lines (issue #11, and #31
3
+ * for texture stretch): what an eye used to be asked about a rendered idle,
4
+ * turned into figures read off rigc's frames, its `geometry.json`,
5
+ * `parts.json` and `motion.json`.
6
+ *
7
+ * Everything here is pure: frames, part records and specs in, figures out.
8
+ * `src/check.ts` owns the rigc renders that produce the frames and the bars
9
+ * the figures are held to.
10
+ *
11
+ * ⭐ **A region is chosen by the See-through tag a part came from, never by a
12
+ * part's name** (`parts.json`'s `from`, read through `src/tags.ts`). The
13
+ * torso is `topwear`, the feet `footwear`, the eyes `eyewhite`, the swinging
14
+ * cloth `handwear` and `bottomwear`, the face `face` less the parts drawn on
15
+ * it (`eyewhite`, `irides`, `eyelash`, `eyebrow`, `mouth`). A part is drawn by
16
+ * the slot of its own name — the rig stage's contract, which `check` holds
17
+ * with `CHECK_PART_SLOT_PRESENT` — so a region's slots follow from its parts.
18
+ *
19
+ * A chain is read off the rig's bone TREE and the idle's rotate tracks, not
20
+ * off link names: a keyed bone's chain parent is its nearest ancestor that is
21
+ * itself keyed, which is also how the control bones `<bone>_ctl` fall into
22
+ * place without being recognised by their suffix.
23
+ */
24
+ import type { StageBox, Viewport } from './check.ts';
25
+ import { cropToSpineY } from './coords.ts';
26
+ import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
27
+ import { type PartRecord, type PartsFile, tagOf } from './parts.ts';
28
+ import type { Raster } from './raster/types.ts';
29
+ import { type BaseTag, readTag } from './tags.ts';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // regions, by tag
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * The v3 base tag a part came from, or null for a `painting:` patch — which no
37
+ * tag classifies, so no region picks it — or a `from` that is neither
38
+ * (readParts already refused that).
39
+ */
40
+ export function baseTagOf(p: PartRecord): BaseTag | null {
41
+ const t = tagOf(p.from);
42
+ return t === null ? null : (readTag(t)?.base ?? null);
43
+ }
44
+
45
+ export function partsTagged(parts: PartsFile, tags: readonly BaseTag[]): PartRecord[] {
46
+ return parts.parts.filter((p) => {
47
+ const t = baseTagOf(p);
48
+ return t !== null && tags.includes(t);
49
+ });
50
+ }
51
+
52
+ export const TORSO_TAGS: readonly BaseTag[] = ['topwear'];
53
+ export const FEET_TAGS: readonly BaseTag[] = ['footwear'];
54
+ export const EYE_TAGS: readonly BaseTag[] = ['eyewhite'];
55
+ export const SWING_TAGS: readonly BaseTag[] = ['handwear', 'bottomwear'];
56
+ export const FACE_TAGS: readonly BaseTag[] = ['face'];
57
+ /** Drawn on the face and moved by the blink or the mouth: left out of the face-outline region. */
58
+ export const FACE_FEATURE_TAGS: readonly BaseTag[] = ['eyewhite', 'irides', 'eyelash', 'eyebrow', 'mouth'];
59
+
60
+ /** A box in frame pixels, half-open: x0 <= x < x1, y0 <= y < y1, clipped to the frame. */
61
+ export interface PixelBox {
62
+ x0: number;
63
+ y0: number;
64
+ x1: number;
65
+ y1: number;
66
+ }
67
+
68
+ /** Frame pixel x of rig (crop) pixel column u: crop -> world through the stage box, world -> frame through the viewport. */
69
+ function frameX(u: number, stage: StageBox, vp: Viewport): number {
70
+ return (stage.x + u - vp.x) * vp.scale;
71
+ }
72
+
73
+ /** Frame pixel y of rig (crop) pixel row v; world y goes through `cropToSpineY`, the one door. */
74
+ function frameY(v: number, H: number, stage: StageBox, vp: Viewport): number {
75
+ return (vp.y + vp.height - (stage.y + cropToSpineY(v, H))) * vp.scale;
76
+ }
77
+
78
+ /**
79
+ * The frame-pixel box covering the union of parts' rig boxes: every frame
80
+ * pixel any of them touches (floor of the near edge, ceil of the far one),
81
+ * clipped to the frame. Null for no part or a box entirely off the frame.
82
+ *
83
+ * {@link frameX} and {@link frameY} carry pixel EDGES, continuous positions,
84
+ * so they take no `0.5 (s - 1)` term: that term belongs to a map between
85
+ * pixel indices, centre to centre (`rigToFrameMap` in check.ts, issue #209).
86
+ * Rig pixel u's left edge is frame position s (stage.x + u - vp.x), and the
87
+ * frame pixel holding it is that position's floor.
88
+ */
89
+ export function frameBox(ps: readonly PartRecord[], rigH: number, stage: StageBox, vp: Viewport): PixelBox | null {
90
+ if (ps.length === 0) return null;
91
+ const x0 = Math.max(0, Math.floor(Math.min(...ps.map((p) => frameX(p.x, stage, vp)))));
92
+ const x1 = Math.min(vp.pixelWidth, Math.ceil(Math.max(...ps.map((p) => frameX(p.x + p.w, stage, vp)))));
93
+ const y0 = Math.max(0, Math.floor(Math.min(...ps.map((p) => frameY(p.y, rigH, stage, vp)))));
94
+ const y1 = Math.min(vp.pixelHeight, Math.ceil(Math.max(...ps.map((p) => frameY(p.y + p.h, rigH, stage, vp)))));
95
+ return x1 > x0 && y1 > y0 ? { x0, y0, x1, y1 } : null;
96
+ }
97
+
98
+ export function boxLabel(b: PixelBox): string {
99
+ return `${b.x0},${b.y0} ${b.x1 - b.x0}x${b.y1 - b.y0}`;
100
+ }
101
+
102
+ /**
103
+ * Which part is on top at every rig pixel of the flat stack: the index (in
104
+ * parts.json order) of the last part whose alpha there is above
105
+ * `OPAQUE_ALPHA_ABOVE`, or -1. `alphaOf(i)` is part i's PNG.
106
+ */
107
+ export function topmostIndex(parts: PartsFile, alphaOf: (i: number) => Raster): Int32Array {
108
+ const [W, H] = parts.rig_size;
109
+ const top = new Int32Array(W * H).fill(-1);
110
+ parts.parts.forEach((p, i) => {
111
+ const im = alphaOf(i);
112
+ for (let y = 0; y < p.h; y++) {
113
+ for (let x = 0; x < p.w; x++) if (im.data[(y * p.w + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE) top[(p.y + y) * W + p.x + x] = i;
114
+ }
115
+ });
116
+ return top;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // heat
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /** Each pixel's largest per-channel RGB change from frame 0 across the frames — the red channel `motion_heat.png` blends in. */
124
+ export function heatField(frames: readonly Raster[]): Uint8Array {
125
+ const f0 = frames[0];
126
+ const n = f0.width * f0.height;
127
+ const d = new Uint8Array(n);
128
+ for (const f of frames) {
129
+ for (let p = 0; p < n; p++) {
130
+ for (let c = 0; c < 3; c++) {
131
+ const v = Math.abs(f.data[p * 4 + c] - f0.data[p * 4 + c]);
132
+ if (v > d[p]) d[p] = v;
133
+ }
134
+ }
135
+ }
136
+ return d;
137
+ }
138
+
139
+ export interface RegionHeat {
140
+ /** Pixels measured. */
141
+ px: number;
142
+ mean: number;
143
+ max: number;
144
+ }
145
+
146
+ /** Heat over a box, or over the pixels of `mask` inside it. */
147
+ export function regionHeat(heat: Uint8Array, width: number, box: PixelBox, mask?: Uint8Array): RegionHeat {
148
+ let px = 0;
149
+ let sum = 0;
150
+ let max = 0;
151
+ for (let y = box.y0; y < box.y1; y++) {
152
+ for (let x = box.x0; x < box.x1; x++) {
153
+ const i = y * width + x;
154
+ if (mask !== undefined && mask[i] === 0) continue;
155
+ px++;
156
+ sum += heat[i];
157
+ if (heat[i] > max) max = heat[i];
158
+ }
159
+ }
160
+ return { px, mean: px === 0 ? 0 : sum / px, max };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // swing: how far a band of a part's art travels
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /** One attachment on one frame as `rigc-geometry/1` writes it: rest vertices (the setup pose's bones, no deform), posed vertices (world, y up) and triangles. */
168
+ export interface PosedTriangles {
169
+ rest: readonly number[];
170
+ posed: readonly number[];
171
+ triangles: readonly number[];
172
+ }
173
+
174
+ /** A part's art on the stage: its box's top-left in stage px and, per pixel, 1 where it is art. */
175
+ export interface ArtMask {
176
+ x: number;
177
+ y: number;
178
+ width: number;
179
+ height: number;
180
+ data: Uint8Array;
181
+ }
182
+
183
+ /** How far each half of a part's box sees its art travel, in rig px (world units); null where frame 0 shows no art in that half. */
184
+ export interface HalfTravels {
185
+ root: number | null;
186
+ tip: number | null;
187
+ }
188
+
189
+ /** A polygon as x, y pairs. */
190
+ type Polygon = number[];
191
+
192
+ /** Sutherland-Hodgman against one half-plane: keeps a x + b y + c >= 0. */
193
+ function clipHalfPlane(poly: Polygon, a: number, b: number, c: number): Polygon {
194
+ const out: Polygon = [];
195
+ const n = poly.length / 2;
196
+ for (let i = 0; i < n; i++) {
197
+ const j = i + 1 === n ? 0 : i + 1;
198
+ const x1 = poly[2 * i];
199
+ const y1 = poly[2 * i + 1];
200
+ const x2 = poly[2 * j];
201
+ const y2 = poly[2 * j + 1];
202
+ const d1 = a * x1 + b * y1 + c;
203
+ const d2 = a * x2 + b * y2 + c;
204
+ if (d1 >= 0) out.push(x1, y1);
205
+ if (d1 >= 0 !== d2 >= 0) {
206
+ const t = d1 / (d1 - d2);
207
+ out.push(x1 + t * (x2 - x1), y1 + t * (y2 - y1));
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /** A polygon clipped to the rectangle x0 <= x <= x1, y0 <= y <= y1. */
214
+ function clipRect(poly: Polygon, x0: number, y0: number, x1: number, y1: number): Polygon {
215
+ let p = clipHalfPlane(poly, 1, 0, -x0);
216
+ if (p.length >= 6) p = clipHalfPlane(p, -1, 0, x1);
217
+ if (p.length >= 6) p = clipHalfPlane(p, 0, 1, -y0);
218
+ if (p.length >= 6) p = clipHalfPlane(p, 0, -1, y1);
219
+ return p;
220
+ }
221
+
222
+ /** A polygon's area and first moments (shoelace), signed by its winding: [A, A cx, A cy]. */
223
+ function polygonMoments(poly: Polygon): [number, number, number] {
224
+ let a = 0;
225
+ let mx = 0;
226
+ let my = 0;
227
+ const n = poly.length / 2;
228
+ for (let i = 0; i < n; i++) {
229
+ const j = i + 1 === n ? 0 : i + 1;
230
+ const cr = poly[2 * i] * poly[2 * j + 1] - poly[2 * j] * poly[2 * i + 1];
231
+ a += cr;
232
+ mx += (poly[2 * i] + poly[2 * j]) * cr;
233
+ my += (poly[2 * i + 1] + poly[2 * j + 1]) * cr;
234
+ }
235
+ return [a / 2, mx / 6, my / 6];
236
+ }
237
+
238
+ /**
239
+ * The art of one attachment cut by its rest triangles, frame-independent:
240
+ * for each triangle, every piece of an art pixel's square inside it, as the
241
+ * barycentric coordinates (l1, l2 per corner) of the piece's corners, and the
242
+ * triangle's art area and the barycentric coordinates of its art centroid.
243
+ */
244
+ interface ArtPieces {
245
+ /** Per triangle: the art area inside it at rest (rig px²), and its centroid as (l1, l2). */
246
+ area: Float64Array;
247
+ centroid: Float64Array;
248
+ /** Per triangle: its pieces, each a flat list of (l1, l2) corner pairs. */
249
+ pieces: Polygon[][];
250
+ }
251
+
252
+ function artPieces(art: ArtMask, a: PosedTriangles, stage: StageBox): ArtPieces {
253
+ const H = stage.height;
254
+ const R = a.rest;
255
+ const T = a.triangles;
256
+ const nt = T.length / 3;
257
+ const area = new Float64Array(nt);
258
+ const centroid = new Float64Array(2 * nt);
259
+ const pieces: Polygon[][] = [];
260
+ // World y of each row edge of the art's box: row v's square spans [wy[v + 1 - y0], wy[v - y0]].
261
+ const wy: number[] = [];
262
+ for (let v = art.y; v <= art.y + art.height; v++) wy.push(stage.y + cropToSpineY(v, H));
263
+ for (let t = 0; t < nt; t++) {
264
+ const i = T[3 * t];
265
+ const j = T[3 * t + 1];
266
+ const k = T[3 * t + 2];
267
+ const x0 = R[2 * i];
268
+ const y0 = R[2 * i + 1];
269
+ const ex1 = R[2 * j] - x0;
270
+ const ey1 = R[2 * j + 1] - y0;
271
+ const ex2 = R[2 * k] - x0;
272
+ const ey2 = R[2 * k + 1] - y0;
273
+ const det = ex1 * ey2 - ex2 * ey1;
274
+ const list: Polygon[] = [];
275
+ pieces.push(list);
276
+ if (det === 0) continue;
277
+ const s = det > 0 ? 1 : -1;
278
+ const xs = [x0, R[2 * j], R[2 * k]];
279
+ const ys = [y0, R[2 * j + 1], R[2 * k + 1]];
280
+ const u0 = Math.max(art.x, Math.floor(Math.min(...xs) - stage.x));
281
+ const u1 = Math.min(art.x + art.width, Math.ceil(Math.max(...xs) - stage.x));
282
+ const yLo = Math.min(...ys);
283
+ const yHi = Math.max(...ys);
284
+ let A = 0;
285
+ let L1 = 0;
286
+ let L2 = 0;
287
+ for (let r = 0; r < art.height; r++) {
288
+ const top = wy[r];
289
+ const bottom = wy[r + 1];
290
+ if (bottom >= yHi || top <= yLo) continue;
291
+ for (let u = u0; u < u1; u++) {
292
+ if (art.data[r * art.width + (u - art.x)] !== 1) continue;
293
+ const left = stage.x + u;
294
+ let poly: Polygon = [left, bottom, left + 1, bottom, left + 1, top, left, top];
295
+ for (let e = 0; e < 3 && poly.length >= 6; e++) {
296
+ const ax = xs[e];
297
+ const ay = ys[e];
298
+ const bx = xs[(e + 1) % 3];
299
+ const by = ys[(e + 1) % 3];
300
+ // inside: (b - a) x (q - a) has the triangle's winding
301
+ poly = clipHalfPlane(poly, -(by - ay) * s, (bx - ax) * s, ((by - ay) * ax - (bx - ax) * ay) * s);
302
+ }
303
+ if (poly.length < 6) continue;
304
+ const [pa, pmx, pmy] = polygonMoments(poly);
305
+ if (pa === 0) continue;
306
+ const bary: Polygon = [];
307
+ for (let q = 0; q < poly.length; q += 2) {
308
+ const dx = poly[q] - x0;
309
+ const dy = poly[q + 1] - y0;
310
+ bary.push((dx * ey2 - ex2 * dy) / det, (ex1 * dy - dx * ey1) / det);
311
+ }
312
+ list.push(bary);
313
+ // The rest pieces are counter-clockwise (y up), so their area is positive.
314
+ A += pa;
315
+ const cx = pmx / pa - x0;
316
+ const cy = pmy / pa - y0;
317
+ L1 += pa * ((cx * ey2 - ex2 * cy) / det);
318
+ L2 += pa * ((ex1 * cy - cx * ey1) / det);
319
+ }
320
+ }
321
+ area[t] = A;
322
+ centroid[2 * t] = A === 0 ? 0 : L1 / A;
323
+ centroid[2 * t + 1] = A === 0 ? 0 : L2 / A;
324
+ }
325
+ return { area, centroid, pieces };
326
+ }
327
+
328
+ /**
329
+ * How far the art in each half of a part's box travels over the frames
330
+ * (issue #118): the centroid of the art's AREA inside the half — a fixed
331
+ * band of the stage, the box's upper or lower half, cut at h / 2 — on each
332
+ * frame, and the largest distance of that centroid from frame 0's, in rig px.
333
+ *
334
+ * It is the figure `check` read off a render of the part alone until #118 (a
335
+ * binary-coverage centroid per half of the frame box, frame px), taken in its
336
+ * continuous limit from the posed geometry instead. A centroid rather than
337
+ * the band's heat, as before: heat is texture times motion, and the question
338
+ * is how far the cloth moves. It is a proxy for displacement, not a
339
+ * displacement — art crossing a half's fixed edge moves the centroid too, by
340
+ * design: a band fixed on the stage is the definition. Every art pixel's square (art = the
341
+ * part's alpha above the threshold its mesh is built from) is cut by the
342
+ * attachment's rest triangles, carried by each triangle's own affine map to
343
+ * its posed vertices — the map the runtime draws the texture with — and
344
+ * clipped to the band, exactly (polygon clipping, no sampling). No render
345
+ * grid enters it, so the figure does not change with `--max`, and a travel
346
+ * well under one frame pixel is measured as exactly as a large one.
347
+ *
348
+ * Art outside every rest triangle is not drawn by the runtime and is not
349
+ * counted. A frame on which the slot shows nothing (null) is skipped; a half
350
+ * with no art on frame 0 is null. Each distinct rest attachment is cut once.
351
+ */
352
+ export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number; h: number }, stage: StageBox, frames: ReadonlyArray<PosedTriangles | null>): HalfTravels {
353
+ const H = stage.height;
354
+ const bx0 = stage.x + box.x;
355
+ const bx1 = stage.x + box.x + box.w;
356
+ const top = stage.y + cropToSpineY(box.y, H);
357
+ const mid = stage.y + cropToSpineY(box.y + box.h / 2, H);
358
+ const bottom = stage.y + cropToSpineY(box.y + box.h, H);
359
+ const bands = { root: [mid, top], tip: [bottom, mid] } as const;
360
+ const cut = new Map<readonly number[], ArtPieces>();
361
+ const centroidIn = (a: PosedTriangles, y0: number, y1: number): [number, number] | null => {
362
+ let pc = cut.get(a.rest);
363
+ if (pc === undefined) {
364
+ pc = artPieces(art, a, stage);
365
+ cut.set(a.rest, pc);
366
+ }
367
+ const P = a.posed;
368
+ const T = a.triangles;
369
+ let A = 0;
370
+ let MX = 0;
371
+ let MY = 0;
372
+ for (let t = 0; t < pc.area.length; t++) {
373
+ if (pc.area[t] === 0) continue;
374
+ const i = T[3 * t];
375
+ const j = T[3 * t + 1];
376
+ const k = T[3 * t + 2];
377
+ const px = P[2 * i];
378
+ const py = P[2 * i + 1];
379
+ const e1x = P[2 * j] - px;
380
+ const e1y = P[2 * j + 1] - py;
381
+ const e2x = P[2 * k] - px;
382
+ const e2y = P[2 * k + 1] - py;
383
+ const xmin = Math.min(px, px + e1x, px + e2x);
384
+ const xmax = Math.max(px, px + e1x, px + e2x);
385
+ const ymin = Math.min(py, py + e1y, py + e2y);
386
+ const ymax = Math.max(py, py + e1y, py + e2y);
387
+ if (xmax <= bx0 || xmin >= bx1 || ymax <= y0 || ymin >= y1) continue;
388
+ const R = a.rest;
389
+ const restDet = (R[2 * j] - R[2 * i]) * (R[2 * k + 1] - R[2 * i + 1]) - (R[2 * k] - R[2 * i]) * (R[2 * j + 1] - R[2 * i + 1]);
390
+ const posedDet = e1x * e2y - e2x * e1y;
391
+ // The posed area of a rest area: |posed det / rest det| times it.
392
+ const scale = Math.abs(posedDet / restDet);
393
+ if (xmin >= bx0 && xmax <= bx1 && ymin >= y0 && ymax <= y1) {
394
+ const w = pc.area[t] * scale;
395
+ const l1 = pc.centroid[2 * t];
396
+ const l2 = pc.centroid[2 * t + 1];
397
+ A += w;
398
+ MX += w * (px + l1 * e1x + l2 * e2x);
399
+ MY += w * (py + l1 * e1y + l2 * e2y);
400
+ continue;
401
+ }
402
+ for (const bary of pc.pieces[t]) {
403
+ const poly: Polygon = [];
404
+ for (let q = 0; q < bary.length; q += 2) poly.push(px + bary[q] * e1x + bary[q + 1] * e2x, py + bary[q] * e1y + bary[q + 1] * e2y);
405
+ const c = clipRect(poly, bx0, y0, bx1, y1);
406
+ if (c.length < 6) continue;
407
+ const [pa, pmx, pmy] = polygonMoments(c);
408
+ // A triangle the pose turns over winds its pieces the other way; what is drawn is the area either way.
409
+ const sg = pa < 0 ? -1 : 1;
410
+ A += sg * pa;
411
+ MX += sg * pmx;
412
+ MY += sg * pmy;
413
+ }
414
+ }
415
+ return A > 0 ? [MX / A, MY / A] : null;
416
+ };
417
+ const travel = (y0: number, y1: number): number | null => {
418
+ const first = frames[0];
419
+ const c0 = first === null ? null : centroidIn(first, y0, y1);
420
+ if (c0 === null) return null;
421
+ let max = 0;
422
+ for (const f of frames) {
423
+ const c = f === null ? null : centroidIn(f, y0, y1);
424
+ if (c === null) continue;
425
+ max = Math.max(max, Math.hypot(c[0] - c0[0], c[1] - c0[1]));
426
+ }
427
+ return max;
428
+ };
429
+ return { root: travel(bands.root[0], bands.root[1]), tip: travel(bands.tip[0], bands.tip[1]) };
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // sines and chains, off motion.json
434
+ // ---------------------------------------------------------------------------
435
+
436
+ export interface SineReading {
437
+ period: number;
438
+ /** |amplitude|: a sine's sign is a half-cycle of phase, and the keys cannot tell the two apart. */
439
+ amp: number;
440
+ /** Cycles, in [0, 1): `v = base + amp sin(2 pi (t / period - phase))`. */
441
+ phase: number;
442
+ }
443
+
444
+ interface Key {
445
+ t: number;
446
+ v: number[];
447
+ }
448
+
449
+ function isRecord(v: unknown): v is Record<string, unknown> {
450
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
451
+ }
452
+
453
+ function keysOf(track: Record<string, unknown>): Key[] | null {
454
+ const ks = track.keys;
455
+ if (!Array.isArray(ks)) return null;
456
+ const out: Key[] = [];
457
+ for (const k of ks) {
458
+ if (!isRecord(k) || typeof k.t !== 'number' || !Array.isArray(k.v) || typeof k.v[0] !== 'number') return null;
459
+ out.push({ t: k.t, v: k.v as number[] });
460
+ }
461
+ return out;
462
+ }
463
+
464
+ /**
465
+ * Read a track as the sampled sine `src/motion.ts` writes: keys at equal steps
466
+ * from 0 to the idle's duration, last equal to first. The fundamental is the
467
+ * strongest bin of the discrete Fourier transform of the first n keys; its
468
+ * magnitude is the amplitude and its argument the phase. Null for a track that
469
+ * is not sampled that way (a hand-authored curve) — it is listed as unread,
470
+ * never guessed at.
471
+ */
472
+ export function readSine(keys: readonly Key[], duration: number): SineReading | null {
473
+ const n = keys.length - 1;
474
+ if (n < 3 || keys[0].t !== 0 || Math.abs(keys[n].t - duration) > 1e-6) return null;
475
+ for (let k = 0; k <= n; k++) if (Math.abs(keys[k].t - (duration * k) / n) > 1e-6) return null;
476
+ const v = keys.slice(0, n).map((k) => k.v[0]);
477
+ let best = { j: 0, a: 0, b: 0, mag: 0 };
478
+ for (let j = 1; j < n / 2; j++) {
479
+ let a = 0;
480
+ let b = 0;
481
+ for (let k = 0; k < n; k++) {
482
+ a += v[k] * Math.sin((2 * Math.PI * j * k) / n);
483
+ b += v[k] * Math.cos((2 * Math.PI * j * k) / n);
484
+ }
485
+ a *= 2 / n;
486
+ b *= 2 / n;
487
+ const mag = Math.hypot(a, b);
488
+ if (mag > best.mag) best = { j, a, b, mag };
489
+ }
490
+ if (best.j === 0) return null;
491
+ // v = A sin(x - psi) = A cos(psi) sin x - A sin(psi) cos x, so a = A cos psi and b = -A sin psi.
492
+ const psi = Math.atan2(-best.b, best.a);
493
+ const phase = (((psi / (2 * Math.PI)) % 1) + 1) % 1;
494
+ return { period: duration / best.j, amp: best.mag, phase };
495
+ }
496
+
497
+ /** A phase difference in cycles, folded into (-1/4, 1/4]: the sign of a sine is half a cycle, so a lag is only readable modulo half a cycle. */
498
+ export function lagStep(from: number, to: number): number {
499
+ let d = (((to - from) % 0.5) + 0.5) % 0.5;
500
+ if (d > 0.25) d -= 0.5;
501
+ return d;
502
+ }
503
+
504
+ export interface ChainLink {
505
+ bone: string;
506
+ reading: SineReading;
507
+ }
508
+
509
+ export interface ChainEdge {
510
+ parent: string;
511
+ child: string;
512
+ step: number;
513
+ }
514
+
515
+ export interface ChainFigures {
516
+ /** Unbranched runs of rotate-keyed bones, each link the keyed child of the one before at the same period. */
517
+ chains: ChainLink[][];
518
+ /** Every keyed bone under a keyed ancestor of the same period: the lag from that ancestor. */
519
+ edges: ChainEdge[];
520
+ /** Keyed bones under a keyed ancestor of another period: their phases are not comparable. */
521
+ otherPeriod: Array<{ parent: string; child: string }>;
522
+ /** Rotate-keyed bones whose track is not a sampled sine, or that carry more than one rotate track. */
523
+ unread: string[];
524
+ }
525
+
526
+ /**
527
+ * The rotate channels of the idle, read as sines and arranged by the rig's
528
+ * bone tree. Only bone tracks and group tracks with property `rotate` are
529
+ * read; a group track keys each of its members.
530
+ */
531
+ export function chainFigures(rig: Record<string, unknown>, motion: Record<string, unknown>): ChainFigures {
532
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string') as Array<Record<string, unknown> & { name: string }>;
533
+ const order = new Map(bones.map((b, i) => [b.name, i]));
534
+ const parent = new Map(bones.map((b) => [b.name, typeof b.parent === 'string' ? b.parent : null]));
535
+ const anims = isRecord(motion.animations) ? motion.animations : {};
536
+ const idle = isRecord(anims.idle) ? anims.idle : {};
537
+ const duration = typeof idle.duration === 'number' ? idle.duration : 0;
538
+ const groups = isRecord(motion.groups) ? motion.groups : {};
539
+ const tracksOn = new Map<string, Key[][]>();
540
+ const unreadable = new Set<string>();
541
+ for (const t of Array.isArray(idle.tracks) ? idle.tracks.filter(isRecord) : []) {
542
+ if (t.property !== 'rotate') continue;
543
+ const targets = typeof t.bone === 'string' ? [t.bone] : typeof t.group === 'string' && Array.isArray(groups[t.group]) ? (groups[t.group] as unknown[]).filter((m): m is string => typeof m === 'string') : [];
544
+ const keys = keysOf(t);
545
+ for (const b of targets) {
546
+ if (keys === null) unreadable.add(b);
547
+ else tracksOn.set(b, [...(tracksOn.get(b) ?? []), keys]);
548
+ }
549
+ }
550
+ const reading = new Map<string, SineReading>();
551
+ for (const [b, ts] of tracksOn) {
552
+ const r = ts.length === 1 ? readSine(ts[0], duration) : null;
553
+ if (r === null) unreadable.add(b);
554
+ else reading.set(b, r);
555
+ }
556
+ const byOrder = (a: string, b: string): number => (order.get(a) ?? Infinity) - (order.get(b) ?? Infinity) || (a < b ? -1 : a > b ? 1 : 0);
557
+ const keyed = [...reading.keys()].sort(byOrder);
558
+ const keyedParent = new Map<string, string | null>();
559
+ for (const b of keyed) {
560
+ let p = parent.get(b) ?? null;
561
+ const seen = new Set<string>();
562
+ while (p !== null && !reading.has(p) && !seen.has(p)) {
563
+ seen.add(p);
564
+ p = parent.get(p) ?? null;
565
+ }
566
+ keyedParent.set(b, p !== null && reading.has(p) ? p : null);
567
+ }
568
+ const kids = new Map<string, string[]>();
569
+ for (const b of keyed) {
570
+ const p = keyedParent.get(b) ?? null;
571
+ if (p !== null) kids.set(p, [...(kids.get(p) ?? []), b]);
572
+ }
573
+ const samePeriod = (a: string, b: string): boolean => Math.abs((reading.get(a) as SineReading).period - (reading.get(b) as SineReading).period) < 1e-9;
574
+ const edges: ChainEdge[] = [];
575
+ const otherPeriod: Array<{ parent: string; child: string }> = [];
576
+ for (const b of keyed) {
577
+ const p = keyedParent.get(b) ?? null;
578
+ if (p === null) continue;
579
+ if (samePeriod(p, b)) edges.push({ parent: p, child: b, step: lagStep((reading.get(p) as SineReading).phase, (reading.get(b) as SineReading).phase) });
580
+ else otherPeriod.push({ parent: p, child: b });
581
+ }
582
+ const continues = (b: string): string | null => {
583
+ const k = kids.get(b) ?? [];
584
+ return k.length === 1 && samePeriod(b, k[0]) ? k[0] : null;
585
+ };
586
+ const chains: ChainLink[][] = [];
587
+ for (const b of keyed) {
588
+ const p = keyedParent.get(b) ?? null;
589
+ if (p !== null && continues(p) === b) continue;
590
+ const links: ChainLink[] = [];
591
+ let cur: string | null = b;
592
+ while (cur !== null) {
593
+ links.push({ bone: cur, reading: reading.get(cur) as SineReading });
594
+ cur = continues(cur);
595
+ }
596
+ chains.push(links);
597
+ }
598
+ return { chains, edges, otherPeriod, unread: [...unreadable].sort(byOrder) };
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // the blink
603
+ // ---------------------------------------------------------------------------
604
+
605
+ export interface BlinkTrack {
606
+ /** How the track names what it keys, as motion.json spells it. */
607
+ target: { bone: string } | { group: string };
608
+ /** The smallest `scaley` it keys — the closed eye. */
609
+ closed: number;
610
+ /** The first and last key time at that value. */
611
+ window: [number, number];
612
+ }
613
+
614
+ /**
615
+ * The bones an eye part's slot is moved by through `scaley` without changing
616
+ * shape: the slot's bone, and each ancestor it sits at the origin of (local
617
+ * x = y = 0) — which is how a control bone is attached, so a blink moved to a
618
+ * control is found without reading its name.
619
+ */
620
+ export function eyeBones(rig: Record<string, unknown>, slotBones: readonly string[]): Set<string> {
621
+ const bones = new Map((Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).map((b) => [String(b.name), b]));
622
+ const out = new Set<string>();
623
+ for (const s of slotBones) {
624
+ let cur: string | null = s;
625
+ while (cur !== null && !out.has(cur)) {
626
+ out.add(cur);
627
+ const b = bones.get(cur);
628
+ if (b === undefined || (b.x ?? 0) !== 0 || (b.y ?? 0) !== 0) break;
629
+ cur = typeof b.parent === 'string' ? b.parent : null;
630
+ }
631
+ }
632
+ return out;
633
+ }
634
+
635
+ /** The idle's `scaley` tracks on the eye bones (directly or through a group) that go below their first key: the blink. */
636
+ export function blinkTracks(motion: Record<string, unknown>, eyes: ReadonlySet<string>): BlinkTrack[] {
637
+ const anims = isRecord(motion.animations) ? motion.animations : {};
638
+ const idle = isRecord(anims.idle) ? anims.idle : {};
639
+ const groups = isRecord(motion.groups) ? motion.groups : {};
640
+ const out: BlinkTrack[] = [];
641
+ for (const t of Array.isArray(idle.tracks) ? idle.tracks.filter(isRecord) : []) {
642
+ if (t.property !== 'scaley') continue;
643
+ let target: BlinkTrack['target'] | null = null;
644
+ if (typeof t.bone === 'string' && eyes.has(t.bone)) target = { bone: t.bone };
645
+ else if (typeof t.group === 'string' && Array.isArray(groups[t.group]) && (groups[t.group] as unknown[]).some((m) => typeof m === 'string' && eyes.has(m))) target = { group: t.group };
646
+ const keys = keysOf(t);
647
+ if (target === null || keys === null || keys.length === 0) continue;
648
+ const closed = Math.min(...keys.map((k) => k.v[0]));
649
+ if (!(closed < keys[0].v[0])) continue;
650
+ const at = keys.filter((k) => k.v[0] === closed).map((k) => k.t);
651
+ out.push({ target, closed, window: [at[0], at[at.length - 1]] });
652
+ }
653
+ return out;
654
+ }
655
+
656
+ export interface BlinkFigures {
657
+ /** Pixels in the box that show the background with the eyes closed and showed art with them open. */
658
+ holePx: number;
659
+ /** Of the box's pixels with the eyes closed, the largest max-channel distance to the nearest colour the open-eye box holds. */
660
+ patchMax: number;
661
+ /** How many exceed `patchLevel`. */
662
+ patchOver: number;
663
+ px: number;
664
+ }
665
+
666
+ /**
667
+ * The closed-eye frame against the open-eye frame on the same grid, inside
668
+ * the eye box. A hole is background where there was art — the rasteriser
669
+ * paints the background exactly where nothing is drawn. A colour patch is a
670
+ * pixel far from every colour the open eye's box held; that half is a
671
+ * reported figure, not a bar (see AUTHORING §7).
672
+ */
673
+ export function blinkFigures(open: Raster, closed: Raster, bg: readonly number[], box: PixelBox, patchLevel: number): BlinkFigures {
674
+ const w = open.width;
675
+ const isBg = (r: Raster, i: number): boolean => r.data[i] === bg[0] && r.data[i + 1] === bg[1] && r.data[i + 2] === bg[2];
676
+ const colours = new Map<number, [number, number, number]>();
677
+ for (let y = box.y0; y < box.y1; y++) {
678
+ for (let x = box.x0; x < box.x1; x++) {
679
+ const i = (y * w + x) * 4;
680
+ colours.set((open.data[i] << 16) | (open.data[i + 1] << 8) | open.data[i + 2], [open.data[i], open.data[i + 1], open.data[i + 2]]);
681
+ }
682
+ }
683
+ const palette = [...colours.entries()].sort((a, b) => a[0] - b[0]).map((e) => e[1]);
684
+ let holePx = 0;
685
+ let patchMax = 0;
686
+ let patchOver = 0;
687
+ let px = 0;
688
+ for (let y = box.y0; y < box.y1; y++) {
689
+ for (let x = box.x0; x < box.x1; x++) {
690
+ const i = (y * w + x) * 4;
691
+ px++;
692
+ if (isBg(closed, i) && !isBg(open, i)) holePx++;
693
+ let near = 255;
694
+ for (const c of palette) {
695
+ const d = Math.max(Math.abs(closed.data[i] - c[0]), Math.abs(closed.data[i + 1] - c[1]), Math.abs(closed.data[i + 2] - c[2]));
696
+ if (d < near) near = d;
697
+ if (near === 0) break;
698
+ }
699
+ if (near > patchMax) patchMax = near;
700
+ if (near > patchLevel) patchOver++;
701
+ }
702
+ }
703
+ return { holePx, patchMax, patchOver, px };
704
+ }
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // texture stretch (issue #31)
708
+ // ---------------------------------------------------------------------------
709
+
710
+ /** One mesh attachment's rest shape and topology, as rigc's `geometry.json` `rest` table carries it: world units, the setup pose's bones with no deform. */
711
+ export interface MeshRest {
712
+ slot: string;
713
+ attachment: string;
714
+ /** x, y per vertex. */
715
+ vertices: readonly number[];
716
+ /** Vertex index triplets. */
717
+ triangles: readonly number[];
718
+ }
719
+
720
+ /** One frame's skinned attachments, as `geometry.json`'s `frames[i].attachments` carries them. */
721
+ export interface GeometryPose {
722
+ index: number;
723
+ attachments: ReadonlyArray<{ slot: string; attachment: string; vertices: readonly number[] }>;
724
+ }
725
+
726
+ /** Where one edge ratio was found: the triangle, its three vertex indices, the edge's two, the frame, and deformed length over rest length. */
727
+ export interface StretchAt {
728
+ triangle: number;
729
+ vertices: [number, number, number];
730
+ edge: [number, number];
731
+ frame: number;
732
+ ratio: number;
733
+ }
734
+
735
+ export interface MeshStretch {
736
+ slot: string;
737
+ attachment: string;
738
+ triangles: number;
739
+ /** Frames that show this attachment; 0 leaves `max` and `min` null. */
740
+ frames: number;
741
+ /** The largest edge ratio over every triangle and every frame. */
742
+ max: StretchAt | null;
743
+ /** The smallest. */
744
+ min: StretchAt | null;
745
+ /** Rest edges of length 0, which no ratio can be read off: `triangle`, the edge's two vertices. */
746
+ degenerate: Array<{ triangle: number; edge: [number, number] }>;
747
+ }
748
+
749
+ /**
750
+ * How far a ratio is from 1 in either direction: `max(r, 1/r)`. Compression
751
+ * distorts a texture as much as stretch does — an edge at half its rest length
752
+ * squeezes the texels on it by the factor an edge at twice its rest length
753
+ * spreads them — so the one figure held to a bar reads both, as |ln r| does.
754
+ */
755
+ export function stretchSeverity(ratio: number): number {
756
+ return ratio >= 1 ? ratio : 1 / ratio;
757
+ }
758
+
759
+ /**
760
+ * For every mesh in `rest`, every triangle's three edges in every frame that
761
+ * shows the attachment: deformed length over rest length, the largest and the
762
+ * smallest, each with the triangle, its vertices, the edge and the frame.
763
+ * Frames are walked in the order given and triangles in index order, and a
764
+ * later value replaces the kept one only when strictly more extreme, so a tie
765
+ * names the earliest frame and then the lowest triangle.
766
+ */
767
+ export function stretchFigures(rest: readonly MeshRest[], frames: readonly GeometryPose[]): MeshStretch[] {
768
+ return rest.map((m) => {
769
+ const t = m.triangles;
770
+ const v0 = m.vertices;
771
+ const len = (v: readonly number[], a: number, b: number): number => Math.hypot(v[2 * a] - v[2 * b], v[2 * a + 1] - v[2 * b + 1]);
772
+ const out: MeshStretch = { slot: m.slot, attachment: m.attachment, triangles: t.length / 3, frames: 0, max: null, min: null, degenerate: [] };
773
+ const restLen: number[] = [];
774
+ for (let i = 0; i < t.length; i += 3) {
775
+ for (const [a, b] of [[t[i], t[i + 1]], [t[i + 1], t[i + 2]], [t[i + 2], t[i]]] as const) {
776
+ const l = len(v0, a, b);
777
+ restLen.push(l);
778
+ if (l === 0) out.degenerate.push({ triangle: i / 3, edge: [a, b] });
779
+ }
780
+ }
781
+ for (const f of frames) {
782
+ const pose = f.attachments.find((a) => a.slot === m.slot && a.attachment === m.attachment);
783
+ if (pose === undefined) continue;
784
+ out.frames++;
785
+ for (let i = 0; i < t.length; i += 3) {
786
+ const edges = [[t[i], t[i + 1]], [t[i + 1], t[i + 2]], [t[i + 2], t[i]]] as const;
787
+ for (let e = 0; e < 3; e++) {
788
+ const l0 = restLen[i + e];
789
+ if (l0 === 0) continue;
790
+ const [a, b] = edges[e];
791
+ const ratio = len(pose.vertices, a, b) / l0;
792
+ const at = (): StretchAt => ({ triangle: i / 3, vertices: [t[i], t[i + 1], t[i + 2]], edge: [a, b], frame: f.index, ratio });
793
+ if (out.max === null || ratio > out.max.ratio) out.max = at();
794
+ if (out.min === null || ratio < out.min.ratio) out.min = at();
795
+ }
796
+ }
797
+ }
798
+ return out;
799
+ });
800
+ }
801
+
802
+ // ---------------------------------------------------------------------------
803
+ // the face in the head's own frame (issue #33)
804
+ // ---------------------------------------------------------------------------
805
+
806
+ /** A bone's world transform as rigc's `geometry.json` records it: world = [a b; c d] local + (worldX, worldY), Spine's y-up world units. */
807
+ export interface BoneWorld {
808
+ a: number;
809
+ b: number;
810
+ c: number;
811
+ d: number;
812
+ worldX: number;
813
+ worldY: number;
814
+ }
815
+
816
+ /** One bone's world transform at the setup pose and on every frame of a render, in frame order, with each frame's time in seconds. */
817
+ export interface BoneTrack {
818
+ bone: string;
819
+ setup: BoneWorld;
820
+ frames: BoneWorld[];
821
+ times: number[];
822
+ }
823
+
824
+ /**
825
+ * The world -> world map that carries the bone from its setup pose to a
826
+ * frame: `frame * setup^-1`, as a {@link BoneWorld} (matrix and translation).
827
+ * Null when the setup transform is singular.
828
+ */
829
+ export function setupToFrame(setup: BoneWorld, frame: BoneWorld): BoneWorld | null {
830
+ const det = setup.a * setup.d - setup.b * setup.c;
831
+ if (!(Math.abs(det) > 1e-12)) return null;
832
+ const ia = setup.d / det;
833
+ const ib = -setup.b / det;
834
+ const ic = -setup.c / det;
835
+ const id = setup.a / det;
836
+ const a = frame.a * ia + frame.b * ic;
837
+ const b = frame.a * ib + frame.b * id;
838
+ const c = frame.c * ia + frame.d * ic;
839
+ const d = frame.c * ib + frame.d * id;
840
+ return { a, b, c, d, worldX: frame.worldX - (a * setup.worldX + b * setup.worldY), worldY: frame.worldY - (c * setup.worldX + d * setup.worldY) };
841
+ }
842
+
843
+ /** A box in Spine world units at the setup pose (y up): x0 <= x <= x1, y0 <= y <= y1. */
844
+ export interface WorldBox {
845
+ x0: number;
846
+ y0: number;
847
+ x1: number;
848
+ y1: number;
849
+ }
850
+
851
+ /**
852
+ * Where a part's art can be, in the head's frame, over the frames: the part's
853
+ * setup-pose box carried by the bone its slot rides to each frame and back
854
+ * through the head's transform, as the bounding box of every corner on every
855
+ * frame (the setup box included). For a feature the head carries rigidly it is
856
+ * the setup box; for one its own bone moves on the face — a brow the blink
857
+ * drops — it is the whole of where it goes. Null when a transform is singular.
858
+ */
859
+ export function sweptBox(box: WorldBox, part: BoneTrack, head: BoneTrack): WorldBox | null {
860
+ const corners: Array<[number, number]> = [
861
+ [box.x0, box.y0],
862
+ [box.x1, box.y0],
863
+ [box.x1, box.y1],
864
+ [box.x0, box.y1],
865
+ ];
866
+ let out: WorldBox = { ...box };
867
+ for (let k = 0; k < part.frames.length; k++) {
868
+ const f = setupToFrame(part.setup, part.frames[k]);
869
+ const g = setupToFrame(head.setup, head.frames[k]);
870
+ if (f === null || g === null) return null;
871
+ const det = g.a * g.d - g.b * g.c;
872
+ if (!(Math.abs(det) > 1e-12)) return null;
873
+ for (const [x, y] of corners) {
874
+ // world at frame k, then back through the head's setup -> frame map
875
+ const wx = f.a * x + f.b * y + f.worldX - g.worldX;
876
+ const wy = f.c * x + f.d * y + f.worldY - g.worldY;
877
+ const hx = (g.d * wx - g.b * wy) / det;
878
+ const hy = (-g.c * wx + g.a * wy) / det;
879
+ out = { x0: Math.min(out.x0, hx), y0: Math.min(out.y0, hy), x1: Math.max(out.x1, hx), y1: Math.max(out.y1, hy) };
880
+ }
881
+ }
882
+ return out;
883
+ }
884
+
885
+ /** A part's rig box (crop pixels, y down) as a world box at the setup pose, through the stage box and `cropToSpineY`. */
886
+ export function partWorldBox(p: PartRecord, rigH: number, stage: StageBox): WorldBox {
887
+ return { x0: stage.x + p.x, x1: stage.x + p.x + p.w, y0: stage.y + cropToSpineY(p.y + p.h, rigH), y1: stage.y + cropToSpineY(p.y, rigH) };
888
+ }
889
+
890
+ // ---------------------------------------------------------------------------
891
+ // still: art that keeps its place in a bone's frame (issue #123)
892
+ // ---------------------------------------------------------------------------
893
+
894
+ /**
895
+ * How many floating-point roundings stand between the rig and one coordinate
896
+ * of a displacement {@link stillReading} returns, on its longest path. Every
897
+ * number on it is a double: rigc's core poser (`rig-c/src/core/vertices.ts`,
898
+ * rig-c 2.15.0) reads a vertex's local coordinates and weights through
899
+ * `Math.fround` and does the arithmetic in doubles, and `geometry.json` carries
900
+ * those doubles. The float32 inputs are the same numbers in the rest vertex and
901
+ * in the posed one, so they cancel. Per coordinate:
902
+ *
903
+ * - rigc's rest vertex, `a x + b y + worldX` (a mesh wholly on one bone
904
+ * multiplies by its weight 1 and adds the product to 0, both exact): 2
905
+ * products and 2 sums, **4**;
906
+ * - the posed vertex, the same way, **4**;
907
+ * - {@link setupToFrame}: each entry of the map takes the setup determinant
908
+ * (3), one division by it (1) and a two-term product sum (3), 7 roundings
909
+ * relative to the entry; the map's translation `worldX - (a sx + b sy)` adds
910
+ * 4 to those, **11**;
911
+ * - carrying the posed vertex back through the map ({@link stillReading}):
912
+ * the translation taken off (1), the map's determinant (3), `d wx - b wy`
913
+ * (3) and the division (1), with the entries' 7, **15**;
914
+ * - the difference with the rest vertex, **1**.
915
+ *
916
+ * That is 35. Each rounding is at most the unit roundoff u = 2^-53 of a
917
+ * quantity no larger than a two-term sum of entries times coordinates,
918
+ * 2 M^2 C (M the largest matrix entry, at least 1; C the largest coordinate),
919
+ * and 2 u C <= 2 ulp(C). So one coordinate is off by at most 70 M^2 ulp(C),
920
+ * and a displacement, the length of two of them, by sqrt(2) times that
921
+ * ({@link stillTolerance}). It is a numerical tolerance and nothing else: no
922
+ * visual allowance is in it.
923
+ */
924
+ export const STILL_ROUNDINGS = 35;
925
+
926
+ /** The spacing of doubles at `x` (> 0): 2^(e - 52) for 2^e <= x < 2^(e + 1). */
927
+ export function ulpOf(x: number): number {
928
+ let e = Math.floor(Math.log2(x));
929
+ if (2 ** e > x) e--;
930
+ if (2 ** (e + 1) <= x) e++;
931
+ return 2 ** (e - 52);
932
+ }
933
+
934
+ /** The largest displacement the arithmetic alone can produce ({@link STILL_ROUNDINGS}): sqrt(2) x 2 x 35 x M^2 x ulp(C), with M and C at least 1. */
935
+ export function stillTolerance(coordMax: number, entryMax: number): number {
936
+ const m = Math.max(1, entryMax);
937
+ return Math.SQRT2 * 2 * STILL_ROUNDINGS * m * m * ulpOf(Math.max(1, coordMax));
938
+ }
939
+
940
+ /** A frame's map from the setup pose into it, carried back into the reference frame: a bone's {@link setupToFrame}, or null for the screen (the identity). */
941
+ export type FrameMap = BoneWorld | null;
942
+
943
+ /** What {@link stillReading} measures of one slot. */
944
+ export interface StillReading {
945
+ /** The largest displacement of any point of its art, rig px, and the frame (index into the set) it is on; null when no frame shows the slot. */
946
+ max: number | null;
947
+ frame: number | null;
948
+ /** Over the frames that show it and each rest triangle with art: that art's area times its centroid's squared displacement, summed, and the areas summed — the RMS's two halves. */
949
+ sumSq: number;
950
+ weight: number;
951
+ /** The largest |coordinate| of its rest and posed vertices, for {@link stillTolerance}. */
952
+ coordMax: number;
953
+ }
954
+
955
+ function backInto(m: FrameMap, x: number, y: number): [number, number] {
956
+ if (m === null) return [x, y];
957
+ const det = m.a * m.d - m.b * m.c;
958
+ const wx = x - m.worldX;
959
+ const wy = y - m.worldY;
960
+ return [(m.d * wx - m.b * wy) / det, (-m.c * wx + m.a * wy) / det];
961
+ }
962
+
963
+ /**
964
+ * How far one slot's art moves relative to a reference frame over the frames
965
+ * (issue #123): on each frame every vertex of the attachment the slot shows is
966
+ * carried back through that frame's map — the reference bone's
967
+ * {@link setupToFrame}, or the screen for null — and compared with its rest
968
+ * position, the setup pose's. Inside a rest triangle the displacement is
969
+ * affine, so its largest length over the art is reached at a corner of an art
970
+ * piece ({@link artPieces}: each art pixel's square cut by the triangle); the
971
+ * RMS weights each triangle's art centroid by its art area. A slot its
972
+ * reference bone carries rigidly reads its arithmetic's rounding and nothing
973
+ * else ({@link stillTolerance}). No render grid enters it.
974
+ */
975
+ export function stillReading(art: ArtMask, stage: StageBox, frames: ReadonlyArray<PosedTriangles | null>, maps: readonly FrameMap[]): StillReading {
976
+ const cut = new Map<readonly number[], ArtPieces>();
977
+ let max: number | null = null;
978
+ let frame: number | null = null;
979
+ let sumSq = 0;
980
+ let weight = 0;
981
+ let coordMax = 0;
982
+ frames.forEach((a, k) => {
983
+ if (a === null) return;
984
+ let pc = cut.get(a.rest);
985
+ if (pc === undefined) {
986
+ pc = artPieces(art, a, stage);
987
+ cut.set(a.rest, pc);
988
+ }
989
+ const n = a.rest.length / 2;
990
+ const d = new Float64Array(2 * n);
991
+ for (let v = 0; v < n; v++) {
992
+ const [qx, qy] = backInto(maps[k], a.posed[2 * v], a.posed[2 * v + 1]);
993
+ d[2 * v] = qx - a.rest[2 * v];
994
+ d[2 * v + 1] = qy - a.rest[2 * v + 1];
995
+ coordMax = Math.max(coordMax, Math.abs(a.rest[2 * v]), Math.abs(a.rest[2 * v + 1]), Math.abs(a.posed[2 * v]), Math.abs(a.posed[2 * v + 1]));
996
+ }
997
+ const T = a.triangles;
998
+ let fmax = 0;
999
+ for (let t = 0; t < pc.area.length; t++) {
1000
+ if (pc.area[t] === 0) continue;
1001
+ const i = T[3 * t];
1002
+ const j = T[3 * t + 1];
1003
+ const l = T[3 * t + 2];
1004
+ const x0 = d[2 * i];
1005
+ const y0 = d[2 * i + 1];
1006
+ const e1x = d[2 * j] - x0;
1007
+ const e1y = d[2 * j + 1] - y0;
1008
+ const e2x = d[2 * l] - x0;
1009
+ const e2y = d[2 * l + 1] - y0;
1010
+ for (const bary of pc.pieces[t]) {
1011
+ for (let q = 0; q < bary.length; q += 2) fmax = Math.max(fmax, Math.hypot(x0 + bary[q] * e1x + bary[q + 1] * e2x, y0 + bary[q] * e1y + bary[q + 1] * e2y));
1012
+ }
1013
+ const cx = x0 + pc.centroid[2 * t] * e1x + pc.centroid[2 * t + 1] * e2x;
1014
+ const cy = y0 + pc.centroid[2 * t] * e1y + pc.centroid[2 * t + 1] * e2y;
1015
+ sumSq += pc.area[t] * (cx * cx + cy * cy);
1016
+ weight += pc.area[t];
1017
+ }
1018
+ if (max === null || fmax > max) {
1019
+ max = fmax;
1020
+ frame = k;
1021
+ }
1022
+ });
1023
+ return { max, frame, sumSq, weight, coordMax };
1024
+ }
1025
+
1026
+ /** A set of the stage's pixels (crop coordinates, y down): 1 where the pixel is in. */
1027
+ export interface RigRegion {
1028
+ width: number;
1029
+ height: number;
1030
+ data: Uint8Array;
1031
+ }
1032
+
1033
+ /** How far another slot's art reaches into a region — reported, never a bar (issue #123). */
1034
+ export interface Crossing {
1035
+ /** The largest distance from a point of its art inside the region to the region's edge, rig px. */
1036
+ depth: number;
1037
+ /** The most of the region its art covers on one frame, rig px², and that frame (index into the set). */
1038
+ area: number;
1039
+ frame: number;
1040
+ }
1041
+
1042
+ /**
1043
+ * How far a slot's art swings into `region` (the stage's pixels at the setup
1044
+ * pose) in the reference frame: each art piece carried through its posed
1045
+ * triangle and back through the frame's map, clipped exactly to the region's
1046
+ * pixel squares. Null when it never enters. The depth is read at the corners
1047
+ * of the clipped pieces, each inside one rig pixel, so it can fall short of the
1048
+ * true depth by at most a pixel's diagonal; a point that starts outside the
1049
+ * region and travels s is never deeper than s.
1050
+ */
1051
+ export function crossingOf(art: ArtMask, stage: StageBox, frames: ReadonlyArray<PosedTriangles | null>, maps: readonly FrameMap[], region: RigRegion): Crossing | null {
1052
+ const { width: W, height: H } = region;
1053
+ const inR = (u: number, v: number): boolean => u >= 0 && u < W && v >= 0 && v < H && region.data[v * W + u] === 1;
1054
+ // The region's box in world units, to pass over the triangles nowhere near it.
1055
+ let rx0 = Infinity;
1056
+ let rx1 = -Infinity;
1057
+ let ry0 = Infinity;
1058
+ let ry1 = -Infinity;
1059
+ for (let v = 0; v < H; v++) {
1060
+ for (let u = 0; u < W; u++) {
1061
+ if (region.data[v * W + u] !== 1) continue;
1062
+ rx0 = Math.min(rx0, stage.x + u);
1063
+ rx1 = Math.max(rx1, stage.x + u + 1);
1064
+ ry0 = Math.min(ry0, stage.y + cropToSpineY(v + 1, H));
1065
+ ry1 = Math.max(ry1, stage.y + cropToSpineY(v, H));
1066
+ }
1067
+ }
1068
+ if (rx0 === Infinity) return null;
1069
+ // Distance from a world point to the nearest pixel outside the region: rings of pixels around the one holding it.
1070
+ const depthAt = (x: number, y: number): number => {
1071
+ const u0 = Math.floor(x - stage.x);
1072
+ const v0 = Math.floor(H - (y - stage.y));
1073
+ let best = Infinity;
1074
+ for (let r = 0; r - 1 < best; r++) {
1075
+ for (let v = v0 - r; v <= v0 + r; v++) {
1076
+ for (let u = u0 - r; u <= u0 + r; u++) {
1077
+ if (Math.max(Math.abs(u - u0), Math.abs(v - v0)) !== r || inR(u, v)) continue;
1078
+ const sx0 = stage.x + u;
1079
+ const sy1 = stage.y + cropToSpineY(v, H);
1080
+ best = Math.min(best, Math.hypot(Math.max(sx0 - x, 0, x - sx0 - 1), Math.max(sy1 - 1 - y, 0, y - sy1)));
1081
+ }
1082
+ }
1083
+ }
1084
+ return best;
1085
+ };
1086
+ const cut = new Map<readonly number[], ArtPieces>();
1087
+ let out: Crossing | null = null;
1088
+ frames.forEach((a, k) => {
1089
+ if (a === null) return;
1090
+ let pc = cut.get(a.rest);
1091
+ if (pc === undefined) {
1092
+ pc = artPieces(art, a, stage);
1093
+ cut.set(a.rest, pc);
1094
+ }
1095
+ const P = a.posed;
1096
+ const T = a.triangles;
1097
+ let area = 0;
1098
+ let depth = 0;
1099
+ for (let t = 0; t < pc.area.length; t++) {
1100
+ if (pc.area[t] === 0) continue;
1101
+ const [px, py] = backInto(maps[k], P[2 * T[3 * t]], P[2 * T[3 * t] + 1]);
1102
+ const [qx, qy] = backInto(maps[k], P[2 * T[3 * t + 1]], P[2 * T[3 * t + 1] + 1]);
1103
+ const [sx, sy] = backInto(maps[k], P[2 * T[3 * t + 2]], P[2 * T[3 * t + 2] + 1]);
1104
+ if (Math.max(px, qx, sx) <= rx0 || Math.min(px, qx, sx) >= rx1 || Math.max(py, qy, sy) <= ry0 || Math.min(py, qy, sy) >= ry1) continue;
1105
+ for (const bary of pc.pieces[t]) {
1106
+ const poly: Polygon = [];
1107
+ for (let q = 0; q < bary.length; q += 2) poly.push(px + bary[q] * (qx - px) + bary[q + 1] * (sx - px), py + bary[q] * (qy - py) + bary[q + 1] * (sy - py));
1108
+ let bx0 = Infinity;
1109
+ let bx1 = -Infinity;
1110
+ let by0 = Infinity;
1111
+ let by1 = -Infinity;
1112
+ for (let q = 0; q < poly.length; q += 2) {
1113
+ bx0 = Math.min(bx0, poly[q]);
1114
+ bx1 = Math.max(bx1, poly[q]);
1115
+ by0 = Math.min(by0, poly[q + 1]);
1116
+ by1 = Math.max(by1, poly[q + 1]);
1117
+ }
1118
+ for (let u = Math.floor(bx0 - stage.x); u <= Math.floor(bx1 - stage.x); u++) {
1119
+ for (let v = Math.floor(H - (by1 - stage.y)); v <= Math.floor(H - (by0 - stage.y)); v++) {
1120
+ if (!inR(u, v)) continue;
1121
+ const sx0 = stage.x + u;
1122
+ const sy1 = stage.y + cropToSpineY(v, H);
1123
+ const c = clipRect(poly, sx0, sy1 - 1, sx0 + 1, sy1);
1124
+ if (c.length < 6) continue;
1125
+ const pa = Math.abs(polygonMoments(c)[0]);
1126
+ if (pa === 0) continue;
1127
+ area += pa;
1128
+ for (let q = 0; q < c.length; q += 2) depth = Math.max(depth, depthAt(c[q], c[q + 1]));
1129
+ }
1130
+ }
1131
+ }
1132
+ }
1133
+ if (area === 0) return;
1134
+ if (out === null) out = { depth, area, frame: k };
1135
+ else {
1136
+ out.depth = Math.max(out.depth, depth);
1137
+ if (area > out.area) {
1138
+ out.area = area;
1139
+ out.frame = k;
1140
+ }
1141
+ }
1142
+ });
1143
+ return out;
1144
+ }
1145
+