spine-rigc 1.7.0 → 1.9.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.
Files changed (79) hide show
  1. package/cli.ts +88 -21
  2. package/docs/AUTHORING.md +18 -12
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a04.ts +40 -0
  6. package/src/assertions/bodies/a06.ts +195 -0
  7. package/src/assertions/bodies/a08.ts +76 -0
  8. package/src/assertions/bodies/a11.ts +15 -0
  9. package/src/assertions/bodies/a13.ts +51 -0
  10. package/src/assertions/bodies/a14.ts +35 -0
  11. package/src/assertions/bodies/a15.ts +97 -0
  12. package/src/assertions/bodies/a17.ts +26 -0
  13. package/src/assertions/bodies/a19.ts +295 -0
  14. package/src/assertions/bodies/a20.ts +122 -0
  15. package/src/assertions/bodies/a21.ts +190 -0
  16. package/src/assertions/bodies/a22.ts +39 -0
  17. package/src/assertions/bodies/a23.ts +255 -0
  18. package/src/assertions/bodies/a27.ts +33 -0
  19. package/src/assertions/bodies/a28.ts +70 -0
  20. package/src/assertions/bodies/a33.ts +110 -0
  21. package/src/assertions/bodies/a36.ts +81 -0
  22. package/src/assertions/bodies/a37.ts +77 -0
  23. package/src/assertions/bodies/a38.ts +73 -0
  24. package/src/assertions/bodies/a41.ts +83 -0
  25. package/src/assertions/bodies/a42.ts +97 -0
  26. package/src/assertions/bodies/a44.ts +23 -0
  27. package/src/assertions/bodies/a45.ts +172 -0
  28. package/src/assertions/bodies/a47.ts +59 -0
  29. package/src/assertions/bodies/a48.ts +83 -0
  30. package/src/assertions/constraint_words.ts +177 -0
  31. package/src/assertions/facts/animated_bones.ts +28 -0
  32. package/src/assertions/facts/atlas_pages.ts +19 -0
  33. package/src/assertions/facts/atlas_regions.ts +52 -0
  34. package/src/assertions/facts/constraints.ts +131 -0
  35. package/src/assertions/facts/linked_meshes.ts +38 -0
  36. package/src/assertions/facts/mesh_attachments.ts +71 -0
  37. package/src/assertions/facts/region_joins.ts +34 -0
  38. package/src/assertions/facts/skin_entries.ts +37 -0
  39. package/src/assertions/facts/skin_members.ts +53 -0
  40. package/src/assertions/facts/skin_meshes.ts +49 -0
  41. package/src/assertions/facts/slot_colour.ts +43 -0
  42. package/src/assertions/facts/stage.ts +24 -0
  43. package/src/assertions/facts/vertex_polygons.ts +53 -0
  44. package/src/assertions/harness.ts +109 -0
  45. package/src/assertions/kinds.ts +102 -0
  46. package/src/assertions/mesh_kinds.ts +56 -0
  47. package/src/assertions/model/animated_bones.ts +30 -0
  48. package/src/assertions/model/atlas_pages.ts +15 -0
  49. package/src/assertions/model/atlas_regions.ts +76 -0
  50. package/src/assertions/model/constraints.ts +207 -0
  51. package/src/assertions/model/given.ts +45 -0
  52. package/src/assertions/model/index.ts +247 -0
  53. package/src/assertions/model/linked_meshes.ts +24 -0
  54. package/src/assertions/model/mesh_attachments.ts +90 -0
  55. package/src/assertions/model/parse.ts +138 -0
  56. package/src/assertions/model/region_joins.ts +59 -0
  57. package/src/assertions/model/skin_entries.ts +59 -0
  58. package/src/assertions/model/skin_members.ts +62 -0
  59. package/src/assertions/model/skin_meshes.ts +90 -0
  60. package/src/assertions/model/slot_colour.ts +80 -0
  61. package/src/assertions/model/stage.ts +28 -0
  62. package/src/assertions/model/vertex_polygons.ts +71 -0
  63. package/src/assertions/reasons.ts +127 -0
  64. package/src/assertions/region_lookups.ts +61 -0
  65. package/src/assertions/values.ts +39 -0
  66. package/src/check.ts +36 -9
  67. package/src/compile.ts +204 -57
  68. package/src/core/index.ts +161 -29
  69. package/src/core/world.ts +21 -20
  70. package/src/deformmeasure.ts +377 -154
  71. package/src/deformreport.ts +3 -0
  72. package/src/deformstructure.ts +344 -0
  73. package/src/emit_spine.ts +21 -41
  74. package/src/keyorder.ts +40 -0
  75. package/src/model.ts +188 -22
  76. package/src/render.ts +243 -54
  77. package/src/render_core.ts +101 -13
  78. package/src/transform.ts +79 -30
  79. package/src/validate.ts +742 -2781
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What kind of rule each assertion is, and which profile runs which kind
3
+ * (issue #1025, step 4c of #380).
4
+ *
5
+ * The table lives here rather than in `src/validate.ts` because two harnesses
6
+ * read it now: `validate()`'s `check`, which runs an assertion over what
7
+ * spine-core loaded, and the model side's (`./harness.ts`), which runs the
8
+ * same body over the model document and the core. A profile has to exclude
9
+ * the same rules on both sides, and two copies of the table is how one of
10
+ * them would come to exclude a rule the other runs. This module links nothing
11
+ * from the runtime, which is the condition for the model side to read it.
12
+ */
13
+
14
+ /** The two rulebooks — `ValidateProfile` in `src/validate.ts`, where the choice between them is argued. */
15
+ export type AssertionProfile = 'spine' | 'spine-html';
16
+
17
+ /** The three kinds a rule can be; the table's own comment says what each means. */
18
+ export type AssertionKind = 'validity' | 'renderer' | 'archetype';
19
+
20
+ /**
21
+ * What kind of rule each assertion is. Every assertion has an entry, and
22
+ * `check()` throws on a name that has none — a new assertion must state its kind
23
+ * rather than defaulting into one, because the default would decide, silently,
24
+ * whether it runs on foreign data.
25
+ *
26
+ * validity — the file is wrong for any consumer. Runs under every profile.
27
+ * renderer — valid Spine that this project's renderer or frame budget refuses.
28
+ * archetype — a structural rule about rigc's own formations, meaningless to a
29
+ * skeleton rigc did not compile.
30
+ *
31
+ * Two assertions are MIXED and are marked `validity` here because their
32
+ * validity half must never stop running; their policy clauses are gated inside
33
+ * the assertion body against `profile`, and each such clause says so where it
34
+ * lives. They are A06 (size-vs-PNG and every region's rectangle inside the page
35
+ * it names are validity; pma / rotation / two regions over the same texels are
36
+ * policy — the rectangle moved across that line in issue #694, and the reason
37
+ * is stated where it now sits) and A20 (weight coherence is validity; requiring
38
+ * a mesh to be weighted at all is policy).
39
+ *
40
+ * A08 was the third until issue #574 retired its policy clause. It required a
41
+ * skin entry's placeholder to be spelled like the region it resolves to, under
42
+ * a renderer that resolves art by `path` and has never read a placeholder — so
43
+ * restating it as the renderer's own join made it a tautology over the validity
44
+ * half. The argument, with the renderer lines it is measured against, sits above
45
+ * `check('A08_…')`.
46
+ */
47
+ export const ASSERTION_KIND: Readonly<Record<string, AssertionKind>> = {
48
+ A00_ROUNDTRIP_PARSE: 'validity',
49
+ A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS: 'validity',
50
+ A02_NO_BONE_TRANSFORM_KEY: 'validity',
51
+ A03_REGION_WIDTH_HEIGHT_FINITE: 'validity',
52
+ A04_MESH_TRIANGLES_AND_ENCODING: 'validity',
53
+ A05_CURVE_ARRAY_LENGTH: 'validity',
54
+ A06_ATLAS_PAGE_SIZE_MATCHES_PNG: 'validity', // mixed — see above
55
+ A07_ATLAS_TEXT_SHAPE: 'validity',
56
+ A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // no longer mixed — see above (#574)
57
+ A09_ANIMATION_DURATION_MATCHES_SPEC: 'validity',
58
+ A10_NO_NAN_AFTER_STEPPING: 'validity',
59
+ A11_NO_CLIPPING_ATTACHMENTS: 'renderer',
60
+ A12_NO_DARK_COLOR: 'renderer',
61
+ A13_MESH_BUDGET: 'renderer',
62
+ A14_NO_FULL_FRAME_MESH: 'renderer',
63
+ A15_IDLE_NO_MESH_BONE_KEYS: 'renderer',
64
+ A16_SKELETON_VERSION_4_3: 'validity',
65
+ A17_ATLAS_PAGE_FILES_EXIST: 'validity',
66
+ A18_DETERMINISTIC_EMIT: 'validity',
67
+ A19_OVERLAY_PNGS_HAVE_ALPHA: 'renderer',
68
+ A20_MESH_WEIGHTS_COHERENT: 'validity', // mixed — see above
69
+ A21_MESH_RIM_PINNED: 'archetype',
70
+ A22_MESH_UVS_IN_UNIT_RANGE: 'validity',
71
+ A23_PHYSICS_CONSTRAINT_EFFECTIVE: 'validity',
72
+ A24_AXIS_SPACE_STROKE: 'archetype',
73
+ A25_DETACHED_BONE_PARENTAGE: 'archetype',
74
+ A26_SLOT_DRAW_ORDER: 'archetype',
75
+ A27_REGION_NAME_MATCHES_PAGE_FILENAME: 'renderer',
76
+ A28_RIBBON_ROWS_SHARE_WEIGHTS: 'archetype',
77
+ A29_STROKE_WITHIN_CONTACT_DEPTH: 'archetype',
78
+ A30_STROKE_WITHIN_CAP_CONTAINMENT: 'archetype',
79
+ A31_DRAW_ORDER_OFFSETS_RESOLVE: 'validity',
80
+ A32_EVENT_KEYS_RESOLVE: 'validity',
81
+ A33_VERTEX_ATTACHMENT_GEOMETRY: 'validity',
82
+ A34_CONSTRAINT_TIMELINE_TARGETS: 'validity',
83
+ A35_DEFORM_KEYS_FIT_THE_ATTACHMENT: 'validity',
84
+ A36_PATH_CONSTRAINT_EFFECTIVE: 'validity',
85
+ A37_SLIDER_CONSTRAINT_EFFECTIVE: 'validity',
86
+ A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED: 'validity',
87
+ A39_DEFORM_KEEPS_TRIANGLE_WINDING: 'archetype',
88
+ A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET: 'validity',
89
+ A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP: 'validity',
90
+ A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
91
+ A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
92
+ A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
93
+ A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
94
+ A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
95
+ A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
96
+ A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
97
+ };
98
+
99
+ /** Whether `profile` runs a rule of `kind`: `spine` runs validity alone, `spine-html` every kind. */
100
+ export function kindRunsUnder(kind: AssertionKind, profile: AssertionProfile): boolean {
101
+ return kind === 'validity' || profile === 'spine-html';
102
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What built a mesh, as the bodies that hold generator topology to its own
3
+ * rules ask it (issue #1025, step 4c of #380) — `A20`, `A21` and `A28`. Moved
4
+ * here from `validate()`'s closures unchanged but for what they read: a mesh
5
+ * entry of `./facts/mesh_attachments.ts` rather than a loaded attachment and
6
+ * the prelude's link map. Links nothing from the runtime.
7
+ */
8
+ import type { RigInfo } from '../types.ts';
9
+ import type { MeshEntry } from './facts/mesh_attachments.ts';
10
+
11
+ /**
12
+ * What built this mesh. ring unless the rig says otherwise; absent rig info
13
+ * reads as ring (legacy).
14
+ *
15
+ * 🚨 `authored` is not a third topology, it is the ABSENCE of one rigc may
16
+ * assume. Geometry that came in through the rig spec was drawn by somebody
17
+ * with an editor, and its rim, its row pairing and its entry edge are
18
+ * whatever that person made them. The `||` fallback below used to hand such
19
+ * a mesh the string `ring`, and A21 then checked ring topology on a shape
20
+ * that was never a ring — 40 failures on correct data (issue #44).
21
+ *
22
+ * 🔗 A LINKED mesh borrows another attachment's geometry, so no generator
23
+ * topology is a claim about THIS attachment — and the ARTIFACT says which
24
+ * ones they are (the entry's `link`, read off the file's own `source` keys).
25
+ * It is read from there rather than off `meshKinds` for two reasons, and the
26
+ * second is the load-bearing one.
27
+ *
28
+ * 1. `meshKinds` is keyed by SLOT, and a link's slot is not its
29
+ * geometry's: a link to a `ring` in another slot looked up the LINK's
30
+ * slot, found nothing, and took the `|| 'ring'` fallback — issue #44's
31
+ * own default, reached by a new route. Measured before this clause on
32
+ * a correct rig (a ring on slot "sa", a link to it on slot "sb"):
33
+ * **8 A21 failures**, `mesh "sb" rim vertex 0 is pinned to "a", not
34
+ * the slot bone "b"`, one per hull vertex. The rim is pinned exactly
35
+ * where the ring's own slot put it, which is the only place it could
36
+ * be.
37
+ * 2. Writing the link into `meshKinds` instead would have overwritten
38
+ * the source's kind wherever the two share a slot, which is the
39
+ * commonest linked mesh there is — silencing A21 on the mesh rigc
40
+ * DID build. A gate turned off by a feature is worse than a gate
41
+ * that skips something it cannot measure.
42
+ */
43
+ export function meshKindOf(mesh: MeshEntry, rig: RigInfo | undefined): RigInfo['meshKinds'][string] {
44
+ if (mesh.link !== null) return 'authored';
45
+ return rig?.meshKinds[mesh.slot] || 'ring';
46
+ }
47
+
48
+ /**
49
+ * Meshes whose topology is not rigc's to have an opinion about, by name and
50
+ * with the reason each one is on the list — the string several skips need.
51
+ */
52
+ export function authoredMeshNames(list: readonly MeshEntry[], rig: RigInfo | undefined): string[] {
53
+ return list
54
+ .filter((m) => meshKindOf(m, rig) === 'authored')
55
+ .map((m) => (m.link === null ? `"${m.name}"` : `"${m.name}" (linked to "${m.link.source}")`));
56
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The model side's supply of `AnimatedBoneFacts` (issue #1025, step 4c of
3
+ * #380): an animation's bone timelines as the document lists them.
4
+ *
5
+ * The document writes each animation's `bones` as a list in the model's map
6
+ * order, and the emitter writes the Spine file's `bones` group from that same
7
+ * map, entry for entry, leaving the group out when it is empty
8
+ * (`emitAnimation`'s `if (animation.bones.size)`) — so the list's order is the
9
+ * file's, an empty list is a file with no `bones` group, and an animation the
10
+ * document does not hold is one the file does not key. The first animation of
11
+ * a name answers, as the file's object would hold only one.
12
+ *
13
+ * Links nothing from the runtime.
14
+ */
15
+ import type { AnimatedBoneFacts } from '../facts/animated_bones.ts';
16
+ import { isObj } from '../values.ts';
17
+ import type { ReadDocument } from './parse.ts';
18
+
19
+ export function modelAnimatedBones(read: ReadDocument): AnimatedBoneFacts {
20
+ const animations = Array.isArray(read.json.animations) ? read.json.animations.filter(isObj) : [];
21
+ return {
22
+ bonesKeyedBy: (name) => {
23
+ const animation = animations.find((a) => a.name === name);
24
+ if (animation === undefined) return undefined;
25
+ const bones = Array.isArray(animation.bones) ? animation.bones.filter(isObj) : [];
26
+ if (bones.length === 0) return null;
27
+ return bones.map((bone) => String(bone.name));
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The model side's supply of `AtlasPageFacts` (issue #1025, step 4c of #380):
3
+ * the document's `pages` section, which `build` spells from the atlas text it
4
+ * writes (`pagesOfAtlas` in `src/model.ts`), so the page names are the ones
5
+ * the written atlas names, in its order and trimmed as the parser trims them.
6
+ * A `rigc-compiled/1` document states no pages, and reads as no atlas.
7
+ *
8
+ * Links nothing from the runtime.
9
+ */
10
+ import type { AtlasPageFacts } from '../facts/atlas_pages.ts';
11
+ import type { ReadDocument } from './parse.ts';
12
+
13
+ export function modelAtlasPages(read: ReadDocument): AtlasPageFacts {
14
+ return { atlas: read.doc.pages === null ? null : { pages: read.doc.pages.map((page) => ({ name: page.name })) } };
15
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The model side's supply of `AtlasRegionFacts` (issue #1025, step 4c of
3
+ * #380): the document's `pages` section — which `build` spells from the atlas
4
+ * text it writes (`pagesOfAtlas` in `src/model.ts`, the reader `PKR01` holds
5
+ * field for field to spine-core's `TextureAtlas`) — with each page's `pma` as
6
+ * a `rigc-compiled/3` document states it (issue #1026), or, for a `/2`
7
+ * document, which does not, as the caller gives it.
8
+ *
9
+ * - The pages and their regions in the document's order, which is the atlas
10
+ * file's; each region carries the page it sits on.
11
+ * - `findRegion` is the core's lookup, called: `documentPageLookup`
12
+ * (`src/core/uvs.ts`), the first region of exactly that spelling with pages
13
+ * in order — the runtime's `findRegion`, measured there.
14
+ * - A `rigc-compiled/1` document states no pages, and reads as no atlas.
15
+ *
16
+ * ⚠️ On a `/2` document `pma` is the one value here the document does not
17
+ * hold. A caller that gives none, or gives a list that is not one value per
18
+ * page, is refused by name rather than read as `false`: a page's claim the
19
+ * caller did not state is not a claim of straight alpha. On a `/3` document a
20
+ * caller that gives one anyway is refused too (`refuseGivenBeside`): the
21
+ * document is the statement, and a second source for the same fact is how a
22
+ * verdict comes to read one value while the document says another.
23
+ *
24
+ * Links nothing from the runtime.
25
+ */
26
+ import { documentPageLookup } from '../../core/uvs.ts';
27
+ import type { ModelPageRegion } from '../../model.ts';
28
+ import type { AtlasRegionEntry, AtlasRegionFacts, AtlasRegionPage } from '../facts/atlas_regions.ts';
29
+ import type { ReadDocument } from './parse.ts';
30
+ import { refuseGivenBeside, type ModelGiven } from './given.ts';
31
+
32
+ export function modelAtlasRegions(read: ReadDocument, given: ModelGiven | undefined): AtlasRegionFacts {
33
+ refuseGivenBeside(read, given);
34
+ const pages = read.doc.pages;
35
+ if (pages === null) return { atlas: null };
36
+ // A rigc-compiled/3 document states each page's pma (issue #1026); `readModel` requires it on every /3 page.
37
+ const pma = read.doc.stated !== null ? pages.map((page) => page.pma) : given?.pma;
38
+ if (pma === undefined || pma.length !== pages.length || pma.some((v) => typeof v !== 'boolean')) {
39
+ throw new Error(`the caller gave ${pma === undefined ? 'no' : pma.length} page pma value(s) for the document's ${pages.length} page(s); a ${read.doc.spec} document does not state pma, so the model side cannot read a page without it`);
40
+ }
41
+ const outPages: AtlasRegionPage[] = [];
42
+ const regions: AtlasRegionEntry[] = [];
43
+ const byRecord = new Map<ModelPageRegion, AtlasRegionEntry>();
44
+ pages.forEach((page, i) => {
45
+ const at: AtlasRegionPage = { name: page.name, width: page.width, height: page.height, pma: pma[i] === true };
46
+ outPages.push(at);
47
+ for (const region of page.regions) {
48
+ const entry: AtlasRegionEntry = {
49
+ name: region.name,
50
+ page: at,
51
+ x: region.x,
52
+ y: region.y,
53
+ width: region.width,
54
+ height: region.height,
55
+ degrees: region.degrees,
56
+ offsetX: region.offsetX,
57
+ offsetY: region.offsetY,
58
+ originalWidth: region.originalWidth,
59
+ originalHeight: region.originalHeight,
60
+ };
61
+ regions.push(entry);
62
+ byRecord.set(region, entry);
63
+ }
64
+ });
65
+ const lookup = documentPageLookup(pages);
66
+ return {
67
+ atlas: {
68
+ pages: outPages,
69
+ regions,
70
+ findRegion: (name) => {
71
+ const found = lookup(name);
72
+ return found === null ? null : (byRecord.get(found.region) ?? null);
73
+ },
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The model side's supply of `ConstraintFacts` (issue #1025, step 4c of
3
+ * #380): the document's constraints in update order, the constraint
4
+ * timelines of its animations in the order the runtime builds them, and the
5
+ * slots its skins give a path.
6
+ *
7
+ * Every value is the one the runtime holds after its parse, through a
8
+ * function rigc already runs:
9
+ *
10
+ * - **A constraint's fields** are the core's records (`readModel` →
11
+ * `readConstraintRecord`, `readPathRecord`, `readPhysicsRecord`,
12
+ * `readSliderRecord`), which read a field the record leaves out at the
13
+ * parser's value; a physics record's `massInverse` and `step` are the
14
+ * reader's `1 / mass` and `1 / fps`. A transform constraint reads a mix
15
+ * exactly where one of its `to` entries names that property — the core's
16
+ * `properties`, by `TRANSFORM_MIXES`.
17
+ * - **A slider's animation** is the core's reading of it: its duration is
18
+ * `CoreAnimationTimelines.duration` (the last key time, float32 — the
19
+ * runtime's), and the timelines it carries are the bone and slot timelines
20
+ * plus the count of every later group the reader counts.
21
+ * - **The timelines**: the animations in the file's order
22
+ * (`editorAnimationOrder`, the function the emitter is handed); in each the
23
+ * groups in the runtime's order (`../facts/constraints.ts`' ⭐) and each
24
+ * group's entries and timelines in the document's order, which the emitter
25
+ * writes unchanged. A key's time and value are the core's (float32), and a
26
+ * Bézier segment's samples `bezierPolyline` over the numbers the document
27
+ * states — the runtime's curve, as the core reproduces it.
28
+ * - **The reach of a physics timeline naming none** is asked of the core's
29
+ * own `posedPhysics` rather than restated: the timeline is posed once with
30
+ * a key the setup cannot hold (NaN), and the constraints it reached are the
31
+ * ones whose pose field took it; a `reset` timeline's reach is the set
32
+ * `posedPhysics` returns.
33
+ *
34
+ * ⚠️ One value is not the document's: `runtimeClass`, the runtime's class
35
+ * name for a kind, which A42's sentence names `update` on. The document has no
36
+ * field for it and rigc has no function returning it, so it is the table
37
+ * below; the selftest compares it with the class spine-core loaded on every
38
+ * call that reaches a constraint (`VF10`).
39
+ *
40
+ * Links nothing from the runtime.
41
+ */
42
+ import { editorAnimationOrder } from '../../compile.ts';
43
+ import { bezierPolyline, type CoreAnimationTimelines, type CoreKey } from '../../core/animation.ts';
44
+ import { TRANSFORM_MIXES, TRANSFORM_PROPERTIES, type CoreConstraintRecord } from '../../core/constraints.ts';
45
+ import { EVERY_GLOBAL_PHYSICS, posedPhysics, type CorePhysicsRecord, type PhysicsTimelineKind } from '../../core/constraints_physics.ts';
46
+ import type { CompiledDocument } from '../../core/index.ts';
47
+ import { physicsRuleFor } from '../../timelines.ts';
48
+ import type { ConstraintEntry, ConstraintFacts, ConstraintKind, ConstraintTimeline } from '../facts/constraints.ts';
49
+ import { isObj, type Json } from '../values.ts';
50
+ import { fileOrderedEntries } from './skin_entries.ts';
51
+ import type { ReadDocument } from './parse.ts';
52
+
53
+ /** The runtime's class for each kind — the one value here that is not the document's (the header's ⚠️). */
54
+ export const RUNTIME_CONSTRAINT_CLASS: Readonly<Record<ConstraintKind, string>> = {
55
+ ik: 'IkConstraint',
56
+ transform: 'TransformConstraint',
57
+ path: 'PathConstraint',
58
+ physics: 'PhysicsConstraint',
59
+ slider: 'Slider',
60
+ };
61
+
62
+ /** How many timelines the runtime builds for an animation the core read: every bone and slot timeline, and every later group's count. */
63
+ export function timelineCount(timelines: CoreAnimationTimelines): number {
64
+ let n = 0;
65
+ for (const target of [...timelines.bones, ...timelines.slots]) n += target.timelines.length;
66
+ for (const [, count] of timelines.later) n += count;
67
+ return n;
68
+ }
69
+
70
+ /** Every value one channel of `keys` poses: each key's own, then the samples of the Bézier segment it opens. */
71
+ export function channelValuesOf(keys: readonly CoreKey[], channel: number): number[] {
72
+ const out: number[] = [];
73
+ keys.forEach((a, i) => {
74
+ out.push(a.values[channel]);
75
+ const b = keys[i + 1];
76
+ if (b === undefined || !Array.isArray(a.curve)) return;
77
+ const c = a.curve.slice(channel * 4, channel * 4 + 4);
78
+ const inner = bezierPolyline(a.stated.time, a.stated.values[channel], c[0], c[1], c[2], c[3], b.stated.time, b.stated.values[channel]);
79
+ for (let p = 1; p < inner.length; p += 2) out.push(inner[p]);
80
+ });
81
+ return out;
82
+ }
83
+
84
+ /** The physics constraints a timeline naming none reaches, asked of `posedPhysics` (the header). */
85
+ export function unnamedReach(records: readonly CorePhysicsRecord[], kind: PhysicsTimelineKind): Set<string> {
86
+ if (kind === 'reset') {
87
+ const keys: CoreKey[] = [{ time: 0, values: [], stated: { time: 0, values: [] }, curve: 'stepped' }];
88
+ return posedPhysics(records, [{ name: EVERY_GLOBAL_PHYSICS, kind, keys }], 0, -1, () => true).reset;
89
+ }
90
+ const keys: CoreKey[] = [{ time: 0, values: [Number.NaN], stated: { time: 0, values: [Number.NaN] }, curve: 'linear' }];
91
+ const posed = posedPhysics(records, [{ name: EVERY_GLOBAL_PHYSICS, kind, keys }], 0, -1, () => true).records;
92
+ const field = kind === 'mass' ? 'massInverse' : kind;
93
+ return new Set([...posed.values()].filter((r) => Number.isNaN(r[field])).map((r) => r.name));
94
+ }
95
+
96
+ function entryOf(doc: CompiledDocument, kind: ConstraintKind, name: string, record: CoreConstraintRecord): ConstraintEntry {
97
+ const base = { kind, name, runtimeClass: RUNTIME_CONSTRAINT_CLASS[kind] };
98
+ switch (record.kind) {
99
+ case 'physics':
100
+ return {
101
+ ...base,
102
+ physics: {
103
+ bone: record.bone,
104
+ components: { x: record.x, y: record.y, rotate: record.rotate, scaleX: record.scaleX, shearX: record.shearX },
105
+ setup: { mix: record.mix, massInverse: record.massInverse, strength: record.strength, damping: record.damping },
106
+ step: record.step,
107
+ },
108
+ };
109
+ case 'path':
110
+ return { ...base, path: { bones: record.bones, slot: record.slot, setup: { mixRotate: record.mixRotate, mixX: record.mixX, mixY: record.mixY } } };
111
+ case 'slider': {
112
+ const anim = doc.animations.find((a) => a.name === record.animation);
113
+ return {
114
+ ...base,
115
+ slider: {
116
+ animation: anim === undefined ? null : { name: anim.name, timelines: timelineCount(anim.timelines), duration: anim.timelines.duration },
117
+ bone: record.bone,
118
+ loop: record.loop,
119
+ scale: record.scale,
120
+ mix: record.mix,
121
+ },
122
+ };
123
+ }
124
+ case 'ik':
125
+ return { ...base, ik: { bones: record.bones, target: record.target, mix: record.mix } };
126
+ case 'transform':
127
+ return {
128
+ ...base,
129
+ transform: {
130
+ bones: record.bones,
131
+ mixes: TRANSFORM_PROPERTIES.map((p) => (record.properties.some((from) => from.to.some((to) => to.property === p)) ? { field: TRANSFORM_MIXES[p], setup: record.mixes[p] } : null)),
132
+ },
133
+ };
134
+ }
135
+ }
136
+
137
+ /** One constraint timeline as the model side walks it: where it is, what it keys, and the core's keys for it. */
138
+ export interface ModelConstraintTimelineSource {
139
+ animation: string;
140
+ kind: ConstraintKind;
141
+ word: string;
142
+ /** The constraint it names, or `EVERY_GLOBAL_PHYSICS`. */
143
+ name: string;
144
+ keys: readonly CoreKey[];
145
+ }
146
+
147
+ /** Every constraint timeline of the document, in the order the runtime builds them (the header) — the walk `modelConstraintFacts` reads, and the selftest's measurement of each derivation. */
148
+ export function modelConstraintTimelineSources(read: ReadDocument): ModelConstraintTimelineSource[] {
149
+ const { doc } = read;
150
+ const jsonAnimations = Array.isArray(read.json.animations) ? read.json.animations.filter(isObj) : [];
151
+ const out: ModelConstraintTimelineSource[] = [];
152
+ for (const name of editorAnimationOrder(doc.animations.map((a) => a.name))) {
153
+ const anim = doc.animations.find((a) => a.name === name);
154
+ if (anim === undefined) continue;
155
+ const c = anim.constraints;
156
+ for (const tl of c.ik) out.push({ animation: name, kind: 'ik', word: 'ik', name: tl.name, keys: tl.keys });
157
+ for (const tl of c.transform) out.push({ animation: name, kind: 'transform', word: 'transform', name: tl.name, keys: tl.keys });
158
+ for (const entry of c.path) {
159
+ for (const word of Object.keys(entry)) {
160
+ if (word === 'position' || word === 'spacing' || word === 'mix') out.push({ animation: name, kind: 'path', word, name: entry.name, keys: entry[word] ?? [] });
161
+ }
162
+ }
163
+ for (const tl of c.physicsKeyed ?? []) out.push({ animation: name, kind: 'physics', word: tl.kind, name: tl.name, keys: tl.keys });
164
+ // A slider entry's timelines in the order the document lists them, which `readSliderTimelines` keeps by name only.
165
+ const jsonSliders = ((): Json[] => {
166
+ const json = jsonAnimations.find((a) => a.name === name)?.constraints;
167
+ return isObj(json) && Array.isArray(json.slider) ? json.slider.filter(isObj) : [];
168
+ })();
169
+ c.slider.forEach((entry, i) => {
170
+ const listed = Array.isArray(jsonSliders[i]?.timelines) ? (jsonSliders[i].timelines as unknown[]).filter(isObj).map((t) => t.name) : [];
171
+ for (const word of listed) {
172
+ if (word === 'time' || word === 'mix') out.push({ animation: name, kind: 'slider', word, name: entry.name, keys: entry[word] ?? [] });
173
+ }
174
+ });
175
+ }
176
+ return out;
177
+ }
178
+
179
+ export function modelConstraintFacts(read: ReadDocument): ConstraintFacts {
180
+ const { doc } = read;
181
+ const constraints: ConstraintEntry[] = doc.constraints.map((c) => {
182
+ if (c.record === undefined) throw new Error(`internal: ${c.kind} constraint "${c.name}" is one the reader returned without its record`);
183
+ return entryOf(doc, c.kind, c.name, c.record);
184
+ });
185
+ const indexOf = (kind: ConstraintKind, name: string): number => doc.constraints.findIndex((c) => c.kind === kind && c.name === name);
186
+ const physicsRecords = doc.constraints.flatMap((c) => (c.record?.kind === 'physics' ? [c.record] : []));
187
+ const timelines: ConstraintTimeline[] = modelConstraintTimelineSources(read).map(({ animation, kind, word, name, keys }) => {
188
+ const constraint = name === EVERY_GLOBAL_PHYSICS && kind === 'physics' ? -1 : indexOf(kind, name);
189
+ const reach = constraint >= 0 ? [constraint] : [...unnamedReach(physicsRecords, word as PhysicsTimelineKind)].map((n) => indexOf('physics', n)).sort((a, b) => a - b);
190
+ const rule = kind === 'physics' ? physicsRuleFor(word) : undefined;
191
+ return {
192
+ animation,
193
+ kind,
194
+ word,
195
+ constraint,
196
+ reach,
197
+ frames: word === 'reset' ? [] : keys.map((k) => ({ time: k.time, value: k.values[0] })),
198
+ channelValues: (channel: number) => (word === 'reset' ? [] : channelValuesOf(keys, channel)),
199
+ ...(rule === undefined ? {} : { posed: (value: number) => rule.toPose(value) }),
200
+ };
201
+ });
202
+ const pathSlots: string[] = [];
203
+ for (const entry of fileOrderedEntries(read)) {
204
+ if (entry.record.kind === 'path' && !pathSlots.includes(entry.slot)) pathSlots.push(entry.slot);
205
+ }
206
+ return { animations: doc.animations.length, constraints, timelines, pathSlots };
207
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * What the model side is given beside the document (issue #1025, step 4c of
3
+ * #380): the two values the census found the document does not hold, which a
4
+ * moved assertion reads — the stage (A14, A19) and each atlas page's `pma`
5
+ * (A06). Both entered the document with issue #1026 (`rigc-compiled/3`):
6
+ * a `/3` document is read for them and this input is for a `/2` or `/1`
7
+ * document alone — given beside a `/3` document it is refused by name
8
+ * (`refuseGivenBeside`) rather than ignored, because a value the caller states
9
+ * beside the document's own is a second source for one fact, and a silent
10
+ * winner between two sources is how a verdict comes to pass for the wrong
11
+ * reason.
12
+ *
13
+ * ⛔ **The model side never reads them off a Spine file itself.** A verdict that
14
+ * read `skeleton.json` would be a second reading of the encoding, which is the
15
+ * round trip's subject; so whoever calls the model side states them, from what
16
+ * it holds. The selftest's supplier check and `tools/verdict_gate.ts` hold the
17
+ * build they gate, and read both off it with one function
18
+ * (`modelGivenOfBuild` in that tool): the stage the skeleton header the build
19
+ * wrote states, and each page's `pma` as rigc's own atlas reader
20
+ * (`parseAtlasText`) reads the atlas the build wrote.
21
+ *
22
+ * Links nothing from the runtime.
23
+ */
24
+
25
+ import type { ReadDocument } from './parse.ts';
26
+
27
+ /**
28
+ * A caller's `given` beside a document that states both values itself
29
+ * (`rigc-compiled/3`, issue #1026), refused by name: the caller is to give
30
+ * them for a `/2` or `/1` document only (`modelGivenOf` in
31
+ * `tools/verdict_gate.ts` gives them exactly then).
32
+ */
33
+ export function refuseGivenBeside(read: ReadDocument, given: ModelGiven | undefined): void {
34
+ if (given !== undefined && read.doc.stated !== null) {
35
+ throw new Error(`the caller gave the stage and the pages' pma beside a ${read.doc.spec} document, which states both itself (issue #1026); a second source for one fact is refused rather than one of them silently read`);
36
+ }
37
+ }
38
+
39
+ /** The stage and the pages' `pma`, as the caller states them. */
40
+ export interface ModelGiven {
41
+ /** The stage the skeleton states — each extent where it states one — or `null` where it states none. */
42
+ readonly stage: { readonly width?: number; readonly height?: number } | null;
43
+ /** Each page's `pma`, in the atlas's page order: one value per page of the document's `pages`. */
44
+ readonly pma: readonly boolean[];
45
+ }