spine-rigc 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/cli.ts +9 -9
- package/docs/AUTHORING.md +71 -12
- package/docs/FACE.md +31 -0
- package/docs/INGEST.md +7 -6
- package/docs/MOTION.md +2 -1
- package/docs/RIGGING.md +4 -1
- package/package.json +1 -1
- package/src/atlas.ts +130 -57
- package/src/deformgen.ts +124 -8
- package/src/trackgen.ts +12 -3
- package/src/validate.ts +128 -19
package/README.md
CHANGED
|
@@ -386,7 +386,7 @@ limits: [AUTHORING.md §11](docs/AUTHORING.md). The parts it refuses because
|
|
|
386
386
|
something is drawn over them are `rigc chainfit`'s, once a candidate exists —
|
|
387
387
|
[§12](docs/AUTHORING.md).
|
|
388
388
|
|
|
389
|
-
## The gallery —
|
|
389
|
+
## The gallery — six complete rigs over art that ships with them
|
|
390
390
|
|
|
391
391
|
Each directory in [`gallery/`](https://github.com/firejune/rigc/tree/main/gallery) is
|
|
392
392
|
one rig spec, one motion spec and the PNGs they name, small enough to read in one
|
|
@@ -402,6 +402,7 @@ was verified, and what writing it cost. Repository material: a clone and
|
|
|
402
402
|
| [`gallery/flex`](https://github.com/firejune/rigc/tree/main/gallery/flex) | **`contour` meshes** | A swallow-tailed banner and a serrated leaf: four meshes traced off their own alpha, waved by bone timelines and rippled by a `deform` |
|
|
403
403
|
| [`gallery/ride`](https://github.com/firejune/rigc/tree/main/gallery/ride) | `path` attachments + **path constraints** | A trolley coasting down a drawn rail and rolling back, driven by a `position` timeline, with `groups` + `stagger` keying the wheels and the ears |
|
|
404
404
|
| [`gallery/portrait`](https://github.com/firejune/rigc/tree/main/gallery/portrait) | **deform `transform`** + `derive` group tracks | A 2.5D head turn: two meshes and six feature bones all keyed from one stated expression, `dx = x(cos t − 1) − z·sin t`, with the depths in the spec rather than a README |
|
|
405
|
+
| [`gallery/nod`](https://github.com/firejune/rigc/tree/main/gallery/nod) | the **`pitch`** and **`wave`** transform kinds | A head bowing and two lop ears rippling, on three meshes each laid out for the closed form that moves it — a fold angle solved for before authoring, and a shear whose winding no amplitude can reverse |
|
|
405
406
|
|
|
406
407
|
<p align="center">
|
|
407
408
|
<img src="https://raw.githubusercontent.com/firejune/rigc/main/assets/rigc-scene.gif" alt="A portrait rig breathing, glancing aside, then turning its head in 2.5D — hair and features sliding at different depths" width="600" />
|
|
@@ -413,6 +414,12 @@ feature carries its own depth. Scene direction of this kind was Live2D's territo
|
|
|
413
414
|
split was authoring cost, and the cost is now one stated expression per key. Compiled and
|
|
414
415
|
rendered entirely by the published package.</em></p>
|
|
415
416
|
|
|
417
|
+
🎞️ **How the three films on this page were made** is kept with them, one directory per
|
|
418
|
+
film in [`films/`](https://github.com/firejune/rigc/tree/main/films) — a `run.sh` that
|
|
419
|
+
names every step, the assembler that cuts the shots and draws the type, and a README
|
|
420
|
+
saying what the film claims and which tool printed each figure on screen. Repository
|
|
421
|
+
material, like the gallery: a clone runs them.
|
|
422
|
+
|
|
416
423
|
## Commands
|
|
417
424
|
|
|
418
425
|
Every command takes its paths explicitly. `rigc <command> --help` prints its flags, and
|
package/cli.ts
CHANGED
|
@@ -727,9 +727,10 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
727
727
|
const { label, opts } = resolveCut(flags);
|
|
728
728
|
const profile = readProfile(flags);
|
|
729
729
|
const packing = flags.pack !== undefined;
|
|
730
|
-
//
|
|
730
|
+
// Two combinations are refused rather than silently resolved, because in each
|
|
731
731
|
// one the two flags disagree about a single question and there is no answer
|
|
732
|
-
// that is not a guess about which the caller meant.
|
|
732
|
+
// that is not a guess about which the caller meant. (There were three until
|
|
733
|
+
// issue #266 — see the note below the second.)
|
|
733
734
|
if (packing && opts.atlasInPath !== undefined) {
|
|
734
735
|
throw new UsageError(
|
|
735
736
|
'--pack and --atlas-in are opposite directions through the same door: --pack MAKES an atlas out of the ' +
|
|
@@ -742,13 +743,12 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
742
743
|
'the loose part PNGs, which a packed atlas does not reference. Drop --copy-images',
|
|
743
744
|
);
|
|
744
745
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}
|
|
746
|
+
// `--pack --profile spine-html` used to be the third refusal here, because
|
|
747
|
+
// A06's coverage clause was "one part per page" flat and a legitimate pack
|
|
748
|
+
// arrived at the gate reading as a defect. Since issue #266's second follow-up
|
|
749
|
+
// that clause is "one part per page OR a tiling page", so the combination is
|
|
750
|
+
// now a build like any other — and it is the only one that puts the renderer's
|
|
751
|
+
// own rulebook over shared-page sampling.
|
|
752
752
|
if (!packing) {
|
|
753
753
|
for (const name of ['page-size', 'padding'] as const) {
|
|
754
754
|
if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
|
package/docs/AUTHORING.md
CHANGED
|
@@ -196,10 +196,16 @@ unweighted meshes, packed atlases) — reach for it when you are shipping into
|
|
|
196
196
|
*that* project, not to be thorough. A report always prints which profile ran and
|
|
197
197
|
lists what that profile left out, on `PROF` lines.
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
🆕 *Packed atlases* used to be in that list twice over: `--pack --profile
|
|
200
|
+
spine-html` was **refused by name**, because `A06`'s coverage clause said "one
|
|
201
|
+
part per page" flat and rigc's own pack could not satisfy it. Since
|
|
202
|
+
[#266](https://github.com/firejune/rigc/issues/266) that clause is **one part per
|
|
203
|
+
page OR a tiling page**, so the combination is an ordinary build — and it is the
|
|
204
|
+
only one that puts the renderer's own rulebook over shared-page sampling. What a
|
|
205
|
+
*tiling* page has to satisfy is stated where the clause is, §7's `A06` row: every
|
|
206
|
+
region wholly inside the page it names, and no two regions on one page
|
|
207
|
+
overlapping. Rotation is still refused, and that is a separate clause about
|
|
208
|
+
rigc's packer never turning a region.
|
|
203
209
|
|
|
204
210
|
### 0.1 Packing the parts onto shared pages — `--pack`
|
|
205
211
|
|
|
@@ -1455,7 +1461,7 @@ is that a **depth** is the decision while a **residual** is not.
|
|
|
1455
1461
|
| `kind` | Reads | `translatex` / `translatey` | `scalex` / `scaley` | Worked case |
|
|
1456
1462
|
| --- | --- | --- | --- | --- |
|
|
1457
1463
|
| `yaw` | each member's setup `x` | `d = (x−about)·(cos t − 1) − (depth − carried)·sin t` — FACE §3 | `cos(α − t)/cos α`, `α = atan2(x−about, depth)` — FACE §5 | `gallery/portrait` |
|
|
1458
|
-
| `pitch` | each member's setup `y` | the same expression with `y` for `x` — a nod | the same |
|
|
1464
|
+
| `pitch` | each member's setup `y` | the same expression with `y` for `x` — a nod | the same | `gallery/nod` |
|
|
1459
1465
|
|
|
1460
1466
|
⭐ **The `property` says which half of the turn a key is.** A turn does two
|
|
1461
1467
|
things to a rigid part on a curved surface: it moves it, and it narrows it. Those
|
|
@@ -1464,7 +1470,19 @@ which one the track is — so a `derive` on a property its kind has no projectio
|
|
|
1464
1470
|
onto is refused by name rather than quietly driven by the wrong half.
|
|
1465
1471
|
|
|
1466
1472
|
**The parameters.** `degrees` and `depth` are required; `depth` is
|
|
1467
|
-
`{ "member": z, … }` on a group track and one number on a bone track.
|
|
1473
|
+
`{ "member": z, … }` on a group track and one number on a bone track.
|
|
1474
|
+
|
|
1475
|
+
⚠️ **`z` runs toward the viewer (FACE §1), so a larger depth is nearer.** A nose
|
|
1476
|
+
in front of the skull surface takes a **bigger** number than the socket beside
|
|
1477
|
+
it, and a **negative** depth is behind the axis — which is what makes the back of
|
|
1478
|
+
a head swing the other way (FACE §2). That sign is the one parameter here no
|
|
1479
|
+
assertion can check, so the closed form is the arbiter: a part with
|
|
1480
|
+
`depth > carried` gets a **negative** residual, and FACE §3 makes exactly that
|
|
1481
|
+
the nose diagnostic — *if the nose's residual is not negative, the depths are
|
|
1482
|
+
wrong*. ([#351](https://github.com/firejune/rigc/issues/351) was this sentence
|
|
1483
|
+
missing here and stated backwards in the field reference.)
|
|
1484
|
+
|
|
1485
|
+
`carried`
|
|
1468
1486
|
(default 0) is **the depth whose shift a parent bone already applies** — FACE
|
|
1469
1487
|
§3's shared-shift split, stated: put a bone at the plate's own origin, key
|
|
1470
1488
|
`−carried·sin t` there, and each member then keys only its residual. That split
|
|
@@ -1943,7 +1961,9 @@ where the remedy is a line you own. `A35` does **not** refuse it: it is pointed
|
|
|
1943
1961
|
other people's files, and a rule stricter than the runtime tells its reader to go
|
|
1944
1962
|
and break correct data.
|
|
1945
1963
|
|
|
1946
|
-
🖼️ **Worked examples, and they use a deform for
|
|
1964
|
+
🖼️ **Worked examples, and they use a deform for four different things** — all
|
|
1965
|
+
four are repository material rather than part of the published package, so the
|
|
1966
|
+
links go to GitHub.
|
|
1947
1967
|
[`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash) — a 9-vertex ball squashed about its contact point,
|
|
1948
1968
|
from the two affine transforms its keys now **state**.
|
|
1949
1969
|
[`gallery/portrait`](https://github.com/firejune/rigc/tree/main/gallery/portrait) — a 2.5D head turn, where the keys are the
|
|
@@ -1951,6 +1971,11 @@ from the two affine transforms its keys now **state**.
|
|
|
1951
1971
|
placed to sample a cosine, and a measured account of the angle past which the
|
|
1952
1972
|
mesh folds. [`gallery/flex`](https://github.com/firejune/rigc/tree/main/gallery/flex) — a leaf whose blade bends on a
|
|
1953
1973
|
`contour` mesh no bone can bend, and the measurement that picked the model.
|
|
1974
|
+
[`gallery/nod`](https://github.com/firejune/rigc/tree/main/gallery/nod) — the
|
|
1975
|
+
projection on the **other** axis (a `pitch`, §4.11.1), and a **travelling wave**
|
|
1976
|
+
whose only moving parameter is `phase`: each of its three meshes is laid out for
|
|
1977
|
+
the model that bends it, so the rows are the argument rather than the vertex
|
|
1978
|
+
count.
|
|
1954
1979
|
|
|
1955
1980
|
📘 **[FACE.md](FACE.md) is the recipe for that second case**, and it is where the
|
|
1956
1981
|
grid questions this section leaves to its reader are answered: where to put the
|
|
@@ -1996,9 +2021,9 @@ example needed it:
|
|
|
1996
2021
|
| `kind` | Parameters | What it evaluates | Worked case |
|
|
1997
2022
|
| --- | --- | --- | --- |
|
|
1998
2023
|
| `yaw` | `radius`, `degrees`, `about` | `dx = (x−about)·(cos t − 1) − z·sin t`, `z = √(radius² − (x−about)²)` — the 2.5D turn (FACE §1) | `gallery/portrait` |
|
|
1999
|
-
| `pitch` | the same | the same expression with `y` for `x` — a nod rather than a turn |
|
|
2024
|
+
| `pitch` | the same | the same expression with `y` for `x` — a nod rather than a turn | `gallery/nod` |
|
|
2000
2025
|
| `affine` | `scale`, `about` | `dx = (sx−1)·(x−ax)`, `dy = (sy−1)·(y−ay)` — a scale about a fixed point | `gallery/squash` |
|
|
2001
|
-
| `wave` | `amplitude`, `wavelength`, `phase`, `along`, `axis` | `d = amplitude · sin(2π·along/wavelength + phase)` |
|
|
2026
|
+
| `wave` | `amplitude`, `wavelength`, `phase`, `along`, `axis` | `d = amplitude · sin(2π·along/wavelength + phase)` | `gallery/nod` |
|
|
2002
2027
|
| `bend` | `amount`, `from`, `to`, `power`, `along`, `axis` | `d = amount · u^power`, `u = (along − from)/(to − from)` | `gallery/flex` |
|
|
2003
2028
|
|
|
2004
2029
|
`along` names the coordinate a wave or a bend reads and `axis` the one it
|
|
@@ -2008,7 +2033,7 @@ axis is a stretch and `affine` states that. `power: 1` is an affine shear and
|
|
|
2008
2033
|
instead of tilting. `about` defaults to 0 (or `[0, 0]`), `phase` to 0 and `power`
|
|
2009
2034
|
to 2; nothing else has a default.
|
|
2010
2035
|
|
|
2011
|
-
|
|
2036
|
+
Six things this construct is bounded by, and each one is a refusal rather than a
|
|
2012
2037
|
convention:
|
|
2013
2038
|
|
|
2014
2039
|
**It covers every vertex, always.** A transform is a model of the attachment, not
|
|
@@ -2039,6 +2064,22 @@ consequences. One kind is the exception and it is an exception with a proof:
|
|
|
2039
2064
|
`affine` refuses a determinant at or below zero, and above zero a positive
|
|
2040
2065
|
determinant means no triangle **can** reverse.
|
|
2041
2066
|
|
|
2067
|
+
**A model that evaluates to nothing is refused too**
|
|
2068
|
+
([#350](https://github.com/firejune/rigc/issues/350)). Every parameter can be
|
|
2069
|
+
individually legal and the model still come out as a **run of zeros** — a `wave`
|
|
2070
|
+
whose wavelength puts every vertex on a zero crossing, a `bend` over a span the
|
|
2071
|
+
part barely enters. The key then claims a deformation, emits the identity and
|
|
2072
|
+
gates green: `A35` is right that the run fits and `A39` is right that no triangle
|
|
2073
|
+
moved, so the compiler is the only place it can be said. ⭐ **What distinguishes
|
|
2074
|
+
it is where the identity is stated.** A key that *means* the setup pose says so
|
|
2075
|
+
in its own parameters — `degrees: 0` (or any whole revolution), `amplitude: 0`,
|
|
2076
|
+
`amount: 0`, `scale: [1, 1]` — or carries no run at all, and those compile. The
|
|
2077
|
+
refused pair is parameters that state a deformation beside an evaluation that is
|
|
2078
|
+
the identity. The message names the vertex count, the largest value the closed
|
|
2079
|
+
form reached before quantising, and the measured fact behind the usual cause: for
|
|
2080
|
+
a wave, the closest two distinct coordinates it read and the ratio the wavelength
|
|
2081
|
+
makes against them.
|
|
2082
|
+
|
|
2042
2083
|
**It is auditable.** `explain` prints the model, the scalars the closed form
|
|
2043
2084
|
derived from it, and every offset it produced — the emitted ones, not a second
|
|
2044
2085
|
evaluation:
|
|
@@ -2154,6 +2195,15 @@ inverted build is the case worth reading — `A39` passes it (correctly: nothing
|
|
|
2154
2195
|
reverses), and the block is what says `x1.362834` where the model's own table
|
|
2155
2196
|
says `x1.319121`, with no reference render anywhere.
|
|
2156
2197
|
|
|
2198
|
+
📘 **[`gallery/nod`](https://github.com/firejune/rigc/tree/main/gallery/nod)'s
|
|
2199
|
+
README is a second reading of the same block** (repository material, hence the
|
|
2200
|
+
GitHub link), and it is the one where the figures are checked from two directions
|
|
2201
|
+
at once. Its `pitch` band ratios are *derived* from the mesh's own row table and
|
|
2202
|
+
*measured* off the posed vertices, and the two agree to six decimals. Its `wave`
|
|
2203
|
+
keys then report an area ratio of `1.000000 ± 1e-6` at **every** amplitude — not
|
|
2204
|
+
a measurement but a **proof** showing up as one, because a wave that reads `y`
|
|
2205
|
+
and displaces `x` over row-major quads preserves every signed area exactly.
|
|
2206
|
+
|
|
2157
2207
|
---
|
|
2158
2208
|
|
|
2159
2209
|
### 4.12 `path` and `slider` timelines — tracks, not their own groups
|
|
@@ -2310,6 +2360,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
|
|
|
2310
2360
|
| `deform … (t=…): … has no single space to evaluate it in` | §4.11.1 — the attachment's vertices are in several bind spaces; key the control bone, or write the pairs with `offset` |
|
|
2311
2361
|
| `deform … (t=…): transform yaw has radius R, and vertex v sits at x=… past it` | §4.11.1 — the cylinder has no surface there; raise the radius to where the part sits |
|
|
2312
2362
|
| `deform … (t=…): transform affine has scale […], whose determinant is …` | §4.11.1 — at or below zero the map reverses every triangle |
|
|
2363
|
+
| `deform … (t=…): transform <kind> states …, and every one of this attachment's N vertices evaluates to an offset of 0` | §4.11.1 — the parameters state a deformation and the geometry sampled it to nothing; the message names the measured cause. A key that means the setup pose states the identity in its parameters, or carries no run |
|
|
2313
2364
|
| `vertexCount is N, which is not a multiple of 3` | §3.4 — a path's vertices are knots and handles read in groups of three: `3(K + 1)` open, `3K` closed |
|
|
2314
2365
|
| `vertexCount is N and an open path needs at least 6` | §3.4 — an open path drops its first and last point, so it needs six for one curve |
|
|
2315
2366
|
| `"lengths" is not authored — rigc measures the setup arc length of each curve` | §3.4 — delete the array; it is a measurement of the vertices above it |
|
|
@@ -2355,7 +2406,7 @@ The report prints one line per assertion:
|
|
|
2355
2406
|
| `A03_REGION_WIDTH_HEIGHT_FINITE` | both | a region loaded `NaN` or a non-positive size — the attachment has no `image` and no `width`/`height` |
|
|
2356
2407
|
| `A04_MESH_TRIANGLES_AND_ENCODING` | both | authored mesh geometry: triangle count not a multiple of 3, an index out of range, or a `vertices` length that disagrees with `uvs` (the weighted/unweighted trap) |
|
|
2357
2408
|
| `A05_CURVE_ARRAY_LENGTH` | both | a raw `curve` with the wrong number of values, a non-finite number in one, or a curve on a timeline that cannot take one. Four numbers **per value channel** |
|
|
2358
|
-
| `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk. Under `spine-html` also: `pma`, rotation, and a region that
|
|
2409
|
+
| `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk. Under `spine-html` also: `pma`, rotation, and a page that is neither **one part covering it exactly** (the unpacked convention) nor a **tiling** — a page whose regions all sit inside it and none of which overlap ([#266](https://github.com/firejune/rigc/issues/266)). A packed atlas therefore gates under this profile; what the message names is the region that runs off its page, or the pair that shares texels |
|
|
2359
2410
|
| `A07_ATLAS_TEXT_SHAPE` | both | atlas text: a region name with stray whitespace, or a blank line splitting a page block. rigc writes the atlas, so this means a hand-edited file |
|
|
2360
2411
|
| `A08_REGION_NAMES_MATCH_ATTACHMENTS` | both ◑ | an attachment resolves to a region the atlas does not have — usually a `path`/`image` basename mismatch. Under `spine-html` the placeholder and the region name must also be *identical* |
|
|
2361
2412
|
| `A09_ANIMATION_DURATION_MATCHES_SPEC` | both | the loaded duration ≠ the declared one, or the two sides disagree about which animations exist (R7). Asymmetric by design: a frame of slack for an animation that ends early, and none worth the name for a key *past* the declared end, which is the same rule §4.5 states at compile time — held here against a skeleton the compiler never saw. **SKIP** when neither side has an animation at all — a static rig has no duration |
|
|
@@ -2368,7 +2419,7 @@ The report prints one line per assertion:
|
|
|
2368
2419
|
| `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` label is not on the 4.3 line (`4.3`, `4.3.N`, `4.3.N-suffix`) |
|
|
2369
2420
|
| `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out` |
|
|
2370
2421
|
| `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
|
|
2371
|
-
| `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art |
|
|
2422
|
+
| `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)) |
|
|
2372
2423
|
| `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. Under `spine-html` also: an unweighted mesh, or a binding at weight 0 |
|
|
2373
2424
|
| `A21_MESH_RIM_PINNED` | archetype | a generated ring's rim, a ribbon's entry row, or a contour's outline (which is all of it) is not pinned to its anchor bone at weight 1 |
|
|
2374
2425
|
| `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count |
|
|
@@ -2773,6 +2824,14 @@ measurement, while segment lengths taken off a folded pose are an estimate — s
|
|
|
2773
2824
|
disagree, suspect the estimate. And do this **per chain, before its first fit**, because
|
|
2774
2825
|
the surgery to fix it invalidates every pose already fitted with the short chain.
|
|
2775
2826
|
|
|
2827
|
+
⚠️ **The paragraph above is written for a chain that is too *short*, and the check bites
|
|
2828
|
+
in both directions.** A chain that is too *long* fails differently — the fits converge,
|
|
2829
|
+
every residual is ordinary, and the figure splays to absorb the excess — which is why
|
|
2830
|
+
reading only this direction has twice sent a run looking for the wrong defect.
|
|
2831
|
+
[RIGGING.md](RIGGING.md) §6.2 carries the too-long case with its record and its cheap
|
|
2832
|
+
detector, and §6.3 carries the refusal that goes with it: an excess names a
|
|
2833
|
+
disagreement between a chain and a shot, and it does not say which of the two is wrong.
|
|
2834
|
+
|
|
2776
2835
|
**Re-fit the setup pose against frames drawn from every shot, not against one.** Every
|
|
2777
2836
|
animation is measured from the setup pose, so an error in it is an error in all of
|
|
2778
2837
|
them — and it is exactly the error one frame cannot show you. Fit an attachment's
|
package/docs/FACE.md
CHANGED
|
@@ -33,6 +33,13 @@ hold its results.
|
|
|
33
33
|
[`gallery/portrait`](https://github.com/firejune/rigc/tree/main/gallery/portrait),
|
|
34
34
|
and its measurement half,
|
|
35
35
|
[`FINDINGS.md`](https://github.com/firejune/rigc/tree/main/gallery/portrait/FINDINGS.md)
|
|
36
|
+
- **The same closed forms on the other axis**, since 2026-09-03:
|
|
37
|
+
[`gallery/nod`](https://github.com/firejune/rigc/tree/main/gallery/nod) is a
|
|
38
|
+
worked `pitch`, and this page stays written for a **yaw**. It re-derives §4.2's
|
|
39
|
+
fold angle on uneven *rows* and brackets it against `A39` at 33°/34°, and it
|
|
40
|
+
measures §5's foreshortening at **0.863–1.176** against the yaw's 0.892–1.064
|
|
41
|
+
below — a wider span at the same 12°, because a face is taller than it is deep.
|
|
42
|
+
Read it after this page, not instead of it
|
|
36
43
|
|
|
37
44
|
🚨 **Nothing in this toolchain measures what a `deform` key does, and that is the
|
|
38
45
|
one gap you have to author around.** The setup geometry is measured and printed —
|
|
@@ -471,6 +478,14 @@ above the 16° §8 calls the instrument's ceiling. ⇒ **Then let the last band
|
|
|
471
478
|
single wide one** — that is the direction that buys safety, and it is the
|
|
472
479
|
opposite of refining.
|
|
473
480
|
|
|
481
|
+
📘 **The identity above, used forwards on a `pitch`.**
|
|
482
|
+
[`gallery/nod`](https://github.com/firejune/rigc/tree/main/gallery/nod)
|
|
483
|
+
(repository material) picks its ceiling first and solves the *rows* out of it —
|
|
484
|
+
`|y|outer = R·cos θmax` with θmax chosen at 21° giving 140.037 — and then
|
|
485
|
+
brackets the fold this section predicts against `A39` itself: **33° gates green
|
|
486
|
+
and 34° does not**, with the refusal naming the row pair the closed form names.
|
|
487
|
+
That is this table checked from the other end, on the other axis.
|
|
488
|
+
|
|
474
489
|
### 4.3 `hull` is `0`, and the vertex order is why
|
|
475
490
|
|
|
476
491
|
⚠️ **A grid's perimeter is 16 of its 25 vertices and they are not a prefix of any
|
|
@@ -510,6 +525,15 @@ The far eye narrows to 89%, the near eye widens to 106% (**derived**: 0.8922 and
|
|
|
510
525
|
track — the nose and mouth did, through a `groups` entry, and it was the only
|
|
511
526
|
place in the worked example's turn where two parts could (§7).
|
|
512
527
|
|
|
528
|
+
📘 **How much this is worth depends on the axis, and there is a worked case for
|
|
529
|
+
the other one.** The same closed form on the `pitch` of
|
|
530
|
+
[`gallery/nod`](https://github.com/firejune/rigc/tree/main/gallery/nod)
|
|
531
|
+
(repository material) spans **0.863 … 1.176** across its five features, against
|
|
532
|
+
the **0.892 … 1.064** above — a wider span at the *identical* angle, because
|
|
533
|
+
`α = atan2(coordinate, depth)` grows with the coordinate and a face is taller
|
|
534
|
+
than it is deep. ⇒ **The foreshortening buys more on a nod than on a turn**, and
|
|
535
|
+
that is arithmetic rather than a judgement about the art.
|
|
536
|
+
|
|
513
537
|
📌 **That entry is gone, and the reason is worth a line.** `scalex` is the
|
|
514
538
|
foreshortening projection of the same `derive` kind §3.1 uses (AUTHORING §4.5.1),
|
|
515
539
|
so all six features are one track and the on-axis pair's shared value **falls out
|
|
@@ -1078,6 +1102,13 @@ listing it. Its
|
|
|
1078
1102
|
is the measurement half: what it cost, the seven-angle sweep, the five tool gaps
|
|
1079
1103
|
it filed.
|
|
1080
1104
|
|
|
1105
|
+
📘 **The `pitch` has its own worked case**, and this page deliberately stays a
|
|
1106
|
+
yaw: [`gallery/nod`](https://github.com/firejune/rigc/tree/main/gallery/nod) is
|
|
1107
|
+
the same two closed forms on the other axis, plus a travelling `wave` (AUTHORING
|
|
1108
|
+
§4.11.1). §4.2 and §5 above point at the two places its figures are worth
|
|
1109
|
+
reading beside these — the fold angle bracketed against `A39`, and a
|
|
1110
|
+
foreshortening span that is wider at the same angle.
|
|
1111
|
+
|
|
1081
1112
|
```bash
|
|
1082
1113
|
bun install # once
|
|
1083
1114
|
|
package/docs/INGEST.md
CHANGED
|
@@ -668,18 +668,19 @@ be weighted at all is policy.
|
|
|
668
668
|
|
|
669
669
|
⚠️ **`--profile spine-html` on foreign data produces a wall of failures that mean
|
|
670
670
|
nothing about the file.** Same `spineboy-pro.json`, same atlas, one flag changed — the
|
|
671
|
-
run ends `rigc:
|
|
672
|
-
per rule
|
|
671
|
+
run ends `rigc: 13 assertion(s) failed`, and this is the tally with one real message
|
|
672
|
+
per rule (re-measured 2026-09-04; it used to read 53, with 40 `A06` rows, until A06
|
|
673
|
+
learned that a page is one part covering it exactly *or a tiling of regions* — #266
|
|
674
|
+
follow-up 2 — so a packed atlas now passes both profiles and the wall is policy only):
|
|
673
675
|
|
|
674
676
|
| Count | Rule | One of its messages |
|
|
675
677
|
| --- | --- | --- |
|
|
676
|
-
| **40** | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | `region "crosshair" has UVs (0.181640625,0.06640625)-(0.2255859375,0.2421875); one part per page must cover the page exactly` |
|
|
677
678
|
| **10** | `A15_IDLE_NO_MESH_BONE_KEYS` | `idle keys bone "front-shoulder", which drives a mesh — meshes never idle-skip` |
|
|
678
|
-
| **2** | `A20_MESH_WEIGHTS_COHERENT` | `mesh "
|
|
679
|
+
| **2** | `A20_MESH_WEIGHTS_COHERENT` | `mesh "front-shin" is unweighted; the ring tier drives meshes by bones` |
|
|
679
680
|
| **1** | `A11_NO_CLIPPING_ATTACHMENTS` | `1 clipping attachment(s); the renderer skips them silently` |
|
|
680
681
|
|
|
681
|
-
Every one of those is a correct statement about a correct file:
|
|
682
|
-
|
|
682
|
+
Every one of those is a correct statement about a correct file: a bone *does* key a
|
|
683
|
+
mesh, a mesh *is* unweighted, a clipping attachment *is* present.
|
|
683
684
|
(The tally was 55 before §3.2's A35 was fixed, and that is the one entry that was *not*
|
|
684
685
|
a correct statement — which is why it belonged in a different section from these.)
|
|
685
686
|
And it is not a big-skeleton problem — `3-timing-and-spacing`, with two regions on one
|
package/docs/MOTION.md
CHANGED
|
@@ -161,7 +161,8 @@ open-code neither: `screenToSpineDegrees(rotationDeg)` and
|
|
|
161
161
|
`cropToSpineY(y, frameHeight)`, both in
|
|
162
162
|
[`src/transform.ts`](../src/transform.ts). The `space` field of every report repeats
|
|
163
163
|
the contract in the file, so a consumer never has to remember which way the flip
|
|
164
|
-
goes.
|
|
164
|
+
goes. (Canonical: AUTHORING §11.2, which states the same contract as a field
|
|
165
|
+
reference; it is restated here because §2 is where you convert one.)
|
|
165
166
|
|
|
166
167
|
⚠️ A **bone offset** in a spec is expressed in its parent's local axes, so the y flip
|
|
167
168
|
applies there too and it applies **once**. Converting a world point and then also
|
package/docs/RIGGING.md
CHANGED
|
@@ -581,7 +581,10 @@ that takes the chain.
|
|
|
581
581
|
The same rig keys `headroll` and never `head`: *"a head rotates about the top of the
|
|
582
582
|
neck, not about the middle of its own face."* A renderer policy assertion
|
|
583
583
|
(`A15_IDLE_NO_MESH_BONE_KEYS`, AUTHORING §5) forced that answer independently — two
|
|
584
|
-
arguments, one bone.
|
|
584
|
+
arguments, one bone. ⚠️ **It is a renderer rule, so like §10.3's `A25` it only fires
|
|
585
|
+
under `--profile spine-html`**; under `--profile spine` it reports `PROF` and the
|
|
586
|
+
structural argument is the only one you get. [FACE.md](FACE.md) §3 is the same bone
|
|
587
|
+
from the mesh's side.
|
|
585
588
|
|
|
586
589
|
📌 **What the extra link buys, for free:** *"a rotation about the neck pivot carries
|
|
587
590
|
its descendants on a circle for free."* A yaw expressed as `translatex` draws a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/atlas.ts
CHANGED
|
@@ -32,19 +32,46 @@
|
|
|
32
32
|
* the field this deliberately does not use.
|
|
33
33
|
*
|
|
34
34
|
* ⚠️ **The rendered pictures are equal to within one least significant bit, not
|
|
35
|
-
* bit-for-bit, and the difference is arithmetic rather than texels.**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
35
|
+
* bit-for-bit, and the difference is arithmetic rather than texels.** Measured: 0
|
|
36
|
+
* to 480 channel samples of 7 to 21 million on the three public fixtures, worst
|
|
37
|
+
* difference **1**, against 22,000 to 60,000 samples and a worst difference of
|
|
38
|
+
* **77** when the gutter is removed — and byte-identical on eleven of the
|
|
39
|
+
* repository's thirteen rigs across 1,101 frames. The two that are not are the
|
|
40
|
+
* two with meshes.
|
|
41
|
+
*
|
|
42
|
+
* 🔬 **Where that last bit is lost, measured (issue #266, follow-up 1).** This
|
|
43
|
+
* paragraph used to say the cause was rigc's own sampling coordinate — `fl(regionX
|
|
44
|
+
* + fl(s · width))` failing to be exact once `regionX > 0` — and that the repair
|
|
45
|
+
* was to sample in region-local coordinates and add the integer page origin to the
|
|
46
|
+
* tap indices. **Both halves are wrong, and the second is unreachable.** Poses of
|
|
47
|
+
* the loose and the packed build of one skeleton, compared coordinate by
|
|
48
|
+
* coordinate as `u · pageWidth − regionX` against the loose `u · pageWidth`:
|
|
49
|
+
*
|
|
50
|
+
* * on every **region** attachment the difference is **exactly 0**. `regionX`,
|
|
51
|
+
* `regionWidth` and `pageWidth` are integers and the page is a power of two,
|
|
52
|
+
* so `u · pageWidth` recovers `regionX + localTexel` with nothing lost —
|
|
53
|
+
* which is why the eleven region-only rigs are already byte-identical, and
|
|
54
|
+
* why `PK18` can assert exactness rather than a bound;
|
|
55
|
+
* * on a **mesh** it is **not** 0 — 88 of 88 coordinates on `gallery/squash`'s
|
|
56
|
+
* ball, worst 3.15e-5 texels — because `spine-core` stores mesh page UVs in a
|
|
57
|
+
* **`Float32Array`**. `MeshAttachment.updateRegion` computes `u +
|
|
58
|
+
* regionUVs[i] · width` and rounds the whole thing to float32, so the low bits
|
|
59
|
+
* of the scaled coordinate are gone **before rigc reads the array**. The
|
|
60
|
+
* counterfactual settles which term does it: the same region at page origin
|
|
61
|
+
* `x = 0` still lands 9.5e-7 texels off the loose value, so it is the `f32(u ·
|
|
62
|
+
* regionWidth / pageWidth)` scaling and not the origin addition.
|
|
63
|
+
*
|
|
64
|
+
* ⇒ **No change to [`src/render.ts`](render.ts) can recover it**, because it reads
|
|
65
|
+
* `piece.uvs` and the information is not in there. The only routes are for rigc to
|
|
66
|
+
* re-derive mesh page UVs from `MeshAttachment.regionUVs` in double precision —
|
|
67
|
+
* a second opinion about the runtime's own trim and rotation mapping, which
|
|
68
|
+
* `src/render.ts` refuses by name (see `artUvsOf`) — or one part per page, which
|
|
69
|
+
* is the unpacked convention. And the first would be worse than the residual: the
|
|
70
|
+
* renderer is the yardstick `check` measures a candidate against *because* it
|
|
71
|
+
* draws what a runtime draws, and a runtime playing this atlas gets the float32
|
|
72
|
+
* numbers. Making two rigc renders agree by disagreeing with the runtime is the
|
|
73
|
+
* wrong trade. So the bound stays a bound, `PK18` attributes it, and the follow-up
|
|
74
|
+
* is closed as measured rather than done.
|
|
48
75
|
*
|
|
49
76
|
* ## Why a second parser for a format `spine-core` already parses
|
|
50
77
|
*
|
|
@@ -498,6 +525,38 @@ function floorPowerOfTwo(n: number): number {
|
|
|
498
525
|
return p;
|
|
499
526
|
}
|
|
500
527
|
|
|
528
|
+
/**
|
|
529
|
+
* The smallest power-of-two page that holds these cells, and where they land on
|
|
530
|
+
* it — or `null` when they do not fit `maxEdge x maxEdge` at all.
|
|
531
|
+
*
|
|
532
|
+
* Every power-of-two pair up to the maximum is tried in order of increasing
|
|
533
|
+
* area, then increasing width, so the answer is a total order and two packs of
|
|
534
|
+
* the same set choose the same page. Powers of two are not decoration:
|
|
535
|
+
* `region.x / page.width` is the coordinate every texel is read through, and a
|
|
536
|
+
* power-of-two denominator makes that division exact in binary floating point.
|
|
537
|
+
*
|
|
538
|
+
* ⭐ One search, two callers, and that is the point. It picks the single page a
|
|
539
|
+
* set that fits gets, and it picks each SPILLED page's own size — so "the page
|
|
540
|
+
* written is the smallest that holds what is on it" is one rule with one
|
|
541
|
+
* implementation rather than a rule and an exception.
|
|
542
|
+
*/
|
|
543
|
+
function smallestPageFor(
|
|
544
|
+
cells: Array<{ w: number; h: number }>,
|
|
545
|
+
maxEdge: number,
|
|
546
|
+
): { width: number; height: number; rects: Rect[] } | null {
|
|
547
|
+
const edges: number[] = [];
|
|
548
|
+
for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
|
|
549
|
+
const candidates: Array<{ w: number; h: number }> = [];
|
|
550
|
+
for (const w of edges) for (const h of edges) candidates.push({ w, h });
|
|
551
|
+
candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
|
|
552
|
+
for (const candidate of candidates) {
|
|
553
|
+
const attempt = packOnePage(cells, candidate.w, candidate.h);
|
|
554
|
+
if (attempt.some((r) => r === null)) continue;
|
|
555
|
+
return { width: candidate.w, height: candidate.h, rects: attempt as Rect[] };
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
|
|
501
560
|
/**
|
|
502
561
|
* MaxRects with Best Short Side Fit, no rotation.
|
|
503
562
|
*
|
|
@@ -664,10 +723,16 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
|
|
|
664
723
|
* the one-bit residual described in this file's header would be two roundings
|
|
665
724
|
* deep instead of one.
|
|
666
725
|
*
|
|
667
|
-
* Only when the whole set will not fit one page at the maximum does it spill
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
* two
|
|
726
|
+
* Only when the whole set will not fit one page at the maximum does it spill.
|
|
727
|
+
* **Which parts share a page is then decided at the maximum size** — that is what
|
|
728
|
+
* makes the boundary deterministic — **and each page is written at the smallest
|
|
729
|
+
* power-of-two pair that holds the cells assigned to it** (issue #266). So the
|
|
730
|
+
* rule is the same one either way: the page written is the smallest that holds
|
|
731
|
+
* what is on it. A spill used to write `pageSize x pageSize` for every page,
|
|
732
|
+
* which charged a set that overflowed by one small part a second full page of
|
|
733
|
+
* transparency — 4 MiB of decoded RAM at the 2048 default. A single part whose
|
|
734
|
+
* cell is bigger than the maximum is refused by name — silently splitting one
|
|
735
|
+
* drawing across two pages is not a thing the format can express.
|
|
671
736
|
*/
|
|
672
737
|
export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResult {
|
|
673
738
|
const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
@@ -696,36 +761,22 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
696
761
|
);
|
|
697
762
|
}
|
|
698
763
|
|
|
699
|
-
|
|
700
|
-
const edges: number[] = [];
|
|
701
|
-
for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
|
|
702
|
-
const candidates: Array<{ w: number; h: number }> = [];
|
|
703
|
-
for (const w of edges) for (const h of edges) candidates.push({ w, h });
|
|
704
|
-
candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
|
|
705
|
-
|
|
706
|
-
let pageW = maxEdge;
|
|
707
|
-
let pageH = maxEdge;
|
|
708
|
-
let single: Array<Rect | null> | null = null;
|
|
709
|
-
for (const candidate of candidates) {
|
|
710
|
-
const attempt = packOnePage(cells, candidate.w, candidate.h);
|
|
711
|
-
if (attempt.some((r) => r === null)) continue;
|
|
712
|
-
pageW = candidate.w;
|
|
713
|
-
pageH = candidate.h;
|
|
714
|
-
single = attempt;
|
|
715
|
-
break;
|
|
716
|
-
}
|
|
764
|
+
const single = smallestPageFor(cells, maxEdge);
|
|
717
765
|
|
|
718
766
|
/** page index -> the placements on it, in packing order. */
|
|
719
767
|
const perPage: Placement[][] = [];
|
|
768
|
+
/** page index -> the size that page is written at. */
|
|
769
|
+
const pageSizes: Array<{ width: number; height: number }> = [];
|
|
720
770
|
const placements: Placement[] = [];
|
|
721
771
|
if (single !== null) {
|
|
722
772
|
perPage.push([]);
|
|
723
|
-
single.
|
|
773
|
+
pageSizes.push({ width: single.width, height: single.height });
|
|
774
|
+
single.rects.forEach((rect, i) => {
|
|
724
775
|
const place: Placement = {
|
|
725
776
|
region: sorted[i].region,
|
|
726
777
|
page: 0,
|
|
727
|
-
x: rect
|
|
728
|
-
y: rect
|
|
778
|
+
x: rect.x + padding,
|
|
779
|
+
y: rect.y + padding,
|
|
729
780
|
width: sorted[i].width,
|
|
730
781
|
height: sorted[i].height,
|
|
731
782
|
};
|
|
@@ -733,42 +784,63 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
733
784
|
placements.push(place);
|
|
734
785
|
});
|
|
735
786
|
} else {
|
|
736
|
-
// Spill.
|
|
737
|
-
//
|
|
787
|
+
// Spill. Which parts share a page is decided at the MAXIMUM size — that is
|
|
788
|
+
// what makes the boundary deterministic and independent of the shrink below
|
|
789
|
+
// — and parts are taken in packing order, whatever will not fit the current
|
|
790
|
+
// page opening the next one.
|
|
738
791
|
let remaining = sorted.map((input, i) => ({ input, cell: cells[i] }));
|
|
739
792
|
while (remaining.length > 0) {
|
|
740
793
|
const pageIndex = perPage.length;
|
|
741
794
|
const attempt = packOnePage(
|
|
742
795
|
remaining.map((r) => r.cell),
|
|
743
|
-
|
|
744
|
-
|
|
796
|
+
maxEdge,
|
|
797
|
+
maxEdge,
|
|
745
798
|
);
|
|
746
|
-
const
|
|
799
|
+
const onPage: Array<{ input: PackInput; cell: { w: number; h: number } }> = [];
|
|
747
800
|
const leftOver: typeof remaining = [];
|
|
748
801
|
attempt.forEach((rect, i) => {
|
|
749
|
-
if (rect === null)
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
802
|
+
if (rect === null) leftOver.push(remaining[i]);
|
|
803
|
+
else onPage.push(remaining[i]);
|
|
804
|
+
});
|
|
805
|
+
if (onPage.length === 0) {
|
|
806
|
+
// Unreachable: every cell was proven to fit an empty page above. Kept as
|
|
807
|
+
// a named stop rather than an infinite loop if that ever stops holding.
|
|
808
|
+
throw new CompileError(
|
|
809
|
+
`packing stalled with ${remaining.length} region(s) left and an empty ${maxEdge}x${maxEdge} page`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
// ⭐ Then the page is written at the smallest power-of-two pair that holds
|
|
813
|
+
// the cells assigned to it, not at the maximum (issue #266, follow-up 3).
|
|
814
|
+
// A spill used to write `pageSize x pageSize` for every page, so a set that
|
|
815
|
+
// overflowed by one small part paid for a second full page of transparency
|
|
816
|
+
// — 4 MiB of decoded RAM at the 2048 default for a part that might be
|
|
817
|
+
// 64x64. Re-packing through the same search the single-page case uses is
|
|
818
|
+
// what keeps "the page written is the smallest that holds what is on it"
|
|
819
|
+
// one rule; a page whose own cells need the maximum simply gets it back.
|
|
820
|
+
const shrunk = smallestPageFor(
|
|
821
|
+
onPage.map((r) => r.cell),
|
|
822
|
+
maxEdge,
|
|
823
|
+
);
|
|
824
|
+
if (shrunk === null) {
|
|
825
|
+
// Unreachable for the same reason as the stall above: these cells were
|
|
826
|
+
// just placed on a maxEdge page.
|
|
827
|
+
throw new CompileError(`page ${pageIndex + 1} of the spill holds ${onPage.length} region(s) that no page fits`);
|
|
828
|
+
}
|
|
829
|
+
const onThisPage: Placement[] = [];
|
|
830
|
+
shrunk.rects.forEach((rect, i) => {
|
|
753
831
|
const place: Placement = {
|
|
754
|
-
region:
|
|
832
|
+
region: onPage[i].input.region,
|
|
755
833
|
page: pageIndex,
|
|
756
834
|
x: rect.x + padding,
|
|
757
835
|
y: rect.y + padding,
|
|
758
|
-
width:
|
|
759
|
-
height:
|
|
836
|
+
width: onPage[i].input.width,
|
|
837
|
+
height: onPage[i].input.height,
|
|
760
838
|
};
|
|
761
839
|
onThisPage.push(place);
|
|
762
840
|
placements.push(place);
|
|
763
841
|
});
|
|
764
|
-
if (onThisPage.length === 0) {
|
|
765
|
-
// Unreachable: every cell was proven to fit an empty page above. Kept as
|
|
766
|
-
// a named stop rather than an infinite loop if that ever stops holding.
|
|
767
|
-
throw new CompileError(
|
|
768
|
-
`packing stalled with ${remaining.length} region(s) left and an empty ${pageW}x${pageH} page`,
|
|
769
|
-
);
|
|
770
|
-
}
|
|
771
842
|
perPage.push(onThisPage);
|
|
843
|
+
pageSizes.push({ width: shrunk.width, height: shrunk.height });
|
|
772
844
|
remaining = leftOver;
|
|
773
845
|
}
|
|
774
846
|
}
|
|
@@ -779,6 +851,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
779
851
|
const pages: PackedPage[] = [];
|
|
780
852
|
const emitPages: EmitPage[] = [];
|
|
781
853
|
perPage.forEach((onPage, index) => {
|
|
854
|
+
const { width: pageW, height: pageH } = pageSizes[index];
|
|
782
855
|
const plate = new Plate(pageW, pageH);
|
|
783
856
|
let covered = 0;
|
|
784
857
|
for (const place of onPage) {
|
package/src/deformgen.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* author can sweep a parameter, and a reviewer can check a claim instead of a
|
|
21
21
|
* transcription.
|
|
22
22
|
*
|
|
23
|
-
* ## What this is NOT, and the
|
|
23
|
+
* ## What this is NOT, and the three rules that keep it that way
|
|
24
24
|
*
|
|
25
25
|
* 1. **It never authors.** Every parameter arrives from the spec — the angle,
|
|
26
26
|
* the radius, the amplitude, the point a scale is about. Nothing here has a
|
|
@@ -31,6 +31,14 @@
|
|
|
31
31
|
* that key states, and the blend between two keys is still the deform
|
|
32
32
|
* timeline's own single 0..1 channel. Sweeping an angle is editing one number
|
|
33
33
|
* per key, never asking the compiler to interpolate a model.
|
|
34
|
+
* 3. **A parameter that cannot mean what it says is refused by name**, on the
|
|
35
|
+
* principle that a parameter changing nothing is a reader's false lead about
|
|
36
|
+
* which model produced the numbers: `wavelength: 0`, a determinant at or
|
|
37
|
+
* below 0, a radius that falls inside the part. Since issue #350 that extends
|
|
38
|
+
* one step later, to a model whose parameters are each legal and whose
|
|
39
|
+
* *evaluation* is an all-zero run — the refusal at the bottom of
|
|
40
|
+
* `evaluateDeformTransform`, whose whole difficulty is telling that apart
|
|
41
|
+
* from a key that means the identity and says so.
|
|
34
42
|
*
|
|
35
43
|
* ## Determinism
|
|
36
44
|
*
|
|
@@ -100,12 +108,24 @@ export interface DeformAffine {
|
|
|
100
108
|
about?: [number, number];
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* A sinusoid of one coordinate, displacing along another.
|
|
113
|
+
*
|
|
114
|
+
* ⚠️ **A wavelength is only as real as the geometry that samples it.** The mesh
|
|
115
|
+
* carries the wave at the coordinates it happens to have, so a period short
|
|
116
|
+
* against the spacing of those coordinates does not make a smaller ripple — it
|
|
117
|
+
* makes a different model. Against a spacing of `s`: `wavelength ≥ 4s` to read
|
|
118
|
+
* as a wave at all and `≥ 8s` to read as a curve; at `2s` every sample lands on
|
|
119
|
+
* the same pair of phases, which is a zigzag, and at that pair's zero crossings
|
|
120
|
+
* it is nothing at all. The last of those is refused (issue #350) because it
|
|
121
|
+
* emits an all-zero run while claiming an amplitude; the zigzag is not, because
|
|
122
|
+
* it is a bad wave rather than an absent one and that is authoring judgement.
|
|
123
|
+
*/
|
|
104
124
|
export interface DeformWave {
|
|
105
125
|
kind: 'wave';
|
|
106
126
|
/** Peak displacement, in the attachment's own units. */
|
|
107
127
|
amplitude: number;
|
|
108
|
-
/** One period, in the same units as the coordinate `along` reads. */
|
|
128
|
+
/** One period, in the same units as the coordinate `along` reads. Sampled by the geometry — see the note above. */
|
|
109
129
|
wavelength: number;
|
|
110
130
|
/** Phase at `along = 0`, in degrees. Default 0. */
|
|
111
131
|
phase?: number;
|
|
@@ -212,6 +232,25 @@ export function evaluateDeformTransform(
|
|
|
212
232
|
let derived: string[];
|
|
213
233
|
let stated: string;
|
|
214
234
|
let formula: string;
|
|
235
|
+
// -- the three values the all-zero refusal at the bottom reads (issue #350) -
|
|
236
|
+
//
|
|
237
|
+
// `identity` is whether the transform's own scalars state the identity, and it
|
|
238
|
+
// is judged on the ROUNDED scalars rather than in float64 — a `degrees: 360`
|
|
239
|
+
// turn leaves `sin t` at −2.4e−16, which is 0 in every number this compiler
|
|
240
|
+
// writes, so a spec that states a whole revolution states the identity as
|
|
241
|
+
// surely as `degrees: 0` does. `band` is the largest magnitude the closed form
|
|
242
|
+
// reached *before* quantising, which is what separates a model that is
|
|
243
|
+
// arithmetically zero (a band of float noise, ~1e−15) from one that is real
|
|
244
|
+
// and smaller than six decimals. `sampledTo` is the per-kind diagnosis.
|
|
245
|
+
let identity: boolean;
|
|
246
|
+
let identitySpelling: string;
|
|
247
|
+
let band = 0;
|
|
248
|
+
let sampledTo = '';
|
|
249
|
+
const widen = (d: number): number => {
|
|
250
|
+
const m = Math.abs(d);
|
|
251
|
+
if (m > band) band = m;
|
|
252
|
+
return d;
|
|
253
|
+
};
|
|
215
254
|
|
|
216
255
|
switch (kind) {
|
|
217
256
|
case 'yaw':
|
|
@@ -247,9 +286,14 @@ export function evaluateDeformTransform(
|
|
|
247
286
|
}
|
|
248
287
|
const z = Math.sqrt(radius * radius - u * u);
|
|
249
288
|
const d = u * cosMinus1 - z * sin;
|
|
250
|
-
offsets[2 * v + along] = round(d);
|
|
289
|
+
offsets[2 * v + along] = round(widen(d));
|
|
251
290
|
offsets[2 * v + (1 - along)] = 0;
|
|
252
291
|
}
|
|
292
|
+
identity = round(cosMinus1) === 0 && round(sin) === 0;
|
|
293
|
+
identitySpelling = 'degrees 0';
|
|
294
|
+
sampledTo =
|
|
295
|
+
`The turn is ${degrees}°, and a projection of it can only vanish where every vertex shares one ` +
|
|
296
|
+
`${kind === 'yaw' ? 'x' : 'y'} — check that this attachment's setup geometry is the shape the radius says it is`;
|
|
253
297
|
const c = kind === 'yaw' ? 'x' : 'y';
|
|
254
298
|
stated = `radius=${radius} degrees=${degrees}${t.about === undefined ? '' : ` about=${about}`}`;
|
|
255
299
|
formula = `d${c} = (${c}−about)·(cos t − 1) − z·sin t, z = √(radius² − (${c}−about)²)`;
|
|
@@ -275,9 +319,15 @@ export function evaluateDeformTransform(
|
|
|
275
319
|
);
|
|
276
320
|
}
|
|
277
321
|
for (let v = 0; v < count; v++) {
|
|
278
|
-
offsets[2 * v] = round((scale[0] - 1) * (setup[2 * v] - about[0]));
|
|
279
|
-
offsets[2 * v + 1] = round((scale[1] - 1) * (setup[2 * v + 1] - about[1]));
|
|
322
|
+
offsets[2 * v] = round(widen((scale[0] - 1) * (setup[2 * v] - about[0])));
|
|
323
|
+
offsets[2 * v + 1] = round(widen((scale[1] - 1) * (setup[2 * v + 1] - about[1])));
|
|
280
324
|
}
|
|
325
|
+
identity = round(scale[0] - 1) === 0 && round(scale[1] - 1) === 0;
|
|
326
|
+
identitySpelling = 'scale [1, 1]';
|
|
327
|
+
sampledTo =
|
|
328
|
+
`A scale about a fixed point moves nothing that SITS on it, so every vertex this attachment has lies at ` +
|
|
329
|
+
`about=[${about[0]}, ${about[1]}] on the axis the scale changes — the geometry has collapsed onto the point ` +
|
|
330
|
+
'the key holds still';
|
|
281
331
|
stated = `scale=[${scale[0]}, ${scale[1]}]${a.about === undefined ? '' : ` about=[${about[0]}, ${about[1]}]`}`;
|
|
282
332
|
formula = 'dx = (sx − 1)·(x − ax), dy = (sy − 1)·(y − ay)';
|
|
283
333
|
derived = [`sx − 1 = ${round(scale[0] - 1)}`, `sy − 1 = ${round(scale[1] - 1)}`, `det = sx·sy = ${round(det)} > 0, so no triangle can reverse`];
|
|
@@ -296,10 +346,30 @@ export function evaluateDeformTransform(
|
|
|
296
346
|
const k = (2 * Math.PI) / wavelength;
|
|
297
347
|
for (let v = 0; v < count; v++) {
|
|
298
348
|
const d = amplitude * Math.sin(k * setup[2 * v + along] + phaseRad);
|
|
299
|
-
offsets[2 * v + axis] = round(d);
|
|
349
|
+
offsets[2 * v + axis] = round(widen(d));
|
|
300
350
|
offsets[2 * v + (1 - axis)] = 0;
|
|
301
351
|
}
|
|
302
352
|
const an = along === 0 ? 'x' : 'y';
|
|
353
|
+
identity = round(amplitude) === 0;
|
|
354
|
+
identitySpelling = 'amplitude 0';
|
|
355
|
+
// The sampling fact, measured off the array this call was handed rather
|
|
356
|
+
// than inferred from a topology the compiler does not have: it knows which
|
|
357
|
+
// coordinates it read, not where anybody's rows are. The smallest gap
|
|
358
|
+
// between two distinct ones is the finest detail the geometry can carry,
|
|
359
|
+
// and the ratio to it is the rule `gallery/nod`'s README states.
|
|
360
|
+
const distinct = [...new Set(Array.from({ length: count }, (_, v) => setup[2 * v + along]))].sort((p, q) => p - q);
|
|
361
|
+
let gap = Infinity;
|
|
362
|
+
for (let i = 1; i < distinct.length; i++) gap = Math.min(gap, distinct[i] - distinct[i - 1]);
|
|
363
|
+
sampledTo =
|
|
364
|
+
distinct.length < 2
|
|
365
|
+
? `Every vertex sits at ${an}=${distinct[0]}, so one value of the sinusoid covers the whole attachment and ` +
|
|
366
|
+
'this phase is where that one value crosses zero. A wave needs the coordinate it reads to VARY across the ' +
|
|
367
|
+
'geometry; a part that displaces as a whole is a bone, not a deform'
|
|
368
|
+
: `The closest two distinct ${an} coordinates in this attachment are ${round(gap)} apart, and a sinusoid has ` +
|
|
369
|
+
`to be sampled to exist: a wavelength of at least 4x that (${round(4 * gap)}) to read as a wave at all and ` +
|
|
370
|
+
`8x (${round(8 * gap)}) to read as a curve, where this key states ${round(wavelength / gap)}x. At 2x every ` +
|
|
371
|
+
'sample lands on the same pair of phases, and at the zero crossings that pair is (0, 0). `gallery/nod`\'s ' +
|
|
372
|
+
'README carries that rule and the measured table behind it';
|
|
303
373
|
stated = `amplitude=${amplitude} wavelength=${wavelength} phase=${phase} along=${an} axis=${axis === 0 ? 'x' : 'y'}`;
|
|
304
374
|
formula = `d${axis === 0 ? 'x' : 'y'} = amplitude · sin(2π·${an}/wavelength + phase)`;
|
|
305
375
|
derived = [`2π/wavelength = ${round(k)} rad per unit`, `phase = ${round(phaseRad)} rad`];
|
|
@@ -323,12 +393,26 @@ export function evaluateDeformTransform(
|
|
|
323
393
|
}
|
|
324
394
|
const [along, axis] = axes(b.along, b.axis, 'bend', where);
|
|
325
395
|
const span = to - from;
|
|
396
|
+
let reach = 0;
|
|
326
397
|
for (let v = 0; v < count; v++) {
|
|
327
398
|
const u = (setup[2 * v + along] - from) / span;
|
|
328
|
-
|
|
399
|
+
if (Math.abs(u) > reach) reach = Math.abs(u);
|
|
400
|
+
offsets[2 * v + axis] = round(widen(amount * u ** power));
|
|
329
401
|
offsets[2 * v + (1 - axis)] = 0;
|
|
330
402
|
}
|
|
331
403
|
const an = along === 0 ? 'x' : 'y';
|
|
404
|
+
identity = round(amount) === 0;
|
|
405
|
+
identitySpelling = 'amount 0';
|
|
406
|
+
// `u` is 0 at `from` and 1 at `to`, so the two ways a stated bend vanishes
|
|
407
|
+
// are both statements about where the geometry sits in that span — and
|
|
408
|
+
// both are measured here rather than guessed.
|
|
409
|
+
sampledTo =
|
|
410
|
+
reach === 0
|
|
411
|
+
? `Every vertex sits at ${an}=${from}, which is "from" — the end the bend is anchored at, where the ` +
|
|
412
|
+
'displacement is 0 by construction. The span the key names does not cross the part it is keyed on'
|
|
413
|
+
: `The furthest any vertex reaches into the span is u=${round(reach)} of 1, and u^${power} of that is ` +
|
|
414
|
+
`${(reach ** power).toExponential(3)} — so the part occupies only the flat end of the curve. Move "to" to ` +
|
|
415
|
+
'where the geometry actually ends, or lower the power';
|
|
332
416
|
stated = `amount=${amount} from=${from} to=${to} power=${power} along=${an} axis=${axis === 0 ? 'x' : 'y'}`;
|
|
333
417
|
formula = `d${axis === 0 ? 'x' : 'y'} = amount · u^${power}, u = (${an} − from) / (to − from)`;
|
|
334
418
|
derived = [
|
|
@@ -341,6 +425,38 @@ export function evaluateDeformTransform(
|
|
|
341
425
|
}
|
|
342
426
|
}
|
|
343
427
|
|
|
428
|
+
// -- a model the geometry sampled to nothing (issue #350) ------------------
|
|
429
|
+
//
|
|
430
|
+
// The three refusals above catch a parameter that cannot mean what it says —
|
|
431
|
+
// `wavelength: 0`, a determinant at or below 0, a radius inside the part. This
|
|
432
|
+
// is the same principle one step later: every parameter is individually legal,
|
|
433
|
+
// and the model still evaluates to a run of zeros. The key then claims a
|
|
434
|
+
// deformation, emits nothing, and *gates green* — `A35` is right that the run
|
|
435
|
+
// fits and `A39` is right that no triangle moved, so neither can see it and
|
|
436
|
+
// this is the only place it can be said.
|
|
437
|
+
//
|
|
438
|
+
// ⭐ **The distinguishing condition is where the identity is stated.** A key
|
|
439
|
+
// that MEANS the setup pose says so in its own parameters — `degrees: 0` (or
|
|
440
|
+
// any whole revolution), `amplitude: 0`, `amount: 0`, `scale: [1, 1]` — or
|
|
441
|
+
// carries no run at all, which is the format's own `{ "t": … }`. Those pass.
|
|
442
|
+
// What is refused is the pair that cannot both be true: parameters that state
|
|
443
|
+
// a deformation, and an evaluation that is the identity. The two are not the
|
|
444
|
+
// same event, and before this they printed the same line.
|
|
445
|
+
//
|
|
446
|
+
// `count > 0` is not defensive noise: `every` on an empty array is `true`, so
|
|
447
|
+
// an attachment with no vertices would otherwise be refused with a message
|
|
448
|
+
// about arithmetic that never ran.
|
|
449
|
+
if (count > 0 && !identity && offsets.every((d) => d === 0)) {
|
|
450
|
+
throw new CompileError(
|
|
451
|
+
`${where}: transform ${kind} states ${stated}, and every one of this attachment's ${count} vertices evaluates ` +
|
|
452
|
+
`to an offset of 0 — the largest value the closed form reached at any of them is ${band.toExponential(3)}, ` +
|
|
453
|
+
'which quantises to 0 at the six decimals every emitted number carries. So the key states a deformation and ' +
|
|
454
|
+
`emits the identity, and nothing downstream can tell it apart from a key that meant the setup pose. ` +
|
|
455
|
+
`${sampledTo}. A key that MEANS the identity states it in its own parameters (${identitySpelling}) or carries ` +
|
|
456
|
+
'no run at all.',
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
344
460
|
let maxOffset = 0;
|
|
345
461
|
let maxOffsetVertex = 0;
|
|
346
462
|
for (let v = 0; v < count; v++) {
|
package/src/trackgen.ts
CHANGED
|
@@ -79,9 +79,18 @@ export interface TrackDeriveTurn {
|
|
|
79
79
|
degrees: number;
|
|
80
80
|
/**
|
|
81
81
|
* Each member's depth: `{ member: z }` on a group track, one number on a bone
|
|
82
|
-
* track.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
82
|
+
* track. `z` runs **toward the viewer** (FACE §1), so a **larger** depth is
|
|
83
|
+
* **nearer** — a nose in front of the skull surface takes a bigger number than
|
|
84
|
+
* the socket beside it — and a **negative** depth is behind the axis, which is
|
|
85
|
+
* what makes the back of a head swing the other way (FACE §2).
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ That sign is the one thing here no assertion can check, and this comment
|
|
88
|
+
* had it backwards until issue #350's neighbour
|
|
89
|
+
* ([#351](https://github.com/firejune/rigc/issues/351)). The closed form is
|
|
90
|
+
* the arbiter: `d = (x−about)·(cos t − 1) − (depth − carried)·sin t` gives a
|
|
91
|
+
* part with `depth > carried` a **negative** residual, and FACE §3 makes
|
|
92
|
+
* exactly that the nose diagnostic — *if the nose's residual is not negative,
|
|
93
|
+
* the depths are wrong*. That only holds if a larger depth means nearer.
|
|
85
94
|
*/
|
|
86
95
|
depth: number | Record<string, number>;
|
|
87
96
|
/**
|
package/src/validate.ts
CHANGED
|
@@ -32,7 +32,13 @@ import {
|
|
|
32
32
|
SkeletonJson,
|
|
33
33
|
SliderData,
|
|
34
34
|
TextureAtlas,
|
|
35
|
+
type TextureAtlasRegion,
|
|
35
36
|
} from '@esotericsoftware/spine-core';
|
|
37
|
+
// ⚠️ `src/` reaches outside itself for exactly two modules and this is one of
|
|
38
|
+
// them, so it is already on `package.json`'s `files` allowlist — see CLAUDE.md.
|
|
39
|
+
// A19 needs the DECODED page, not its header, to measure one region's own
|
|
40
|
+
// rectangle on a shared page.
|
|
41
|
+
import { readPlate } from '../tools/plate.ts';
|
|
36
42
|
import { surveyDeformKeys } from './deformmeasure.ts';
|
|
37
43
|
import { colourTypeName, readPngInfo } from './png.ts';
|
|
38
44
|
import { CHANNELS_BY_KIND, KEY_TIME_EPSILON, walkTimelines } from './timelines.ts';
|
|
@@ -1992,31 +1998,83 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1992
1998
|
`page "${page.name}" declares ${page.width}x${page.height} but the PNG is ${info.width}x${info.height}`,
|
|
1993
1999
|
);
|
|
1994
2000
|
}
|
|
1995
|
-
// 📐 PROFILE, from here down. `pma: false
|
|
1996
|
-
//
|
|
1997
|
-
//
|
|
1998
|
-
//
|
|
1999
|
-
//
|
|
2000
|
-
// on for spine-html; under `spine` an atlas is judged only on whether its
|
|
2001
|
-
// declared size matches the file it names.
|
|
2002
|
-
//
|
|
2003
|
-
// ⚠️ Since issue #4 rigc can produce a multi-region page itself
|
|
2004
|
-
// (`build --pack`), so this clause is no longer "nobody can have made
|
|
2005
|
-
// this". It is still the convention `spine-html` asks for, and the CLI
|
|
2006
|
-
// therefore refuses `--pack --profile spine-html` by name rather than
|
|
2007
|
-
// letting a legitimate pack arrive here and read as a defect.
|
|
2001
|
+
// 📐 PROFILE, from here down. `pma: false` and no rotation are rigc's atlas
|
|
2002
|
+
// CONVENTION, not the atlas format's rules — a packed page with
|
|
2003
|
+
// `rotate: 90` is what the Spine packer produces and every official example
|
|
2004
|
+
// ships one. Under `spine` an atlas is judged only on whether its declared
|
|
2005
|
+
// size matches the file it names.
|
|
2008
2006
|
if (policy && page.pma) {
|
|
2009
2007
|
fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `page "${page.name}" claims premultiplied alpha; parts are straight alpha`);
|
|
2010
2008
|
}
|
|
2011
2009
|
}
|
|
2012
2010
|
if (!policy) return;
|
|
2011
|
+
// ⭐ **One part per page OR a tiling page** (issue #266, follow-up 2). This
|
|
2012
|
+
// clause used to be the first alternative alone — every region's UVs
|
|
2013
|
+
// `(0,0)-(1,1)` — which is rigc's *unpacked* convention and exactly what
|
|
2014
|
+
// `build --pack` stops being true, so `--pack --profile spine-html` had to be
|
|
2015
|
+
// a named CLI refusal. A pack is not a defect, and refusing it under this
|
|
2016
|
+
// profile meant the one shape that exercises the renderer's shared-page
|
|
2017
|
+
// sampling could never be gated by the renderer's own rulebook.
|
|
2018
|
+
//
|
|
2019
|
+
// What the first alternative bought was the attachment -> region -> file
|
|
2020
|
+
// chain being checkable exactly, and `A27` already owns that half and already
|
|
2021
|
+
// stands down on a multi-region page. What is left to check on a *tiling*
|
|
2022
|
+
// page is what makes shared-page sampling well defined at all: every region
|
|
2023
|
+
// wholly inside the page it names, and no two regions on one page
|
|
2024
|
+
// overlapping. Both are conditions a foreign pack can fail while loading
|
|
2025
|
+
// clean — an off-page rectangle samples texels that are not there, and two
|
|
2026
|
+
// overlapping rectangles put one drawing inside another's.
|
|
2027
|
+
//
|
|
2028
|
+
// ⚠️ Rotation stays refused either way, and that is not the same clause: it
|
|
2029
|
+
// is about rigc's own packer never turning a region, and `extractRegion`
|
|
2030
|
+
// refusing to read one back.
|
|
2031
|
+
const regionsPerPage = new Map<string, TextureAtlasRegion[]>();
|
|
2013
2032
|
for (const region of atlas.regions) {
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2033
|
+
const on = regionsPerPage.get(region.page.name);
|
|
2034
|
+
if (on) on.push(region);
|
|
2035
|
+
else regionsPerPage.set(region.page.name, [region]);
|
|
2036
|
+
}
|
|
2037
|
+
for (const [pageName, on] of regionsPerPage) {
|
|
2038
|
+
const onePartPerPage =
|
|
2039
|
+
on.length === 1 && on[0].u === 0 && on[0].v === 0 && on[0].u2 === 1 && on[0].v2 === 1;
|
|
2040
|
+
if (onePartPerPage) continue;
|
|
2041
|
+
const rects = on.map((region) => ({
|
|
2042
|
+
name: region.name,
|
|
2043
|
+
x: region.x,
|
|
2044
|
+
y: region.y,
|
|
2045
|
+
// spine-core transposes a region's extent at 90 and not at 270 when it
|
|
2046
|
+
// derives the UVs, so the rectangle ON THE PAGE follows the same rule.
|
|
2047
|
+
width: region.degrees === 90 ? region.height : region.width,
|
|
2048
|
+
height: region.degrees === 90 ? region.width : region.height,
|
|
2049
|
+
page: region.page,
|
|
2050
|
+
}));
|
|
2051
|
+
for (const rect of rects) {
|
|
2052
|
+
if (rect.x < 0 || rect.y < 0 || rect.x + rect.width > rect.page.width || rect.y + rect.height > rect.page.height) {
|
|
2053
|
+
fail(
|
|
2054
|
+
'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
|
|
2055
|
+
`region "${rect.name}" occupies ${rect.x},${rect.y} ${rect.width}x${rect.height} of page ` +
|
|
2056
|
+
`"${pageName}", which is ${rect.page.width}x${rect.page.height} — a region that runs off its page ` +
|
|
2057
|
+
'samples texels that are not there',
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
for (let i = 0; i < rects.length; i++) {
|
|
2062
|
+
for (let j = i + 1; j < rects.length; j++) {
|
|
2063
|
+
const a = rects[i];
|
|
2064
|
+
const b = rects[j];
|
|
2065
|
+
if (a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height) {
|
|
2066
|
+
fail(
|
|
2067
|
+
'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
|
|
2068
|
+
`regions "${a.name}" (${a.x},${a.y} ${a.width}x${a.height}) and "${b.name}" (${b.x},${b.y} ` +
|
|
2069
|
+
`${b.width}x${b.height}) overlap on page "${pageName}"; a page is one part covering it exactly or ` +
|
|
2070
|
+
'a tiling of regions that do not, and two rectangles over the same texels put one drawing inside ' +
|
|
2071
|
+
"the other's",
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2019
2075
|
}
|
|
2076
|
+
}
|
|
2077
|
+
for (const region of atlas.regions) {
|
|
2020
2078
|
if (region.degrees !== 0) {
|
|
2021
2079
|
fail(
|
|
2022
2080
|
'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
|
|
@@ -2050,10 +2108,14 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2050
2108
|
const stageW = skeletonData?.width ?? 0;
|
|
2051
2109
|
const stageH = skeletonData?.height ?? 0;
|
|
2052
2110
|
const basePages = new Set<string>();
|
|
2111
|
+
const baseRegions = new Set<string>();
|
|
2053
2112
|
for (const att of regionAttachments) {
|
|
2054
2113
|
if (stageW && stageH && att.width >= stageW && att.height >= stageH) {
|
|
2055
2114
|
const region = atlas.findRegion(att.path || att.name);
|
|
2056
|
-
if (region)
|
|
2115
|
+
if (region) {
|
|
2116
|
+
basePages.add(region.page.name);
|
|
2117
|
+
baseRegions.add(region.name);
|
|
2118
|
+
}
|
|
2057
2119
|
}
|
|
2058
2120
|
}
|
|
2059
2121
|
// The escape hatch is only worth naming when it is reachable: with no stage
|
|
@@ -2064,9 +2126,56 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2064
2126
|
? `Only the one image big enough to cover the whole stage (${stageW}x${stageH}) may be opaque.`
|
|
2065
2127
|
: 'The one image that covers the whole stage may be opaque, but this skeleton declares no stage size, so ' +
|
|
2066
2128
|
'nothing here qualifies.';
|
|
2129
|
+
// 🚨 Counted per page for the unpacked convention and per REGION on a shared
|
|
2130
|
+
// page, and the split is not a convenience (issue #266, follow-up 2). A
|
|
2131
|
+
// packed page's own file all but always declares transparency — the gutter
|
|
2132
|
+
// and whatever is left over of the page are transparent — so the file-level
|
|
2133
|
+
// question is answered "yes" by the packing itself, whatever the parts on it
|
|
2134
|
+
// look like. Asking it that way once packs became gateable under this profile
|
|
2135
|
+
// would have turned this assertion into a pass that measures nothing, which
|
|
2136
|
+
// is the failure mode this file exists to prevent. So a shared page is opened
|
|
2137
|
+
// and each region's own rectangle is measured instead.
|
|
2138
|
+
const sharedPages = new Map<string, TextureAtlasRegion[]>();
|
|
2139
|
+
for (const region of atlas.regions) {
|
|
2140
|
+
const on = sharedPages.get(region.page.name);
|
|
2141
|
+
if (on) on.push(region);
|
|
2142
|
+
else sharedPages.set(region.page.name, [region]);
|
|
2143
|
+
}
|
|
2067
2144
|
for (const page of atlas.pages) {
|
|
2068
2145
|
const abs = resolve(input.atlasDir, page.name);
|
|
2069
2146
|
if (!existsSync(abs)) continue;
|
|
2147
|
+
const on = sharedPages.get(page.name) ?? [];
|
|
2148
|
+
if (on.length > 1) {
|
|
2149
|
+
// A rotated region is refused by A06 under this profile, so the
|
|
2150
|
+
// rectangle read here is the region's own extent either way; the
|
|
2151
|
+
// transpose is applied so the reading is right even while A06 is
|
|
2152
|
+
// reporting the rotation.
|
|
2153
|
+
const plate = readPlate(abs);
|
|
2154
|
+
for (const region of on) {
|
|
2155
|
+
if (baseRegions.has(region.name)) continue;
|
|
2156
|
+
const width = region.degrees === 90 ? region.height : region.width;
|
|
2157
|
+
const height = region.degrees === 90 ? region.width : region.height;
|
|
2158
|
+
let transparent = false;
|
|
2159
|
+
for (let y = region.y; y < region.y + height && !transparent; y++) {
|
|
2160
|
+
for (let x = region.x; x < region.x + width; x++) {
|
|
2161
|
+
if (x < 0 || y < 0 || x >= plate.width || y >= plate.height) continue;
|
|
2162
|
+
if (plate.get(x, y)[3] < 255) {
|
|
2163
|
+
transparent = true;
|
|
2164
|
+
break;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
if (transparent) continue;
|
|
2169
|
+
fail(
|
|
2170
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
2171
|
+
`part "${region.name}" is opaque in every one of its ${width}x${height} texels on shared page ` +
|
|
2172
|
+
`"${page.name}", so it would paint a solid rectangle over whatever is drawn behind it. Re-export ` +
|
|
2173
|
+
`the part with transparency and pack again. ${exemption} This is renderer policy, and it belongs ` +
|
|
2174
|
+
'to --profile spine-html: the default --profile spine does not run this check.',
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
continue;
|
|
2178
|
+
}
|
|
2070
2179
|
const info = readPngInfo(abs);
|
|
2071
2180
|
if (info.hasTransparency) continue;
|
|
2072
2181
|
if (basePages.has(page.name)) continue; // full-stage base plate: opaque is correct
|