@rig-c/parts 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/assemble.ts CHANGED
@@ -73,6 +73,17 @@
73
73
  * recorded with the same `<run>:<tag>` (its pixels came from that layer).
74
74
  * The cut is made after step 4, so the new part takes the merged pixels and
75
75
  * every later step treats it as a part of its own.
76
+ * - **The owner of a pixel follows the plan** (`pixelOwners`, issue #255):
77
+ * where two planned layers of one run are both opaque, the one the plan
78
+ * draws in front owns the pixel for projection; the reference went by
79
+ * See-through's depth alone. The proposer writes each run's parts in depth
80
+ * order, so the two differ only where an author reorders a plan; on the
81
+ * public examples no pixel changes owner, and they assemble to the bytes
82
+ * they did.
83
+ * - **The drift line** (`driftBodies`, issue #255) is this port's: a part
84
+ * whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw
85
+ * order is named, with the body and that part; the reference printed the
86
+ * refused count alone.
76
87
  * - **What lies under a piece** (`cuts[].under`, `fillUnder`, issue #193) is
77
88
  * this port's: an authored image the base takes where the cut removed its
78
89
  * pixels, so a piece that swings away leaves no hole. The stage reads that
@@ -449,8 +460,8 @@ export interface ProjectionStats {
449
460
  taken: number;
450
461
  refused: number;
451
462
  /**
452
- * Where no later layer of the run is opaque (`alpha >= 250`) in front of
453
- * this one: the run's owner of the pixel is this layer or one behind it. A
463
+ * Where no layer of the run in front of this one (`frontOf`) owns the
464
+ * pixel (`pixelOwners`): the owner is this layer, one behind it, or none. A
454
465
  * pixel of this layer with alpha above 8 is VISIBLE exactly where this is
455
466
  * set, and OCCLUDED everywhere else.
456
467
  */
@@ -466,12 +477,71 @@ function maxDiff(a: Uint8ClampedArray, i: number, b: Uint8ClampedArray, j: numbe
466
477
  return Math.max(Math.abs(a[i] - b[j]), Math.abs(a[i + 1] - b[j + 1]), Math.abs(a[i + 2] - b[j + 2]));
467
478
  }
468
479
 
480
+ /**
481
+ * The plan's rank of each layer of one run (issue #255): the index of the
482
+ * last plan entry that takes the layer from this run, or -1 for a layer no
483
+ * plan entry takes (an `extend_below_crop` layer included: its pixels are
484
+ * copied into another part, and it keeps See-through's depth).
485
+ */
486
+ export function planRanks(tags: readonly string[], run: Run, plan: readonly PlanEntry[]): number[] {
487
+ return tags.map((tag) => {
488
+ let r = -1;
489
+ plan.forEach(([, ru, tg], i) => {
490
+ if (ru === run && tg === tag) r = i;
491
+ });
492
+ return r;
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Whether layer `o` of a run is in front of layer `i` of the same run: by the
498
+ * plan when both are planned (`rank` >= 0 for both; the later plan entry is
499
+ * drawn in front), by See-through's depth (the run's own order) otherwise.
500
+ * `rank` null is depth throughout, the reference's rule.
501
+ */
502
+ export function frontOf(o: number, i: number, rank: readonly number[] | null): boolean {
503
+ if (rank !== null && rank[o] >= 0 && rank[i] >= 0) return rank[o] > rank[i];
504
+ return o > i;
505
+ }
506
+
507
+ /**
508
+ * Who owns each rig pixel of one run, for projection (issue #255).
509
+ *
510
+ * ⚖️ Invariant: per pixel, the DEPTH owner is the run's top-most layer with
511
+ * alpha >= `CORE_ALPHA` there (-1: none), the reference's rule. When `rank`
512
+ * is given and the depth owner is a planned layer, the owner is instead the
513
+ * planned layer at alpha >= `CORE_ALPHA` there with the greatest rank: where
514
+ * two planned parts both hold a pixel, the one the plan draws in front takes
515
+ * it, whatever See-through's depth says. A depth owner no plan entry takes is
516
+ * kept — the painting there shows a layer the rig does not draw, and no
517
+ * planned part may take it. Where plan and depth agree the owner is the
518
+ * reference's, pixel for pixel.
519
+ */
520
+ export function pixelOwners(rigLayers: readonly Raster[], rank: readonly number[] | null): Int32Array {
521
+ const n = rigLayers.length === 0 ? 0 : rigLayers[0].width * rigLayers[0].height;
522
+ const owner = new Int32Array(n).fill(-1);
523
+ for (let p = 0; p < n; p++) {
524
+ let depth = -1;
525
+ let best = -1;
526
+ for (let i = 0; i < rigLayers.length; i++) {
527
+ if (rigLayers[i].data[p * 4 + 3] < CORE_ALPHA) continue;
528
+ depth = i;
529
+ if (rank !== null && rank[i] >= 0 && (best < 0 || rank[i] > rank[best])) best = i;
530
+ }
531
+ owner[p] = rank !== null && depth >= 0 && rank[depth] >= 0 ? best : depth;
532
+ }
533
+ return owner;
534
+ }
535
+
469
536
  /**
470
537
  * Step 3, over one run's layers in draw order. Modifies each rig layer's
471
538
  * colour in place and returns its counts.
472
539
  *
473
- * ⚖️ Invariant: a layer's CORE is where it is the top-most `alpha >= 250`
474
- * layer of its own run, eroded by a 5x5 square. Inside the core, the pixels
540
+ * ⚖️ Invariant: a layer's CORE is where it is the pixel's owner
541
+ * (`pixelOwners`: the top-most `alpha >= 250` layer of its own run, or, with
542
+ * `rank`, the planned one the plan draws in front — issue #255, not in the
543
+ * reference, which is the same wherever plan and depth agree), eroded by a
544
+ * 5x5 square. Inside the core, the pixels
475
545
  * that agree with the painting (max channel difference <= `DRIFT_LIMIT`),
476
546
  * closed by 5x5 and cut back to the core — and, for the head run, kept
477
547
  * `HEAD_WINDOW_INSET` pixels inside the head crop — are the ACCEPTED set. The
@@ -484,22 +554,20 @@ function maxDiff(a: Uint8ClampedArray, i: number, b: Uint8ClampedArray, j: numbe
484
554
  *
485
555
  * With `rule: 'visible'` (NOT in the reference) the core is the top-most
486
556
  * `alpha >= 250` set less every pixel within the 5x5 square of a pixel where a
487
- * LATER layer of the run is the owner — the erosion kept only along a rim with
557
+ * layer in front of it (`frontOf`) is the owner — the erosion kept only along a rim with
488
558
  * a neighbour in front, where the painting's pixel may be that neighbour's
489
559
  * edge. Every other step is the same. Fringe pixels (alpha below 250) are not
490
560
  * projected under either rule: the painting there is this layer blended with
491
561
  * what is behind it.
492
562
  *
493
- * Both rules return `clear` (no later layer is the owner) and `takenMask`, the
563
+ * Both rules return `clear` (no layer in front of this one, by `frontOf`, is the owner) and `takenMask`, the
494
564
  * masks the part counts in `parts.json` are derived from; `takenMask` is a
495
565
  * subset of the top-most set, and so of `clear`.
496
566
  */
497
- export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, run: Run, rule: ProjectRule = DEFAULT_PROJECT_RULE): ProjectionStats[] {
567
+ export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, run: Run, rule: ProjectRule = DEFAULT_PROJECT_RULE, rank: readonly number[] | null = null): ProjectionStats[] {
498
568
  const { W, H } = frame;
499
- const owner = new Int32Array(W * H).fill(-1);
500
- rigLayers.forEach((r, i) => {
501
- for (let p = 0; p < W * H; p++) if (r.data[p * 4 + 3] >= CORE_ALPHA) owner[p] = i;
502
- });
569
+ const owner = pixelOwners(rigLayers, rank);
570
+ const inFront = (o: number, i: number): boolean => frontOf(o, i, rank);
503
571
  let win: Mask | null = null;
504
572
  if (run === 'head') {
505
573
  win = newMask(W, H);
@@ -516,7 +584,7 @@ export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, r
516
584
  const takenMask = newMask(W, H);
517
585
  let anyTop = false;
518
586
  for (let p = 0; p < W * H; p++) {
519
- if (owner[p] <= i) clear.data[p] = 1;
587
+ if (owner[p] === -1 || owner[p] === i || !inFront(owner[p], i)) clear.data[p] = 1;
520
588
  if (owner[p] === i && r.data[p * 4 + 3] >= CORE_ALPHA) {
521
589
  top.data[p] = 1;
522
590
  anyTop = true;
@@ -527,7 +595,7 @@ export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, r
527
595
  if (rule === 'core') core = erode(top, CORE_KERNEL);
528
596
  else {
529
597
  const front = newMask(W, H);
530
- for (let p = 0; p < W * H; p++) if (owner[p] > i) front.data[p] = 1;
598
+ for (let p = 0; p < W * H; p++) if (owner[p] >= 0 && owner[p] !== i && inFront(owner[p], i)) front.data[p] = 1;
531
599
  const rim = dilate(front, CORE_KERNEL);
532
600
  core = newMask(W, H);
533
601
  for (let p = 0; p < W * H; p++) if (top.data[p] === 1 && rim.data[p] === 0) core.data[p] = 1;
@@ -1602,6 +1670,8 @@ export interface AssembleResult {
1602
1670
  cuts: CutCount[];
1603
1671
  /** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
1604
1672
  uncovered: UncoveredErrorLayer[];
1673
+ /** The parts whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw order (`driftBodies`), in draw order; empty otherwise. */
1674
+ drift: DriftBodies[];
1605
1675
  seamRule: SeamRule;
1606
1676
  projectRule: ProjectRule;
1607
1677
  }
@@ -1664,7 +1734,7 @@ export function assemble(input: AssembleInput): AssembleResult {
1664
1734
  const layers = runLayers(set, input.resolution);
1665
1735
  if (run === 'full') fullTags.push(...layers.filter((l) => l.opaqueRunPx > 0).map((l) => l.tag));
1666
1736
  const rigLayers = layers.map((l) => layerToRig(l.canvas, frame, run));
1667
- const st = projectSource(rigLayers, srcr, frame, run, projectRule);
1737
+ const st = projectSource(rigLayers, srcr, frame, run, projectRule, planRanks(layers.map((l) => l.tag), run, plan));
1668
1738
  layers.forEach((l, i) => {
1669
1739
  rig.set(`${run}:${l.tag}`, rigLayers[i]);
1670
1740
  stats.set(`${run}:${l.tag}`, st[i]);
@@ -1958,6 +2028,23 @@ export function assemble(input: AssembleInput): AssembleResult {
1958
2028
  return { record: whole, image: b.r };
1959
2029
  });
1960
2030
  const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
2031
+ // Issue #255: a part's drift refusals in a body, and who holds the body in the draw order (the push-back clears
2032
+ // only fringe below CORE_ALPHA, and the seam override only recolours, so the holders read here are final).
2033
+ const holderAt = new Int32Array(W * H).fill(-1);
2034
+ stack.forEach(({ image }, i) => {
2035
+ for (let p = 0; p < W * H; p++) if (image.data[p * 4 + 3] >= CORE_ALPHA) holderAt[p] = i;
2036
+ });
2037
+ const stackNames = stack.map((q) => q.record.name);
2038
+ const drift: DriftBodies[] = [];
2039
+ order.forEach((d) => {
2040
+ if ('patch' in d) return;
2041
+ const { name, b } = pieceAt(d);
2042
+ if (b.st.refusedMask === null) return;
2043
+ const refused = newMask(W, H);
2044
+ for (let p = 0; p < W * H; p++) if (b.st.refusedMask.data[p] === 1 && (b.region === null || b.region.data[p] === 1)) refused.data[p] = 1;
2045
+ const found = driftBodies(name, b.key, refused, bodyMinPx(frame), holderAt, stackNames);
2046
+ if (found !== null) drift.push(found);
2047
+ });
1961
2048
  const pushedOf: FringePushed[][] = plan.map(() => []);
1962
2049
  const pushedOfCut: FringePushed[][] = cuts.map(() => []);
1963
2050
  order.forEach((d, i) => {
@@ -2048,11 +2135,99 @@ export function assemble(input: AssembleInput): AssembleResult {
2048
2135
  figures,
2049
2136
  cuts: cutCounts,
2050
2137
  uncovered,
2138
+ drift,
2051
2139
  seamRule,
2052
2140
  projectRule,
2053
2141
  };
2054
2142
  }
2055
2143
 
2144
+ // ---------------------------------------------------------------------------
2145
+ // refused bodies: a part whose layer is not what the painting shows (issue #255)
2146
+ // ---------------------------------------------------------------------------
2147
+
2148
+ /** A part whose `DRIFT_LIMIT` refusals hold a body, and which parts the draw order puts there. */
2149
+ export interface DriftBodies {
2150
+ /** The part, and its `<run>:<tag>`. */
2151
+ part: string;
2152
+ from: string;
2153
+ /** The part's `refused_drift_px`, in a body or not. */
2154
+ refusedPx: number;
2155
+ /** The bar the bodies were held to (`bodyMinPx`, the uncovered line's). */
2156
+ minPx: number;
2157
+ /** The bodies, largest first (`bodiesOf`). */
2158
+ bodies: UncoveredBody[];
2159
+ /**
2160
+ * Over every pixel of the bodies, the part that holds it in the draw order
2161
+ * — the top-most at alpha >= `CORE_ALPHA`, as the seam pass reads a holder
2162
+ * (`seamCandidates`) — with its pixel count, most first, then by name.
2163
+ */
2164
+ holders: Array<{ part: string; px: number }>;
2165
+ }
2166
+
2167
+ /**
2168
+ * A part's refused bodies that another part holds in the draw order (issue
2169
+ * #255).
2170
+ *
2171
+ * ⚖️ Invariant: `refused` is the part's projection core pixels whose layer
2172
+ * colour is more than `DRIFT_LIMIT` off the painting (`projectSource`'s
2173
+ * `refusedMask`, cut to the part's region); its bodies are `bodiesOf(refused,
2174
+ * minPx)` — the pixels that survive a `BODY_KERNEL` opening, in 8-connected
2175
+ * components of at least `minPx` — the same reading of "a body" as an
2176
+ * uncovered layer's, and the stage passes the same bar (`bodyMinPx`).
2177
+ * `holderAt` is, per rig pixel, the index into `names` of the top-most part
2178
+ * at alpha >= `CORE_ALPHA` in draw order (-1: none), as the seam pass reads a
2179
+ * holder. The result is non-null exactly when the bodies hold a pixel whose
2180
+ * holder is another part: the plan draws that part over the body, which is
2181
+ * the signature of "this layer is not the owner here".
2182
+ *
2183
+ * Not every body: a body the part holds itself, with nothing drawn over it,
2184
+ * is See-through's colour for the part's own pixels disagreeing with the
2185
+ * painting, and on four of the five public paintings it holds bodies of
2186
+ * hundreds to tens of thousands of pixels that no other part touches (the
2187
+ * pull request that added this has the figures); a line there would name no
2188
+ * owner, which is what the line exists to name.
2189
+ */
2190
+ export function driftBodies(part: string, from: string, refused: Mask, minPx: number, holderAt: Int32Array, names: readonly string[]): DriftBodies | null {
2191
+ let refusedPx = 0;
2192
+ for (let p = 0; p < refused.data.length; p++) refusedPx += refused.data[p];
2193
+ if (refusedPx === 0) return null;
2194
+ const { bodies, pixels } = bodiesWithPixels(refused, minPx);
2195
+ if (bodies.length === 0) return null;
2196
+ const count = new Map<string, number>();
2197
+ for (let p = 0; p < pixels.data.length; p++) {
2198
+ if (pixels.data[p] === 0 || holderAt[p] < 0) continue;
2199
+ const n = names[holderAt[p]];
2200
+ count.set(n, (count.get(n) ?? 0) + 1);
2201
+ }
2202
+ if (![...count.keys()].some((n) => n !== part)) return null;
2203
+ const holders = [...count.entries()].map(([q, px]) => ({ part: q, px })).sort((a, b) => b.px - a.px || (a.part < b.part ? -1 : a.part > b.part ? 1 : 0));
2204
+ return { part, from, refusedPx, minPx, bodies, holders };
2205
+ }
2206
+
2207
+ /**
2208
+ * One `drift` line under the `uncovered` lines: the part, its refused pixels,
2209
+ * their bodies, and the parts the plan's draw order puts on them —
2210
+ * `drift: "topwear" refuses 196 px of full:topwear (its colour more than 90
2211
+ * off the painting, max channel), in a body of 196 px at 16,12 14x14 (at
2212
+ * least 150 px past a 5x5 opening); by the plan's draw order "face" holds 196
2213
+ * px of it (the top-most part at alpha >= 250), drawn in front of "topwear"`.
2214
+ */
2215
+ export function driftLine(d: DriftBodies): string {
2216
+ const total = d.bodies.reduce((a, b) => a + b.px, 0);
2217
+ const big = d.bodies[0];
2218
+ const held = d.bodies.length > 1 ? `${d.bodies.length} bodies of ${total} px, the largest ${big.px} px at ${bodyBox(big)}` : `a body of ${big.px} px at ${bodyBox(big)}`;
2219
+ const others = d.holders.filter((h) => h.part !== d.part).map((h) => `"${h.part}"`);
2220
+ return (
2221
+ `drift: "${d.part}" refuses ${d.refusedPx} px of ${d.from} (its colour more than ${DRIFT_LIMIT} off the painting, max channel), in ${held} (at least ${fmtPx(d.minPx)} px past a ${BODY_KERNEL}x${BODY_KERNEL} opening); ` +
2222
+ `by the plan's draw order ${d.holders.map((h) => `"${h.part}" holds ${h.px} px`).join(', ')} of it (the top-most part at alpha >= ${CORE_ALPHA}), ${others.join(', ')} drawn in front of "${d.part}"`
2223
+ );
2224
+ }
2225
+
2226
+ /** A rig-pixel bar as the lines print it: a whole number as it is, otherwise to one decimal. */
2227
+ function fmtPx(n: number): string {
2228
+ return Number.isInteger(n) ? String(n) : n.toFixed(1);
2229
+ }
2230
+
2056
2231
  // ---------------------------------------------------------------------------
2057
2232
  // uncovered bodies: a full-run layer the plan leaves out (issue #233)
2058
2233
  // ---------------------------------------------------------------------------
@@ -2104,12 +2279,20 @@ export interface UncoveredLayer {
2104
2279
  * the box's top row and left column.
2105
2280
  */
2106
2281
  export function bodiesOf(mask: Mask, minPx: number): UncoveredBody[] {
2282
+ return bodiesWithPixels(mask, minPx).bodies;
2283
+ }
2284
+
2285
+ /** `bodiesOf`, and the mask of every pixel that lies in one of the bodies it returns. */
2286
+ export function bodiesWithPixels(mask: Mask, minPx: number): { bodies: UncoveredBody[]; pixels: Mask } {
2107
2287
  const opened = dilate(erode(mask, BODY_KERNEL), BODY_KERNEL);
2108
2288
  for (let p = 0; p < opened.data.length; p++) if (mask.data[p] === 0) opened.data[p] = 0;
2109
2289
  const cc = connectedComponents(opened, 8);
2110
- const all = cc.stats.slice(1).filter((s) => s.area > 0 && s.area >= minPx);
2290
+ const keep = cc.stats.map((s, l) => l > 0 && s.area > 0 && s.area >= minPx);
2291
+ const all = cc.stats.filter((s, l) => keep[l]);
2111
2292
  all.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
2112
- return all.map((s) => ({ px: s.area, x: s.left, y: s.top, w: s.width, h: s.height }));
2293
+ const pixels = newMask(mask.width, mask.height);
2294
+ for (let p = 0; p < pixels.data.length; p++) if (keep[cc.labels[p]]) pixels.data[p] = 1;
2295
+ return { bodies: all.map((s) => ({ px: s.area, x: s.left, y: s.top, w: s.width, h: s.height })), pixels };
2113
2296
  }
2114
2297
 
2115
2298
  /** A body's box as the hole lines print one: `x,y wxh`. */
package/src/build.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, driftLine, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, stimulusMotion, withStimulus } from './automotion.ts';
@@ -82,7 +82,8 @@ import { type PartRecord, readParts, writeParts } from './parts.ts';
82
82
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
83
  import type { Raster } from './raster/types.ts';
84
84
  import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
85
- import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
85
+ import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared, type TurnRow } from './rig.ts';
86
+ import { TURN_BONE } from './turn.ts';
86
87
  import { pyRound } from './round.ts';
87
88
 
88
89
  /** Where a stage's lines go. The commands hand it `console.log`; `build` hands it a prefixing wrapper. */
@@ -230,6 +231,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
230
231
  log(figuresLine(result.figures));
231
232
  for (const l of holeLines(result.figures)) log(l);
232
233
  for (const u of result.uncovered) log(uncoveredLine(u));
234
+ for (const d of result.drift) log(driftLine(d));
233
235
  log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)`);
234
236
  return result;
235
237
  }
@@ -821,6 +823,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
821
823
  );
822
824
  if (from !== null) log(` animations beside the idle, from ${from.file} as written: ${Object.keys(from.table).join(', ') || 'none'}`);
823
825
  log(faceAxisLine(rig.faceAxis));
826
+ if (rig.turn !== null) log(turnLine(rig.turn));
824
827
  log(
825
828
  rig.idleKeys === 'ctl'
826
829
  ? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
@@ -946,7 +949,9 @@ export function barsSummary(r: CheckReport): string {
946
949
  /**
947
950
  * The rig stage's face-axis line (issue #239): the eye line's angle, the parts it runs between and the face bones
948
951
  * turned to it, or why there is no line and the face bones keep the page's axes — the note a one-eyed painting gets.
949
- * With a line it also names the `invariants.axes` entry written for rigc's A51, its tolerance, or why none was.
952
+ * With a line it also names the `invariants.axes` entry written for rigc's A51, its tolerance, or why none was; and
953
+ * the entry holding the face bones whose own tip lies along the line, or each such bone with why it is not held
954
+ * (issue #258) — a face with no entry is never silent.
950
955
  */
951
956
  export function faceAxisLine(f: FaceAxis): string {
952
957
  const own = f.ownTip.length === 0 ? '' : `; ${f.ownTip.join(', ')} ${f.ownTip.length === 1 ? 'takes its' : 'take their'} own tip`;
@@ -958,7 +963,25 @@ export function faceAxisLine(f: FaceAxis): string {
958
963
  : f.noAxis !== null
959
964
  ? `; no invariants.axes entry: ${f.noAxis}`
960
965
  : '';
961
- return ` face axis: ${pyRound(f.degrees, 6)} degrees, the line through the eye bones eye_r and eye_l (on ${f.parts[0]} and ${f.parts[1]}); ${turned}${own}${held}`;
966
+ // Issue #258: the tipped face bones are held in an entry of their own, or each is named with why it is not.
967
+ const tips =
968
+ f.noAxis !== null && f.turned.length === 0
969
+ ? ''
970
+ : `${f.tipAxis === null ? '' : `; invariants.axes "${f.tipAxis.name}" holds ${f.tipAxis.bones.join(', ')}, whose own tips lie along the line within whole-pixel rounding, tolerance ${f.tipAxis.tolerance} degrees (rigc's A51)`}${f.tipOff.map((t) => `; ${t}`).join('')}`;
971
+ // Issue #250: the slotless bone carrying the face's up axis under head, or why there is none.
972
+ const up = f.headUp !== null ? `; head_up under head along the face's up axis, ${f.headUp.rotation} degrees, ${f.headUp.length} px (head's origin to the eye midpoint along it)` : f.headUpNone !== null ? `; no head_up: ${f.headUpNone}` : '';
973
+ return ` face axis: ${pyRound(f.degrees, 6)} degrees, the line through the eye bones eye_r and eye_l (on ${f.parts[0]} and ${f.parts[1]}); ${turned}${own}${held}${tips}${up}`;
974
+ }
975
+
976
+ /**
977
+ * The rig stage's turn line (issue #251): the control, and per carried part its fraction, the depth it came from and
978
+ * how many twins carry it — a part at the pivot depth said to stay. Printed only when the config has `turn`.
979
+ */
980
+ export function turnLine(rows: readonly TurnRow[]): string {
981
+ const carried = rows.filter((r) => r.twins.length > 0);
982
+ const twins = carried.reduce((n, r) => n + r.twins.length, 0);
983
+ const parts = rows.map((r) => (r.twins.length === 0 ? `${r.part} stays (depth ${r.depth}, the pivot's)` : `${r.part} ${r.fraction} (depth ${r.depth}, ${r.twins.length} twin(s))`));
984
+ return ` turn: control "${TURN_BONE}" under root, keyed by nothing; ${carried.length} part(s) carried by ${carried.length} transform constraint(s) over ${twins} twin bone(s), each part's x a fraction of the control's: ${parts.join(', ')}`;
962
985
  }
963
986
 
964
987
  /** A judgement line as the console prints it: its name, its status, then its figures and bars as check.json holds them (a SKIP prints its reason). */
package/src/check.ts CHANGED
@@ -68,14 +68,15 @@
68
68
  * was that for every rig it ever saw); the grey is read from rigc's
69
69
  * `frames.json` rather than typed; the crop-to-world map comes from the rig's
70
70
  * own stage box through `src/coords.ts` rather than an open-coded `-W/2`; and
71
- * the last idle frame is refused unless it sits at t = duration.
71
+ * the last idle frame is refused unless it sits at t = duration (within
72
+ * `TIME_SLACK`, issue #244).
72
73
  */
73
74
  import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
74
75
  import { basename, delimiter, dirname, join, resolve } from 'node:path';
75
76
  import { measureRecomposite, type RecompositeFigures, recompositeRecord, sourceInRig } from './assemble.ts';
76
77
  import { cropToSpineY } from './coords.ts';
77
78
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
78
- import { framesInside, IDLE_FPS } from './motion.ts';
79
+ import { framesInside, IDLE_FPS, TIME_SLACK } from './motion.ts';
79
80
  import {
80
81
  blinkFigures,
81
82
  type GeometryPose,
@@ -1816,12 +1817,14 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1816
1817
  if (render.status !== 0) refuseIfAny(rigcFailed('render --animation idle --geometry', render, render.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1817
1818
  idle = readFrameSet(idleDir);
1818
1819
  const last = idle.frames[idle.frames.length - 1];
1819
- if (idle.stride !== 1 || idle.written !== idle.sampled || idle.duration !== inp.idleDuration || last.index !== idle.sampled - 1) {
1820
+ // Issue #244: the last frame's time and the duration are compared within TIME_SLACK, not with `!==` — a whole
1821
+ // number of frames (40/12 s) is not exact in binary, and rigc writes its last frame at 40 * (1/12), one ulp off.
1822
+ if (idle.stride !== 1 || idle.written !== idle.sampled || Math.abs(idle.duration - inp.idleDuration) > TIME_SLACK || last.index !== idle.sampled - 1) {
1820
1823
  refuseIfAny([
1821
1824
  {
1822
1825
  code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
1823
1826
  object: `idle frame ${last.name}`,
1824
- detail: `rigc sampled ${idle.sampled} frame(s), wrote ${idle.written} at stride ${idle.stride}, the last at t = ${idle.duration}s; the loop compares frame 0 with the frame at the idle's duration, ${inp.idleDuration}s, so a duration that is a whole number of 1/${IDLE_FPS} s ticks and every frame written are required`,
1827
+ detail: `rigc sampled ${idle.sampled} frame(s), wrote ${idle.written} at stride ${idle.stride}, the last at t = ${idle.duration}s; the loop compares frame 0 with the frame at the idle's duration, ${inp.idleDuration}s, so a duration that is a whole number of 1/${IDLE_FPS} s ticks (within ${TIME_SLACK} s) and every frame written are required`,
1825
1828
  },
1826
1829
  ]);
1827
1830
  }
@@ -2447,10 +2450,17 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2447
2450
  rows.push({ part: p.name, ...said, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
2448
2451
  if (!ok) {
2449
2452
  failed = true;
2453
+ // Issue #262: a root the breath carries is named with its carrier, not blamed on the chain.
2454
+ const carry = breathCarrier(inp.rig, inp.motion, p.name, geometryPath, frames, { art, box: p, stage: inp.stage, shown, toward: rises ? 'up' : 'down', root: t.root, tip: t.tip });
2455
+ if (carry !== null) rows[rows.length - 1] = { ...rows[rows.length - 1], carried_by: carry.bone, breath_rig_px: round3(carry.breath), root_without_breath_rig_px: round3(carry.root), tip_without_breath_rig_px: round3(carry.tip) };
2456
+ const why =
2457
+ carry === null
2458
+ ? "the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain"
2459
+ : `the root travels with the breath (${round3(t.root)} px of ${round3(carry.breath)} px): it is carried by \`${carry.bone}\`, which moves it more than the tip; with \`${carry.keyed}\`'s translate held still the root travels ${round3(carry.root)} rig px and the tip ${round3(carry.tip)}, which the bar passes (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}) — bind the root to the bone the part hangs from, not to \`${carry.bone}\``;
2450
2460
  problems.push({
2451
2461
  code: 'CHECK_TIP_OVER_ROOT',
2452
2462
  object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
2453
- detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain`,
2463
+ detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${why}`,
2454
2464
  });
2455
2465
  }
2456
2466
  }
@@ -2462,6 +2472,110 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2462
2472
  return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
2463
2473
  }
2464
2474
 
2475
+ /**
2476
+ * The bones a part's attachments are bound to in the default skin (issue #231): a mesh's weights, or its slot's
2477
+ * bone for an attachment that carries no weights.
2478
+ */
2479
+ function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
2480
+ const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
2481
+ const skins = isRecord(rig.skins) ? rig.skins : {};
2482
+ const skin = isRecord(skins.default) ? skins.default : {};
2483
+ const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
2484
+ const bound = new Set<string>();
2485
+ for (const a of atts) {
2486
+ if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
2487
+ if (!(isRecord(a) && Array.isArray(a.weights)) && slot !== undefined && typeof slot.bone === 'string') bound.add(slot.bone);
2488
+ }
2489
+ return bound;
2490
+ }
2491
+
2492
+ /** The idle track properties that move a bone without turning it: the breath is one of these (issue #262). */
2493
+ const BREATH_PROPS: readonly string[] = ['translate', 'translatex', 'translatey'];
2494
+
2495
+ /**
2496
+ * The bone a failing part's root travels with, when that is the breath's (issue #262). The breath is every idle
2497
+ * track that translates a bone ({@link BREATH_PROPS}), each read on its own: `K` the bone it keys. Its motion is
2498
+ * `K`'s world origin less where `K`'s parent carries `K`'s setup position, frame by frame — what the translate keys
2499
+ * add — and the breath's travel is that, less its frame-0 value, at its largest. The part (one weighted mesh; a
2500
+ * region is carried whole and cannot stretch) is posed again with that motion taken out of every vertex in
2501
+ * proportion to its weight on `K` and the bones under it, and its halves measured as {@link halfTravels} measures
2502
+ * them. The root travels with the breath when the breath adds more to the root's travel than to the tip's and, held
2503
+ * still, the halves pass the bar: tip >= {@link TIP_RATIO_FLOOR} x root, the tip above 0 at the line's precision
2504
+ * (round3). The margin is the bar's own, so the line names the carrier exactly when the breath is what fails it;
2505
+ * the carrier named is the bone the part is bound to ({@link boundBones}) at or under `K` that no other such bone is
2506
+ * above (the sleeve's links ride `chest`, which is the carrier). Null otherwise.
2507
+ */
2508
+ function breathCarrier(
2509
+ rig: Record<string, unknown>,
2510
+ motion: Record<string, unknown>,
2511
+ part: string,
2512
+ geometryPath: string,
2513
+ frames: number,
2514
+ m: { art: ArtMask; box: { x: number; y: number; w: number; h: number }; stage: StageBox; shown: ReadonlyArray<PosedAttachment | null>; toward: 'down' | 'up'; root: number; tip: number },
2515
+ ): { bone: string; keyed: string; breath: number; root: number; tip: number } | null {
2516
+ const anims = isRecord(motion.animations) ? motion.animations : {};
2517
+ const idle = isRecord(anims.idle) ? anims.idle : {};
2518
+ const keyed = [...new Set((Array.isArray(idle.tracks) ? idle.tracks : []).filter(isRecord).filter((t) => typeof t.bone === 'string' && typeof t.property === 'string' && BREATH_PROPS.includes(t.property)).map((t) => String(t.bone)))];
2519
+ if (keyed.length === 0) return null;
2520
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
2521
+ const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
2522
+ const setupAt = new Map(bones.map((b) => [String(b.name), [typeof b.x === 'number' ? b.x : 0, typeof b.y === 'number' ? b.y : 0] as const]));
2523
+ const atOrUnder = (b: string, anc: string): boolean => {
2524
+ const seen = new Set<string>();
2525
+ for (let x: string | null = b; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
2526
+ if (x === anc) return true;
2527
+ seen.add(x);
2528
+ }
2529
+ return false;
2530
+ };
2531
+ // The one weighted mesh the part draws, its weights in the order of the geometry's vertices.
2532
+ const skins = isRecord(rig.skins) ? rig.skins : {};
2533
+ const skin = isRecord(skins.default) ? skins.default : {};
2534
+ const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
2535
+ const first = m.shown[0];
2536
+ if (atts.length !== 1 || first === null || first === undefined) return null;
2537
+ const att = atts[0];
2538
+ if (!isRecord(att) || !Array.isArray(att.weights) || att.weights.length * 2 !== first.rest.length) return null;
2539
+ const weights = att.weights.map((v) => (Array.isArray(v) ? v.filter(isRecord).filter((w) => typeof w.bone === 'string' && typeof w.weight === 'number') : []));
2540
+ const geo = readGeometry(geometryPath, frames);
2541
+ if (geo === null) return null;
2542
+ const world = (bs: unknown, name: string): BoneWorld | null => {
2543
+ const b = Array.isArray(bs) ? bs.filter(isRecord).find((x) => x.name === name) : undefined;
2544
+ if (b === undefined) return null;
2545
+ const f = [b.a, b.b, b.c, b.d, b.worldX, b.worldY];
2546
+ if (!f.every((v) => typeof v === 'number' && Number.isFinite(v))) return null;
2547
+ return { a: b.a as number, b: b.b as number, c: b.c as number, d: b.d as number, worldX: b.worldX as number, worldY: b.worldY as number };
2548
+ };
2549
+ const bound = [...boundBones(rig, part)];
2550
+ for (const k of keyed) {
2551
+ const under = bound.filter((b) => atOrUnder(b, k));
2552
+ const carrier = under.find((b) => !under.some((o) => o !== b && atOrUnder(b, o)));
2553
+ if (carrier === undefined) continue;
2554
+ // What the translate keys add to K's world origin, frame by frame, less frame 0's.
2555
+ const [lx, ly] = setupAt.get(k) ?? [0, 0];
2556
+ const pk = parent.get(k) ?? null;
2557
+ const added: Array<[number, number] | null> = geo.frameBones.map((bs) => {
2558
+ const w = world(bs, k);
2559
+ if (w === null) return null;
2560
+ const pw = pk === null ? { a: 1, b: 0, c: 0, d: 1, worldX: 0, worldY: 0 } : world(bs, pk);
2561
+ if (pw === null) return null;
2562
+ return [w.worldX - (pw.a * lx + pw.b * ly + pw.worldX), w.worldY - (pw.c * lx + pw.d * ly + pw.worldY)];
2563
+ });
2564
+ const a0 = added[0];
2565
+ if (a0 === null || added.some((d) => d === null)) continue;
2566
+ const delta = (added as Array<[number, number]>).map(([x, y]) => [x - a0[0], y - a0[1]] as const);
2567
+ const breath = Math.max(...delta.map(([x, y]) => Math.hypot(x, y)));
2568
+ if (!(breath > 0)) continue;
2569
+ const share = weights.map((v) => v.filter((w) => atOrUnder(String(w.bone), k)).reduce((s, w) => s + (w.weight as number), 0));
2570
+ const held = m.shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: f.posed.map((v, j) => v - share[j >> 1] * delta[i][j & 1]) }));
2571
+ const t = halfTravels(m.art, m.box, m.stage, held, m.toward);
2572
+ if (t.root === null || t.tip === null) continue;
2573
+ if (!(m.root - t.root > m.tip - t.tip) || round3(t.tip) === 0 || !(t.tip >= TIP_RATIO_FLOOR * t.root)) continue;
2574
+ return { bone: carrier, keyed: k, breath, root: t.root, tip: t.tip };
2575
+ }
2576
+ return null;
2577
+ }
2578
+
2465
2579
  /**
2466
2580
  * Which way the chain carrying a part runs (issue #231), for a part the scene
2467
2581
  * declares `rises`: the bones its attachments are bound to in the default skin
@@ -2477,15 +2591,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2477
2591
  export function chainDirection(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string, origins: ReadonlyMap<string, { x: number; y: number }>): { toward: Toward; chains: string[] } | { none: string } {
2478
2592
  const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
2479
2593
  const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
2480
- const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
2481
- const skins = isRecord(rig.skins) ? rig.skins : {};
2482
- const skin = isRecord(skins.default) ? skins.default : {};
2483
- const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
2484
- const bound = new Set<string>();
2485
- for (const a of atts) {
2486
- if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
2487
- if (!(isRecord(a) && Array.isArray(a.weights)) && slot !== undefined && typeof slot.bone === 'string') bound.add(slot.bone);
2488
- }
2594
+ const bound = boundBones(rig, part);
2489
2595
  const under = (b: string, anc: string): boolean => {
2490
2596
  const seen = new Set<string>();
2491
2597
  for (let x = parent.get(b) ?? null; x !== null && !seen.has(x); x = parent.get(x) ?? null) {