spine-rigc 2.11.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,408 @@
1
+ /**
2
+ * `rigc repack <build> --out <dir>` — a packed build repacked from its own
3
+ * output, written only after the repack is shown to have lost nothing (issue
4
+ * #1169).
5
+ *
6
+ * A consumer that keeps only `skeleton.json`, `skeleton.atlas` and the page
7
+ * PNGs could not take up a packer improvement (`--page-edges free`,
8
+ * `--pack-shape polygon`) without regenerating its parts upstream. The route
9
+ * it measured by hand — cut each region off its page, `ingest --art loose`,
10
+ * `build --pack` over the cut parts — is this command, in process and through
11
+ * the tree's own functions: `liftAtlas` (`extractRegion` per region), `ingest`,
12
+ * and `runBuild` with the entry's own gate, the body every `build` runs.
13
+ *
14
+ * 🔒 **Nothing reaches `--out` until three things have been shown**, and the
15
+ * command prints each:
16
+ *
17
+ * (a) every region lifted off the NEW pages is pixel-identical, by name, to
18
+ * the same region lifted off the input's pages (`regionDifferences`);
19
+ * (b) the rebuilt `skeleton.json` is byte-identical to the input's. A repack
20
+ * is entitled to change nothing in the skeleton: attachments name their
21
+ * regions and the atlas alone carries where a region sits, so the pack
22
+ * owns the atlas and the pages and none of the skeleton. Measured on the
23
+ * tree's recipes, every repack of a rigc build wrote the same bytes;
24
+ * a difference is refused naming the paths (`skeletonDifferences`);
25
+ * (c) the gate is green — `runBuild` returns only then, having gated the
26
+ * compile and the packed pair exactly as `build --pack` does.
27
+ *
28
+ * The build runs in a work directory of its own under `tmpdir()`
29
+ * (`REPACK_WORK_PREFIX`), which also holds the lifted parts and the two specs;
30
+ * the output is the build alone, the same four kinds of file `build --pack`
31
+ * writes, so a repacked build can be repacked again. The directory is removed
32
+ * when the command ends — green, refused, a gate that exits, or a throw —
33
+ * from this function's `finally` and, for the `process.exit` inside
34
+ * `runBuild` that no `finally` sees, from an `exit` listener. A process killed
35
+ * by a signal runs neither and leaves its one directory.
36
+ *
37
+ * ⚠️ The lifted parts are not kept: the output is the input's shape, and the
38
+ * parts are recoverable from it at any time by the same lift — keeping a
39
+ * second copy of every texel beside the pages would be two records of one
40
+ * thing that can disagree.
41
+ */
42
+ import { parseAtlasText } from '../atlas.ts';
43
+ import { relativeImagesPath } from '../compile.ts';
44
+ import { ingest, INGEST_GUTTERS, IngestError, type IngestFinding, type IngestOptions, IngestSpecRefused, type IngestStage } from '../ingest.ts';
45
+ import { MODEL_DOCUMENT_FILE } from '../model.ts';
46
+ import { atlasRefusals, documentDisagreements, firstLineApart, type InputPage, liftAtlas, polygonOwnedTexels, regionDifferences, REPACK_WORK_PREFIX, RepackError, skeletonDifferences } from '../repack.ts';
47
+ import {
48
+ type BuildGate,
49
+ type CommandArgs,
50
+ documentStageBeside,
51
+ modelTextBeside,
52
+ parseJsonNamed,
53
+ readIntFlag,
54
+ readPackShape,
55
+ readPageEdges,
56
+ readProfile,
57
+ readSkeletonText,
58
+ readVersion,
59
+ resolveBuild,
60
+ runBuild,
61
+ UsageError,
62
+ } from './shared.ts';
63
+ import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE } from '../atlas.ts';
64
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
65
+ import { tmpdir } from 'node:os';
66
+ import { basename, dirname, join, resolve } from 'node:path';
67
+
68
+ /** The flags `repack` takes: the packing flags `build --pack` takes, the gate's profile, the atlas when it is not beside the skeleton, the stage `ingest` takes, and `--out`. */
69
+ /**
70
+ * The switch that writes a rebuild whose skeleton differs from the input's
71
+ * (issue #1169's follow-up). Default off: check (b) refuses a difference, and
72
+ * the refusal names this flag. A general acceptance rather than a switch for one
73
+ * field, because the command cannot know WHY a skeleton differs — only where —
74
+ * and every path is printed when it is accepted.
75
+ */
76
+ export const ACCEPT_FLAG = 'accept-skeleton-differences';
77
+
78
+ export const REPACK_FLAGS = ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', ACCEPT_FLAG] as const;
79
+
80
+ /** `build`'s flags that `repack` does not take, and why — each refused by name rather than ignored. */
81
+ const BUILD_FLAGS_REFUSED: Readonly<Record<string, string>> = {
82
+ rig: 'repack reads the rig back out of the skeleton (ingest); it takes no rig spec',
83
+ motion: 'repack reads the motion back out of the skeleton (ingest); it takes no motion spec',
84
+ manifest: 'repack lifts the parts off the pages; it takes no cut manifest',
85
+ images: 'repack lifts the parts off the pages the atlas names; it takes no parts directory',
86
+ 'copy-images': 'repack always writes self-contained pages into --out, which is what packing is',
87
+ pack: 'repack always packs; the flag would say nothing',
88
+ 'atlas-in': 'repack MAKES a new pack out of the regions of the old one; --atlas-in would keep the old pack — pass the build itself',
89
+ cut: 'repack takes a build directory, not a cut',
90
+ cuts: 'repack takes a build directory, not a cuts table',
91
+ };
92
+
93
+ /** The three files besides the pages that every repacked build is, in the order `runBuild` writes them. */
94
+ const BUILD_TEXTS = ['skeleton.json', 'skeleton.atlas', MODEL_DOCUMENT_FILE] as const;
95
+
96
+ /**
97
+ * A hook between the lift and the build, given the directory the parts were
98
+ * lifted into — the selftest's plant (`RPK`), which alters a lifted part to
99
+ * show check (a) refusing before anything is written. Nothing else passes one.
100
+ */
101
+ export type RepackPlant = (liftDir: string) => void;
102
+
103
+ /** How every gate's closing line opens (`BuildGate.look`), which a staged build drops. */
104
+ const LOOK_LINE = 'rigc: look at it: ';
105
+
106
+ /** How many skeleton differences a refusal lists before it says how many more there are. */
107
+ const SKELETON_DIFFERENCES_SHOWN = 8;
108
+
109
+ /** `--stage x,y,w,h`, read as `ingest` reads it. */
110
+ function readStageFlag(raw: string | undefined): IngestStage | undefined {
111
+ if (raw === undefined) return undefined;
112
+ const parts = raw.split(',').map(Number);
113
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) throw new UsageError(`--stage is ${JSON.stringify(raw)}; give four numbers, x,y,width,height`);
114
+ return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
115
+ }
116
+
117
+ /** Every flag given that `repack` does not take, refused by name. */
118
+ function refuseForeignFlags(flags: Record<string, string>): void {
119
+ const taken = new Set<string>([...REPACK_FLAGS, 'help']);
120
+ for (const name of Object.keys(flags).sort()) {
121
+ if (taken.has(name)) continue;
122
+ const why = BUILD_FLAGS_REFUSED[name];
123
+ throw new UsageError(why === undefined ? `repack takes no --${name}; it takes --${REPACK_FLAGS.join(', --')}` : `repack takes no --${name}: ${why}`);
124
+ }
125
+ }
126
+
127
+ /** `--out`: required, and absent or an empty directory — refused by name otherwise, before anything is read. */
128
+ function readOut(flags: Record<string, string>): string {
129
+ if (flags.out === undefined) throw new UsageError('repack needs --out <dir> — the directory the repacked build is written into');
130
+ const out = resolve(flags.out);
131
+ if (existsSync(out)) {
132
+ if (!statSync(out).isDirectory()) throw new RepackError(`--out ${out} is a file; it takes a directory, absent or empty — nothing was written`, 2);
133
+ const held = readdirSync(out).sort();
134
+ if (held.length > 0) {
135
+ throw new RepackError(
136
+ `--out ${out} is not empty (${held.slice(0, 4).join(', ')}${held.length > 4 ? `, … ${held.length} entries` : ''}) — a page an earlier pack wrote and this one does not ` +
137
+ 'would stay beside the new atlas with nothing naming it, so repack writes only into an absent or empty directory; nothing was written',
138
+ 2,
139
+ );
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * The one place `repack` calls `ingest`'s options: the skeleton read back with
147
+ * `--art loose` (an `image` per attachment, named after the region it draws,
148
+ * which is the file the lift wrote), the parts directory spelled from the
149
+ * specs, and the stage by `ingest`'s own rules — a caller's `--stage`, else
150
+ * the model document beside the skeleton when it is this skeleton's, else the
151
+ * header. A new `ingest` option goes in here and nowhere else.
152
+ */
153
+ function ingestOptionsFor(
154
+ skeletonPath: string,
155
+ specsDir: string,
156
+ liftDir: string,
157
+ stage: IngestStage | undefined,
158
+ stageBox: string | undefined,
159
+ documentStage: IngestStage | null | undefined,
160
+ ): IngestOptions {
161
+ return {
162
+ ...(stageBox === undefined ? {} : { stageBox }),
163
+ ...(documentStage === undefined ? {} : { documentStage }),
164
+ name: basename(skeletonPath, '.json'),
165
+ art: 'loose',
166
+ images: relativeImagesPath(specsDir, liftDir),
167
+ stage,
168
+ source: basename(skeletonPath),
169
+ version: readVersion(),
170
+ };
171
+ }
172
+
173
+ /** The stage line: the box the rebuild declares and where it came from (issue #1169's step 6). */
174
+ function stageLine(
175
+ rig: { skeleton?: { x?: number | null; y?: number | null; width?: number | null; height?: number | null } },
176
+ flagged: boolean,
177
+ stageBox: string | undefined,
178
+ documentStage: IngestStage | null | undefined,
179
+ ): string {
180
+ const head = rig.skeleton;
181
+ const box =
182
+ head !== undefined && typeof head.width === 'number' && typeof head.height === 'number'
183
+ ? `${head.width} x ${head.height} at ${head.x ?? 0},${head.y ?? 0}`
184
+ : 'none';
185
+ const why = flagged
186
+ ? '— from --stage, which ingest takes only for a skeleton that states no box'
187
+ : stageBox !== undefined
188
+ ? `— read from the bounding box in slot "${stageBox}" (--stage-box), the stage the build carried in its own files${documentStage === undefined ? '' : `, and equal to the ${MODEL_DOCUMENT_FILE} beside it (ingest refuses the two disagreeing)`}`
189
+ : documentStage !== undefined
190
+ ? `— read from the ${MODEL_DOCUMENT_FILE} beside it (its spine.sha256 is this skeleton's)${documentStage === null ? ', which states the rig declared none' : ''}`
191
+ : box === 'none'
192
+ ? `— the skeleton's header states no box and no ${MODEL_DOCUMENT_FILE} of this skeleton is beside it`
193
+ : `— read from the skeleton's header box: no ${MODEL_DOCUMENT_FILE} of this skeleton is beside it, and which box it is depends on the rigc that wrote it — on a build written before 2.2.0 it is the stage itself, on one written since it is the setup-pose bounding box, not the stage the rig declared, and nothing in the skeleton says which; keep ${MODEL_DOCUMENT_FILE} beside the pair to carry the stage, or, for a build that carries it as a bounding box (skeleton.stageBox), pass --stage-box <slot> to read it exactly`;
194
+ return ` .. stage ${box} ${why}. skeleton.json is held byte-identical below whatever it is; ${MODEL_DOCUMENT_FILE}'s stage, and the stage rules on the rebuild, read it`;
195
+ }
196
+
197
+ /** The findings `ingest` reported, in its own format, blockers first. */
198
+ function findingLines(findings: readonly IngestFinding[]): string[] {
199
+ const width = Math.max(...Object.values(INGEST_GUTTERS).map((gutter) => gutter.length));
200
+ const lines: string[] = [];
201
+ for (const kind of ['blocker', 'judgement', 'lossy'] as const) {
202
+ for (const f of findings.filter((x) => x.kind === kind)) lines.push(` ${INGEST_GUTTERS[kind].padEnd(width)} ${f.code}: ${f.where} — ${f.detail}`);
203
+ }
204
+ return lines;
205
+ }
206
+
207
+ /**
208
+ * `runBuild` with its two closing lines restated for a build that is staged:
209
+ * `rigc: wrote <work>/…` becomes `staged <work>/…`, and the `look at it` line
210
+ * is dropped — the work directory is removed when the command ends, and
211
+ * `repack` names `--out` itself once it has written there. Every other line is
212
+ * printed as it comes, so a gate that exits mid-way loses none.
213
+ */
214
+ function stagedBuild(flags: Record<string, string>, gate: BuildGate): void {
215
+ const print = console.log;
216
+ console.log = (...args: unknown[]): void => {
217
+ const line = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ');
218
+ if (line.startsWith('rigc: wrote ')) print(` .. staged ${line.slice('rigc: wrote '.length)}`);
219
+ else if (!line.startsWith(LOOK_LINE)) print(...args);
220
+ };
221
+ try {
222
+ runBuild(flags, gate);
223
+ } finally {
224
+ console.log = print;
225
+ }
226
+ }
227
+
228
+ /** The pages and the atlas against the input's: a line saying which are byte-identical. */
229
+ function pagesLine(inputAtlas: string, inputPages: readonly InputPage[], outAtlas: string, buildDir: string, outPages: readonly string[]): string {
230
+ const inputBytes = new Map(inputPages.map((p) => [p.page.name, p.path]));
231
+ const same = outPages.filter((name) => {
232
+ const from = inputBytes.get(name);
233
+ return from !== undefined && readFileSync(from).equals(readFileSync(join(buildDir, name)));
234
+ });
235
+ if (inputAtlas === outAtlas && same.length === outPages.length && outPages.length === inputPages.length) {
236
+ return ` .. pages skeleton.atlas and all ${outPages.length} page(s) byte-identical to the input's — these settings pack these regions as the input was packed`;
237
+ }
238
+ return (
239
+ ` .. pages skeleton.atlas ${inputAtlas === outAtlas ? 'byte-identical to' : 'differs from'} the input's; ${same.length} of ${outPages.length} page(s) byte-identical to the input's page of the same name ` +
240
+ `(the input had ${inputPages.length}) — pages move under other pack settings; what (a) holds is every region`
241
+ );
242
+ }
243
+
244
+ /**
245
+ * repack — the command body both entries register, with the entry's own gate
246
+ * (`build`'s). `plant` is the selftest's (`RepackPlant`).
247
+ */
248
+ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, plant?: RepackPlant): void {
249
+ refuseForeignFlags(flags);
250
+ if (positional.length !== 1) throw new UsageError(`repack takes one path — the build directory, or its skeleton.json — and was given ${positional.length}`);
251
+ // Every flag read before anything is: a typo is a usage error, never a default.
252
+ const packing: Record<string, string> = {
253
+ 'page-size': String(readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE)),
254
+ padding: String(readIntFlag(flags, 'padding', DEFAULT_PADDING)),
255
+ 'page-edges': readPageEdges(flags),
256
+ 'pack-shape': readPackShape(flags),
257
+ profile: readProfile(flags),
258
+ };
259
+ const stage = readStageFlag(flags.stage);
260
+ const out = readOut(flags);
261
+ const input = resolveBuild(positional[0], flags.atlas);
262
+ if (input.atlas !== 'there') throw new UsageError(input.atlasRefusal);
263
+ const skeletonText = readSkeletonText(input.skeletonPath);
264
+ const atlasText = readFileSync(input.atlasPath, 'utf8');
265
+ const parsed = parseAtlasText(atlasText);
266
+
267
+ console.log(`rigc repack ${input.skeletonPath}`);
268
+ console.log(` .. atlas ${input.atlasPath}`);
269
+ console.log(` .. out ${out}`);
270
+ console.log(` .. pack page-size ${packing['page-size']}, padding ${packing.padding}, page-edges ${packing['page-edges']}, pack-shape ${packing['pack-shape']}, profile ${packing.profile}`);
271
+
272
+ // What the atlas cannot carry through a repack, all of it, before anything is made.
273
+ const { refusals, pages } = atlasRefusals(parsed, dirname(input.atlasPath));
274
+ const modelText = modelTextBeside(input.skeletonPath, skeletonText);
275
+ const disagreement = modelText === undefined ? null : documentDisagreements((parseJsonNamed(modelText, join(dirname(input.skeletonPath), MODEL_DOCUMENT_FILE)) as { pages?: unknown }).pages, parsed);
276
+ if (disagreement !== null) {
277
+ refusals.push(
278
+ ...disagreement.lines.map(
279
+ (line) => `the atlas and ${MODEL_DOCUMENT_FILE} beside it disagree — ${line}; the document is the record of where the build placed each region, so one of the two was edited after it was written`,
280
+ ),
281
+ );
282
+ }
283
+ if (refusals.length > 0) {
284
+ throw new RepackError(
285
+ `${input.atlasPath} cannot be lifted exactly — ${refusals.length} reason(s), and nothing was written:\n${refusals.map((r) => ` ${r}`).join('\n')}`,
286
+ );
287
+ }
288
+ console.log(
289
+ ` .. model ${
290
+ disagreement === null
291
+ ? `no ${MODEL_DOCUMENT_FILE} of this skeleton beside it (or one stating no pages); the atlas is read as it stands`
292
+ : `the atlas held to ${MODEL_DOCUMENT_FILE}'s pages beside it: ${disagreement.regions} region(s), every placement field equal`
293
+ }`,
294
+ );
295
+
296
+ const documentStage = documentStageBeside(input.skeletonPath, skeletonText);
297
+ const work = mkdtempSync(join(tmpdir(), REPACK_WORK_PREFIX));
298
+ console.error(`rigc repack: work directory ${work}`);
299
+ const removeWork = (): void => rmSync(work, { recursive: true, force: true });
300
+ // The `process.exit` inside `runBuild` (a red gate) and the refusal chain's are seen by no `finally`.
301
+ const onExit = (code: number): void => {
302
+ removeWork();
303
+ if (code !== 0) console.error(`rigc repack: nothing was written to ${out}; the work directory ${work} was removed`);
304
+ };
305
+ process.on('exit', onExit);
306
+ try {
307
+ const liftDir = join(work, 'lift');
308
+ const specsDir = join(work, 'specs');
309
+ const buildDir = join(work, 'build');
310
+ const lifted = liftAtlas(pages, liftDir);
311
+ console.log(
312
+ ` .. lift ${lifted.parts.size} region(s) off ${pages.length} page(s), each named once: ${lifted.turned} turned on its page, ${lifted.trimmed} trimmed of whitespace — ` +
313
+ 'each lifted as the drawing it is (extractRegion)',
314
+ );
315
+ if (plant !== undefined) plant(liftDir);
316
+
317
+ let result: { rig: unknown; motion: unknown; findings: IngestFinding[] };
318
+ try {
319
+ result = ingest(parseJsonNamed(skeletonText, input.skeletonPath), ingestOptionsFor(input.skeletonPath, specsDir, liftDir, stage, flags['stage-box'], documentStage));
320
+ } catch (err) {
321
+ if (err instanceof IngestError) throw new RepackError(`ingest refused the skeleton: ${err.message} — nothing was written`, 2);
322
+ if (!(err instanceof IngestSpecRefused)) throw err;
323
+ result = { rig: err.rig, motion: err.motion, findings: err.findings };
324
+ }
325
+ console.log(stageLine(result.rig as Parameters<typeof stageLine>[0], stage !== undefined, flags['stage-box'], documentStage));
326
+ const blockers = result.findings.filter((f) => f.kind === 'blocker');
327
+ console.log(` .. ingest ${result.findings.length} finding(s), ${blockers.length} blocker(s)`);
328
+ for (const line of findingLines(result.findings)) console.log(line);
329
+ if (blockers.length > 0) {
330
+ throw new RepackError(
331
+ `ingest found ${blockers.length} blocker(s) (${[...new Set(blockers.map((f) => f.code))].join(', ')}), so a rebuild would not be the skeleton that was read — nothing was written`,
332
+ );
333
+ }
334
+ mkdirSync(specsDir, { recursive: true });
335
+ const rigPath = join(specsDir, 'rig.json');
336
+ const motionPath = join(specsDir, 'motion.json');
337
+ writeFileSync(rigPath, `${JSON.stringify(result.rig, null, 2)}\n`);
338
+ writeFileSync(motionPath, `${JSON.stringify(result.motion, null, 2)}\n`);
339
+
340
+ // (c) `build --pack` through the entry's own gate. It returns only green; a red gate exits from inside it.
341
+ stagedBuild({ rig: rigPath, motion: motionPath, out: buildDir, pack: 'true', ...packing }, gate);
342
+
343
+ // (a) and (b), both before the first write.
344
+ const outAtlas = readFileSync(join(buildDir, 'skeleton.atlas'), 'utf8');
345
+ const repacked = atlasRefusals(parseAtlasText(outAtlas), buildDir);
346
+ if (repacked.refusals.length > 0) {
347
+ throw new Error(`internal: the repacked atlas is one this command would refuse to lift:\n${repacked.refusals.join('\n')}`);
348
+ }
349
+ const outSkeleton = readFileSync(join(buildDir, 'skeleton.json'), 'utf8');
350
+ const owned = packing['pack-shape'] === 'polygon' ? polygonOwnedTexels(outSkeleton, lifted.parts, Number(packing.padding)) : undefined;
351
+ const regions = regionDifferences(lifted.parts, liftAtlas(repacked.pages).parts, owned);
352
+ const lost: string[] = [];
353
+ if (regions.differences.length > 0) lost.push(`(a) ${regions.differences.length} region(s) are not pixel-identical to the input's:`, ...regions.differences.map((d) => ` ${d}`));
354
+ else {
355
+ console.log(
356
+ ` .. check (a) regions: ${regions.identical} of ${lifted.parts.size} pixel-identical, by name, to the same region lifted off the input's pages — ` +
357
+ (regions.byFootprint === 0
358
+ ? 'each over its whole rectangle'
359
+ : `${regions.identical - regions.byFootprint} over the whole rectangle, ${regions.byFootprint} that only meshes draw over the footprint a polygon page keeps as its own (the rest of its rectangle may hold a neighbour, by design)`),
360
+ );
361
+ }
362
+ const accepting = flags[ACCEPT_FLAG] !== undefined;
363
+ if (outSkeleton === skeletonText) {
364
+ console.log(
365
+ " .. check (b) skeleton.json: byte-identical to the input's — the pack owns none of it" +
366
+ (accepting ? `; --${ACCEPT_FLAG} accepted nothing, because there was nothing to accept` : ''),
367
+ );
368
+ } else {
369
+ // Accepted, every path is printed: an accepted difference nobody is shown is the silence the check exists to remove.
370
+ const diff = skeletonDifferences(parseJsonNamed(skeletonText, input.skeletonPath), parseJsonNamed(outSkeleton, join(buildDir, 'skeleton.json')), accepting ? Infinity : SKELETON_DIFFERENCES_SHOWN);
371
+ const head =
372
+ diff.total === 0
373
+ ? `skeleton.json: the rebuild states the same values in other bytes — ${firstLineApart(skeletonText, outSkeleton)}`
374
+ : `skeleton.json: ${diff.total} place(s) differ from the input`;
375
+ if (accepting) {
376
+ console.log(` .. check (b) ${head}, accepted by --${ACCEPT_FLAG} — the output's skeleton is this rigc's rebuild, not the input's bytes:`);
377
+ for (const line of diff.lines) console.log(` .. ${line}`);
378
+ } else {
379
+ lost.push(
380
+ `(b) ${head}, which the pack does not own${diff.total > diff.lines.length ? ` (the first ${diff.lines.length})` : ''}:`,
381
+ ...diff.lines.map((d) => ` ${d}`),
382
+ ' a repack changes where regions sit and nothing in the skeleton; this skeleton is not what this rigc writes from what it states. ' +
383
+ `--${ACCEPT_FLAG} writes the rebuild anyway — the skeleton this rigc writes from what the input states, every difference listed — ` +
384
+ 'with check (a) and the gate exactly as they are; a build written before 2.2.0, whose header was the stage, differs here in its header box and nowhere else',
385
+ );
386
+ }
387
+ }
388
+ if (lost.length > 0) throw new RepackError(`the repack lost something, so nothing was written to ${out}:\n${lost.map((l) => ` ${l}`).join('\n')}`);
389
+ console.log(' .. check (c) gate: green — the report above, on the compile and on the packed pair');
390
+
391
+ const outParsed = parseAtlasText(outAtlas);
392
+ const outPages = outParsed.pages.map((p) => p.name);
393
+ const staged = readdirSync(buildDir).sort();
394
+ const expected = [...BUILD_TEXTS, ...outPages].sort();
395
+ if (staged.join('\n') !== expected.join('\n')) throw new Error(`internal: the staged build holds [${staged.join(', ')}], not [${expected.join(', ')}]`);
396
+ console.log(pagesLine(atlasText, pages, outAtlas, buildDir, outPages));
397
+
398
+ mkdirSync(out, { recursive: true });
399
+ for (const name of [...outPages, ...BUILD_TEXTS]) {
400
+ copyFileSync(join(buildDir, name), join(out, name));
401
+ console.log(`rigc: wrote ${join(out, name)}`);
402
+ }
403
+ console.log(gate.look(out));
404
+ } finally {
405
+ removeWork();
406
+ process.off('exit', onExit);
407
+ }
408
+ }
package/src/cli/shared.ts CHANGED
@@ -45,6 +45,7 @@ import { CheckError } from '../check.ts';
45
45
  import { IngestError, type IngestStage } from '../ingest.ts';
46
46
  import { NotAPngError } from '../png.ts';
47
47
  import { PoseError } from '../pose.ts';
48
+ import { RepackError } from '../repack.ts';
48
49
  import { SpineRuntimeError, SPINE_SIDE_ABSENT } from '../spine_side.ts';
49
50
  import type { CompileResult, DroppedState } from '../types.ts';
50
51
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -139,7 +140,7 @@ export function readVersion(): string {
139
140
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
140
141
  * halves together by reading `--help` rather than by naming a flag.
141
142
  */
142
- const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry']);
143
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'accept-skeleton-differences']);
143
144
 
144
145
  /**
145
146
  * The flags a command is allowed to spell more than once.
@@ -1101,6 +1102,17 @@ const FLAG_MEANINGS: Record<string, string> = {
1101
1102
  'so the flag is for a file that really has none rather than for editor exports as a class. ⛔ Beside a ' +
1102
1103
  'skeleton that already declares a box it is REFUSED rather than ignored: two sources for one value, and the ' +
1103
1104
  'file is the record of what was measured',
1105
+ 'accept-skeleton-differences':
1106
+ 'write the repack even where the rebuilt skeleton.json differs from the input\'s, and print every differing path ' +
1107
+ 'with both values (default: refused naming them). It says only where the two differ, never why — a build written ' +
1108
+ 'before 2.2.0, whose header box was the stage, differs in those four fields and nowhere else. Region identity and ' +
1109
+ 'the gate hold exactly as without it; beside an input that needs none, the check line says it accepted nothing',
1110
+ 'stage-box':
1111
+ 'the slot whose bounding box carries the stage — what `build` writes for a rig that asks for one ' +
1112
+ '(`skeleton.stageBox`). Its four corners are read as the rebuild\'s stage in place of the header\'s box, which ' +
1113
+ 'is the setup-pose bounding box, and the rebuilt spec asks for the same box rather than transcribing it. Only a ' +
1114
+ 'box `build` could write back is read: anything else in that slot is refused by name, and without the flag no ' +
1115
+ 'slot is read as the stage because of its name',
1104
1116
  help: "show this command's flags and exit",
1105
1117
  };
1106
1118
 
@@ -1154,6 +1166,7 @@ const FLAG_VALUES: Record<string, string> = {
1154
1166
  name: '<n>',
1155
1167
  art: 'loose|none',
1156
1168
  stage: '<x,y,w,h>',
1169
+ 'stage-box': '<slot>',
1157
1170
  dir: '<path>',
1158
1171
  };
1159
1172
 
@@ -1295,6 +1308,67 @@ export const COMMANDS: CommandDoc[] = [
1295
1308
  'profile',
1296
1309
  ],
1297
1310
  },
1311
+ {
1312
+ name: 'repack',
1313
+ runtime: {
1314
+ for: 'the rebuild it writes is gated by build\'s gate, which round-trips it through spine-core',
1315
+ core: {
1316
+ usage: [
1317
+ `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] (gated without spine-core — see repack --help)`,
1318
+ ],
1319
+ notes: [
1320
+ 'a packed build repacked from its own output — skeleton.json, skeleton.atlas and the',
1321
+ 'pages: every region lifted off its page, the skeleton read back (ingest --art loose),',
1322
+ 'and build --pack over the lifted parts, in a work directory under the system temp',
1323
+ 'directory that is removed when the command ends. Nothing reaches --out until three',
1324
+ 'things are shown, each on its own line: (a) every region lifted off the new pages is',
1325
+ 'pixel-identical, by name, to the same region off the input\'s (under --pack-shape',
1326
+ 'polygon, a region only meshes draw over the footprint the page keeps as its own); (b) the rebuilt',
1327
+ 'skeleton.json is byte-identical to the input\'s — the pack owns the atlas and the pages',
1328
+ 'and none of the skeleton; (c) the gate is green. On this entry that gate is build\'s',
1329
+ 'here: the model side over the document and the round trip\'s own rules restated over',
1330
+ 'the emitted text, A00_ROUNDTRIP_PARSE a SKIP naming spine-core. An atlas the lift',
1331
+ 'cannot read exactly is refused by name before anything is made: docs/AUTHORING.md',
1332
+ '§0.4 lists what is accepted and what is refused, and why.',
1333
+ ],
1334
+ },
1335
+ },
1336
+ spineFormat: true,
1337
+ usage: [
1338
+ `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]`,
1339
+ ],
1340
+ flags: ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', 'accept-skeleton-differences'],
1341
+ overrides: {
1342
+ out: {
1343
+ value: '<dir>',
1344
+ meaning:
1345
+ 'the directory the repacked build is written into — skeleton.json, skeleton.atlas, skeleton.model.json and the ' +
1346
+ 'pages, exactly what build --pack writes — absent or empty, and refused otherwise: a page an earlier pack wrote ' +
1347
+ 'and this one does not would stay beside the new atlas with nothing naming it. Written only after the three checks',
1348
+ },
1349
+ 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)" },
1350
+ },
1351
+ notes: [
1352
+ 'a packed build repacked from its own output — skeleton.json, skeleton.atlas and the',
1353
+ 'pages: every region lifted off its page, the skeleton read back (ingest --art loose),',
1354
+ 'and build --pack over the lifted parts with the packing flags above, which mean what',
1355
+ 'they mean to build --pack (repack always packs). The work runs in a directory under the',
1356
+ 'system temp directory that is removed when the command ends. Nothing reaches --out until',
1357
+ 'three things are shown, each on its own line: (a) every region lifted off the new pages',
1358
+ 'is pixel-identical, by name, to the same region off the input\'s (under --pack-shape',
1359
+ 'polygon, a region only meshes draw over the footprint the page keeps as its own); (b) the rebuilt',
1360
+ 'skeleton.json is byte-identical to the input\'s — the pack owns the atlas and the pages',
1361
+ 'and none of the skeleton (--accept-skeleton-differences writes a rebuild that differs,',
1362
+ 'every differing path printed); (c) build\'s gate is green. Under the settings the input was',
1363
+ 'packed with, a line says the atlas and pages came back byte-identical; under others the',
1364
+ 'pages differ by design and (a) is the guarantee. An atlas the lift cannot read exactly',
1365
+ '— a page scale, premultiplied alpha, a region named twice, a region off its page, a page',
1366
+ 'that is missing — is refused by name before anything is made: docs/AUTHORING.md §0.4',
1367
+ 'lists what is accepted and what is refused, and why. The stage line says which stage the',
1368
+ 'rebuild declared and where it was read (--stage, the skeleton.model.json beside the',
1369
+ 'pair, or the header\'s box).',
1370
+ ],
1371
+ },
1298
1372
  {
1299
1373
  name: 'explain',
1300
1374
  runtime: false,
@@ -1328,8 +1402,8 @@ export const COMMANDS: CommandDoc[] = [
1328
1402
  name: 'ingest',
1329
1403
  runtime: false,
1330
1404
  spineFormat: true,
1331
- usage: ['rigc ingest <skeleton.json> --out <dir> [--name <n>] [--art loose|none] [--images <dir>] [--stage x,y,w,h]'],
1332
- flags: ['out', 'name', 'art', 'images', 'stage'],
1405
+ usage: ['rigc ingest <skeleton.json> --out <dir> [--name <n>] [--art loose|none] [--images <dir>] [--stage x,y,w,h] [--stage-box <slot>]'],
1406
+ flags: ['out', 'name', 'art', 'images', 'stage', 'stage-box'],
1333
1407
  overrides: {
1334
1408
  out: {
1335
1409
  value: '<dir>',
@@ -1633,6 +1707,18 @@ const USAGE_PARAGRAPHS: ReadonlyArray<{ about: readonly string[] | { flag: strin
1633
1707
  'rules that profile left out.',
1634
1708
  ],
1635
1709
  },
1710
+ {
1711
+ about: ['repack'],
1712
+ lines: () => [
1713
+ 'repack takes a packed build whose parts were not kept — skeleton.json, skeleton.atlas and',
1714
+ 'the pages — and packs it again under build --pack\'s packing flags, so a packer',
1715
+ 'improvement reaches a build without its parts:',
1716
+ ' rigc repack build/ --out build.free/ --page-edges free --pack-shape polygon',
1717
+ 'It writes only after every region is shown pixel-identical, the skeleton byte-identical',
1718
+ 'and the gate green, and refuses by name an atlas it cannot lift exactly. See',
1719
+ '`rigc repack --help`.',
1720
+ ],
1721
+ },
1636
1722
  {
1637
1723
  about: ['check'],
1638
1724
  lines: () => [
@@ -2530,6 +2616,13 @@ function refuse(err: unknown, command: string | undefined, USAGE: string, entry:
2530
2616
  console.error(`rigc ingest: ${err.message}`);
2531
2617
  process.exit(2);
2532
2618
  }
2619
+ // A repack refused (issue #1169): an atlas it cannot lift exactly, a repack that lost something, or an `--out`
2620
+ // it will not write into. The message names every reason and says that nothing was written; `status` is 1 for a
2621
+ // file that is not what a repack needs and 2 for an invocation that has to change.
2622
+ if (err instanceof RepackError) {
2623
+ console.error(`rigc repack: ${err.message}`);
2624
+ process.exit(err.status);
2625
+ }
2533
2626
  // A page or frame that is not a PNG rigc can read, from any command that
2534
2627
  // opens one (`render`, `check`, `preview`, …) — issue #732. The sentence is
2535
2628
  // the one reader's and already names the file, what it is and what rigc
@@ -20,6 +20,7 @@ import { atlasPageNames } from '../render.ts';
20
20
  import { BoneDistError } from '../bonedist.ts';
21
21
  import { assertionCountForProfile, CLI_DEFAULT_PROFILE, reportLines, validate, type ValidateProfile } from '../validate.ts';
22
22
  import { type CliRefusal, type CommandRun, DEFAULT_BALLOT, PACKAGE_ROOT, DEFAULT_LEDGER, parseJsonNamed, readAnimationFlag, readJsonFile, readPackageMeta, readSkeletonText, readVersion, resolveBuild, resolveCut, resolveViewable, runCheck, spinePairOf, type BuildGate, runBuild, readProfile, STAGELESS_FRAMING, UsageError, writeJson, modelTextBeside } from './shared.ts';
23
+ import { cmdRepack } from './repack.ts';
23
24
  import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
24
25
  import { dirname, join, resolve } from 'node:path';
25
26
 
@@ -801,6 +802,7 @@ export function cmdBoneDist(flags: Record<string, string>): void {
801
802
  */
802
803
  export const SPINE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
803
804
  build: ({ flags }) => cmdBuild(flags),
805
+ repack: (args) => cmdRepack(args, ROUND_TRIP_GATE),
804
806
  validate: ({ flags, positional }) => cmdValidate(flags, positional),
805
807
  bench: ({ flags, positional }) => cmdBench(flags, positional),
806
808
  bonedist: ({ flags }) => cmdBoneDist(flags),