rig-c 0.0.0-stage → 2.20.4
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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
|
@@ -0,0 +1,2776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every rigc entry shares (issue #1052, step 4e of #380): the argument
|
|
3
|
+
* parser, the readers of the files a command line names, the helpers more
|
|
4
|
+
* than one command calls, every command's documentation and the dispatch —
|
|
5
|
+
* moved here unchanged from `cli.ts`, which is now one entry over it.
|
|
6
|
+
*
|
|
7
|
+
* ⭐ A command is documented once (`COMMANDS`), and the documentation says,
|
|
8
|
+
* as data, what the command's body needs (`runtime`, `spineFormat`). An entry
|
|
9
|
+
* is the documentation plus the bodies it registers (`runCli`): `cli.ts`
|
|
10
|
+
* registers every command, and `cli_core.ts` registers only those whose
|
|
11
|
+
* `runtime` is `false` — its set is read off this table, its `--help` prints
|
|
12
|
+
* that set, and a command outside it is refused naming what it needs the
|
|
13
|
+
* runtime for. Nothing in this file links spine-core, and neither does
|
|
14
|
+
* anything it imports: the bodies that reach the runtime are
|
|
15
|
+
* `./spine_commands.ts`'s, which only `cli.ts` imports.
|
|
16
|
+
*/
|
|
17
|
+
import { type AtlasRegion, DEFAULT_PACK_SHAPE, DEFAULT_PADDING, DEFAULT_PAGE_EDGES, DEFAULT_PAGE_SIZE, PACK_SHAPES, packAtlas, type PackInput, packFootprints, type PackShape, PAGE_EDGES, type PageEdges, pageFootprint } from '../atlas.ts';
|
|
18
|
+
import { copyAtlasPages, plannedPageCopies } from '../emit.ts';
|
|
19
|
+
import {
|
|
20
|
+
BUILD_REPORT_SPEC,
|
|
21
|
+
type BuildReportDocument,
|
|
22
|
+
type BuildReportGate,
|
|
23
|
+
type BuildReportSupplier,
|
|
24
|
+
buildReportGate,
|
|
25
|
+
buildReportText,
|
|
26
|
+
CLI_DEFAULT_PROFILE,
|
|
27
|
+
type GateHere,
|
|
28
|
+
type PackPageFigures,
|
|
29
|
+
VALIDATE_PROFILES,
|
|
30
|
+
} from '../assertions/report.ts';
|
|
31
|
+
import type { VerdictLists } from '../assertions/harness.ts';
|
|
32
|
+
import type { AssertionProfile } from '../assertions/kinds.ts';
|
|
33
|
+
import { MAX_CANDIDATES, MIN_CANDIDATES } from '../ballot.ts';
|
|
34
|
+
import { BONEDIST_SPEC, IDENTITY_CORRESPONDENCE } from '../correspondence.ts';
|
|
35
|
+
import { ANCHOR_MAX_RESIDUAL, ANCHOR_MAX_UNEXPLAINED, DEFAULT_HINGE_MAX, DEFAULT_HINGE_MIN, DEFAULT_MIN_LEVER_PX, DEFAULT_MIN_VISIBLE, DEFAULT_PASSES } from '../chainfit.ts';
|
|
36
|
+
import { checkAgainstFrames, type CheckOptions, CheckPlates, type CheckReport } from '../check.ts';
|
|
37
|
+
import { compile, CompileError, droppedStateReason, headerBoundsOf, type CompileOptions } from '../compile.ts';
|
|
38
|
+
import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
|
|
39
|
+
import { parseJsonWithPosition } from '../json-position.ts';
|
|
40
|
+
import { RUNG_IDS } from '../ladder.ts';
|
|
41
|
+
import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
|
|
42
|
+
import { PACKAGE_ROOT, readPackageMeta, readVersion } from '../package_meta.ts';
|
|
43
|
+
import { DEFAULT_MAX_RESIDUAL, DEFAULT_SCALE_MAX, DEFAULT_SCALE_MIN } from '../pose.ts';
|
|
44
|
+
import {
|
|
45
|
+
CandidateAtlasError,
|
|
46
|
+
CandidatePairError,
|
|
47
|
+
GEOMETRY_FILE,
|
|
48
|
+
GeometryError,
|
|
49
|
+
POSER_NAMES,
|
|
50
|
+
PoserChoiceError,
|
|
51
|
+
type PoserName,
|
|
52
|
+
PROTOCOL_FPS,
|
|
53
|
+
UnframeablePoseError,
|
|
54
|
+
} from '../render_shared.ts';
|
|
55
|
+
import { BallotError } from '../ballot.ts';
|
|
56
|
+
import { ChainFitError } from '../chainfit.ts';
|
|
57
|
+
import { CheckError } from '../check.ts';
|
|
58
|
+
import { IngestError, type IngestStage } from '../ingest.ts';
|
|
59
|
+
import { NotAPngError } from '../png.ts';
|
|
60
|
+
import { PoseError } from '../pose.ts';
|
|
61
|
+
import { RepackError } from '../repack.ts';
|
|
62
|
+
import { SpineRuntimeError, SPINE_SIDE_ABSENT } from '../spine_side.ts';
|
|
63
|
+
import type { CompileResult, DroppedState } from '../types.ts';
|
|
64
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
65
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One entry of a cuts.json, every path relative to the cuts.json file.
|
|
70
|
+
*
|
|
71
|
+
* `rig` is required — it is the skeleton's structure, and until it was a file
|
|
72
|
+
* that structure was three hard-coded tables in the compiler. `manifest` is
|
|
73
|
+
* optional: a skeleton with no measured art behind it has none, and then the rig
|
|
74
|
+
* spec carries its own attachments and stage size.
|
|
75
|
+
*/
|
|
76
|
+
export interface CutEntry {
|
|
77
|
+
rig: string;
|
|
78
|
+
motion: string;
|
|
79
|
+
out: string;
|
|
80
|
+
manifest?: string;
|
|
81
|
+
/** Base directory for the rig spec's `image` references, if not the rig's own. */
|
|
82
|
+
images?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type CutTable = Record<string, CutEntry>;
|
|
86
|
+
|
|
87
|
+
export class UsageError extends Error {}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `explain` refusing a pair it cannot pose — a usage error in kind, printed
|
|
91
|
+
* without the usage block for `PoseError`'s and `IngestError`'s reason: the
|
|
92
|
+
* message names an attachment, a region and two flags, and reprinting every
|
|
93
|
+
* command's usage under it buries the one line that says what to change.
|
|
94
|
+
*/
|
|
95
|
+
export class ExplainError extends Error {}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// package metadata — the installed version and repository, for `--version`
|
|
99
|
+
// and for naming a remedy `bench` can only give from a repo checkout.
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
// The reader moved to `../package_meta.ts` (issue #1230), so a library module can read
|
|
103
|
+
// the version without loading the CLI; every name it had here is re-exported unchanged.
|
|
104
|
+
export { PACKAGE_ROOT, readPackageMeta, readVersion };
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// argument parsing
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The flags that are switches rather than `--flag value` pairs.
|
|
112
|
+
*
|
|
113
|
+
* Listed by name rather than inferred from "the next argument looks like a
|
|
114
|
+
* flag": inferring it would turn `--out --json report.json` — a real typo, a
|
|
115
|
+
* missing value — into a silently accepted switch plus a stray positional.
|
|
116
|
+
*
|
|
117
|
+
* ⚠️ This set and `FLAG_VALUES` are two halves of one statement, and they are
|
|
118
|
+
* the halves a reader and the parser read separately: a flag absent from
|
|
119
|
+
* `FLAG_VALUES` is printed bare in every usage line and flag table, and a flag
|
|
120
|
+
* present here is the only kind the parser will accept bare. `all-bones` was in
|
|
121
|
+
* one half and not the other for two releases — documented bare in `bonedist`'s
|
|
122
|
+
* usage line, in the shared flag table, and in the hint `src/bonedist.ts` prints
|
|
123
|
+
* under a truncated bone table, while the parser fell through to the value
|
|
124
|
+
* branch and answered the caller who followed that hint with `rigc: --all-bones
|
|
125
|
+
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
126
|
+
* halves together by reading `--help` rather than by naming a flag.
|
|
127
|
+
*/
|
|
128
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'accept-skeleton-differences']);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The flags a command is allowed to spell more than once.
|
|
132
|
+
*
|
|
133
|
+
* `vote --candidate` is, because a ballot is *by definition* several
|
|
134
|
+
* candidates; `preview --candidate` is, because a pane per candidate on one
|
|
135
|
+
* page is what an agent otherwise builds by hand (issue #837); and `diff --as`
|
|
136
|
+
* is, because a skeleton has as many shots as it has and one pairing per flag is the only spelling that keeps each pair a pair
|
|
137
|
+
* (issue #720). Everywhere else a repeat is a mistake and is refused: `check
|
|
138
|
+
* --candidate a --candidate b` used to take `b` silently, which is a report
|
|
139
|
+
* about a rig the caller did not think they were asking about.
|
|
140
|
+
*/
|
|
141
|
+
export const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
|
|
142
|
+
vote: new Set(['candidate']),
|
|
143
|
+
diff: new Set(['as']),
|
|
144
|
+
preview: new Set(['candidate']),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `--flag value` pairs plus the leftover positionals, in order.
|
|
149
|
+
*
|
|
150
|
+
* `lists` carries every occurrence of every flag and `flags` carries the last
|
|
151
|
+
* one, so a command that wants a repeated flag reads `lists` and the ones that
|
|
152
|
+
* do not are untouched by the addition.
|
|
153
|
+
*/
|
|
154
|
+
export function parseArgs(
|
|
155
|
+
argv: string[],
|
|
156
|
+
repeatable: ReadonlySet<string> = new Set(),
|
|
157
|
+
): { flags: Record<string, string>; lists: Record<string, string[]>; positional: string[] } {
|
|
158
|
+
const flags: Record<string, string> = {};
|
|
159
|
+
const lists: Record<string, string[]> = {};
|
|
160
|
+
const positional: string[] = [];
|
|
161
|
+
const take = (name: string, value: string): void => {
|
|
162
|
+
if (flags[name] !== undefined && !repeatable.has(name)) {
|
|
163
|
+
throw new UsageError(
|
|
164
|
+
`--${name} was given more than once (${JSON.stringify(flags[name])} then ${JSON.stringify(value)}); ` +
|
|
165
|
+
'this command takes it once',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
flags[name] = value;
|
|
169
|
+
(lists[name] ??= []).push(value);
|
|
170
|
+
};
|
|
171
|
+
for (let i = 0; i < argv.length; i++) {
|
|
172
|
+
const arg = argv[i];
|
|
173
|
+
if (arg.startsWith('--')) {
|
|
174
|
+
const eq = arg.indexOf('=');
|
|
175
|
+
if (eq !== -1) {
|
|
176
|
+
take(arg.slice(2, eq), arg.slice(eq + 1));
|
|
177
|
+
} else if (BOOLEAN_FLAGS.has(arg.slice(2))) {
|
|
178
|
+
take(arg.slice(2), 'true');
|
|
179
|
+
} else {
|
|
180
|
+
const next = argv[i + 1];
|
|
181
|
+
if (next === undefined || next.startsWith('--')) throw new UsageError(`${arg} needs a value`);
|
|
182
|
+
take(arg.slice(2), next);
|
|
183
|
+
i++;
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
positional.push(arg);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { flags, lists, positional };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read and parse a JSON file the caller named on the command line — a cuts
|
|
194
|
+
* table, a candidate or reference skeleton to `diff`. A parse failure names the
|
|
195
|
+
* file and, best-effort, where inside it the syntax broke (see
|
|
196
|
+
* `parseJsonWithPosition`); left as a raw `JSON.parse`, it would surface as an
|
|
197
|
+
* unhandled `SyntaxError` with a stack trace instead of a usage error.
|
|
198
|
+
*/
|
|
199
|
+
export function readJsonFile(path: string): unknown {
|
|
200
|
+
return parseJsonNamed(readFileSync(path, 'utf8'), path);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** `text`, read from `path`, parsed — and refused naming the file and where it broke when it is not JSON. */
|
|
204
|
+
export function parseJsonNamed(text: string, path: string): unknown {
|
|
205
|
+
try {
|
|
206
|
+
return parseJsonWithPosition(text);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw new UsageError(`cannot read ${path}: ${(err as Error).message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A skeleton a command was pointed at, as text — refused like every other JSON
|
|
214
|
+
* file on the command line (`parseJsonNamed`) when it is not JSON (issue
|
|
215
|
+
* #1042). `render`, `check`, `bench` and `bonedist` hand the text to a loader
|
|
216
|
+
* that parses it again, and on a file that is not JSON that parse surfaced as
|
|
217
|
+
* the runtime's `SyntaxError` and a stack, where `diff`, `preview`, `vote` and
|
|
218
|
+
* `ingest` already said `cannot read <path>`.
|
|
219
|
+
*/
|
|
220
|
+
export function readSkeletonText(path: string): string {
|
|
221
|
+
const text = readFileSync(path, 'utf8');
|
|
222
|
+
parseJsonNamed(text, path);
|
|
223
|
+
return text;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read a cuts.json and resolve its three paths against the file's own
|
|
228
|
+
* directory. Anchoring on the table rather than on the process cwd is what lets
|
|
229
|
+
* the same command work from anywhere in the owning project.
|
|
230
|
+
*/
|
|
231
|
+
function readCutTable(cutsPath: string): { dir: string; table: CutTable } {
|
|
232
|
+
const abs = resolve(cutsPath);
|
|
233
|
+
if (!existsSync(abs)) throw new UsageError(`no cuts file at ${abs}`);
|
|
234
|
+
const parsed = readJsonFile(abs);
|
|
235
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
236
|
+
throw new UsageError(`${abs}: expected an object of cut name -> { manifest, motion, out }`);
|
|
237
|
+
}
|
|
238
|
+
return { dir: dirname(abs), table: parsed as CutTable };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function entryToOptions(dir: string, name: string, entry: CutEntry): CompileOptions {
|
|
242
|
+
for (const key of ['rig', 'motion', 'out'] as const) {
|
|
243
|
+
if (typeof entry?.[key] !== 'string') throw new UsageError(`cut ${JSON.stringify(name)} has no "${key}" path`);
|
|
244
|
+
}
|
|
245
|
+
const opts: CompileOptions = {
|
|
246
|
+
rigPath: resolve(dir, entry.rig),
|
|
247
|
+
motionPath: resolve(dir, entry.motion),
|
|
248
|
+
outDir: resolve(dir, entry.out),
|
|
249
|
+
};
|
|
250
|
+
if (entry.manifest !== undefined) opts.manifestPath = resolve(dir, entry.manifest);
|
|
251
|
+
if (entry.images !== undefined) opts.imagesDir = resolve(dir, entry.images);
|
|
252
|
+
return opts;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve the cut a command was pointed at, either spelled out on the command
|
|
257
|
+
* line or looked up by name in a cuts.json.
|
|
258
|
+
*/
|
|
259
|
+
export function resolveCut(flags: Record<string, string>): { label: string; opts: CompileOptions } {
|
|
260
|
+
const explicit =
|
|
261
|
+
flags.rig !== undefined || flags.manifest !== undefined || flags.motion !== undefined || flags.out !== undefined;
|
|
262
|
+
if (explicit) {
|
|
263
|
+
if (flags.cut !== undefined || flags.cuts !== undefined) {
|
|
264
|
+
throw new UsageError('--rig/--motion/--out and --cut/--cuts are two ways to say the same thing; pick one');
|
|
265
|
+
}
|
|
266
|
+
for (const key of ['rig', 'motion', 'out'] as const) {
|
|
267
|
+
if (flags[key] === undefined) throw new UsageError(`--${key} is required when the cut is spelled out`);
|
|
268
|
+
}
|
|
269
|
+
const opts: CompileOptions = {
|
|
270
|
+
rigPath: resolve(flags.rig),
|
|
271
|
+
motionPath: resolve(flags.motion),
|
|
272
|
+
outDir: resolve(flags.out),
|
|
273
|
+
};
|
|
274
|
+
if (flags.manifest !== undefined) opts.manifestPath = resolve(flags.manifest);
|
|
275
|
+
if (flags.images !== undefined) opts.imagesDir = resolve(flags.images);
|
|
276
|
+
if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
|
|
277
|
+
return { label: flags.rig, opts };
|
|
278
|
+
}
|
|
279
|
+
if (flags.cut === undefined) throw new UsageError('give either --cut <name> --cuts <cuts.json>, or --rig/--motion/--out');
|
|
280
|
+
if (flags.cuts === undefined) throw new UsageError('--cut needs --cuts <cuts.json> to look the name up in');
|
|
281
|
+
const { dir, table } = readCutTable(flags.cuts);
|
|
282
|
+
const entry = table[flags.cut];
|
|
283
|
+
if (!entry) {
|
|
284
|
+
throw new UsageError(
|
|
285
|
+
`unknown cut ${JSON.stringify(flags.cut)} in ${resolve(flags.cuts)}. known: ${Object.keys(table).join(', ') || '(none)'}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const opts = entryToOptions(dir, flags.cut, entry);
|
|
289
|
+
// `--atlas-in` is not part of the cuts table: a cut names its rig, motion and
|
|
290
|
+
// manifest, and where the pixels are delivered from is a property of the BUILD.
|
|
291
|
+
// Resolved against the working directory, like every other path on the command
|
|
292
|
+
// line, rather than against the table's directory.
|
|
293
|
+
if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
|
|
294
|
+
return { label: flags.cut, opts };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* One line per mesh kind on this cut, printed above the table.
|
|
299
|
+
*
|
|
300
|
+
* A legend rather than a heading: the heading used to describe the ring tier
|
|
301
|
+
* unconditionally, so a build whose only mesh was a ribbon or a contour got a
|
|
302
|
+
* sentence about a rim ring and a seam it does not have.
|
|
303
|
+
*/
|
|
304
|
+
/**
|
|
305
|
+
* What a depth map and a soft region put on a mesh, when it named either.
|
|
306
|
+
*
|
|
307
|
+
* The digests are the reason this prints at all: a claim about a rig can name
|
|
308
|
+
* WHICH sheet produced it, and two runs a reader believes differ can be shown to
|
|
309
|
+
* have read the same pixels. The ranges and counts are what say the input
|
|
310
|
+
* reached the geometry rather than merely being resolved — a `carried 0` never
|
|
311
|
+
* gets here (it is refused) and a `ramped 0` is a hard-edged mask, which is
|
|
312
|
+
* legal and usually not what somebody meant.
|
|
313
|
+
*/
|
|
314
|
+
/**
|
|
315
|
+
* One axis's two ceilings, as `+31.41 / -18.03`, or what is unbounded on it.
|
|
316
|
+
*
|
|
317
|
+
* ⚠️ `none` and a number are different claims and are printed differently. A
|
|
318
|
+
* sheet with no gradient along an axis cannot fold anything on it AT ANY ANGLE,
|
|
319
|
+
* which is a fact about the sheet worth reading; printing `90` for it would be
|
|
320
|
+
* a limit nothing measured.
|
|
321
|
+
*/
|
|
322
|
+
function ceilingPair(axis: { positive: FoldLimit | null; negative: FoldLimit | null }): string {
|
|
323
|
+
const one = (l: FoldLimit | null, sign: string) => (l === null ? `${sign}none` : `${sign}${l.degrees.toFixed(2)}°`);
|
|
324
|
+
return `${one(axis.positive, '+')} / ${one(axis.negative, '-')}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The same axis's two 1st percentiles, each with its ratio to the ceiling above
|
|
329
|
+
* it and the population it came out of — `+64.80° x1.003 of 5988`.
|
|
330
|
+
*
|
|
331
|
+
* ⭐ The ratio is the whole point and it is printed rather than judged. A
|
|
332
|
+
* ceiling set by the FORM is the floor of a band: the steepest region of a
|
|
333
|
+
* smooth sheet has area, so the 1st percentile sits a fraction of a percent
|
|
334
|
+
* above the minimum. A ceiling set by one bad texel has 99 % of the mesh
|
|
335
|
+
* surviving to the form's angle while the reported number collapses — 64.58°
|
|
336
|
+
* against 6.08° for one texel of 160,000, with the percentile unmoved at 64.80°
|
|
337
|
+
* in both ([#412](https://github.com/firejune/rigc/issues/412),
|
|
338
|
+
* `bench/studies/2026-09-05-noise` §6).
|
|
339
|
+
*
|
|
340
|
+
* Three spellings, three different claims, for the reason `ceilingPair` prints
|
|
341
|
+
* `none` rather than 90: `+none` is a side nothing folds on at all, `+unranked
|
|
342
|
+
* of 36` is a side whose population is too small for a first percentile to be
|
|
343
|
+
* anything but the minimum itself, and a number is a measurement.
|
|
344
|
+
*
|
|
345
|
+
* ⛔ No threshold lives here. What ratio means what is in `docs/AUTHORING.md`
|
|
346
|
+
* §3.4, because a number rigc printed an adjective beside would be a policy the
|
|
347
|
+
* compiler invented out of a measurement — and `A39` would go on refusing at the
|
|
348
|
+
* raw angle either way.
|
|
349
|
+
*/
|
|
350
|
+
function spreadPair(axis: { positive: FoldLimit | null; negative: FoldLimit | null }): string {
|
|
351
|
+
const one = (l: FoldLimit | null, sign: string) =>
|
|
352
|
+
l === null
|
|
353
|
+
? `${sign}none`
|
|
354
|
+
: l.p1 === null
|
|
355
|
+
? `${sign}unranked of ${l.count}`
|
|
356
|
+
: `${sign}${l.p1.toFixed(2)}° x${(l.p1 / l.degrees).toFixed(3)} of ${l.count}`;
|
|
357
|
+
return `${one(axis.positive, '+')} / ${one(axis.negative, '-')}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The tightest of the four, so the line that names a triangle names the right one. */
|
|
361
|
+
function tightestFold(c: TurnCeiling): { kind: string; sign: string; limit: FoldLimit } | null {
|
|
362
|
+
const all = [
|
|
363
|
+
{ kind: 'yaw', sign: '+', limit: c.yaw.positive },
|
|
364
|
+
{ kind: 'yaw', sign: '-', limit: c.yaw.negative },
|
|
365
|
+
{ kind: 'pitch', sign: '+', limit: c.pitch.positive },
|
|
366
|
+
{ kind: 'pitch', sign: '-', limit: c.pitch.negative },
|
|
367
|
+
].filter((e): e is { kind: string; sign: string; limit: FoldLimit } => e.limit !== null);
|
|
368
|
+
if (all.length === 0) return null;
|
|
369
|
+
return all.reduce((best, e) => (e.limit.degrees < best.limit.degrees ? e : best));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function meshDepthNote(m: CompileResult['meshes'][number]): string {
|
|
373
|
+
const parts: string[] = [];
|
|
374
|
+
if (m.depth) {
|
|
375
|
+
parts.push(
|
|
376
|
+
`depth "${m.depth.image}" ${m.depth.digest} near=${m.depth.near} zScale=${m.depth.zScale} ` +
|
|
377
|
+
`z=[${m.depth.range[0]}, ${m.depth.range[1]}]`,
|
|
378
|
+
);
|
|
379
|
+
// Directly under the sheet's own line, because it is the other half of
|
|
380
|
+
// "what did this mesh read": `z=[…]` says how much of the map's range
|
|
381
|
+
// reached the vertices, and this says how many of them read a texel that
|
|
382
|
+
// draws nothing (issue #449). Reported only when there is something to
|
|
383
|
+
// report, so a mesh whose every vertex sits on drawn art gains no line.
|
|
384
|
+
//
|
|
385
|
+
// ⭐ A `contour` gets the OTHER half of the sentence, because rigc built
|
|
386
|
+
// that outline and knows what it is: `buildContourMesh` returns
|
|
387
|
+
// `hullVertices: points.length` over `offsetPolygon(simplified, margin)`,
|
|
388
|
+
// so every vertex of a contour mesh is the traced silhouette pushed out by
|
|
389
|
+
// the margin — there are no interior vertices for the count to be about.
|
|
390
|
+
// Nothing is derived, inferred or thresholded to say so; it is what the
|
|
391
|
+
// generator returns, and `generatedHullAndEdges` already cross-checks that
|
|
392
|
+
// hull against the triangulation's own outline. Without it the line reads
|
|
393
|
+
// as a fault on every correct contour rig, which is a diagnostic authors
|
|
394
|
+
// learn to ignore.
|
|
395
|
+
// Withheld, and said where the count would have stood (issue #750): the
|
|
396
|
+
// part's texels could not be located on its page, so there is no count
|
|
397
|
+
// that is about this part.
|
|
398
|
+
if (m.depth.unlocated !== undefined) {
|
|
399
|
+
parts.push(`the count of vertices on undrawn texels is not measured: ${m.depth.unlocated}. ${PAGE_GRID_UNLOCATED}`);
|
|
400
|
+
} else if (m.depth.undrawn !== null && m.depth.undrawn > 0) {
|
|
401
|
+
parts.push(
|
|
402
|
+
`${m.depth.undrawn} of ${m.vertices} vertices sample a texel the part image does not draw — ` +
|
|
403
|
+
(m.kind === 'contour'
|
|
404
|
+
? 'a contour\'s vertices are all traced outline, pushed out by the margin, so this is the topology and not the sheet'
|
|
405
|
+
: 'their z is the sheet\'s reading of somewhere the part is not'),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const c = m.depth.ceiling;
|
|
409
|
+
parts.push(`turn ceiling yaw ${ceilingPair(c.yaw)} pitch ${ceilingPair(c.pitch)}`);
|
|
410
|
+
const worst = tightestFold(c);
|
|
411
|
+
if (worst !== null) {
|
|
412
|
+
parts.push(` 1st pct yaw ${spreadPair(c.yaw)} pitch ${spreadPair(c.pitch)}`);
|
|
413
|
+
}
|
|
414
|
+
parts.push(
|
|
415
|
+
worst === null
|
|
416
|
+
? ` nothing in this sheet folds: ${c.measured} triangle(s) measured, none with a depth gradient across it`
|
|
417
|
+
: ` first to fold: ${worst.kind} ${worst.sign} at ${worst.limit.degrees.toFixed(2)}°, ` +
|
|
418
|
+
`triangle ${worst.limit.triangle} [${worst.limit.ids.join(',')}], the sheet steps ` +
|
|
419
|
+
`${depthStepLevels(worst.limit.depthStep, m.depth.zScale).toFixed(2)} level(s) across it, ` +
|
|
420
|
+
// The same step over the range the mesh sampled (issue #448). A
|
|
421
|
+
// suffix and not a line of its own: it is an apposition on the step
|
|
422
|
+
// beside it, and the reading that matters is the two together — a
|
|
423
|
+
// discontinuity says "plenty of levels" and "nearly all of them" at
|
|
424
|
+
// once, and they have to be read in one breath to say the opposite.
|
|
425
|
+
`which is ${worst.limit.stepShare.toFixed(3)} of the range this mesh sampled` +
|
|
426
|
+
`${c.degenerate ? `; ${c.degenerate} triangle(s) too flat in setup to measure` : ''}`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
if (m.soft) {
|
|
430
|
+
parts.push(
|
|
431
|
+
`soft "${m.soft.mask}" ${m.soft.digest} -> ${m.soft.bone}, ${m.soft.carried} carried / ${m.soft.ramped} in the falloff`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
return parts.length === 0 ? '' : `\n ${parts.join('\n ')}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The line under a `segments` mesh: how its bones share the vertices.
|
|
439
|
+
*
|
|
440
|
+
* ⭐ The weights are this generator's whole output, and the per-vertex numbers
|
|
441
|
+
* are nowhere a reader can see them — so the three figures that say whether the
|
|
442
|
+
* falloff did what was meant are printed where the mesh is: the most bones any
|
|
443
|
+
* vertex binds (against `maxBones`), the mean, and how many vertices a single
|
|
444
|
+
* bone owns outright. A named bone no vertex binds is said by name, because it
|
|
445
|
+
* is in no weight and so in no other figure either.
|
|
446
|
+
*/
|
|
447
|
+
export function meshInfluenceNote(m: CompileResult['meshes'][number]): string {
|
|
448
|
+
const inf = m.influence;
|
|
449
|
+
if (inf === undefined) return '';
|
|
450
|
+
const unbound = m.bones.filter((b) => !inf.bound.includes(b));
|
|
451
|
+
const single = m.vertices === 0 ? 0 : (inf.singleBone / m.vertices) * 100;
|
|
452
|
+
const joined = inf.keptCells - inf.artCells;
|
|
453
|
+
return (
|
|
454
|
+
`\n influence max ${inf.maxBones} bone(s) per vertex, mean ${inf.meanBones.toFixed(2)}, ` +
|
|
455
|
+
`${inf.singleBone} of ${m.vertices} vertices (${single.toFixed(2)}%) on a single bone` +
|
|
456
|
+
(unbound.length ? `; named and bound by no vertex: ${unbound.join(', ')}` : '') +
|
|
457
|
+
`\n lattice cell ${inf.cell}px, ${inf.cols}x${inf.rows} cells, ${inf.artCells} with art, ${inf.keptCells} kept` +
|
|
458
|
+
(inf.islands > 1 || joined > 0
|
|
459
|
+
? ` (${inf.islands} island(s) joined into one outline, ${joined} cell(s) added that hold no art)`
|
|
460
|
+
: '')
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Why a figure taken off a part's texels is withheld on a page whose file is
|
|
466
|
+
* not the size its atlas declares — the tail of every line that withholds one
|
|
467
|
+
* (issue #750). The page and its ratios come first, from `pageGridSaid`; this
|
|
468
|
+
* says what that does to the reading and where the repair is named.
|
|
469
|
+
*/
|
|
470
|
+
export const PAGE_GRID_UNLOCATED =
|
|
471
|
+
'rigc lifts a part off its page at the coordinates the atlas states, and on this file those are not where the ' +
|
|
472
|
+
"part's texels are, so a figure taken there would describe another part of the page. " +
|
|
473
|
+
'`A06_ATLAS_PAGE_SIZE_MATCHES_PNG` refuses the page by the same ratio and names the `scale:` header that states it';
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* What a mesh measured about its own fit against the art it names, or nothing
|
|
477
|
+
* for a mesh with no art to measure against.
|
|
478
|
+
*
|
|
479
|
+
* Printed for authored geometry as well as for a `contour` (issue #277): the
|
|
480
|
+
* figure is a measurement between the emitted triangles and the PNG, so it means
|
|
481
|
+
* the same thing whoever drew the vertices, and the silence was the defect —
|
|
482
|
+
* an octagon rim placed on a round part's silhouette clips its own ink outline
|
|
483
|
+
* at 94.31% and used to print nothing at all.
|
|
484
|
+
*
|
|
485
|
+
* The hole is appended only when there is one, so the common line is unchanged.
|
|
486
|
+
* It is the one figure in the report that a hole moves: `coverage` and
|
|
487
|
+
* `overshoot` are both measured against the FILLED silhouette, so spanning an
|
|
488
|
+
* interior hole is neither missing coverage nor reaching past anything, and an
|
|
489
|
+
* unintentional hole — a gap in the art, a stroke that failed to join — bought
|
|
490
|
+
* fill over transparent pixels with nothing anywhere saying so (issue #275).
|
|
491
|
+
*/
|
|
492
|
+
export function meshFit(m: CompileResult['meshes'][number]): string {
|
|
493
|
+
// The fit is a measurement against the part's texels, and on a page whose
|
|
494
|
+
// file is not its declared size the region lift does not have them (issue
|
|
495
|
+
// #750). It printed 68.49% / 76.24px there for a mesh that measures 100.00% /
|
|
496
|
+
// 16.00px on the page it was packed from — two plausible numbers about
|
|
497
|
+
// another part of the picture. So the line says what was not measured and
|
|
498
|
+
// why, in the place the figures stood, and prints no figure.
|
|
499
|
+
if (m.fitWithheld !== undefined) return ` fit not measured: ${m.fitWithheld}. ${PAGE_GRID_UNLOCATED}`;
|
|
500
|
+
if (m.coverage === undefined) return '';
|
|
501
|
+
// A count of the plate's own cells, so on a `scale:` page it is texels and
|
|
502
|
+
// says so rather than borrowing the overshoot's unit beside it (issue #762).
|
|
503
|
+
const hole = m.holePixels ? `, enclosing ${m.holePixels}${m.pageScale === undefined ? 'px' : ' texel(s)'} of hole` : '';
|
|
504
|
+
return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${meshFitGrid(m)}${hole}`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The grid a fit was taken on, when it is not the drawing's own (issue #762).
|
|
509
|
+
*
|
|
510
|
+
* The overshoot is stated in the drawing's pixels on every route — the unit an
|
|
511
|
+
* attachment's size is in — and on a page that declares a `scale:` other than
|
|
512
|
+
* 1 it was measured on the page's texels and divided by that scale. So it
|
|
513
|
+
* carries the coarser grid's step: on `scale: 0.5` a figure moves in steps of
|
|
514
|
+
* 2.00px of the drawing, and it need not equal the figure the page it was
|
|
515
|
+
* packed from reads except where the distance falls on whole texels. Said
|
|
516
|
+
* beside the figure, and nothing at all on a loose part or a page at scale 1,
|
|
517
|
+
* where the line is the one it always was.
|
|
518
|
+
*/
|
|
519
|
+
function meshFitGrid(m: CompileResult['meshes'][number]): string {
|
|
520
|
+
if (m.pageScale === undefined) return '';
|
|
521
|
+
return (
|
|
522
|
+
` (the drawing's pixels, measured on the page's texels at scale: ${m.pageScale} — a texel is ` +
|
|
523
|
+
`${(1 / m.pageScale).toFixed(2)}px of the drawing)`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The triangle budget a `MESH` line is read against: the rig's, or nothing.
|
|
529
|
+
*
|
|
530
|
+
* 📐 It used to be the literal `80`, which was nobody's budget — the rig quoted
|
|
531
|
+
* in issue #275 declared 64, `A13_MESH_BUDGET` measured against that 64
|
|
532
|
+
* correctly, and the line an author actually reads printed 80. Under the default
|
|
533
|
+
* `--profile spine` `A13` is `PROF`, so the printed number is the only budget
|
|
534
|
+
* figure in the output and it has to be the declared one. A rig that declares
|
|
535
|
+
* none says so in the same words `A13` SKIPs in, rather than being given a wall.
|
|
536
|
+
*/
|
|
537
|
+
export function meshBudget(rig: CompileResult['rig']): string {
|
|
538
|
+
return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* What a path names as a build — the one statement of it every command that
|
|
543
|
+
* takes a build resolves through (issue #1046): the Spine skeleton, the atlas
|
|
544
|
+
* and the model document, each with whether it is there.
|
|
545
|
+
*
|
|
546
|
+
* ⭐ A build is a directory holding `skeleton.json` and `skeleton.atlas`, and
|
|
547
|
+
* `skeleton.model.json` beside them when rigc wrote it — or a `.json`
|
|
548
|
+
* skeleton named directly, a foreign export's. A directory holding no
|
|
549
|
+
* `skeleton.json`, or a path with nothing at it, is refused here, `nothing at
|
|
550
|
+
* <path>`, for every command alike: before this, `validate` read the missing
|
|
551
|
+
* file unguarded and died on an ENOENT and a stack (#1046), and `diff` on a
|
|
552
|
+
* directory on an EISDIR. What a command then needs beyond the skeleton is the
|
|
553
|
+
* command's to say — `spinePairOf` for the atlas, `resolveDrawable` for a
|
|
554
|
+
* rigc build drawn without one.
|
|
555
|
+
*
|
|
556
|
+
* Two shapes of target, because rigc's own output and a foreign export are
|
|
557
|
+
* named differently and both have to be gateable. rigc writes
|
|
558
|
+
* `skeleton.json` + `skeleton.atlas` into a directory. Everybody else writes
|
|
559
|
+
* whatever the editor called the project, and the official examples are not
|
|
560
|
+
* even consistent with themselves — `7-anticipation/export/` holds
|
|
561
|
+
* `sack-pro.json`, `spineboy/export/` holds two skeletons and two atlases.
|
|
562
|
+
*
|
|
563
|
+
* ⚠️ When more than one atlas sits beside a named skeleton, the atlas is not
|
|
564
|
+
* chosen: guessing by name would be wrong on the corpus that motivated it —
|
|
565
|
+
* `spineboy-ess` shares a longer prefix with `spineboy-run.atlas` than with
|
|
566
|
+
* the `spineboy.atlas` it actually uses, so the plausible heuristic picks the
|
|
567
|
+
* wrong file and every attachment then resolves against the wrong pixels —
|
|
568
|
+
* silently, which is the exact failure mode this tool exists to remove. The
|
|
569
|
+
* refusal is the atlas's (`atlasRefusal`), said by a command that reads one;
|
|
570
|
+
* `diff`, which reads the skeleton alone, is not refused for it.
|
|
571
|
+
*/
|
|
572
|
+
export interface BuildFiles {
|
|
573
|
+
/** The Spine skeleton — `skeleton.json` in a directory, or the `.json` named — and there, or `resolveBuild` refuses. */
|
|
574
|
+
skeletonPath: string;
|
|
575
|
+
/** The atlas: `--atlas`, else `skeleton.atlas` in a directory, else the one `.atlas` beside a named `.json` (or `skeleton.atlas` there, when there is none). */
|
|
576
|
+
atlasPath: string;
|
|
577
|
+
/** `there` — a file at `atlasPath`; `absent` — none; `ambiguous` — several beside a named `.json` and no `--atlas`. */
|
|
578
|
+
atlas: 'there' | 'absent' | 'ambiguous';
|
|
579
|
+
/** What a command that reads the atlas says where `atlas` is not `there`. */
|
|
580
|
+
atlasRefusal: string;
|
|
581
|
+
/** `skeleton.model.json` beside the skeleton, or `null` where there is none (a Spine export). */
|
|
582
|
+
modelPath: string | null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function resolveBuild(target: string, atlasFlag: string | undefined): BuildFiles {
|
|
586
|
+
const abs = resolve(target);
|
|
587
|
+
if (!existsSync(abs)) throw new UsageError(`nothing at ${abs}`);
|
|
588
|
+
let skeletonPath: string;
|
|
589
|
+
let atlasPath: string;
|
|
590
|
+
let atlas: BuildFiles['atlas'];
|
|
591
|
+
let atlasRefusal: string;
|
|
592
|
+
if (statSync(abs).isDirectory()) {
|
|
593
|
+
skeletonPath = join(abs, 'skeleton.json');
|
|
594
|
+
atlasPath = atlasFlag ? resolve(atlasFlag) : join(abs, 'skeleton.atlas');
|
|
595
|
+
atlas = existsSync(atlasPath) ? 'there' : 'absent';
|
|
596
|
+
atlasRefusal = `nothing at ${atlasPath}`;
|
|
597
|
+
} else {
|
|
598
|
+
if (!abs.endsWith('.json')) throw new UsageError(`${abs} is neither a directory nor a .json skeleton`);
|
|
599
|
+
skeletonPath = abs;
|
|
600
|
+
if (atlasFlag) {
|
|
601
|
+
atlasPath = resolve(atlasFlag);
|
|
602
|
+
atlas = existsSync(atlasPath) ? 'there' : 'absent';
|
|
603
|
+
atlasRefusal = `nothing at ${atlasPath}`;
|
|
604
|
+
} else {
|
|
605
|
+
const dir = dirname(abs);
|
|
606
|
+
const atlases = readdirSync(dir)
|
|
607
|
+
.filter((f) => f.endsWith('.atlas'))
|
|
608
|
+
.sort();
|
|
609
|
+
atlasPath = join(dir, atlases.length === 1 ? atlases[0] : 'skeleton.atlas');
|
|
610
|
+
atlas = atlases.length === 1 ? 'there' : atlases.length === 0 ? 'absent' : 'ambiguous';
|
|
611
|
+
atlasRefusal =
|
|
612
|
+
atlases.length === 0
|
|
613
|
+
? `no .atlas beside ${abs}; name one with --atlas <path>`
|
|
614
|
+
: `${atlases.length} atlases beside ${abs} (${atlases.join(', ')}); name the right one with --atlas <path> ` +
|
|
615
|
+
'— guessing by filename is how an attachment quietly resolves against the wrong page';
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (!existsSync(skeletonPath)) throw new UsageError(`nothing at ${skeletonPath}`);
|
|
619
|
+
const model = join(dirname(skeletonPath), MODEL_DOCUMENT_FILE);
|
|
620
|
+
return { skeletonPath, atlasPath, atlas, atlasRefusal, modelPath: existsSync(model) ? model : null };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The build's Spine pair, for a command that reads both files — the round
|
|
625
|
+
* trip (`validate`, `bench`), `bonedist`, `preview`, `vote` — refused naming
|
|
626
|
+
* the atlas where it is not there (`atlasRefusal`); the skeleton is
|
|
627
|
+
* `resolveBuild`'s.
|
|
628
|
+
*/
|
|
629
|
+
export function spinePairOf(build: BuildFiles): { skeletonPath: string; atlasPath: string; atlasDir: string } {
|
|
630
|
+
if (build.atlas !== 'there') throw new UsageError(build.atlasRefusal);
|
|
631
|
+
return { skeletonPath: build.skeletonPath, atlasPath: build.atlasPath, atlasDir: dirname(build.atlasPath) };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* check — how close does the candidate LOOK to the reference frames?
|
|
636
|
+
*
|
|
637
|
+
* ⭐ The gate cannot see a wrong animation. It parses, it steps, it refuses the
|
|
638
|
+
* degenerate — and a rig whose easings are all reversed passes it green, which is
|
|
639
|
+
* not a hypothetical: ladder rung 1's first honest run shipped exactly that build
|
|
640
|
+
* and the validator was structurally incapable of noticing. `diff` cannot see it
|
|
641
|
+
* either, because it compares structure and a reversed curve is the same curve
|
|
642
|
+
* count. The only thing that can is a picture, so this renders the candidate into
|
|
643
|
+
* the reference's own frame and compares pixels.
|
|
644
|
+
*
|
|
645
|
+
* 🔒 It never reads the reference skeleton — see `src/check.ts`. That is what
|
|
646
|
+
* keeps it usable **inside** an authoring loop rather than at the finish line the
|
|
647
|
+
* way `bench` is: an author may run it as often as they like without their run
|
|
648
|
+
* stopping being an authoring run.
|
|
649
|
+
*
|
|
650
|
+
* There is no pass mark, for the same reason `diff` has none.
|
|
651
|
+
*/
|
|
652
|
+
function readCheckFlags(
|
|
653
|
+
flags: Record<string, string>,
|
|
654
|
+
): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom' | 'skin'> {
|
|
655
|
+
const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom' | 'skin'> = {};
|
|
656
|
+
if (flags.framing !== undefined) {
|
|
657
|
+
if (flags.framing !== 'per-shot' && flags.framing !== 'shared') {
|
|
658
|
+
throw new UsageError('--framing takes per-shot (the default) or shared');
|
|
659
|
+
}
|
|
660
|
+
out.framing = flags.framing;
|
|
661
|
+
}
|
|
662
|
+
if (flags.fps !== undefined) {
|
|
663
|
+
const fps = Number(flags.fps);
|
|
664
|
+
if (!Number.isFinite(fps) || fps <= 0) throw new UsageError('--fps must be a positive number');
|
|
665
|
+
out.fps = fps;
|
|
666
|
+
}
|
|
667
|
+
if (flags.viewport !== undefined) {
|
|
668
|
+
const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
|
|
669
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
|
|
670
|
+
throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
|
|
671
|
+
}
|
|
672
|
+
if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
|
|
673
|
+
out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
|
674
|
+
}
|
|
675
|
+
if (flags.as !== undefined) out.as = flags.as;
|
|
676
|
+
// The name is not checked against the candidate here: `check` owns that
|
|
677
|
+
// refusal, because it is the side that has the skeleton open and can list the
|
|
678
|
+
// skins it declares. `render` checks its own for the same reason.
|
|
679
|
+
if (flags.skin !== undefined) out.skin = flags.skin;
|
|
680
|
+
if (flags['texture-from'] !== undefined) {
|
|
681
|
+
const path = resolve(flags['texture-from']);
|
|
682
|
+
if (!existsSync(path)) {
|
|
683
|
+
throw new UsageError(
|
|
684
|
+
`--texture-from ${path} is not a file. It takes the ATLAS the reference frames were rendered through — the ` +
|
|
685
|
+
"example's own .atlas — so check can measure how much of the MAE is texture resampling.",
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
out.textureFrom = { atlasText: readFileSync(path, 'utf8'), atlasDir: dirname(path), label: flags['texture-from'] };
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export function runCheck(
|
|
694
|
+
candidate: string,
|
|
695
|
+
atlasFlag: string | undefined,
|
|
696
|
+
framesDir: string,
|
|
697
|
+
flags: Record<string, string>,
|
|
698
|
+
plates?: CheckPlates,
|
|
699
|
+
): CheckReport {
|
|
700
|
+
const { skeletonPath, atlasPath, atlasText } = resolveDrawable(candidate, atlasFlag);
|
|
701
|
+
// The poser is `render`'s choice, over the same two files (issue #968): the
|
|
702
|
+
// paths are what `candidatePosers` looks beside for `skeleton.model.json`.
|
|
703
|
+
const poser = readPoserFlag(flags);
|
|
704
|
+
return checkAgainstFrames({
|
|
705
|
+
skeletonText: readSkeletonText(skeletonPath),
|
|
706
|
+
atlasText,
|
|
707
|
+
atlasDir: dirname(atlasPath),
|
|
708
|
+
framesDir,
|
|
709
|
+
labels: { skeleton: skeletonPath, atlas: atlasText === null ? `${atlasPath} — not there (${ATLAS_ABSENT})` : atlasPath },
|
|
710
|
+
candidatePaths: { skeleton: skeletonPath, atlas: atlasPath },
|
|
711
|
+
...(poser === undefined ? {} : { poser }),
|
|
712
|
+
...readCheckFlags(flags),
|
|
713
|
+
...(plates === undefined ? {} : { plates }),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function writeJson(target: string, body: unknown): void {
|
|
718
|
+
const out = resolve(target);
|
|
719
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
720
|
+
writeFileSync(out, `${JSON.stringify(body, null, 2)}\n`);
|
|
721
|
+
console.log(`rigc: wrote ${out}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// seeing the result — render and preview
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
//
|
|
728
|
+
// ⭐ Why two commands exist for one question. `validate` says the artifact is
|
|
729
|
+
// valid, `check` says how close it is to reference frames — and a first user has
|
|
730
|
+
// neither a reference nor any way to look at what they built. A rig whose head
|
|
731
|
+
// sits visibly off its torso passes the gate, loads in `spine-core` and steps
|
|
732
|
+
// cleanly, because the offsets are the ones the spec asked for. The only remedy
|
|
733
|
+
// is looking (issue #216).
|
|
734
|
+
//
|
|
735
|
+
// `render` looks with OUR rasteriser: PNGs on disk, no browser, no network, and
|
|
736
|
+
// the same frame geometry `check` compares against — so its output is a frame set
|
|
737
|
+
// like any other, sidecar included. `preview` looks with ESOTERIC'S, in one HTML
|
|
738
|
+
// file, which is the stronger statement of the two: a rig that plays there has
|
|
739
|
+
// been played by the reference implementation rather than by ours (issue #151).
|
|
740
|
+
//
|
|
741
|
+
// Both take a COMPILED artifact rather than a rig and motion spec. That is what
|
|
742
|
+
// `check`, `bench` and `validate` all take, it is what `build --out` leaves
|
|
743
|
+
// behind, and it keeps `--out` meaning one thing per command instead of naming
|
|
744
|
+
// the build directory on the way in and the pictures on the way out.
|
|
745
|
+
|
|
746
|
+
/** `--candidate`, the one build `preview`, `bench` and `bonedist` read the Spine pair of (`spinePairOf`). */
|
|
747
|
+
export function resolveViewable(flags: Record<string, string>): {
|
|
748
|
+
skeletonPath: string;
|
|
749
|
+
atlasPath: string;
|
|
750
|
+
atlasDir: string;
|
|
751
|
+
} {
|
|
752
|
+
if (flags.candidate === undefined) {
|
|
753
|
+
throw new UsageError('needs --candidate <dir | skeleton.json> — the directory `build --out` wrote');
|
|
754
|
+
}
|
|
755
|
+
return spinePairOf(resolveBuild(flags.candidate, flags.atlas));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** What `render` and `check` say where a rigc build's atlas is not there (issue #1020). */
|
|
759
|
+
export const ATLAS_ABSENT = 'a build the core poses from a rigc-compiled/2 or /3 document needs none';
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The candidate `render` and `check` draw, with its atlas text — or `null`
|
|
763
|
+
* where the atlas file is not there and need not be (issue #1020).
|
|
764
|
+
*
|
|
765
|
+
* ⭐ A rigc build is drawn by the core from its `skeleton.model.json`, and a
|
|
766
|
+
* `rigc-compiled/2` document states where each region sits on its page, so
|
|
767
|
+
* the build's `skeleton.atlas` is not needed to draw it. Its absence is let
|
|
768
|
+
* through here only where it can be that case — no `--atlas` named, a model
|
|
769
|
+
* document beside the skeleton, and no atlas beside it at all — and the poser
|
|
770
|
+
* choice decides the rest: anything that is read through an atlas after all
|
|
771
|
+
* (a `rigc-compiled/1` document, a build the core refuses) is refused there
|
|
772
|
+
* naming the file and why (`CandidateAtlasError`). An atlas that IS there is
|
|
773
|
+
* read, as it always was: the core holds it to the document's `pages` and
|
|
774
|
+
* draws through spine-core, saying why, when it is not the one the build
|
|
775
|
+
* wrote (#1016), and `check` reads its `scale:` lines for the texture note.
|
|
776
|
+
* Everywhere else this is `resolveViewable`'s refusal, word for word — both
|
|
777
|
+
* read the build off `resolveBuild` (issue #1046).
|
|
778
|
+
*/
|
|
779
|
+
export function resolveDrawable(target: string, atlasFlag: string | undefined): { skeletonPath: string; atlasPath: string; atlasText: string | null } {
|
|
780
|
+
const build = resolveBuild(target, atlasFlag);
|
|
781
|
+
const { skeletonPath, atlasPath } = build;
|
|
782
|
+
if (atlasFlag === undefined && build.atlas === 'absent') {
|
|
783
|
+
if (build.modelPath !== null) {
|
|
784
|
+
return { skeletonPath, atlasPath, atlasText: null };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (build.atlas !== 'there') throw new UsageError(build.atlasRefusal);
|
|
788
|
+
return { skeletonPath, atlasPath, atlasText: readFileSync(atlasPath, 'utf8') };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** `--animation`, checked against what the skeleton actually carries. */
|
|
792
|
+
export function readAnimationFlag(flags: Record<string, string>, available: string[]): string | undefined {
|
|
793
|
+
const name = flags.animation;
|
|
794
|
+
if (name === undefined) return undefined;
|
|
795
|
+
if (!available.includes(name)) {
|
|
796
|
+
throw new UsageError(
|
|
797
|
+
`no animation ${JSON.stringify(name)} in this skeleton; it has [${available.join(', ') || 'none'}]`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
return name;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* `--poser` as spelled, checked against the posers there are — shared by
|
|
805
|
+
* `render` and `check`. On `render` it is read where its refusal has always
|
|
806
|
+
* stood, after the candidate's own flags; the candidate is loaded before that
|
|
807
|
+
* with the spelling as given (`posersAsked`), so a rigc build the core poses
|
|
808
|
+
* is chosen without loading spine-core (issue #1014).
|
|
809
|
+
*/
|
|
810
|
+
export function readPoserFlag(flags: Record<string, string>): PoserName | undefined {
|
|
811
|
+
const raw = flags.poser;
|
|
812
|
+
const forced = POSER_NAMES.find((name) => name === raw);
|
|
813
|
+
if (raw !== undefined && forced === undefined) {
|
|
814
|
+
throw new UsageError(`--poser ${JSON.stringify(raw)}: known posers are ${POSER_NAMES.join(', ')}`);
|
|
815
|
+
}
|
|
816
|
+
return forced;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* What `render` and `preview` say of their framing on a skeleton that declares
|
|
821
|
+
* no stage (issue #714).
|
|
822
|
+
*
|
|
823
|
+
* ⚠️ **Neither frames to a stage on ANY skeleton**, so this is not a fallback
|
|
824
|
+
* being announced: `framingViewport` is the union of every animation's posed
|
|
825
|
+
* bounds, and the Spine Web Player's `calculateAnimationViewport` samples the
|
|
826
|
+
* playing animation's bounds whenever its config states no viewport box, which
|
|
827
|
+
* `buildPreview` never does. The line is printed only where a stage is absent
|
|
828
|
+
* because that is the one case where a reader can ask *what box stood in for
|
|
829
|
+
* it* — and the answer has to be "none", said, rather than a rectangle that looks
|
|
830
|
+
* like a default. On a staged skeleton the output is the bytes it always was.
|
|
831
|
+
*/
|
|
832
|
+
export const STAGELESS_FRAMING = {
|
|
833
|
+
render:
|
|
834
|
+
'framing the posed extent of every animation, padded — this skeleton declares no stage, and nothing stands ' +
|
|
835
|
+
'in for one: render frames to the posed extent whether or not a stage is declared',
|
|
836
|
+
preview:
|
|
837
|
+
"framing the Spine Web Player's own: the posed extent of the animation it plays — this skeleton declares no " +
|
|
838
|
+
'stage, and nothing stands in for one: the player frames that way whether or not a stage is declared',
|
|
839
|
+
} as const;
|
|
840
|
+
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
// reading a given condition — pose
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
//
|
|
845
|
+
// ⭐ Every other command here takes a spec and looks at what came out. This one
|
|
846
|
+
// runs the other way: it takes a PICTURE the user already has — a key pose — and
|
|
847
|
+
// reads spec coordinates out of it, so an agent can state those poses in a rig and
|
|
848
|
+
// a motion by construction and spend its loops on the part nobody can measure, the
|
|
849
|
+
// movement between them.
|
|
850
|
+
//
|
|
851
|
+
// 🚫 It grades nothing, and the distinction is load-bearing rather than modest.
|
|
852
|
+
// `check` and `bench` compare a build against a reference and their numbers mean
|
|
853
|
+
// "how close"; a pose frame is not a reference, it is an INPUT, and once the spec
|
|
854
|
+
// states it there is nothing left to be close to. So the residual here is a trust
|
|
855
|
+
// signal — how much of the frame this placement actually explains — and the only
|
|
856
|
+
// threshold in `src/pose.ts` is the one that decides whether to print an answer at
|
|
857
|
+
// all, which the caller can move.
|
|
858
|
+
//
|
|
859
|
+
// rigc pose --images parts/ --frame poseA.png [--out pose.json]
|
|
860
|
+
|
|
861
|
+
export const DEFAULT_POSE_OUT = 'pose.json';
|
|
862
|
+
|
|
863
|
+
// ---------------------------------------------------------------------------
|
|
864
|
+
// reading the half a picture hides — chainfit
|
|
865
|
+
// ---------------------------------------------------------------------------
|
|
866
|
+
//
|
|
867
|
+
// ⭐ `pose` above reads a picture with nothing but the loose parts, and refuses
|
|
868
|
+
// the parts another part is drawn over — a residual measured through an occluder
|
|
869
|
+
// rises AT the correct placement, so the honest answer is a refusal. This reads
|
|
870
|
+
// those, and the whole difference is that it is also given the CANDIDATE: with a
|
|
871
|
+
// draw order the covered pixels can be excluded from a part's objective instead
|
|
872
|
+
// of charged to it, and with a hierarchy a child of a placed bone has one degree
|
|
873
|
+
// of freedom — the hinge about its own pivot — where `pose` has four.
|
|
874
|
+
//
|
|
875
|
+
// 🚫 Same phase and the same framing as `pose`: it reads a given condition into
|
|
876
|
+
// spec coordinates and grades nothing. Every residual is a trust signal, every
|
|
877
|
+
// threshold is reported, and `visibleShare` is how much of the part the number
|
|
878
|
+
// was even computed on.
|
|
879
|
+
//
|
|
880
|
+
// rigc chainfit --candidate <dir> --images <dir> --frame poseA.png [--anchor pose.json]
|
|
881
|
+
|
|
882
|
+
export const DEFAULT_CHAINFIT_OUT = 'chainfit.json';
|
|
883
|
+
|
|
884
|
+
// ---------------------------------------------------------------------------
|
|
885
|
+
// choosing between results — vote
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
//
|
|
888
|
+
// ⭐ `preview` shows candidates and asks nothing; this shows two to four of them
|
|
889
|
+
// side by side, hides where each came from, and takes an answer back. The rest of this toolchain is instruments, and it
|
|
890
|
+
// should be — the vote opens only where the instruments have already run out.
|
|
891
|
+
// See `src/ballot.ts` for why the ballot is ordered compile-first-vote-last,
|
|
892
|
+
// why the labels are A and B, and why the record is hashes.
|
|
893
|
+
//
|
|
894
|
+
// Two modes on one command, because they share exactly one thing and it is the
|
|
895
|
+
// contract between them: the ballot manifest. Splitting them would document
|
|
896
|
+
// that format twice and let the halves drift.
|
|
897
|
+
//
|
|
898
|
+
// rigc vote --candidate <a> --candidate <b> [--animation <n>] [--out ballot.html]
|
|
899
|
+
// rigc vote --record <result.json> [--ballot ballot.html] [--ledger votes.jsonl] [--again]
|
|
900
|
+
|
|
901
|
+
export const DEFAULT_BALLOT = 'ballot.html';
|
|
902
|
+
export const DEFAULT_LEDGER = 'votes.jsonl';
|
|
903
|
+
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// skills install — put the shipped skills where an agent host looks (issue #831)
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
//
|
|
908
|
+
// After `bun add -d spine-rigc` the skills sit at `node_modules/spine-rigc/skills/`,
|
|
909
|
+
// which no host reads. Codex, Gemini CLI and Antigravity all read
|
|
910
|
+
// `<workspace>/.agents/skills/<name>/`, so this links every `skills/<name>/` the
|
|
911
|
+
// package ships into one directory — `.agents/skills` under the working
|
|
912
|
+
// directory unless `--dir` says otherwise.
|
|
913
|
+
//
|
|
914
|
+
// ⭐ The skills are found from THIS FILE's location, never from the working
|
|
915
|
+
// directory: the command installs the package it is, and a cwd that happens to
|
|
916
|
+
// hold some other `skills/` is not a source. That is also why it lives here and
|
|
917
|
+
// not in `src/`: its one input is where the CLI was installed, nothing else
|
|
918
|
+
// calls it, and `src/` is about rigs.
|
|
919
|
+
//
|
|
920
|
+
// A RELATIVE symlink by default, so the directory survives the project being
|
|
921
|
+
// moved or cloned elsewhere and an upgrade of the package is seen with no second
|
|
922
|
+
// run. `--copy` writes the folder instead, for a host that does not follow a
|
|
923
|
+
// linked skill folder.
|
|
924
|
+
//
|
|
925
|
+
// 🔒 **An entry that is already there and is not what this command would write is
|
|
926
|
+
// refused by name, and then nothing at all is written.** The check runs over
|
|
927
|
+
// every skill before the first write, so a refusal never leaves half an install
|
|
928
|
+
// behind. The one entry that is NOT refused is the one this command would have
|
|
929
|
+
// made — a link that already resolves to the same skill folder, however it is
|
|
930
|
+
// spelled, or with `--copy` a folder whose files are byte for byte the package's
|
|
931
|
+
// — and a run over only those says it had nothing to do. No lifecycle script
|
|
932
|
+
// does this on install: a postinstall writing into a consumer's project root is
|
|
933
|
+
// refused as design, and Bun does not run a dependency's lifecycle scripts
|
|
934
|
+
// outside `trustedDependencies`, so half the installs would silently skip it.
|
|
935
|
+
// ---------------------------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
/** The default `--dir`, resolved against the caller's working directory. */
|
|
938
|
+
export const DEFAULT_SKILLS_DIR = '.agents/skills';
|
|
939
|
+
|
|
940
|
+
/** An install refused before its first write — nothing to install, or an entry in the way. Exit 1. */
|
|
941
|
+
export class SkillsInstallError extends Error {}
|
|
942
|
+
|
|
943
|
+
// ---------------------------------------------------------------------------
|
|
944
|
+
// usage / per-command help
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* One meaning per flag name, shared by every command that takes it — the
|
|
949
|
+
* single place this project states what a flag means. AUTHORING.md §0 quotes
|
|
950
|
+
* this table for `build`'s `--rig`/`--motion`/`--out`/`--images`/`--manifest`/
|
|
951
|
+
* `--profile`; if the two ever disagree, this is the one the code runs.
|
|
952
|
+
*/
|
|
953
|
+
const FLAG_MEANINGS: Record<string, string> = {
|
|
954
|
+
rig: 'the rig spec — skeleton structure',
|
|
955
|
+
motion: 'the motion spec — time',
|
|
956
|
+
out: 'directory for skeleton.json + skeleton.atlas (and, from build, skeleton.model.json); atlas page paths and skeleton.images are written relative to it',
|
|
957
|
+
images: "override the rig spec's own images directory (relative to your working directory)",
|
|
958
|
+
manifest: 'a cut manifest, for a rig with measured art behind it; a foreign skeleton has none',
|
|
959
|
+
'copy-images':
|
|
960
|
+
'also copy every referenced page PNG into --out and rewrite the atlas to the copies, so the directory is ' +
|
|
961
|
+
'self-contained enough to zip or commit on its own, and point skeleton.images at --out itself so the editor finds ' +
|
|
962
|
+
'the parts beside the skeleton on import (default: page paths still point at the source art)',
|
|
963
|
+
pack: 'arrange every part PNG onto shared atlas page(s) written into --out as real PNGs, instead of one page ' +
|
|
964
|
+
'per part. Lossless: every region is a byte-for-byte copy and nothing is resampled, trimmed or rotated ' +
|
|
965
|
+
'(default: one part, one page, pointing at the source art)',
|
|
966
|
+
'page-size': `largest page edge, --pack only (default ${DEFAULT_PAGE_SIZE}); pages are powers of two and the ` +
|
|
967
|
+
'one written is the smallest that holds the pack, spilling to more pages only when the set will not fit',
|
|
968
|
+
padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
|
|
969
|
+
"extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
|
|
970
|
+
'page-edges': `what a page's edges may be, --pack only (default ${DEFAULT_PAGE_EDGES}): pot is a power of two on ` +
|
|
971
|
+
'both; free tries every width from the widest part up, takes the height the placement needs and keeps ' +
|
|
972
|
+
'the least area — a smaller page, at the cost of region attachments sampling within 1 LSB of the loose ' +
|
|
973
|
+
'build rather than exactly',
|
|
974
|
+
'pack-shape': `what two packed rectangles may share, --pack only (default ${DEFAULT_PACK_SHAPE}): rect keeps every ` +
|
|
975
|
+
'region\'s cell apart; polygon packs a region that only meshes draw by its emitted hull, so a neighbour may ' +
|
|
976
|
+
'sit inside its rectangle where the hull is not, with the padding kept between footprints (so a mesh\'s ' +
|
|
977
|
+
'rectangle is its own bytes only where it can be sampled) — a region attachment stays its rectangle',
|
|
978
|
+
'atlas-in':
|
|
979
|
+
'resolve every part against the regions of this pre-packed .atlas instead of against loose PNGs — region ' +
|
|
980
|
+
'geometry (bounds/offsets/rotate) is read from the file and the atlas is re-emitted into --out, re-anchored',
|
|
981
|
+
cut: 'look up a named cut in --cuts <cuts.json>, instead of --rig/--motion/--out',
|
|
982
|
+
cuts: 'the cuts.json --cut names',
|
|
983
|
+
profile:
|
|
984
|
+
'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
|
|
985
|
+
"correctly; spine-html = also this project's renderer/archetype policy",
|
|
986
|
+
atlas: "the candidate's atlas, when it is not beside the skeleton",
|
|
987
|
+
reference: 'the reference skeleton to pose beside the candidate — a directory or a skeleton.json path',
|
|
988
|
+
'reference-atlas': "the reference's atlas, when it is not beside the reference skeleton",
|
|
989
|
+
bones: `a bone correspondence — { "spec": "${BONEDIST_SPEC}", "bones": { "<candidate bone>": "<reference bone>" }, ` +
|
|
990
|
+
'"animations"?: { … } } — or `identity` to state that the two skeletons use the same names. An INPUT, never ' +
|
|
991
|
+
'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
|
|
992
|
+
'as a measurement',
|
|
993
|
+
'all-bones': 'print every bone pair, not just the worst by position',
|
|
994
|
+
geometry:
|
|
995
|
+
`also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
|
|
996
|
+
"slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
|
|
997
|
+
'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
|
|
998
|
+
poser:
|
|
999
|
+
"`render` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
|
|
1000
|
+
'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
|
|
1001
|
+
"beside the skeleton and the skeleton is the one the document records (spine.sha256), `spine` otherwise and wherever the core refuses the input by name; the `poser` " +
|
|
1002
|
+
'line of the render or the check report says which and why. `--poser core` on an input that cannot carry it is refused by name. ' +
|
|
1003
|
+
"`explain`: which reads and poses the DEFORM block's survey — `core` (the model document the compile writes, spine-core untouched) or " +
|
|
1004
|
+
'`spine` (the Spine skeleton parsed and posed by spine-core); default `core`, and `spine` where the core refuses the document, which the block names',
|
|
1005
|
+
'texture-from':
|
|
1006
|
+
"also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
|
|
1007
|
+
'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
|
|
1008
|
+
'through. ⚠️ NOT --atlas: that one names the candidate\'s own atlas and loading a foreign one there re-seats ' +
|
|
1009
|
+
'every region attachment on its packing, so a rotated or trimmed pack moves the geometry too',
|
|
1010
|
+
candidate: 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path',
|
|
1011
|
+
frames: 'a rendered reference frame set (a skeleton root, or one animation directory)',
|
|
1012
|
+
fps: 'frame rate, only for a frame set with no frames.json sidecar',
|
|
1013
|
+
viewport: "pin the candidate's world box, y up, instead of fitting it",
|
|
1014
|
+
framing: 'fit each frame set on its own (default) or once across all of them',
|
|
1015
|
+
as: 'the candidate animation to play, when it is named differently from the frame set',
|
|
1016
|
+
'all-frames': 'print every frame, not just the worst by MAE',
|
|
1017
|
+
json: 'also write the whole report to this path',
|
|
1018
|
+
frame: 'one pose frame — a picture of the pose to read the part placements out of',
|
|
1019
|
+
scale: `the scale window to search, as frame pixels per part pixel (default \`${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}\`)`,
|
|
1020
|
+
rotation: 'the rotation window to search, in screen degrees (default `-180,180`, a full turn)',
|
|
1021
|
+
'max-residual':
|
|
1022
|
+
`above this residual a placement is refused by name instead of reported flat (default ${DEFAULT_MAX_RESIDUAL}); ` +
|
|
1023
|
+
'it is a reporting threshold, not a pass bar',
|
|
1024
|
+
anchor:
|
|
1025
|
+
'a `rigc pose` report for THIS frame, whose confident placements become the anchors the chains hang off ' +
|
|
1026
|
+
'(default: run that pass internally over exactly the parts the candidate draws)',
|
|
1027
|
+
hinge:
|
|
1028
|
+
`the window each child bone's local rotation is searched over, in Spine degrees about its setup value ` +
|
|
1029
|
+
`(default \`${DEFAULT_HINGE_MIN},${DEFAULT_HINGE_MAX}\`, a full turn — one degree of freedom is cheap enough not ` +
|
|
1030
|
+
'to risk a window that does not contain the truth)',
|
|
1031
|
+
stretch:
|
|
1032
|
+
'also search a uniform scale on every bone, over this ratio either way (e.g. 1.25). Without it the stretch ' +
|
|
1033
|
+
"degree of freedom is searched only where the candidate's own animations key a `scale` timeline, because a rig " +
|
|
1034
|
+
'that never scales a bone is a rig saying that bone does not stretch',
|
|
1035
|
+
'min-visible':
|
|
1036
|
+
`below this share of a part surviving the parts drawn over it, the placement is refused by name instead of ` +
|
|
1037
|
+
`reported flat (default ${DEFAULT_MIN_VISIBLE}); the best one found is still printed, and it is a reporting ` +
|
|
1038
|
+
'threshold, not a pass bar',
|
|
1039
|
+
passes:
|
|
1040
|
+
`how many times the occluder masks are rebuilt from the answers and the fit rerun (default ${DEFAULT_PASSES}); ` +
|
|
1041
|
+
"pass 1 freezes each part's visible set where the RIG predicts it, later passes where the last one landed",
|
|
1042
|
+
'anchor-residual':
|
|
1043
|
+
`the residual a \`pose\` placement must be within to anchor a chain (default ${ANCHOR_MAX_RESIDUAL}, with ` +
|
|
1044
|
+
`unexplained ≤ ${ANCHOR_MAX_UNEXPLAINED} and unambiguous — the 2026-09-03 measurement run's own clean-frame criterion)`,
|
|
1045
|
+
'inward-lever':
|
|
1046
|
+
`how far apart, in frame pixels, two anchored descendants have to sit before the rotation they determine is ` +
|
|
1047
|
+
`printed (default ${DEFAULT_MIN_LEVER_PX}); below it the bone is refused \`no-bracket\` naming the measured ` +
|
|
1048
|
+
'lever, because an angle read across a short lever turns a half-pixel anchor error into several degrees',
|
|
1049
|
+
animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
|
|
1050
|
+
skin:
|
|
1051
|
+
'pose under this skin, by the name the skeleton declares. Without it NO skin is set — every slot resolves ' +
|
|
1052
|
+
'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
|
|
1053
|
+
'skeleton does not declare is refused with the ones it does. `render` records the skin in frames.json and ' +
|
|
1054
|
+
'`check` reads it back, so a skin-A candidate is not scored against skin-B frames in silence',
|
|
1055
|
+
slot:
|
|
1056
|
+
'draw only these slots, comma-separated, in the skeleton\'s draw order, on the SAME grid as the whole rig: the ' +
|
|
1057
|
+
'viewport is still fitted to every slot, so this frame overlays the full one pixel for pixel. A name the ' +
|
|
1058
|
+
'skeleton does not declare is refused with every one it does; a slot whose art lives only under another skin ' +
|
|
1059
|
+
'is refused naming that skin. frames.json records the subset, and `check` refuses such a set as a reference',
|
|
1060
|
+
hide:
|
|
1061
|
+
'draw every slot but these, comma-separated — `--slot` the other way round, on the same grid, recorded and ' +
|
|
1062
|
+
'refused the same way. Not with `--slot`: the two are one statement',
|
|
1063
|
+
max: 'longest side of a rendered frame, in pixels (default 256)',
|
|
1064
|
+
record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
|
|
1065
|
+
ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
|
|
1066
|
+
ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
|
|
1067
|
+
again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
|
|
1068
|
+
dir:
|
|
1069
|
+
`the directory to install into, resolved against your working directory (default \`${DEFAULT_SKILLS_DIR}\`, the ` +
|
|
1070
|
+
'workspace directory Codex, Gemini CLI and Antigravity read skills from)',
|
|
1071
|
+
copy:
|
|
1072
|
+
'copy each skill folder instead of linking it, for a host that does not follow a linked skill folder. A copy ' +
|
|
1073
|
+
'is not reached by an upgrade of the package, and one that is no longer the package\'s bytes is refused by name ' +
|
|
1074
|
+
'on the next run — remove it and run again (default: a relative symlink, which an upgrade reaches with no ' +
|
|
1075
|
+
'second run)',
|
|
1076
|
+
name: "the rig spec's own name, which the motion spec's archetype must match (default: the skeleton file's basename)",
|
|
1077
|
+
art: 'how the written spec reaches the art, which a skeleton does not encode: `loose` names an image per ' +
|
|
1078
|
+
"attachment, measured out of the rig spec's own images directory (--images writes it; without it, `build " +
|
|
1079
|
+
'--images <dir>` on every rebuild), `none` states width/height only for `build --atlas-in <pack>` to ' +
|
|
1080
|
+
'resolve (default: loose)',
|
|
1081
|
+
stage:
|
|
1082
|
+
'the setup bounding box — `skeleton.x,y,width,height` — to ADD to a skeleton that declares none. It cannot be ' +
|
|
1083
|
+
'derived: posing the rig gives the ANIMATED extent, which is a different number from the setup box, so this ' +
|
|
1084
|
+
"is the caller's value, and without it the absence is carried: the spec states `\"width\": null, \"height\": " +
|
|
1085
|
+
'null` and the rebuild declares no stage either. ⚠️ An editor export MAY ' +
|
|
1086
|
+
'carry none; every editor export measured for this project carries one and ingest reads it straight through, ' +
|
|
1087
|
+
'so the flag is for a file that really has none rather than for editor exports as a class. ⛔ Beside a ' +
|
|
1088
|
+
'skeleton that already declares a box it is REFUSED rather than ignored: two sources for one value, and the ' +
|
|
1089
|
+
'file is the record of what was measured',
|
|
1090
|
+
'accept-skeleton-differences':
|
|
1091
|
+
'write the repack even where the rebuilt skeleton.json differs from the input\'s, and print every differing path ' +
|
|
1092
|
+
'with both values (default: refused naming them). It says only where the two differ, never why — a build written ' +
|
|
1093
|
+
'before 2.2.0, whose header box was the stage, differs in those four fields and nowhere else. Region identity and ' +
|
|
1094
|
+
'the gate hold exactly as without it; beside an input that needs none, the check line says it accepted nothing',
|
|
1095
|
+
'stage-box':
|
|
1096
|
+
'the slot whose bounding box carries the stage — what `build` writes for a rig that asks for one ' +
|
|
1097
|
+
'(`skeleton.stageBox`). Its four corners are read as the rebuild\'s stage in place of the header\'s box, which ' +
|
|
1098
|
+
'is the setup-pose bounding box, and the rebuilt spec asks for the same box rather than transcribing it. Only a ' +
|
|
1099
|
+
'box `build` could write back is read: anything else in that slot is refused by name, and without the flag no ' +
|
|
1100
|
+
'slot is read as the stage because of its name',
|
|
1101
|
+
report:
|
|
1102
|
+
`also write the gate's report as a JSON document (\`${BUILD_REPORT_SPEC}\`) to this file: every gate's PASS, SKIP and FAIL rows, ` +
|
|
1103
|
+
'its summary figures and stats, the supplier that judged it, and every pack line\'s figures — written when the command ' +
|
|
1104
|
+
'writes --out and when a gate is red, before the exit. A file already at the path is removed first, so any other ending ' +
|
|
1105
|
+
'(a compile error, a repack refused) leaves none rather than an earlier run\'s. Never inside --out, refused by name. ' +
|
|
1106
|
+
'The lines printed are the same with it and without it',
|
|
1107
|
+
help: "show this command's flags and exit",
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
/** The `<value>` a flag takes, for its column in a command's flag table. Absent for a boolean switch. */
|
|
1111
|
+
const FLAG_VALUES: Record<string, string> = {
|
|
1112
|
+
rig: '<path>',
|
|
1113
|
+
motion: '<path>',
|
|
1114
|
+
out: '<dir>',
|
|
1115
|
+
images: '<dir>',
|
|
1116
|
+
manifest: '<path>',
|
|
1117
|
+
cut: '<name>',
|
|
1118
|
+
cuts: '<path>',
|
|
1119
|
+
profile: 'spine|spine-html',
|
|
1120
|
+
atlas: '<path>',
|
|
1121
|
+
'atlas-in': '<file.atlas>',
|
|
1122
|
+
'page-size': '<px>',
|
|
1123
|
+
padding: '<px>',
|
|
1124
|
+
'page-edges': 'pot|free',
|
|
1125
|
+
'pack-shape': 'rect|polygon',
|
|
1126
|
+
'texture-from': '<path>',
|
|
1127
|
+
poser: 'core|spine',
|
|
1128
|
+
reference: '<dir|skeleton.json>',
|
|
1129
|
+
'reference-atlas': '<path>',
|
|
1130
|
+
bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
|
|
1131
|
+
candidate: '<dir|skeleton.json>',
|
|
1132
|
+
frames: '<dir>',
|
|
1133
|
+
fps: '<n>',
|
|
1134
|
+
viewport: '<x,y,w,h>',
|
|
1135
|
+
framing: 'per-shot|shared',
|
|
1136
|
+
as: '<name>',
|
|
1137
|
+
json: '<out>',
|
|
1138
|
+
frame: '<path>',
|
|
1139
|
+
scale: '<min,max>',
|
|
1140
|
+
rotation: '<min,max>',
|
|
1141
|
+
'max-residual': '<0..1>',
|
|
1142
|
+
anchor: '<pose.json>',
|
|
1143
|
+
hinge: '<min,max>',
|
|
1144
|
+
stretch: '<ratio>',
|
|
1145
|
+
'min-visible': '<0..1>',
|
|
1146
|
+
passes: '<n>',
|
|
1147
|
+
'anchor-residual': '<0..1>',
|
|
1148
|
+
'inward-lever': '<px>',
|
|
1149
|
+
animation: '<name>',
|
|
1150
|
+
skin: '<name>',
|
|
1151
|
+
slot: '<name[,name…]>',
|
|
1152
|
+
hide: '<name[,name…]>',
|
|
1153
|
+
max: '<px>',
|
|
1154
|
+
record: '<result.json>',
|
|
1155
|
+
ballot: '<ballot.html>',
|
|
1156
|
+
ledger: '<votes.jsonl>',
|
|
1157
|
+
name: '<n>',
|
|
1158
|
+
art: 'loose|none',
|
|
1159
|
+
stage: '<x,y,w,h>',
|
|
1160
|
+
'stage-box': '<slot>',
|
|
1161
|
+
dir: '<path>',
|
|
1162
|
+
report: '<file>',
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* A command whose body differs by entry (issue #1060): the body the entry that
|
|
1167
|
+
* links none of spine-core runs under the same name, as that entry's page
|
|
1168
|
+
* documents it. `runtime.for` still says what the full entry's body runs
|
|
1169
|
+
* through the runtime for, and the full entry's page is the command's own
|
|
1170
|
+
* fields, unchanged; the second entry's page takes `usage`, `flags` (the
|
|
1171
|
+
* command's own where this states none) and `notes` from here
|
|
1172
|
+
* (`entryCommands`). Registered by `./core_commands.ts`'s `CORE_ENTRY_RUNS`,
|
|
1173
|
+
* which only the second entry registers — `RC26` holds each body to the
|
|
1174
|
+
* closure of the module that registers it.
|
|
1175
|
+
*/
|
|
1176
|
+
interface CoreBody {
|
|
1177
|
+
usage: string[];
|
|
1178
|
+
flags?: string[];
|
|
1179
|
+
notes: string[];
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
interface CommandDoc {
|
|
1183
|
+
name: string;
|
|
1184
|
+
/** One or more invocation forms, each already spelling the command name. */
|
|
1185
|
+
usage: string[];
|
|
1186
|
+
/** Flag names (into FLAG_MEANINGS/FLAG_VALUES), in display order. `--help` is appended automatically. */
|
|
1187
|
+
flags: string[];
|
|
1188
|
+
/**
|
|
1189
|
+
* Per-command wording for a flag whose value or meaning genuinely differs here.
|
|
1190
|
+
*
|
|
1191
|
+
* ⚠️ The default above it — one meaning per flag name, everywhere — is the rule
|
|
1192
|
+
* and this is the named exception to it, not a second table. What earns an entry
|
|
1193
|
+
* is the criterion rather than a headcount: the flag is **shared with another
|
|
1194
|
+
* command**, and it means something different in this one. Examples, and not an
|
|
1195
|
+
* inventory: `--out` is a directory of artifacts to `build`, a directory of specs
|
|
1196
|
+
* to `ingest`, a directory of pictures to
|
|
1197
|
+
* `render` and one file to `preview` and `vote`; `--fps` is the rate a frame set
|
|
1198
|
+
* was RECORDED at to `check`, which reads it off a sidecar, and the rate to
|
|
1199
|
+
* SAMPLE at to `render`, which is choosing it; `--candidate` is one artifact
|
|
1200
|
+
* everywhere except `vote`, which is the one command that takes several and is
|
|
1201
|
+
* the reason there is a ballot at all. Writing any of them as one sentence
|
|
1202
|
+
* covering every command would leave every command's own help less true.
|
|
1203
|
+
*
|
|
1204
|
+
* ⛔ The other side of the criterion, which is the one that keeps this from
|
|
1205
|
+
* becoming the second table it says it is not: a flag no other command takes has
|
|
1206
|
+
* nothing to differ FROM, so its wording belongs in `FLAG_MEANINGS` /
|
|
1207
|
+
* `FLAG_VALUES` above and an entry here for it buys only a second place to look.
|
|
1208
|
+
* Both halves are read off `--help` by `CLI71` in `selftest.ts`, which is why
|
|
1209
|
+
* this sentence no longer counts anything: it said *"three"* where #605 counted
|
|
1210
|
+
* nine, and nothing had ever compared the two.
|
|
1211
|
+
*/
|
|
1212
|
+
overrides?: Record<string, { value?: string; meaning?: string }>;
|
|
1213
|
+
/**
|
|
1214
|
+
* Lines printed under the flag table: what this command's own figures mean.
|
|
1215
|
+
*
|
|
1216
|
+
* ⚠️ Not a second place to describe a flag. It exists for what is true of the
|
|
1217
|
+
* **command** and of no flag it takes — and the case that earned it is issue
|
|
1218
|
+
* #678: `pose` and `chainfit` each say *"it is a reporting threshold, not a
|
|
1219
|
+
* pass bar"* on the flag that carries their threshold, and `check` has no such
|
|
1220
|
+
* flag, so its page said nothing at all about whether any of its figures is a
|
|
1221
|
+
* bar to beat. An agent reading `slot drift worst 3.7 px` off a correct rig had
|
|
1222
|
+
* no page to consult and no exit code to read it in.
|
|
1223
|
+
*/
|
|
1224
|
+
notes?: string[];
|
|
1225
|
+
/**
|
|
1226
|
+
* What the command's body reaches of spine-core, as data (issue #1052):
|
|
1227
|
+
* `false` when nothing — it runs in an entry that links none of the runtime
|
|
1228
|
+
* (`cli_core.ts`) — and otherwise what it runs through the runtime for,
|
|
1229
|
+
* which is what that entry says when it refuses the command. ⚠️ Not a
|
|
1230
|
+
* claim anybody keeps by hand: `RC26` in `selftest.ts` derives it from the
|
|
1231
|
+
* import graph of the module whose bodies register the command, and a
|
|
1232
|
+
* mark that disagrees with the graph is red by name.
|
|
1233
|
+
*
|
|
1234
|
+
* `rerunsTheGate` marks a command whose body re-runs the gate `build` ran
|
|
1235
|
+
* before it wrote (`validate`): refused by the entry that links none of the
|
|
1236
|
+
* runtime and pointed at a rigc build, its refusal says that gate already
|
|
1237
|
+
* ran (`gatedOnWrite`, issue #1097).
|
|
1238
|
+
*/
|
|
1239
|
+
runtime: false | { for: string; core?: CoreBody; rerunsTheGate?: true };
|
|
1240
|
+
/**
|
|
1241
|
+
* Whether the command exists for the Spine format — reads, writes, compares
|
|
1242
|
+
* or embeds Spine skeleton data as the whole of what it does with it — so
|
|
1243
|
+
* the commands that are Spine's without being the runtime's (`ingest`,
|
|
1244
|
+
* `diff`) can be moved to another side in one place.
|
|
1245
|
+
*/
|
|
1246
|
+
spineFormat: boolean;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
export const COMMANDS: CommandDoc[] = [
|
|
1250
|
+
{
|
|
1251
|
+
name: 'build',
|
|
1252
|
+
runtime: {
|
|
1253
|
+
for: 'the gate round-trips every build through it before anything is written',
|
|
1254
|
+
core: {
|
|
1255
|
+
usage: [
|
|
1256
|
+
'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] [--report <file>] (the same files the round-tripped build writes; gated without spine-core — see build --help)',
|
|
1257
|
+
`rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] (parts onto shared pages, written into --out)`,
|
|
1258
|
+
'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
|
|
1259
|
+
'rigc build --cut <name> --cuts <cuts.json>',
|
|
1260
|
+
],
|
|
1261
|
+
notes: [
|
|
1262
|
+
'this entry\'s build writes what the entry that links spine-core writes — skeleton.json,',
|
|
1263
|
+
'skeleton.atlas, skeleton.model.json and the pages --pack or --copy-images put in',
|
|
1264
|
+
'--out — by the same body, and only when no assertion fails. Its gate is not the round',
|
|
1265
|
+
'trip through spine-core, which this entry links none of. What runs instead: the model',
|
|
1266
|
+
'side over the document (every assertion moved off the round trip), and the round',
|
|
1267
|
+
'trip\'s own rules restated over the text the emitter wrote — A01, A02, A05, A07, A16,',
|
|
1268
|
+
'A31, A35, and A18 over a second, independent compile\'s skeleton, atlas and document.',
|
|
1269
|
+
'What does not run is A00_ROUNDTRIP_PARSE, spine-core\'s parse: it reports SKIP naming',
|
|
1270
|
+
'spine-core. The report\'s last line says which ran here and which did not. A00 runs',
|
|
1271
|
+
'in build and validate on the entry that links spine-core: installed, the same `rigc`',
|
|
1272
|
+
'runs that entry once @esotericsoftware/spine-core is installed beside the package',
|
|
1273
|
+
'(`rigc --version` names the entry that ran); from a source checkout, it is',
|
|
1274
|
+
'`bun cli.ts`.',
|
|
1275
|
+
],
|
|
1276
|
+
},
|
|
1277
|
+
},
|
|
1278
|
+
spineFormat: true,
|
|
1279
|
+
usage: [
|
|
1280
|
+
'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] [--report <file>]',
|
|
1281
|
+
`rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] (parts onto shared pages, written into --out)`,
|
|
1282
|
+
'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
|
|
1283
|
+
'rigc build --cut <name> --cuts <cuts.json>',
|
|
1284
|
+
],
|
|
1285
|
+
flags: [
|
|
1286
|
+
'rig',
|
|
1287
|
+
'motion',
|
|
1288
|
+
'out',
|
|
1289
|
+
'manifest',
|
|
1290
|
+
'images',
|
|
1291
|
+
'copy-images',
|
|
1292
|
+
'pack',
|
|
1293
|
+
'page-size',
|
|
1294
|
+
'padding',
|
|
1295
|
+
'page-edges',
|
|
1296
|
+
'pack-shape',
|
|
1297
|
+
'atlas-in',
|
|
1298
|
+
'cut',
|
|
1299
|
+
'cuts',
|
|
1300
|
+
'profile',
|
|
1301
|
+
'report',
|
|
1302
|
+
],
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
name: 'repack',
|
|
1306
|
+
runtime: {
|
|
1307
|
+
for: 'the rebuild it writes is gated by build\'s gate, which round-trips it through spine-core',
|
|
1308
|
+
core: {
|
|
1309
|
+
usage: [
|
|
1310
|
+
`rigc repack <dir | skeleton.json> --out <dir> [--atlas <path>] [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] [--profile spine|spine-html] [--stage x,y,w,h] [--stage-box <slot>] [--accept-skeleton-differences] [--report <file>] (gated without spine-core — see repack --help)`,
|
|
1311
|
+
],
|
|
1312
|
+
notes: [
|
|
1313
|
+
'a packed build repacked from its own output — skeleton.json, skeleton.atlas and the',
|
|
1314
|
+
'pages: every region lifted off its page, the skeleton read back (ingest --art loose),',
|
|
1315
|
+
'and build --pack over the lifted parts, in a work directory under the system temp',
|
|
1316
|
+
'directory that is removed when the command ends. Nothing reaches --out until three',
|
|
1317
|
+
'things are shown, each on its own line: (a) every region lifted off the new pages is',
|
|
1318
|
+
'pixel-identical, by name, to the same region off the input\'s (under --pack-shape',
|
|
1319
|
+
'polygon, a region only meshes draw over the footprint the page keeps as its own); (b) the rebuilt',
|
|
1320
|
+
'skeleton.json is byte-identical to the input\'s — the pack owns the atlas and the pages',
|
|
1321
|
+
'and none of the skeleton; (c) the gate is green. On this entry that gate is build\'s',
|
|
1322
|
+
'here: the model side over the document and the round trip\'s own rules restated over',
|
|
1323
|
+
'the emitted text, A00_ROUNDTRIP_PARSE a SKIP naming spine-core. An atlas the lift',
|
|
1324
|
+
'cannot read exactly is refused by name before anything is made: docs/AUTHORING.md',
|
|
1325
|
+
'§0.4 lists what is accepted and what is refused, and why.',
|
|
1326
|
+
],
|
|
1327
|
+
},
|
|
1328
|
+
},
|
|
1329
|
+
spineFormat: true,
|
|
1330
|
+
usage: [
|
|
1331
|
+
`rigc repack <dir | skeleton.json> --out <dir> [--atlas <path>] [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] [--profile spine|spine-html] [--stage x,y,w,h] [--stage-box <slot>] [--accept-skeleton-differences] [--report <file>]`,
|
|
1332
|
+
],
|
|
1333
|
+
flags: ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', 'accept-skeleton-differences', 'report'],
|
|
1334
|
+
overrides: {
|
|
1335
|
+
out: {
|
|
1336
|
+
value: '<dir>',
|
|
1337
|
+
meaning:
|
|
1338
|
+
'the directory the repacked build is written into — skeleton.json, skeleton.atlas, skeleton.model.json and the ' +
|
|
1339
|
+
'pages, exactly what build --pack writes — absent or empty, and refused otherwise: a page an earlier pack wrote ' +
|
|
1340
|
+
'and this one does not would stay beside the new atlas with nothing naming it. Written only after the three checks',
|
|
1341
|
+
},
|
|
1342
|
+
atlas: { meaning: "the build's atlas, when it is not skeleton.atlas beside the skeleton (a skeleton.json path with several .atlas files beside it needs it)" },
|
|
1343
|
+
},
|
|
1344
|
+
notes: [
|
|
1345
|
+
'a packed build repacked from its own output — skeleton.json, skeleton.atlas and the',
|
|
1346
|
+
'pages: every region lifted off its page, the skeleton read back (ingest --art loose),',
|
|
1347
|
+
'and build --pack over the lifted parts with the packing flags above, which mean what',
|
|
1348
|
+
'they mean to build --pack (repack always packs). The work runs in a directory under the',
|
|
1349
|
+
'system temp directory that is removed when the command ends. Nothing reaches --out until',
|
|
1350
|
+
'three things are shown, each on its own line: (a) every region lifted off the new pages',
|
|
1351
|
+
'is pixel-identical, by name, to the same region off the input\'s (under --pack-shape',
|
|
1352
|
+
'polygon, a region only meshes draw over the footprint the page keeps as its own); (b) the rebuilt',
|
|
1353
|
+
'skeleton.json is byte-identical to the input\'s — the pack owns the atlas and the pages',
|
|
1354
|
+
'and none of the skeleton (--accept-skeleton-differences writes a rebuild that differs,',
|
|
1355
|
+
'every differing path printed); (c) build\'s gate is green. Under the settings the input was',
|
|
1356
|
+
'packed with, a line says the atlas and pages came back byte-identical; under others the',
|
|
1357
|
+
'pages differ by design and (a) is the guarantee. An atlas the lift cannot read exactly',
|
|
1358
|
+
'— a page scale, premultiplied alpha, a region named twice, a region off its page, a page',
|
|
1359
|
+
'that is missing — is refused by name before anything is made: docs/AUTHORING.md §0.4',
|
|
1360
|
+
'lists what is accepted and what is refused, and why. The stage line says which stage the',
|
|
1361
|
+
'rebuild declared and where it was read (--stage, the skeleton.model.json beside the',
|
|
1362
|
+
'pair, or the header\'s box).',
|
|
1363
|
+
],
|
|
1364
|
+
},
|
|
1365
|
+
{
|
|
1366
|
+
name: 'explain',
|
|
1367
|
+
runtime: false,
|
|
1368
|
+
spineFormat: false,
|
|
1369
|
+
usage: [
|
|
1370
|
+
'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--poser core|spine] (it never gates, and writes nothing)',
|
|
1371
|
+
'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
|
|
1372
|
+
'rigc explain --cut <name> --cuts <cuts.json>',
|
|
1373
|
+
],
|
|
1374
|
+
flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts', 'poser'],
|
|
1375
|
+
notes: [
|
|
1376
|
+
'this line said "the same arguments as build, minus --profile" and was false in both',
|
|
1377
|
+
'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
|
|
1378
|
+
'command read what `ingest --art none` writes was reachable and undocumented, while',
|
|
1379
|
+
'--pack, --page-size, --padding, --page-edges, --pack-shape and --copy-images are build\'s and do nothing here —',
|
|
1380
|
+
'they decide what is WRITTEN, and this command writes nothing. What it takes is listed',
|
|
1381
|
+
'above, and that is now the whole of it.',
|
|
1382
|
+
],
|
|
1383
|
+
},
|
|
1384
|
+
{
|
|
1385
|
+
name: 'validate',
|
|
1386
|
+
runtime: { for: 'the gate it re-runs is the round trip through it', rerunsTheGate: true },
|
|
1387
|
+
spineFormat: true,
|
|
1388
|
+
usage: [
|
|
1389
|
+
'rigc validate <dir | skeleton.json> [--atlas <path>] [--profile spine|spine-html]',
|
|
1390
|
+
'rigc validate --cut <name> --cuts <cuts.json> (also re-derives declared durations)',
|
|
1391
|
+
],
|
|
1392
|
+
flags: ['atlas', 'profile', 'cut', 'cuts', 'rig', 'motion', 'out', 'manifest', 'images'],
|
|
1393
|
+
},
|
|
1394
|
+
{
|
|
1395
|
+
name: 'ingest',
|
|
1396
|
+
runtime: false,
|
|
1397
|
+
spineFormat: true,
|
|
1398
|
+
usage: ['rigc ingest <skeleton.json> --out <dir> [--name <n>] [--art loose|none] [--images <dir>] [--stage x,y,w,h] [--stage-box <slot>]'],
|
|
1399
|
+
flags: ['out', 'name', 'art', 'images', 'stage', 'stage-box'],
|
|
1400
|
+
overrides: {
|
|
1401
|
+
out: {
|
|
1402
|
+
value: '<dir>',
|
|
1403
|
+
meaning: 'directory to write rig.json, motion.json and findings.json into — the two specs that rebuild this skeleton',
|
|
1404
|
+
},
|
|
1405
|
+
images: {
|
|
1406
|
+
value: '<dir>',
|
|
1407
|
+
meaning:
|
|
1408
|
+
"WRITE the rig spec's own images directory, spelled relative to --out, so the rebuild is a plain `build " +
|
|
1409
|
+
'--rig … --motion … --out …` with no flag. ⚠️ The opposite direction from `build --images`, which ' +
|
|
1410
|
+
'OVERRIDES that field: this one fills it in. Without it the field is left out and every `image` resolves ' +
|
|
1411
|
+
'against --out itself. Refused together with --art none, which writes no `image` for it to be the base of',
|
|
1412
|
+
},
|
|
1413
|
+
},
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
name: 'diff',
|
|
1417
|
+
runtime: false,
|
|
1418
|
+
spineFormat: true,
|
|
1419
|
+
usage: ['rigc diff <candidate.json> <reference.json> [--as <candidate>=<reference>]… [--json <out>]'],
|
|
1420
|
+
flags: ['as', 'json'],
|
|
1421
|
+
overrides: {
|
|
1422
|
+
as: {
|
|
1423
|
+
value: '<candidate>=<reference>',
|
|
1424
|
+
meaning:
|
|
1425
|
+
'pair a candidate animation with a reference one, so the name-agnostic `animations` block can be ' +
|
|
1426
|
+
'measured over shots the two files call different things. Repeatable, one pair each. An INPUT and never ' +
|
|
1427
|
+
'derived: two skeletons cannot say which of their shots are the same shot. Without it the block appears ' +
|
|
1428
|
+
'only when each side has exactly one animation, which pairs by position, and is otherwise absent rather ' +
|
|
1429
|
+
'than guessed',
|
|
1430
|
+
},
|
|
1431
|
+
},
|
|
1432
|
+
notes: [
|
|
1433
|
+
'the `animations` block reads two figures once something has paired the shots, exactly as',
|
|
1434
|
+
'`bones` and `slots` do: name-matched, where `names` lives, and name-agnostic over the pair.',
|
|
1435
|
+
'A candidate that followed a brief withholding the animation name reads `count` 1/1 and 0.000',
|
|
1436
|
+
'on every other name-matched measure — including `duration` and `key_counts` it may have got',
|
|
1437
|
+
'exactly right — so read the pair and not the section mean.',
|
|
1438
|
+
],
|
|
1439
|
+
},
|
|
1440
|
+
{
|
|
1441
|
+
name: 'check',
|
|
1442
|
+
runtime: false,
|
|
1443
|
+
spineFormat: false,
|
|
1444
|
+
usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
|
|
1445
|
+
flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'poser', 'all-frames', 'json', 'out'],
|
|
1446
|
+
overrides: {
|
|
1447
|
+
skin: {
|
|
1448
|
+
meaning:
|
|
1449
|
+
'pose the CANDIDATE under this skin, by the name it declares. Without it no skin is set and the ' +
|
|
1450
|
+
'default skin alone is compared, which for a multi-skin rig is a comparison that can see none of the ' +
|
|
1451
|
+
'contested art. The frames are checked back: a set whose frames.json records a different skin is ' +
|
|
1452
|
+
'REFUSED by name, and one that records none says so in the report rather than pretending to agree',
|
|
1453
|
+
},
|
|
1454
|
+
out: {
|
|
1455
|
+
value: '<dir>',
|
|
1456
|
+
meaning:
|
|
1457
|
+
'also write the PICTURE each listed frame\'s figures came from, as <dir>/<set>/f####.png: reference, ' +
|
|
1458
|
+
'candidate, difference and overlay side by side at the comparison grid\'s native size, with the table\'s ' +
|
|
1459
|
+
'figures burned in and one row per slot under them, beside a frames.json that says what they are pictures ' +
|
|
1460
|
+
'of. The frames are the ones the table lists, so --all-frames writes every compared one. Each <dir>/<set>/ ' +
|
|
1461
|
+
'is cleared first; a file at <dir>, or a directory that is or holds --frames, is refused. See ' +
|
|
1462
|
+
'docs/AUTHORING.md §9.2.1',
|
|
1463
|
+
},
|
|
1464
|
+
},
|
|
1465
|
+
notes: [
|
|
1466
|
+
'every figure here is a reporting threshold, not a pass bar. Nothing in this report',
|
|
1467
|
+
'grades, no number has to beat anything, and the exit code says only whether the',
|
|
1468
|
+
'comparison could be MADE: 0 when it ran — including the build with every easing',
|
|
1469
|
+
'reversed, which is the defect this command exists for — 1 when it could not (frames',
|
|
1470
|
+
'that are not there, a skin the frames do not record, a candidate that will not load),',
|
|
1471
|
+
'2 on the flags.',
|
|
1472
|
+
'',
|
|
1473
|
+
'So read a figure against a floor you measured yourself: render the first green build',
|
|
1474
|
+
'and keep its frames, then check every later build against them. The identity run of',
|
|
1475
|
+
'that pair is the floor, and it is not zero — docs/AUTHORING.md §9.2 states it, what',
|
|
1476
|
+
'it comes from, and which column separates a wrong curve from a moved key.',
|
|
1477
|
+
],
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
name: 'bench',
|
|
1481
|
+
runtime: { for: 'the gate runs before anything is measured, and --bones poses the rung\'s reference skeletons through it' },
|
|
1482
|
+
spineFormat: false,
|
|
1483
|
+
usage: [`rigc bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>] [flags]`],
|
|
1484
|
+
flags: ['candidate', 'atlas', 'frames', 'profile', 'bones', 'all-frames', 'all-bones', 'json'],
|
|
1485
|
+
overrides: {
|
|
1486
|
+
bones: {
|
|
1487
|
+
meaning:
|
|
1488
|
+
'also run the stage-3 per-frame bone world-transform distance against each of the rung\'s reference ' +
|
|
1489
|
+
`skeletons, with this correspondence (or \`identity\`), at ${PROTOCOL_FPS} fps. Reports; gates nothing — ` +
|
|
1490
|
+
'for another sampling rate call `rigc bonedist` directly, where --fps means only that',
|
|
1491
|
+
},
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
name: 'bonedist',
|
|
1496
|
+
runtime: { for: 'it poses both skeletons through it, by design' },
|
|
1497
|
+
spineFormat: false,
|
|
1498
|
+
usage: [
|
|
1499
|
+
`rigc bonedist --candidate <dir | skeleton.json> --reference <dir | skeleton.json> --bones <path | ${IDENTITY_CORRESPONDENCE}> [--fps ${PROTOCOL_FPS}] [--all-bones] [--json <out>]`,
|
|
1500
|
+
],
|
|
1501
|
+
flags: ['candidate', 'atlas', 'reference', 'reference-atlas', 'bones', 'fps', 'all-bones', 'json'],
|
|
1502
|
+
overrides: {
|
|
1503
|
+
fps: { meaning: `the rate both skeletons are sampled at, from t=0 over their own durations (default ${PROTOCOL_FPS})` },
|
|
1504
|
+
},
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
name: 'render',
|
|
1508
|
+
runtime: false,
|
|
1509
|
+
spineFormat: false,
|
|
1510
|
+
usage: [
|
|
1511
|
+
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--poser core|spine] [--out render/]',
|
|
1512
|
+
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
1513
|
+
],
|
|
1514
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'poser', 'out'],
|
|
1515
|
+
overrides: {
|
|
1516
|
+
out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
|
|
1517
|
+
fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
|
|
1518
|
+
},
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
name: 'preview',
|
|
1522
|
+
runtime: { for: "its gate line is the round trip's, and it names the pages it embeds through spine-core's atlas reader" },
|
|
1523
|
+
spineFormat: true,
|
|
1524
|
+
usage: ['rigc preview --candidate <dir | skeleton.json> [--candidate <another> …] [--animation <name>] [--out preview.html]'],
|
|
1525
|
+
flags: ['candidate', 'atlas', 'animation', 'out'],
|
|
1526
|
+
overrides: {
|
|
1527
|
+
candidate: {
|
|
1528
|
+
value: '<dir|skeleton.json>',
|
|
1529
|
+
meaning:
|
|
1530
|
+
'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path. Repeat it ' +
|
|
1531
|
+
'for one page with a pane per candidate, in the order given; the same skeleton twice is refused. Each ' +
|
|
1532
|
+
'header carries the line `rigc validate <dir>` prints for that candidate, measured when the page is written',
|
|
1533
|
+
},
|
|
1534
|
+
atlas: { meaning: "the candidate's atlas, when it is not beside the skeleton — one candidate only" },
|
|
1535
|
+
animation: {
|
|
1536
|
+
meaning:
|
|
1537
|
+
"the animation to start on (default: each candidate's own first). With several candidates every one of " +
|
|
1538
|
+
'them must have it, or the run is refused naming the one that does not',
|
|
1539
|
+
},
|
|
1540
|
+
out: {
|
|
1541
|
+
value: '<file>',
|
|
1542
|
+
meaning: 'the .html file to write (default `preview.html`); a directory means "the default name in here"',
|
|
1543
|
+
},
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
{
|
|
1547
|
+
name: 'pose',
|
|
1548
|
+
runtime: false,
|
|
1549
|
+
spineFormat: false,
|
|
1550
|
+
usage: [
|
|
1551
|
+
`rigc pose --images <dir> --frame <path> [--scale ${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}] [--rotation -180,180] [--out ${DEFAULT_POSE_OUT}]`,
|
|
1552
|
+
],
|
|
1553
|
+
flags: ['images', 'frame', 'scale', 'rotation', 'max-residual', 'out'],
|
|
1554
|
+
overrides: {
|
|
1555
|
+
images: { value: '<dir>', meaning: 'the loose part PNGs to place; every `.png` in it is a part, in name order' },
|
|
1556
|
+
out: {
|
|
1557
|
+
value: '<file>',
|
|
1558
|
+
meaning: `the .json report to write (default \`${DEFAULT_POSE_OUT}\`); a directory means "the default name in here"`,
|
|
1559
|
+
},
|
|
1560
|
+
},
|
|
1561
|
+
},
|
|
1562
|
+
{
|
|
1563
|
+
name: 'chainfit',
|
|
1564
|
+
runtime: false,
|
|
1565
|
+
spineFormat: false,
|
|
1566
|
+
usage: [
|
|
1567
|
+
`rigc chainfit --candidate <dir | skeleton.json> --images <dir> --frame <path> [--anchor pose.json] [--out ${DEFAULT_CHAINFIT_OUT}]`,
|
|
1568
|
+
],
|
|
1569
|
+
flags: [
|
|
1570
|
+
'candidate',
|
|
1571
|
+
'images',
|
|
1572
|
+
'frame',
|
|
1573
|
+
'anchor',
|
|
1574
|
+
'hinge',
|
|
1575
|
+
'stretch',
|
|
1576
|
+
'min-visible',
|
|
1577
|
+
'max-residual',
|
|
1578
|
+
'passes',
|
|
1579
|
+
'anchor-residual',
|
|
1580
|
+
'inward-lever',
|
|
1581
|
+
'scale',
|
|
1582
|
+
'rotation',
|
|
1583
|
+
'out',
|
|
1584
|
+
],
|
|
1585
|
+
overrides: {
|
|
1586
|
+
images: {
|
|
1587
|
+
value: '<dir>',
|
|
1588
|
+
meaning:
|
|
1589
|
+
"where each attachment's image name resolves to a loose PNG. ⚠️ NOT a part list the way `pose --images` " +
|
|
1590
|
+
'is one — the candidate decides what the parts are, so extra PNGs in here are simply unused and a name ' +
|
|
1591
|
+
'the directory lacks is refused by name',
|
|
1592
|
+
},
|
|
1593
|
+
scale: {
|
|
1594
|
+
meaning:
|
|
1595
|
+
'the scale window the INTERNAL anchor pass searches, as frame pixels per part pixel (default ' +
|
|
1596
|
+
`\`${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}\`). Refused together with --anchor, which means there is no internal pass`,
|
|
1597
|
+
},
|
|
1598
|
+
rotation: {
|
|
1599
|
+
meaning:
|
|
1600
|
+
'the rotation window the INTERNAL anchor pass searches, in screen degrees (default `-180,180`). Refused ' +
|
|
1601
|
+
'together with --anchor — the chains\' own window is --hinge',
|
|
1602
|
+
},
|
|
1603
|
+
out: {
|
|
1604
|
+
value: '<file>',
|
|
1605
|
+
meaning: `the .json report to write (default \`${DEFAULT_CHAINFIT_OUT}\`); a directory means "the default name in here"`,
|
|
1606
|
+
},
|
|
1607
|
+
},
|
|
1608
|
+
},
|
|
1609
|
+
{
|
|
1610
|
+
name: 'vote',
|
|
1611
|
+
runtime: { for: "it names each candidate's pages through spine-core's atlas reader, as preview does" },
|
|
1612
|
+
spineFormat: true,
|
|
1613
|
+
usage: [
|
|
1614
|
+
`rigc vote --candidate <dir | skeleton.json> --candidate <…> [--candidate …] [--animation <name>] [--out ${DEFAULT_BALLOT}]`,
|
|
1615
|
+
`rigc vote --record <result.json> [--ballot ${DEFAULT_BALLOT}] [--ledger ${DEFAULT_LEDGER}] [--again]`,
|
|
1616
|
+
],
|
|
1617
|
+
flags: ['candidate', 'animation', 'out', 'record', 'ballot', 'ledger', 'again'],
|
|
1618
|
+
overrides: {
|
|
1619
|
+
candidate: {
|
|
1620
|
+
value: '<dir|skeleton.json>',
|
|
1621
|
+
meaning: `repeat it ${MIN_CANDIDATES}–${MAX_CANDIDATES} times — one compiled artifact per pane, labelled A, B, C, D in the order given`,
|
|
1622
|
+
},
|
|
1623
|
+
animation: {
|
|
1624
|
+
meaning:
|
|
1625
|
+
'the one animation every pane plays (default: the first of candidate A). A candidate that does not have ' +
|
|
1626
|
+
'it is refused — two panes playing two animations is not a comparison',
|
|
1627
|
+
},
|
|
1628
|
+
out: {
|
|
1629
|
+
value: '<file>',
|
|
1630
|
+
meaning: `the .html ballot to write (default \`${DEFAULT_BALLOT}\`); a directory means "the default name in here"`,
|
|
1631
|
+
},
|
|
1632
|
+
},
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
name: 'skills',
|
|
1636
|
+
runtime: false,
|
|
1637
|
+
spineFormat: false,
|
|
1638
|
+
usage: [`rigc skills install [--dir ${DEFAULT_SKILLS_DIR}] [--copy] (every skill this package ships, where an agent host looks)`],
|
|
1639
|
+
flags: ['dir', 'copy'],
|
|
1640
|
+
notes: [
|
|
1641
|
+
'the skills installed are the skills/ directory of the package this command runs from,',
|
|
1642
|
+
'never whatever the working directory holds. Each becomes <dir>/<name>: a relative',
|
|
1643
|
+
'symlink into that folder, or with --copy a copy of it. An entry already there that',
|
|
1644
|
+
'is not a link to the same folder — or, with --copy, not the same bytes — is refused',
|
|
1645
|
+
'by name, exit 1, and nothing is written; a run over only what this command made has',
|
|
1646
|
+
'nothing to do and exits 0. Codex, Gemini CLI and Antigravity read',
|
|
1647
|
+
`<workspace>/${DEFAULT_SKILLS_DIR}; Claude Code installs the plugin instead (README,`,
|
|
1648
|
+
'"Install it into your agent").',
|
|
1649
|
+
],
|
|
1650
|
+
},
|
|
1651
|
+
];
|
|
1652
|
+
|
|
1653
|
+
/** Every command's name, in the order the usage lists them — what the full entry dispatches over. */
|
|
1654
|
+
export const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
|
|
1655
|
+
|
|
1656
|
+
/** `rigc <command> --help`: that command's own usage line(s) and flag table, as the entry running it (`docs`) documents it. */
|
|
1657
|
+
export function commandHelp(name: string, docs: readonly CommandDoc[] = COMMANDS): string {
|
|
1658
|
+
const doc = docs.find((c) => c.name === name);
|
|
1659
|
+
if (!doc) throw new Error(`internal: no help text for command "${name}"`);
|
|
1660
|
+
const keys = [...doc.flags, 'help'];
|
|
1661
|
+
const value = (key: string): string | undefined => doc.overrides?.[key]?.value ?? FLAG_VALUES[key];
|
|
1662
|
+
const meaning = (key: string): string => doc.overrides?.[key]?.meaning ?? FLAG_MEANINGS[key];
|
|
1663
|
+
const labels = keys.map((key) => `--${key}${value(key) ? ` ${value(key)}` : ''}`);
|
|
1664
|
+
const width = Math.max(...labels.map((l) => l.length)) + 2;
|
|
1665
|
+
return [
|
|
1666
|
+
'usage:',
|
|
1667
|
+
...doc.usage.map((u) => ` ${u}`),
|
|
1668
|
+
'',
|
|
1669
|
+
'flags:',
|
|
1670
|
+
...keys.map((key, i) => ` ${labels[i].padEnd(width)}${meaning(key)}`),
|
|
1671
|
+
...(doc.notes === undefined ? [] : ['', ...doc.notes]),
|
|
1672
|
+
].join('\n');
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* The usage's paragraphs under the invocation lines, each with the commands it
|
|
1677
|
+
* is about (issue #1052): an entry prints a paragraph only when every command
|
|
1678
|
+
* it names is one the entry runs, so `cli_core.ts`'s page says nothing about a
|
|
1679
|
+
* command it does not have, and `cli.ts`'s — which runs every one — is the
|
|
1680
|
+
* page it always was, byte for byte. The `--cuts` paragraph is about a flag
|
|
1681
|
+
* rather than a command, and is printed wherever a command takes it. The
|
|
1682
|
+
* `--profile` paragraph states each profile's rule count, which only the
|
|
1683
|
+
* validator knows (`CliEntry.profileRules`).
|
|
1684
|
+
*/
|
|
1685
|
+
const USAGE_PARAGRAPHS: ReadonlyArray<{ about: readonly string[] | { flag: string }; lines: (rules: (profile: 'spine' | 'spine-html') => number) => string[] }> = [
|
|
1686
|
+
{
|
|
1687
|
+
about: ['build', 'validate', 'bench'],
|
|
1688
|
+
lines: (rules) => [
|
|
1689
|
+
'build, validate and bench take --profile spine|spine-html:',
|
|
1690
|
+
' spine is this valid Spine 4.3 that any runtime plays correctly?',
|
|
1691
|
+
` THE DEFAULT — ${rules('spine')} rules, and the question the output answers when`,
|
|
1692
|
+
' you import it into the Spine editor.',
|
|
1693
|
+
' spine-html the above, plus this project\'s renderer and archetype policy:',
|
|
1694
|
+
` all ${rules('spine-html')} rules, opt-in. Those extra ` +
|
|
1695
|
+
`${rules('spine-html') - rules('spine')} fire on real, correct,`,
|
|
1696
|
+
' editor-produced Spine data, so they are somebody\'s policy rather',
|
|
1697
|
+
' than anybody\'s validity.',
|
|
1698
|
+
'',
|
|
1699
|
+
'Every report names the profile that judged it and lists, on PROF lines, the',
|
|
1700
|
+
'rules that profile left out.',
|
|
1701
|
+
],
|
|
1702
|
+
},
|
|
1703
|
+
{
|
|
1704
|
+
about: ['repack'],
|
|
1705
|
+
lines: () => [
|
|
1706
|
+
'repack takes a packed build whose parts were not kept — skeleton.json, skeleton.atlas and',
|
|
1707
|
+
'the pages — and packs it again under build --pack\'s packing flags, so a packer',
|
|
1708
|
+
'improvement reaches a build without its parts:',
|
|
1709
|
+
' rigc repack build/ --out build.free/ --page-edges free --pack-shape polygon',
|
|
1710
|
+
'It writes only after every region is shown pixel-identical, the skeleton byte-identical',
|
|
1711
|
+
'and the gate green, and refuses by name an atlas it cannot lift exactly. See',
|
|
1712
|
+
'`rigc repack --help`.',
|
|
1713
|
+
],
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
about: ['check'],
|
|
1717
|
+
lines: () => [
|
|
1718
|
+
'check renders the candidate onto the reference frames\' own pixel grid, fitting it',
|
|
1719
|
+
'there by its own drawn pixels, and compares. It reads the frames and never the',
|
|
1720
|
+
'reference skeleton, so it belongs INSIDE an authoring loop — the validator cannot',
|
|
1721
|
+
'see a wrong animation and this can. See `rigc check --help` for its flags.',
|
|
1722
|
+
],
|
|
1723
|
+
},
|
|
1724
|
+
{
|
|
1725
|
+
about: ['render', 'preview'],
|
|
1726
|
+
lines: () => [
|
|
1727
|
+
'render and preview are how you LOOK at a build, and they need no reference at all:',
|
|
1728
|
+
' rigc render --candidate <the dir build --out wrote> PNG frames + a contact sheet',
|
|
1729
|
+
' rigc preview --candidate <the same dir> one .html file that plays it',
|
|
1730
|
+
'A rig with its head off its torso passes the gate and steps cleanly — the offsets',
|
|
1731
|
+
'are the ones you asked for — so looking is the only thing that catches it. render',
|
|
1732
|
+
'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
|
|
1733
|
+
'it in the official Spine Web Player, which is also the interop proof.',
|
|
1734
|
+
],
|
|
1735
|
+
},
|
|
1736
|
+
{
|
|
1737
|
+
about: ['ingest'],
|
|
1738
|
+
lines: () => [
|
|
1739
|
+
'ingest runs build backwards: it reads a Spine 4.3 skeleton.json and writes the rig',
|
|
1740
|
+
'spec and motion spec that rebuild it, so an existing skeleton becomes a starting',
|
|
1741
|
+
'point instead of something to retype:',
|
|
1742
|
+
' rigc ingest hero.json --out specs/ --images parts/ rig.json + motion.json',
|
|
1743
|
+
'The contract is an equality, not a rulebook: build(ingest(x)) is x, byte for byte.',
|
|
1744
|
+
'It reads the skeleton and nothing else — no .spine project, no binary .skel, no',
|
|
1745
|
+
'atlas — so how the spec reaches the art is the caller\'s (--art) and is not guessed.',
|
|
1746
|
+
'A setup stage the skeleton states is read; one it does not state is carried as',
|
|
1747
|
+
'absent, and --stage is how a caller adds a box to such a file — beside a box the',
|
|
1748
|
+
'file states, the flag is refused rather than ignored. --images <dir> WRITES the rig',
|
|
1749
|
+
'spec\'s own images directory, relative to --out, so the rebuild needs no flag.',
|
|
1750
|
+
'Everything the spec format cannot hold is printed as a named finding and',
|
|
1751
|
+
'exits non-zero, with both files still written, because a spec plus a list of what',
|
|
1752
|
+
'is missing from it beats no spec at all.',
|
|
1753
|
+
],
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
about: ['pose'],
|
|
1757
|
+
lines: () => [
|
|
1758
|
+
'pose runs the other way round from everything above: it reads a picture you already',
|
|
1759
|
+
'have — one key pose — and reports where each loose part PNG sits in it (x, y, rotation,',
|
|
1760
|
+
'scale) so an agent can state those poses in a spec by construction:',
|
|
1761
|
+
' rigc pose --images parts/ --frame poseA.png pose.json, one entry per part',
|
|
1762
|
+
'It grades nothing and no pass bar attaches to its numbers. The residual is a trust',
|
|
1763
|
+
'signal, and where two placements are equally good it reports BOTH rather than picking —',
|
|
1764
|
+
'two identical limbs look exactly like that. A part that matches nowhere, a part the',
|
|
1765
|
+
'canvas cannot contain and a part whose rotation is a free degree of freedom are each',
|
|
1766
|
+
'named as such. See `rigc pose --help`.',
|
|
1767
|
+
],
|
|
1768
|
+
},
|
|
1769
|
+
{
|
|
1770
|
+
about: ['pose', 'chainfit'],
|
|
1771
|
+
lines: () => [
|
|
1772
|
+
'chainfit reads the half of that picture pose refuses. It is the same question with',
|
|
1773
|
+
'one more input — the candidate rig — and that input buys two things: draw order, so',
|
|
1774
|
+
'the pixels another part covers are EXCLUDED from a part\'s residual instead of',
|
|
1775
|
+
'charged to it, and hierarchy, so a child of a placed bone is searched over one hinge',
|
|
1776
|
+
'instead of four degrees of freedom:',
|
|
1777
|
+
' rigc chainfit --candidate build/ --images parts/ --frame poseA.png',
|
|
1778
|
+
'Every residual is over the part\'s VISIBLE pixels and comes with the `visibleShare` it',
|
|
1779
|
+
'was computed on, so a mostly-hidden answer carries its own uncertainty. It grades',
|
|
1780
|
+
'nothing either: a part too far behind the others is refused by the visibility floor,',
|
|
1781
|
+
'a limb with no trusted part on it or above it is refused `no-anchor`, and two hinge',
|
|
1782
|
+
'answers that explain the picture equally well are both reported. See',
|
|
1783
|
+
'`rigc chainfit --help`.',
|
|
1784
|
+
],
|
|
1785
|
+
},
|
|
1786
|
+
{
|
|
1787
|
+
about: ['preview', 'vote'],
|
|
1788
|
+
lines: () => [
|
|
1789
|
+
'vote is the same page with two to four builds in it and an answer coming back:',
|
|
1790
|
+
' rigc vote --candidate <build A> --candidate <build B> ballot.html, panes labelled A and B',
|
|
1791
|
+
' rigc vote --record vote-<id>.json --ballot ballot.html check it, append it to votes.jsonl',
|
|
1792
|
+
'Reach for it where the instruments have run out — a choice with no reference behind',
|
|
1793
|
+
'it, two fits that measure the same. The panes carry no paths, a tie is a recorded',
|
|
1794
|
+
'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
|
|
1795
|
+
'refused by name instead of appended.',
|
|
1796
|
+
],
|
|
1797
|
+
},
|
|
1798
|
+
{
|
|
1799
|
+
about: ['skills'],
|
|
1800
|
+
lines: () => [
|
|
1801
|
+
'skills install puts the agent skills this package ships where an agent host looks',
|
|
1802
|
+
'for them, since none of them reads node_modules:',
|
|
1803
|
+
` rigc skills install relative links in ${DEFAULT_SKILLS_DIR}, which Codex, Gemini CLI`,
|
|
1804
|
+
' and Antigravity read; --copy writes the folders instead',
|
|
1805
|
+
'An entry already there that this command did not make is refused by name and',
|
|
1806
|
+
'nothing is written; a second run has nothing to do. See `rigc skills --help`.',
|
|
1807
|
+
],
|
|
1808
|
+
},
|
|
1809
|
+
{
|
|
1810
|
+
about: { flag: 'cuts' },
|
|
1811
|
+
lines: () => [
|
|
1812
|
+
'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
|
|
1813
|
+
' "manifest": "..." (optional) } }, with every path',
|
|
1814
|
+
'resolved relative to the cuts.json file itself.',
|
|
1815
|
+
|
|
1816
|
+
],
|
|
1817
|
+
},
|
|
1818
|
+
];
|
|
1819
|
+
|
|
1820
|
+
/**
|
|
1821
|
+
* The usage page of an entry that runs `docs` — the commands' invocation
|
|
1822
|
+
* lines, then every paragraph about only those commands (`USAGE_PARAGRAPHS`).
|
|
1823
|
+
* `checkout` is the file a source checkout runs this entry as.
|
|
1824
|
+
*/
|
|
1825
|
+
export function usageText(docs: readonly CommandDoc[], checkout: string, rules: ((profile: 'spine' | 'spine-html') => number) | undefined): string {
|
|
1826
|
+
const names = new Set(docs.map((doc) => doc.name));
|
|
1827
|
+
const takes = (flag: string): boolean => docs.some((doc) => doc.flags.includes(flag));
|
|
1828
|
+
const lines = [
|
|
1829
|
+
'rigc — the rig compiler',
|
|
1830
|
+
'',
|
|
1831
|
+
checkout === 'cli.ts'
|
|
1832
|
+
? '(from a source checkout: `bun cli.ts <command>` is the same as `rigc <command>`)'
|
|
1833
|
+
: `(installed: \`rigc\` runs this entry where @esotericsoftware/spine-core is not installed beside the package, and every command once it is; ` +
|
|
1834
|
+
`from a source checkout: \`bun ${checkout} <command>\` runs the commands below, which link nothing of spine-core; \`bun cli.ts <command>\` runs every command)`,
|
|
1835
|
+
'',
|
|
1836
|
+
'usage:',
|
|
1837
|
+
...docs.flatMap((c) => c.usage.map((u) => ` ${u}`)),
|
|
1838
|
+
'',
|
|
1839
|
+
' rigc <command> --help that command\'s own flag table',
|
|
1840
|
+
' rigc --version print the installed version (-v works too)',
|
|
1841
|
+
];
|
|
1842
|
+
// Read only by the --profile paragraph, which only an entry running a command that takes --profile prints.
|
|
1843
|
+
const counts =
|
|
1844
|
+
rules ??
|
|
1845
|
+
((): number => {
|
|
1846
|
+
throw new Error('internal: the --profile paragraph is printed and this entry states no rule counts');
|
|
1847
|
+
});
|
|
1848
|
+
for (const paragraph of USAGE_PARAGRAPHS) {
|
|
1849
|
+
const about = paragraph.about;
|
|
1850
|
+
const shown = 'flag' in about ? takes(about.flag) : about.every((name) => names.has(name));
|
|
1851
|
+
if (shown) lines.push('', ...paragraph.lines(counts));
|
|
1852
|
+
}
|
|
1853
|
+
return lines.join('\n');
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// ---------------------------------------------------------------------------
|
|
1857
|
+
// what both `build` bodies share (issue #1060): the flags, the header and the compile's report
|
|
1858
|
+
// ---------------------------------------------------------------------------
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* Read `--profile`, defaulting to `spine` — see `CLI_DEFAULT_PROFILE`.
|
|
1862
|
+
*
|
|
1863
|
+
* An unknown name is a usage error rather than a silent fallback, and that
|
|
1864
|
+
* matters in both directions: a typo used to re-apply the strictest rulebook to
|
|
1865
|
+
* data the caller was trying to exempt, and it would now drop the policy layer
|
|
1866
|
+
* from a caller who typed `--profile spine-htlm` and believes they asked for it.
|
|
1867
|
+
* Neither is something to discover from a green.
|
|
1868
|
+
*/
|
|
1869
|
+
export function readProfile(flags: Record<string, string>): AssertionProfile {
|
|
1870
|
+
const raw = flags.profile;
|
|
1871
|
+
if (raw === undefined) return CLI_DEFAULT_PROFILE;
|
|
1872
|
+
const found = VALIDATE_PROFILES.find((p) => p === raw);
|
|
1873
|
+
if (!found) throw new UsageError(`--profile ${JSON.stringify(raw)}; known profiles: ${VALIDATE_PROFILES.join(', ')}`);
|
|
1874
|
+
return found;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Read one non-negative integer flag, or its default.
|
|
1879
|
+
*
|
|
1880
|
+
* A usage error rather than a `NaN` that reaches the packer: `--padding two`
|
|
1881
|
+
* would otherwise place every region at NaN and write a blank page, which is a
|
|
1882
|
+
* green build and an empty picture.
|
|
1883
|
+
*/
|
|
1884
|
+
export function readIntFlag(flags: Record<string, string>, name: string, fallback: number): number {
|
|
1885
|
+
const raw = flags[name];
|
|
1886
|
+
if (raw === undefined) return fallback;
|
|
1887
|
+
if (!/^\d+$/.test(raw)) throw new UsageError(`--${name} takes a non-negative integer, got ${JSON.stringify(raw)}`);
|
|
1888
|
+
return Number(raw);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* Read `--page-edges`, or its default — `pot`.
|
|
1893
|
+
*
|
|
1894
|
+
* An unknown value is a usage error for `readProfile`'s reason: a typo that fell
|
|
1895
|
+
* back to `pot` would hand the caller who asked for the smaller page the bigger
|
|
1896
|
+
* one, green, and say nothing.
|
|
1897
|
+
*/
|
|
1898
|
+
export function readPageEdges(flags: Record<string, string>): PageEdges {
|
|
1899
|
+
const raw = flags['page-edges'];
|
|
1900
|
+
if (raw === undefined) return DEFAULT_PAGE_EDGES;
|
|
1901
|
+
const found = PAGE_EDGES.find((e) => e === raw);
|
|
1902
|
+
if (!found) throw new UsageError(`--page-edges ${JSON.stringify(raw)}; known values: ${PAGE_EDGES.join(', ')}`);
|
|
1903
|
+
return found;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* Read `--pack-shape`, or its default — `rect` (issue #1099).
|
|
1908
|
+
*
|
|
1909
|
+
* Refused on any other value for `readPageEdges`'s reason: a typo that fell
|
|
1910
|
+
* back to `rect` would hand the caller who asked for the denser page the
|
|
1911
|
+
* looser one, green, and say nothing.
|
|
1912
|
+
*/
|
|
1913
|
+
export function readPackShape(flags: Record<string, string>): PackShape {
|
|
1914
|
+
const raw = flags['pack-shape'];
|
|
1915
|
+
if (raw === undefined) return DEFAULT_PACK_SHAPE;
|
|
1916
|
+
const found = PACK_SHAPES.find((e) => e === raw);
|
|
1917
|
+
if (!found) throw new UsageError(`--pack-shape ${JSON.stringify(raw)}; known values: ${PACK_SHAPES.join(', ')}`);
|
|
1918
|
+
return found;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/**
|
|
1922
|
+
* The parts of a compile as `packAtlas` takes them — under `polygon` each with
|
|
1923
|
+
* the footprint its attachments draw, read off the skeleton text the build
|
|
1924
|
+
* emitted (`packFootprints`); under `rect` without, which is the input every
|
|
1925
|
+
* pack had before #1099.
|
|
1926
|
+
*/
|
|
1927
|
+
function packInputs(result: CompileResult, shape: PackShape): PackInput[] {
|
|
1928
|
+
const sizes = new Map(result.images.map((img) => [img.region, { width: img.width, height: img.height }]));
|
|
1929
|
+
const footprints = shape === 'polygon' ? packFootprints(result.skeletonText, (region) => sizes.get(region)) : null;
|
|
1930
|
+
return result.images.map((img) => {
|
|
1931
|
+
const footprint = footprints?.get(img.region) ?? undefined;
|
|
1932
|
+
return {
|
|
1933
|
+
region: img.region,
|
|
1934
|
+
absPath: img.absPath,
|
|
1935
|
+
width: img.width,
|
|
1936
|
+
height: img.height,
|
|
1937
|
+
...(footprint === undefined ? {} : { footprint }),
|
|
1938
|
+
};
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* The rectangle a region occupies **on its page**, for a line that has already
|
|
1944
|
+
* said where the region is — and the empty string where the page rectangle is
|
|
1945
|
+
* the one `bounds:` already states.
|
|
1946
|
+
*
|
|
1947
|
+
* ## The fact no surface an author reads carried (issue #718)
|
|
1948
|
+
*
|
|
1949
|
+
* The atlas line beside this clause prints the DRAWING's size, because that is
|
|
1950
|
+
* what an attachment's width and height mean. A packer that turned the drawing a
|
|
1951
|
+
* quarter to fit it wrote `bounds:` in the drawing's orientation too. So an
|
|
1952
|
+
* author holding the pack and the build report had neither end of the rectangle
|
|
1953
|
+
* they have to cut out of the page to measure a part against a rendered frame —
|
|
1954
|
+
* and the one place rigc printed it was `A06`'s overlap text, reachable only
|
|
1955
|
+
* under `--profile spine-html`. The knowledge was in the tree the whole time:
|
|
1956
|
+
* `pageFootprint` has derived this rectangle for every reader of it since issue
|
|
1957
|
+
* #579, and nothing an author reads said it.
|
|
1958
|
+
*
|
|
1959
|
+
* ⚠️ **The condition is `pageFootprint`'s own answer, not a second reading of
|
|
1960
|
+
* `degrees`.** Re-spelling that predicate here is the exact duplication #579 was
|
|
1961
|
+
* filed on — four readers derived this rectangle and two derived it wrongly — so
|
|
1962
|
+
* the clause asks the function whether its answer differs from the `bounds:`
|
|
1963
|
+
* line, and prints only then.
|
|
1964
|
+
*
|
|
1965
|
+
* 🔸 A consequence worth stating rather than leaving to be discovered: a region
|
|
1966
|
+
* whose KEPT rectangle is square is silent here, because a quarter turn leaves
|
|
1967
|
+
* its footprint the same two numbers and there is nothing the pack does not
|
|
1968
|
+
* already say. The general rule — `bounds` is the unturned size, the footprint
|
|
1969
|
+
* is its transpose at `rotate: 90` and `rotate: 270`, and which way to turn the
|
|
1970
|
+
* rectangle to recover the drawing — belongs to an author's own reading and is
|
|
1971
|
+
* stated in `docs/AUTHORING.md` §0.2, which holds for every region including
|
|
1972
|
+
* that one.
|
|
1973
|
+
*/
|
|
1974
|
+
function pageRectangle(region: AtlasRegion): string {
|
|
1975
|
+
const foot = pageFootprint(region);
|
|
1976
|
+
return foot.width === region.width && foot.height === region.height
|
|
1977
|
+
? ''
|
|
1978
|
+
: `, occupies ${foot.width}x${foot.height}`;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* `build`'s invocation, read and refused before anything compiles — the cut,
|
|
1983
|
+
* the profile, and the flag combinations that disagree about one question —
|
|
1984
|
+
* for both bodies of the command (issue #1060): the one that writes the Spine
|
|
1985
|
+
* pair (`./spine_commands.ts`) and the one that writes the model document
|
|
1986
|
+
* alone (`./core_commands.ts`). Moved here unchanged from `cmdBuild`.
|
|
1987
|
+
*/
|
|
1988
|
+
export function readBuildInvocation(flags: Record<string, string>): { label: string; opts: CompileOptions; profile: AssertionProfile; packing: boolean } {
|
|
1989
|
+
const { label, opts } = resolveCut(flags);
|
|
1990
|
+
const profile = readProfile(flags);
|
|
1991
|
+
const packing = flags.pack !== undefined;
|
|
1992
|
+
// Two combinations are refused rather than silently resolved, because in each
|
|
1993
|
+
// one the two flags disagree about a single question and there is no answer
|
|
1994
|
+
// that is not a guess about which the caller meant. (There were three until
|
|
1995
|
+
// issue #266 — see the note below the second.)
|
|
1996
|
+
if (packing && opts.atlasInPath !== undefined) {
|
|
1997
|
+
throw new UsageError(
|
|
1998
|
+
'--pack and --atlas-in are opposite directions through the same door: --pack MAKES an atlas out of the ' +
|
|
1999
|
+
'loose parts, --atlas-in resolves the parts against one somebody already made. Pick one',
|
|
2000
|
+
);
|
|
2001
|
+
}
|
|
2002
|
+
if (packing && flags['copy-images'] !== undefined) {
|
|
2003
|
+
throw new UsageError(
|
|
2004
|
+
'--pack already writes self-contained pages into --out (that is what packing is), and --copy-images copies ' +
|
|
2005
|
+
'the loose part PNGs, which a packed atlas does not reference. Drop --copy-images',
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
// The copy itself happens after the gate (below). The header has to know NOW,
|
|
2009
|
+
// because the skeleton text the gate reads is the skeleton text that is written
|
|
2010
|
+
// — `skeleton.images` says where the parts will be (issue #370).
|
|
2011
|
+
if (flags['copy-images'] !== undefined) opts.copyImages = true;
|
|
2012
|
+
// `--pack --profile spine-html` used to be the third refusal here, because
|
|
2013
|
+
// A06's coverage clause was "one part per page" flat and a legitimate pack
|
|
2014
|
+
// arrived at the gate reading as a defect. Since issue #266's second follow-up
|
|
2015
|
+
// that clause is "one part per page OR a tiling page", so the combination is
|
|
2016
|
+
// now a build like any other — and it is the only one that puts the renderer's
|
|
2017
|
+
// own rulebook over shared-page sampling.
|
|
2018
|
+
if (!packing) {
|
|
2019
|
+
for (const name of ['page-size', 'padding', 'page-edges', 'pack-shape'] as const) {
|
|
2020
|
+
if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
return { label, opts, profile, packing };
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
/** The lines a build opens on: the cut, then its two input files. */
|
|
2027
|
+
export function printBuildHeader(label: string, opts: CompileOptions): void {
|
|
2028
|
+
console.log(`rigc build ${label}`);
|
|
2029
|
+
// Named explicitly and on their own lines rather than folded into the header
|
|
2030
|
+
// above: with two input files, a header that names only one of them (the rig,
|
|
2031
|
+
// historically) reads as though it were the one at fault whenever the error
|
|
2032
|
+
// that follows actually comes from the other.
|
|
2033
|
+
console.log(` .. rig ${opts.rigPath}`);
|
|
2034
|
+
console.log(` .. motion ${opts.motionPath}`);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
/** What a compile measured, as `build` reports it before its gate: the parts, the dropped states, the absent parts, the meshes, the physics. */
|
|
2038
|
+
export function printCompiled(opts: CompileOptions, result: Pick<CompileResult, 'images' | 'droppedStates' | 'absentParts' | 'meshes' | 'rig' | 'physics'>): void {
|
|
2039
|
+
if (opts.atlasInPath !== undefined) console.log(` .. atlas-in ${opts.atlasInPath}`);
|
|
2040
|
+
console.log(` .. ${result.images.length} part page(s):`);
|
|
2041
|
+
for (const img of result.images) {
|
|
2042
|
+
// An imported part says where on the page it came from, because "resolved
|
|
2043
|
+
// against a region" is the claim `--atlas-in` makes and a line that only
|
|
2044
|
+
// repeated the page filename would look identical for all of them. A page
|
|
2045
|
+
// that declares a `scale:` also says so and shows the texels it was read
|
|
2046
|
+
// from: the size on the left is the DRAWING's and the rectangle is the
|
|
2047
|
+
// pack's, and issue #267 is the report that printed the second as the first.
|
|
2048
|
+
//
|
|
2049
|
+
// `pageRectangle` closes the line's last silence (issue #718), and it is
|
|
2050
|
+
// placed LAST rather than beside the turn it follows from, which is where
|
|
2051
|
+
// the card put it. The two clauses collide nowhere else, and the collision
|
|
2052
|
+
// is real: `scale 0.5 (373x106 texels)` is itself a size, so
|
|
2053
|
+
// `rotate 90, occupies 106x373 scale 0.5 (…)` reads as though the footprint
|
|
2054
|
+
// were the scaled quantity. As a trailing clause of the whole location
|
|
2055
|
+
// phrase it is unambiguous with a `scale:` line and identical to the card's
|
|
2056
|
+
// wording without one, which is every pack that has no `scale:` to state.
|
|
2057
|
+
const where =
|
|
2058
|
+
img.atlas === undefined
|
|
2059
|
+
? img.page
|
|
2060
|
+
: `${img.page} @ ${img.atlas.x},${img.atlas.y}${img.atlas.degrees ? ` rotate ${img.atlas.degrees}` : ''}` +
|
|
2061
|
+
(img.atlasScale === undefined
|
|
2062
|
+
? ''
|
|
2063
|
+
: ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`) +
|
|
2064
|
+
pageRectangle(img.atlas);
|
|
2065
|
+
console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
|
|
2066
|
+
}
|
|
2067
|
+
for (const d of result.droppedStates) console.log(dropLine(d));
|
|
2068
|
+
// "The optional slots are optional" is a claim about this code path, so this
|
|
2069
|
+
// code path says which ones it left out rather than being silently right.
|
|
2070
|
+
for (const a of result.absentParts) {
|
|
2071
|
+
console.log(` ABSENT ${a.slot}: ${a.why} — slot not emitted`);
|
|
2072
|
+
}
|
|
2073
|
+
for (const m of result.meshes) {
|
|
2074
|
+
console.log(
|
|
2075
|
+
` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
|
|
2076
|
+
`${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}` +
|
|
2077
|
+
meshDepthNote(m) +
|
|
2078
|
+
meshInfluenceNote(m),
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
for (const ph of result.physics) {
|
|
2082
|
+
console.log(
|
|
2083
|
+
` PHYS ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] ` +
|
|
2084
|
+
`mix=${ph.mix}${ph.drivesMesh ? ' <- drives a mesh: its canvas re-rasterises while the spring settles' : ''}`,
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
/**
|
|
2091
|
+
* An atlas text to gate INSTEAD of the compile's own, with the second, independent
|
|
2092
|
+
* emit A18 compares it against.
|
|
2093
|
+
*
|
|
2094
|
+
* `--pack` is the only caller. A packed build is gated twice on purpose — once as
|
|
2095
|
+
* compiled (which is the gate that reads the loose PNGs, so `A06`'s size-vs-file
|
|
2096
|
+
* clause still measures the art R5 measures) and once as packed (which is the pair
|
|
2097
|
+
* that actually ships). Handing the second pass its texts rather than re-deriving
|
|
2098
|
+
* them here keeps `runGate` ignorant of what a pack is.
|
|
2099
|
+
*/
|
|
2100
|
+
interface AtlasOverride {
|
|
2101
|
+
text: string;
|
|
2102
|
+
again: string;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* What one gate of `build` is handed (issue #1060): the compile, the atlas text
|
|
2107
|
+
* it gates (the compile's own, or the packed one), the directory its pages
|
|
2108
|
+
* resolve against, the document `build` writes, and a second, independent
|
|
2109
|
+
* compile's three texts for A18 — the same arguments `cli.ts build` has always
|
|
2110
|
+
* handed `validate()`, so each entry's gate reads one statement of them.
|
|
2111
|
+
*/
|
|
2112
|
+
export interface BuildGateInput {
|
|
2113
|
+
result: CompileResult;
|
|
2114
|
+
atlasText: string;
|
|
2115
|
+
atlasDir: string;
|
|
2116
|
+
modelText: string;
|
|
2117
|
+
reEmit: { skeletonText: string; atlasText: string; modelText: string };
|
|
2118
|
+
profile: AssertionProfile;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* The gate a `build` body runs, by entry (issue #1060): `cli.ts`'s is the
|
|
2123
|
+
* round trip through spine-core (`./spine_commands.ts`), `cli_core.ts`'s the
|
|
2124
|
+
* model side and the rules restated over the emitted text
|
|
2125
|
+
* (`./core_commands.ts`). Everything else `build` does — compile, the
|
|
2126
|
+
* document, `--copy-images`, `--pack`, the writes and their order — is
|
|
2127
|
+
* `runBuild`'s, written once, so the two entries write the same build.
|
|
2128
|
+
*/
|
|
2129
|
+
export interface BuildGate {
|
|
2130
|
+
/** Which supplier this gate is, as the `--report` document names it (issue #1213). */
|
|
2131
|
+
supplier: BuildReportSupplier;
|
|
2132
|
+
/** The line before the first gate's report, naming what judges it. */
|
|
2133
|
+
heading: (profile: AssertionProfile) => string;
|
|
2134
|
+
/** Run the gate, print its report, and return the lists it printed — and, on the core entry, its `here:` line's values. */
|
|
2135
|
+
run: (input: BuildGateInput) => { report: VerdictLists & { profile: AssertionProfile }; here: GateHere | null };
|
|
2136
|
+
/** The command a green build ends by naming, for its own `--out`. */
|
|
2137
|
+
look: (outDir: string) => string;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
function runGate(
|
|
2141
|
+
gate: BuildGate,
|
|
2142
|
+
record: BuildReport | null,
|
|
2143
|
+
atlasKind: BuildReportGate['atlas'],
|
|
2144
|
+
result: CompileResult,
|
|
2145
|
+
modelText: string,
|
|
2146
|
+
opts: CompileOptions,
|
|
2147
|
+
profile: AssertionProfile,
|
|
2148
|
+
/** The atlas text `build` writes for a compile's own — `--copy-images` renames the pages — which the second compile's document is spelled from, as `modelText` was (issue #1016). */
|
|
2149
|
+
written: (atlasText: string) => string,
|
|
2150
|
+
atlas?: AtlasOverride,
|
|
2151
|
+
): number {
|
|
2152
|
+
// The determinism check compares a second, independent compile — its model
|
|
2153
|
+
// document included, which is the text `build` writes beside the pair, and
|
|
2154
|
+
// which states where each region sits in the atlas written with it (`pages`,
|
|
2155
|
+
// issue #1016): so the second document is spelled from the second compile's
|
|
2156
|
+
// atlas as it would be written, or from the second, independent pack.
|
|
2157
|
+
const again = compile(opts);
|
|
2158
|
+
const { report, here } = gate.run({
|
|
2159
|
+
result,
|
|
2160
|
+
atlasText: atlas ? atlas.text : result.atlasText,
|
|
2161
|
+
atlasDir: opts.outDir,
|
|
2162
|
+
modelText,
|
|
2163
|
+
reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText, atlas ? atlas.again : written(again.atlasText)) },
|
|
2164
|
+
profile,
|
|
2165
|
+
});
|
|
2166
|
+
record?.gates.push(buildReportGate(atlasKind, report, here));
|
|
2167
|
+
return report.failures.length;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// ---------------------------------------------------------------------------
|
|
2171
|
+
// --report: the build's report as a document (issue #1213)
|
|
2172
|
+
// ---------------------------------------------------------------------------
|
|
2173
|
+
|
|
2174
|
+
/**
|
|
2175
|
+
* A `--report` in the making: where it goes, and what the run has stated so
|
|
2176
|
+
* far — every gate's lists and every `pack:` line's figures, recorded where
|
|
2177
|
+
* the line is printed and from the values it is printed from. `write` spells
|
|
2178
|
+
* the document (`buildReportText`) and writes it; nothing else in it is read
|
|
2179
|
+
* off the disk, the clock or the machine.
|
|
2180
|
+
*/
|
|
2181
|
+
export interface BuildReport {
|
|
2182
|
+
path: string;
|
|
2183
|
+
command: BuildReportDocument['command'];
|
|
2184
|
+
supplier: BuildReportSupplier;
|
|
2185
|
+
gates: BuildReportGate[];
|
|
2186
|
+
pack: PackPageFigures[] | null;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/** The document a recorded run states, keys in the order the spec writes them. */
|
|
2190
|
+
export function buildReportDocument(record: BuildReport): BuildReportDocument {
|
|
2191
|
+
return { spec: BUILD_REPORT_SPEC, command: record.command, supplier: record.supplier, gates: record.gates, pack: record.pack };
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/** Write what the run stated to `--report`. Called when a gate has reached its verdict: before a red gate's exit, after a green build's last write. */
|
|
2195
|
+
export function writeBuildReport(record: BuildReport): void {
|
|
2196
|
+
writeFileSync(record.path, buildReportText(buildReportDocument(record)));
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
/**
|
|
2200
|
+
* `--report <file>` read for a command whose build goes into `outDir`: the
|
|
2201
|
+
* file resolved against the working directory, refused by name when it is
|
|
2202
|
+
* inside `outDir` or is `outDir` — the build's directory holds the files A18
|
|
2203
|
+
* and `emit_hashes` hold byte-identical, and a report there would be a file of
|
|
2204
|
+
* the build that is not the build — and refused when a directory stands at the
|
|
2205
|
+
* path. A file already at the path is removed here, before anything is
|
|
2206
|
+
* compiled, so the file at `--report` is always this run's: a run that reaches
|
|
2207
|
+
* no gate (a usage refusal after this point, a compile error, a repack refused
|
|
2208
|
+
* before or after its gate) leaves no document rather than an earlier run's.
|
|
2209
|
+
* `null` when the flag is absent.
|
|
2210
|
+
*/
|
|
2211
|
+
export function readReportFlag(flags: Record<string, string>, command: BuildReport['command'], outDir: string, supplier: BuildReportSupplier): BuildReport | null {
|
|
2212
|
+
const raw = flags.report;
|
|
2213
|
+
if (raw === undefined) return null;
|
|
2214
|
+
const path = resolve(raw);
|
|
2215
|
+
const out = resolve(outDir);
|
|
2216
|
+
const within = relative(out, path);
|
|
2217
|
+
if (within === '' || (!within.startsWith('..') && !isAbsolute(within))) {
|
|
2218
|
+
throw new UsageError(
|
|
2219
|
+
`--report ${raw} is inside --out ${outDir}: the report is written beside a build, never into it — --out holds the build's own files, ` +
|
|
2220
|
+
'which A18 and emit_hashes hold byte-identical. Name a path outside --out',
|
|
2221
|
+
);
|
|
2222
|
+
}
|
|
2223
|
+
if (existsSync(path) && statSync(path).isDirectory()) throw new UsageError(`--report ${raw} is a directory; it takes the path of the file to write`);
|
|
2224
|
+
rmSync(path, { force: true });
|
|
2225
|
+
return { path, command, supplier, gates: [], pack: null };
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* One `pack:` line's figures (issue #1213): what the line prints, as values —
|
|
2230
|
+
* `coveredPct` at the one decimal the line spells, so the document and the
|
|
2231
|
+
* line state the same number.
|
|
2232
|
+
*/
|
|
2233
|
+
function packPageFigures(page: { name: string; width: number; height: number; occupancy: number }, regions: number, padding: number, pageEdges: PageEdges, packShape: PackShape): PackPageFigures {
|
|
2234
|
+
return { page: page.name, width: page.width, height: page.height, regions, coveredPct: Number((page.occupancy * 100).toFixed(1)), padding, pageEdges, packShape };
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/** The `pack:` line, spelled from its figures. */
|
|
2238
|
+
function packLine(f: PackPageFigures): string {
|
|
2239
|
+
return (
|
|
2240
|
+
` .. pack: ${f.page} ${f.width}x${f.height}, ` +
|
|
2241
|
+
`${f.regions} region(s), ` +
|
|
2242
|
+
`${f.coveredPct.toFixed(1)}% covered, padding ${f.padding}` +
|
|
2243
|
+
(f.pageEdges === 'free' ? ', page edges free' : '') +
|
|
2244
|
+
// Appended, never inserted: a reader that takes the line whole keeps
|
|
2245
|
+
// every field it read before #1099, and the mode is named under the
|
|
2246
|
+
// default too, so no reader infers it from an absent word.
|
|
2247
|
+
`, shape ${f.packShape}`
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* The model document `build` wrote beside a skeleton, when the one beside it
|
|
2253
|
+
* is that build's: a `skeleton.model.json` in the skeleton's directory whose
|
|
2254
|
+
* `spine.sha256` is the digest of exactly this skeleton text (the digest the
|
|
2255
|
+
* core poser checks before it poses a build, issue #968). Since issue #907 a
|
|
2256
|
+
* rigc build's header carries the setup-pose bounding box and its stage is the
|
|
2257
|
+
* document's, so a gate run over a directory reads A14's and A19's stage from
|
|
2258
|
+
* this document, as `build`'s own gate did, and `ingest` reads the rebuild's
|
|
2259
|
+
* stage from it (`documentStageBeside`); a skeleton with no such document
|
|
2260
|
+
* beside it — an export, or a document of another skeleton — is read off its
|
|
2261
|
+
* header, as before.
|
|
2262
|
+
*/
|
|
2263
|
+
export function modelTextBeside(skeletonPath: string, skeletonText: string): string | undefined {
|
|
2264
|
+
const path = join(dirname(skeletonPath), MODEL_DOCUMENT_FILE);
|
|
2265
|
+
if (!existsSync(path)) return undefined;
|
|
2266
|
+
const text = readFileSync(path, 'utf8');
|
|
2267
|
+
let digest: unknown;
|
|
2268
|
+
try {
|
|
2269
|
+
const doc: unknown = JSON.parse(text);
|
|
2270
|
+
digest = typeof doc === 'object' && doc !== null ? (doc as { spine?: { sha256?: unknown } }).spine?.sha256 : undefined;
|
|
2271
|
+
} catch {
|
|
2272
|
+
return undefined;
|
|
2273
|
+
}
|
|
2274
|
+
return digest === spineFileSha256(skeletonText) ? text : undefined;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/**
|
|
2278
|
+
* The stage a rigc build's model document states, for `ingest` (issue #907):
|
|
2279
|
+
* the `rigc-compiled/3` document beside the skeleton whose digest is that
|
|
2280
|
+
* skeleton's (`modelTextBeside`) — its `stage`, four numbers or `null` for a
|
|
2281
|
+
* rig that declared none. `undefined` — read the header, as before — for an
|
|
2282
|
+
* export or a bare file (no document), a document of another skeleton, a
|
|
2283
|
+
* `/2` or `/1` document (written when the header still was the stage), or a
|
|
2284
|
+
* `stage` that is not one of those two shapes.
|
|
2285
|
+
*
|
|
2286
|
+
* ⭐ Why `ingest` needs it: since #907 a rigc build's header carries the
|
|
2287
|
+
* setup-pose bounding box, and the stage is only in the document. Reading the
|
|
2288
|
+
* header as the stage would rebuild a rig whose stage is its own bounding box
|
|
2289
|
+
* — gallery/nod's 640x700 stage came back 640x725 — so `A14` and `A19` on the
|
|
2290
|
+
* rebuild would measure against a box the author never stated.
|
|
2291
|
+
*/
|
|
2292
|
+
export function documentStageBeside(skeletonPath: string, skeletonText: string): IngestStage | null | undefined {
|
|
2293
|
+
const text = modelTextBeside(skeletonPath, skeletonText);
|
|
2294
|
+
if (text === undefined) return undefined;
|
|
2295
|
+
const doc = JSON.parse(text) as { spec?: unknown; stage?: unknown };
|
|
2296
|
+
if (doc.spec !== MODEL_DOCUMENT_SPEC || !('stage' in doc)) return undefined;
|
|
2297
|
+
const stage = doc.stage;
|
|
2298
|
+
if (stage === null) return null;
|
|
2299
|
+
if (typeof stage !== 'object') return undefined;
|
|
2300
|
+
const { x, y, width, height } = stage as Record<string, unknown>;
|
|
2301
|
+
return typeof x === 'number' && typeof y === 'number' && typeof width === 'number' && typeof height === 'number' ? { x, y, width, height } : undefined;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/**
|
|
2305
|
+
* The line `build` prints when the rig declares a stage and the header carries
|
|
2306
|
+
* no setup-pose bounding box (issue #907): why — nothing drawn, a region with
|
|
2307
|
+
* no atlas rectangle, or a setup pose rigc's core leaves out
|
|
2308
|
+
* (`headerBoundsOf`). Silent where the box is written, or where the rig
|
|
2309
|
+
* declares no stage, which asked for no box.
|
|
2310
|
+
*/
|
|
2311
|
+
function printHeaderBox(result: CompileResult): void {
|
|
2312
|
+
if (result.model.stage === null || typeof result.skeleton.skeleton.width === 'number') return;
|
|
2313
|
+
const { why } = headerBoundsOf(result.model, result.atlasText);
|
|
2314
|
+
if (why !== null) console.log(` .. header no setup-pose bounding box — ${why}`);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/**
|
|
2318
|
+
* build — moved here unchanged from `./spine_commands.ts` (issue #1060) but
|
|
2319
|
+
* for its gate, which the entry hands in (`BuildGate`): both entries write
|
|
2320
|
+
* the Spine pair, the model document and the pages by this one body.
|
|
2321
|
+
*/
|
|
2322
|
+
export function runBuild(flags: Record<string, string>, gate: BuildGate, caller: BuildReport | null = null): void {
|
|
2323
|
+
const { label, opts, profile, packing } = readBuildInvocation(flags);
|
|
2324
|
+
// `--report` (issue #1213): `build`'s own, written by this body on either verdict — or the caller's
|
|
2325
|
+
// (`repack`'s), which this body writes on a red gate, since the exit leaves the caller no turn, and which the
|
|
2326
|
+
// caller writes when it has written its own output.
|
|
2327
|
+
const own = caller === null ? readReportFlag(flags, 'build', opts.outDir, gate.supplier) : null;
|
|
2328
|
+
const record = caller ?? own;
|
|
2329
|
+
printBuildHeader(label, opts);
|
|
2330
|
+
const result = compile(opts);
|
|
2331
|
+
printCompiled(opts, result);
|
|
2332
|
+
printHeaderBox(result);
|
|
2333
|
+
|
|
2334
|
+
// The model's document is spelled before the gate, so the text A18 compares
|
|
2335
|
+
// is the text written, and a model the document cannot carry is refused
|
|
2336
|
+
// before anything is (issue #922). It states where each region sits in the
|
|
2337
|
+
// atlas written beside it (`pages`, issue #1016), so it is spelled from that
|
|
2338
|
+
// atlas: under `--copy-images` the text with the copies' page names, planned
|
|
2339
|
+
// here from the text alone (`plannedPageCopies`) and copied after the gate.
|
|
2340
|
+
// Under `--pack` the pages move again after this gate, and the document
|
|
2341
|
+
// written is the one the packed gate below spells and compares.
|
|
2342
|
+
const copying = flags['copy-images'] !== undefined;
|
|
2343
|
+
const writtenAtlas = (atlasText: string): string => (copying ? plannedPageCopies(atlasText, opts.outDir).atlasText : atlasText);
|
|
2344
|
+
let modelText = modelDocument(result.model, result.skeletonText, writtenAtlas(result.atlasText));
|
|
2345
|
+
console.log(gate.heading(profile));
|
|
2346
|
+
const failures = runGate(gate, record, 'compiled', result, modelText, opts, profile, writtenAtlas);
|
|
2347
|
+
if (failures > 0) {
|
|
2348
|
+
if (record !== null) writeBuildReport(record);
|
|
2349
|
+
console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
|
|
2350
|
+
process.exit(1);
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
mkdirSync(opts.outDir, { recursive: true });
|
|
2354
|
+
|
|
2355
|
+
// `--copy-images`: `--out` is otherwise NOT self-contained — a page's default
|
|
2356
|
+
// path is relative to the source art (often `../parts/foo.png`), which is
|
|
2357
|
+
// correct for a build sitting beside the project it came from and breaks the
|
|
2358
|
+
// moment the directory is zipped, committed or moved on its own (issue #217).
|
|
2359
|
+
// Opt-in only: the default stays exactly what it has always been.
|
|
2360
|
+
//
|
|
2361
|
+
// What is copied is what the ATLAS names, not what the image list holds: under
|
|
2362
|
+
// `--atlas-in` the two are different lists, and rebuilding the text from the
|
|
2363
|
+
// second wrote a file the pack never contained — zero bytes for a rig that
|
|
2364
|
+
// declares no parts, one fabricated page per part for a rig that does, both of
|
|
2365
|
+
// them green here because the gate above had already read the compile's own
|
|
2366
|
+
// text (issue #693, `src/emit.ts`).
|
|
2367
|
+
let atlasText = result.atlasText;
|
|
2368
|
+
if (flags['copy-images'] !== undefined) {
|
|
2369
|
+
const copied = copyAtlasPages(atlasText, opts.outDir);
|
|
2370
|
+
// The document's pages were spelled from the plan; the copy is held to it before anything of the pair is written.
|
|
2371
|
+
if (copied.atlasText !== writtenAtlas(result.atlasText)) {
|
|
2372
|
+
throw new Error(`internal: --copy-images wrote an atlas whose page names are not the ones ${MODEL_DOCUMENT_FILE} was spelled with — nothing of the pair was written`);
|
|
2373
|
+
}
|
|
2374
|
+
atlasText = copied.atlasText;
|
|
2375
|
+
console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
|
|
2376
|
+
for (const p of copied.pages) {
|
|
2377
|
+
const note = p.to === basename(p.from) ? '' : ' (renamed — basename collision)';
|
|
2378
|
+
console.log(` .. ${p.to.padEnd(24)} <- ${p.from} (${p.regions} region(s))${note}`);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// `--pack`: the parts go onto shared pages, which are written here as real
|
|
2383
|
+
// PNGs, so `--out` is self-contained by construction. The atlas above stays
|
|
2384
|
+
// the one the gate just read — packing changes only the ARRANGEMENT of the
|
|
2385
|
+
// bytes, and the sizes in `result.images` are still the ones measured off the
|
|
2386
|
+
// loose PNGs (see src/atlas.ts's header).
|
|
2387
|
+
if (packing) {
|
|
2388
|
+
const packOpts = {
|
|
2389
|
+
pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
|
|
2390
|
+
padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
|
|
2391
|
+
pageEdges: readPageEdges(flags),
|
|
2392
|
+
shape: readPackShape(flags),
|
|
2393
|
+
pageStem: 'skeleton',
|
|
2394
|
+
};
|
|
2395
|
+
const packed = packAtlas(packInputs(result, packOpts.shape), packOpts);
|
|
2396
|
+
atlasText = packed.atlasText;
|
|
2397
|
+
if (record !== null) record.pack = [];
|
|
2398
|
+
for (const page of packed.pages) {
|
|
2399
|
+
page.plate.writePng(join(opts.outDir, page.name));
|
|
2400
|
+
const figures = packPageFigures(page, packed.placements.filter((p) => packed.pages[p.page].name === page.name).length, packed.padding, packOpts.pageEdges, packed.shape);
|
|
2401
|
+
record?.pack?.push(figures);
|
|
2402
|
+
console.log(packLine(figures));
|
|
2403
|
+
}
|
|
2404
|
+
for (const place of packed.placements) {
|
|
2405
|
+
console.log(
|
|
2406
|
+
` .. ${place.region.padEnd(24)} ${place.width}x${place.height} -> ` +
|
|
2407
|
+
`${packed.pages[place.page].name} @ ${place.x},${place.y}`,
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
// The pages are on disk now, so the packed pair can be gated as an artifact
|
|
2411
|
+
// rather than trusted as a construction: A17 stats every page, A06 reads its
|
|
2412
|
+
// IHDR back, A07 re-reads the text shape, A08 re-joins every attachment onto
|
|
2413
|
+
// a region, and A18 compares a second independent compile+pack. Two gates on
|
|
2414
|
+
// one build is the cost of shipping a second atlas shape.
|
|
2415
|
+
console.log(' .. validate (packed atlas, pages on disk)');
|
|
2416
|
+
const packAgain = packAtlas(packInputs(compile(opts), packOpts.shape), packOpts);
|
|
2417
|
+
// The document written is spelled from the packed atlas, and this gate's A18 compares it with a
|
|
2418
|
+
// second compile's spelled from the second, independent pack (issue #1016).
|
|
2419
|
+
modelText = modelDocument(result.model, result.skeletonText, atlasText);
|
|
2420
|
+
const packFailures = runGate(gate, record, 'packed', result, modelText, opts, profile, (text) => text, { text: atlasText, again: packAgain.atlasText });
|
|
2421
|
+
if (packFailures > 0) {
|
|
2422
|
+
if (record !== null) writeBuildReport(record);
|
|
2423
|
+
console.error(
|
|
2424
|
+
`rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
|
|
2425
|
+
`${opts.outDir}, the skeleton/atlas pair was not`,
|
|
2426
|
+
);
|
|
2427
|
+
process.exit(1);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
|
|
2432
|
+
writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
|
|
2433
|
+
// rigc's own record of the compiled rig (`rigc-compiled/3`, issue #922;
|
|
2434
|
+
// its `pages` the atlas just written, issue #1016), written with the pair
|
|
2435
|
+
// and only after the same gate — under `--pack`, the packed one. rigc's own posing core
|
|
2436
|
+
// reads it (`readModel`, `src/core/index.ts`, issue #380's step 2).
|
|
2437
|
+
writeFileSync(join(opts.outDir, MODEL_DOCUMENT_FILE), modelText);
|
|
2438
|
+
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
|
|
2439
|
+
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
|
|
2440
|
+
console.log(`rigc: wrote ${join(opts.outDir, MODEL_DOCUMENT_FILE)}`);
|
|
2441
|
+
// Written after the build, and printed nowhere: the lines a build prints are the same with the flag and without it.
|
|
2442
|
+
if (own !== null) writeBuildReport(own);
|
|
2443
|
+
// The next command is part of the message (issue #837). A green build is the
|
|
2444
|
+
// moment somebody wants to see what came out, and the one page rigc writes
|
|
2445
|
+
// for that is `preview` of exactly this directory — so the line names it,
|
|
2446
|
+
// with the path already resolved. Printed only here: a red build wrote
|
|
2447
|
+
// nothing, so there is nothing to look at and no line.
|
|
2448
|
+
console.log(gate.look(opts.outDir));
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/**
|
|
2452
|
+
* One `DROP` line, written once because two outcomes print it.
|
|
2453
|
+
*
|
|
2454
|
+
* A build that succeeds prints it in its report; a build that REFUSES prints it
|
|
2455
|
+
* under the refusal (issue #671), and the two have to be the same line or the
|
|
2456
|
+
* failing run would be quoting a different fact from the one the green run
|
|
2457
|
+
* shows. What it names — a file or a region — is `droppedStateReason`'s, in the
|
|
2458
|
+
* compiler, beside the code that decided which of the two was consulted.
|
|
2459
|
+
*/
|
|
2460
|
+
export function dropLine(dropped: DroppedState): string {
|
|
2461
|
+
return ` DROP ${dropped.slot}/${dropped.state}: ${droppedStateReason(dropped)} (state not emitted)`;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// ---------------------------------------------------------------------------
|
|
2465
|
+
// the dispatch — one for every entry (issue #1052)
|
|
2466
|
+
// ---------------------------------------------------------------------------
|
|
2467
|
+
|
|
2468
|
+
/** What `rigc <command> …` hands a command's body: the flags, every occurrence of a repeatable one, and the positionals. */
|
|
2469
|
+
export interface CommandArgs {
|
|
2470
|
+
flags: Record<string, string>;
|
|
2471
|
+
lists: Record<string, string[]>;
|
|
2472
|
+
positional: string[];
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/** One command's body, as an entry registers it. */
|
|
2476
|
+
export type CommandRun = (args: CommandArgs) => void;
|
|
2477
|
+
|
|
2478
|
+
/**
|
|
2479
|
+
* A refusal a body throws that the shared chain below does not know, because
|
|
2480
|
+
* the class lives in a module only one entry links — `bonedist`'s, whose
|
|
2481
|
+
* module poses through spine-core. Printed as `prefix` + the message, then
|
|
2482
|
+
* exit `status`.
|
|
2483
|
+
*/
|
|
2484
|
+
export interface CliRefusal {
|
|
2485
|
+
is(err: unknown): boolean;
|
|
2486
|
+
prefix: string;
|
|
2487
|
+
status: number;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/** An entry: the bodies it registers, and what only its side of the seam knows. */
|
|
2491
|
+
export interface CliEntry {
|
|
2492
|
+
/** The file a source checkout runs this entry as — what the usage's first line names. */
|
|
2493
|
+
checkout: string;
|
|
2494
|
+
/**
|
|
2495
|
+
* Whether this entry links spine-core: `true` runs every command `COMMANDS`
|
|
2496
|
+
* documents, `false` only those whose `runtime` is `false` — the set is read
|
|
2497
|
+
* off the table, never listed beside it.
|
|
2498
|
+
*/
|
|
2499
|
+
linksRuntime: boolean;
|
|
2500
|
+
/** Every command this entry runs, by name — exactly its set, or the run refuses to start. */
|
|
2501
|
+
runs: Readonly<Record<string, CommandRun>>;
|
|
2502
|
+
/** Each profile's rule count, for the `--profile` paragraph; the validator's, so only an entry that links it states it. */
|
|
2503
|
+
profileRules?: (profile: 'spine' | 'spine-html') => number;
|
|
2504
|
+
/** The refusals of bodies only this entry registers (`CliRefusal`). */
|
|
2505
|
+
refusals?: readonly CliRefusal[];
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/**
|
|
2509
|
+
* The commands an entry runs, as its page documents them: every documented
|
|
2510
|
+
* one, or — linking nothing of the runtime — those whose `runtime` is `false`
|
|
2511
|
+
* and those with a body of their own there (`runtime.core`, issue #1060),
|
|
2512
|
+
* documented by that body.
|
|
2513
|
+
*/
|
|
2514
|
+
export function entryCommands(linksRuntime: boolean): CommandDoc[] {
|
|
2515
|
+
if (linksRuntime) return COMMANDS;
|
|
2516
|
+
return COMMANDS.flatMap((doc): CommandDoc[] => {
|
|
2517
|
+
if (doc.runtime === false) return [doc];
|
|
2518
|
+
const core = doc.runtime.core;
|
|
2519
|
+
return core === undefined ? [] : [{ ...doc, usage: core.usage, flags: core.flags ?? doc.flags, notes: core.notes }];
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
/**
|
|
2524
|
+
* What the refusal of a command that re-runs the gate says when its target is
|
|
2525
|
+
* a rigc build (issue #1097): that build already ran the gate, before it wrote
|
|
2526
|
+
* anything. A consumer met the refusal and read it as a gate the install could
|
|
2527
|
+
* not run, because the refusal said only what `validate` needs (#1095).
|
|
2528
|
+
*
|
|
2529
|
+
* ⚠️ Every clause is a fact about what the directory carries, never a reading
|
|
2530
|
+
* of the document: `skeleton.model.json` beside the pair is what makes it a
|
|
2531
|
+
* rigc build (`resolveBuild`'s `modelPath`), and emit-only-after-green is
|
|
2532
|
+
* what makes a rigc build gated on write. It does not say which entry wrote
|
|
2533
|
+
* it, which spec version the document is, or that the skeleton is still the
|
|
2534
|
+
* one the gate passed — nothing here reads the document, so those are said as
|
|
2535
|
+
* where to look (`spine.sha256`), not as findings.
|
|
2536
|
+
*/
|
|
2537
|
+
export function gatedOnWriteSentence(skeletonPath: string): string {
|
|
2538
|
+
return (
|
|
2539
|
+
`The target is a rigc build — ${skeletonPath} has ${MODEL_DOCUMENT_FILE} beside it — and a rigc build writes nothing until its gate is green: ` +
|
|
2540
|
+
`that gate ran when the pair was written, and the document is the record of what it passed, its spine.sha256 naming the skeleton bytes the gate read ` +
|
|
2541
|
+
"— on this entry the gate is the model side's rules over the document and the round trip's own restated over the emitted text, " +
|
|
2542
|
+
'with A00_ROUNDTRIP_PARSE alone reported as a SKIP.'
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
/**
|
|
2547
|
+
* `gatedOnWriteSentence` for a refused command marked `rerunsTheGate`, or
|
|
2548
|
+
* `null` — the refusal then says what it always said. The target is
|
|
2549
|
+
* `cmdValidate`'s: the positional (`.` without one) through `resolveBuild`;
|
|
2550
|
+
* a `--cut` or `--rig` run derives its directory from a spec, which the
|
|
2551
|
+
* refusal does not compile, so it keeps the sentence it had. Arguments
|
|
2552
|
+
* `parseArgs` or `resolveBuild` refuses are the command's to refuse, not the
|
|
2553
|
+
* runtime refusal's, and leave it as it was.
|
|
2554
|
+
*/
|
|
2555
|
+
function gatedOnWrite(doc: CommandDoc, args: readonly string[]): string | null {
|
|
2556
|
+
if (doc.runtime === false || doc.runtime.rerunsTheGate !== true) return null;
|
|
2557
|
+
try {
|
|
2558
|
+
const { flags, positional } = parseArgs([...args], REPEATABLE_FLAGS[doc.name]);
|
|
2559
|
+
if (flags.cut !== undefined || flags.rig !== undefined) return null;
|
|
2560
|
+
const build = resolveBuild(positional[0] ?? '.', flags.atlas);
|
|
2561
|
+
return build.modelPath === null ? null : gatedOnWriteSentence(build.skeletonPath);
|
|
2562
|
+
} catch (err) {
|
|
2563
|
+
if (err instanceof UsageError) return null;
|
|
2564
|
+
throw err;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
/**
|
|
2569
|
+
* A command `COMMANDS` documents and this entry does not run, because its body
|
|
2570
|
+
* reaches spine-core and the entry links none of it — refused naming what the
|
|
2571
|
+
* command runs through the runtime for, and the commands the entry does run.
|
|
2572
|
+
* A `SpineRuntimeError`, so it is printed and exits as the export an entry
|
|
2573
|
+
* cannot pose is.
|
|
2574
|
+
*/
|
|
2575
|
+
function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[], args: readonly string[]): SpineRuntimeError {
|
|
2576
|
+
const needs = doc.runtime === false ? 'nothing' : doc.runtime.for;
|
|
2577
|
+
const gated = gatedOnWrite(doc, args);
|
|
2578
|
+
return new SpineRuntimeError(
|
|
2579
|
+
`\`${doc.name}\` runs through spine-core (${needs}), and the runtime could not be used: ${SPINE_SIDE_ABSENT}. ` +
|
|
2580
|
+
(gated === null ? '' : `${gated} `) +
|
|
2581
|
+
`The commands this entry runs are ${runs.join(', ')}; the entry that links spine-core runs \`${doc.name}\` — ` +
|
|
2582
|
+
`installed, \`rigc ${doc.name}\` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, \`bun cli.ts ${doc.name}\``,
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
/**
|
|
2587
|
+
* Run `argv` (`process.argv` without the runtime and the script) through
|
|
2588
|
+
* `entry`: the usage, `--version`, a command's `--help`, the command, and
|
|
2589
|
+
* every refusal printed and mapped to its exit code — the dispatch `cli.ts`
|
|
2590
|
+
* always had, written once for both entries.
|
|
2591
|
+
*/
|
|
2592
|
+
export function runCli(entry: CliEntry, argv: readonly string[]): void {
|
|
2593
|
+
const docs = entryCommands(entry.linksRuntime);
|
|
2594
|
+
const known = docs.map((doc) => doc.name);
|
|
2595
|
+
const registered = Object.keys(entry.runs).sort();
|
|
2596
|
+
if (JSON.stringify([...known].sort()) !== JSON.stringify(registered)) {
|
|
2597
|
+
throw new Error(`internal: this entry runs [${known.join(', ')}] by the command table and registers [${registered.join(', ')}]`);
|
|
2598
|
+
}
|
|
2599
|
+
const USAGE = usageText(docs, entry.checkout, entry.profileRules);
|
|
2600
|
+
const [command, ...rest] = argv;
|
|
2601
|
+
try {
|
|
2602
|
+
if (command === undefined) {
|
|
2603
|
+
console.error(USAGE);
|
|
2604
|
+
process.exit(2);
|
|
2605
|
+
}
|
|
2606
|
+
if (command === '--version' || command === '-v') {
|
|
2607
|
+
console.log(readVersion());
|
|
2608
|
+
process.exit(0);
|
|
2609
|
+
}
|
|
2610
|
+
if (command === '--help' || command === '-h') {
|
|
2611
|
+
console.log(USAGE);
|
|
2612
|
+
process.exit(0);
|
|
2613
|
+
}
|
|
2614
|
+
if (!known.includes(command)) {
|
|
2615
|
+
const elsewhere = COMMANDS.find((doc) => doc.name === command);
|
|
2616
|
+
if (elsewhere !== undefined) throw commandNeedsRuntime(elsewhere, known, rest);
|
|
2617
|
+
throw new UsageError(`unknown command: ${command}`);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
const { flags, lists, positional } = parseArgs([...rest], REPEATABLE_FLAGS[command]);
|
|
2621
|
+
if (flags.help !== undefined) {
|
|
2622
|
+
console.log(commandHelp(command, docs));
|
|
2623
|
+
process.exit(0);
|
|
2624
|
+
}
|
|
2625
|
+
entry.runs[command]({ flags, lists, positional });
|
|
2626
|
+
} catch (err) {
|
|
2627
|
+
refuse(err, command, USAGE, entry);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
/**
|
|
2632
|
+
* Every refusal a command can end on, printed and mapped to its exit code —
|
|
2633
|
+
* the chain `cli.ts`'s dispatch always ended on, moved here unchanged (issue
|
|
2634
|
+
* #1052) but for `bonedist`'s refusal, which its entry hands over
|
|
2635
|
+
* (`CliEntry.refusals`). Anything else is not a refusal and is thrown on.
|
|
2636
|
+
*/
|
|
2637
|
+
function refuse(err: unknown, command: string | undefined, USAGE: string, entry: CliEntry): never {
|
|
2638
|
+
if (err instanceof UsageError) {
|
|
2639
|
+
console.error(`rigc: ${err.message}\n\n${USAGE}`);
|
|
2640
|
+
process.exit(2);
|
|
2641
|
+
}
|
|
2642
|
+
// A ballot refuses on its arguments, like a usage error, but its messages are
|
|
2643
|
+
// long enough that reprinting the whole usage under them buries the reason.
|
|
2644
|
+
if (err instanceof BallotError) {
|
|
2645
|
+
console.error(`rigc vote: ${err.message}`);
|
|
2646
|
+
process.exit(2);
|
|
2647
|
+
}
|
|
2648
|
+
if (err instanceof CompileError) {
|
|
2649
|
+
console.error(`rigc compile error: ${err.message}`);
|
|
2650
|
+
// The drops the compile recorded before it stopped, in the same line the
|
|
2651
|
+
// green build prints (issue #671). They were reported from the compile
|
|
2652
|
+
// RESULT alone, so the run that failed BECAUSE a file was missing was the
|
|
2653
|
+
// one run that never named the file. On stderr with the refusal rather than
|
|
2654
|
+
// on stdout, so redirecting one stream does not separate a fact from the
|
|
2655
|
+
// sentence it explains.
|
|
2656
|
+
for (const dropped of err.droppedStates ?? []) console.error(dropLine(dropped));
|
|
2657
|
+
process.exit(1);
|
|
2658
|
+
}
|
|
2659
|
+
if (err instanceof CheckError) {
|
|
2660
|
+
console.error(`rigc check error: ${err.message}`);
|
|
2661
|
+
process.exit(1);
|
|
2662
|
+
}
|
|
2663
|
+
// The refusals of bodies only this entry registers — `bonedist`'s, whose module poses through spine-core — checked
|
|
2664
|
+
// where the chain always checked them. Every class in it is unrelated to every other, so the place is not load-bearing.
|
|
2665
|
+
for (const refusal of entry.refusals ?? []) {
|
|
2666
|
+
if (refusal.is(err)) {
|
|
2667
|
+
console.error(`${refusal.prefix}${(err as Error).message}`);
|
|
2668
|
+
process.exit(refusal.status);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
// Like a usage error in kind — a missing directory, an unreadable frame — but
|
|
2672
|
+
// its messages name a path and a reason, and reprinting the whole usage under
|
|
2673
|
+
// them buries that.
|
|
2674
|
+
if (err instanceof PoseError) {
|
|
2675
|
+
console.error(`rigc pose: ${err.message}`);
|
|
2676
|
+
process.exit(2);
|
|
2677
|
+
}
|
|
2678
|
+
// A refusal of the invocation, like the two below it, and exit 2 for the same
|
|
2679
|
+
// reason: nothing was posed and nothing was written, so it is the command line
|
|
2680
|
+
// that has to change (issue #697).
|
|
2681
|
+
if (err instanceof ExplainError) {
|
|
2682
|
+
console.error(`rigc explain: ${err.message}`);
|
|
2683
|
+
process.exit(2);
|
|
2684
|
+
}
|
|
2685
|
+
// Same kind as a PoseError, and printed the same way for the same reason: the
|
|
2686
|
+
// messages name a path, a bone or an attachment, and reprinting the whole
|
|
2687
|
+
// usage under them buries the one line that says what to change.
|
|
2688
|
+
if (err instanceof ChainFitError) {
|
|
2689
|
+
console.error(`rigc chainfit: ${err.message}`);
|
|
2690
|
+
process.exit(2);
|
|
2691
|
+
}
|
|
2692
|
+
// A usage error in kind — an option that contradicts the file it was given —
|
|
2693
|
+
// and exit 2 for that reason rather than 1: nothing was compiled and nothing
|
|
2694
|
+
// was written, so it is the invocation that has to change (issue #626). It is
|
|
2695
|
+
// raised in `src/ingest.ts` rather than here because the library caller who
|
|
2696
|
+
// passes the same contradiction deserves the same refusal, and one rule in one
|
|
2697
|
+
// place is what stops the two from drifting apart.
|
|
2698
|
+
if (err instanceof IngestError) {
|
|
2699
|
+
console.error(`rigc ingest: ${err.message}`);
|
|
2700
|
+
process.exit(2);
|
|
2701
|
+
}
|
|
2702
|
+
// A repack refused (issue #1169): an atlas it cannot lift exactly, a repack that lost something, or an `--out`
|
|
2703
|
+
// it will not write into. The message names every reason and says that nothing was written; `status` is 1 for a
|
|
2704
|
+
// file that is not what a repack needs and 2 for an invocation that has to change.
|
|
2705
|
+
if (err instanceof RepackError) {
|
|
2706
|
+
console.error(`rigc repack: ${err.message}`);
|
|
2707
|
+
process.exit(err.status);
|
|
2708
|
+
}
|
|
2709
|
+
// A page or frame that is not a PNG rigc can read, from any command that
|
|
2710
|
+
// opens one (`render`, `check`, `preview`, …) — issue #732. The sentence is
|
|
2711
|
+
// the one reader's and already names the file, what it is and what rigc
|
|
2712
|
+
// reads; a stack under it is the tool describing its own internals instead.
|
|
2713
|
+
// Exit 1, like a compile error: the invocation was fine, a file was not.
|
|
2714
|
+
// A pose that is not finite (issues #864, #873): the invocation was fine and
|
|
2715
|
+
// the skeleton posed a NaN or an infinity, so exit 1 like a file that is not
|
|
2716
|
+
// a PNG. Raised by the geometry export and by the framing — the one sentence
|
|
2717
|
+
// naming the bone or vertex and its value — before the first file is written.
|
|
2718
|
+
// A pose whose every drawn vertex sits at one point (issue #997), from
|
|
2719
|
+
// `render` or `check`: exit 2 like *nothing to draw*, the other framing
|
|
2720
|
+
// refusal, because the usual fix is the invocation's — `--skin` — and nothing
|
|
2721
|
+
// was written. A `GeometryError` in kind, so it is caught before that.
|
|
2722
|
+
if (err instanceof UnframeablePoseError) {
|
|
2723
|
+
console.error(`rigc ${command}: ${err.message}`);
|
|
2724
|
+
process.exit(2);
|
|
2725
|
+
}
|
|
2726
|
+
if (err instanceof GeometryError) {
|
|
2727
|
+
console.error(`rigc render: ${err.message}`);
|
|
2728
|
+
process.exit(1);
|
|
2729
|
+
}
|
|
2730
|
+
// `--poser core` on an input the core cannot carry (issue #968): a refusal of
|
|
2731
|
+
// the invocation, nothing written, and the message names the input and why —
|
|
2732
|
+
// the usage under it would bury that. Without the flag the same refusal is a
|
|
2733
|
+
// fallback to spine-core, named on the render's `poser` line instead.
|
|
2734
|
+
if (err instanceof PoserChoiceError) {
|
|
2735
|
+
console.error(`rigc ${command}: ${err.message}`);
|
|
2736
|
+
process.exit(2);
|
|
2737
|
+
}
|
|
2738
|
+
if (err instanceof NotAPngError) {
|
|
2739
|
+
console.error(`rigc: ${err.message}`);
|
|
2740
|
+
process.exit(1);
|
|
2741
|
+
}
|
|
2742
|
+
// An input posed through spine-core — a Spine export, `--poser spine`, a
|
|
2743
|
+
// fallback — on a run where the runtime cannot be used (issue #1014). The
|
|
2744
|
+
// sentence names the input and why it needs the runtime; exit 1, like a file
|
|
2745
|
+
// that is not a PNG: the invocation was fine and the run could not pose it.
|
|
2746
|
+
// A candidate with no atlas beside it that has to be read through one (issue
|
|
2747
|
+
// #1020): a refusal of the invocation like a missing atlas on an export, exit
|
|
2748
|
+
// 2 and nothing written, and the message names the file and why it is needed.
|
|
2749
|
+
if (err instanceof CandidateAtlasError) {
|
|
2750
|
+
console.error(`rigc ${command}: ${err.message}`);
|
|
2751
|
+
process.exit(2);
|
|
2752
|
+
}
|
|
2753
|
+
// A skeleton spine-core could not load against the atlas beside it (issue #1033) — on a rigc build, a
|
|
2754
|
+
// skeleton.json or an atlas from another build beside this one's model document. The same class as the
|
|
2755
|
+
// missing atlas above: nothing was posed or written, and it is the directory the command was pointed at that
|
|
2756
|
+
// has to change. The message names both files, the reason the runtime drew them and the runtime's own words.
|
|
2757
|
+
// Since issue #1042 also `bonedist` and `bench --bones` on either side, and a page a candidate is drawn from that
|
|
2758
|
+
// is not there, whichever poser draws it.
|
|
2759
|
+
if (err instanceof CandidatePairError) {
|
|
2760
|
+
console.error(`rigc ${command}: ${err.message}`);
|
|
2761
|
+
process.exit(2);
|
|
2762
|
+
}
|
|
2763
|
+
if (err instanceof SpineRuntimeError) {
|
|
2764
|
+
console.error(`rigc ${command}: ${err.message}`);
|
|
2765
|
+
process.exit(1);
|
|
2766
|
+
}
|
|
2767
|
+
// An install refused before its first write (issue #831): the invocation was
|
|
2768
|
+
// fine and an entry on disk was not what this command would write, so exit 1
|
|
2769
|
+
// like a file that is not a PNG. The message names every such entry, what is
|
|
2770
|
+
// there and what was required; the usage under it would bury that.
|
|
2771
|
+
if (err instanceof SkillsInstallError) {
|
|
2772
|
+
console.error(`rigc skills install: ${err.message}`);
|
|
2773
|
+
process.exit(1);
|
|
2774
|
+
}
|
|
2775
|
+
throw err;
|
|
2776
|
+
}
|