rig-c 2.37.0 → 2.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/docs/AUTHORING.md +71 -0
- package/package.json +1 -1
- package/src/cli/repack.ts +220 -28
- package/src/cli/shared.ts +22 -3
- package/src/core/tracks.ts +223 -39
- package/src/repack.ts +100 -1
package/README.md
CHANGED
|
@@ -575,6 +575,7 @@ commands take it and what its default is.
|
|
|
575
575
|
| `build … --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
|
|
576
576
|
| `validate <dir>` | re-gates artifacts already on disk |
|
|
577
577
|
| `repack <dir> --out <dir>` | a packed build **repacked from its own output** — `skeleton.json`, `skeleton.atlas` and the pages, with no parts kept — under the packing flags `build --pack` takes, so a packer improvement reaches a build whose parts are gone. Every region is lifted off its page, the skeleton read back with `ingest`, and `build --pack` run over the lifted parts with the same gate; it writes only after every region is shown pixel-identical to the input's, the skeleton byte-identical and the gate green, and refuses by name an atlas it cannot lift exactly. A skeleton the rebuild does not reproduce byte for byte — a build written before 2.2.0, whose header box was its stage — is refused naming every differing path, and written, every path printed, only under `--accept-skeleton-differences`. `--stage-box <slot>` reads the stage from the box a build carries for it (`skeleton.stageBox`), the one way the three files alone carry the stage exactly ([AUTHORING §0.4](docs/AUTHORING.md)) |
|
|
578
|
+
| `repack <dir> --out <dir> --keep-pages` | **`skeleton.model.json` alone, for the pages already in place** — for a player that reads the document beside an atlas and pages that already shipped. The same read and stage rules, then `build --atlas-in` over the input atlas in place of the lift and the pack; it writes the document, and nothing else, only after its `pages` are shown to state the atlas as it stands, the rebuilt skeleton byte-identical to the one on disk (so `spine.sha256` names it — no acceptance flag here) and the gate green. `--out` is usually the build's own directory and must not already hold a `skeleton.model.json`; a page or region line the document has no field for is refused by name ([AUTHORING §0.4](docs/AUTHORING.md)) |
|
|
578
579
|
| `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
|
|
579
580
|
| `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
|
|
580
581
|
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid |
|
package/docs/AUTHORING.md
CHANGED
|
@@ -1317,6 +1317,7 @@ that lifts them.
|
|
|
1317
1317
|
| `--stage x,y,w,h` | handed to `ingest` as its own `--stage` (§0.3) — a box for a skeleton that states none, refused beside one that does |
|
|
1318
1318
|
| `--stage-box <slot>` | handed to `ingest` as its own `--stage-box` (§0.3): the stage read from the bounding box a build writes for a rig that states `skeleton.stageBox` — the exact way to carry the stage through a repack of the three files alone (*The stage*, below). `ingest`'s refusals reach the caller in its own words |
|
|
1319
1319
|
| `--accept-skeleton-differences` | write the rebuild even where its `skeleton.json` differs from the input's, with **every** differing path printed and both values (default: refused naming them, check (b) below). Nothing else relaxes: check (a) and the gate hold exactly as without it. Beside a skeleton that comes back byte-identical, check (b)'s line says the flag accepted nothing |
|
|
1320
|
+
| `--keep-pages` | write **`skeleton.model.json` alone**, for the atlas and pages as they stand — nothing lifted, nothing packed, no page and no atlas written (*Keeping the pages*, below). Takes none of the packing flags and not `--accept-skeleton-differences`, each refused by name (exit 2) |
|
|
1320
1321
|
|
|
1321
1322
|
Every other flag `build` takes is refused by name, with the reason `repack` does not
|
|
1322
1323
|
take it (`--rig`, `--motion`, `--images`, `--manifest`, `--copy-images`, `--pack`,
|
|
@@ -1449,6 +1450,71 @@ either way; the stage line is where it shows. Pass `--stage-box <slot>` for such
|
|
|
1449
1450
|
build. (`--stage` adds a box only to a skeleton that states none, which is `ingest`'s
|
|
1450
1451
|
rule.)
|
|
1451
1452
|
|
|
1453
|
+
**Keeping the pages — `--keep-pages`** (issue #1339). A player that reads
|
|
1454
|
+
`skeleton.model.json` needs the document for the pages it already ships, not for new
|
|
1455
|
+
ones: a repack's document states the repacked placement, and beside the old pages it
|
|
1456
|
+
describes texels that are not there. `--keep-pages` writes that document and nothing
|
|
1457
|
+
else:
|
|
1458
|
+
|
|
1459
|
+
```bash
|
|
1460
|
+
bun cli.ts repack spine/ --out spine/ --keep-pages
|
|
1461
|
+
# .. keep --keep-pages: the atlas and its pages are kept as they are and skeleton.model.json is the one file written; profile spine
|
|
1462
|
+
# .. stage 640 x 725 at -0.000016,-25 — read from the skeleton's header box: …
|
|
1463
|
+
# .. check (a) pages: the document states the atlas's 1 page(s) and 10 region(s) as they stand, field for field (pagesOfAtlas of the input atlas) — no page and no atlas is written
|
|
1464
|
+
# .. check (b) skeleton.json: byte-identical to the input's — the document's spine.sha256 names the file on disk
|
|
1465
|
+
# .. check (c) gate: green — the report above, on the build against the atlas as it stands
|
|
1466
|
+
# rigc: wrote /abs/path/spine/skeleton.model.json
|
|
1467
|
+
```
|
|
1468
|
+
|
|
1469
|
+
The read and the stage are the repack's — the same `ingest`, the same `--stage` and
|
|
1470
|
+
`--stage-box`, the same stage line. In place of the lift and `build --pack` it runs
|
|
1471
|
+
**`build --atlas-in`** over the input atlas (§0.1's *a pack somebody already made*),
|
|
1472
|
+
which resolves every part to a region of that atlas and writes no page; the atlas and
|
|
1473
|
+
its pages are staged as copies in the work directory, so the document's page names
|
|
1474
|
+
are the atlas's own. Before the one write, three things are shown: **(a)** the
|
|
1475
|
+
document's `pages` are the input atlas's pages and regions field for field;
|
|
1476
|
+
**(b)** the rebuilt `skeleton.json` is byte-identical to the input's, so
|
|
1477
|
+
`spine.sha256` names the file on disk — **with no acceptance**: a document whose digest
|
|
1478
|
+
names a skeleton other than the one beside it is exactly what this must not write, so
|
|
1479
|
+
`--accept-skeleton-differences` is refused here, and a build written before 2.2.0
|
|
1480
|
+
needs the full repack, which writes the rebuilt skeleton with its own pages; **(c)**
|
|
1481
|
+
the gate is green.
|
|
1482
|
+
|
|
1483
|
+
- `--out` is usually the build's own directory. It may hold anything **except** a
|
|
1484
|
+
`skeleton.model.json`, which is never written over (exit 2, naming the file): remove
|
|
1485
|
+
it to have it rewritten. The atlas has to sit beside the skeleton, because the
|
|
1486
|
+
document's page names are paths from the directory it sits in.
|
|
1487
|
+
- What the document states is accepted as it stands: a turned region, a trimmed
|
|
1488
|
+
region, a page `scale:`, `pma: true`, `index:` and a name spelled twice — the
|
|
1489
|
+
document has fields for each and the core reads them. Whether the skeleton still
|
|
1490
|
+
rebuilds byte for byte against such an atlas is check (b)'s to say, not a
|
|
1491
|
+
refusal's — and measured, it differs for a trim: `gallery/nod` laid with every
|
|
1492
|
+
region turned 90 is written, its poses equal to the document over its own pack, but
|
|
1493
|
+
laid turned **and trimmed** it is refused by (b) on `skeleton.y` and
|
|
1494
|
+
`skeleton.height` alone, because a trimmed region's quad is its kept rectangle and
|
|
1495
|
+
the setup-pose box in the header shrinks with it (`RPK23`). A line the document has **no field for** — `filter` other than
|
|
1496
|
+
`Linear, Linear` (the core samples every page bilinearly), `format`, `repeat`,
|
|
1497
|
+
`split`/`pad`, any other — is refused naming the page or region, because a player
|
|
1498
|
+
reading only the document would draw without it.
|
|
1499
|
+
- ⚠️ **A texel is not seen.** Nothing in the three files records one — the atlas
|
|
1500
|
+
states rectangles, not contents — so a page edited after its pack is a page the
|
|
1501
|
+
atlas still describes, and the document written beside it is the same bytes
|
|
1502
|
+
(`RPK24`). The document states placement only.
|
|
1503
|
+
|
|
1504
|
+
Measured on `gallery/nod` and `gallery/flex` built `--pack` and reduced to the three
|
|
1505
|
+
kinds of file (`RPK20`, `RPK21`): every input file the same bytes after (sha256), the
|
|
1506
|
+
document the one file added, its pages `pagesOfAtlas` of the atlas on disk,
|
|
1507
|
+
`cli_core.ts` writing the same document byte for byte; posed through the core
|
|
1508
|
+
(`poseRawSetup`, `poseRawAnimation`), every pose text-equal to the one the document
|
|
1509
|
+
`build` wrote gives, and `render --poser core` drawing the same decoded pixels from the
|
|
1510
|
+
kept pages as from the build. Without `--stage-box` the stage is the header's box by
|
|
1511
|
+
the rules above — the document differs from `build`'s there, by design, and in the
|
|
1512
|
+
order `ingest` writes the animations — and neither the poses nor the frames read it;
|
|
1513
|
+
with the stage carried as a box and `--stage-box` given, the stage is the declared one
|
|
1514
|
+
too. The document's `images` section names the atlas's page and region for each part,
|
|
1515
|
+
where the full repack's names the lifted part in a work directory that no longer
|
|
1516
|
+
exists; nothing that poses or draws reads it.
|
|
1517
|
+
|
|
1452
1518
|
**What each refusal says, and what to change:**
|
|
1453
1519
|
|
|
1454
1520
|
| `rigc repack: …` | exit | change |
|
|
@@ -1460,6 +1526,11 @@ rule.)
|
|
|
1460
1526
|
| `ingest found N blocker(s) …` / `ingest refused the skeleton: …` | 1 / 2 | the skeleton cannot be read back exactly (§0.3's findings); `--stage` beside a stated box is `ingest`'s refusal |
|
|
1461
1527
|
| `--out <dir> is not empty …` / `is a file …` | 2 | name a new directory |
|
|
1462
1528
|
| `repack takes no --<flag>: …` | 2 | drop the flag |
|
|
1529
|
+
| `<atlas> cannot be stated by skeleton.model.json as it stands — N reason(s), and nothing was written:` then one line per reason (`page … states filter: …`, `… a line skeleton.model.json has no field for`, `page … is not on disk`, `page … declares size …`, `region … occupies … which leaves the … page`) | 1 | `--keep-pages`: the atlas states something the document cannot carry; repack without `--keep-pages`, or rebuild from the parts |
|
|
1530
|
+
| `the build against <atlas> as it stands refused: image "…" resolves to region "…", which the atlas at <atlas> does not have …` | 1 | `--keep-pages`: the skeleton draws a region the atlas does not name — the atlas was edited, or is another build's |
|
|
1531
|
+
| `no skeleton.model.json describes these files as they stand, …` then `(b) skeleton.json: …` and the paths | 1 | `--keep-pages`: the skeleton is not what this rigc writes from what it states, so no digest can name it; repack without `--keep-pages` (which writes the rebuild and its pages) |
|
|
1532
|
+
| `--out <dir> already holds skeleton.model.json …` | 2 | `--keep-pages`: remove the document to have it rewritten, or name another `--out` |
|
|
1533
|
+
| `repack --keep-pages takes no --<flag>: …` / `the atlas … is not in the skeleton's directory …` | 2 | drop the flag; keep the atlas beside the skeleton |
|
|
1463
1534
|
|
|
1464
1535
|
A red gate is `build`'s own report and its `rigc: N assertion(s) failed — nothing
|
|
1465
1536
|
written`, followed by `rigc repack: nothing was written to <out>; the work directory
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-c",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.39.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/repack.ts
CHANGED
|
@@ -34,6 +34,14 @@
|
|
|
34
34
|
* `runBuild` that no `finally` sees, from an `exit` listener. A process killed
|
|
35
35
|
* by a signal runs neither and leaves its one directory.
|
|
36
36
|
*
|
|
37
|
+
* `--keep-pages` (issue #1339) is the same read, refusals-first and stage
|
|
38
|
+
* rules included, ending in a different build: `build --atlas-in` over the
|
|
39
|
+
* input atlas, which resolves every part to a region of the pack already on
|
|
40
|
+
* disk and writes no page, and then the document alone (`keptPagesDocument`).
|
|
41
|
+
* A flag rather than a verb, because its flow is this one minus the lift and
|
|
42
|
+
* the pages — a second verb would be a second copy of every refusal above
|
|
43
|
+
* the build, free to drift from this one.
|
|
44
|
+
*
|
|
37
45
|
* ⚠️ The lifted parts are not kept: the output is the input's shape, and the
|
|
38
46
|
* parts are recoverable from it at any time by the same lift — keeping a
|
|
39
47
|
* second copy of every texel beside the pages would be two records of one
|
|
@@ -41,9 +49,10 @@
|
|
|
41
49
|
*/
|
|
42
50
|
import { parseAtlasText } from '../atlas.ts';
|
|
43
51
|
import { relativeImagesPath } from '../compile.ts';
|
|
52
|
+
import { CompileError } from '../errors.ts';
|
|
44
53
|
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';
|
|
54
|
+
import { MODEL_DOCUMENT_FILE, pagesOfAtlas, spineFileSha256 } from '../model.ts';
|
|
55
|
+
import { atlasRefusals, documentDisagreements, firstLineApart, type InputPage, keptPageRefusals, liftAtlas, polygonOwnedTexels, regionDifferences, REPACK_WORK_PREFIX, RepackError, skeletonDifferences } from '../repack.ts';
|
|
47
56
|
import {
|
|
48
57
|
type BuildGate,
|
|
49
58
|
type BuildReport,
|
|
@@ -64,7 +73,7 @@ import {
|
|
|
64
73
|
writeBuildReport,
|
|
65
74
|
} from './shared.ts';
|
|
66
75
|
import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE } from '../atlas.ts';
|
|
67
|
-
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
76
|
+
import { constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
68
77
|
import { tmpdir } from 'node:os';
|
|
69
78
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
70
79
|
|
|
@@ -78,7 +87,14 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|
|
78
87
|
*/
|
|
79
88
|
export const ACCEPT_FLAG = 'accept-skeleton-differences';
|
|
80
89
|
|
|
81
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The switch that writes `skeleton.model.json` alone, for the atlas and pages
|
|
92
|
+
* as they stand (issue #1339): `build --atlas-in` over the input atlas in place
|
|
93
|
+
* of the lift and `build --pack`, nothing written but the document.
|
|
94
|
+
*/
|
|
95
|
+
export const KEEP_PAGES_FLAG = 'keep-pages';
|
|
96
|
+
|
|
97
|
+
export const REPACK_FLAGS = ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', ACCEPT_FLAG, KEEP_PAGES_FLAG, 'report'] as const;
|
|
82
98
|
|
|
83
99
|
/** `build`'s flags that `repack` does not take, and why — each refused by name rather than ignored. */
|
|
84
100
|
const BUILD_FLAGS_REFUSED: Readonly<Record<string, string>> = {
|
|
@@ -88,7 +104,7 @@ const BUILD_FLAGS_REFUSED: Readonly<Record<string, string>> = {
|
|
|
88
104
|
images: 'repack lifts the parts off the pages the atlas names; it takes no parts directory',
|
|
89
105
|
'copy-images': 'repack always writes self-contained pages into --out, which is what packing is',
|
|
90
106
|
pack: 'repack always packs; the flag would say nothing',
|
|
91
|
-
'atlas-in':
|
|
107
|
+
'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, and --${KEEP_PAGES_FLAG} to keep its pack`,
|
|
92
108
|
cut: 'repack takes a build directory, not a cut',
|
|
93
109
|
cuts: 'repack takes a build directory, not a cuts table',
|
|
94
110
|
};
|
|
@@ -244,27 +260,87 @@ function pagesLine(inputAtlas: string, inputPages: readonly InputPage[], outAtla
|
|
|
244
260
|
);
|
|
245
261
|
}
|
|
246
262
|
|
|
263
|
+
/**
|
|
264
|
+
* `--keep-pages`'s flags beside the ones it makes meaningless — each refused
|
|
265
|
+
* by name rather than ignored (issue #1339).
|
|
266
|
+
*/
|
|
267
|
+
const KEEP_PAGES_REFUSED: Readonly<Record<string, string>> = {
|
|
268
|
+
'page-size': 'nothing is packed, so a page size says nothing',
|
|
269
|
+
padding: 'nothing is packed, so a padding says nothing',
|
|
270
|
+
'page-edges': 'nothing is packed, so the page edges say nothing',
|
|
271
|
+
'pack-shape': 'nothing is packed, so the pack shape says nothing',
|
|
272
|
+
[ACCEPT_FLAG]:
|
|
273
|
+
`a document whose spine.sha256 names a skeleton other than the skeleton.json on disk is exactly what --${KEEP_PAGES_FLAG} must not write, and --${KEEP_PAGES_FLAG} writes no skeleton — ` +
|
|
274
|
+
'repack without it writes the rebuild, its atlas and its pages together',
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** Every flag given that `--keep-pages` makes meaningless, refused by name. */
|
|
278
|
+
function refuseKeepPagesConflicts(flags: Record<string, string>): void {
|
|
279
|
+
for (const name of Object.keys(KEEP_PAGES_REFUSED).sort()) {
|
|
280
|
+
if (flags[name] !== undefined) throw new UsageError(`repack --${KEEP_PAGES_FLAG} takes no --${name}: ${KEEP_PAGES_REFUSED[name]}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* `--out` under `--keep-pages`: required, a directory or absent, and holding
|
|
286
|
+
* no `skeleton.model.json` — the one file the command writes is never written
|
|
287
|
+
* over (issue #1339). Any other file may be there: the input's own directory
|
|
288
|
+
* is the usual `--out`, since the document belongs beside the pair and pages
|
|
289
|
+
* it states.
|
|
290
|
+
*/
|
|
291
|
+
function readKeptOut(flags: Record<string, string>): string {
|
|
292
|
+
if (flags.out === undefined) throw new UsageError(`repack --${KEEP_PAGES_FLAG} needs --out <dir> — the directory ${MODEL_DOCUMENT_FILE} is written into (the build's own directory, to sit beside the pages it states)`);
|
|
293
|
+
const out = resolve(flags.out);
|
|
294
|
+
if (existsSync(out)) {
|
|
295
|
+
if (!statSync(out).isDirectory()) throw new RepackError(`--out ${out} is a file; it takes a directory — nothing was written`, 2);
|
|
296
|
+
const held = join(out, MODEL_DOCUMENT_FILE);
|
|
297
|
+
if (existsSync(held)) {
|
|
298
|
+
throw new RepackError(
|
|
299
|
+
`--out ${out} already holds ${MODEL_DOCUMENT_FILE} — --${KEEP_PAGES_FLAG} writes that file and nothing else, and never over one: ` +
|
|
300
|
+
'a document already there is a record somebody kept, and which of the two describes the pages is not this command\'s to decide. ' +
|
|
301
|
+
`Remove ${held} first to have it rewritten, or name another --out; nothing was written`,
|
|
302
|
+
2,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The name the input atlas is staged under in the work directory: not `skeleton.atlas`, which the build writes beside it. */
|
|
310
|
+
const KEPT_ATLAS = 'kept.atlas';
|
|
311
|
+
|
|
247
312
|
/**
|
|
248
313
|
* repack — the command body both entries register, with the entry's own gate
|
|
249
314
|
* (`build`'s). `plant` is the selftest's (`RepackPlant`).
|
|
250
315
|
*/
|
|
251
316
|
export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, plant?: RepackPlant): void {
|
|
252
317
|
refuseForeignFlags(flags);
|
|
318
|
+
const keeping = flags[KEEP_PAGES_FLAG] !== undefined;
|
|
319
|
+
if (keeping) refuseKeepPagesConflicts(flags);
|
|
253
320
|
if (positional.length !== 1) throw new UsageError(`repack takes one path — the build directory, or its skeleton.json — and was given ${positional.length}`);
|
|
254
321
|
// Every flag read before anything is: a typo is a usage error, never a default.
|
|
255
|
-
const packing: Record<string, string> =
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
322
|
+
const packing: Record<string, string> = keeping
|
|
323
|
+
? { profile: readProfile(flags) }
|
|
324
|
+
: {
|
|
325
|
+
'page-size': String(readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE)),
|
|
326
|
+
padding: String(readIntFlag(flags, 'padding', DEFAULT_PADDING)),
|
|
327
|
+
'page-edges': readPageEdges(flags),
|
|
328
|
+
'pack-shape': readPackShape(flags),
|
|
329
|
+
profile: readProfile(flags),
|
|
330
|
+
};
|
|
262
331
|
const stage = readStageFlag(flags.stage);
|
|
263
|
-
const out = readOut(flags);
|
|
332
|
+
const out = keeping ? readKeptOut(flags) : readOut(flags);
|
|
264
333
|
// `--report` (issue #1213): refused inside --out, written on a red gate by the build body and here once --out is written.
|
|
265
334
|
const record = readReportFlag(flags, 'repack', out, gate.supplier);
|
|
266
335
|
const input = resolveBuild(positional[0], flags.atlas);
|
|
267
336
|
if (input.atlas !== 'there') throw new UsageError(input.atlasRefusal);
|
|
337
|
+
const atlasDir = dirname(input.atlasPath);
|
|
338
|
+
if (keeping && resolve(atlasDir) !== resolve(dirname(input.skeletonPath))) {
|
|
339
|
+
throw new UsageError(
|
|
340
|
+
`repack --${KEEP_PAGES_FLAG}: the atlas ${input.atlasPath} is not in the skeleton's directory ${dirname(input.skeletonPath)} — ` +
|
|
341
|
+
`${MODEL_DOCUMENT_FILE} sits beside skeleton.json and states the atlas's page names as paths from there, so beside this skeleton they would name pages that are not there`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
268
344
|
const skeletonText = readSkeletonText(input.skeletonPath);
|
|
269
345
|
const atlasText = readFileSync(input.atlasPath, 'utf8');
|
|
270
346
|
const parsed = parseAtlasText(atlasText);
|
|
@@ -272,10 +348,15 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
|
|
|
272
348
|
console.log(`rigc repack ${input.skeletonPath}`);
|
|
273
349
|
console.log(` .. atlas ${input.atlasPath}`);
|
|
274
350
|
console.log(` .. out ${out}`);
|
|
275
|
-
console.log(
|
|
351
|
+
console.log(
|
|
352
|
+
keeping
|
|
353
|
+
? ` .. keep --${KEEP_PAGES_FLAG}: the atlas and its pages are kept as they are and ${MODEL_DOCUMENT_FILE} is the one file written; profile ${packing.profile}`
|
|
354
|
+
: ` .. pack page-size ${packing['page-size']}, padding ${packing.padding}, page-edges ${packing['page-edges']}, pack-shape ${packing['pack-shape']}, profile ${packing.profile}`,
|
|
355
|
+
);
|
|
276
356
|
|
|
277
|
-
// What the atlas cannot carry through a repack, all of it, before anything is made.
|
|
278
|
-
const
|
|
357
|
+
// What the atlas cannot carry — through a repack, or into the document as it stands — all of it, before anything is made.
|
|
358
|
+
const lifting = keeping ? null : atlasRefusals(parsed, atlasDir);
|
|
359
|
+
const refusals = lifting === null ? keptPageRefusals(parsed, atlasDir) : lifting.refusals;
|
|
279
360
|
const modelText = modelTextBeside(input.skeletonPath, skeletonText);
|
|
280
361
|
const disagreement = modelText === undefined ? null : documentDisagreements((parseJsonNamed(modelText, join(dirname(input.skeletonPath), MODEL_DOCUMENT_FILE)) as { pages?: unknown }).pages, parsed);
|
|
281
362
|
if (disagreement !== null) {
|
|
@@ -287,7 +368,7 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
|
|
|
287
368
|
}
|
|
288
369
|
if (refusals.length > 0) {
|
|
289
370
|
throw new RepackError(
|
|
290
|
-
`${input.atlasPath} cannot be lifted exactly — ${refusals.length} reason(s), and nothing was written:\n${refusals.map((r) => ` ${r}`).join('\n')}`,
|
|
371
|
+
`${input.atlasPath} ${keeping ? `cannot be stated by ${MODEL_DOCUMENT_FILE} as it stands` : 'cannot be lifted exactly'} — ${refusals.length} reason(s), and nothing was written:\n${refusals.map((r) => ` ${r}`).join('\n')}`,
|
|
291
372
|
);
|
|
292
373
|
}
|
|
293
374
|
console.log(
|
|
@@ -312,12 +393,15 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
|
|
|
312
393
|
const liftDir = join(work, 'lift');
|
|
313
394
|
const specsDir = join(work, 'specs');
|
|
314
395
|
const buildDir = join(work, 'build');
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
)
|
|
320
|
-
|
|
396
|
+
// Under --keep-pages nothing is lifted: the build resolves every part against the input atlas (`--atlas-in`), which reads no loose file.
|
|
397
|
+
const lifted = lifting === null ? null : liftAtlas(lifting.pages, liftDir);
|
|
398
|
+
if (lifted !== null && lifting !== null) {
|
|
399
|
+
console.log(
|
|
400
|
+
` .. lift ${lifted.parts.size} region(s) off ${lifting.pages.length} page(s), each named once: ${lifted.turned} turned on its page, ${lifted.trimmed} trimmed of whitespace — ` +
|
|
401
|
+
'each lifted as the drawing it is (extractRegion)',
|
|
402
|
+
);
|
|
403
|
+
if (plant !== undefined) plant(liftDir);
|
|
404
|
+
}
|
|
321
405
|
|
|
322
406
|
let result: { rig: unknown; motion: unknown; findings: IngestFinding[] };
|
|
323
407
|
try {
|
|
@@ -342,6 +426,11 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
|
|
|
342
426
|
writeFileSync(rigPath, `${JSON.stringify(result.rig, null, 2)}\n`);
|
|
343
427
|
writeFileSync(motionPath, `${JSON.stringify(result.motion, null, 2)}\n`);
|
|
344
428
|
|
|
429
|
+
if (lifted === null || lifting === null) {
|
|
430
|
+
keptPagesDocument({ flags, gate, record, input, skeletonText, atlasText, parsed, out, rigPath, motionPath, buildDir, profile: packing.profile });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
345
434
|
// (c) `build --pack` through the entry's own gate. It returns only green; a red gate exits from inside it.
|
|
346
435
|
stagedBuild({ rig: rigPath, motion: motionPath, out: buildDir, pack: 'true', ...packing }, gate, record);
|
|
347
436
|
|
|
@@ -373,10 +462,7 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
|
|
|
373
462
|
} else {
|
|
374
463
|
// Accepted, every path is printed: an accepted difference nobody is shown is the silence the check exists to remove.
|
|
375
464
|
const diff = skeletonDifferences(parseJsonNamed(skeletonText, input.skeletonPath), parseJsonNamed(outSkeleton, join(buildDir, 'skeleton.json')), accepting ? Infinity : SKELETON_DIFFERENCES_SHOWN);
|
|
376
|
-
const head =
|
|
377
|
-
diff.total === 0
|
|
378
|
-
? `skeleton.json: the rebuild states the same values in other bytes — ${firstLineApart(skeletonText, outSkeleton)}`
|
|
379
|
-
: `skeleton.json: ${diff.total} place(s) differ from the input`;
|
|
465
|
+
const head = skeletonHead(diff.total, skeletonText, outSkeleton);
|
|
380
466
|
if (accepting) {
|
|
381
467
|
console.log(` .. check (b) ${head}, accepted by --${ACCEPT_FLAG} — the output's skeleton is this rigc's rebuild, not the input's bytes:`);
|
|
382
468
|
for (const line of diff.lines) console.log(` .. ${line}`);
|
|
@@ -398,7 +484,7 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
|
|
|
398
484
|
const staged = readdirSync(buildDir).sort();
|
|
399
485
|
const expected = [...BUILD_TEXTS, ...outPages].sort();
|
|
400
486
|
if (staged.join('\n') !== expected.join('\n')) throw new Error(`internal: the staged build holds [${staged.join(', ')}], not [${expected.join(', ')}]`);
|
|
401
|
-
console.log(pagesLine(atlasText, pages, outAtlas, buildDir, outPages));
|
|
487
|
+
console.log(pagesLine(atlasText, lifting.pages, outAtlas, buildDir, outPages));
|
|
402
488
|
|
|
403
489
|
mkdirSync(out, { recursive: true });
|
|
404
490
|
for (const name of [...outPages, ...BUILD_TEXTS]) {
|
|
@@ -412,3 +498,109 @@ export function cmdRepack({ flags, positional }: CommandArgs, gate: BuildGate, p
|
|
|
412
498
|
process.off('exit', onExit);
|
|
413
499
|
}
|
|
414
500
|
}
|
|
501
|
+
|
|
502
|
+
/** Check (b)'s head: how many places differ, or — values equal, bytes not — the first line apart. */
|
|
503
|
+
function skeletonHead(total: number, skeletonText: string, outSkeleton: string): string {
|
|
504
|
+
return total === 0
|
|
505
|
+
? `skeleton.json: the rebuild states the same values in other bytes — ${firstLineApart(skeletonText, outSkeleton)}`
|
|
506
|
+
: `skeleton.json: ${total} place(s) differ from the input`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* `--keep-pages`' tail (issue #1339): `build --atlas-in` over the input atlas
|
|
511
|
+
* — the build that resolves every part against a pack somebody already made
|
|
512
|
+
* and writes no page — then the two checks that say the document it spelled
|
|
513
|
+
* describes the files on disk, then the one write.
|
|
514
|
+
*
|
|
515
|
+
* The input atlas and its pages are staged as copies in the build's own
|
|
516
|
+
* directory, so `--atlas-in`'s re-anchored page names (`relative(outDir, …)`)
|
|
517
|
+
* are the atlas's own spellings and the document's `pages` names what sits
|
|
518
|
+
* beside the input skeleton. Nothing under the input is opened for writing.
|
|
519
|
+
*
|
|
520
|
+
* (a) the document's `pages` are `pagesOfAtlas` of the input atlas text,
|
|
521
|
+
* field for field — what `build` would have stated had it written this
|
|
522
|
+
* atlas itself;
|
|
523
|
+
* (b) the rebuilt `skeleton.json` is byte-identical to the input's, so the
|
|
524
|
+
* document's `spine.sha256` names the file it will sit beside. There is
|
|
525
|
+
* no acceptance here: a digest of a skeleton that is not on disk is the
|
|
526
|
+
* document the command exists not to write;
|
|
527
|
+
* (c) the gate is green — `runBuild` returns only then.
|
|
528
|
+
*/
|
|
529
|
+
function keptPagesDocument(c: {
|
|
530
|
+
flags: Record<string, string>;
|
|
531
|
+
gate: BuildGate;
|
|
532
|
+
record: BuildReport | null;
|
|
533
|
+
input: { skeletonPath: string; atlasPath: string };
|
|
534
|
+
skeletonText: string;
|
|
535
|
+
atlasText: string;
|
|
536
|
+
parsed: ReturnType<typeof parseAtlasText>;
|
|
537
|
+
out: string;
|
|
538
|
+
rigPath: string;
|
|
539
|
+
motionPath: string;
|
|
540
|
+
buildDir: string;
|
|
541
|
+
profile: string;
|
|
542
|
+
}): void {
|
|
543
|
+
const atlasDir = dirname(c.input.atlasPath);
|
|
544
|
+
mkdirSync(c.buildDir, { recursive: true });
|
|
545
|
+
const keptAtlas = join(c.buildDir, KEPT_ATLAS);
|
|
546
|
+
writeFileSync(keptAtlas, c.atlasText);
|
|
547
|
+
for (const page of c.parsed.pages) {
|
|
548
|
+
const to = join(c.buildDir, page.name);
|
|
549
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
550
|
+
copyFileSync(resolve(atlasDir, page.name), to);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// (c) `build --atlas-in` through the entry's own gate. It returns only green; a red gate exits from inside it.
|
|
554
|
+
// A compile refusal names the staged copy of the atlas, so it is restated naming the input's own.
|
|
555
|
+
try {
|
|
556
|
+
stagedBuild({ rig: c.rigPath, motion: c.motionPath, out: c.buildDir, 'atlas-in': keptAtlas, profile: c.profile }, c.gate, c.record);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
if (!(err instanceof CompileError)) throw err;
|
|
559
|
+
throw new RepackError(`the build against ${c.input.atlasPath} as it stands refused: ${err.message.split(keptAtlas).join(c.input.atlasPath)} — nothing was written`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const lost: string[] = [];
|
|
563
|
+
const documentText = readFileSync(join(c.buildDir, MODEL_DOCUMENT_FILE), 'utf8');
|
|
564
|
+
const doc = parseJsonNamed(documentText, join(c.buildDir, MODEL_DOCUMENT_FILE)) as { pages?: unknown; spine?: { sha256?: unknown } };
|
|
565
|
+
const stated = skeletonDifferences(pagesOfAtlas(c.atlasText), doc.pages, SKELETON_DIFFERENCES_SHOWN);
|
|
566
|
+
const regionCount = c.parsed.pages.reduce((n, p) => n + p.regions.length, 0);
|
|
567
|
+
if (stated.total > 0) {
|
|
568
|
+
lost.push(
|
|
569
|
+
`(a) pages: ${stated.total} place(s) where the document would not state the atlas as it stands${stated.total > stated.lines.length ? ` (the first ${stated.lines.length})` : ''} — "the input" is the atlas read, "the rebuild" the document:`,
|
|
570
|
+
...stated.lines.map((d) => ` pages${d.startsWith('[') ? '' : '.'}${d}`),
|
|
571
|
+
);
|
|
572
|
+
} else {
|
|
573
|
+
console.log(
|
|
574
|
+
` .. check (a) pages: the document states the atlas's ${c.parsed.pages.length} page(s) and ${regionCount} region(s) as they stand, field for field (pagesOfAtlas of the input atlas) — ` +
|
|
575
|
+
'no page and no atlas is written',
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const outSkeleton = readFileSync(join(c.buildDir, 'skeleton.json'), 'utf8');
|
|
579
|
+
if (outSkeleton === c.skeletonText) {
|
|
580
|
+
console.log(" .. check (b) skeleton.json: byte-identical to the input's — the document's spine.sha256 names the file on disk");
|
|
581
|
+
} else {
|
|
582
|
+
const diff = skeletonDifferences(parseJsonNamed(c.skeletonText, c.input.skeletonPath), parseJsonNamed(outSkeleton, join(c.buildDir, 'skeleton.json')), SKELETON_DIFFERENCES_SHOWN);
|
|
583
|
+
lost.push(
|
|
584
|
+
`(b) ${skeletonHead(diff.total, c.skeletonText, outSkeleton)}${diff.total > diff.lines.length ? ` (the first ${diff.lines.length})` : ''}:`,
|
|
585
|
+
...diff.lines.map((d) => ` ${d}`),
|
|
586
|
+
` the document's spine.sha256 would name this rebuild, not the skeleton.json beside it — the one thing --${KEEP_PAGES_FLAG} must not write, so it takes no --${ACCEPT_FLAG}. ` +
|
|
587
|
+
'repack without it writes the rebuild together with its own atlas and pages, every difference listed under that flag; a build written before 2.2.0, whose header was the stage, differs here in its header box and nowhere else',
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
if (lost.length > 0) throw new RepackError(`no ${MODEL_DOCUMENT_FILE} describes these files as they stand, so nothing was written to ${c.out}:\n${lost.map((l) => ` ${l}`).join('\n')}`);
|
|
591
|
+
if (doc.spine?.sha256 !== spineFileSha256(c.skeletonText)) throw new Error(`internal: the staged ${MODEL_DOCUMENT_FILE}'s spine.sha256 is not the digest of the skeleton.json it was built from`);
|
|
592
|
+
console.log(' .. check (c) gate: green — the report above, on the build against the atlas as it stands');
|
|
593
|
+
|
|
594
|
+
mkdirSync(c.out, { recursive: true });
|
|
595
|
+
// `readKeptOut` refused an existing document; one appearing since is refused here rather than written over.
|
|
596
|
+
copyFileSync(join(c.buildDir, MODEL_DOCUMENT_FILE), join(c.out, MODEL_DOCUMENT_FILE), constants.COPYFILE_EXCL);
|
|
597
|
+
console.log(`rigc: wrote ${join(c.out, MODEL_DOCUMENT_FILE)}`);
|
|
598
|
+
if (c.record !== null) writeBuildReport(c.record);
|
|
599
|
+
if (resolve(c.out) === resolve(dirname(c.input.skeletonPath))) console.log(c.gate.look(c.out));
|
|
600
|
+
else {
|
|
601
|
+
console.log(
|
|
602
|
+
`rigc: ${MODEL_DOCUMENT_FILE} names the atlas's pages as paths from ${dirname(c.input.skeletonPath)} and the digest of the skeleton.json there; ` +
|
|
603
|
+
'it is read beside those files',
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
package/src/cli/shared.ts
CHANGED
|
@@ -125,7 +125,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
|
|
|
125
125
|
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
126
126
|
* halves together by reading `--help` rather than by naming a flag.
|
|
127
127
|
*/
|
|
128
|
-
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'accept-skeleton-differences']);
|
|
128
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'accept-skeleton-differences', 'keep-pages']);
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* The flags a command is allowed to spell more than once.
|
|
@@ -1092,6 +1092,13 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1092
1092
|
'with both values (default: refused naming them). It says only where the two differ, never why — a build written ' +
|
|
1093
1093
|
'before 2.2.0, whose header box was the stage, differs in those four fields and nowhere else. Region identity and ' +
|
|
1094
1094
|
'the gate hold exactly as without it; beside an input that needs none, the check line says it accepted nothing',
|
|
1095
|
+
'keep-pages':
|
|
1096
|
+
'write skeleton.model.json alone, for the atlas and pages as they stand: nothing is lifted or packed, the build ' +
|
|
1097
|
+
'resolves every part against the input atlas (build --atlas-in) and the document states that atlas\'s pages and ' +
|
|
1098
|
+
'regions field for field, its spine.sha256 the skeleton.json beside them. Written only when the rebuilt skeleton is ' +
|
|
1099
|
+
'byte-identical to the input\'s and the gate is green; --out may be the build\'s own directory and must not already ' +
|
|
1100
|
+
'hold skeleton.model.json (refused by name, never written over). A page or region line the document has no field ' +
|
|
1101
|
+
'for is refused naming it. Takes none of the packing flags, nor --accept-skeleton-differences',
|
|
1095
1102
|
'stage-box':
|
|
1096
1103
|
'the slot whose bounding box carries the stage — what `build` writes for a rig that asks for one ' +
|
|
1097
1104
|
'(`skeleton.stageBox`). Its four corners are read as the rebuild\'s stage in place of the header\'s box, which ' +
|
|
@@ -1308,6 +1315,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1308
1315
|
core: {
|
|
1309
1316
|
usage: [
|
|
1310
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] [--report <file>] (gated without spine-core — see repack --help)`,
|
|
1318
|
+
`rigc repack <dir | skeleton.json> --out <dir> --keep-pages [--atlas <path>] [--profile spine|spine-html] [--stage x,y,w,h] [--stage-box <slot>] [--report <file>] (skeleton.model.json alone, pages kept)`,
|
|
1311
1319
|
],
|
|
1312
1320
|
notes: [
|
|
1313
1321
|
'a packed build repacked from its own output — skeleton.json, skeleton.atlas and the',
|
|
@@ -1322,15 +1330,17 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1322
1330
|
'here: the model side over the document and the round trip\'s own rules restated over',
|
|
1323
1331
|
'the emitted text, A00_ROUNDTRIP_PARSE a SKIP naming spine-core. An atlas the lift',
|
|
1324
1332
|
'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.',
|
|
1333
|
+
'§0.4 lists what is accepted and what is refused, and why. --keep-pages writes',
|
|
1334
|
+
'skeleton.model.json alone, for the atlas and pages as they stand (see repack --help).',
|
|
1326
1335
|
],
|
|
1327
1336
|
},
|
|
1328
1337
|
},
|
|
1329
1338
|
spineFormat: true,
|
|
1330
1339
|
usage: [
|
|
1331
1340
|
`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>]`,
|
|
1341
|
+
`rigc repack <dir | skeleton.json> --out <dir> --keep-pages [--atlas <path>] [--profile spine|spine-html] [--stage x,y,w,h] [--stage-box <slot>] [--report <file>] (skeleton.model.json alone, pages kept)`,
|
|
1332
1342
|
],
|
|
1333
|
-
flags: ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', 'accept-skeleton-differences', 'report'],
|
|
1343
|
+
flags: ['out', 'atlas', 'page-size', 'padding', 'page-edges', 'pack-shape', 'profile', 'stage', 'stage-box', 'accept-skeleton-differences', 'keep-pages', 'report'],
|
|
1334
1344
|
overrides: {
|
|
1335
1345
|
out: {
|
|
1336
1346
|
value: '<dir>',
|
|
@@ -1360,6 +1370,15 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1360
1370
|
'lists what is accepted and what is refused, and why. The stage line says which stage the',
|
|
1361
1371
|
'rebuild declared and where it was read (--stage, the skeleton.model.json beside the',
|
|
1362
1372
|
'pair, or the header\'s box).',
|
|
1373
|
+
'--keep-pages writes skeleton.model.json alone, for the atlas and pages as they stand (a',
|
|
1374
|
+
'player that reads the document beside pages already shipped): the same read, the same',
|
|
1375
|
+
'ingest and stage rules, then build --atlas-in over the input atlas, which writes no page.',
|
|
1376
|
+
'(a) the document states the atlas\'s pages and regions field for field; (b) the rebuilt',
|
|
1377
|
+
'skeleton.json is byte-identical to the input\'s, so spine.sha256 names the file on disk',
|
|
1378
|
+
'(no acceptance: a digest of another skeleton is the document it must not write); (c) the',
|
|
1379
|
+
'gate is green. A turned, trimmed, scaled or premultiplied page is stated as it is; a line',
|
|
1380
|
+
'the document has no field for is refused by name. Nothing in the three files records a',
|
|
1381
|
+
'texel, so a page edited after its pack is not seen: the document states placement only.',
|
|
1363
1382
|
],
|
|
1364
1383
|
},
|
|
1365
1384
|
{
|
package/src/core/tracks.ts
CHANGED
|
@@ -88,14 +88,65 @@
|
|
|
88
88
|
* compounds without it. The state carries nothing from one step to the next
|
|
89
89
|
* but its entries, so a write lasts one pose, as on the single track.
|
|
90
90
|
*
|
|
91
|
+
* ## Alpha and additive entries (issue #1344)
|
|
92
|
+
*
|
|
93
|
+
* An entry carries spine-core's `TrackEntry.alpha` (`MixOptions.alpha`, 1 when
|
|
94
|
+
* absent, changed while it plays by `setAlpha`) and `TrackEntry.additive`
|
|
95
|
+
* (`MixOptions.additive`). 4.3.13 has no `MixBlend` on an entry — `additive` is
|
|
96
|
+
* a boolean fixed before the next apply — so the option carries that name and
|
|
97
|
+
* that type. What the runtime does with them, ported and measured (`CO52`):
|
|
98
|
+
*
|
|
99
|
+
* - **Alpha is a weight, multiplied in**: the current entry applies at
|
|
100
|
+
* `alpha · mix`; one mixing out at `from.alpha · fromMix · (1 − mix)`, its
|
|
101
|
+
* held timelines at that over `1 − mix · to.alpha`. Track 0 below alpha 1
|
|
102
|
+
* leaves the setup-pose shortcut and mixes as a higher track does.
|
|
103
|
+
* - **Events ignore alpha**: the current entry fires its events at any alpha,
|
|
104
|
+
* 0 included; an entry mixing out fires none (`eventThreshold` 0, above).
|
|
105
|
+
* - **Attachments ignore alpha**: `alphaAttachmentThreshold` is 0, so the
|
|
106
|
+
* current entry keeps its attachments at any alpha, 0 included;
|
|
107
|
+
* `mixAttachmentThreshold` is 0, so an entry mixing out keeps none. The draw
|
|
108
|
+
* order stays refused, so `mixDrawOrderThreshold` has nothing to act on.
|
|
109
|
+
* - **Additive adds what the runtime marks additive** (`Timeline.additive`):
|
|
110
|
+
* every bone timeline keying numbers and the deform. On a property a lower
|
|
111
|
+
* track or an older entry keyed, the keyed value (a rotation's, a
|
|
112
|
+
* translation's, a shear's) is added times alpha — `current + value · alpha`
|
|
113
|
+
* — a scale's offset from the setup scale is added, and a deform's offset
|
|
114
|
+
* from the setup vertices (weighted: the offsets themselves). From the setup
|
|
115
|
+
* pose it is the same as replacing. A rotation is added as it stands, with
|
|
116
|
+
* no shortest-angle wrap and no remembered direction (`shortestRotation` is
|
|
117
|
+
* `additive || …`). Colour, attachment, inherit and event timelines do not
|
|
118
|
+
* read `add`: under an additive entry they apply as under a replacing one.
|
|
119
|
+
* Track 0 at alpha 1 with no mix adds nothing — the runtime passes `add`
|
|
120
|
+
* false on that shortcut.
|
|
121
|
+
* - **An additive entry is never held**, and does not hold what it mixes in
|
|
122
|
+
* over: its additive timelines take their mode with no `HOLD` bit, and a
|
|
123
|
+
* timeline of an entry it replaces is not held for it. Mixing out, an
|
|
124
|
+
* additive entry adds at its faded alpha, so its offset fades to nothing.
|
|
125
|
+
*
|
|
126
|
+
* `CO52`'s oracle resets bones and slots (`setupPose()`) before every apply,
|
|
127
|
+
* not the bones alone as `CO51`'s does: on its blend probe a two-colour key at
|
|
128
|
+
* a partial alpha over track 0's `rgba` mixes the dark colour from what the
|
|
129
|
+
* previous frame left, which carries in a runtime whose slots are not reset —
|
|
130
|
+
* measured off with the bones-only reset, equal with the full one. The state
|
|
131
|
+
* poses every frame from the setup pose, slots included, as it always has.
|
|
132
|
+
*
|
|
133
|
+
* Each rejected reading is planted (`TracksPlant`) and read red by `CO52`:
|
|
134
|
+
* alpha inverted, alpha ignored, additive as replace, an additive rotation
|
|
135
|
+
* through the remembered direction, an additive entry's attachments as a
|
|
136
|
+
* plain timeline, the hold blind to the entry mixing in, the holds as if no
|
|
137
|
+
* entry were additive, and an alpha threshold of one half on attachments.
|
|
138
|
+
*
|
|
91
139
|
* ## What is left out, by name
|
|
92
140
|
*
|
|
93
141
|
* An animation is refused when it is set or queued if it keys what the state
|
|
94
142
|
* does not mix: a constraint timeline (ik, transform, path, physics, slider),
|
|
95
143
|
* a sequence timeline, the draw order, or a deform of a path attachment (a
|
|
96
144
|
* path's deformed curve reaches the constraints, which this state poses from
|
|
97
|
-
* their records as they stand).
|
|
98
|
-
* `
|
|
145
|
+
* their records as they stand). `timeScale`, `holdPrevious`, `reverse`,
|
|
146
|
+
* `shortestRotation` on a replacing entry, the thresholds as options, per-pair
|
|
147
|
+
* mix tables and the listeners are not offered. An additive entry is refused
|
|
148
|
+
* nothing more: every timeline the state accepts is defined under it (*Alpha
|
|
149
|
+
* and additive entries* below).
|
|
99
150
|
*
|
|
100
151
|
* ⛔ **A state refused part-way stays refused**, as a live track does: a step
|
|
101
152
|
* is checked before anything moves, and a refusal past that — a construct the
|
|
@@ -117,10 +168,18 @@ export interface TracksOptions {
|
|
|
117
168
|
adjust?: LocalAdjust;
|
|
118
169
|
}
|
|
119
170
|
|
|
120
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* How `setAnimation` starts an entry: whether it loops, over how many seconds
|
|
173
|
+
* the entry it replaces mixes out (0 cuts), and — issue #1344 — how strongly
|
|
174
|
+
* it applies and whether it adds (the header's *Alpha and additive entries*).
|
|
175
|
+
*/
|
|
121
176
|
export interface MixOptions {
|
|
122
177
|
loop: boolean;
|
|
123
178
|
mixDuration: number;
|
|
179
|
+
/** `TrackEntry.alpha`: from 0 to 1, multiplied into every timeline's weight; 1 when absent. Changed while the entry plays with `LiveTracks.setAlpha`. */
|
|
180
|
+
alpha?: number;
|
|
181
|
+
/** `TrackEntry.additive`: the entry's bone and deform timelines add their keyed values to what the tracks below posed rather than replacing it; `false` when absent. Fixed when the entry is set or queued, as the runtime fixes it before the next apply. */
|
|
182
|
+
additive?: boolean;
|
|
124
183
|
}
|
|
125
184
|
|
|
126
185
|
/** How `addAnimation` queues an entry: `MixOptions`, and its delay — at or below 0 relative to when the entry before it completes, above 0 seconds after the entry before starts. */
|
|
@@ -136,6 +195,10 @@ export interface TrackEntryView {
|
|
|
136
195
|
/** The animation time the entry applies at now (`getAnimationTime`): the track time wrapped or held at the duration. */
|
|
137
196
|
animationTime: number;
|
|
138
197
|
duration: number;
|
|
198
|
+
/** The entry's alpha as it stands (`MixOptions.alpha`, `LiveTracks.setAlpha`). */
|
|
199
|
+
alpha: number;
|
|
200
|
+
/** Whether the entry adds (`MixOptions.additive`). */
|
|
201
|
+
additive: boolean;
|
|
139
202
|
}
|
|
140
203
|
|
|
141
204
|
/** The live tracks (`openTracks`). */
|
|
@@ -148,6 +211,8 @@ export interface LiveTracks {
|
|
|
148
211
|
addAnimation(track: number, animation: string, options: QueueOptions): void;
|
|
149
212
|
/** Queues a mix out to nothing after the last entry on `track`; `delay` 0 ends the mix when that entry completes. */
|
|
150
213
|
addEmptyAnimation(track: number, mixDuration: number, delay: number): void;
|
|
214
|
+
/** Sets the alpha of `track`'s current entry (`TrackEntry.alpha`), from the next pose on; an entry it mixes from keeps its own. A track playing nothing is refused. */
|
|
215
|
+
setAlpha(track: number, alpha: number): void;
|
|
151
216
|
/** Pose 0, the reset (the header's *What a step is*): once, before the first `step`. */
|
|
152
217
|
start(): RawPose;
|
|
153
218
|
/** The next pose: every entry moved by `dt`, a finite time at or above 0. */
|
|
@@ -174,6 +239,22 @@ export interface TracksPlant extends TimelinePlant {
|
|
|
174
239
|
queueDelayNow?: boolean;
|
|
175
240
|
/** An entry mixing out fires its events while the mix is below one half — the reading the issue's brief stated, which the runtime's `eventThreshold` of 0 contradicts. */
|
|
176
241
|
eventsUntilHalfMix?: boolean;
|
|
242
|
+
/** `CO52`'s, issue #1344: every entry's alpha read as `1 − alpha`. */
|
|
243
|
+
alphaInverted?: boolean;
|
|
244
|
+
/** Every entry's alpha read as 1 — the option ignored. */
|
|
245
|
+
alphaIgnored?: boolean;
|
|
246
|
+
/** An additive entry applied, and its modes computed, as a replacing one. */
|
|
247
|
+
additiveAsReplace?: boolean;
|
|
248
|
+
/** An additive entry's rotations mixed through the remembered direction (`applyRotateTimeline`) as a replacing entry's are, rather than added through the timeline itself. */
|
|
249
|
+
additiveRotateRemembered?: boolean;
|
|
250
|
+
/** An additive entry's attachment timelines applied as a plain timeline (`AttachmentTimeline.apply`, no attachment state) rather than under the threshold rule (`applyAttachmentTimeline`). */
|
|
251
|
+
additiveAttachmentsDirect?: boolean;
|
|
252
|
+
/** The hold alpha computed as if the entry mixing in were at alpha 1 (`keep = 1 − mix`, not `1 − mix·to.alpha`). */
|
|
253
|
+
holdIgnoresEntryAlpha?: boolean;
|
|
254
|
+
/** The holds computed as if no entry were additive: an additive entry's own timelines held, and a timeline held under an additive entry mixing in. */
|
|
255
|
+
additiveHeld?: boolean;
|
|
256
|
+
/** An entry applied below alpha one half keeps none of its attachments — a threshold the runtime's `alphaAttachmentThreshold` of 0 does not have. */
|
|
257
|
+
attachmentsBelowHalfAlpha?: boolean;
|
|
177
258
|
}
|
|
178
259
|
|
|
179
260
|
// ---------------------------------------------------------------------------
|
|
@@ -206,6 +287,11 @@ interface MixAnimation {
|
|
|
206
287
|
/** The empty animation (`AnimationState.emptyAnimation`): no timeline, duration 0 — one object, so setting it on an entry never applied replaces that entry. */
|
|
207
288
|
const EMPTY: MixAnimation = { name: null, duration: 0, timelines: [], ids: new Set() };
|
|
208
289
|
|
|
290
|
+
/** `Timeline.additive`: the timelines the runtime adds under an additive entry — every bone timeline that keys numbers, and the deform. Colour, attachment, inherit and event timelines do not read `add`. */
|
|
291
|
+
function addable(tl: MixTimeline): boolean {
|
|
292
|
+
return tl.type === 'deform' || (tl.type === 'bone' && tl.kind !== 'inherit');
|
|
293
|
+
}
|
|
294
|
+
|
|
209
295
|
/** The properties a bone timeline keys, spine-core's `propertyIds` by bone name. */
|
|
210
296
|
const BONE_PROPERTIES: Record<BoneTimelineKind, readonly string[]> = {
|
|
211
297
|
rotate: ['rotate'], translate: ['x', 'y'], translatex: ['x'], translatey: ['y'], scale: ['scaleX', 'scaleY'], scalex: ['scaleX'], scaley: ['scaleY'], shear: ['shearX', 'shearY'], shearx: ['shearX'], sheary: ['shearY'], inherit: ['inherit'],
|
|
@@ -289,6 +375,10 @@ interface Entry {
|
|
|
289
375
|
mixTime: number;
|
|
290
376
|
mixDuration: number;
|
|
291
377
|
totalAlpha: number;
|
|
378
|
+
/** `TrackEntry.alpha`. */
|
|
379
|
+
alpha: number;
|
|
380
|
+
/** `TrackEntry.additive`. */
|
|
381
|
+
additive: boolean;
|
|
292
382
|
keepHold: boolean;
|
|
293
383
|
timelineMode: number[];
|
|
294
384
|
timelineHoldMix: Array<Entry | null | undefined>;
|
|
@@ -296,12 +386,12 @@ interface Entry {
|
|
|
296
386
|
timelinesRotation: number[];
|
|
297
387
|
}
|
|
298
388
|
|
|
299
|
-
/** A new entry (`AnimationState.trackEntry`): every field the runtime's default; the mix duration 0, `AnimationStateData`'s default mix. */
|
|
389
|
+
/** A new entry (`AnimationState.trackEntry`): every field the runtime's default; the mix duration 0, `AnimationStateData`'s default mix, alpha 1, not additive. */
|
|
300
390
|
function newEntry(anim: MixAnimation, loop: boolean): Entry {
|
|
301
391
|
return {
|
|
302
392
|
anim, loop, previous: null, next: null, mixingFrom: null, mixingTo: null,
|
|
303
393
|
delay: 0, trackTime: 0, trackLast: -1, nextTrackLast: -1, trackEnd: Number.MAX_VALUE,
|
|
304
|
-
animationLast: -1, nextAnimationLast: -1, mixTime: 0, mixDuration: 0, totalAlpha: 0, keepHold: false,
|
|
394
|
+
animationLast: -1, nextAnimationLast: -1, mixTime: 0, mixDuration: 0, totalAlpha: 0, alpha: 1, additive: false, keepHold: false,
|
|
305
395
|
timelineMode: [], timelineHoldMix: [], timelinesRotation: [],
|
|
306
396
|
};
|
|
307
397
|
}
|
|
@@ -535,6 +625,22 @@ class Mixer {
|
|
|
535
625
|
return this.plant.mixReversed === true ? 1 - m : m;
|
|
536
626
|
}
|
|
537
627
|
|
|
628
|
+
/** `TrackEntry.alpha` — inverted or ignored under the plants. */
|
|
629
|
+
alphaOf(e: Entry): number {
|
|
630
|
+
if (this.plant.alphaIgnored === true) return 1;
|
|
631
|
+
return this.plant.alphaInverted === true ? 1 - e.alpha : e.alpha;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** `TrackEntry.additive` — never under the plant that applies an additive entry as a replacing one. */
|
|
635
|
+
additiveOf(e: Entry): boolean {
|
|
636
|
+
return e.additive && this.plant.additiveAsReplace !== true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Whether `computeHold` reads an entry as additive — never under the plant that holds as if none were. */
|
|
640
|
+
private holdsAdditive(e: Entry | null): boolean {
|
|
641
|
+
return e !== null && this.additiveOf(e) && this.plant.additiveHeld !== true;
|
|
642
|
+
}
|
|
643
|
+
|
|
538
644
|
update(delta: number): void {
|
|
539
645
|
const tracks = this.tracks;
|
|
540
646
|
for (let i = 0, n = tracks.length; i < n; i++) {
|
|
@@ -612,7 +718,7 @@ class Mixer {
|
|
|
612
718
|
const current = tracks[i];
|
|
613
719
|
if (current === null || current.delay > 0) continue;
|
|
614
720
|
// Apply mixing from entries first.
|
|
615
|
-
let alpha =
|
|
721
|
+
let alpha = this.alphaOf(current);
|
|
616
722
|
if (current.mixingFrom !== null) alpha *= this.applyMixingFrom(current, pose, i);
|
|
617
723
|
else if (current.trackTime >= current.trackEnd && current.next === null) alpha = 0;
|
|
618
724
|
// Apply the current entry.
|
|
@@ -621,20 +727,27 @@ class Mixer {
|
|
|
621
727
|
const timelines = current.anim.timelines;
|
|
622
728
|
const count = timelines.length;
|
|
623
729
|
if (i === 0 && alpha === 1) {
|
|
730
|
+
// Track 0 at full weight poses from the setup pose and adds nothing, additive or not — the runtime passes `add` false here.
|
|
624
731
|
for (const tl of timelines) {
|
|
625
732
|
if (tl.type === 'attachment') this.applyAttachment(pose, tl, animationTime, SETUP, true);
|
|
626
|
-
else this.applyTimeline(pose, tl, animationLast, animationTime, true, 1, SETUP, false);
|
|
733
|
+
else this.applyTimeline(pose, tl, animationLast, animationTime, true, 1, SETUP, false, false);
|
|
627
734
|
}
|
|
628
735
|
} else {
|
|
629
736
|
const modes = current.timelineMode;
|
|
630
|
-
|
|
631
|
-
const
|
|
737
|
+
// `alphaAttachmentThreshold` is 0: an entry keeps its attachments at any alpha, 0 included.
|
|
738
|
+
const retain = this.plant.attachmentsBelowHalfAlpha === true ? alpha >= 0.5 : alpha >= 0;
|
|
739
|
+
const add = this.additiveOf(current);
|
|
740
|
+
// `shortestRotation = add || …`: an additive entry's rotations go through the timeline itself, never the remembered direction.
|
|
741
|
+
const shortest = add && this.plant.additiveRotateRemembered !== true;
|
|
742
|
+
const firstFrame = !shortest && current.timelinesRotation.length !== count << 1;
|
|
632
743
|
if (firstFrame) current.timelinesRotation.length = count << 1;
|
|
633
744
|
timelines.forEach((tl, ii) => {
|
|
634
745
|
const from = this.fromOf(modes[ii] & MODE, i);
|
|
635
|
-
if (tl.type === 'bone' && tl.kind === 'rotate') this.applyRotate(pose, tl, animationTime, alpha, from, current.timelinesRotation, ii << 1, firstFrame);
|
|
636
|
-
else if (tl.type === 'attachment')
|
|
637
|
-
|
|
746
|
+
if (!shortest && tl.type === 'bone' && tl.kind === 'rotate') this.applyRotate(pose, tl, animationTime, alpha, from, current.timelinesRotation, ii << 1, firstFrame);
|
|
747
|
+
else if (tl.type === 'attachment') {
|
|
748
|
+
if (add && this.plant.additiveAttachmentsDirect === true) this.applyAttachmentDirect(pose, tl, animationTime, from, false);
|
|
749
|
+
else this.applyAttachment(pose, tl, animationTime, from, retain);
|
|
750
|
+
} else this.applyTimeline(pose, tl, animationLast, animationTime, true, alpha, from, false, add);
|
|
638
751
|
});
|
|
639
752
|
}
|
|
640
753
|
if (i === 0) base = animationTime;
|
|
@@ -657,15 +770,17 @@ class Mixer {
|
|
|
657
770
|
const from = to.mixingFrom as Entry;
|
|
658
771
|
const fromMix = from.mixingFrom !== null ? this.applyMixingFrom(from, pose, track) : 1;
|
|
659
772
|
const mix = this.mixOf(to);
|
|
660
|
-
const a =
|
|
661
|
-
const keep = 1 - mix * 1;
|
|
773
|
+
const a = this.alphaOf(from) * fromMix;
|
|
774
|
+
const keep = 1 - mix * (this.plant.holdIgnoresEntryAlpha === true ? 1 : this.alphaOf(to));
|
|
662
775
|
const alphaMix = a * (1 - mix);
|
|
663
776
|
const alphaHold = keep > 0 ? alphaMix / keep : a;
|
|
664
777
|
const timelines = from.anim.timelines;
|
|
665
778
|
const count = timelines.length;
|
|
666
779
|
const modes = from.timelineMode;
|
|
667
780
|
const holdMix = from.timelineHoldMix;
|
|
668
|
-
const
|
|
781
|
+
const add = this.additiveOf(from);
|
|
782
|
+
const shortest = add && this.plant.additiveRotateRemembered !== true;
|
|
783
|
+
const firstFrame = !shortest && from.timelinesRotation.length !== count << 1;
|
|
669
784
|
if (firstFrame) from.timelinesRotation.length = count << 1;
|
|
670
785
|
const animationLast = from.animationLast;
|
|
671
786
|
const animationTime = animationTimeOf(from);
|
|
@@ -680,10 +795,15 @@ class Mixer {
|
|
|
680
795
|
alpha = h === null || h === undefined ? alphaHold : alphaHold * (1 - this.mixOf(h));
|
|
681
796
|
} else alpha = alphaMix;
|
|
682
797
|
from.totalAlpha += alpha;
|
|
683
|
-
if (tl.type === 'bone' && tl.kind === 'rotate') this.applyRotate(pose, tl, animationTime, alpha, mixFrom, from.timelinesRotation, i << 1, firstFrame);
|
|
684
|
-
else if (tl.type === 'attachment')
|
|
798
|
+
if (!shortest && tl.type === 'bone' && tl.kind === 'rotate') this.applyRotate(pose, tl, animationTime, alpha, mixFrom, from.timelinesRotation, i << 1, firstFrame);
|
|
799
|
+
else if (tl.type === 'attachment') {
|
|
800
|
+
// `mixAttachmentThreshold` is 0: an entry mixing out never keeps an attachment, whatever its alpha.
|
|
801
|
+
if (add && this.plant.additiveAttachmentsDirect === true) this.applyAttachmentDirect(pose, tl, animationTime, mixFrom, true);
|
|
802
|
+
else this.applyAttachment(pose, tl, animationTime, mixFrom, false);
|
|
803
|
+
}
|
|
685
804
|
// An entry mixing out fires no event: `eventThreshold` is 0 and its events are applied only while the mix is below it.
|
|
686
|
-
|
|
805
|
+
// An additive one mixing out adds at the faded alpha — its additive timelines are never held (`computeHold`).
|
|
806
|
+
else this.applyTimeline(pose, tl, animationLast, animationTime, fire, alpha, mixFrom, true, add);
|
|
687
807
|
});
|
|
688
808
|
from.nextAnimationLast = animationTime;
|
|
689
809
|
from.nextTrackLast = from.trackTime;
|
|
@@ -712,16 +832,23 @@ class Mixer {
|
|
|
712
832
|
const holdMix = entry.timelineHoldMix;
|
|
713
833
|
holdMix.length = 0;
|
|
714
834
|
const to = entry.mixingTo;
|
|
835
|
+
const add = this.holdsAdditive(entry);
|
|
836
|
+
const toAdds = this.holdsAdditive(to);
|
|
715
837
|
timelines.forEach((tl, i) => {
|
|
716
838
|
const from = this.propertyFrom(track, tl.ids);
|
|
839
|
+
// An additive entry's additive timelines are never held, and the entry's keepHold does not reach them.
|
|
840
|
+
if (add && addable(tl)) {
|
|
841
|
+
modes[i] = from;
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
717
844
|
let mode: number;
|
|
718
|
-
// Hold if the next entry will overwrite this property.
|
|
719
|
-
if (to === null || tl.instant || !hasAny(to.anim, tl.ids)) mode = from;
|
|
845
|
+
// Hold if the next entry will overwrite this property — an additive one adding to it does not.
|
|
846
|
+
if (to === null || tl.instant || (toAdds && addable(tl)) || !hasAny(to.anim, tl.ids)) mode = from;
|
|
720
847
|
else {
|
|
721
848
|
mode = from | HOLD;
|
|
722
849
|
// Find the next entry that doesn't overwrite this property. Its mix fades out the hold, instead of it ending abruptly.
|
|
723
850
|
for (let next = to.mixingTo; next !== null; next = next.mixingTo) {
|
|
724
|
-
if (!hasAny(next.anim, tl.ids)) {
|
|
851
|
+
if ((this.holdsAdditive(next) && addable(tl)) || !hasAny(next.anim, tl.ids)) {
|
|
725
852
|
if (next.mixDuration > 0) holdMix[i] = next;
|
|
726
853
|
break;
|
|
727
854
|
}
|
|
@@ -761,12 +888,12 @@ class Mixer {
|
|
|
761
888
|
return keys[index].values.map((_v, c) => channel(keys, index, c, t));
|
|
762
889
|
}
|
|
763
890
|
|
|
764
|
-
/** `Timeline.apply` for every timeline but an attachment's and a rotation mixed below full weight. */
|
|
765
|
-
private applyTimeline(pose: Pose, tl: MixTimeline, lastTime: number, time: number, fire: boolean, alpha: number, from: number, out: boolean): void {
|
|
891
|
+
/** `Timeline.apply` for every timeline but an attachment's and a rotation mixed below full weight; `add` is the entry's, read only by the timelines the runtime adds (`addable`). */
|
|
892
|
+
private applyTimeline(pose: Pose, tl: MixTimeline, lastTime: number, time: number, fire: boolean, alpha: number, from: number, out: boolean, add: boolean): void {
|
|
766
893
|
switch (tl.type) {
|
|
767
|
-
case 'bone': return this.applyBone(pose, tl, time, alpha, from, out);
|
|
894
|
+
case 'bone': return this.applyBone(pose, tl, time, alpha, from, out, add);
|
|
768
895
|
case 'colour': return this.applyColour(pose, tl, time, alpha, from);
|
|
769
|
-
case 'deform': return this.applyDeform(pose, tl, time, alpha, from);
|
|
896
|
+
case 'deform': return this.applyDeform(pose, tl, time, alpha, from, add);
|
|
770
897
|
case 'events':
|
|
771
898
|
if (fire) pose.events.push(...eventsFired(tl.keys, lastTime, time, rawNumber, time < lastTime));
|
|
772
899
|
return;
|
|
@@ -774,7 +901,7 @@ class Mixer {
|
|
|
774
901
|
}
|
|
775
902
|
}
|
|
776
903
|
|
|
777
|
-
private applyBone(pose: Pose, tl: Extract<MixTimeline, { type: 'bone' }>, time: number, alpha: number, from: number, out: boolean): void {
|
|
904
|
+
private applyBone(pose: Pose, tl: Extract<MixTimeline, { type: 'bone' }>, time: number, alpha: number, from: number, out: boolean, add: boolean): void {
|
|
778
905
|
const bone = pose.bone(tl.bone);
|
|
779
906
|
if (bone === null) return;
|
|
780
907
|
const setup = this.doc.bones[tl.bone];
|
|
@@ -797,7 +924,8 @@ class Mixer {
|
|
|
797
924
|
const relative = (field: 'x' | 'y' | 'rotation' | 'shearX' | 'shearY', i: number): void => {
|
|
798
925
|
const s = setup[field] ?? 0;
|
|
799
926
|
const c = bone[field] ?? 0;
|
|
800
|
-
|
|
927
|
+
// `getRelativeValue`: from the setup pose the same either way; else the keyed value added, or the value over the setup mixed in.
|
|
928
|
+
bone[field] = v === null ? beforeFirstKey(from, alpha, c, s) : from === SETUP ? s + v[i] * alpha : c + (add ? v[i] : v[i] + s - c) * alpha;
|
|
801
929
|
};
|
|
802
930
|
const pair = (fx: 'x' | 'shearX', fy: 'y' | 'shearY'): void => {
|
|
803
931
|
const sx = setup[fx] ?? 0;
|
|
@@ -815,6 +943,9 @@ class Mixer {
|
|
|
815
943
|
if (from === SETUP) {
|
|
816
944
|
bone[fx] = sx + v[0] * alpha;
|
|
817
945
|
bone[fy] = sy + v[1] * alpha;
|
|
946
|
+
} else if (add) {
|
|
947
|
+
bone[fx] = (bone[fx] ?? 0) + v[0] * alpha;
|
|
948
|
+
bone[fy] = (bone[fy] ?? 0) + v[1] * alpha;
|
|
818
949
|
} else {
|
|
819
950
|
bone[fx] = (bone[fx] ?? 0) + (sx + v[0] - (bone[fx] ?? 0)) * alpha;
|
|
820
951
|
bone[fy] = (bone[fy] ?? 0) + (sy + v[1] - (bone[fy] ?? 0)) * alpha;
|
|
@@ -823,7 +954,7 @@ class Mixer {
|
|
|
823
954
|
const scale = (field: 'scaleX' | 'scaleY', i: number): void => {
|
|
824
955
|
const s = setup[field] ?? 1;
|
|
825
956
|
const c = bone[field] ?? 1;
|
|
826
|
-
bone[field] = v === null ? beforeFirstKey(from, alpha, c, s) : scaleValue(v[i] * s, alpha, from, out, c, s);
|
|
957
|
+
bone[field] = v === null ? beforeFirstKey(from, alpha, c, s) : scaleValue(v[i] * s, alpha, from, out, add, c, s);
|
|
827
958
|
};
|
|
828
959
|
switch (tl.kind) {
|
|
829
960
|
case 'rotate': relative('rotation', 0); break;
|
|
@@ -845,8 +976,8 @@ class Mixer {
|
|
|
845
976
|
bone.scaleY = (bone.scaleY ?? 1) + (sy - (bone.scaleY ?? 1)) * alpha;
|
|
846
977
|
}
|
|
847
978
|
} else {
|
|
848
|
-
bone.scaleX = scaleValue(v[0] * (setup.scaleX ?? 1), alpha, from, out, bone.scaleX ?? 1, setup.scaleX ?? 1);
|
|
849
|
-
bone.scaleY = scaleValue(v[1] * (setup.scaleY ?? 1), alpha, from, out, bone.scaleY ?? 1, setup.scaleY ?? 1);
|
|
979
|
+
bone.scaleX = scaleValue(v[0] * (setup.scaleX ?? 1), alpha, from, out, add, bone.scaleX ?? 1, setup.scaleX ?? 1);
|
|
980
|
+
bone.scaleY = scaleValue(v[1] * (setup.scaleY ?? 1), alpha, from, out, add, bone.scaleY ?? 1, setup.scaleY ?? 1);
|
|
850
981
|
}
|
|
851
982
|
break;
|
|
852
983
|
case 'scalex': scale('scaleX', 0); break;
|
|
@@ -858,7 +989,7 @@ class Mixer {
|
|
|
858
989
|
private applyRotate(pose: Pose, tl: Extract<MixTimeline, { type: 'bone' }>, time: number, alpha: number, from: number, rotations: number[], i: number, firstFrame: boolean): void {
|
|
859
990
|
if (firstFrame) rotations[i] = 0;
|
|
860
991
|
if (alpha === 1) {
|
|
861
|
-
this.applyBone(pose, tl, time, 1, from, false);
|
|
992
|
+
this.applyBone(pose, tl, time, 1, from, false, false);
|
|
862
993
|
return;
|
|
863
994
|
}
|
|
864
995
|
const bone = pose.bone(tl.bone);
|
|
@@ -934,6 +1065,15 @@ class Mixer {
|
|
|
934
1065
|
else if (!setup) s.attachmentState = ATTACH_SETUP;
|
|
935
1066
|
}
|
|
936
1067
|
|
|
1068
|
+
/** `AttachmentTimeline.apply` — the plain timeline, writing no attachment state: only `additiveAttachmentsDirect` reaches it, the reading `CO52` rejects. */
|
|
1069
|
+
private applyAttachmentDirect(pose: Pose, tl: Extract<MixTimeline, { type: 'attachment' }>, time: number, from: number, out: boolean): void {
|
|
1070
|
+
if (!pose.slotLive(tl.slot)) return;
|
|
1071
|
+
const index = (this.plant.search ?? keyIndexAt)(tl.keys, time);
|
|
1072
|
+
if (out || index < 0) {
|
|
1073
|
+
if (from !== CURRENT) pose.setAttachment(tl.slot, this.doc.slots[tl.slot].setup);
|
|
1074
|
+
} else pose.setAttachment(tl.slot, tl.keys[index].name ?? null);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
937
1077
|
private applyColour(pose: Pose, tl: Extract<MixTimeline, { type: 'colour' }>, time: number, alpha: number, from: number): void {
|
|
938
1078
|
if (!pose.slotLive(tl.slot)) return;
|
|
939
1079
|
const slot = this.doc.slots[tl.slot];
|
|
@@ -1030,7 +1170,7 @@ class Mixer {
|
|
|
1030
1170
|
}
|
|
1031
1171
|
|
|
1032
1172
|
/** `DeformTimeline.apply`: every live slot showing the record the timeline keys, its deform array mixed. */
|
|
1033
|
-
private applyDeform(pose: Pose, tl: Extract<MixTimeline, { type: 'deform' }>, time: number, alpha: number, from: number): void {
|
|
1173
|
+
private applyDeform(pose: Pose, tl: Extract<MixTimeline, { type: 'deform' }>, time: number, alpha: number, from: number, add: boolean): void {
|
|
1034
1174
|
const keys = tl.timeline.deform;
|
|
1035
1175
|
if (keys === null) return;
|
|
1036
1176
|
pose.slots.forEach((s, k) => {
|
|
@@ -1061,13 +1201,27 @@ class Mixer {
|
|
|
1061
1201
|
}
|
|
1062
1202
|
return;
|
|
1063
1203
|
}
|
|
1064
|
-
// `applyToSlot
|
|
1065
|
-
|
|
1066
|
-
|
|
1204
|
+
// `applyToSlot`: added only over a deform already there — over none it is the setup pose's branch, additive or not.
|
|
1205
|
+
const fromSetup = mixFrom === SETUP;
|
|
1206
|
+
if (alpha === 1) {
|
|
1207
|
+
if (add && !fromSetup) {
|
|
1208
|
+
const current = s.deform as number[];
|
|
1209
|
+
if (!vertices.weighted) {
|
|
1210
|
+
const setup = setupArray(vertices);
|
|
1211
|
+
s.deform = current.map((c, i) => c + (target[i] - setup[i]));
|
|
1212
|
+
} else s.deform = current.map((c, i) => c + target[i]);
|
|
1213
|
+
} else s.deform = [...target];
|
|
1214
|
+
} else if (fromSetup) {
|
|
1067
1215
|
if (!vertices.weighted) {
|
|
1068
1216
|
const setup = setupArray(vertices);
|
|
1069
1217
|
s.deform = target.map((v, i) => setup[i] + (v - setup[i]) * alpha);
|
|
1070
1218
|
} else s.deform = target.map((v) => v * alpha);
|
|
1219
|
+
} else if (add) {
|
|
1220
|
+
const current = s.deform as number[];
|
|
1221
|
+
if (!vertices.weighted) {
|
|
1222
|
+
const setup = setupArray(vertices);
|
|
1223
|
+
s.deform = current.map((c, i) => c + (target[i] - setup[i]) * alpha);
|
|
1224
|
+
} else s.deform = current.map((c, i) => c + target[i] * alpha);
|
|
1071
1225
|
} else {
|
|
1072
1226
|
const current = s.deform as number[];
|
|
1073
1227
|
s.deform = current.map((c, i) => c + (target[i] - c) * alpha);
|
|
@@ -1083,10 +1237,11 @@ function beforeFirstKey(from: number, alpha: number, current: number, setup: num
|
|
|
1083
1237
|
return current;
|
|
1084
1238
|
}
|
|
1085
1239
|
|
|
1086
|
-
/** `CurveTimeline1.getScaleValue` past the first key, `value` the curve times the setup scale,
|
|
1087
|
-
function scaleValue(value: number, alpha: number, from: number, out: boolean, current: number, setup: number): number {
|
|
1088
|
-
if (alpha === 1) return value;
|
|
1240
|
+
/** `CurveTimeline1.getScaleValue` past the first key, `value` the curve times the setup scale; added, its offset from the setup scale is added to the base. */
|
|
1241
|
+
function scaleValue(value: number, alpha: number, from: number, out: boolean, add: boolean, current: number, setup: number): number {
|
|
1242
|
+
if (alpha === 1 && !add) return value;
|
|
1089
1243
|
let base = from === SETUP ? setup : current;
|
|
1244
|
+
if (add) return base + (value - setup) * alpha;
|
|
1090
1245
|
if (out) return base + (Math.abs(value) * Math.sign(base) - base) * alpha;
|
|
1091
1246
|
base = Math.abs(base) * Math.sign(value);
|
|
1092
1247
|
return base + (value - base) * alpha;
|
|
@@ -1117,6 +1272,20 @@ function loopOf(options: MixOptions | undefined, call: string): boolean {
|
|
|
1117
1272
|
return options.loop;
|
|
1118
1273
|
}
|
|
1119
1274
|
|
|
1275
|
+
/** An alpha: a finite number from 0 to 1, or the call refused naming it — the runtime clamps nothing and defines no weight outside it. */
|
|
1276
|
+
function alphaValue(v: unknown, what: string, call: string): number {
|
|
1277
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) throw new CoreInputError(`the live tracks' ${call} ${what} is ${typeof v === 'number' ? String(v) : JSON.stringify(v)}, not a number from 0 to 1`);
|
|
1278
|
+
return v;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/** `options.alpha` and `options.additive`: 1 and `false` when absent, else refused by name where they are not a weight and a boolean. */
|
|
1282
|
+
function blendOf(options: MixOptions, call: string): { alpha: number; additive: boolean } {
|
|
1283
|
+
const alpha = options.alpha === undefined ? 1 : alphaValue(options.alpha, 'options.alpha', call);
|
|
1284
|
+
const additive: unknown = options.additive;
|
|
1285
|
+
if (additive !== undefined && typeof additive !== 'boolean') throw new CoreInputError(`the live tracks' ${call} options.additive is ${JSON.stringify(additive)}, not true or false — an entry adds or it does not`);
|
|
1286
|
+
return { alpha, additive: additive === true };
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1120
1289
|
function delayOf(v: number, call: string): number {
|
|
1121
1290
|
if (typeof v !== 'number' || !Number.isFinite(v)) throw new CoreInputError(`the live tracks' ${call} delay is ${String(v)}, not a finite number`);
|
|
1122
1291
|
return v;
|
|
@@ -1163,8 +1332,11 @@ class Tracks implements LiveTracks {
|
|
|
1163
1332
|
const i = trackIndex(track, 'setAnimation');
|
|
1164
1333
|
const loop = loopOf(options, 'setAnimation');
|
|
1165
1334
|
const mix = finiteAtOrAbove0(options.mixDuration, 'mixDuration', 'setAnimation');
|
|
1335
|
+
const blend = blendOf(options, 'setAnimation');
|
|
1166
1336
|
const entry = this.mixer.setAnimation(i, this.mixer.animation(animation), loop);
|
|
1167
1337
|
entry.mixDuration = mix;
|
|
1338
|
+
entry.alpha = blend.alpha;
|
|
1339
|
+
entry.additive = blend.additive;
|
|
1168
1340
|
}
|
|
1169
1341
|
|
|
1170
1342
|
setEmptyAnimation(track: number, mixDuration: number): void {
|
|
@@ -1182,8 +1354,11 @@ class Tracks implements LiveTracks {
|
|
|
1182
1354
|
const loop = loopOf(options, 'addAnimation');
|
|
1183
1355
|
const mix = finiteAtOrAbove0(options.mixDuration, 'mixDuration', 'addAnimation');
|
|
1184
1356
|
const delay = delayOf(options.delay, 'addAnimation');
|
|
1357
|
+
const blend = blendOf(options, 'addAnimation');
|
|
1185
1358
|
const entry = this.mixer.addAnimation(i, this.mixer.animation(animation), loop, delay);
|
|
1186
1359
|
this.mixer.setMixDuration(entry, mix, delay);
|
|
1360
|
+
entry.alpha = blend.alpha;
|
|
1361
|
+
entry.additive = blend.additive;
|
|
1187
1362
|
}
|
|
1188
1363
|
|
|
1189
1364
|
addEmptyAnimation(track: number, mixDuration: number, delay: number): void {
|
|
@@ -1197,10 +1372,19 @@ class Tracks implements LiveTracks {
|
|
|
1197
1372
|
entry.trackEnd = mix;
|
|
1198
1373
|
}
|
|
1199
1374
|
|
|
1375
|
+
setAlpha(track: number, alpha: number): void {
|
|
1376
|
+
this.live('setAlpha');
|
|
1377
|
+
const i = trackIndex(track, 'setAlpha');
|
|
1378
|
+
const a = alphaValue(alpha, 'alpha', 'setAlpha');
|
|
1379
|
+
const e = this.mixer.tracks[i] ?? null;
|
|
1380
|
+
if (e === null) throw new CoreInputError(`the live tracks' setAlpha names track ${i}, which plays nothing — there is no entry to weigh`);
|
|
1381
|
+
e.alpha = a;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1200
1384
|
current(track: number): TrackEntryView | null {
|
|
1201
1385
|
const e = this.mixer.tracks[trackIndex(track, 'current')] ?? null;
|
|
1202
1386
|
if (e === null) return null;
|
|
1203
|
-
return { animation: e.anim.name, loop: e.loop, trackTime: e.trackTime, animationTime: animationTimeOf(e), duration: e.anim.duration };
|
|
1387
|
+
return { animation: e.anim.name, loop: e.loop, trackTime: e.trackTime, animationTime: animationTimeOf(e), duration: e.anim.duration, alpha: e.alpha, additive: e.additive };
|
|
1204
1388
|
}
|
|
1205
1389
|
|
|
1206
1390
|
start(): RawPose {
|
package/src/repack.ts
CHANGED
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
* PNG is missing or not the size the atlas declares, would be lifted from
|
|
41
41
|
* texels the atlas does not describe.
|
|
42
42
|
*
|
|
43
|
+
* `repack --keep-pages` (issue #1339) asks another question of the same
|
|
44
|
+
* atlas — not "can it be repacked?" but "can the model document state it as
|
|
45
|
+
* it stands?" — and `keptPageRefusals` is its list.
|
|
46
|
+
*
|
|
43
47
|
* 🔒 Every refusal is collected before the first file is written, and the
|
|
44
48
|
* command writes nothing when there is one. Nothing here guesses: there is no
|
|
45
49
|
* "close enough" cut.
|
|
@@ -49,8 +53,10 @@
|
|
|
49
53
|
*/
|
|
50
54
|
import { type AtlasPage, type AtlasRegion, extractRegion, footprintCell, packFootprints, pageFootprint, type ParsedAtlas, readEntry } from './atlas.ts';
|
|
51
55
|
import { Plate, readPlate } from '../tools/plate.ts';
|
|
56
|
+
import { MODEL_DOCUMENT_FILE } from './model.ts';
|
|
57
|
+
import { readPngHeader } from './png.ts';
|
|
52
58
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
53
|
-
import { dirname, join, resolve } from 'node:path';
|
|
59
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
54
60
|
|
|
55
61
|
/**
|
|
56
62
|
* A repack refused: an input it cannot lift exactly, a repack that lost
|
|
@@ -260,6 +266,99 @@ export function atlasRefusals(parsed: ParsedAtlas, atlasDir: string): { refusals
|
|
|
260
266
|
return { refusals, pages };
|
|
261
267
|
}
|
|
262
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The page lines `skeleton.model.json` states (issue #1339): `size` as the
|
|
271
|
+
* page's `width`/`height`, `pma`, and `scale` as the number written
|
|
272
|
+
* (`pagesOfAtlas`). `filter: Linear, Linear` is the one sampling the document
|
|
273
|
+
* implies by leaving filters out — the core's draw is bilinear on every path
|
|
274
|
+
* (`src/render.ts`'s header) — so a page stating it states nothing the document
|
|
275
|
+
* drops.
|
|
276
|
+
*/
|
|
277
|
+
const PAGE_FIELDS_STATED = new Set(['size', 'pma', 'scale']);
|
|
278
|
+
|
|
279
|
+
/** The region lines the document states: placement, the drawing's own size and turn, and `index`. */
|
|
280
|
+
const REGION_FIELDS_STATED = new Set([...REGION_PLACEMENT_KEYS, 'index']);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Every reason a model document cannot be written for this atlas AS IT STANDS
|
|
284
|
+
* — `repack --keep-pages`, issue #1339 — in file order, empty when it can be.
|
|
285
|
+
*
|
|
286
|
+
* The question is not `atlasRefusals`' (can it be lifted and repacked?) but
|
|
287
|
+
* whether the document's `pages` section can say everything the atlas says.
|
|
288
|
+
* So a turned region, a trimmed region, a page `scale:`, `pma: true`, an
|
|
289
|
+
* `index:` and a name the atlas spells twice are not refused here — the
|
|
290
|
+
* document states each of them (`ModelPage`, `ModelPageRegion`) and the core
|
|
291
|
+
* reads them (`./core/uvs.ts`); whether the skeleton still rebuilds byte for
|
|
292
|
+
* byte against them is the command's check (b), not a guess made here. Every
|
|
293
|
+
* line the document has no field for is refused naming its page or region: a
|
|
294
|
+
* player reading only the document would draw without it. The page PNGs are
|
|
295
|
+
* checked as `atlasRefusals` checks them — on disk, the size the atlas
|
|
296
|
+
* declares, every rectangle on its page — because the document is written to
|
|
297
|
+
* sit beside them; and a page name that is not a relative path below the
|
|
298
|
+
* atlas's directory is refused, because the document's page names are paths
|
|
299
|
+
* from the directory it sits in.
|
|
300
|
+
*
|
|
301
|
+
* ⚠️ What no refusal here can see is a texel. Nothing in the three files
|
|
302
|
+
* records one — the atlas states rectangles, not contents — so a page edited
|
|
303
|
+
* after it was packed is a page the atlas still describes, and the document
|
|
304
|
+
* written beside it is the same bytes. That is a limit of the inputs, stated
|
|
305
|
+
* rather than papered over.
|
|
306
|
+
*/
|
|
307
|
+
export function keptPageRefusals(parsed: ParsedAtlas, atlasDir: string): string[] {
|
|
308
|
+
const refusals: string[] = [];
|
|
309
|
+
if (parsed.regions.length === 0) refusals.push('the atlas names no region, so the document would state no placement');
|
|
310
|
+
for (const { page, fields, regions } of atlasFields(parsed)) {
|
|
311
|
+
const segments = page.name.split('/');
|
|
312
|
+
if (page.name.includes('\\') || isAbsolute(page.name) || segments.some((s) => s === '' || s === '.' || s === '..')) {
|
|
313
|
+
refusals.push(
|
|
314
|
+
`page "${page.name}" is not a relative path below the atlas's directory (an empty, "." or ".." segment, a leading "/" or a "\\"), ` +
|
|
315
|
+
`and ${MODEL_DOCUMENT_FILE}'s page names are paths from the directory it sits in`,
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
const path = resolve(atlasDir, page.name);
|
|
319
|
+
if (!existsSync(path)) refusals.push(`page "${page.name}" is not on disk: nothing at ${path}`);
|
|
320
|
+
else {
|
|
321
|
+
const header = readPngHeader(path);
|
|
322
|
+
if (header.info === null) refusals.push(`page "${page.name}" cannot be read as PNG: ${header.problem}`);
|
|
323
|
+
else if (header.info.width !== page.width || header.info.height !== page.height) {
|
|
324
|
+
refusals.push(
|
|
325
|
+
`page "${page.name}" declares size ${page.width}x${page.height} and ${path} is ${header.info.width}x${header.info.height} — ` +
|
|
326
|
+
'the document would state the declared grid beside a page that is not on it',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const entry of fields) {
|
|
332
|
+
const value = entry.values.join(', ');
|
|
333
|
+
if (PAGE_FIELDS_STATED.has(entry.key) || (entry.key === 'filter' && value === PAGE_FIELDS_WRITTEN.filter)) continue;
|
|
334
|
+
refusals.push(
|
|
335
|
+
entry.key === 'filter'
|
|
336
|
+
? `page "${page.name}" states filter: ${value} — ${MODEL_DOCUMENT_FILE} states no filter and the core samples every page bilinearly (filter: ${PAGE_FIELDS_WRITTEN.filter}), so a player reading the document would draw this page otherwise than the atlas asks`
|
|
337
|
+
: `page "${page.name}" states ${entry.key}: ${value}, a line ${MODEL_DOCUMENT_FILE} has no field for — a player reading only the document would draw without it`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
regions.forEach(({ raw, fields: regionFields }, k) => {
|
|
341
|
+
const name = raw.trim();
|
|
342
|
+
for (const entry of regionFields) {
|
|
343
|
+
if (entry.key === 'rotate') {
|
|
344
|
+
if (!ROTATE_VALUES.has(entry.values[0] ?? '')) {
|
|
345
|
+
refusals.push(`region "${name}" on page "${page.name}" states rotate: ${entry.values.join(', ')}, which is not one of true, false, 0, 90, 180, 270 — the turns the document's degrees can state`);
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (REGION_FIELDS_STATED.has(entry.key)) continue;
|
|
350
|
+
refusals.push(
|
|
351
|
+
`region "${name}" on page "${page.name}" states ${entry.key}: ${entry.values.join(', ')}, a line ${MODEL_DOCUMENT_FILE} has no field for` +
|
|
352
|
+
`${entry.key === 'split' || entry.key === 'pad' ? ' (a nine-patch region)' : ''} — a player reading only the document would draw without it`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const geometry = geometryRefusal(page, page.regions[k]);
|
|
356
|
+
if (geometry !== null) refusals.push(geometry);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return refusals;
|
|
360
|
+
}
|
|
361
|
+
|
|
263
362
|
/** Every region the atlas names, lifted, in file order, by its (trimmed) name. */
|
|
264
363
|
export interface Lift {
|
|
265
364
|
parts: Map<string, Plate>;
|