@rig-c/core 2.64.3 → 2.65.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 +3 -3
- package/docs/AUTHORING.md +27 -3
- package/docs/MOTION.md +5 -1
- package/package.json +1 -1
- package/src/compile.ts +51 -18
- package/src/pose.ts +134 -12
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ published beside it with the same files at every version.
|
|
|
26
26
|
|
|
27
27
|
<p align="center"><em>Fourteen part PNGs drawn from scratch for this repo, one rig spec, one motion spec — the assembly,
|
|
28
28
|
the breathing and the wave are all rigc-compiled Spine animations, rendered with
|
|
29
|
-
<code>rigc render</code
|
|
29
|
+
<code>rigc render</code>. A <a href="https://raw.githubusercontent.com/rig-c/core/main/assets/rigc-demo@2x.gif">2x version</a> (1200x810) sits beside it.</em></p>
|
|
30
30
|
|
|
31
31
|
Loose part PNGs and two small JSON files in; **Spine 4.3 skeleton data out** — a
|
|
32
32
|
`skeleton.json` and a `skeleton.atlas` that load in any Spine runtime and **import
|
|
@@ -526,7 +526,7 @@ a rig compiles green, because green says nothing at all about the picture.
|
|
|
526
526
|
conditions, <code>rigc pose</code> reads where every part sits in each picture, two
|
|
527
527
|
candidate in-betweenings are compiled from the same pair — and a real
|
|
528
528
|
<code>rigc vote</code> ballot picks the winner, because the movement between the poses
|
|
529
|
-
is the one thing no instrument here will grade.</em></p>
|
|
529
|
+
is the one thing no instrument here will grade. A <a href="https://raw.githubusercontent.com/rig-c/core/main/assets/rigc-keypose@2x.gif">2x version</a> (1200x810) sits beside it.</em></p>
|
|
530
530
|
|
|
531
531
|
Three properties of `vote` are worth stating, because they are what make its ledger
|
|
532
532
|
usable by the next agent rather than by a reader: **a tie is a recorded outcome, not a
|
|
@@ -580,7 +580,7 @@ while <code>gaze</code> and <code>turn</code> return to rest, so the hand-offs m
|
|
|
580
580
|
differing pixels — and the composing is the consumer's. Authorable on plain Spine 4.3, no
|
|
581
581
|
plugin, no runtime patch; what the turn costs is authoring rather than runtime capability,
|
|
582
582
|
and that cost is one stated expression per key. Compiled and rendered entirely by the
|
|
583
|
-
published package.</em></p>
|
|
583
|
+
published package. A <a href="https://raw.githubusercontent.com/rig-c/core/main/assets/rigc-scene@2x.gif">2x version</a> (1200x810) sits beside it.</em></p>
|
|
584
584
|
|
|
585
585
|
🎞️ **How the three films on this page were made** is kept with them, one directory per
|
|
586
586
|
film in [`films/`](https://github.com/rig-c/core/tree/main/films) — a `run.sh` that
|
package/docs/AUTHORING.md
CHANGED
|
@@ -4993,6 +4993,14 @@ consumer's, decided by dressing the skeleton rather than by the animation.
|
|
|
4993
4993
|
Seconds, not frames: nothing requires a key to land on any frame grid, and a
|
|
4994
4994
|
reference rendered at some rate says nothing about where its keys are. Put keys
|
|
4995
4995
|
where the motion changes.
|
|
4996
|
+
⚠️ **A phrase generated cycle by cycle ends one key before its period.** Write
|
|
4997
|
+
each cycle of a 1 s phrase as t = 0…1 and the closing key of one cycle lands on
|
|
4998
|
+
the opening key of the next, so the track is refused at the first boundary; the
|
|
4999
|
+
refusal names both keys and, when the keys after the pair repeat the spacing of
|
|
5000
|
+
the keys before it, says so. Generate each cycle without its closing key and
|
|
5001
|
+
keep that key on the last cycle only: a looping player takes `time % duration`
|
|
5002
|
+
and nothing interpolates across the wrap, so a track that stops short of its
|
|
5003
|
+
period holds its last key until it (issue #1506).
|
|
4996
5004
|
- **Key times are emitted as float32s like every other number, and never stored
|
|
4997
5005
|
LATER than you wrote them.** Every emitted number is the shortest decimal naming
|
|
4998
5006
|
its float32 — the text the editor writes, and the precision the runtime keeps,
|
|
@@ -7213,7 +7221,7 @@ retain the minimal inputs and report that code rather than inventing author data
|
|
|
7213
7221
|
| <a id="CE_COMPILE_CURVE_VALUE_NONFINITE"></a>`CE_COMPILE_CURVE_VALUE_NONFINITE` | “<value> (t=<value>): raw curve holds a non-finite value <value>” | Supply finite numbers in the raw curve array. |
|
|
7214
7222
|
| <a id="CE_COMPILE_VALUE_TIMELINE_PROPERTY_UNKNOWN"></a>`CE_COMPILE_VALUE_TIMELINE_PROPERTY_UNKNOWN` | “animation "<value>": <value> "<value>" has no property "<value>"” | Use a property present on the named target's timeline family. |
|
|
7215
7223
|
| <a id="CE_COMPILE_TIMELINE_KEYS_EMPTY"></a>`CE_COMPILE_TIMELINE_KEYS_EMPTY` | “<value>: no keys”<br>Also: “animation "<value>" deform <value>/<value>/<value>”<br>“animation "<value>" events”<br>“animation "<value>" drawOrder”<br>“animation "<value>" slot "<value>" <value>”<br>“animation "<value>" <value> constraint "<value>"”<br>Here the values named in the message include: “default”. | Supply at least one key in the timeline. |
|
|
7216
|
-
| <a id="CE_COMPILE_KEY_TIMES_NOT_INCREASING"></a>`CE_COMPILE_KEY_TIMES_NOT_INCREASING` | “<value>: key times must strictly increase
|
|
7224
|
+
| <a id="CE_COMPILE_KEY_TIMES_NOT_INCREASING"></a>`CE_COMPILE_KEY_TIMES_NOT_INCREASING` | “<value>: key times must strictly increase — key <value> at t=<value> does not come after key <value> at t=<value> (keys counted from 0)”, and when the two keys share a time and the keys after them repeat the spacing of the keys before them: “. The keys from t=<value> repeat every <value>s, so key <value> closes one cycle and key <value> opens the next at the same time: a phrase that repeats every <value>s ends one key before its period. Generate each cycle without its closing key and keep that key on the last cycle only — a looping player wraps from it to t=0, and a track that stops short holds its last key until the wrap”<br>Also: “animation "<value>" deform <value>/<value>/<value>”<br>“animation "<value>" drawOrder”<br>“animation "<value>" slot "<value>" <value>”<br>“animation "<value>" <value> constraint "<value>"”<br>Here the values named in the message include: “default”. | Order keys by strictly increasing time; the message names both keys of the pair. A phrase generated cycle by cycle with each cycle written from its start to its end repeats every boundary key — drop each cycle's closing key and keep it on the last cycle only (§4.5). |
|
|
7217
7225
|
| <a id="CE_COMPILE_STEPPED_TIMELINE_EASING"></a>`CE_COMPILE_STEPPED_TIMELINE_EASING` | “<value> key at t=<value> carries [an easing ("<value>") / a curve], and this timeline is stepped by the format — its reader builds no curve, so the mode changes AT the key and holds until the next one. Remove it”<br>Also: “animation "<value>" <value> "<value>" <value>” | Remove easing/curve from the stepped mode timeline. |
|
|
7218
7226
|
| <a id="CE_COMPILE_TIMELINE_MODE_UNKNOWN"></a>`CE_COMPILE_TIMELINE_MODE_UNKNOWN` | “<value> key at t=<value> names mode <value>; <value>”<br>“known: <value> — the runtime folds the case of the first letter and of nothing else”<br>Also: “animation "<value>" <value> "<value>" <value>”<br>“, ”<br>Here the values named in the message include: “noScaleOrReflection”, “normal”, “onlyTranslation”, “noRotationOrReflection”. | Use a mode named in the timeline's allowed mode list. |
|
|
7219
7227
|
| <a id="CE_COMPILE_VALUELESS_TIMELINE_VALUE"></a>`CE_COMPILE_VALUELESS_TIMELINE_VALUE` | “<value>: this timeline takes no value; use null”<br>Also: “animation "<value>" <value> "<value>" <value>” | Use null for a timeline that takes no value. |
|
|
@@ -11357,10 +11365,23 @@ ambiguous one ends with a sentence saying which reading its figures support:
|
|
|
11357
11365
|
|
|
11358
11366
|
> loud.png: the best placement found has residual 0.5000, above --max-residual 0.25; part is 200x150 px (span 200 frame px, opaque 1) with texture 0.2473, detail 49.455; 2 candidate(s), best 0.5000, next 0.5000, spread 0.0000 — above the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up), so size and texture do not explain this; the cut (or the search window) is the suspect
|
|
11359
11367
|
|
|
11368
|
+
- **above the floor, one size in two places** — two copies of an 18x34 checker 0.6 of its span and 6° apart, the shape a left/right pair cut from one drawing reads as:
|
|
11369
|
+
|
|
11370
|
+
> part is 18x34 px (span 36.2 frame px, opaque 1) with texture 0.0221, detail 0.752; 14 candidate(s), best 0.0160, next 0.0162, spread 0.0002 — above the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up), so size and texture do not explain this; every placement in the margin is at the best's scale (within 0.30%, bar 2%) and in another place (the nearest 0.62 span(s) away, bar 0.45), which is the part — or a near-copy, such as a left/right pair cut from one drawing — drawn more than once: read the placements as an unordered set and order it across frames (MOTION.md §2.2 🔀); a --rotation window that excludes one place chooses the answer rather than measuring it
|
|
11371
|
+
|
|
11360
11372
|
Read the figures, not only the verdict. *Below* says plainness alone explains the
|
|
11361
11373
|
failure: the cut may be right, and `pose` cannot tell. *Above* says it does not,
|
|
11362
11374
|
so look at the cut, the `--scale`/`--rotation` window, or — for an ambiguity — at
|
|
11363
|
-
whether the frame really holds the part twice.
|
|
11375
|
+
whether the frame really holds the part twice. 🔀 When **every** placement inside
|
|
11376
|
+
the margin is at the best's scale and in another place — the two bars the sentence
|
|
11377
|
+
prints beside the figures it measured — the reading says the frame does hold it more than once and
|
|
11378
|
+
names MOTION.md §2.2's unordered set instead of a window: no `--scale` window
|
|
11379
|
+
separates two copies of one size, and a `--rotation` window that excludes one place
|
|
11380
|
+
*chooses* the answer — both files then place on the other copy, one of them off its
|
|
11381
|
+
truth and printed clean. Both bars sit at the geometric middle of the gap measured
|
|
11382
|
+
between such repeats and every other ambiguity on the floor's grid (the constants
|
|
11383
|
+
`REPEAT_SCALE_RATIO` and `REPEAT_DISTANCE_SPANS` in `src/pose.ts` carry the figures);
|
|
11384
|
+
any other ambiguity above the floor keeps the window sentence. ⚠️ Above the floor is **not** a
|
|
11364
11385
|
promise that `pose` will place the part (see the rates below).
|
|
11365
11386
|
|
|
11366
11387
|
**The texture figure.** `legibility.texture` is the mean colour step between
|
|
@@ -11505,7 +11526,10 @@ cutoff, and `PO26` holds every polish to strictly descending residuals, read thr
|
|
|
11505
11526
|
the same trace. `PO31` places §6's post at the old placement from the page's own
|
|
11506
11527
|
input bytes, and `PO32` holds it to taking the quarter-rung escape and reading the same twice.
|
|
11507
11528
|
`PO33` holds the coarse row the tool reads rotations from to the field each seed was
|
|
11508
|
-
picked from.
|
|
11529
|
+
picked from. `PO34` holds two copies of one part 0.6 span apart to both coming back — and is
|
|
11530
|
+
seen red on the quarter-span grid releases before 1.4.0 searched on, which put them on
|
|
11531
|
+
one placement — and `PO35` holds the two readings of an ambiguity above the floor to
|
|
11532
|
+
their own shapes, each with a forged alternate that moves it to the other.
|
|
11509
11533
|
|
|
11510
11534
|
## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
|
|
11511
11535
|
|
package/docs/MOTION.md
CHANGED
|
@@ -234,7 +234,11 @@ frames is your job.** `alternates` non-empty means the answer was not unique;
|
|
|
234
234
|
`ambiguous` means at least one alternate is inside the margin. Two identical limbs
|
|
235
235
|
look exactly like that, and so does a shape whose silhouette fits itself at more than
|
|
236
236
|
one angle. The instrument has run out — it sees one frame and has no notion of which
|
|
237
|
-
limb is which. A
|
|
237
|
+
limb is which. A left/right pair cut from one drawing is two files that each fit both
|
|
238
|
+
places, so each comes back with both, and narrowing `--scale` or `--rotation` until one
|
|
239
|
+
place is left picks the answer — it does not read it; the `ambiguous:` reading names
|
|
240
|
+
this route instead of a window when every placement in the margin is one size in
|
|
241
|
+
another place (AUTHORING §11.5). A method that works:
|
|
238
242
|
|
|
239
243
|
1. **Enumerate the assignments, not the placements.** With k interchangeable
|
|
240
244
|
placements of one part in frame A and k in frame B, there are k! ways to pair them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.65.1",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/compile.ts
CHANGED
|
@@ -1148,6 +1148,51 @@ function checkKeyTime(where: string, time: number, authored: number, duration: n
|
|
|
1148
1148
|
);
|
|
1149
1149
|
}
|
|
1150
1150
|
|
|
1151
|
+
/**
|
|
1152
|
+
* The one refusal for a key that does not come after the key before it, for
|
|
1153
|
+
* every timeline kind (issue #1506) — six call sites used to spell it six times.
|
|
1154
|
+
*
|
|
1155
|
+
* It names both keys by index and authored time, because the time alone does
|
|
1156
|
+
* not say which pair to look at: a generated phrase of four one-second cycles,
|
|
1157
|
+
* each written t = 0…1, collides at every cycle boundary, and `(at t=1)` read
|
|
1158
|
+
* the same as an ordinary slip. When the two keys share a time AND the keys
|
|
1159
|
+
* after the pair repeat the spacing of the keys before it, the period is read
|
|
1160
|
+
* off the keys themselves — the second key's time less the first key's — and
|
|
1161
|
+
* the message says that the closing key of one cycle is the opening key of the
|
|
1162
|
+
* next. Nothing else is inferred: a duplicate with no keys after it, or with
|
|
1163
|
+
* keys that do not repeat, carries the pair alone, and the declared `loop` is
|
|
1164
|
+
* not consulted because it is a player hint (§4.1) and the boundary is the same
|
|
1165
|
+
* either way. The fix is phrased from the runtime rather than from taste:
|
|
1166
|
+
* `Animation.apply` wraps a looping time as `time % duration` and the last
|
|
1167
|
+
* frame of every curve timeline is stepped, so a track that stops short of its
|
|
1168
|
+
* period holds its last key until the wrap rather than easing into t = 0.
|
|
1169
|
+
*
|
|
1170
|
+
* ⚠️ The repetition is compared in authored seconds within one float32 step of
|
|
1171
|
+
* the later key (`float32Step`) — `lag`/`stagger` shift every key of a track by
|
|
1172
|
+
* the same amount, so the spacing is the same before and after them.
|
|
1173
|
+
*/
|
|
1174
|
+
function refuseKeyOrder(where: string, keys: ReadonlyArray<{ t: number }>, i: number, duplicate: boolean): never {
|
|
1175
|
+
const at = keys[i].t;
|
|
1176
|
+
const before = keys[i - 1].t;
|
|
1177
|
+
const period = at - keys[0].t;
|
|
1178
|
+
const following = Math.min(i - 1, keys.length - 1 - i);
|
|
1179
|
+
let repeats = duplicate && period > 0 && following >= 1;
|
|
1180
|
+
for (let j = 1; repeats && j <= following; j++) {
|
|
1181
|
+
if (Math.abs(keys[i + j].t - keys[j].t - period) > float32Step(keys[i + j].t)) repeats = false;
|
|
1182
|
+
}
|
|
1183
|
+
const boundary = repeats
|
|
1184
|
+
? `. The keys from t=${keys[0].t} repeat every ${period}s, so key ${i - 1} closes one cycle and key ${i} opens ` +
|
|
1185
|
+
`the next at the same time: a phrase that repeats every ${period}s ends one key before its period. Generate ` +
|
|
1186
|
+
'each cycle without its closing key and keep that key on the last cycle only — a looping player wraps from ' +
|
|
1187
|
+
'it to t=0, and a track that stops short holds its last key until the wrap'
|
|
1188
|
+
: '';
|
|
1189
|
+
throw new CompileError(
|
|
1190
|
+
`${where}: key times must strictly increase — key ${i} at t=${at} does not come after key ${i - 1} at ` +
|
|
1191
|
+
`t=${before} (keys counted from 0)${boundary}`,
|
|
1192
|
+
'CE_COMPILE_KEY_TIMES_NOT_INCREASING',
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1151
1196
|
function channelHex(v: number): string {
|
|
1152
1197
|
const clamped = Math.max(0, Math.min(1, v));
|
|
1153
1198
|
return Math.round(clamped * 255)
|
|
@@ -8106,9 +8151,7 @@ function compileValueTrack(
|
|
|
8106
8151
|
const key = track.keys[i];
|
|
8107
8152
|
const next = track.keys[i + 1];
|
|
8108
8153
|
const time = keyTime(key.t + shift);
|
|
8109
|
-
if (i > 0 && time <= (out[i - 1].time as number))
|
|
8110
|
-
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`, 'CE_COMPILE_KEY_TIMES_NOT_INCREASING');
|
|
8111
|
-
}
|
|
8154
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, track.keys, i, time === out[i - 1].time);
|
|
8112
8155
|
checkKeyTime(where, time, key.t, duration);
|
|
8113
8156
|
// A named-value timeline (`inherit`): the key's `v` is one name, stepped by
|
|
8114
8157
|
// the format. The name is resolved rather than copied, and what is WRITTEN
|
|
@@ -8237,9 +8280,7 @@ function compileDrawOrder(
|
|
|
8237
8280
|
for (let i = 0; i < keys.length; i++) {
|
|
8238
8281
|
const key = keys[i];
|
|
8239
8282
|
const time = keyTime(key.t);
|
|
8240
|
-
if (i > 0 && time <= (out[i - 1].time as number))
|
|
8241
|
-
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`, 'CE_COMPILE_KEY_TIMES_NOT_INCREASING');
|
|
8242
|
-
}
|
|
8283
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, keys, i, time === out[i - 1].time);
|
|
8243
8284
|
checkKeyTime(where, time, key.t, duration);
|
|
8244
8285
|
// No offsets = "back to the setup draw order", which is the parser's own
|
|
8245
8286
|
// encoding for it. An empty array means the same thing and is written the
|
|
@@ -8478,9 +8519,7 @@ function compileConstraintTrack(
|
|
|
8478
8519
|
const key = keys[i];
|
|
8479
8520
|
const next = keys[i + 1];
|
|
8480
8521
|
const time = keyTime(key.t);
|
|
8481
|
-
if (i > 0 && time <= (out[i - 1].time as number))
|
|
8482
|
-
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`, 'CE_COMPILE_KEY_TIMES_NOT_INCREASING');
|
|
8483
|
-
}
|
|
8522
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, keys, i, time === out[i - 1].time);
|
|
8484
8523
|
checkKeyTime(where, time, key.t, duration);
|
|
8485
8524
|
|
|
8486
8525
|
const entry: ModelKey = { time };
|
|
@@ -8906,9 +8945,7 @@ function compileSequenceTrack(
|
|
|
8906
8945
|
for (let i = 0; i < track.keys.length; i++) {
|
|
8907
8946
|
const key = track.keys[i];
|
|
8908
8947
|
const time = keyTime(key.t);
|
|
8909
|
-
if (i > 0 && time <= (out[i - 1].time as number))
|
|
8910
|
-
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`, 'CE_COMPILE_KEY_TIMES_NOT_INCREASING');
|
|
8911
|
-
}
|
|
8948
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, track.keys, i, time === out[i - 1].time);
|
|
8912
8949
|
checkKeyTime(where, time, key.t, duration);
|
|
8913
8950
|
if (key.index !== undefined && key.index >= series.count) {
|
|
8914
8951
|
throw new CompileError(
|
|
@@ -8946,9 +8983,7 @@ function compileDeformTrack(
|
|
|
8946
8983
|
for (let i = 0; i < keys.length; i++) {
|
|
8947
8984
|
const key = keys[i];
|
|
8948
8985
|
const time = keyTime(key.t);
|
|
8949
|
-
if (i > 0 && time <= (out[i - 1].time as number))
|
|
8950
|
-
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`, 'CE_COMPILE_KEY_TIMES_NOT_INCREASING');
|
|
8951
|
-
}
|
|
8986
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, keys, i, time === out[i - 1].time);
|
|
8952
8987
|
checkKeyTime(where, time, key.t, duration);
|
|
8953
8988
|
// -- a key that states a MODEL rather than a run (issue #294) ------------
|
|
8954
8989
|
//
|
|
@@ -9660,9 +9695,7 @@ function compileTrack(
|
|
|
9660
9695
|
const key = track.keys[i];
|
|
9661
9696
|
const next = track.keys[i + 1];
|
|
9662
9697
|
const time = keyTime(key.t + shift);
|
|
9663
|
-
if (i > 0 && time <= (out[i - 1].time as number))
|
|
9664
|
-
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`, 'CE_COMPILE_KEY_TIMES_NOT_INCREASING');
|
|
9665
|
-
}
|
|
9698
|
+
if (i > 0 && time <= (out[i - 1].time as number)) refuseKeyOrder(where, track.keys, i, time === out[i - 1].time);
|
|
9666
9699
|
checkKeyTime(where, time, key.t, duration);
|
|
9667
9700
|
|
|
9668
9701
|
if (shape === 'attachment') {
|
package/src/pose.ts
CHANGED
|
@@ -185,7 +185,7 @@ export const COARSE_STRIDE_FRACTION = 0.125;
|
|
|
185
185
|
* trials found rather than 226, and 12 that placed on the old grid lost rather
|
|
186
186
|
* than 9 (both at three minima per rung).
|
|
187
187
|
*/
|
|
188
|
-
const COARSE_SUPPRESSION_CELLS = 4;
|
|
188
|
+
export const COARSE_SUPPRESSION_CELLS = 4;
|
|
189
189
|
|
|
190
190
|
/** How many scale rungs one octave gets in the coarse ladder. */
|
|
191
191
|
export const SCALE_STEPS_PER_OCTAVE = 3;
|
|
@@ -254,7 +254,27 @@ export const BACKGROUND_BORDER_SHARE = 0.6;
|
|
|
254
254
|
* carries it: 227 trials found against 226 at three, none lost, and a CPU cost
|
|
255
255
|
* inside the grid's run-to-run noise.
|
|
256
256
|
*/
|
|
257
|
-
const MINIMA_PER_SCALE = 5;
|
|
257
|
+
export const MINIMA_PER_SCALE = 5;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The three numbers that decide which basins the coarse pass sends down —
|
|
261
|
+
* `COARSE_STRIDE_FRACTION`, `COARSE_SUPPRESSION_CELLS`, `MINIMA_PER_SCALE` — as
|
|
262
|
+
* one value, so a control can run the search on the grid an earlier release
|
|
263
|
+
* used and show what the current one buys (issue #1519). See
|
|
264
|
+
* `PoseOptions.coarseGrid`.
|
|
265
|
+
*/
|
|
266
|
+
export interface PoseCoarseGrid {
|
|
267
|
+
strideFraction: number;
|
|
268
|
+
suppressionCells: number;
|
|
269
|
+
minimaPerScale: number;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The grid every caller gets — the three constants above, unchanged. */
|
|
273
|
+
export const COARSE_GRID: PoseCoarseGrid = {
|
|
274
|
+
strideFraction: COARSE_STRIDE_FRACTION,
|
|
275
|
+
suppressionCells: COARSE_SUPPRESSION_CELLS,
|
|
276
|
+
minimaPerScale: MINIMA_PER_SCALE,
|
|
277
|
+
};
|
|
258
278
|
|
|
259
279
|
/**
|
|
260
280
|
* How many candidates survive each refinement level.
|
|
@@ -387,6 +407,64 @@ export function floorClause(): string {
|
|
|
387
407
|
return `detail ${POSE_FLOOR.detail}, measured from ${POSE_FLOOR.span} px up`;
|
|
388
408
|
}
|
|
389
409
|
|
|
410
|
+
/**
|
|
411
|
+
* When an ambiguity is ONE part fitting several places at one size — the
|
|
412
|
+
* reading a left/right pair cut from one drawing gives, which no window
|
|
413
|
+
* resolves (issue #1519).
|
|
414
|
+
*
|
|
415
|
+
* Both bars are read off the measured gap between the two populations, and
|
|
416
|
+
* each sits at that gap's geometric middle — the same rule for both, so neither
|
|
417
|
+
* is a number chosen to make one case come out:
|
|
418
|
+
*
|
|
419
|
+
* - **Scale**, as `|alternate / best − 1|` and over EVERY alternate inside the
|
|
420
|
+
* margin. Same-size repeats measured 0.00001–0.00034 (a pair of legs and a pair
|
|
421
|
+
* of ears in a picture rendered from a rig), 0.0030 (the selftest's twin
|
|
422
|
+
* fixture, PO34) and 0.0055 (the pose fixture's mirrored arms, PO02). Every
|
|
423
|
+
* other ambiguous part above the floor on `tools/pose_floor.ts`'s 420 trials
|
|
424
|
+
* had an alternate at least 0.0719 off its best's scale — two scales at one
|
|
425
|
+
* spot — and so did a pair of eyes read at 1.000 and 0.922. √(0.0055 × 0.0719)
|
|
426
|
+
* ≈ 0.020.
|
|
427
|
+
* - **Distance**, from the best's centre to the nearest alternate's, over the
|
|
428
|
+
* part's span at the best's scale. On that grid every other alternate above
|
|
429
|
+
* the floor sat within 0.336 span of its best — two readings of one spot — or
|
|
430
|
+
* 2.26 spans or more away at a scale more than 20% off, which the scale bar
|
|
431
|
+
* already separates; the closest repeat measured was the legs at 0.603.
|
|
432
|
+
* √(0.336 × 0.603) ≈ 0.45.
|
|
433
|
+
*
|
|
434
|
+
* ⚠️ One grid trial above the floor takes this branch and is not a repeat: a
|
|
435
|
+
* blurred 48 px shin whose four placements all stopped on the scale FLOOR of the
|
|
436
|
+
* default window, at 0.5 against a truth at 1.0. Every placement on one wall has
|
|
437
|
+
* one scale by construction. Its console line marks the wall beside the value,
|
|
438
|
+
* which is the first thing to move; nothing in the alternates alone separates it
|
|
439
|
+
* from a repeat — a pair of ears in the same picture also sat on a scale floor.
|
|
440
|
+
*/
|
|
441
|
+
export const REPEAT_SCALE_RATIO = 0.02;
|
|
442
|
+
export const REPEAT_DISTANCE_SPANS = 0.45;
|
|
443
|
+
|
|
444
|
+
/** How an ambiguity's placements stand to the best one, when they are one part in several places at one size. */
|
|
445
|
+
export interface PoseRepeat {
|
|
446
|
+
/** The largest `|alternate.scale / best.scale − 1|` inside the margin. */
|
|
447
|
+
scaleRatio: number;
|
|
448
|
+
/** The nearest alternate's centre distance from the best's, in spans at the best's scale. */
|
|
449
|
+
spans: number;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Whether every alternate inside the margin is a DIFFERENT place at the SAME
|
|
454
|
+
* scale as the best, against `REPEAT_SCALE_RATIO` and `REPEAT_DISTANCE_SPANS`
|
|
455
|
+
* — `null` when any one is not, or there is none. `span` is the part's longest
|
|
456
|
+
* material side in frame pixels at the best placement's scale.
|
|
457
|
+
*
|
|
458
|
+
* ⭐ Exported so the selftest can hand it a forged alternate and watch the
|
|
459
|
+
* reading change branch; the search calls it with exactly what it reported.
|
|
460
|
+
*/
|
|
461
|
+
export function repeatedPlaces(best: PosePlacement, alternates: readonly PosePlacement[], span: number): PoseRepeat | null {
|
|
462
|
+
if (alternates.length === 0 || !(span > 0)) return null;
|
|
463
|
+
const scaleRatio = Math.max(...alternates.map((a) => Math.abs(a.scale / best.scale - 1)));
|
|
464
|
+
const spans = Math.min(...alternates.map((a) => hypot2(a.x - best.x, a.y - best.y) / span));
|
|
465
|
+
return scaleRatio <= REPEAT_SCALE_RATIO && spans >= REPEAT_DISTANCE_SPANS ? { scaleRatio, spans } : null;
|
|
466
|
+
}
|
|
467
|
+
|
|
390
468
|
/**
|
|
391
469
|
* The sentence that tells "the part is wrong" from "the part is too small or too
|
|
392
470
|
* plain for `pose`", with every figure it rests on (issue #857).
|
|
@@ -394,7 +472,11 @@ export function floorClause(): string {
|
|
|
394
472
|
* ⭐ Exported for the reason `windowEdgeNote` is: the guide quotes it, and a
|
|
395
473
|
* message and the document teaching it must be built from one place.
|
|
396
474
|
*/
|
|
397
|
-
export function legibilityReading(
|
|
475
|
+
export function legibilityReading(
|
|
476
|
+
l: Omit<PoseLegibility, 'reading'>,
|
|
477
|
+
verdict: 'refused' | 'ambiguous',
|
|
478
|
+
repeat: PoseRepeat | null = null,
|
|
479
|
+
): string {
|
|
398
480
|
const figures =
|
|
399
481
|
`part is ${l.width}x${l.height} px (span ${l.span} frame px, opaque ${l.opaqueShare}) with texture ${l.texture}, ` +
|
|
400
482
|
`detail ${l.detail}; ` +
|
|
@@ -425,12 +507,27 @@ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: '
|
|
|
425
507
|
// blurred goggles and gun from 128 px and rear shin at 48 px — five of them
|
|
426
508
|
// 2.1–2.7 px off, the rear shin 19 px. So the sentence names that reading
|
|
427
509
|
// too, with the one remedy that separates it: a window.
|
|
510
|
+
//
|
|
511
|
+
// 🔀 Except when the placements are one size in different places (issue
|
|
512
|
+
// #1519). Measured on a picture holding a left/right pair cut from one
|
|
513
|
+
// drawing: no --scale window separated them, narrower or wider, because they
|
|
514
|
+
// ARE one scale, and every --rotation window that "resolved" them did so by
|
|
515
|
+
// excluding one place — after which both files placed on the other, one of
|
|
516
|
+
// them 69 px off its truth and printed clean. There the window does not tell
|
|
517
|
+
// the two readings apart, it picks one, so the sentence names the route that
|
|
518
|
+
// does work instead.
|
|
519
|
+
const ambiguity =
|
|
520
|
+
repeat === null
|
|
521
|
+
? 'either the frame holds more than one place this part fits, or every candidate missed the true one — ' +
|
|
522
|
+
'a narrower --scale or --rotation window around what you know of the part tells the two apart'
|
|
523
|
+
: `every placement in the margin is at the best's scale (within ${(repeat.scaleRatio * 100).toFixed(2)}%, ` +
|
|
524
|
+
`bar ${REPEAT_SCALE_RATIO * 100}%) and in another place (the nearest ${repeat.spans.toFixed(2)} span(s) away, ` +
|
|
525
|
+
`bar ${REPEAT_DISTANCE_SPANS}), which is the part — or a near-copy, such as a left/right pair cut from one ` +
|
|
526
|
+
'drawing — drawn more than once: read the placements as an unordered set and order it across frames ' +
|
|
527
|
+
'(MOTION.md §2.2 🔀); a --rotation window that excludes one place chooses the answer rather than measuring it';
|
|
428
528
|
return (
|
|
429
529
|
`${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
|
|
430
|
-
(verdict === 'refused'
|
|
431
|
-
? 'the cut (or the search window) is the suspect'
|
|
432
|
-
: 'either the frame holds more than one place this part fits, or every candidate missed the true one — ' +
|
|
433
|
-
'a narrower --scale or --rotation window around what you know of the part tells the two apart')
|
|
530
|
+
(verdict === 'refused' ? 'the cut (or the search window) is the suspect' : ambiguity)
|
|
434
531
|
);
|
|
435
532
|
}
|
|
436
533
|
|
|
@@ -664,6 +761,19 @@ export interface PoseOptions {
|
|
|
664
761
|
* level of every part's refinement is appended to it. See `PoseTrace`.
|
|
665
762
|
*/
|
|
666
763
|
trace?: PoseTrace;
|
|
764
|
+
/**
|
|
765
|
+
* The coarse grid to search on — default `COARSE_GRID`, which is what the CLI
|
|
766
|
+
* always uses and has no flag to change.
|
|
767
|
+
*
|
|
768
|
+
* ⚠️ A plant, not a tuning knob. It exists so the selftest can run the search
|
|
769
|
+
* on the quarter-span grid releases before 1.4.0 used and watch a control go
|
|
770
|
+
* red there: two near-identical parts 0.6 of a span apart came back as ONE
|
|
771
|
+
* placement on that grid, printed `PLACE` with no alternate (issue #1519). A
|
|
772
|
+
* control nobody has seen fail holds nothing, and the only honest way to see
|
|
773
|
+
* this one fail is to search on the grid that failed. The report states the
|
|
774
|
+
* stride fraction in force in `search.coarse.strideFraction`.
|
|
775
|
+
*/
|
|
776
|
+
coarseGrid?: PoseCoarseGrid;
|
|
667
777
|
}
|
|
668
778
|
|
|
669
779
|
/** A search candidate as the trace states it: where the part image's centre would land, frame pixels. */
|
|
@@ -1742,7 +1852,7 @@ export function estimatePose(options: PoseOptions): PoseReport {
|
|
|
1742
1852
|
coarse: {
|
|
1743
1853
|
frameLongSide: COARSE_LONG_SIDE,
|
|
1744
1854
|
partSpan: COARSE_PART_SPAN,
|
|
1745
|
-
strideFraction:
|
|
1855
|
+
strideFraction: (options.coarseGrid ?? COARSE_GRID).strideFraction,
|
|
1746
1856
|
framePyramid: framePyramid.length,
|
|
1747
1857
|
},
|
|
1748
1858
|
maxResidual,
|
|
@@ -1787,6 +1897,7 @@ export function estimatePose(options: PoseOptions): PoseReport {
|
|
|
1787
1897
|
maxResidual,
|
|
1788
1898
|
{ min: scaleMin, max: scaleMax },
|
|
1789
1899
|
rotationBounds,
|
|
1900
|
+
options.coarseGrid ?? COARSE_GRID,
|
|
1790
1901
|
options.trace,
|
|
1791
1902
|
),
|
|
1792
1903
|
);
|
|
@@ -1804,6 +1915,7 @@ function placePart(
|
|
|
1804
1915
|
maxResidual: number,
|
|
1805
1916
|
scaleBounds: { min: number; max: number },
|
|
1806
1917
|
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1918
|
+
coarseGrid: PoseCoarseGrid,
|
|
1807
1919
|
trace?: PoseTrace,
|
|
1808
1920
|
): PosePart {
|
|
1809
1921
|
const scaleMin = scaleBounds.min;
|
|
@@ -1938,14 +2050,14 @@ function placePart(
|
|
|
1938
2050
|
}
|
|
1939
2051
|
const coarse = levels[coarseIndex];
|
|
1940
2052
|
const spanAtCoarse = (span * scaleReference) / coarse.reduction;
|
|
1941
|
-
const stride = Math.max(1, Math.round(spanAtCoarse *
|
|
2053
|
+
const stride = Math.max(1, Math.round(spanAtCoarse * coarseGrid.strideFraction));
|
|
1942
2054
|
const coarseSamples = samplesFor(coarse.reduction, COARSE_SAMPLES);
|
|
1943
2055
|
let candidates: Candidate[] = [];
|
|
1944
2056
|
let grid = { reduction: coarse.reduction, cols: 0, rows: 0, stride };
|
|
1945
2057
|
for (const scale of scales) {
|
|
1946
2058
|
const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
|
|
1947
2059
|
grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
|
|
1948
|
-
candidates.push(...localMinima(field,
|
|
2060
|
+
candidates.push(...localMinima(field, coarseGrid.suppressionCells, coarseGrid.minimaPerScale));
|
|
1949
2061
|
}
|
|
1950
2062
|
base.coarse = grid;
|
|
1951
2063
|
candidates.sort((a, b) => a.residual - b.residual);
|
|
@@ -1976,7 +2088,7 @@ function placePart(
|
|
|
1976
2088
|
const plate = plates[li];
|
|
1977
2089
|
const smooth = li !== coarseIndex;
|
|
1978
2090
|
const branch = li === branchLevel;
|
|
1979
|
-
const keep = li === coarseIndex ? scales.length *
|
|
2091
|
+
const keep = li === coarseIndex ? scales.length * coarseGrid.minimaPerScale : REFINE_CANDIDATES;
|
|
1980
2092
|
const s = samplesFor(level.reduction, li === 0 ? POLISH_SAMPLES : REFINE_SAMPLES);
|
|
1981
2093
|
// The coarse pass only sampled every `stride` pixels, so entering the
|
|
1982
2094
|
// refinement the anchor can be half a stride out; the first polish gets a
|
|
@@ -2235,7 +2347,17 @@ function placePart(
|
|
|
2235
2347
|
// move, and this sentence is about what moving it cannot change.
|
|
2236
2348
|
if (base.refusal !== null || base.ambiguous) {
|
|
2237
2349
|
const verdict = base.refusal !== null ? 'refused' : 'ambiguous';
|
|
2238
|
-
|
|
2350
|
+
// The whole margin rather than the alternates kept, so a repeat is not
|
|
2351
|
+
// read off a list `MAX_ALTERNATES` truncated.
|
|
2352
|
+
const repeat =
|
|
2353
|
+
verdict === 'ambiguous'
|
|
2354
|
+
? repeatedPlaces(
|
|
2355
|
+
best,
|
|
2356
|
+
close.map((d) => d.placement),
|
|
2357
|
+
span * best.scale,
|
|
2358
|
+
)
|
|
2359
|
+
: null;
|
|
2360
|
+
const reading = legibilityReading(base.legibility, verdict, repeat);
|
|
2239
2361
|
base.legibility.reading = reading;
|
|
2240
2362
|
if (base.refusal !== null) base.refusal.detail += `; ${reading}`;
|
|
2241
2363
|
base.notes.push(`${name}: ${reading}.`);
|