@rig-c/core 2.65.0 → 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/docs/AUTHORING.md CHANGED
@@ -11365,10 +11365,23 @@ ambiguous one ends with a sentence saying which reading its figures support:
11365
11365
 
11366
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
11367
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
+
11368
11372
  Read the figures, not only the verdict. *Below* says plainness alone explains the
11369
11373
  failure: the cut may be right, and `pose` cannot tell. *Above* says it does not,
11370
11374
  so look at the cut, the `--scale`/`--rotation` window, or — for an ambiguity — at
11371
- 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
11372
11385
  promise that `pose` will place the part (see the rates below).
11373
11386
 
11374
11387
  **The texture figure.** `legibility.texture` is the mean colour step between
@@ -11513,7 +11526,10 @@ cutoff, and `PO26` holds every polish to strictly descending residuals, read thr
11513
11526
  the same trace. `PO31` places §6's post at the old placement from the page's own
11514
11527
  input bytes, and `PO32` holds it to taking the quarter-rung escape and reading the same twice.
11515
11528
  `PO33` holds the coarse row the tool reads rotations from to the field each seed was
11516
- 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.
11517
11533
 
11518
11534
  ## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
11519
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.65.0",
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/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}.`);