spine-rigc 0.13.0 → 0.14.1
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/README.md +16 -4
- package/cli.ts +329 -3
- package/docs/AUTHORING.md +703 -24
- package/docs/FACE.md +334 -88
- package/docs/INGEST.md +19 -4
- package/docs/MOTION.md +72 -2
- package/docs/RIGGING.md +1453 -0
- package/package.json +2 -1
- package/src/chainfit.ts +707 -35
- package/src/compile.ts +374 -70
- package/src/deformgen.ts +405 -0
- package/src/deformmeasure.ts +453 -0
- package/src/motion.ts +423 -0
- package/src/pose.ts +43 -12
- package/src/render.ts +17 -11
- package/src/rig.ts +56 -0
- package/src/trackgen.ts +354 -0
- package/src/types.ts +129 -3
- package/src/validate.ts +153 -3
package/README.md
CHANGED
|
@@ -386,7 +386,7 @@ limits: [AUTHORING.md §11](docs/AUTHORING.md). The parts it refuses because
|
|
|
386
386
|
something is drawn over them are `rigc chainfit`'s, once a candidate exists —
|
|
387
387
|
[§12](docs/AUTHORING.md).
|
|
388
388
|
|
|
389
|
-
## The gallery —
|
|
389
|
+
## The gallery — five complete rigs over art that ships with them
|
|
390
390
|
|
|
391
391
|
Each directory in [`gallery/`](https://github.com/firejune/rigc/tree/main/gallery) is
|
|
392
392
|
one rig spec, one motion spec and the PNGs they name, small enough to read in one
|
|
@@ -398,9 +398,20 @@ was verified, and what writing it cost. Repository material: a clone and
|
|
|
398
398
|
| Example | Stars | What it is |
|
|
399
399
|
| --- | --- | --- |
|
|
400
400
|
| [`gallery/walk`](https://github.com/firejune/rigc/tree/main/gallery/walk) | `ik` constraints + **`ik` timelines** | Two two-bone leg chains solved to foot targets — the planted leg nailed down, the swinging one let go at the top of its lift |
|
|
401
|
-
| [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash) | **`deform` timelines** | A ball squashed about its contact point and stretched along its travel, from two affine transforms
|
|
401
|
+
| [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash) | **`deform` timelines** | A ball squashed about its contact point and stretched along its travel, from two affine transforms the keys state rather than tabulate |
|
|
402
402
|
| [`gallery/flex`](https://github.com/firejune/rigc/tree/main/gallery/flex) | **`contour` meshes** | A swallow-tailed banner and a serrated leaf: four meshes traced off their own alpha, waved by bone timelines and rippled by a `deform` |
|
|
403
403
|
| [`gallery/ride`](https://github.com/firejune/rigc/tree/main/gallery/ride) | `path` attachments + **path constraints** | A trolley coasting down a drawn rail and rolling back, driven by a `position` timeline, with `groups` + `stagger` keying the wheels and the ears |
|
|
404
|
+
| [`gallery/portrait`](https://github.com/firejune/rigc/tree/main/gallery/portrait) | **deform `transform`** + `derive` group tracks | A 2.5D head turn: two meshes and six feature bones all keyed from one stated expression, `dx = x(cos t − 1) − z·sin t`, with the depths in the spec rather than a README |
|
|
405
|
+
|
|
406
|
+
<p align="center">
|
|
407
|
+
<img src="https://raw.githubusercontent.com/firejune/rigc/main/assets/rigc-scene.gif" alt="A portrait rig breathing, glancing aside, then turning its head in 2.5D — hair and features sliding at different depths" width="600" />
|
|
408
|
+
</p>
|
|
409
|
+
|
|
410
|
+
<p align="center"><em>The portrait rig playing its three animations in one take — the turn is
|
|
411
|
+
the shot: both silhouette edges move apart, which a flat slide cannot do, because every
|
|
412
|
+
feature carries its own depth. Scene direction of this kind was Live2D's territory; the
|
|
413
|
+
split was authoring cost, and the cost is now one stated expression per key. Compiled and
|
|
414
|
+
rendered entirely by the published package.</em></p>
|
|
404
415
|
|
|
405
416
|
## Commands
|
|
406
417
|
|
|
@@ -419,7 +430,7 @@ commands take it and what its default is.
|
|
|
419
430
|
| `preview --candidate <dir>` | one self-contained `.html` that plays it |
|
|
420
431
|
| `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
|
|
421
432
|
| `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
|
|
422
|
-
| `chainfit --candidate <dir> --images <dir> --frame <png>` | reads the parts `pose` refuses, through the candidate's own draw order and hierarchy: masked residuals over **visible** pixels, one hinge per child instead of four degrees of freedom, and the `rotate` key value each answer implies |
|
|
433
|
+
| `chainfit --candidate <dir> --images <dir> --frame <png>` | reads the parts `pose` refuses, through the candidate's own draw order and hierarchy: masked residuals over **visible** pixels, one hinge per child instead of four degrees of freedom, and the `rotate` key value each answer implies. A bone with two or more anchored descendants is **determined** rather than searched, and the residual that over-determination leaves is reported |
|
|
423
434
|
| `diff <candidate.json> <reference.json>` | structural comparison of two skeletons, one ratio per measure and deliberately no combined score |
|
|
424
435
|
| `check --candidate <dir> --frames <dir>` | the candidate against reference pictures — the only instrument here that can see a *wrong animation* |
|
|
425
436
|
| `bench <rung> --candidate <dir>` | one rung of the benchmark ladder |
|
|
@@ -447,6 +458,7 @@ the art. Its shape is under
|
|
|
447
458
|
| Document | For |
|
|
448
459
|
| --- | --- |
|
|
449
460
|
| 📘 **[docs/AUTHORING.md](docs/AUTHORING.md)** | **the format guide, and the one to read before writing a spec.** Both input files field by field with a complete minimal example each, every field with its Spine meaning, the rules that decide what is emitted, the build → read the report → fix → repeat loop, the map from every named failure to the file that has to change, and the features rigc refuses by name so you do not spend a loop discovering them. It travels **inside the npm package**, at `node_modules/spine-rigc/docs/AUTHORING.md` |
|
|
461
|
+
| 🦴 **[docs/RIGGING.md](docs/RIGGING.md)** | **authoring the hierarchy.** Where a bone goes and why the art is pushed out on an offset, why a pivot in the wrong place looks like a search failure and what identifies one, moving a pivot and the child row that gets forgotten, gauges, siblings-not-a-chain, what a chain can reach and how many links it needs, why a local key is not a world key, duplicate art at mirrored pivots, and constraints as structure. Every section is a stumble the run records hold more than once, ranked by how often. Ships in the package too |
|
|
450
462
|
| 🎞️ **[docs/MOTION.md](docs/MOTION.md)** | **the key-pose recipe.** How to get two poses, what a pair of poses does and does not fix, the in-betweening rules and where each comes from, and how to spread candidates so a ballot informs. Ships in the package too |
|
|
451
463
|
| 🙂 **[docs/FACE.md](docs/FACE.md)** | **authoring a face.** A blink, a gaze and a 2.5D head turn on plain Spine data: the one line of yaw arithmetic every number in a turn comes from, depth as the parameter you are actually authoring, where to put a grid's columns and the angle at which any grid folds, what foreshortens and what does not, channel allocation before the first key, and the three cliffs with their angles. Also the deform audit gap, demonstrated — a folded mesh gates green — and the differential check that works today |
|
|
452
464
|
| 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
|
|
@@ -491,7 +503,7 @@ that an agent authors a spineboy-scale rig from the brief alone in one run: the
|
|
|
491
503
|
ladder has not demonstrated that, and each row records which of the two it is.
|
|
492
504
|
|
|
493
505
|
The whole dossier — the yardstick, `diff` and `check` and what neither of them can
|
|
494
|
-
see, every rung, the run viewer, the
|
|
506
|
+
see, every rung, the run viewer, the 40 assertions and the selftest behind them — is
|
|
495
507
|
[docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
|
|
496
508
|
Live rung status is
|
|
497
509
|
[docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
|
package/cli.ts
CHANGED
|
@@ -58,10 +58,17 @@ import {
|
|
|
58
58
|
} from './src/bonedist.ts';
|
|
59
59
|
import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
|
|
60
60
|
import { compile, CompileError, type CompileOptions } from './src/compile.ts';
|
|
61
|
+
import {
|
|
62
|
+
skeletonDataFromText,
|
|
63
|
+
surveyDeformKeys,
|
|
64
|
+
type DeformExtreme,
|
|
65
|
+
type DeformKeyMeasure,
|
|
66
|
+
} from './src/deformmeasure.ts';
|
|
61
67
|
import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
|
|
62
68
|
import { copyAtlasImages } from './src/emit.ts';
|
|
63
69
|
import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
|
|
64
70
|
import { parseJsonWithPosition } from './src/json-position.ts';
|
|
71
|
+
import { KEY_TIME_EPSILON } from './src/timelines.ts';
|
|
65
72
|
import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
|
|
66
73
|
import {
|
|
67
74
|
DEFAULT_MAX_RESIDUAL,
|
|
@@ -79,6 +86,7 @@ import {
|
|
|
79
86
|
ChainFitError,
|
|
80
87
|
DEFAULT_HINGE_MAX,
|
|
81
88
|
DEFAULT_HINGE_MIN,
|
|
89
|
+
DEFAULT_MIN_LEVER_PX,
|
|
82
90
|
DEFAULT_MIN_VISIBLE,
|
|
83
91
|
DEFAULT_PASSES,
|
|
84
92
|
estimateChainFit,
|
|
@@ -105,7 +113,8 @@ import {
|
|
|
105
113
|
type FrameSet,
|
|
106
114
|
} from './src/render.ts';
|
|
107
115
|
import { CLI_DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from './src/validate.ts';
|
|
108
|
-
import
|
|
116
|
+
import { parseMotionSpec } from './src/motion.ts';
|
|
117
|
+
import type { CompileResult } from './src/types.ts';
|
|
109
118
|
|
|
110
119
|
/**
|
|
111
120
|
* One entry of a cuts.json, every path relative to the cuts.json file.
|
|
@@ -172,8 +181,19 @@ function repositoryUrl(): string {
|
|
|
172
181
|
* Listed by name rather than inferred from "the next argument looks like a
|
|
173
182
|
* flag": inferring it would turn `--out --json report.json` — a real typo, a
|
|
174
183
|
* missing value — into a silently accepted switch plus a stray positional.
|
|
184
|
+
*
|
|
185
|
+
* ⚠️ This set and `FLAG_VALUES` are two halves of one statement, and they are
|
|
186
|
+
* the halves a reader and the parser read separately: a flag absent from
|
|
187
|
+
* `FLAG_VALUES` is printed bare in every usage line and flag table, and a flag
|
|
188
|
+
* present here is the only kind the parser will accept bare. `all-bones` was in
|
|
189
|
+
* one half and not the other for two releases — documented bare in `bonedist`'s
|
|
190
|
+
* usage line, in the shared flag table, and in the hint `src/bonedist.ts` prints
|
|
191
|
+
* under a truncated bone table, while the parser fell through to the value
|
|
192
|
+
* branch and answered the caller who followed that hint with `rigc: --all-bones
|
|
193
|
+
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
194
|
+
* halves together by reading `--help` rather than by naming a flag.
|
|
175
195
|
*/
|
|
176
|
-
const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again', 'pack']);
|
|
196
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack']);
|
|
177
197
|
|
|
178
198
|
/**
|
|
179
199
|
* The flags a command is allowed to spell more than once.
|
|
@@ -431,6 +451,264 @@ function meshBudget(rig: CompileResult['rig']): string {
|
|
|
431
451
|
return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
|
|
432
452
|
}
|
|
433
453
|
|
|
454
|
+
/**
|
|
455
|
+
* One extreme, as `x0.637306 tri 0`, or an em dash when no triangle on the key
|
|
456
|
+
* could carry the quantity.
|
|
457
|
+
*
|
|
458
|
+
* A dash rather than `x1.000000`: a key over a mesh whose every triangle is a
|
|
459
|
+
* hair has no ratio and no map, and printing the identity there would report a
|
|
460
|
+
* measurement that was never taken — this repository's favourite false green.
|
|
461
|
+
*/
|
|
462
|
+
function deformExtreme(extreme: DeformExtreme | null): string {
|
|
463
|
+
return extreme === null ? '—'.padEnd(9) : `x${extreme.value.toFixed(6)} tri ${extreme.triangle}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The `MEMBER` report block — a group track's per-member values, side by side
|
|
468
|
+
* (issue #295).
|
|
469
|
+
*
|
|
470
|
+
* ## Why side by side is the whole point
|
|
471
|
+
*
|
|
472
|
+
* The complaint that filed #295 was not the line count. `gallery/portrait`'s
|
|
473
|
+
* held yaw put six sibling bones' `translatex` in six separate tracks, and the
|
|
474
|
+
* reason that is bad is that **nobody can see a wrong sign in a column that is
|
|
475
|
+
* eighty lines from its neighbours.** FACE §3 makes the same argument from the
|
|
476
|
+
* other side: a residual is 1–6 units where a total is 30–40, and the split is
|
|
477
|
+
* *an auditing decision before it is a rigging one*. So the report's job is to
|
|
478
|
+
* put the numbers in the arrangement the audit needs — one row per member, one
|
|
479
|
+
* block per key — which is exactly the arrangement the emitted format cannot
|
|
480
|
+
* have, because Spine keys one bone per timeline.
|
|
481
|
+
*
|
|
482
|
+
* ## It quotes; it does not re-derive
|
|
483
|
+
*
|
|
484
|
+
* The same rule as the `DEFORM` block. Every value here is the one the compiler
|
|
485
|
+
* **emitted**, carried on `result.trackDerivations`, so the block and the
|
|
486
|
+
* artifact cannot disagree. `derived` and `formula` are the model's own strings
|
|
487
|
+
* from `src/trackgen.ts`, so the block names the closed form the spec stated
|
|
488
|
+
* rather than a second reading of it.
|
|
489
|
+
*
|
|
490
|
+
* ## What it deliberately does not print
|
|
491
|
+
*
|
|
492
|
+
* **Tracks whose members all share one value** — the ordinary `groups` entry.
|
|
493
|
+
* There is one number there and the timelines above already show it on every
|
|
494
|
+
* member; a table of six identical rows would be a tautology, and the block
|
|
495
|
+
* exists to make a *difference* visible. `look_l`/`look_r` in the worked example
|
|
496
|
+
* are exactly that case and they are right to be absent from here.
|
|
497
|
+
*
|
|
498
|
+
* **`stagger`.** A per-member time offset is printed as it always was — on each
|
|
499
|
+
* member's own timeline, where the shifted key times are. Repeating it here
|
|
500
|
+
* would put one lag in two places.
|
|
501
|
+
*/
|
|
502
|
+
function memberReportLines(result: CompileResult): string[] {
|
|
503
|
+
if (result.trackDerivations.length === 0) return [];
|
|
504
|
+
const out: string[] = [
|
|
505
|
+
'',
|
|
506
|
+
'group members (the per-member values of one track, side by side — issue #295)',
|
|
507
|
+
' .. a row per member and a block per key, because a wrong sign is visible in a column of six and',
|
|
508
|
+
' .. invisible in six tracks. Values are the EMITTED ones, so this and the artifact cannot disagree',
|
|
509
|
+
' .. a group whose members all share one value is not here: there is one number and the timelines',
|
|
510
|
+
' .. above already carry it. `stagger` is not here either — the shifted key times are on those timelines',
|
|
511
|
+
];
|
|
512
|
+
for (const entry of result.trackDerivations) {
|
|
513
|
+
const states =
|
|
514
|
+
entry.model === null
|
|
515
|
+
? 'stated per member'
|
|
516
|
+
: `derive ${entry.model.kind} ${entry.model.stated} -> ${entry.model.projection === 'shift' ? 'the displacement' : 'the narrowing'}`;
|
|
517
|
+
out.push(
|
|
518
|
+
` MEMBER ${entry.animation} ${entry.targetKind} "${entry.target}".${entry.property} ` +
|
|
519
|
+
`t=${entry.time.toFixed(6)} ${entry.members.length} member(s) ${states}`,
|
|
520
|
+
);
|
|
521
|
+
if (entry.model !== null) {
|
|
522
|
+
out.push(` ${entry.model.formula}`);
|
|
523
|
+
for (const line of entry.model.derived) out.push(` ${line}`);
|
|
524
|
+
}
|
|
525
|
+
const width = Math.max(6, ...entry.members.map((m) => m.member.length));
|
|
526
|
+
for (let i = 0; i < entry.members.length; i++) {
|
|
527
|
+
const m = entry.members[i];
|
|
528
|
+
const value = Array.isArray(m.value) ? m.value.join(', ') : JSON.stringify(m.value);
|
|
529
|
+
// The model's own row carries the two inputs that produced the value — the
|
|
530
|
+
// coordinate it read off the rig and the depth the spec stated — because
|
|
531
|
+
// "5.513" alone is a number a reader can only take on trust, and `−62` and
|
|
532
|
+
// `150` beside it are a claim they can check.
|
|
533
|
+
const from = entry.model === null ? '' : ` <- ${entry.model.members[i].at >= 0 ? ' ' : ''}${entry.model.members[i].at} at depth ${entry.model.members[i].depth}`;
|
|
534
|
+
out.push(` ${m.member.padEnd(width)} ${value.padStart(12)}${from}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** `head/head key 1`, which is how A39's own message names a key. */
|
|
541
|
+
function deformKeyName(key: DeformKeyMeasure): string {
|
|
542
|
+
return `${key.slot}/${key.attachment} key ${key.key}`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Does this compiled `transform` report belong to this loaded key?
|
|
547
|
+
*
|
|
548
|
+
* ⚠️ The two times are not the same number and cannot be compared with `===`.
|
|
549
|
+
* The report's is the spec's own `t`; the survey's came back through
|
|
550
|
+
* `Float32Array`, because that is what `spine-core` reads a timeline's frames
|
|
551
|
+
* into — a key written `0.62` arrives as `0.6200000047683716`. So the tolerance
|
|
552
|
+
* is the compiler's own key-time grid plus one float32 ulp at this magnitude,
|
|
553
|
+
* which is narrower than any key spacing the format can hold and wide enough for
|
|
554
|
+
* both roundings.
|
|
555
|
+
*/
|
|
556
|
+
function sameKeyTime(specTime: number, loaded: number): boolean {
|
|
557
|
+
return Math.abs(specTime - loaded) <= KEY_TIME_EPSILON + Math.abs(loaded) * 2 ** -23;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The `DEFORM` report block — what each deform key does to the geometry, per key
|
|
562
|
+
* and then per animation (issue #316).
|
|
563
|
+
*
|
|
564
|
+
* ## Why this is a report and not an assertion
|
|
565
|
+
*
|
|
566
|
+
* Because a 3× stretch is a real thing to author, for the same reason issue #277
|
|
567
|
+
* settled mesh coverage as a printed figure on authored geometry rather than a
|
|
568
|
+
* bar. The one deformed-geometry fault that has no legitimate counter-example is
|
|
569
|
+
* the fold, and that one already IS an assertion —
|
|
570
|
+
* `A39_DEFORM_KEEPS_TRIANGLE_WINDING`. What this block adds is **the approach to
|
|
571
|
+
* that wall**: FACE §4.2's table of ratios down to the fold at 31.37° was
|
|
572
|
+
* measured by rendering seven variants of `gallery/portrait` and looking at them,
|
|
573
|
+
* and `0.637` was a number an author derived from the closed form rather than one
|
|
574
|
+
* the tool printed.
|
|
575
|
+
*
|
|
576
|
+
* ## It quotes; it does not re-derive
|
|
577
|
+
*
|
|
578
|
+
* - the reversal and collapse counts are the **survey's**, which is A39's own
|
|
579
|
+
* survey ([`src/deformmeasure.ts`](src/deformmeasure.ts)) — one measurement,
|
|
580
|
+
* two readers, so the block and the gate cannot disagree about a fold;
|
|
581
|
+
* - a key's model is the **compiler's** `transform` report (§4.11.1), so the
|
|
582
|
+
* block names the same `kind` and parameters the spec stated;
|
|
583
|
+
* - the fold ANGLE is nowhere here. It is A39's, derived at run time from the
|
|
584
|
+
* grid, and a second copy of it printed beside a ratio would be a number that
|
|
585
|
+
* goes stale when somebody moves a column.
|
|
586
|
+
*
|
|
587
|
+
* ## And what it deliberately does not print
|
|
588
|
+
*
|
|
589
|
+
* **Deformed coverage**, which #296 asked for. The coverage figure is rasterised
|
|
590
|
+
* from the attachment's **uvs** against the part's alpha, and a deform moves
|
|
591
|
+
* positions and never uvs — so it is identical at every key by construction, and
|
|
592
|
+
* a `coverage 100.00% (setup 100.00%)` line would be a tautology wearing a
|
|
593
|
+
* measurement's clothes. The header line says so and points at `meshes`, because
|
|
594
|
+
* an author who came here asking whether their deform broke the coverage
|
|
595
|
+
* deserves the answer rather than a silence. What does move is the stretch.
|
|
596
|
+
*/
|
|
597
|
+
function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>): string[] {
|
|
598
|
+
const survey = surveyDeformKeys(skeletonDataFromText(result.skeletonText, result.atlasText));
|
|
599
|
+
if (survey.timelines === 0) return [];
|
|
600
|
+
const out: string[] = ['', 'deform (what each key does to the geometry — figures with names, never a bar; issue #316)'];
|
|
601
|
+
// The legend costs six lines and is worth them exactly once — on a report that
|
|
602
|
+
// has figures in it. A bounding box or a clipping polygon deformed and nothing
|
|
603
|
+
// else gets the reason it has no figures and no essay about them.
|
|
604
|
+
if (survey.keys.length) {
|
|
605
|
+
out.push(
|
|
606
|
+
' .. every key measured at its OWN time against the same pose with the deform CLEARED, so the',
|
|
607
|
+
' .. denominator is 1.000 by definition and a NEGATIVE area ratio IS a reversed triangle',
|
|
608
|
+
' .. stretch is the two singular values of the map from the cleared triangle to the deformed one —',
|
|
609
|
+
' .. the worst stretch and the worst squash the drawing takes there; their product is |area ratio|',
|
|
610
|
+
' .. coverage is NOT here: it is rasterised from the uvs, which no deform moves, so the figure on',
|
|
611
|
+
' .. the `meshes` line below is already the deformed one',
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (survey.notAMesh.length) {
|
|
615
|
+
out.push(` .. ${survey.notAMesh.join(', ')} deform an attachment with no triangles — nothing to measure`);
|
|
616
|
+
}
|
|
617
|
+
for (const key of survey.keys) {
|
|
618
|
+
const model = result.deformTransforms.find(
|
|
619
|
+
(g) =>
|
|
620
|
+
g.animation === key.animation &&
|
|
621
|
+
g.skin === key.skin &&
|
|
622
|
+
g.slot === key.slot &&
|
|
623
|
+
g.attachment === key.placeholder &&
|
|
624
|
+
sameKeyTime(g.time, key.time),
|
|
625
|
+
);
|
|
626
|
+
// A stated model is quoted rather than reduced to its results: `yaw
|
|
627
|
+
// radius=170 degrees=12` is what a reviewer checks the ratios against, and an
|
|
628
|
+
// authored table says so instead of saying nothing, because "no model here"
|
|
629
|
+
// is itself the thing a reader of a wrong ratio needs to know.
|
|
630
|
+
const states = model === undefined ? 'authored table' : `transform ${model.kind} ${model.stated}`;
|
|
631
|
+
out.push(
|
|
632
|
+
` DEFORM ${key.animation} ${key.skin}/${key.slot}/${key.placeholder} key ${key.key} ` +
|
|
633
|
+
`t=${key.time.toFixed(6)} ${states}`,
|
|
634
|
+
);
|
|
635
|
+
// A key that moves nothing gets one line and no figures. `{ "t": 2.2 }` with
|
|
636
|
+
// no run is the format's own way of writing "back to the setup pose" (§4.11),
|
|
637
|
+
// and its geometry is bit-identical to the cleared pose it would be measured
|
|
638
|
+
// against — so `x1.000000` there is the definition and not a measurement, and
|
|
639
|
+
// four lines of it on every loop's opening and closing key is the noise that
|
|
640
|
+
// stops the block being read. It is still counted in the rollup below,
|
|
641
|
+
// because A39 measures it too.
|
|
642
|
+
if (key.moved === 0) {
|
|
643
|
+
out.push(
|
|
644
|
+
` moved 0 of ${key.vertices} vertices — this key IS the setup pose, so every ` +
|
|
645
|
+
`figure is the identity (${key.triangles} triangles, all kept)`,
|
|
646
|
+
);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
out.push(
|
|
650
|
+
` moved ${key.moved} of ${key.vertices} vertices, ` +
|
|
651
|
+
`worst ${key.maxDisplacement.toFixed(4)}px at v${key.maxDisplacementVertex}`,
|
|
652
|
+
);
|
|
653
|
+
out.push(
|
|
654
|
+
` area min ${deformExtreme(key.areaRatioMin)} max ${deformExtreme(key.areaRatioMax)} ` +
|
|
655
|
+
`(${key.triangles} triangles, ${key.degenerate} with no area at the cleared pose, band ${key.band.toFixed(6)}px²)`,
|
|
656
|
+
);
|
|
657
|
+
out.push(
|
|
658
|
+
` stretch max ${deformExtreme(key.stretchMax)} min ${deformExtreme(key.stretchMin)}`,
|
|
659
|
+
);
|
|
660
|
+
// The marker has to know about the exemption, or it says the false half of
|
|
661
|
+
// the truth on the one build where it matters: a declared fold IS a fold and
|
|
662
|
+
// A39 does not refuse it — it SKIPs the slot entirely.
|
|
663
|
+
const exempted = exempt.has(key.slot);
|
|
664
|
+
const fold = key.reversed.length
|
|
665
|
+
? exempted
|
|
666
|
+
? ' <- a fold, and A39 does not gate it — see below'
|
|
667
|
+
: ' <- a fold: A39 refuses this key by name'
|
|
668
|
+
: '';
|
|
669
|
+
out.push(
|
|
670
|
+
` winding ${key.triangles - key.reversed.length} of ${key.triangles} kept, ` +
|
|
671
|
+
`${key.collapsed} collapsed${fold}`,
|
|
672
|
+
);
|
|
673
|
+
if (exempted) {
|
|
674
|
+
out.push(
|
|
675
|
+
` .. A39 is exempt on "${key.slot}" (invariants.deformMayFold), so nothing here is gated`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// The rollup, per animation: the worst key by each quantity. A timeline's own
|
|
680
|
+
// eight keys are eight blocks above, and "which of them is the one to look at"
|
|
681
|
+
// is the question the sweep in issue #313's landing comment answered by hand.
|
|
682
|
+
for (const animation of [...new Set(survey.keys.map((k) => k.animation))]) {
|
|
683
|
+
const keys = survey.keys.filter((k) => k.animation === animation);
|
|
684
|
+
const worst = (
|
|
685
|
+
pick: (key: DeformKeyMeasure) => DeformExtreme | null,
|
|
686
|
+
better: (a: number, b: number) => boolean,
|
|
687
|
+
): string => {
|
|
688
|
+
let best: { key: DeformKeyMeasure; extreme: DeformExtreme } | null = null;
|
|
689
|
+
for (const key of keys) {
|
|
690
|
+
const extreme = pick(key);
|
|
691
|
+
if (extreme === null) continue;
|
|
692
|
+
if (best === null || better(extreme.value, best.extreme.value)) best = { key, extreme };
|
|
693
|
+
}
|
|
694
|
+
return best === null ? '—' : `x${best.extreme.value.toFixed(6)} (${deformKeyName(best.key)} tri ${best.extreme.triangle})`;
|
|
695
|
+
};
|
|
696
|
+
const reversed = keys.reduce((n, k) => n + k.reversed.length, 0);
|
|
697
|
+
const collapsed = keys.reduce((n, k) => n + k.collapsed, 0);
|
|
698
|
+
const samples = keys.reduce((n, k) => n + k.triangles, 0);
|
|
699
|
+
out.push(
|
|
700
|
+
` WORST ${animation} area ${worst((k) => k.areaRatioMin, (a, b) => a < b)} ` +
|
|
701
|
+
`stretch ${worst((k) => k.stretchMax, (a, b) => a > b)} ` +
|
|
702
|
+
`squash ${worst((k) => k.stretchMin, (a, b) => a < b)}`,
|
|
703
|
+
);
|
|
704
|
+
out.push(
|
|
705
|
+
` .. ${''.padEnd(animation.length)} reversed ${reversed}, collapsed ${collapsed}, over ` +
|
|
706
|
+
`${keys.length} key(s) and ${samples} triangle sample(s) <- A39 reads the same two counts`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
return out;
|
|
710
|
+
}
|
|
711
|
+
|
|
434
712
|
/**
|
|
435
713
|
* Read one non-negative integer flag, or its default.
|
|
436
714
|
*
|
|
@@ -1141,6 +1419,11 @@ function cmdChainFit(flags: Record<string, string>): void {
|
|
|
1141
1419
|
if (!Number.isInteger(value) || value < 1 || value > 8) throw new UsageError('--passes must be a whole number in 1..8');
|
|
1142
1420
|
options.passes = value;
|
|
1143
1421
|
}
|
|
1422
|
+
if (flags['inward-lever'] !== undefined) {
|
|
1423
|
+
const value = Number(flags['inward-lever']);
|
|
1424
|
+
if (!Number.isFinite(value) || value < 0) throw new UsageError('--inward-lever must be a number of frame pixels, 0 or more');
|
|
1425
|
+
options.minLeverPx = value;
|
|
1426
|
+
}
|
|
1144
1427
|
if (flags['anchor-residual'] !== undefined) {
|
|
1145
1428
|
const value = Number(flags['anchor-residual']);
|
|
1146
1429
|
if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--anchor-residual must be a number in (0, 1]');
|
|
@@ -1693,7 +1976,10 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
1693
1976
|
console.log(` .. rig ${opts.rigPath}`);
|
|
1694
1977
|
console.log(` .. motion ${opts.motionPath}`);
|
|
1695
1978
|
const result = compile(opts);
|
|
1696
|
-
|
|
1979
|
+
// `compile` has already parsed this file, so the read below cannot fail — but
|
|
1980
|
+
// it goes through the same parser rather than a cast, because the cast was the
|
|
1981
|
+
// last one in the repository and issue #307 was about exactly that.
|
|
1982
|
+
const motion = parseMotionSpec(readJsonFile(opts.motionPath), opts.motionPath);
|
|
1697
1983
|
|
|
1698
1984
|
console.log(`\nstage ${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height} (spine ${result.skeleton.skeleton.spine})`);
|
|
1699
1985
|
|
|
@@ -1813,6 +2099,30 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
1813
2099
|
: 'back to the setup pose';
|
|
1814
2100
|
const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
|
|
1815
2101
|
console.log(` t=${String(key.time).padEnd(7)} ${span.padEnd(46)} ${curve}`);
|
|
2102
|
+
// A generated key prints its MODEL and then every offset the model
|
|
2103
|
+
// produced (issue #294). Both halves are the point: the model is
|
|
2104
|
+
// what a reviewer checks a claim against, and the offsets are what
|
|
2105
|
+
// reaches the file — printing only the first would ask a reader to
|
|
2106
|
+
// trust an evaluation they cannot see, which is the gap FACE §9.3
|
|
2107
|
+
// records. The numbers are the emitted ones, not a second
|
|
2108
|
+
// evaluation, so this block and the artifact cannot disagree.
|
|
2109
|
+
const gen = result.deformTransforms.find(
|
|
2110
|
+
(g) => g.animation === animName && g.skin === skinName && g.slot === slotName && g.attachment === attName && g.time === key.time,
|
|
2111
|
+
);
|
|
2112
|
+
if (gen === undefined) continue;
|
|
2113
|
+
console.log(` transform ${gen.kind} ${gen.stated}`);
|
|
2114
|
+
console.log(` ${gen.formula}`);
|
|
2115
|
+
for (const line of gen.derived) console.log(` ${line}`);
|
|
2116
|
+
console.log(
|
|
2117
|
+
` ${gen.vertexCount} vertices, largest offset ${gen.maxOffset}px at vertex ${gen.maxOffsetVertex}`,
|
|
2118
|
+
);
|
|
2119
|
+
for (let v = 0; v < gen.vertexCount; v += 4) {
|
|
2120
|
+
const pairs: string[] = [];
|
|
2121
|
+
for (let k = v; k < Math.min(v + 4, gen.vertexCount); k++) {
|
|
2122
|
+
pairs.push(`v${String(k).padStart(3)} (${gen.offsets[2 * k]}, ${gen.offsets[2 * k + 1]})`);
|
|
2123
|
+
}
|
|
2124
|
+
console.log(` ${pairs.join(' ')}`);
|
|
2125
|
+
}
|
|
1816
2126
|
}
|
|
1817
2127
|
}
|
|
1818
2128
|
}
|
|
@@ -1834,6 +2144,16 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
1834
2144
|
}
|
|
1835
2145
|
}
|
|
1836
2146
|
|
|
2147
|
+
// The `MEMBER` block sits beside the `DEFORM` one and for the same reason:
|
|
2148
|
+
// both re-print timelines the reader has just read, in the arrangement the
|
|
2149
|
+
// question needs rather than the one the format has.
|
|
2150
|
+
for (const line of memberReportLines(result)) console.log(line);
|
|
2151
|
+
|
|
2152
|
+
// The `DEFORM` block goes after the timelines and before the constraints,
|
|
2153
|
+
// because it is a measurement OF the deform timelines printed above — the keys
|
|
2154
|
+
// it names are the keys the reader has just read, by the same index.
|
|
2155
|
+
for (const line of deformReportLines(result, new Set(result.rig.deformMayFold))) console.log(line);
|
|
2156
|
+
|
|
1837
2157
|
if (result.physics.length) {
|
|
1838
2158
|
console.log('\nphysics constraints (4.3 top-level `constraints` array, type per entry)');
|
|
1839
2159
|
for (const ph of result.physics) {
|
|
@@ -1997,6 +2317,10 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1997
2317
|
'anchor-residual':
|
|
1998
2318
|
`the residual a \`pose\` placement must be within to anchor a chain (default ${ANCHOR_MAX_RESIDUAL}, with ` +
|
|
1999
2319
|
`unexplained ≤ ${ANCHOR_MAX_UNEXPLAINED} and unambiguous — the 2026-09-03 measurement run's own clean-frame criterion)`,
|
|
2320
|
+
'inward-lever':
|
|
2321
|
+
`how far apart, in frame pixels, two anchored descendants have to sit before the rotation they determine is ` +
|
|
2322
|
+
`printed (default ${DEFAULT_MIN_LEVER_PX}); below it the bone is refused \`no-bracket\` naming the measured ` +
|
|
2323
|
+
'lever, because an angle read across a short lever turns a half-pixel anchor error into several degrees',
|
|
2000
2324
|
animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
|
|
2001
2325
|
max: 'longest side of a rendered frame, in pixels (default 256)',
|
|
2002
2326
|
record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
|
|
@@ -2041,6 +2365,7 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
2041
2365
|
'min-visible': '<0..1>',
|
|
2042
2366
|
passes: '<n>',
|
|
2043
2367
|
'anchor-residual': '<0..1>',
|
|
2368
|
+
'inward-lever': '<px>',
|
|
2044
2369
|
animation: '<name>',
|
|
2045
2370
|
max: '<px>',
|
|
2046
2371
|
record: '<result.json>',
|
|
@@ -2193,6 +2518,7 @@ const COMMANDS: CommandDoc[] = [
|
|
|
2193
2518
|
'max-residual',
|
|
2194
2519
|
'passes',
|
|
2195
2520
|
'anchor-residual',
|
|
2521
|
+
'inward-lever',
|
|
2196
2522
|
'scale',
|
|
2197
2523
|
'rotation',
|
|
2198
2524
|
'out',
|