@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/NOTICE.md +3 -3
- package/README.md +48 -47
- package/docs/AUTHORING.md +86 -30
- package/docs/FACE.md +14 -14
- package/docs/INGEST.md +4 -4
- package/docs/MOTION.md +2 -2
- package/docs/PROMPTING.md +4 -4
- package/docs/RIGGING.md +33 -33
- package/package.json +4 -4
- package/skills/rigc/SKILL.md +8 -8
- package/skills/rigc-face/SKILL.md +6 -6
- package/skills/rigc-ingest/SKILL.md +5 -5
- package/skills/rigc-motion/SKILL.md +6 -6
- package/skills/rigc-rigging/SKILL.md +6 -6
- package/src/assertions/bodies/a51.ts +92 -0
- package/src/assertions/facts/setup_world.ts +40 -0
- package/src/assertions/kinds.ts +1 -0
- package/src/assertions/model/declared.ts +1 -0
- package/src/assertions/model/index.ts +7 -0
- package/src/assertions/model/setup_world.ts +35 -0
- package/src/ballot.ts +1 -1
- package/src/bonedist.ts +1 -1
- package/src/cli/shared.ts +1 -1
- package/src/cli/spine_commands.ts +1 -1
- package/src/compile.ts +2 -0
- package/src/deformmeasure.ts +3 -3
- package/src/depth.ts +3 -3
- package/src/diff.ts +1 -1
- package/src/generation.ts +1 -1
- package/src/preview.ts +2 -2
- package/src/rig.ts +104 -2
- package/src/trackgen.ts +1 -1
- package/src/types.ts +9 -0
- package/src/validate.ts +49 -0
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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,
|
package/src/deformmeasure.ts
CHANGED
|
@@ -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/
|
|
8
|
-
* [#314](https://github.com/
|
|
9
|
-
* [#316](https://github.com/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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.
|