@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 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>.</em></p>
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` | “&lt;value&gt; (t=&lt;value&gt;): raw curve holds a non-finite value &lt;value&gt;” | 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 "&lt;value&gt;": &lt;value&gt; "&lt;value&gt;" has no property "&lt;value&gt;"” | 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` | “&lt;value&gt;: no keys”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>“animation "&lt;value&gt;" events”<br>“animation "&lt;value&gt;" drawOrder”<br>“animation "&lt;value&gt;" slot "&lt;value&gt;" &lt;value&gt;”<br>“animation "&lt;value&gt;" &lt;value&gt; constraint "&lt;value&gt;"”<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` | “&lt;value&gt;: key times must strictly increase (at t=&lt;value&gt;)”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>“animation "&lt;value&gt;" drawOrder”<br>“animation "&lt;value&gt;" slot "&lt;value&gt;" &lt;value&gt;”<br>“animation "&lt;value&gt;" &lt;value&gt; constraint "&lt;value&gt;"”<br>Here the values named in the message include: “default”. | Order keys by strictly increasing time. |
7224
+ | <a id="CE_COMPILE_KEY_TIMES_NOT_INCREASING"></a>`CE_COMPILE_KEY_TIMES_NOT_INCREASING` | “&lt;value&gt;: key times must strictly increase — key &lt;value&gt; at t=&lt;value&gt; does not come after key &lt;value&gt; at t=&lt;value&gt; (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=&lt;value&gt; repeat every &lt;value&gt;s, so key &lt;value&gt; closes one cycle and key &lt;value&gt; opens the next at the same time: a phrase that repeats every &lt;value&gt;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 "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>“animation "&lt;value&gt;" drawOrder”<br>“animation "&lt;value&gt;" slot "&lt;value&gt;" &lt;value&gt;”<br>“animation "&lt;value&gt;" &lt;value&gt; constraint "&lt;value&gt;"”<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` | “&lt;value&gt; key at t=&lt;value&gt; carries [an easing ("&lt;value&gt;") / 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 "&lt;value&gt;" &lt;value&gt; "&lt;value&gt;" &lt;value&gt;” | Remove easing/curve from the stepped mode timeline. |
7218
7226
  | <a id="CE_COMPILE_TIMELINE_MODE_UNKNOWN"></a>`CE_COMPILE_TIMELINE_MODE_UNKNOWN` | “&lt;value&gt; key at t=&lt;value&gt; names mode &lt;value&gt;; &lt;value&gt;”<br>“known: &lt;value&gt; — the runtime folds the case of the first letter and of nothing else”<br>Also: “animation "&lt;value&gt;" &lt;value&gt; "&lt;value&gt;" &lt;value&gt;”<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` | “&lt;value&gt;: this timeline takes no value; use null”<br>Also: “animation "&lt;value&gt;" &lt;value&gt; "&lt;value&gt;" &lt;value&gt;” | 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. ⚠️ Above the floor is **not** a
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 method that works:
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.64.3",
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(l: Omit<PoseLegibility, 'reading'>, verdict: 'refused' | 'ambiguous'): string {
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: COARSE_STRIDE_FRACTION,
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 * COARSE_STRIDE_FRACTION));
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, COARSE_SUPPRESSION_CELLS, MINIMA_PER_SCALE));
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 * MINIMA_PER_SCALE : REFINE_CANDIDATES;
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
- const reading = legibilityReading(base.legibility, verdict);
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}.`);