@rig-c/core 2.39.0 → 2.40.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.
@@ -64,6 +64,9 @@ import { a06AtlasPageSizeMatchesPng } from '../bodies/a06.ts';
64
64
  import { a19OverlayPngsHaveAlpha } from '../bodies/a19.ts';
65
65
  import { a49PackedFootprintsDoNotOverlap } from '../bodies/a49.ts';
66
66
  import { a50StageBoxIsTheStage } from '../bodies/a50.ts';
67
+ import { a51BonesLieAlongTheirDeclaredAxis } from '../bodies/a51.ts';
68
+ import { modelSetupWorld } from './setup_world.ts';
69
+ import type { SetupWorldFacts } from '../facts/setup_world.ts';
67
70
  import { modelStageBox } from './stage_box.ts';
68
71
  import type { StageBoxFacts } from '../facts/stage_box.ts';
69
72
  import { a27RegionNameMatchesPageFilename } from '../bodies/a27.ts';
@@ -197,6 +200,8 @@ export interface ModelSupply {
197
200
  steppedPoses: (read: ReadDocument) => SteppedPoseFacts;
198
201
  /** The stage box the document asks for and what its default skin holds there (issue #1168, `./stage_box.ts`). */
199
202
  stageBox: (read: ReadDocument) => StageBoxFacts;
203
+ /** Each bone's world frame at the setup pose, A50's pose (issue #1351, `./setup_world.ts`). */
204
+ setupWorld: (read: ReadDocument) => SetupWorldFacts;
200
205
  /** The rig info the document declares (issue #1054, `./declared.ts`); a caller's `rig` beside it is refused by name. */
201
206
  rigInfo: (read: ReadDocument, input: ModelValidateInput) => RigInfo;
202
207
  /** Each animation's declared duration as the document states it (issue #1054); a caller's `declaredDurations` beside it is refused by name. */
@@ -228,6 +233,7 @@ export const MODEL_SUPPLY: ModelSupply = {
228
233
  constraintTargets: modelConstraintTargets,
229
234
  steppedPoses: modelSteppedPoses,
230
235
  stageBox: modelStageBox,
236
+ setupWorld: modelSetupWorld,
231
237
  rigInfo: (read, input) => {
232
238
  refuseDeclaredBeside(read, input, 'rig');
233
239
  return modelRigInfo(read);
@@ -318,6 +324,7 @@ export const MOVED_ASSERTIONS: readonly MovedAssertion[] = [
318
324
  { code: 'A28_RIBBON_ROWS_SHARE_WEIGHTS', run: (v, read, input, supply) => a28RibbonRowsShareWeights(v, supply.meshes(read), supply.rigInfo(read, input)), unread: SKIP_NO_MODEL },
319
325
  { code: 'A29_STROKE_WITHIN_CONTACT_DEPTH', run: (v, read, input, supply) => a29StrokeWithinContactDepth(v, supply.boneTimelines(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
320
326
  { code: 'A30_STROKE_WITHIN_CAP_CONTAINMENT', run: (v, read, input, supply) => a30StrokeWithinCapContainment(v, supply.boneTimelines(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
327
+ { code: 'A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS', run: (v, read, input, supply) => a51BonesLieAlongTheirDeclaredAxis(v, supply.setupWorld(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL },
321
328
  ];
322
329
 
323
330
  /** The codes the model side prints: its two parse rules, then the moved assertions. */
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The model side's supply of `SetupWorldFacts` (issue #1351): each bone's
3
+ * world frame at the setup pose as rigc's core poses it — `poseSetup` under no
4
+ * skin, constraints applied, the pose `./stage_box.ts` reads for A50 — posed
5
+ * once, on the first question, so a rig that declares no axis poses nothing.
6
+ *
7
+ * Links nothing from the runtime.
8
+ */
9
+ import { activeBones, poseSetup, rawNumber, underNoSkin } from '../../core/index.ts';
10
+ import type { CoreWorld } from '../../core/world.ts';
11
+ import { inactiveAtSetup, type SetupWorldFacts } from '../facts/setup_world.ts';
12
+ import type { ReadDocument } from './parse.ts';
13
+
14
+ export function modelSetupWorld(read: ReadDocument): SetupWorldFacts {
15
+ let posed: { active: ReadonlySet<string>; world: Map<string, CoreWorld> | string } | null = null;
16
+ const pose = (): { active: ReadonlySet<string>; world: Map<string, CoreWorld> | string } => {
17
+ if (posed !== null) return posed;
18
+ const view = underNoSkin(read.doc);
19
+ const setup = poseSetup(view, { round: rawNumber });
20
+ const world = setup.world ?? setup.absent.find(([block]) => block === 'setup.bones')?.[1] ?? "rigc's core does not pose this setup pose";
21
+ posed = { active: activeBones(view), world };
22
+ return posed;
23
+ };
24
+ return {
25
+ frameOf: (bone) => {
26
+ if (!read.doc.bones.some((b) => b.name === bone)) return null;
27
+ const { active, world } = pose();
28
+ if (!active.has(bone)) return inactiveAtSetup(bone);
29
+ if (typeof world === 'string') return world;
30
+ const frame = world.get(bone);
31
+ if (frame === undefined) return `bone "${bone}" has no setup world transform`;
32
+ return { worldX: frame.worldX, worldY: frame.worldY, a: frame.a, c: frame.c };
33
+ },
34
+ };
35
+ }
package/src/ballot.ts CHANGED
@@ -616,7 +616,7 @@ export function buildBallot(input: BallotInput): { html: string; manifest: Ballo
616
616
  <meta name="viewport" content="width=device-width, initial-scale=1">
617
617
  <title>rigc ballot — ${escapeHtml(manifest.ballot)}</title>
618
618
  <!--
619
- Generated by rigc ${escapeHtml(input.version)} — https://github.com/firejune/rigc
619
+ Generated by rigc ${escapeHtml(input.version)} — https://github.com/rig-c/core
620
620
 
621
621
  Every candidate's skeleton, atlas and atlas pages are embedded in this file as
622
622
  data URIs, so it plays on its own with no server and no sibling files.
package/src/bonedist.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * a rotation of 300° are one key each. Stage 3 is the measure for that, and
10
10
  * until this file it did not exist: *"None of it exists. Do not report a
11
11
  * per-frame figure until it does"*
12
- * ([issue #8](https://github.com/firejune/rigc/issues/8)).
12
+ * ([issue #8](https://github.com/rig-c/core/issues/8)).
13
13
  *
14
14
  * `rigc check` is the other neighbour and it is **not** this. It measures a
15
15
  * candidate against **pictures** — the rendered reference frames — which is
package/src/cli/shared.ts CHANGED
@@ -334,7 +334,7 @@ function ceilingPair(axis: { positive: FoldLimit | null; negative: FoldLimit | n
334
334
  * above the minimum. A ceiling set by one bad texel has 99 % of the mesh
335
335
  * surviving to the form's angle while the reported number collapses — 64.58°
336
336
  * against 6.08° for one texel of 160,000, with the percentile unmoved at 64.80°
337
- * in both ([#412](https://github.com/firejune/rigc/issues/412),
337
+ * in both ([#412](https://github.com/rig-c/core/issues/412),
338
338
  * `bench/studies/2026-09-05-noise` §6).
339
339
  *
340
340
  * Three spellings, three different claims, for the reason `ceilingPair` prints
@@ -28,7 +28,7 @@ import { dirname, join, resolve } from 'node:path';
28
28
  function repositoryUrl(): string {
29
29
  const repo = readPackageMeta()?.repository;
30
30
  const url = typeof repo === 'string' ? repo : repo?.url;
31
- return (url ?? 'https://github.com/firejune/rigc').replace(/^git\+/, '').replace(/\.git$/, '');
31
+ return (url ?? 'https://github.com/rig-c/core').replace(/^git\+/, '').replace(/\.git$/, '');
32
32
  }
33
33
 
34
34
  // ---------------------------------------------------------------------------
package/src/compile.ts CHANGED
@@ -7555,6 +7555,8 @@ function buildRigInfo(
7555
7555
  consumerDrivenMix: (rig.invariants?.consumerDrivenMix ?? []).map((e) => ({ type: e.type, constraint: e.constraint, why: e.why })),
7556
7556
  // `parseRigSpec` refused every shape but a non-blank `why`.
7557
7557
  idleDrivesMeshes: rig.invariants?.idleDrivesMeshes?.why ?? null,
7558
+ // `parseRigSpec` resolved every name and refused every shape (issue #1351).
7559
+ axes: (rig.invariants?.axes ?? []).map((e) => ({ name: e.name, through: [e.through[0], e.through[1]] as [string, string], bones: [...e.bones], tolerance: e.tolerance })),
7558
7560
  // `isBase` is set on the manifest route only; every other image says false.
7559
7561
  basePlates: images.filter((img) => img.isBase).map((img) => img.region),
7560
7562
  meshSlotBudget: rig.invariants?.meshSlots ?? null,
@@ -4,9 +4,9 @@
4
4
  * ⭐ **One survey, two consumers.** `A39_DEFORM_KEEPS_TRIANGLE_WINDING` reads the
5
5
  * reversals out of it and refuses a build; `explain`'s `DEFORM` block prints the
6
6
  * whole of it and refuses nothing. That split is issue
7
- * [#296](https://github.com/firejune/rigc/issues/296)'s two halves —
8
- * [#314](https://github.com/firejune/rigc/pull/314) landed the assertion and
9
- * [#316](https://github.com/firejune/rigc/issues/316) the report — and the reason
7
+ * [#296](https://github.com/rig-c/core/issues/296)'s two halves —
8
+ * [#314](https://github.com/rig-c/core/pull/314) landed the assertion and
9
+ * [#316](https://github.com/rig-c/core/issues/316) the report — and the reason
10
10
  * they share this file rather than each posing the skeleton themselves is that
11
11
  * the report's `winding 32 of 32 kept` and A39's `8 of 32 reverse` are **the same
12
12
  * count**. Two derivations of one number drift, and the one that drifts silently
package/src/depth.ts CHANGED
@@ -454,14 +454,14 @@ export function foldPrecedes(
454
454
  * minimum alone cannot answer the question an author actually has: `degrees` is
455
455
  * the same number whether a whole band of the mesh reaches the limit together
456
456
  * or one triangle does, and those are a form and a bad texel respectively
457
- * ([#412](https://github.com/firejune/rigc/issues/412),
457
+ * ([#412](https://github.com/rig-c/core/issues/412),
458
458
  * `bench/studies/2026-09-05-noise` §6).
459
459
  *
460
460
  * ⚠️ And a band is not sufficient evidence of a form either, which is what
461
461
  * `stepShare` is here for: an OUTLINE is a band, so a mesh whose ceiling is set
462
462
  * by the occlusion edge of a cut-out reads `p1/degrees` near 1 and a `depthStep`
463
463
  * of most of the sheet's range — both of the older figures reading *healthy* on
464
- * the same measurement ([#448](https://github.com/firejune/rigc/issues/448)).
464
+ * the same measurement ([#448](https://github.com/rig-c/core/issues/448)).
465
465
  *
466
466
  * ⛔ Neither figure changes `degrees`, and neither is a threshold. rigc does not
467
467
  * have the authority to guess its input away, so nothing here filters,
@@ -499,7 +499,7 @@ export interface FoldLimit {
499
499
  * triangle that folds first.
500
500
  *
501
501
  * ⭐ The figure that tells a form from a cliff, and it is the one reading the
502
- * other two cannot give ([#448](https://github.com/firejune/rigc/issues/448)).
502
+ * other two cannot give ([#448](https://github.com/rig-c/core/issues/448)).
503
503
  * A form has a slope, so refining the lattice halves the step and halves this
504
504
  * with it while the angle converges. A **discontinuity has no slope**: the
505
505
  * step stays the whole range however fine the lattice gets, this figure pins
package/src/diff.ts CHANGED
@@ -362,7 +362,7 @@ function atPlaces(n: number): number {
362
362
  * it, so `note` states both rates and which side is the bigger one. A histogram
363
363
  * intersection over the same quantity reads 421/1339 whether the candidate has
364
364
  * a third of the reference's keys or three times them — that ambiguity is what
365
- * [issue #20](https://github.com/firejune/rigc/issues/20) turned on, and a
365
+ * [issue #20](https://github.com/rig-c/core/issues/20) turned on, and a
366
366
  * figure that repeated it would be the second copy of the same defect.
367
367
  */
368
368
  function rateAgreement(id: string, what: string, unit: string, a: number, b: number, convention: string): DiffMeasure {
package/src/generation.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * **silently**: 4.3 takes constraints from the top-level `constraints` array
8
8
  * alone, so a 4.0–4.2 file parses clean with none of them — measured across
9
9
  * 1,302 shipped skeletons that loaded 0 of 8,672 constraints
10
- * ([#706](https://github.com/firejune/rigc/issues/706) row 1). rigc is where
10
+ * ([#706](https://github.com/rig-c/core/issues/706) row 1). rigc is where
11
11
  * that knowledge lives for the tools around it (#706 *Ownership*), and living
12
12
  * in one place means one reader of `skeleton.spine`:
13
13
  * `A16_SKELETON_VERSION_4_3` asks this function for its verdict, and `ingest`
package/src/preview.ts CHANGED
@@ -263,7 +263,7 @@ export function buildPreview(input: PreviewInput): string {
263
263
  <meta name="viewport" content="width=device-width, initial-scale=1">
264
264
  <title>rigc preview — ${label}</title>
265
265
  <!--
266
- Generated by rigc ${escapeHtml(input.version)} — https://github.com/firejune/rigc
266
+ Generated by rigc ${escapeHtml(input.version)} — https://github.com/rig-c/core
267
267
 
268
268
  The skeleton, the atlas and every atlas page are embedded in this file as data
269
269
  URIs, so it plays on its own with no server and no sibling files.
@@ -357,7 +357,7 @@ export function buildPreviewPanes(inputs: PreviewInput[], version: string): stri
357
357
  <meta name="viewport" content="width=device-width, initial-scale=1">
358
358
  <title>rigc preview — ${inputs.length} candidates</title>
359
359
  <!--
360
- Generated by rigc ${escapeHtml(version)} — https://github.com/firejune/rigc
360
+ Generated by rigc ${escapeHtml(version)} — https://github.com/rig-c/core
361
361
 
362
362
  Every candidate's skeleton, atlas and atlas pages are embedded in this file as
363
363
  data URIs, so it plays on its own with no server and no sibling files.
package/src/rig.ts CHANGED
@@ -1757,6 +1757,51 @@ export interface RigInvariants {
1757
1757
  * refused at the gate: it would switch off nothing while reading like it did.
1758
1758
  */
1759
1759
  idleDrivesMeshes?: RigIdleDrivesMeshes;
1760
+ /**
1761
+ * Lines some of the rig's bones are held to: for each entry, the line
1762
+ * through the setup-pose world origins of the two `through` bones, and every
1763
+ * bone in `bones` with its setup-pose world x axis within `tolerance` degrees
1764
+ * of it, as a line — `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` (issue #1351).
1765
+ *
1766
+ * 🔑 **Why a bone's axes are the rig's business.** A `scaleY` key squashes
1767
+ * along the bone's own y axis and a `translateY` key moves along it. On a face
1768
+ * painted upright with every bone at rotation 0 that is the face's axis; on a
1769
+ * face painted tilted it is the page's, so a blink squashes the eye along the
1770
+ * page's vertical rather than across the eye line, a brow rises toward the
1771
+ * page's top rather than the forehead — and the parser, the round trip and
1772
+ * every other rule accept it. The author cannot see it. Rotating the bones to
1773
+ * the face's line (their attachments counter-rotated, so the setup pose is
1774
+ * unmoved) is the fix, and this is the declaration that holds it.
1775
+ *
1776
+ * 🚨 **Held whether or not any animation keys the bones.** A consumer writes
1777
+ * them at run time (`openTrack({ adjust })` on the core entry), which no
1778
+ * animation in the file shows, so "nothing keys it" is not "nothing reads it".
1779
+ *
1780
+ * ⚠️ `tolerance` is **required**, finite, above 0 and below 90 — the compiler
1781
+ * does not invent one. Two lines meet at 0° to 90°, so a tolerance of 90 or
1782
+ * more admits every orientation and could never fail; and a bone's world
1783
+ * angle is read off a posed matrix in floating point, so 0 would refuse a
1784
+ * bone rotated exactly onto the line whenever the arithmetic rounds.
1785
+ *
1786
+ * Every name resolves or is refused by name: an unknown bone in `through` or
1787
+ * `bones`, `through` naming one bone twice, a repeated entry `name`. Two
1788
+ * `through` bones that land on one world origin draw no line, and that is
1789
+ * known only once the setup pose is posed — constraints included — so the
1790
+ * gate refuses it, by the entry's name, rather than this parser.
1791
+ */
1792
+ axes?: RigAxis[];
1793
+ }
1794
+
1795
+ /** One line some bones are held to — `invariants.axes` (`A51`). */
1796
+ export interface RigAxis {
1797
+ /** Names the entry in every failure; unique among the rig's axes. */
1798
+ name: string;
1799
+ /** The two bones whose setup-pose world origins draw the line, first to second. Two different bones. */
1800
+ through: string[];
1801
+ /** The bones whose setup-pose world x axis must lie along it. */
1802
+ bones: string[];
1803
+ /** Degrees, required: above 0 and below 90 — see `RigInvariants.axes`. */
1804
+ tolerance: number;
1760
1805
  }
1761
1806
 
1762
1807
  /** The rig's `idle` is meant to deform meshes — `invariants.idleDrivesMeshes` (`A15`). */
@@ -1869,7 +1914,8 @@ export const RIG_KEYS = {
1869
1914
  RigBoneFrom: ['anchor', 'slotWindow', 'meshCenter', 'rotation'],
1870
1915
  RigSlot: ['name', 'bone', 'attachment', 'color', 'dark', 'blend'],
1871
1916
  RigEvent: ['int', 'float', 'string', 'audio', 'volume', 'balance'],
1872
- RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'consumerDrivenMix', 'idleDrivesMeshes'],
1917
+ RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'consumerDrivenMix', 'idleDrivesMeshes', 'axes'],
1918
+ RigAxis: ['name', 'through', 'bones', 'tolerance'],
1873
1919
  RigIdleDrivesMeshes: ['why'],
1874
1920
  RigDetachedRule: ['bone', 'notUnder', 'why'],
1875
1921
  RigDeformFoldExemption: ['slot', 'why'],
@@ -1977,8 +2023,9 @@ export const RIG_TYPES = {
1977
2023
  RigEvent: { int: 'number', float: 'number', string: 'string', audio: 'string', volume: 'number', balance: 'number' },
1978
2024
  RigInvariants: {
1979
2025
  meshSlots: 'number', meshTriangles: 'number', axisBone: 'string', massBone: 'string', detached: 'object[]',
1980
- deformMayFold: 'object[]', consumerDrivenMix: 'object[]', idleDrivesMeshes: 'object',
2026
+ deformMayFold: 'object[]', consumerDrivenMix: 'object[]', idleDrivesMeshes: 'object', axes: 'object[]',
1981
2027
  },
2028
+ RigAxis: { name: 'string', through: 'string[]', bones: 'string[]', tolerance: 'number' },
1982
2029
  RigIdleDrivesMeshes: { why: 'string' },
1983
2030
  RigDetachedRule: { bone: 'string', notUnder: 'string', why: 'string' },
1984
2031
  RigDeformFoldExemption: { slot: 'string', why: 'string' },
@@ -2312,6 +2359,9 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
2312
2359
  at(rule, 'RigConsumerDrivenMix', `invariants.consumerDrivenMix[${i}]`, ['invariants', 'consumerDrivenMix', i]);
2313
2360
  }
2314
2361
  at(raw.invariants.idleDrivesMeshes, 'RigIdleDrivesMeshes', 'invariants.idleDrivesMeshes', ['invariants', 'idleDrivesMeshes']);
2362
+ for (const [i, rule] of (Array.isArray(raw.invariants.axes) ? raw.invariants.axes : []).entries()) {
2363
+ at(rule, 'RigAxis', `invariants.axes[${i}]`, ['invariants', 'axes', i]);
2364
+ }
2315
2365
  }
2316
2366
 
2317
2367
  for (const [skinName, skin] of Object.entries(isObj(raw.skins) ? raw.skins : {})) {
@@ -2777,6 +2827,58 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2777
2827
  }
2778
2828
  }
2779
2829
 
2830
+ // `invariants.axes` (issue #1351) — a field that TURNS A CHECK ON, and every
2831
+ // way an entry could check nothing while reading like it did is refused by
2832
+ // name: a bone that resolves to nothing, a line through one bone, a repeated
2833
+ // name, a tolerance that is missing or admits every orientation. What it
2834
+ // cannot refuse is two `through` bones landing on one world origin: that is
2835
+ // a fact about the posed setup pose, constraints included, which this parser
2836
+ // does not pose — `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` refuses it by the
2837
+ // entry's name.
2838
+ const axes = spec.invariants?.axes;
2839
+ if (axes !== undefined) {
2840
+ const shape = 'an array of { "name": …, "through": [<bone>, <bone>], "bones": [<bone>, …], "tolerance": <degrees> }';
2841
+ if (!Array.isArray(axes)) throw new CompileError(`${where}: invariants.axes is ${JSON.stringify(axes)}, expected ${shape}`);
2842
+ const named = new Set<string>();
2843
+ for (const [i, entry] of axes.entries()) {
2844
+ const field = `invariants.axes[${i}]`;
2845
+ if (!isObj(entry) || typeof entry.name !== 'string' || entry.name.length === 0) {
2846
+ throw new CompileError(`${where}: ${field} needs a "name" — ${shape}`);
2847
+ }
2848
+ const axis = `${field} "${entry.name}"`;
2849
+ if (named.has(entry.name)) throw new CompileError(`${where}: ${field}: invariants.axes names "${entry.name}" twice — a failure names its entry, and two entries of one name could not be told apart`);
2850
+ named.add(entry.name);
2851
+ const through: unknown = entry.through;
2852
+ if (!Array.isArray(through) || through.length !== 2 || !through.every((b): b is string => typeof b === 'string')) {
2853
+ throw new CompileError(`${where}: ${axis}: "through" is ${JSON.stringify(through)}, expected two bone names — the line is drawn through their setup-pose world origins`);
2854
+ }
2855
+ for (const [k, bone] of through.entries()) {
2856
+ if (!seen.has(bone)) throw new CompileError(`${where}: ${axis}: through[${k}] names bone "${bone}", which this rig does not declare — a line through nothing holds nothing`);
2857
+ }
2858
+ if (through[0] === through[1]) {
2859
+ throw new CompileError(`${where}: ${axis}: "through" names bone "${through[0]}" twice — one bone's origin is a point, and a point draws no line`);
2860
+ }
2861
+ const bones: unknown = entry.bones;
2862
+ if (!Array.isArray(bones) || bones.length === 0 || !bones.every((b): b is string => typeof b === 'string')) {
2863
+ throw new CompileError(`${where}: ${axis}: "bones" is ${JSON.stringify(bones)}, expected a non-empty array of bone names — the bones held to the line`);
2864
+ }
2865
+ const held = new Set<string>();
2866
+ for (const [k, bone] of bones.entries()) {
2867
+ if (!seen.has(bone)) throw new CompileError(`${where}: ${axis}: bones[${k}] names bone "${bone}", which this rig does not declare — a name that resolves to nothing is held to nothing, and reads like it was`);
2868
+ if (held.has(bone)) throw new CompileError(`${where}: ${axis}: "bones" names bone "${bone}" twice`);
2869
+ held.add(bone);
2870
+ }
2871
+ const tolerance: unknown = entry.tolerance;
2872
+ if (typeof tolerance !== 'number' || !Number.isFinite(tolerance) || !(tolerance > 0) || !(tolerance < 90)) {
2873
+ throw new CompileError(
2874
+ `${where}: ${axis}: "tolerance" is ${tolerance === undefined ? 'absent' : JSON.stringify(tolerance)}; it must be a number of degrees above 0 and below 90, ` +
2875
+ 'and there is no default — two lines meet at 0° to 90°, so 90 or more admits every orientation and can never fail, ' +
2876
+ 'and 0 refuses a bone rotated exactly onto the line whenever the posed arithmetic rounds',
2877
+ );
2878
+ }
2879
+ }
2880
+ }
2881
+
2780
2882
  // --- skins: the attachment table, and what the skin ACTIVATES --------------
2781
2883
  //
2782
2884
  // The lists resolve by name like everything else in this format, and the
package/src/trackgen.ts CHANGED
@@ -87,7 +87,7 @@ export interface TrackDeriveTurn {
87
87
  *
88
88
  * ⚠️ That sign is the one thing here no assertion can check, and this comment
89
89
  * had it backwards until issue #350's neighbour
90
- * ([#351](https://github.com/firejune/rigc/issues/351)). The closed form is
90
+ * ([#351](https://github.com/rig-c/core/issues/351)). The closed form is
91
91
  * the arbiter: `d = (x−about)·(cos t − 1) − (depth − carried)·sin t` gives a
92
92
  * part with `depth > carried` a **negative** residual, and FACE §3 makes
93
93
  * exactly that the nose diagnostic — *if the nose's residual is not negative,
package/src/types.ts CHANGED
@@ -1495,6 +1495,15 @@ export interface RigInfo {
1495
1495
  * `validate <dir>` has no rig, so null, and A15 refuses per bone as before.
1496
1496
  */
1497
1497
  idleDrivesMeshes: string | null;
1498
+ /**
1499
+ * The lines the rig declares some of its bones to lie along, from
1500
+ * `invariants.axes`, in the rig spec's order — each with the two bones whose
1501
+ * setup-pose world origins draw it, the bones held to it and the tolerance in
1502
+ * degrees. Empty is the ordinary case, and `A51` SKIPs on it. `parseRigSpec`
1503
+ * resolved every name and refused every shape before this was built (issue
1504
+ * #1351).
1505
+ */
1506
+ axes: Array<{ name: string; through: [string, string]; bones: string[]; tolerance: number }>;
1498
1507
  /**
1499
1508
  * The atlas region(s) the build names as its base plate — the one image
1500
1509
  * `A19_OVERLAY_PNGS_HAVE_ALPHA` lets be opaque — in compile order.
package/src/validate.ts CHANGED
@@ -105,6 +105,8 @@ import { a06AtlasPageSizeMatchesPng } from './assertions/bodies/a06.ts';
105
105
  import { a19OverlayPngsHaveAlpha } from './assertions/bodies/a19.ts';
106
106
  import { a49PackedFootprintsDoNotOverlap } from './assertions/bodies/a49.ts';
107
107
  import { a50StageBoxIsTheStage } from './assertions/bodies/a50.ts';
108
+ import { a51BonesLieAlongTheirDeclaredAxis } from './assertions/bodies/a51.ts';
109
+ import { inactiveAtSetup, type SetupWorldFacts } from './assertions/facts/setup_world.ts';
108
110
  import { SKIP_NO_STAGE_BOX, type StageBoxFacts } from './assertions/facts/stage_box.ts';
109
111
  import { a27RegionNameMatchesPageFilename } from './assertions/bodies/a27.ts';
110
112
  import type { MeshEntry as MeshAttachmentEntry, MeshFacts } from './assertions/facts/mesh_attachments.ts';
@@ -1309,6 +1311,31 @@ export function spineStageBox(data: ReturnType<SkeletonJson['readSkeletonData']>
1309
1311
  return { asked, why: '', slot: true, held: { type: 'boundingbox', weighted, stored, world } };
1310
1312
  }
1311
1313
 
1314
+ /**
1315
+ * The runtime's supply of `SetupWorldFacts` (issue #1351): each bone's world
1316
+ * frame on a fresh skeleton at the setup pose — `setupPose()`,
1317
+ * `updateWorldTransform(Physics.none)`, no skin set, the pose `spineStageBox`
1318
+ * reads for A50 — posed once, on the first question.
1319
+ */
1320
+ export function spineSetupWorld(data: ReturnType<SkeletonJson['readSkeletonData']>): SetupWorldFacts {
1321
+ let skeleton: Skeleton | null = null;
1322
+ return {
1323
+ frameOf: (name) => {
1324
+ if (data.findBone(name) === null) return null;
1325
+ if (skeleton === null) {
1326
+ skeleton = new Skeleton(data);
1327
+ skeleton.setupPose();
1328
+ skeleton.updateWorldTransform(Physics.none);
1329
+ }
1330
+ const bone = skeleton.findBone(name);
1331
+ if (bone === null) return null;
1332
+ if (!bone.active) return inactiveAtSetup(name);
1333
+ const { a, c, worldX, worldY } = bone.appliedPose;
1334
+ return { worldX, worldY, a, c };
1335
+ },
1336
+ };
1337
+ }
1338
+
1312
1339
  /** The box a `rigc-compiled/3` document's stage asks for, with that stage — or the SKIP's reason where nothing asks (`spineStageBox`). */
1313
1340
  function documentStageBox(modelText: string | undefined): StageBoxFacts['asked'] & object | string {
1314
1341
  if (modelText === undefined) return SKIP_NO_STAGE_BOX;
@@ -3422,6 +3449,28 @@ export function validate(input: ValidateInput): ValidateReport {
3422
3449
  // re-measure containment for the scaled contour and say so.
3423
3450
  check('A30_STROKE_WITHIN_CAP_CONTAINMENT', () => a30StrokeWithinCapContainment(verdicts, rawBoneTimelines(raw), input));
3424
3451
 
3452
+ // --- A51: the bones a rig declares to share an axis lie along it ---------
3453
+ //
3454
+ // ⭐ A `scaleY` key squashes along the bone's own y axis and a `translateY`
3455
+ // key moves along it, so on a face painted tilted with its bones at rotation
3456
+ // 0 a blink squashes the eye along the page's vertical rather than across the
3457
+ // eye line — measured on a seated rig whose eye line is 23.7° off the page,
3458
+ // where all five face bones sat at 0 and the closed eyes came out level on a
3459
+ // tilted face (issue #1351). The file loads, every other rule passes, and the
3460
+ // picture is wrong: the author cannot see it, which is the case this gate is
3461
+ // for. The rig declares the line (`invariants.axes`) and the bones held to
3462
+ // it; this measures their setup-pose world x axes against it, as A50 poses
3463
+ // the setup pose.
3464
+ //
3465
+ // 🔒 **Validity, not archetype.** It fires only on a declaration, so it
3466
+ // cannot fire on foreign data — the reason archetype rules leave the default
3467
+ // profile — and a declaration the default profile never read would be one
3468
+ // nothing reads. And it holds every declared bone, keyed or not: a consumer
3469
+ // writes them at run time (`openTrack({ adjust })`), which no animation shows.
3470
+ check('A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS', () =>
3471
+ skeletonData === null ? skip('A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS', SKIP_NO_SKELETON) : a51BonesLieAlongTheirDeclaredAxis(verdicts, spineSetupWorld(skeletonData), input),
3472
+ );
3473
+
3425
3474
  // --- A18: determinism ----------------------------------------------------
3426
3475
  // The body is `./assertions/bodies/a18.ts` (issue #1060): the model document's clause, which the model side
3427
3476
  // runs too, and the Spine pair's two clauses handed to it as this side's encoding findings, in their order.