spine-rigc 0.25.5 → 0.25.6

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
@@ -413,8 +413,11 @@ that does not belong to it. See
413
413
  against frames of itself — is the floor the rest are read against, because
414
414
  `check` grades nothing and has no pass mark. It is the same instrument and the
415
415
  same commands; what changes is that the reference is a build of yours you have
416
- already looked at, so what it measures is **what your edit did**. AUTHORING.md
417
- §9.2 says what that floor reads and why it is not zero.
416
+ already looked at, so what it measures is **what your edit did**. Every drift
417
+ the report prints carries the bound its own match gives it on the line under
418
+ it, so a figure is read against that rather than against a number from a page;
419
+ AUTHORING.md §9.2 says what the two halves of that bound are and why the floor
420
+ is not zero.
418
421
  - [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) is the benchmark: the same job, from a brief
419
422
  and rendered frames, scored. [docs/PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) is how to run an
420
423
  agent through it and score what comes back.
package/cli.ts CHANGED
@@ -68,8 +68,8 @@ import {
68
68
  } from './src/deformmeasure.ts';
69
69
  import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
70
70
  import { ingest, IngestError, INGEST_GUTTERS, type IngestStage } from './src/ingest.ts';
71
- import { copyAtlasImages } from './src/emit.ts';
72
- import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
71
+ import { copyAtlasPages } from './src/emit.ts';
72
+ import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas, parseAtlasText } from './src/atlas.ts';
73
73
  import { parseJsonWithPosition } from './src/json-position.ts';
74
74
  import { KEY_TIME_EPSILON } from './src/timelines.ts';
75
75
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
@@ -117,6 +117,7 @@ import {
117
117
  } from './src/render.ts';
118
118
  import {
119
119
  assertionCountForProfile,
120
+ attachmentRegionJoins,
120
121
  CLI_DEFAULT_PROFILE,
121
122
  reportLines,
122
123
  validate,
@@ -148,6 +149,14 @@ export type CutTable = Record<string, CutEntry>;
148
149
 
149
150
  class UsageError extends Error {}
150
151
 
152
+ /**
153
+ * `explain` refusing a pair it cannot pose — a usage error in kind, printed
154
+ * without the usage block for `PoseError`'s and `IngestError`'s reason: the
155
+ * message names an attachment, a region and two flags, and reprinting every
156
+ * command's usage under it buries the one line that says what to change.
157
+ */
158
+ class ExplainError extends Error {}
159
+
151
160
  // ---------------------------------------------------------------------------
152
161
  // package metadata — the installed version and repository, for `--version`
153
162
  // and for naming a remedy `bench` can only give from a repo checkout.
@@ -1242,14 +1251,21 @@ function cmdBuild(flags: Record<string, string>): void {
1242
1251
  // correct for a build sitting beside the project it came from and breaks the
1243
1252
  // moment the directory is zipped, committed or moved on its own (issue #217).
1244
1253
  // Opt-in only: the default stays exactly what it has always been.
1254
+ //
1255
+ // What is copied is what the ATLAS names, not what the image list holds: under
1256
+ // `--atlas-in` the two are different lists, and rebuilding the text from the
1257
+ // second wrote a file the pack never contained — zero bytes for a rig that
1258
+ // declares no parts, one fabricated page per part for a rig that does, both of
1259
+ // them green here because the gate above had already read the compile's own
1260
+ // text (issue #693, `src/emit.ts`).
1245
1261
  let atlasText = result.atlasText;
1246
1262
  if (flags['copy-images'] !== undefined) {
1247
- const copied = copyAtlasImages(result.images, opts.outDir);
1263
+ const copied = copyAtlasPages(atlasText, opts.outDir);
1248
1264
  atlasText = copied.atlasText;
1249
1265
  console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
1250
1266
  for (const p of copied.pages) {
1251
- const note = p.to === basename(p.from) ? '' : ` (renamed from ${basename(p.from)} — basename collision)`;
1252
- console.log(` .. ${p.region.padEnd(24)} <- ${p.to}${note}`);
1267
+ const note = p.to === basename(p.from) ? '' : ' (renamed — basename collision)';
1268
+ console.log(` .. ${p.to.padEnd(24)} <- ${p.from} (${p.regions} region(s))${note}`);
1253
1269
  }
1254
1270
  }
1255
1271
 
@@ -2434,6 +2450,81 @@ function cmdBoneDist(flags: Record<string, string>): void {
2434
2450
  if (flags.json !== undefined) writeJson(flags.json, report);
2435
2451
  }
2436
2452
 
2453
+ /**
2454
+ * Refuse a compiled pair whose art `explain` cannot pose through, by name.
2455
+ *
2456
+ * 🚨 `explain` poses the rig — `deformReportLines` loads the emitted pair through
2457
+ * `spine-core` to measure what each deform key did — and a pose resolves EVERY
2458
+ * attachment against the atlas, whether or not anything deforms it. On the specs
2459
+ * `ingest --art none` writes there is nothing to resolve against: the entries
2460
+ * state a size and name no `image`, so the compile atlases nothing, and the load
2461
+ * threw the runtime's own `Region not found in atlas: rear-upper-arm (attachment:
2462
+ * rear-upper-arm)` with a spine-core stack trace under it and exit 1 (measured on
2463
+ * `examples/spineboy/export/spineboy-ess.json`, issue #697). That is the tool
2464
+ * telling an agent about its own internals instead of about the rig, on the one
2465
+ * input `docs/INGEST.md` §2.0 documents as the route through a foreign skeleton.
2466
+ *
2467
+ * ⭐ Both halves of the join are rigc's own readers rather than a second opinion
2468
+ * on somebody else's format: the walk is `attachmentRegionJoins`, which `PS127`
2469
+ * measures against the loader's own `findRegion` calls, and the region names come
2470
+ * from `parseAtlasText`, which is what `--atlas-in` already resolves against.
2471
+ * Names are compared EXACTLY — as `A08` compares them and as `findRegion`
2472
+ * matches them — so a padded region name is a miss on both sides.
2473
+ *
2474
+ * ⚠️ What it deliberately does not do is catch the pose. A blanket `try` around
2475
+ * `skeletonDataFromText` would convert any exception the runtime raises into a
2476
+ * sentence claiming the cause is missing art, and a rigc defect reported under
2477
+ * somebody else's name is the doctrine's second bullet inverted. This refuses
2478
+ * the case it can NAME, before a line of the report is printed, and leaves
2479
+ * anything else to arrive as itself.
2480
+ */
2481
+ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
2482
+ const regionNames = parseAtlasText(result.atlasText).pages.flatMap((page) => page.regions.map((region) => region.name));
2483
+ const have = new Set(regionNames);
2484
+ const misses: Array<{ at: string; attachment: string; lookup: string }> = [];
2485
+ let lookups = 0;
2486
+ for (const join of attachmentRegionJoins(JSON.parse(result.skeletonText))) {
2487
+ // A `sequence` this walk will not guess at names no region it can check, and
2488
+ // `A08` passes over it for the same reason.
2489
+ if (join.lookups === null) continue;
2490
+ for (const lookup of join.lookups) {
2491
+ lookups++;
2492
+ if (!have.has(lookup)) {
2493
+ misses.push({
2494
+ at: `skin "${join.skin}" slot "${join.slot}" placeholder "${join.placeholder}"`,
2495
+ attachment: join.name,
2496
+ lookup,
2497
+ });
2498
+ }
2499
+ }
2500
+ }
2501
+ if (misses.length === 0) return;
2502
+ const first = misses[0];
2503
+ // Reported because it was measured, and absent where there is none — `A08`'s
2504
+ // own near-miss clause, in `A08`'s own words.
2505
+ const near = regionNames.find((region) => region.trim().toLowerCase() === first.lookup.toLowerCase());
2506
+ const has =
2507
+ regionNames.length === 0
2508
+ ? 'it declares no region at all'
2509
+ : `it declares ${regionNames.length} region(s) and none of them is that${
2510
+ near === undefined ? '' : `, though it does have ${JSON.stringify(near)}`
2511
+ }`;
2512
+ const remedy =
2513
+ opts.atlasInPath === undefined
2514
+ ? 'Art reaches a compile two ways and this run took neither: `--atlas-in <pack.atlas>` resolves the parts ' +
2515
+ 'against a pack somebody already made, and an "image" per attachment resolves them as loose PNGs under ' +
2516
+ '`--images <dir>` — a spec that states a size and names no image is what `ingest --art none` writes, and ' +
2517
+ '`--atlas-in` is what reads it'
2518
+ : `Either the spec's region name or ${opts.atlasInPath} is the one that moved: fix the name, or point ` +
2519
+ '`--atlas-in` at the pack that has it';
2520
+ throw new ExplainError(
2521
+ `${first.at}: attachment ${JSON.stringify(first.attachment)} wants region ${JSON.stringify(first.lookup)}, ` +
2522
+ `which this build's atlas does not have (${has}). \`explain\` poses the rig to measure its deform keys and a ` +
2523
+ `pose resolves every attachment against the atlas, so there is nothing to pose it against. ${remedy}. ` +
2524
+ `${misses.length} of ${lookups} attachment lookup(s) here resolve to no region.`,
2525
+ );
2526
+ }
2527
+
2437
2528
  function cmdExplain(flags: Record<string, string>): void {
2438
2529
  const { label, opts } = resolveCut(flags);
2439
2530
  console.log(`rigc explain ${label}`);
@@ -2442,6 +2533,13 @@ function cmdExplain(flags: Record<string, string>): void {
2442
2533
  console.log(` .. rig ${opts.rigPath}`);
2443
2534
  console.log(` .. motion ${opts.motionPath}`);
2444
2535
  const result = compile(opts);
2536
+ // Before a line of the report, rather than at the pose two hundred lines in:
2537
+ // the blocks that need the art — DEFORM, meshes, dropped states — all sit
2538
+ // BELOW the pose, so a run that printed the bone and timeline dump and then
2539
+ // refused would be a report missing everything the missing art decides, with
2540
+ // the sentence saying so scrolled off the top. It is the invocation that has
2541
+ // to change, so it is refused before the report it cannot finish (issue #697).
2542
+ refuseUnposableArt(result, opts);
2445
2543
  // `compile` has already parsed this file, so the read below cannot fail — but
2446
2544
  // it goes through the same parser rather than a cast, because the cast was the
2447
2545
  // last one in the repository and issue #307 was about exactly that.
@@ -3108,8 +3206,20 @@ const COMMANDS: CommandDoc[] = [
3108
3206
  },
3109
3207
  {
3110
3208
  name: 'explain',
3111
- usage: ['rigc explain (same arguments as build, minus --profile — it never gates)'],
3112
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'cut', 'cuts'],
3209
+ usage: [
3210
+ 'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] (it never gates, and writes nothing)',
3211
+ 'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
3212
+ 'rigc explain --cut <name> --cuts <cuts.json>',
3213
+ ],
3214
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts'],
3215
+ notes: [
3216
+ 'this line said "the same arguments as build, minus --profile" and was false in both',
3217
+ 'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
3218
+ 'command read what `ingest --art none` writes was reachable and undocumented, while',
3219
+ '--pack, --page-size, --padding and --copy-images are build\'s and do nothing here —',
3220
+ 'they decide what is WRITTEN, and this command writes nothing. What it takes is listed',
3221
+ 'above, and that is now the whole of it.',
3222
+ ],
3113
3223
  },
3114
3224
  {
3115
3225
  name: 'validate',
@@ -3482,6 +3592,13 @@ try {
3482
3592
  console.error(`rigc pose: ${err.message}`);
3483
3593
  process.exit(2);
3484
3594
  }
3595
+ // A refusal of the invocation, like the two below it, and exit 2 for the same
3596
+ // reason: nothing was posed and nothing was written, so it is the command line
3597
+ // that has to change (issue #697).
3598
+ if (err instanceof ExplainError) {
3599
+ console.error(`rigc explain: ${err.message}`);
3600
+ process.exit(2);
3601
+ }
3485
3602
  // Same kind as a PoseError, and printed the same way for the same reason: the
3486
3603
  // messages name a path, a bone or an attachment, and reprinting the whole
3487
3604
  // usage under them buries the one line that says what to change.
package/docs/AUTHORING.md CHANGED
@@ -160,11 +160,11 @@ What the flags mean:
160
160
  | `--rig` | the rig spec — skeleton structure |
161
161
  | `--motion` | the motion spec — time |
162
162
  | `--out` | directory for `skeleton.json` + `skeleton.atlas`; atlas page paths and `skeleton.images` are written relative to it |
163
- | `--copy-images` | `build` only: also copies every referenced page PNG into `--out` and rewrites the atlas to the copies, so the directory is self-contained enough to zip or commit on its own, and points `skeleton.images` at `--out` itself so the editor's import finds the parts beside the skeleton (issue #370; §3.1 says why it is spelled `../<out>/` and not `./`). Default is unchanged — page paths still point at the source art (issue #217) |
163
+ | `--copy-images` | `build` only: also copies every page **the emitted atlas names** into `--out` and rewrites the atlas to the copies, so the directory is self-contained enough to zip or commit on its own, and points `skeleton.images` at `--out` itself so the editor's import finds the parts beside the skeleton (issue #370; §3.1 says why it is spelled `../<out>/` and not `./`). Under `--atlas-in` those pages are the pack's, not one per part (issue #693 — **§0.2**). Default is unchanged — page paths still point at the source art (issue #217) |
164
164
  | `--pack` | `build` only: arrange every part onto **shared** atlas page(s), written into `--out` as real PNGs, instead of one page per part. Lossless — nothing is resampled, trimmed or rotated. Default is unchanged (issue #4) — **§0.1** |
165
165
  | `--page-size` | `build --pack` only: the largest page edge (default `2048`). A ceiling, not the size: page edges are powers of two and the one written is the smallest that holds the pack — **§0.1** |
166
166
  | `--padding` | `build --pack` only: the gutter each region reserves on every side (default `2`), filled by extending the region's own edge pixels outwards. `0` is not a legal-but-tight choice, it is bleed — **§0.1** |
167
- | `--atlas-in` | `build` only: resolve every part against the **regions of a pre-packed `.atlas`** instead of against loose PNGs. Region geometry is read from the file and sizes are descaled by the page's `scale:`; the atlas is re-emitted into `--out`, re-anchored — **§0.2** |
167
+ | `--atlas-in` | `build` and `explain`: resolve every part against the **regions of a pre-packed `.atlas`** instead of against loose PNGs. Region geometry is read from the file and sizes are descaled by the page's `scale:`; `build` re-emits the atlas into `--out`, re-anchored, and `explain` writes nothing and poses through it — **§0.2**. On `explain` it is the flag that makes a **size-only** spec readable at all (`ingest --art none`), because posing resolves every attachment against an atlas; without it that pair is refused by name rather than thrown through ([#697](https://github.com/firejune/rigc/issues/697), §5.1) |
168
168
  | `--images` | where the rig spec's `image` names resolve (overrides the rig's own `images` field, and is relative to your working directory). For `pose` it is the directory of **loose part PNGs to place** — every `.png` in it is a part, in name order. For `chainfit` it is only where each attachment's image name **resolves**: the candidate decides what the parts are, so extra PNGs are unused and a missing name is refused by name (§12.3) |
169
169
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
170
170
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
@@ -319,6 +319,17 @@ texel count beside it so both numbers are visible:
319
319
  measures the PNG. Reach for `--atlas-in` when the pack is what you were handed, or
320
320
  when drawing through the pack's own texels is the point.
321
321
 
322
+ **What `--out` holds afterwards:** `skeleton.json` and a `skeleton.atlas` that is
323
+ the pack, page paths pointing back at the pack's own PNGs — so `rigc validate
324
+ <that directory>` reads it green with no flags, exactly as it reads a loose
325
+ build's. Add `--copy-images` and the pack's page PNGs are copied in beside the
326
+ skeleton and the page names become their basenames, which is the same directory
327
+ with nothing outside it left to resolve. ⚠️ Until
328
+ [#693](https://github.com/firejune/rigc/issues/693) that flag rebuilt the atlas
329
+ from the parts the rig declared instead of from the pack, and a rebuild through
330
+ `ingest --art none` declares none: the file written was **zero bytes**, on a build
331
+ that printed `PASS` for all four atlas assertions.
332
+
322
333
  The emitted `skeleton.atlas` **is** the imported one, verbatim except for its page
323
334
  name lines, which are paths and have to be re-anchored to `--out`. Fields rigc
324
335
  does not re-serialise (`format:`, `repeat:`, and `scale:` itself) survive the trip
@@ -518,6 +529,17 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
518
529
  extremes, how far each vertex moved and whether the winding survived
519
530
  (**§4.11.2**). It takes no `--profile`, it never gates, and it does not write
520
531
  anything — so the figures are readable on a build the gate is refusing.
532
+
533
+ ⭐ **It reads what `ingest` writes, given the pack** — `--atlas-in <pack.atlas>`,
534
+ with `build`'s meaning (§0.2). That matters more here than it looks: printing the
535
+ `DEFORM` block means **posing** the rig, a pose resolves *every* attachment
536
+ against the atlas whether or not anything deforms it, and a spec written by
537
+ `ingest --art none` states sizes and names no image. So the size-only pair
538
+ `build --atlas-in` gates green is readable here through the same flag, and
539
+ without it the pair is refused by name at exit 2 rather than posed
540
+ ([#697](https://github.com/firejune/rigc/issues/697), §5.1). ⚠️ `--profile`,
541
+ `--pack`, `--page-size`, `--padding` and `--copy-images` are `build`'s and are
542
+ not here: four of them decide what is *written*, and this command writes nothing.
521
543
  - **`diff`** compares two skeletons and reports **a ratio per measure** in six
522
544
  sections (bones, slots, attachments, constraints, animations, events). It
523
545
  deliberately does not combine them into a score: a rig with the right skeleton
@@ -964,6 +986,12 @@ A skin can also say which bones and constraints it **switches on**, and that nee
964
986
  one more level, so a skin entry has a second spelling — see §3.4.1. The short one
965
987
  above is unchanged and is what almost every rig wants.
966
988
 
989
+ 🔸 **`default` is a name, not a requirement.** A rig may put every attachment in
990
+ named skins and declare no `default` at all, which is what an editor export of a
991
+ multi-skin character gives back; rigc still emits a `default` skin, empty, because
992
+ it always does. An animation keying such a slot resolves its attachment names
993
+ against every skin there is — §4.4 states that rule and §5.1 the one refusal left.
994
+
967
995
  🔸 **A skin may fill no slot with anything that needs art, and that build is
968
996
  green.** The atlas is built out of what the skins reference, so a rig whose skins
969
997
  name no `image` — an empty `default`, or one carrying only a `boundingbox`, a
@@ -1213,7 +1241,7 @@ ends, so the deformation dies into the pinned rim instead of creasing against it
1213
1241
  | `inner` | **required.** Where the moving ring sits between the centre (`0`) and the hull (`1`), strictly inside that interval. No default: a ring with no number here is refused, not centred |
1214
1242
  | `size` | **required.** The part window, `[w, h]` in pixels, which the UVs and the emitted `width`/`height` are taken from |
1215
1243
  | `bias` | optional; absent means authority is radial only. `{ "axis_deg": <screen degrees, y down>, "ramp": [d0, d1] }` — a line through `center` at that angle, with control authority 0 on its negative side and 1 on its positive side, smoothstepped across the signed distances `d0 < d1`. It is what lets a mouth open downward with the upper lip left pinned |
1216
- | `controls` | **required.** The control bones, by name, at least one. Each must be a bone the rig declares |
1244
+ | `controls` | **required.** The control bones, by name, at least one. Each must be a bone the rig declares. **More than one splits the ring by angle**, and the angle of each is measured from where the rig put that bone relative to `center` — so the split is a consequence of the skeleton and never a number you write here |
1217
1245
 
1218
1246
  ⚠️ **`size` is stated here, not measured.** A `contour` and a `grid` take the
1219
1247
  window off the attachment's own `image`; a `ring` and a `ribbon` are built from
@@ -1223,17 +1251,24 @@ drawing — measured: a 240x240 part declared `"size": [64, 64]` builds green un
1223
1251
  `--profile spine-html`. That is R1 rather than a gap: the compiler emits the
1224
1252
  number the spec states and does not re-measure a plate to overrule it.
1225
1253
 
1226
- 🚨 **On this route the FIRST control bone is the only one that moves the mesh.**
1227
- Splitting a ring's authority between several grips needs each bone's angle about
1228
- the aperture centre, and rigc measures that from where the rig put the bone — on
1229
- the **manifest** route, which is the one that has a crop to measure in. A rig spec
1230
- that lists two gets the single-bone geometry instead: `controls[0]` takes the
1231
- whole of the control authority, the second name is still printed on the `MESH`
1232
- line, and the gate is green. Measured on a two-control ring — 25 vertices, 40
1233
- triangles, report line `bones=[box, grip_a, grip_b]`, and the emitted weighted run
1234
- binds two bone indices, the slot bone and `grip_a`. Until that is closed, write
1235
- one `controls` entry on this route and reach for the manifest when a ring needs
1236
- several grips.
1254
+ ⭐ **Several grips split the ring by where the rig put them, on this route as on
1255
+ the manifest one.** Each control bone's angle about `center` is measured from its
1256
+ own rest position — the window is centred on the slot bone (the ⭐ above), so a
1257
+ bone sitting below the aperture owns the arc below it — and authority is
1258
+ smoothstepped between the two bones either side of a vertex, so no grip creases
1259
+ against the next. Measured on a two-control ring whose grips sit 12px above and
1260
+ below the centre: 25 vertices, both grips bound, and of the 8 shared vertices off
1261
+ the centre line all 8 take more weight from the grip on their own side. Splitting
1262
+ **moves** authority rather than adding it: every vertex gives its controls the
1263
+ same total with one grip, two or three.
1264
+
1265
+ ⚠️ **This route used to bind only the first name**
1266
+ ([#684](https://github.com/firejune/rigc/issues/684)). It passed no angles at all,
1267
+ so the split collapsed onto `controls[0]` and the rest of the names reached the
1268
+ `MESH` line, the bone list and nothing else — and the gate was green, because a
1269
+ bone no vertex binds is in no weight, no sum and no index. `A20_MESH_WEIGHTS_COHERENT`
1270
+ now names it, so a ring that declares a grip and does not use it is a failure
1271
+ rather than a quiet stiffness.
1237
1272
 
1238
1273
  **Stated limits, each a named refusal rather than a mesh that loads wrong:**
1239
1274
 
@@ -1245,6 +1280,8 @@ several grips.
1245
1280
  | a hull the centre cannot see all of | `hull is not star-shaped about the aperture centre; the inner ring would fold` — the inner ring is the hull scaled toward `center`, so an edge hidden from it crosses the rim and renders as folded meat |
1246
1281
  | a `bias` ramp that does not increase | `bias ramp must increase, got [16, 4]` |
1247
1282
  | a control bone the rig does not declare | `mesh bone "nobody" is not in the rig's bone list` |
1283
+ | two or more `controls`, one of them ON `center` | `control bone "iris_aperture" sits on the aperture centre, so it has no radial direction` — the position a lone control is supposed to occupy is the one position a split cannot use. With a single control it is never asked, so this refusal cannot fire on one |
1284
+ | two `controls` at the same angle about `center` | `two control bones share the angle 90 degrees about the aperture centre` — further out is not elsewhere: the arc between them is empty and one of them would bind nothing |
1248
1285
 
1249
1286
  #### `ribbon` — a strip of cross rows riding a bone chain
1250
1287
 
@@ -2871,6 +2908,36 @@ different curves. The same applies to `scale`/`scalex`/`scaley` and
2871
2908
  An `attachment` key carries no easing — attachment timelines are inherently
2872
2909
  stepped.
2873
2910
 
2911
+ 🔑 **An attachment key resolves against every skin, not against `default` alone.**
2912
+ A slot's `attachment` timeline carries a name and **no skin** — the format has no
2913
+ field for one — so the names a key may use are the union of every skin's
2914
+ placeholders for that slot, the default skin's included.
2915
+ `Skeleton.getAttachment` resolves the keyed name at run time through the skin the
2916
+ skeleton is **wearing** and, failing that, through `defaultSkin` (spine-core
2917
+ 4.3.13 `Skeleton.js:335-346`), so which skin's art a key lands on is the
2918
+ consumer's, decided by dressing the skeleton rather than by the animation.
2919
+
2920
+ - **A name some skins fill and others do not is accepted, and that is the
2921
+ format's own semantics** rather than a hole in the check: under a skin that
2922
+ lacks it the slot shows nothing, which is exactly what a `null` key says and a
2923
+ thing a rig may well mean. What is refused is a name **no** skin holds, and the
2924
+ refusal says which skins were searched and what the slot does have (§5.1).
2925
+ - A rig with **no `default` skin at all** — every attachment in named skins,
2926
+ which is the shape an editor export of a multi-skin character gives back — is
2927
+ therefore a rig whose attachment keys work. Until
2928
+ [#695](https://github.com/firejune/rigc/issues/695) it was not: keys were
2929
+ checked against the default skin alone, so a named-skin name was refused as
2930
+ unknown, and a rig with no default skin had **every** attachment key refused,
2931
+ including ones whose art is in the first named skin. The setup pose resolved
2932
+ across skins the whole time (§4.2), so the two halves of one slot disagreed —
2933
+ `slots[].attachment: "plain"` was accepted and a key naming `plain` on that
2934
+ same slot was not.
2935
+ - ⚠️ **A `deform` track is the other way round and names its skin outright**
2936
+ (§4.11.5), because the format keys a deform timeline on a `skin/slot/attachment`
2937
+ triple and a deform run is geometry for one attachment object. An attachment
2938
+ timeline has no such field, which is why this one is a union and that one is a
2939
+ lookup.
2940
+
2874
2941
  ### 4.5 `keys` — times, values, curves
2875
2942
 
2876
2943
  - `t` is in seconds and **must strictly increase** after `lag`/`stagger` are added.
@@ -4395,6 +4462,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
4395
4462
  | `motion spec names archetype "A" but the rig spec at … is called "B"` | make `archetype` equal the rig's `name` |
4396
4463
  | `animation "A" declares duration Ns but its last key is at Ms` | R7 — fix whichever of the two you meant |
4397
4464
  | `animation "A" slot "X" attachment: key at Ns is Ms past the declared duration Ds` | §4.5 — the key is past the end of the animation and nothing will sample it. Move the key onto `duration`, or raise `duration` |
4465
+ | `animation "A" slot "X" attachment: attachment "N" is not in slot "X" under any skin (searched: default, alt) — the slot has: plain, trim` | §4.4 — the keyed name is in **no** skin, and the two clauses say where the compiler looked and what it would have taken. Fix the spelling, or give some skin a placeholder called `N`. A name only a NAMED skin fills is not this error and never was one to fix — it compiles, and the slot shows nothing under the skins that lack it. Before [#695](https://github.com/firejune/rigc/issues/695) the message read `attachment "N" is not in slot "X"` and was raised against the **default skin alone**, so it fired on correct rigs: any key into named-skin art, and every key in a rig with no default skin. `the slot has no attachments at all` is the same message where nothing fills the slot |
4398
4466
  | `animation "A" keys unknown bone "X"` | the track's `bone` is not in the rig |
4399
4467
  | `animation "A" bone "X" translatex: key value must be an array of 1 number(s)` | the value shape must match the property (§4.4) |
4400
4468
  | `animation "A" physics constraint "C" mass key at t=… is 0 (massInverse Infinity); must be > 0 — …` | §4.4 — a keyed physics value the runtime cannot use. The message names the bound and the `PhysicsConstraint.js` lines that make it one: `mass` and `strength` are `> 0`, `damping` is inside `(0, 1)`, `mix` is `0` or more, and `inertia`/`wind`/`gravity` are bounded nowhere ([#610](https://github.com/firejune/rigc/issues/610)) |
@@ -4465,6 +4533,35 @@ or the key's position in its own track. These are the frequent ones, verbatim:
4465
4533
  | `slot "patch": placeholder "patch" is filled by the "default" skin AND by skins "zulu", "mike", and the Spine editor has no way to hold that … Move the default skin's entry for this slot into a named skin — call it "base"` | **R12** — do what it says: move that entry out of `default` into a named skin. The editor has no representation for a placeholder the default skin shares with a named one, in either spelling, and §3.4.2 has both measurements. Renaming the placeholder does not help; the shape is what is refused |
4466
4534
  | `N attachment name collision(s): a placeholder that more than one skin fills is emitted with the name "<skin>/<placeholder>" … slot "patch": skin "base" placeholder "zulu/patch" and skin "zulu" placeholder "patch" would both be named "zulu/patch"` | **R12** — rename the placeholder or the skin. rigc composes an attachment name for every placeholder more than one skin fills (§3.4.2), and this fires when a composed name is one another entry in the same slot already answers to — including a plain name in the default skin, which composed nothing. Both sites are named; either rename ends it |
4467
4535
 
4536
+ ⚠️ **One refusal in this section is not a `CompileError`, and it is `explain`'s.**
4537
+ `explain` prints the `DEFORM` block by **posing** the rig, and a pose resolves every
4538
+ attachment against the atlas — so it needs the art `build` needs, reached the same
4539
+ two ways (§0.2). A pair whose art it cannot resolve is refused **before a line of
4540
+ the report**, at **exit 2**, in rigc's own sentence:
4541
+
4542
+ ```
4543
+ rigc explain: skin "default" slot "lamp" placeholder "shade": attachment "shade"
4544
+ wants region "shade", which this build's atlas does not have (it declares no region
4545
+ at all). `explain` poses the rig to measure its deform keys and a pose resolves
4546
+ every attachment against the atlas, so there is nothing to pose it against. Art
4547
+ reaches a compile two ways and this run took neither: `--atlas-in <pack.atlas>`
4548
+ resolves the parts against a pack somebody already made, and an "image" per
4549
+ attachment resolves them as loose PNGs under `--images <dir>` — a spec that states
4550
+ a size and names no image is what `ingest --art none` writes, and `--atlas-in` is
4551
+ what reads it. 1 of 1 attachment lookup(s) here resolve to no region.
4552
+ ```
4553
+
4554
+ Both halves of it are measured rather than fixed text: the count is this pair's, and
4555
+ where the atlas has a **near miss** the sentence prints that too, in `A08`'s own
4556
+ clause and `A08`'s own words. When `--atlas-in` **was** given and the region is still
4557
+ absent, the second half names the pack instead and asks you to fix the spec's region
4558
+ name or point the flag at the pack that has it. Until
4559
+ [#697](https://github.com/firejune/rigc/issues/697) there was no rigc sentence at
4560
+ all: the report printed in full and the run then died inside `AtlasAttachmentLoader`
4561
+ with `Region not found in atlas: shade (attachment: shade)` and a spine-core stack
4562
+ trace, at exit 1 — the runtime's message about rigc's internals standing in for
4563
+ rigc's message about your two files.
4564
+
4468
4565
  ### 5.2 Assertions — the gate
4469
4566
 
4470
4567
  The report prints one line per assertion:
@@ -4545,7 +4642,7 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
4545
4642
  | `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out`. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) — as it is for `A06`, `A19` and `A27`; see `A07` ([#608](https://github.com/firejune/rigc/issues/608)) |
4546
4643
  | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
4547
4644
  | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)) **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
4548
- | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. Under `spine-html` also: an unweighted mesh, or a binding at weight 0. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
4645
+ | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. Under `spine-html` also: an unweighted mesh, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not ([#684](https://github.com/firejune/rigc/issues/684)). Fix the rig spec's `controls`/`chain`, or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
4549
4646
  | `A21_MESH_RIM_PINNED` | archetype | a generated ring's rim, a ribbon's entry row, or a contour's outline (which is all of it) is not pinned to its anchor bone at weight 1 |
4550
4647
  | `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
4551
4648
  | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | both | a physics constraint that drives no component, is muted by `mix: 0`, has `mass: 0`, has `strength: 0`, or has `damping` outside `(0, 1)` so it never settles — **at rest, and on every physics timeline key** ([#610](https://github.com/firejune/rigc/issues/610)). The timeline arm reads each key through the runtime's own `PhysicsConstraint*Timeline.set`, so a keyed `mass` is judged as the `massInverse` it becomes, and the detail names the animation, the constraint, the key time, the value and the bound. One difference between the two arms, and the runtime is the reason for it: a **key** of `mix: 0` is accepted, because `update` opens with `if (mix === 0) return;` and muting a constraint for a stretch is what a mix timeline is for — the editor's own `sack-pro` example keys it there on 24 of its 36 mix keys. `inertia`, `wind`, `gravity` and the top of `mix` are bounded nowhere, at rest or keyed. **SKIP** when the skeleton declares no physics constraint ([#580](https://github.com/firejune/rigc/issues/580)) — the same sentence `A36` and `A37` have always printed for their own constraint types |
@@ -5937,6 +6034,13 @@ There are two matchers and the `how` column says which one answered:
5937
6034
  confidence: how much better the winning position was than the best rival inside
5938
6035
  the search window. This is what gives a shot like a chain of touching links any
5939
6036
  drift at all — under connected components alone, every frame of it is ambiguous.
6037
+ ⭐ **Only the pixels of it your own composite lets show are correlated**: a
6038
+ template pixel you draw something over cannot match the reference wherever the
6039
+ slot really is, so it adds the same residual at every offset — and, because
6040
+ sliding the template moves those samples onto other pixels, their gradient
6041
+ decides the winner wherever the visible basin is shallow. That is not a
6042
+ hypothetical: it is what made four of the seven examples in this repository
6043
+ report 0.8–2.2 px against frames rendered from themselves (issue #698).
5940
6044
 
5941
6045
  ⚠️ **Both matchers are capped, and a blank is a real answer.** A part can be
5942
6046
  displaced by about its own size and still be that part in the picture; past that,
@@ -5946,6 +6050,23 @@ the 47 px course as its drift. The bar rises with the distance being claimed: a
5946
6050
  peak sitting where you already drew the slot only has to confirm it, a peak
5947
6051
  claiming the part moved most of a radius has to be distinctive to be believed.
5948
6052
 
6053
+ ⚠️ **And a slot you cover completely has no drift to report at all.** If every
6054
+ pixel a slot draws is painted over by something later in your own draw order,
6055
+ none of its ink reaches the picture, so there is nothing to correlate — `check`
6056
+ says so by name and counts it out, rather than correlating hidden pixels against
6057
+ whatever is on top of them:
6058
+
6059
+ ```
6060
+ its drift is not measurable — every one of the 2116 px this 48x54 px slot draws is
6061
+ covered by something the candidate draws over it, so none of its own ink reaches the
6062
+ picture to be correlated against
6063
+ ```
6064
+
6065
+ That is a fact about the shot and not an error: a part behind another part is
6066
+ still where the rig put it, and the answer to *where did it land* is that this
6067
+ run cannot say. Read it beside the `slots` column, which counts it as
6068
+ unattributed.
6069
+
5949
6070
  The `slots` column is how many of the slots you drew got an answer at all, and the
5950
6071
  summary line carries the same denominator. `N reference component(s) no slot
5951
6072
  reaches` means the reference frame contains something none of your slots overlaps:
@@ -5969,25 +6090,67 @@ bun cli.ts check --candidate <build> --frames <frames>
5969
6090
  frames 11 on disk, candidate samples 11, 11 compared
5970
6091
  MAE mean 0.00 worst 0.00 (exact: none of the 11 compared frame(s) differs from the reference) (0..255 over the union alpha; over the whole frame, mean 0.00)
5971
6092
  ⤷ over the REFERENCE's own drawn pixels, mean 0.00 — the union figure compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.
5972
- slot drift worst 0.4 px "arm_b" at f0007
6093
+ slot drift worst 0.5 px "plate" at f0004
6094
+ ⤷ bounded by 0.71 px — the correlation put this slot where the candidate drew it, so the whole figure is the sub-pixel step, clamped to 0.5 px on each axis
5973
6095
  per-frame all 10 adjacent pair(s) change by as much as the reference's own frames do
5974
6096
  ```
5975
6097
 
5976
6098
  ⚠️ **The MAE floor is zero and the slot-drift floor is not**, and the second half of
5977
6099
  that is the instrument's own arithmetic rather than anything about your rig. Both
5978
6100
  sides are the same pixels, so every frame's MAE is exactly 0 — which is why the line
5979
- says `(exact)` instead of naming a frame. The drift is a **correlation**, and its
5980
- last step fits a parabola through three whole-pixel residuals and takes its vertex,
5981
- clamped to half a pixel on each axis; so an identity run can report up to
5982
- `hypot(0.5, 0.5) = 0.71 px` and no more. ⭐ **It is not zero because the template is
5983
- your slot drawn *alone* and the reference is the composite**: wherever a neighbour
5984
- covers part of the slot, the residual surface around the true minimum is asymmetric
5985
- and the parabola's vertex sits a fraction of a pixel off it. That fraction is the
5986
- floor, it is per slot, and it is bounded — **a drift above 0.71 px on an identity run
5987
- is a defect in `check`, not a property of it.** This example read 3.7 px until issue
5988
- #678: the coarse sweep started at `−radius` and stepped by its stride, so the
5989
- identity offset was on the lattice only when the stride divided the radius, and the
5990
- `±1` refinement around a winner two pixels out could not reach back to it.
6101
+ says `(exact)` instead of naming a frame. The drift is a **correlation**: it finds
6102
+ the whole-pixel offset that matches best and then fits a parabola through three
6103
+ whole-pixel residuals for the fraction, clamped to half a pixel on each axis.
6104
+
6105
+ 🔑 **So the bound has two halves, and the run states both of them for you.** The
6106
+ `⤷ bounded by` line is not a constant off this page: it is `hypot(|dx| + 0.5,
6107
+ |dy| + 0.5)` for that match's own whole-pixel winner `(dx, dy)`. A winner at the
6108
+ offset you drew — which is the answer on every frame of a correct rig — bounds the
6109
+ whole figure at `hypot(0.5, 0.5) = 0.71 px`, and the line says the figure is the
6110
+ sub-pixel step and nothing else. A winner one pixel out bounds it at 1.58 px and
6111
+ says the correlation moved the part. ⇒ **Read a drift against the line under it,
6112
+ never against a number from a page about another rig.** A component match prints
6113
+ the other sentence — two centroids have no such bound, only the search radius.
6114
+
6115
+ 🚨 **The clamp bounds the figure only while the whole-pixel winner is the
6116
+ identity, and for a while nothing checked that second half.** Issue #698: four of
6117
+ the seven examples in this repository read **0.81, 1.11, 2.13 and 2.21 px** against
6118
+ frames rendered from themselves, because the template carried the pixels the
6119
+ candidate draws *over itself*. Those match nothing wherever the slot really is, so
6120
+ they add a residual at every offset — and sliding the template moves them onto
6121
+ other pixels, so their gradient walks the winner off the origin wherever the
6122
+ visible basin is shallow. The exhaustive whole-pixel field for `flex`'s backdrop
6123
+ had its minimum at `(2, 0)` scoring 7.75 against the identity offset's 8.76.
6124
+ Correlating only what shows put all seven back on `(0, 0)`, and `C25`–`C30` of
6125
+ the repository's own selftest gate that over every example it ships — so the next
6126
+ one is gated by arriving.
6127
+
6128
+ 🔸 The same page said `3.7 px` before issue #678, for an unrelated reason worth
6129
+ keeping: the coarse sweep started at `−radius` and stepped by its stride, so the
6130
+ identity offset was on the lattice only when the stride divided the radius, and
6131
+ the `±1` refinement around a winner two pixels out could not reach back to it.
6132
+
6133
+ **A second example, and the one the repair was measured on.** `gallery/flex` draws
6134
+ a banner and a leaf over a full-stage backdrop, so almost every slot of it reaches
6135
+ the template matcher:
6136
+
6137
+ ```bash
6138
+ bun cli.ts build --rig gallery/flex/rig.json --motion gallery/flex/motion.json --out <build>
6139
+ bun cli.ts render --candidate <build> --animation wave --fps 12 --out <frames>
6140
+ bun cli.ts check --candidate <build> --frames <frames>
6141
+ ```
6142
+
6143
+ ```
6144
+ ── wave — candidate animation "wave", 12 fps ──
6145
+ frames 30 on disk, candidate samples 30, 30 compared
6146
+ MAE mean 0.00 worst 0.00 (exact: none of the 30 compared frame(s) differs from the reference) (0..255 over the union alpha; over the whole frame, mean 0.00)
6147
+ ⤷ over the REFERENCE's own drawn pixels, mean 0.00 — the union figure compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.
6148
+ slot drift worst 0.2 px "plate" at f0009
6149
+ ⤷ bounded by 0.71 px — the correlation put this slot where the candidate drew it, so the whole figure is the sub-pixel step, clamped to 0.5 px on each axis
6150
+ per-frame all 29 adjacent pair(s) change by as much as the reference's own frames do
6151
+ ```
6152
+
6153
+ That figure was **2.21 px** on the same command before #698, on the same slot.
5991
6154
 
5992
6155
  ⭐ **And the same frames plus two deliberately wrong builds are what say which column
5993
6156
  answers which question.** Each differs from the build above in exactly one way —
@@ -6029,6 +6192,15 @@ samples, so the column is correctly silent on it. ⇒ Read the pair together: a
6029
6192
  MAE with `per-frame` silent is a pose in the wrong place or at the wrong moment; a
6030
6193
  loud MAE with `per-frame` firing is a **speed**, which is a curve.
6031
6194
 
6195
+ 🔸 Neither block above reprints its own `⤷ bounded by` line, for the same reason
6196
+ both carry the declaration: the spec edits are not in the tree, so those two lines
6197
+ would be hand-written rather than taken. What the gate measures on them is stated
6198
+ instead — the worst match of each sits at whole-pixel `(0, −31)` and `(0, −32)`,
6199
+ which bounds them at **31.50 px** and **32.50 px**. That is the contrast worth
6200
+ carrying away: a correct rig's drift is bounded by the clamp because its winner is
6201
+ the identity, and these two are bounded by nothing of the kind because a part
6202
+ really moved.
6203
+
6032
6204
  **The `chains` block is the same two measures on the unit you actually repair.**
6033
6205
 
6034
6206
  ```
package/docs/INGEST.md CHANGED
@@ -394,7 +394,10 @@ rigc explain examples/spineboy/export/spineboy-pro.json
394
394
  rigc: give either --cut <name> --cuts <cuts.json>, or --rig/--motion/--out
395
395
  ```
396
396
 
397
- It takes the same arguments as `build` minus `--profile`, prints the resolved account
397
+ It takes `--rig`, `--motion`, `--out`, and optionally `--manifest`, `--images` and
398
+ `--atlas-in` — `build`'s spec-reading flags, and not the ones that decide what `build`
399
+ *writes* (`--pack`, `--page-size`, `--padding`, `--copy-images`) or the one that gates
400
+ (`--profile`). It prints the resolved account
398
401
  of **your** two spec files, and never gates. Which makes it a §2 instrument rather
399
402
  than a §1 one — the thing you run to compare what you transcribed against the export
400
403
  you transcribed it from, by eye:
@@ -497,7 +500,12 @@ of this section**, with its gutter, its effect on the exit code and what to do.
497
500
 
498
501
  And two flags for what the skeleton also does not encode: `--art loose` (the default)
499
502
  names an `image` per attachment resolved against loose PNGs, `--art none` states
500
- `width`/`height` for `build --atlas-in`; and under `loose`, `--images <dir>` writes
503
+ `width`/`height` for `build --atlas-in` — and for `explain --atlas-in`, which is the
504
+ same pack read for a report rather than for an artifact: `explain` **poses** the rig
505
+ to print its `DEFORM` block, a pose resolves every attachment against an atlas, and a
506
+ size-only spec carries none of its own, so without the flag that pair is refused by
507
+ name rather than posed ([#697](https://github.com/firejune/rigc/issues/697)); and
508
+ under `loose`, `--images <dir>` writes
501
509
  the rig spec's own images directory relative to `--out`, so the rebuild is a plain
502
510
  `build --rig … --motion … --out …` rather than one carrying `--images` forever. It is
503
511
  refused together with `--art none`, which writes no `image` for a directory to be the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.25.5",
3
+ "version": "0.25.6",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/atlas.ts CHANGED
@@ -405,10 +405,19 @@ export function parseAtlasText(text: string): ParsedAtlas {
405
405
  * field it re-emits, and the ones it did not understand would quietly vanish
406
406
  * (`scale:` is the expensive example — [`atlasScales`](render.ts) reports it, and
407
407
  * a pack that is coarser than its drawings would stop saying so).
408
+ *
409
+ * `rename` is handed the page's INDEX as well as its name, because a name is not
410
+ * a key: nothing in the format forbids two pages spelling the same path, and a
411
+ * caller that has already decided one new name per page (`copyAtlasPages` in
412
+ * [`emit.ts`](emit.ts) assigns the copies' filenames in page order) would then
413
+ * hand both of them the first decision. The index is the page's identity here;
414
+ * the name is data.
408
415
  */
409
- export function rewritePageNames(parsed: ParsedAtlas, rename: (name: string) => string): string {
416
+ export function rewritePageNames(parsed: ParsedAtlas, rename: (name: string, index: number) => string): string {
410
417
  const out = parsed.lines.slice();
411
- for (const page of parsed.pages) out[page.nameLine] = rename(page.name);
418
+ parsed.pages.forEach((page, index) => {
419
+ out[page.nameLine] = rename(page.name, index);
420
+ });
412
421
  return out.join('\n');
413
422
  }
414
423