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 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 — five complete rigs over art that ships with them
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
- // Three combinations are refused rather than silently resolved, because in each
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
- if (packing && profile === 'spine-html') {
746
- throw new UsageError(
747
- "--profile spine-html asserts one part per page (A06's full-page coverage clause), which is rigc's unpacked " +
748
- 'convention and exactly what --pack stops being true. A packed atlas is valid Spine — build it under the ' +
749
- 'default --profile spine',
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
- ⚠️ *Packed atlases* in that list includes the ones rigc now writes itself, so
200
- `--pack --profile spine-html` is refused by name rather than compiled and then
201
- failed: `A06`'s full-page-coverage clause **is** the unpacked convention, and a
202
- legitimate pack cannot satisfy it. Build a pack under the default `spine`.
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. `carried`
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 three different things.**
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
- Five things this construct is bounded by, and each one is a refusal rather than a
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 does not cover its page |
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: 53 assertion(s) failed`, and this is the tally with one real message
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 "hoverboard-board" is unweighted; the ring tier drives meshes by bones` |
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: the atlas *is* packed,
682
- a bone *does* key a mesh, a mesh *is* unweighted, a clipping attachment *is* present.
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.14.1",
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.** A packed
36
- * region's UVs are `x / pageWidth` rather than `0..1`, so the sampling coordinate
37
- * carries one more rounding step (`fl(regionX + fl(s * width))` cannot be exact
38
- * once `regionX > 0`); the interpolation weight can then differ in its last bit
39
- * and a `Math.round` sitting exactly on a `.5` boundary lands the other way.
40
- * Measured: 0 to 480 channel samples of 7 to 21 million on the three public
41
- * fixtures, worst difference **1**, against 22,000 to 60,000 samples and a worst
42
- * difference of **77** when the gutter is removed and byte-identical on eleven
43
- * of the repository's thirteen rigs across 1,101 frames. Making it exact would mean
44
- * sampling in region-local coordinates and adding the integer page offset to the
45
- * tap indices, which is a change to `src/render.ts` that would move every
46
- * committed reference frame by the same one bit; that is a decision for whoever
47
- * owns those records, not a side effect of adding a packer.
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
- * and then every page is `pageSize x pageSize`. A single part whose cell is
669
- * bigger than that is refused by name silently splitting one drawing across
670
- * two pages is not a thing the format can express.
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
- // The smallest power-of-two page that holds the whole set, by area then width.
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.forEach((rect, i) => {
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!.x + padding,
728
- y: rect!.y + padding,
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. Every page is the maximum size; parts are taken in packing order
737
- // and whatever will not fit the current page opens the next one.
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
- pageW,
744
- pageH,
796
+ maxEdge,
797
+ maxEdge,
745
798
  );
746
- const onThisPage: Placement[] = [];
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
- leftOver.push(remaining[i]);
751
- return;
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: remaining[i].input.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: remaining[i].input.width,
759
- height: remaining[i].input.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 two rules that keep it that way
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
- /** A sinusoid of one coordinate, displacing along another. */
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
- offsets[2 * v + axis] = round(amount * u ** power);
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. Deeper is further from the viewer, and a **negative** depth is behind
83
- * the axis which is what makes the back of a head swing the other way
84
- * (FACE §2).
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`, one region per page and no
1996
- // rotation are rigc's atlas CONVENTION, not the atlas format's rules — a
1997
- // packed page with `rotate: 90` is what the Spine packer produces and
1998
- // every official example ships one. The convention is what makes the
1999
- // attachment -> region -> file chain checkable exactly (A27), so it stays
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
- if (region.u !== 0 || region.v !== 0 || region.u2 !== 1 || region.v2 !== 1) {
2015
- fail(
2016
- 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
2017
- `region "${region.name}" has UVs (${region.u},${region.v})-(${region.u2},${region.v2}); one part per page must cover the page exactly`,
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) basePages.add(region.page.name);
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