@rig-c/parts 1.13.1 → 1.15.0

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/STABILITY.md CHANGED
@@ -127,7 +127,7 @@ part name, `[i]` an entry of a list):
127
127
  - **meshes.<part>** — exactly one of `grid`, `contour`, `auto`; required in every mode: `r`, `segments`; optional in every mode: `exponent`, `rule`; optional with `contour` or `auto`: `ribs`; optional with `grid` or `contour`: `influences` (issue #284; beside `auto` it is refused, CONFIG_INFLUENCES_MODE); optional with `grid`: `stray` (issue #303; beside `contour` or `auto` it is refused, CONFIG_STRAY_MODE)
128
128
  - **meshes.<part>.influences** — optional, at least one: `maxInfluences` (a whole number, 1 or more, or null for no cap), `floor` ("cut" or "continuous")
129
129
  - **meshes.<part>.ribs.<chain>** — required: `stations`
130
- - **meshes.<part>.contour** — required: `tolerance`, `margin`, `spacing`; optional: `maxEdge`, `budget`, `stray`, `regions`
130
+ - **meshes.<part>.contour** — required: `tolerance`, `margin`, `spacing`; optional: `maxEdge`, `budget`, `stray`, `bridges`, `regions`
131
131
  - **meshes.<part>.contour.regions[i]** — required: `name`, `shape`, `bone`, `spacing`, `band`, and `cx`, `cy`, `r` for a circle or `points` for a polygon
132
132
  - **meshes.<part>.auto** — required: `source`, `sourceBounds`, `targets`, `influences`, `budget`, `minArtSamples`, `motion`; optional: `protect`, `regions`, the three Stage B opt-ins `boundaryRuns`, `retriangulate`, `removalOrder`, and `stimulus`
133
133
  - **meshes.<part>.auto.source** — required: `tolerance`, `margin`, `spacing`; optional: `stray`
@@ -171,11 +171,11 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
171
171
  | --- | --- | --- |
172
172
  | `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from` (`<run>:<tag>` with a See-through v3 tag, `<run>:author:<label>` for a layer the author made — issue #256, read by no role rule — or `painting:<name>` for a patch), `cut_from` on a cut's piece only (the plan part it was cut from, issue #230), the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite`, and `scene` (`figure_alpha_above`, `figure_px`, `figure_mask`) under `config.scene` only, with the figure's mask `figure_rig.png` beside the recomposite (issue #242) |
173
173
  | `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts |
174
- | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions` (`params.maxEdge` and `contour.outlineCut` only when the mesh declares `contour.maxEdge`); or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `influences`, as declared, when a lattice or contour mesh declares it (after `exponent`, or where `exponent` would be); `stray` and `stray_cleared` when a lattice mesh declares `stray` (after `influences`, or where `influences` would be); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
175
- | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_border` after the seam figures when `parts.json` declares a scene (issue #242), `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
174
+ | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions` (`params.maxEdge` and `contour.outlineCut` only when the mesh declares `contour.maxEdge`; `params.bridges` and `contour.bridges` — `islands`, `units`, `joins`, and `faint` only when an island was left out as faint — only when it declares `contour.bridges`); or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `influences`, as declared, when a lattice or contour mesh declares it (after `exponent`, or where `exponent` would be); `stray` and `stray_cleared` when a lattice mesh declares `stray` (after `influences`, or where `influences` would be); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
175
+ | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_border` after the seam figures when `parts.json` declares a scene (issue #242), `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `ANIMATIONS_DO_NOT_FOLD` after them when `motion.json` holds an animation, the idle included — on every rig with an idle since issue #333 (issue #320: `status`, `animations`, `fps`, `physics_left_out`, `folding`, `folding_undeclared`, `declared_not_folding`, `bar`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
176
176
  | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `turn` with `--full`/`--head`, and `notes` |
177
177
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
178
- | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate`, `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
178
+ | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate` (with `bridges` — `units`, `joins` of `span` and `area_px2`, `area_px2`, `overdraw`, and `faint` only when an island was left out as faint — only when the candidate is a bridged one, issue #326), `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
179
179
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
180
180
  | `idle.png`, `idle-indexed.png`, `idle.gif` | `loop`, `build --loop` | APNG and GIF encodings of the rendered idle |
181
181
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.13.1",
3
+ "version": "1.15.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/build.ts CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { BARS, besideIdleLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
73
+ import { besideIdleLine, FOLD_LINE, type FoldsLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
74
  import { type Cut, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
75
  import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
@@ -277,6 +277,8 @@ export interface GateRun {
277
277
  * {@link causeLines} says name why.
278
278
  */
279
279
  lines: string[];
280
+ /** Which rigc ran it, when the runner said ({@link RigcCall.ran}; issue #337). */
281
+ ran?: string;
280
282
  }
281
283
 
282
284
  /**
@@ -295,7 +297,7 @@ function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
295
297
  const r = rigc(args);
296
298
  const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l) || DECLARED_SKIP.test(l) || HERE_LINE.test(l));
297
299
  const failed = lines.some((l) => /^ {2}FAIL {2}/.test(l) || /^rigc compile error/.test(l));
298
- return { label, status: r.status, lines: r.status !== 0 && !failed ? [...lines, ...causeLines(r.out)] : lines };
300
+ return { label, status: r.status, lines: r.status !== 0 && !failed ? [...lines, ...causeLines(r.out)] : lines, ...(r.ran === undefined ? {} : { ran: r.ran }) };
299
301
  }
300
302
 
301
303
  /**
@@ -490,7 +492,7 @@ export function motionGates(
490
492
  {
491
493
  code: 'AUTO_MESH_MOTION_INPUT',
492
494
  object: c.object,
493
- detail: `the candidate with the declared stimulus (the rig as written, its motion carrying the stimulus animations) ${candidate.status !== 0 ? `is refused by rigc's gate, exit ${candidate.status}${candidate.lines.length > 0 ? `: ${candidate.lines.map((l) => l.trim()).join(' | ')}` : ''}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
495
+ detail: `the candidate with the declared stimulus (the rig as written, its motion carrying the stimulus animations) ${candidate.status !== 0 ? `is refused by rigc's gate, exit ${candidate.status}${candidate.lines.length > 0 ? `: ${candidate.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(candidate)}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
494
496
  },
495
497
  ],
496
498
  notCompared: 'the stimulus candidate build is red',
@@ -520,7 +522,7 @@ export function motionGates(
520
522
  {
521
523
  code: 'AUTO_MESH_MOTION_INPUT',
522
524
  object: c.object,
523
- detail: `the reference (the rig with "${c.part}" as its unreduced source mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a reference that is not gated is not evidence, and nothing is built in the part's place`,
525
+ detail: `the reference (the rig with "${c.part}" as its unreduced source mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(gate)}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a reference that is not gated is not evidence, and nothing is built in the part's place`,
524
526
  },
525
527
  ],
526
528
  notCompared: 'the reference build is red',
@@ -642,7 +644,7 @@ export function replaySearch(
642
644
  fatal = {
643
645
  code: 'AUTO_MESH_MOTION_INPUT',
644
646
  object: c.object,
645
- detail: `the replay to accepted step ${step} (the rig with "${c.part}" as that step's mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
647
+ detail: `the replay to accepted step ${step} (the rig with "${c.part}" as that step's mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(gate)}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
646
648
  };
647
649
  return refused(`rigc exit ${gate.status}`);
648
650
  }
@@ -897,7 +899,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
897
899
  const problems: Problem[] = red.map((g) => ({
898
900
  code: 'RIG_RIGC_GREEN',
899
901
  object: `rigc ${g.label}`,
900
- detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ', and it printed nothing'}; ${ctlRemedies(g.lines, rig.controls, input.command ?? 'rig').map((s) => `${s}; `).join('')}exit 0 is required before anything is written, and nothing was`,
902
+ detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ', and it printed nothing'}${rigcRanClause(g)}; ${ctlRemedies(g.lines, rig.controls, input.command ?? 'rig').map((s) => `${s}; `).join('')}exit 0 is required before anything is written, and nothing was`,
901
903
  }));
902
904
  throw new PartsError(problems);
903
905
  }
@@ -972,13 +974,15 @@ function showFigure(v: unknown): string {
972
974
  }
973
975
 
974
976
  /**
975
- * How many of the {@link BARS} measured and how many said SKIP, by name — the
977
+ * How many of the bars measured and how many said SKIP, by name — the
976
978
  * summary's second half (issue #77): `PASS` reads only the bars that measured,
977
- * so a run that measured one bar says so on the line that says PASS.
979
+ * so a run that measured one bar says so on the line that says PASS. The
980
+ * count is every bar `check` tallied: the `BARS` of `src/check.ts`, and
981
+ * `ANIMATIONS_DO_NOT_FOLD` where it is written (issue #320).
978
982
  */
979
983
  export function barsSummary(r: CheckReport): string {
980
984
  const { measured, skipped } = r.bars;
981
- return `${measured.length} of ${BARS.length} bar(s) measured, ${skipped.length} skipped${skipped.length === 0 ? '' : ` (${skipped.map((s) => s.bar).join(', ')})`}`;
985
+ return `${measured.length} of ${measured.length + skipped.length} bar(s) measured, ${skipped.length} skipped${skipped.length === 0 ? '' : ` (${skipped.map((s) => s.bar).join(', ')})`}`;
982
986
  }
983
987
 
984
988
  /**
@@ -1028,6 +1032,20 @@ export function judgementLine(name: string, line: JudgementLine | ReportedLine):
1028
1032
  .join('; ')}`;
1029
1033
  }
1030
1034
 
1035
+ /**
1036
+ * The fold line (issues #320, #333) as the console prints it: the status, what was
1037
+ * read and at what rate, and then each folding mesh on a line of its own,
1038
+ * rigc's own words, with the declaration's why where `may_fold` takes it.
1039
+ */
1040
+ export function foldsLines(line: FoldsLine): string[] {
1041
+ const physics = line.physics_left_out === 0 ? '' : `, its ${line.physics_left_out} physics constraint(s) left out`;
1042
+ const head =
1043
+ `${FOLD_LINE}: ${line.status} — ${line.animations.length} animation(s) read by rigc render --folds at ${line.fps} fps${physics} (${line.animations.join(', ')}): ` +
1044
+ `${line.folding.length} folding mesh(es), ${line.folding_undeclared} not declared in may_fold (<= ${line.bar})` +
1045
+ (line.declared_not_folding.length === 0 ? '' : `; may_fold declares ${line.declared_not_folding.map((d) => `slot "${d.slot}" in "${d.animation}"`).join(', ')}, which do(es) not fold`);
1046
+ return [head, ...line.folding.map((r) => ` folds ${r.line}${r.declared === null ? '' : ` — declared in may_fold: ${r.declared}`}`)];
1047
+ }
1048
+
1031
1049
  /** A requirement's line as the console prints it: its name and status, the reason first when NOT MEASURABLE, then its figures as check.json holds them. */
1032
1050
  export function requirementText(name: string, line: RequirementLine): string {
1033
1051
  const rest = Object.entries(line).filter(([k]) => k !== 'status' && k !== 'reason');
@@ -1044,7 +1062,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1044
1062
  log(`rig-parts check: ${input.rig} -> ${input.out}`);
1045
1063
  const versionLines = v.out.trim().split('\n');
1046
1064
  const entryLine = versionLines.find((l) => l.startsWith('entry:'));
1047
- log(` rigc ${versionLines[0]} at ${bin}${entryLine === undefined ? '' : `; ${entryLine}`}`);
1065
+ log(` ${rigcIdentity(bin, v)}${entryLine === undefined ? '' : `; ${entryLine}`}`);
1048
1066
  const mode: PackMode = { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE };
1049
1067
  const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source, input.requirements, input.seamPairs === true, input.keepStills === true);
1050
1068
  log(` gate spine-html (rigc ${packedBuildLabel(mode)}), verbatim:`);
@@ -1052,7 +1070,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1052
1070
  for (const l of packLines(r)) log(` ${l}`);
1053
1071
  const fig = r.figures;
1054
1072
  if (r.idle === null) log(` loop: SKIP — ${fig.skipped?.loop ?? ''}`);
1055
- else log(` loop: idle ${r.idle.frames} frame(s) at ${r.idle.fps} fps, f0000 vs f${String(r.idle.lastIndex).padStart(4, '0')} (t = ${r.idle.duration}s): max |d| ${fig.loop_max_diff} (0 required)`);
1073
+ else log(` ${loopCheckLine(r.idle, fig.loop_max_diff, fig.loop_physics?.length ?? 0)}`);
1056
1074
  if (r.loopPhysics !== null) log(` ${r.loopPhysics}`);
1057
1075
  const beside = besideIdleLine(r.besideIdle);
1058
1076
  if (beside !== null) log(` ${beside}`);
@@ -1066,6 +1084,8 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1066
1084
  pairs.forEach((row, i) => log(` seam pair ${i + 1} of ${pairs.length}: ${seamPairText(row)}`));
1067
1085
  }
1068
1086
  for (const name of JUDGEMENT_LINES) log(` ${judgementLine(name, fig[name])}`);
1087
+ const folds = fig[FOLD_LINE];
1088
+ if (folds !== undefined) for (const l of foldsLines(folds)) log(` ${l}`);
1069
1089
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
1070
1090
  const vsSource = fig[SOURCE_LINE];
1071
1091
  if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
@@ -1216,12 +1236,49 @@ function pixelsOver(a: Raster, b: Raster, level: number): number {
1216
1236
  return n;
1217
1237
  }
1218
1238
 
1239
+ /**
1240
+ * `check`'s `loop:` line (issue #325). With physics constraints in the rig the
1241
+ * comparison it reports was made with them left out (issue #183), and the line
1242
+ * says so itself rather than leaving it to the `loop physics:` line below: the
1243
+ * loop stage compares the frames rendered with them, and on a rig with physics
1244
+ * those ends differ although this line reads max |d| 0.
1245
+ */
1246
+ export function loopCheckLine(idle: { frames: number; fps: number; duration: number; lastIndex: number }, max: number | null, physics: number): string {
1247
+ const left = physics === 0 ? '' : ` with the rig's ${physics} physics constraint(s) left out (named on the \`loop physics:\` line; rendered with them, the ends need not meet — \`build --loop --lead-in <n>\`)`;
1248
+ return `loop: idle ${idle.frames} frame(s) at ${idle.fps} fps, f0000 vs f${String(idle.lastIndex).padStart(4, '0')} (t = ${idle.duration}s)${left}: max |d| ${String(max)} (0 required)`;
1249
+ }
1250
+
1251
+ /**
1252
+ * The loop stage's line when the end frame is not frame 0 (issue #325): the
1253
+ * jump as measured (max |d| over R, G and B, and pixels over 40 — the lead-in
1254
+ * line's figures), then why. `physics` is how many physics constraints the rig
1255
+ * behind the frames holds; `build --loop` knows it from `check`, while
1256
+ * `loop --frames` reads frames alone and does not, so its line names both
1257
+ * causes. With physics, rigc's render starts every constraint at rest at frame
1258
+ * 0, so the idle does not end where it starts, and `--lead-in` is the remedy;
1259
+ * with none, the idle itself does not close, which `check`'s `loop:` line
1260
+ * measures — under `build`, which stops at that bar, the branch is reached
1261
+ * only by a difference the bar does not read.
1262
+ */
1263
+ export function loopJumpLine(lastName: string, max: number, over40: number, physics: number | undefined): string {
1264
+ const jump = `${lastName} differs from f0000.png by max |d| ${max}, ${over40} px over 40, so every frame is kept and the loop will jump at the wrap`;
1265
+ const remedy = '`build --loop --lead-in <n>` plays the idle n periods ahead and keeps the last (docs/AUTHORING.md §5, *build --loop --lead-in*)';
1266
+ if (physics === undefined) {
1267
+ return `${jump}; the frames alone do not say why — if their rig holds a physics constraint, rigc's render starts it at rest at frame 0 and ${remedy}; if it holds none, the idle itself does not close, which \`check\`'s \`loop:\` line (CHECK_LOOP_CLOSES) measures`;
1268
+ }
1269
+ if (physics === 0) {
1270
+ return `${jump}: the rig holds no physics constraint, so the idle itself does not close — \`check\`'s \`loop:\` line (CHECK_LOOP_CLOSES) measures the same two frames, and \`--lead-in\`, which settles physics, is not the remedy`;
1271
+ }
1272
+ return `${jump}: the rig holds ${physics} physics constraint(s), and rigc's render starts every one at rest at frame 0, so the idle does not end where it starts — \`check\`'s \`loop:\` line read its ends with them left out; ${remedy}`;
1273
+ }
1274
+
1219
1275
  /**
1220
1276
  * Encode a rigc frame set as a looping APNG, indexed APNG or GIF and write it.
1221
1277
  * An encoder refusal is a PartsError coded `LOOP_ENCODE`, so it prints like
1222
- * every other.
1278
+ * every other. `physics` is how many physics constraints the frames' rig holds,
1279
+ * when the caller knows it ({@link loopJumpLine}).
1223
1280
  */
1224
- export function loopStage(frames: string, out: string, format: LoopFormat, log: Log, window?: LoopWindow): LoopResult {
1281
+ export function loopStage(frames: string, out: string, format: LoopFormat, log: Log, window?: LoopWindow, physics?: number): LoopResult {
1225
1282
  const set = readFrameSet(frames);
1226
1283
  const images = set.frames.map((fr) => fr.image);
1227
1284
  log(`rig-parts loop: ${set.dir} -> ${out}`);
@@ -1243,7 +1300,8 @@ export function loopStage(frames: string, out: string, format: LoopFormat, log:
1243
1300
  used = images.slice(0, -1);
1244
1301
  log(` ${lastName} equals f0000.png byte for byte, so it is dropped: the loop wraps onto frame 0, and showing it twice would hold that pose for two ticks`);
1245
1302
  } else if (images.length > 1) {
1246
- log(` ${lastName} differs from f0000.png, so every frame is kept and the loop will jump at the wrap`);
1303
+ const last = images[images.length - 1];
1304
+ log(` ${loopJumpLine(lastName, maxRgbDiff(images[0], last).max, pixelsOver(images[0], last, 40), physics)}`);
1247
1305
  }
1248
1306
  const anim: AnimFrame[] = used.map((image) => ({ image, ticks: 1 }));
1249
1307
  log(` ${anim.length} frame(s) encoded, ${(anim.length / set.fps).toFixed(3)}s per loop, looping forever`);
@@ -1462,9 +1520,10 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1462
1520
  const window = input.leadIn === undefined ? undefined : { periods: input.leadIn };
1463
1521
  if (window !== undefined) rmSync(run.scratch, { recursive: true, force: true });
1464
1522
  const frames = window === undefined ? join(out, 'check', 'idle_frames') : leadInFrames(join(out, 'rig'), run.scratch, window.periods, run.check, { pageEdges: input.pageEdges, packShape: input.packShape ?? DEFAULT_PACK_SHAPE });
1465
- const lossless = loopStage(frames, join(out, 'idle.png'), 'apng', say, window);
1466
- const indexed = loopStage(frames, join(out, 'idle-indexed.png'), 'indexed', say, window);
1467
- const gif = loopStage(frames, join(out, 'idle.gif'), 'gif', say, window);
1523
+ const physics = report.figures.loop_physics?.length ?? 0;
1524
+ const lossless = loopStage(frames, join(out, 'idle.png'), 'apng', say, window, physics);
1525
+ const indexed = loopStage(frames, join(out, 'idle-indexed.png'), 'indexed', say, window, physics);
1526
+ const gif = loopStage(frames, join(out, 'idle.gif'), 'gif', say, window, physics);
1468
1527
  if (window !== undefined) rmSync(run.scratch, { recursive: true, force: true });
1469
1528
  const fig = (e: PaletteError | null): string => (e === null ? 'lossless' : `max ${e.max}, mean ${e.mean.toFixed(3)}`);
1470
1529
  say(`loop: idle.png ${lossless.bytes} B (${fig(lossless.error)}); idle-indexed.png ${indexed.bytes} B (${fig(indexed.error)}); idle.gif ${gif.bytes} B (${fig(gif.error)}) — palette error per channel over all frames`);