rig-c 0.0.0-stage → 2.20.4
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 +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
package/src/ingest.ts
ADDED
|
@@ -0,0 +1,2293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ingest — Spine 4.3 skeleton JSON back into a rig spec and a motion spec.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is, and what makes it checkable
|
|
5
|
+
*
|
|
6
|
+
* `build` turns two spec files into a skeleton. This turns a skeleton back into
|
|
7
|
+
* two spec files, so that the contract is an **equality against the file it was
|
|
8
|
+
* read from**: `build(ingest(A)) === A`, byte for byte on `skeleton.json`. Every
|
|
9
|
+
* other gate in this repository compares rigc to rigc — the compiler against the
|
|
10
|
+
* validator, one compile against a second (`A18`), the emitter against its own
|
|
11
|
+
* assertions. This one compares rigc's output against an input rigc did not
|
|
12
|
+
* write, which is the only reference of that kind the tree has.
|
|
13
|
+
*
|
|
14
|
+
* ⇒ So every function below is an **inversion of one named function in
|
|
15
|
+
* [`compile.ts`](compile.ts)**, and each says which. That citation is what makes
|
|
16
|
+
* the module reviewable: a reader checks the pair, not the prose.
|
|
17
|
+
*
|
|
18
|
+
* ## The rule it is held to
|
|
19
|
+
*
|
|
20
|
+
* 🔒 **A decompiler never invents a value the skeleton does not carry.** It is
|
|
21
|
+
* CLAUDE.md's *"the compiler never invents a value that is not in the spec"*,
|
|
22
|
+
* mirrored — and the mirror is where a decompiler's defects live, because a
|
|
23
|
+
* plausible guess here produces a spec that compiles, gates green and says
|
|
24
|
+
* something nobody wrote. Where the skeleton cannot answer, this records a
|
|
25
|
+
* **finding** with a code and writes nothing: `findings` is the product, not a
|
|
26
|
+
* log. Exactly two values are not in a skeleton at all (the stage and an
|
|
27
|
+
* animation's duration), and a value somebody supplies for either is a
|
|
28
|
+
* `judgement` finding — a stage the file does not declare is carried as the
|
|
29
|
+
* absence it is, and only a caller's `--stage` is somebody deciding (issue
|
|
30
|
+
* #714); every construct the spec format cannot hold is a `blocker`; everything
|
|
31
|
+
* rigc re-derives rather than carries is `lossy`. The one thing it refuses outright rather than recording is
|
|
32
|
+
* an OPTION that contradicts the file — see `IngestError`.
|
|
33
|
+
*
|
|
34
|
+
* ## What it does not read
|
|
35
|
+
*
|
|
36
|
+
* Skeleton JSON, and nothing else. Not the atlas, not a `.spine` project, not a
|
|
37
|
+
* binary `.skel`, not the art. A rig spec's texture side is therefore the
|
|
38
|
+
* caller's (`IngestOptions.art`) and is stated as such.
|
|
39
|
+
*
|
|
40
|
+
* ## Purity
|
|
41
|
+
*
|
|
42
|
+
* No clock, no randomness, no filesystem, no network, and no `spine-core` — the
|
|
43
|
+
* three files allowed to link the runtime are named in CLAUDE.md and this is not
|
|
44
|
+
* one of them (`CUR07` refuses a fourth). The provenance `note` therefore carries
|
|
45
|
+
* a version the caller passes in and **no timestamp**, because a timestamp would
|
|
46
|
+
* break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
|
|
47
|
+
* spec.
|
|
48
|
+
*/
|
|
49
|
+
import {
|
|
50
|
+
PHYSICS_COMPONENTS,
|
|
51
|
+
SLOT_TRACKS as EMITTED_SLOT_TRACKS,
|
|
52
|
+
SPINE_VERSION,
|
|
53
|
+
} from './compile.ts';
|
|
54
|
+
import { CompileError } from './errors.ts';
|
|
55
|
+
import { physicsDrives } from './core/constraints_physics.ts';
|
|
56
|
+
import { CHANNELS_BY_KIND } from './timelines.ts';
|
|
57
|
+
import {
|
|
58
|
+
LEGACY_BONE_INHERIT_KEY,
|
|
59
|
+
PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED,
|
|
60
|
+
SPINE_GENERATIONS,
|
|
61
|
+
spineGeneration,
|
|
62
|
+
TOPLEVEL_CONSTRAINT_ARRAYS,
|
|
63
|
+
} from './generation.ts';
|
|
64
|
+
import { EVERY_GLOBAL_PHYSICS, MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
|
|
65
|
+
import { PARSER_DEFAULTS, parserOmits } from './keyorder.ts';
|
|
66
|
+
import { constraintAt, parseRigSpec, resolveBoneInherit, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
|
|
67
|
+
import type { MotionSpec } from './types.ts';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The invocation this module refuses outright, rather than recording.
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ **A finding is about the FILE; this is about the call.** Everything below
|
|
73
|
+
* that a skeleton cannot answer is a `finding` and the specs are still written,
|
|
74
|
+
* because a spec plus a list of what is missing from it beats no spec. An
|
|
75
|
+
* `IngestError` is the other thing: an option that contradicts the file it was
|
|
76
|
+
* given, where writing anything at all would be writing something the caller did
|
|
77
|
+
* not ask for. It is one re-run away from everything, which is what makes
|
|
78
|
+
* refusing cheaper than recording here (issue #626).
|
|
79
|
+
*/
|
|
80
|
+
export class IngestError extends Error {
|
|
81
|
+
constructor(message: string) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'IngestError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The specs this module wrote, and the tree's own parser refusing one of them.
|
|
89
|
+
*
|
|
90
|
+
* 🚨 **A parser refusal mid-`ingest` used to be the run's last word** (issue
|
|
91
|
+
* #692). `parseRigSpec` throws a `CompileError`, it went straight out through
|
|
92
|
+
* `ingest()`, and the run exited 1 with **no `BLOCK` line, no code and no
|
|
93
|
+
* `findings.json` on disk** — so a census that counts finding codes read those
|
|
94
|
+
* files as refused for no stated reason. A shape the spec format cannot hold is
|
|
95
|
+
* exactly what a coded `blocker` is for, and the code rides in `findings` here
|
|
96
|
+
* with everything else the run found.
|
|
97
|
+
*
|
|
98
|
+
* It carries the two spec objects because they are what the parser was given:
|
|
99
|
+
* writing them is what lets the sentence be read against a file rather than
|
|
100
|
+
* against the console. They are the same objects a successful run returns —
|
|
101
|
+
* `parseRigSpec` hands its input back typed — so the bytes on disk do not depend
|
|
102
|
+
* on which way the parse went.
|
|
103
|
+
*/
|
|
104
|
+
export class IngestSpecRefused extends Error {
|
|
105
|
+
constructor(
|
|
106
|
+
message: string,
|
|
107
|
+
readonly findings: IngestFinding[],
|
|
108
|
+
readonly rig: JsonObject,
|
|
109
|
+
readonly motion: JsonObject,
|
|
110
|
+
) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.name = 'IngestSpecRefused';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// findings
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What a finding is about, which is also what the caller's exit code turns on.
|
|
122
|
+
*
|
|
123
|
+
* - `blocker` — the spec format cannot say this, so the rebuilt skeleton will
|
|
124
|
+
* NOT be the one that was read. Non-zero exit.
|
|
125
|
+
* - `judgement` — the skeleton does not carry it and somebody decided. There
|
|
126
|
+
* are exactly two: a stage the caller supplied, and an animation's duration.
|
|
127
|
+
* - `lossy` — the skeleton's spelling and rigc's differ, on purpose, and the
|
|
128
|
+
* difference is named: a value rigc re-derives rather than takes (the
|
|
129
|
+
* `spine` version), a field the spec has no home for (`hash`), or
|
|
130
|
+
* a default the source left to the format and the rebuild writes out
|
|
131
|
+
* (`HEADER_ORIGIN`, issue #622). The rebuilt file is a different file in that
|
|
132
|
+
* field; it is not a different rig.
|
|
133
|
+
*/
|
|
134
|
+
export type IngestFindingKind = 'blocker' | 'judgement' | 'lossy';
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The gutter each kind prints under — the token a reader meets before the code,
|
|
138
|
+
* and the column `docs/INGEST.md` §2.0's finding-code table is keyed on.
|
|
139
|
+
*
|
|
140
|
+
* ⭐ Exported for the reason `SLOT_TRACKS` below is read off `compile.ts`
|
|
141
|
+
* (issue #650): it was a local table inside `cmdIngest`, so the CLI that prints
|
|
142
|
+
* a gutter, the page that documents one and the gate that compares the two
|
|
143
|
+
* would have held three copies of the same three pairs. The CLI pads it to one
|
|
144
|
+
* width for the column; the width is a printing decision and stays there.
|
|
145
|
+
*/
|
|
146
|
+
export const INGEST_GUTTERS: Record<IngestFindingKind, string> = {
|
|
147
|
+
blocker: 'BLOCK',
|
|
148
|
+
judgement: 'JUDGE',
|
|
149
|
+
lossy: 'LOSS',
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export interface IngestFinding {
|
|
153
|
+
/** Stable code, so a table can count them and a doc can name one. */
|
|
154
|
+
code: string;
|
|
155
|
+
/** The object this is about, named the way a validator failure names one. */
|
|
156
|
+
where: string;
|
|
157
|
+
/** One sentence: what was found, and what it means for the rebuild. */
|
|
158
|
+
detail: string;
|
|
159
|
+
kind: IngestFindingKind;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The stage a skeleton does not carry. See `NO_STAGE` below. */
|
|
163
|
+
export interface IngestStage {
|
|
164
|
+
x: number;
|
|
165
|
+
y: number;
|
|
166
|
+
width: number;
|
|
167
|
+
height: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface IngestOptions {
|
|
171
|
+
/** The rig spec's `name`, which the motion spec's `archetype` must equal. */
|
|
172
|
+
name: string;
|
|
173
|
+
/**
|
|
174
|
+
* How the rebuilt spec gets at the art.
|
|
175
|
+
*
|
|
176
|
+
* `loose` names an `image` per attachment, so `build --images <dir>` measures
|
|
177
|
+
* the PNGs; `none` states `width`/`height` only, for a rebuild that resolves
|
|
178
|
+
* through `build --atlas-in <pack>`. The skeleton encodes neither, which is
|
|
179
|
+
* why this is a flag rather than a derivation.
|
|
180
|
+
*/
|
|
181
|
+
art: 'loose' | 'none';
|
|
182
|
+
/**
|
|
183
|
+
* The rig spec's own `images` — the directory every `image` written below
|
|
184
|
+
* resolves against — ALREADY spelled relative to the directory the spec will
|
|
185
|
+
* be written into (issue #595).
|
|
186
|
+
*
|
|
187
|
+
* Absent leaves the field out, and an absent `images` resolves against the
|
|
188
|
+
* spec's own directory: a caller who extracted the parts anywhere else then
|
|
189
|
+
* carries `build --images <dir>` on every rebuild forever, and a spec that
|
|
190
|
+
* needs a flag to build is a spec whose `note` would have to say so.
|
|
191
|
+
*
|
|
192
|
+
* ⚠️ Spelled by the CALLER, not here. Turning a directory somebody typed into
|
|
193
|
+
* an absolute path reads a working directory, and this module reads nothing;
|
|
194
|
+
* `cli.ts` resolves it and spells it with `relativeImagesPath`, which is the
|
|
195
|
+
* same function `build` spells `skeleton.images` with, so the two cannot
|
|
196
|
+
* drift. Meaningless under `art: 'none'`, which writes no `image` at all —
|
|
197
|
+
* `cli.ts` refuses that pair rather than writing a field nothing reads.
|
|
198
|
+
*/
|
|
199
|
+
images?: string;
|
|
200
|
+
/**
|
|
201
|
+
* Supplied stage, for a skeleton that declares none.
|
|
202
|
+
*
|
|
203
|
+
* ⛔ **Beside a skeleton that declares one this is an `IngestError`, not an
|
|
204
|
+
* override** (issue #626). It used to be read only after the early return in
|
|
205
|
+
* `ingestHeader`, so a caller who passed it alongside a declared box got the
|
|
206
|
+
* file's box, no finding and exit 0 — and the sharper case is the caller who
|
|
207
|
+
* meant to correct a wrong box and believed they had. The file is the record
|
|
208
|
+
* of what was measured; two sources for one value is a question, and rigc
|
|
209
|
+
* refuses it rather than answering it quietly.
|
|
210
|
+
*/
|
|
211
|
+
stage?: IngestStage;
|
|
212
|
+
/**
|
|
213
|
+
* The stage a rigc build's model document states (issue #907): its `stage`,
|
|
214
|
+
* or `null` for a rig that declared none. Given, it is read IN PLACE OF the
|
|
215
|
+
* header's `x`, `y`, `width`, `height` — which on a rigc build since #907 are
|
|
216
|
+
* the setup-pose bounding box, not the stage — and everything below reads it
|
|
217
|
+
* as it would read a header stating it (`--stage` beside it is the same
|
|
218
|
+
* refusal). Absent, the header is read, as for an export: an export carries
|
|
219
|
+
* no other box. The caller reads it — `src/` reads no files —
|
|
220
|
+
* (`documentStageBeside` in `src/cli/shared.ts`: the `rigc-compiled/3`
|
|
221
|
+
* document beside the skeleton, only when its digest is that skeleton's).
|
|
222
|
+
*/
|
|
223
|
+
documentStage?: IngestStage | null;
|
|
224
|
+
/**
|
|
225
|
+
* The slot whose bounding box carries the stage (issue #1168, `--stage-box
|
|
226
|
+
* <slot>`): a rig built with `skeleton.stageBox` carries its stage there,
|
|
227
|
+
* in the files a consumer ships, while the header carries the setup-pose
|
|
228
|
+
* bounding box. Named, that box is read as the stage — in place of the
|
|
229
|
+
* header's box, as `documentStage` is — and the rebuilt rig spec asks for
|
|
230
|
+
* the same box (`skeleton.stageBox`) instead of transcribing it as an
|
|
231
|
+
* attachment. Every shape `build` could not write back is refused by name
|
|
232
|
+
* (`readStageBox`). Absent, nothing is read from a slot: no box is the stage
|
|
233
|
+
* because of its name.
|
|
234
|
+
*/
|
|
235
|
+
stageBox?: string;
|
|
236
|
+
/** The source file's basename, for the provenance note. No path: no leak. */
|
|
237
|
+
source: string;
|
|
238
|
+
/** rigc's own version, for the provenance note. Passed in — `src/` reads no files. */
|
|
239
|
+
version: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface IngestResult {
|
|
243
|
+
rig: RigSpec;
|
|
244
|
+
motion: MotionSpec;
|
|
245
|
+
findings: IngestFinding[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// JSON narrowing — the input is a file somebody else wrote
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
type JsonObject = Record<string, unknown>;
|
|
253
|
+
|
|
254
|
+
function isObj(v: unknown): v is JsonObject {
|
|
255
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The array at `v`, or an empty one. An absent collection is not a fault here. */
|
|
259
|
+
function arr(v: unknown): unknown[] {
|
|
260
|
+
return Array.isArray(v) ? v : [];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The object at `v`, or an empty one. */
|
|
264
|
+
function obj(v: unknown): JsonObject {
|
|
265
|
+
return isObj(v) ? v : {};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** `Object.entries` over the OBJECT-valued entries of `v`, in file order. */
|
|
269
|
+
function objEntries(v: unknown): Array<[string, JsonObject]> {
|
|
270
|
+
return Object.entries(obj(v)).filter((entry): entry is [string, JsonObject] => isObj(entry[1]));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** `Object.entries` over the ARRAY-valued entries of `v`, in file order. */
|
|
274
|
+
function arrEntries(v: unknown): Array<[string, unknown[]]> {
|
|
275
|
+
return Object.entries(obj(v)).filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1]));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The numbers at `v`, or undefined. A mixed array is not a number array. */
|
|
279
|
+
function numbers(v: unknown): number[] | undefined {
|
|
280
|
+
if (!Array.isArray(v)) return undefined;
|
|
281
|
+
return v.every((n) => typeof n === 'number') ? (v as number[]) : undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function nameOf(v: unknown): string {
|
|
285
|
+
return isObj(v) && typeof v.name === 'string' ? v.name : '';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// field tables — DERIVED from the rig spec's own key sets, never retyped
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
//
|
|
292
|
+
// ⭐ `RIG_KEYS` is already the statement of which fields a rig spec holds, and
|
|
293
|
+
// `checkRigSpecKeys` refuses everything outside it. Reading the carry list off
|
|
294
|
+
// that table rather than copying it means a field added to the spec becomes
|
|
295
|
+
// carryable here with no edit, and — the half that matters — a skeleton field
|
|
296
|
+
// that has no rig-spec home is refused BY NAME instead of being dropped in
|
|
297
|
+
// silence. A second list would be two lists that have to agree, which is the
|
|
298
|
+
// defect `RIG_KEYS`'s own comment is about.
|
|
299
|
+
|
|
300
|
+
/** Everything `RIG_KEYS` names for a shape, minus the keys this file handles itself. */
|
|
301
|
+
function carried(shape: keyof typeof RIG_KEYS, ...handled: string[]): string[] {
|
|
302
|
+
return (RIG_KEYS[shape] as readonly string[]).filter((key) => !handled.includes(key));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Bone fields carried verbatim. Inverts `buildBone` in `compile.ts`.
|
|
307
|
+
*
|
|
308
|
+
* `from` is excluded because it is rigc's own: a bone position taken from a
|
|
309
|
+
* manifest anchor, resolved to `x`/`y` at compile time (`cropPointOf`). A
|
|
310
|
+
* skeleton holds the resolved numbers and nothing else, so carrying them as
|
|
311
|
+
* `x`/`y` is the inversion and a `from` here would be an invention.
|
|
312
|
+
*/
|
|
313
|
+
const BONE_FIELDS = carried('RigBone', 'name', 'from');
|
|
314
|
+
|
|
315
|
+
/** Slot fields carried verbatim. Inverts the slot loop in `compile()` step 4. */
|
|
316
|
+
const SLOT_FIELDS = carried('RigSlot', 'name');
|
|
317
|
+
|
|
318
|
+
/** Per constraint type, the fields carried verbatim. Inverts `buildRigConstraint`. */
|
|
319
|
+
const CONSTRAINT_FIELDS: Record<string, string[]> = {
|
|
320
|
+
ik: carried('RigIkConstraint', 'name', 'type'),
|
|
321
|
+
transform: carried('RigTransformConstraint', 'name', 'type'),
|
|
322
|
+
path: carried('RigPathConstraint', 'name', 'type'),
|
|
323
|
+
physics: carried('RigPhysicsConstraint', 'name', 'type'),
|
|
324
|
+
slider: carried('RigSliderConstraint', 'name', 'type'),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The physics constraints that drive nothing, by name, each with the component
|
|
329
|
+
* fields it DOES state — every one of them at most 0 (issue #731).
|
|
330
|
+
*
|
|
331
|
+
* 🔑 The question is the core's `physicsDrives` (`src/core/constraints_physics.ts`,
|
|
332
|
+
* issue #1015): a component above 0 drives its part of the step and nothing
|
|
333
|
+
* else does, so a constraint with none of `PHYSICS_COMPONENTS` above 0 moves no
|
|
334
|
+
* bone — `PhysicsConstraint.update` applies a part only on that test
|
|
335
|
+
* (`PhysicsConstraint.js:112`). An unstated component is the parser's 0. `build`
|
|
336
|
+
* refuses exactly that shape by name — `A23_PHYSICS_CONSTRAINT_EFFECTIVE` at the
|
|
337
|
+
* gate — so carrying one through made the decompiled spec of a file an editor
|
|
338
|
+
* exports unbuildable as a whole, over a constraint that did nothing in it.
|
|
339
|
+
*
|
|
340
|
+
* ⚠️ A component that is present and NOT a number is not inert: the parser takes
|
|
341
|
+
* it as written and `"0.5" > 0` is true in the runtime's comparison, so such a
|
|
342
|
+
* constraint is carried and rigc's own parser says what is wrong with it.
|
|
343
|
+
*/
|
|
344
|
+
function inertPhysics(root: JsonObject): Map<string, string[]> {
|
|
345
|
+
const out = new Map<string, string[]>();
|
|
346
|
+
for (const raw of arr(root.constraints)) {
|
|
347
|
+
const constraint = obj(raw);
|
|
348
|
+
if (constraint.type !== 'physics' || typeof constraint.name !== 'string') continue;
|
|
349
|
+
const stated = PHYSICS_COMPONENTS.filter((field) => constraint[field] !== undefined);
|
|
350
|
+
if (!stated.every((field) => typeof constraint[field] === 'number')) continue;
|
|
351
|
+
const value = (field: (typeof PHYSICS_COMPONENTS)[number]): number => (constraint[field] === undefined ? 0 : (constraint[field] as number));
|
|
352
|
+
const drives = physicsDrives({ x: value('x'), y: value('y'), rotate: value('rotate'), shearX: value('shearX'), scaleX: value('scaleX') });
|
|
353
|
+
if (drives.x || drives.y || drives.rotateOrShearX || drives.scaleX) continue;
|
|
354
|
+
out.set(
|
|
355
|
+
constraint.name,
|
|
356
|
+
stated.map((field) => `${field} ${String(constraint[field])}`),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The two kinds `A47` / `A48` ask the muted-at-rest question of. An alias rather
|
|
364
|
+
* than the union written at each use, because `IG25`'s scan resolves a composed
|
|
365
|
+
* finding code against the nearest `<name>: '…' | '…'` above it, and `type:` is
|
|
366
|
+
* the identifier `ATTACHMENT_<TYPE>` composes from further down.
|
|
367
|
+
*/
|
|
368
|
+
type MixedConstraintKind = 'ik' | 'transform';
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The ik and transform constraints this file rests muted and never switches on
|
|
372
|
+
* — the shape `A47` / `A48` refuse — with the mixes each one reads (issue #784).
|
|
373
|
+
*
|
|
374
|
+
* 🔑 **Read the way the gate reads it, from the raw JSON.** This module does not
|
|
375
|
+
* link the runtime, so the defaults are the parser's own, spelled here: an ik
|
|
376
|
+
* `mix` is 1 when absent, on the constraint and on every key; a transform mix
|
|
377
|
+
* is 1 when absent except `mixY`, which is the same object's `mixX`, and
|
|
378
|
+
* `mixScaleY`, which is its `mixScaleX` (`SkeletonJson.js`, every
|
|
379
|
+
* `getValue(…, 1)`). A transform reads only the mixes of the `to` properties it
|
|
380
|
+
* declares, and one that declares none is `A48`'s other sentence, which no
|
|
381
|
+
* declaration answers — so it is not a candidate. Live is `!== 0`, the
|
|
382
|
+
* runtime's test. A key lifts a mix when the value it states is live, or when
|
|
383
|
+
* its Bezier handles for that channel are — the curve the runtime interpolates
|
|
384
|
+
* through (`curveChannelValues` in `validate.ts`), which is how a 0 → 0 pair
|
|
385
|
+
* with raised handles moves the bones.
|
|
386
|
+
*
|
|
387
|
+
* ⚠️ A disagreement with the gate is loud either way, never silent: an entry on
|
|
388
|
+
* a constraint the gate reads as live is refused by name (declared but already
|
|
389
|
+
* switched on), and a muted one this misses is `A47` / `A48` refusing it.
|
|
390
|
+
*/
|
|
391
|
+
function consumerDrivenCandidates(root: JsonObject): Array<{ type: MixedConstraintKind; name: string; reads: string[] }> {
|
|
392
|
+
const TO_MIX: Record<string, string> = {
|
|
393
|
+
rotate: 'mixRotate',
|
|
394
|
+
x: 'mixX',
|
|
395
|
+
y: 'mixY',
|
|
396
|
+
scaleX: 'mixScaleX',
|
|
397
|
+
scaleY: 'mixScaleY',
|
|
398
|
+
shearY: 'mixShearY',
|
|
399
|
+
};
|
|
400
|
+
/** Frame order of a transform timeline's six curve channels. */
|
|
401
|
+
const TRANSFORM_ORDER = ['mixRotate', 'mixX', 'mixY', 'mixScaleX', 'mixScaleY', 'mixShearY'];
|
|
402
|
+
const num = (value: unknown, fallback: number): number => (typeof value === 'number' ? value : fallback);
|
|
403
|
+
const transformMixes = (m: JsonObject): Record<string, number> => {
|
|
404
|
+
const mixX = num(m.mixX, 1);
|
|
405
|
+
const mixScaleX = num(m.mixScaleX, 1);
|
|
406
|
+
return {
|
|
407
|
+
mixRotate: num(m.mixRotate, 1),
|
|
408
|
+
mixX,
|
|
409
|
+
mixY: num(m.mixY, mixX),
|
|
410
|
+
mixScaleX,
|
|
411
|
+
mixScaleY: num(m.mixScaleY, mixScaleX),
|
|
412
|
+
mixShearY: num(m.mixShearY, 1),
|
|
413
|
+
};
|
|
414
|
+
};
|
|
415
|
+
/** The Bezier handle values of channel `c` on a key's `curve`; none when it is linear or stepped. */
|
|
416
|
+
const handles = (key: JsonObject, c: number): number[] =>
|
|
417
|
+
Array.isArray(key.curve) ? [key.curve[4 * c + 1], key.curve[4 * c + 3]].filter((v): v is number => typeof v === 'number') : [];
|
|
418
|
+
const keysOf = (kind: MixedConstraintKind, name: string): JsonObject[] =>
|
|
419
|
+
Object.values(obj(root.animations)).flatMap((animation) => arr(obj(obj(animation)[kind])[name]).map(obj));
|
|
420
|
+
const out: Array<{ type: MixedConstraintKind; name: string; reads: string[] }> = [];
|
|
421
|
+
for (const raw of arr(root.constraints)) {
|
|
422
|
+
const constraint = obj(raw);
|
|
423
|
+
if (typeof constraint.name !== 'string') continue;
|
|
424
|
+
if (constraint.type === 'ik') {
|
|
425
|
+
if (num(constraint.mix, 1) !== 0) continue;
|
|
426
|
+
const lifted = keysOf('ik', constraint.name).some((key) => num(key.mix, 1) !== 0 || handles(key, 0).some((v) => v !== 0));
|
|
427
|
+
if (!lifted) out.push({ type: 'ik', name: constraint.name, reads: ['mix'] });
|
|
428
|
+
} else if (constraint.type === 'transform') {
|
|
429
|
+
const reads = [
|
|
430
|
+
...new Set(
|
|
431
|
+
Object.values(obj(constraint.properties)).flatMap((from) => Object.keys(obj(obj(from).to)).map((to) => TO_MIX[to])),
|
|
432
|
+
),
|
|
433
|
+
]
|
|
434
|
+
.filter((mix): mix is string => mix !== undefined)
|
|
435
|
+
.sort((a, b) => TRANSFORM_ORDER.indexOf(a) - TRANSFORM_ORDER.indexOf(b));
|
|
436
|
+
if (reads.length === 0) continue;
|
|
437
|
+
const setup = transformMixes(constraint);
|
|
438
|
+
if (reads.some((mix) => setup[mix] !== 0)) continue;
|
|
439
|
+
const lifted = keysOf('transform', constraint.name).some((key) => {
|
|
440
|
+
const values = transformMixes(key);
|
|
441
|
+
return reads.some((mix) => values[mix] !== 0 || handles(key, TRANSFORM_ORDER.indexOf(mix)).some((v) => v !== 0));
|
|
442
|
+
});
|
|
443
|
+
if (!lifted) out.push({ type: 'transform', name: constraint.name, reads });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** What omitting one inert physics constraint took with it, for its finding. */
|
|
450
|
+
interface PhysicsOmission {
|
|
451
|
+
/** `animation "<a>" <timeline>`, in file order. */
|
|
452
|
+
timelines: string[];
|
|
453
|
+
/** The skins whose `physics` member list named it. */
|
|
454
|
+
skins: string[];
|
|
455
|
+
/** Per animation, the latest key time on an omitted timeline of this constraint. */
|
|
456
|
+
lastKeys: Map<string, number>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The per-skin member lists (`SkeletonJson` reads them before `attachments`).
|
|
461
|
+
*
|
|
462
|
+
* `RIG_KEYS.RigSkinEntry` is `attachments` plus the five constraint lists plus
|
|
463
|
+
* `bones`; the long form of a skin is exactly those, so the list is that set
|
|
464
|
+
* minus the table itself.
|
|
465
|
+
*/
|
|
466
|
+
const SKIN_LISTS = carried('RigSkinEntry', 'attachments');
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* One value-track channel: the JSON field, and what the PARSER reads where a key
|
|
470
|
+
* omits it.
|
|
471
|
+
*
|
|
472
|
+
* ⚠️ A number is a constant default; a STRING is another field of the same key,
|
|
473
|
+
* which is how `mixY` works (`SkeletonJson:988` — it defaults to that key's own
|
|
474
|
+
* `mixX`, not to 1). The same two-shaped table as `CONSTRAINT_TIMELINES`'s
|
|
475
|
+
* `inheritsFrom`, and for the same reason.
|
|
476
|
+
*/
|
|
477
|
+
type TrackShape = Array<[field: string, dflt: number | string]>;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Bone timeline shapes. Inverts `BONE_TRACKS` + `compileValueTrack`.
|
|
481
|
+
*
|
|
482
|
+
* 🚨 The defaults matter more than they look, and they are not all the same:
|
|
483
|
+
* Spine omits a field that equals the SETUP value, `translate` reads 0 there and
|
|
484
|
+
* `scale` reads 1. A decompiler that filled every omission with 0 would collapse
|
|
485
|
+
* every scale key it read, silently.
|
|
486
|
+
*/
|
|
487
|
+
const BONE_TRACKS: Record<string, TrackShape> = {
|
|
488
|
+
translate: [['x', 0], ['y', 0]],
|
|
489
|
+
translatex: [['value', 0]],
|
|
490
|
+
translatey: [['value', 0]],
|
|
491
|
+
scale: [['x', 1], ['y', 1]],
|
|
492
|
+
scalex: [['value', 1]],
|
|
493
|
+
scaley: [['value', 1]],
|
|
494
|
+
shear: [['x', 0], ['y', 0]],
|
|
495
|
+
shearx: [['value', 0]],
|
|
496
|
+
sheary: [['value', 0]],
|
|
497
|
+
rotate: [['value', 0]],
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The bone timeline whose key holds a NAME rather than numbers — `inherit`,
|
|
502
|
+
* `{ time, inherit }`, stepped by the format (its reader builds no curve). The
|
|
503
|
+
* field and the default `SkeletonJson`'s `inherit` branch reads where a key
|
|
504
|
+
* omits it: `getValue(aFrame, "inherit", "Normal")`, which the table spells
|
|
505
|
+
* `normal`. Inverts `compileValueTrack`'s named branch (issue #733).
|
|
506
|
+
*/
|
|
507
|
+
const INHERIT_TRACK = { property: 'inherit', field: 'inherit', dflt: 'normal' } as const;
|
|
508
|
+
|
|
509
|
+
/** Every bone timeline the motion spec has a track for, in the order the refusal prints them. */
|
|
510
|
+
const BONE_TRACK_NAMES = [...Object.keys(BONE_TRACKS), INHERIT_TRACK.property];
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The eight physics timelines — `PHYSICS_TRACKS` in `compile.ts`, same order.
|
|
514
|
+
* `reset` carries no value at all — `compileValueTrack`'s zero-field branch.
|
|
515
|
+
*
|
|
516
|
+
* 🚨 The defaults are the parser's per-key ones and they are **0 on six of the
|
|
517
|
+
* eight**, which is not where a reader looks for them: the constraint's own
|
|
518
|
+
* defaults (`inertia` 0.5, `strength` 100, `damping` 0.85, `mass` 1) sit in the
|
|
519
|
+
* same file at `:306-312` and belong to the constraint, not to a key. A
|
|
520
|
+
* decompiler that filled an omitted `damping` key with 0.85 would write a spec
|
|
521
|
+
* that plays a different animation from the one it read, and every gate would
|
|
522
|
+
* call it green. Copied off `SkeletonJson.js:1062` and `:1090`, not assumed.
|
|
523
|
+
*/
|
|
524
|
+
const PHYSICS_TRACKS: Record<string, TrackShape> = {
|
|
525
|
+
inertia: [['value', 0]],
|
|
526
|
+
strength: [['value', 0]],
|
|
527
|
+
damping: [['value', 0]],
|
|
528
|
+
mass: [['value', 0]],
|
|
529
|
+
wind: [['value', 0]],
|
|
530
|
+
gravity: [['value', 0]],
|
|
531
|
+
mix: [['value', 1]],
|
|
532
|
+
reset: [],
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/** `mix` is three values in ONE key — `PATH_TRACKS` in `compile.ts`. */
|
|
536
|
+
const PATH_TRACKS: Record<string, TrackShape> = {
|
|
537
|
+
position: [['value', 0]],
|
|
538
|
+
spacing: [['value', 0]],
|
|
539
|
+
mix: [['mixRotate', 1], ['mixX', 1], ['mixY', 'mixX']],
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/** ⚠️ `time`'s per-key default is **1**, not 0 (`:1121`). Copied, not assumed. */
|
|
543
|
+
const SLIDER_TRACKS: Record<string, TrackShape> = { time: [['value', 1]], mix: [['value', 1]] };
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The `ik` and `transform` key fields and the value the PARSER reads where a key
|
|
547
|
+
* omits one — `CONSTRAINT_TIMELINES` in `compile.ts`, channels then flags.
|
|
548
|
+
*
|
|
549
|
+
* ⚠️ `mixY` is the one field whose default is not a constant: it is the same
|
|
550
|
+
* key's own `mixX` (`SkeletonJson:988`), which is why it is spelled here as a
|
|
551
|
+
* field name rather than a number.
|
|
552
|
+
*/
|
|
553
|
+
const IK_KEY_DEFAULTS: Record<string, number | boolean> = {
|
|
554
|
+
mix: 1,
|
|
555
|
+
softness: 0,
|
|
556
|
+
bendPositive: true,
|
|
557
|
+
compress: false,
|
|
558
|
+
stretch: false,
|
|
559
|
+
};
|
|
560
|
+
const TRANSFORM_KEY_DEFAULTS: Record<string, number | boolean | string> = {
|
|
561
|
+
mixRotate: 1,
|
|
562
|
+
mixX: 1,
|
|
563
|
+
mixY: 'mixX',
|
|
564
|
+
mixScaleX: 1,
|
|
565
|
+
mixScaleY: 1,
|
|
566
|
+
mixShearY: 1,
|
|
567
|
+
};
|
|
568
|
+
/** The three ik booleans, which `compileConstraintTrack` stamps from the rig. */
|
|
569
|
+
const IK_FLAGS = ['bendPositive', 'compress', 'stretch'];
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The animation groups the motion spec carries. Anything else is a blocker.
|
|
573
|
+
*
|
|
574
|
+
* ⚠️ This said *"the groups `readAnimation` reads"* until issue #675, and the
|
|
575
|
+
* `ANIMATION_GROUP` detail below said it to the reader. It is not the same set:
|
|
576
|
+
* `SkeletonJson.readAnimation` reads `drawOrderFolder` too and builds a
|
|
577
|
+
* `DrawOrderFolderTimeline` from it, so on that one name the sentence told an
|
|
578
|
+
* author the parser ignores something it plays. What is true either way is the
|
|
579
|
+
* half that decides the rebuild — the motion spec has no home for it — so that
|
|
580
|
+
* is what both the list and the finding now say.
|
|
581
|
+
*/
|
|
582
|
+
const ANIMATION_GROUPS = ['bones', 'slots', 'ik', 'transform', 'path', 'physics', 'slider', 'attachments', 'drawOrder', 'events'];
|
|
583
|
+
|
|
584
|
+
/** The header fields rigc writes that no rig spec field holds. */
|
|
585
|
+
const HEADER_REDERIVED = ['spine'];
|
|
586
|
+
|
|
587
|
+
/** The attachment types this module inverts. Everything else is refused by name. */
|
|
588
|
+
const ATTACHMENT_TYPES = ['region', 'mesh', 'linkedmesh', 'boundingbox', 'clipping', 'path'];
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The geometry keys a LINKED mesh may state and the parser never reads
|
|
592
|
+
* (issue #710).
|
|
593
|
+
*
|
|
594
|
+
* `readAttachment` returns from the `source` branch at `SkeletonJson.js:586`,
|
|
595
|
+
* before `map.uvs` is touched, so a link carrying any of these is a file that
|
|
596
|
+
* says one mesh while every runtime draws its source's. The rig spec cannot hold
|
|
597
|
+
* them either — `buildRigLinkedMesh` refuses geometry on a link by name — so a
|
|
598
|
+
* rebuild that carried one would be a spec `build` refuses, and a rebuild that
|
|
599
|
+
* dropped it in silence would be this module normalising somebody's file without
|
|
600
|
+
* saying so.
|
|
601
|
+
*
|
|
602
|
+
* ⚠️ A second list beside `src/validate.ts`'s, deliberately: that module links
|
|
603
|
+
* spine-core and this one must not, so importing it would pull the runtime into
|
|
604
|
+
* every `ingest`. What holds the two equal is a RUN rather than a shared
|
|
605
|
+
* constant — one forged skeleton through both, with the keys `A44` names and the
|
|
606
|
+
* keys this finding names compared as sets.
|
|
607
|
+
*/
|
|
608
|
+
const LINKED_MESH_UNREAD_KEYS = ['uvs', 'triangles', 'vertices', 'hull', 'edges'];
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The slot timelines the motion spec carries — `compileTrack`'s own table,
|
|
612
|
+
* rather than a second list of the same two names.
|
|
613
|
+
*
|
|
614
|
+
* ⚠️ It was that second list until issue #650, spelled `['rgba', 'attachment']`
|
|
615
|
+
* with a comment saying where it had been copied from. The copy was true, which
|
|
616
|
+
* is the point: the emitter had no list at all — one `if` and a fall-through —
|
|
617
|
+
* so this module's blocker was the only place in `src/` that said what a slot
|
|
618
|
+
* track may be, and it said it about a compiler that accepted anything. Now
|
|
619
|
+
* there is one list and both sides read it.
|
|
620
|
+
*/
|
|
621
|
+
const SLOT_TRACKS = Object.keys(EMITTED_SLOT_TRACKS);
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The slot timelines the FORMAT has and the motion spec has no track for —
|
|
625
|
+
* **none** since issue #730 spelled `rgb`, `alpha` and `rgb2`, which were the
|
|
626
|
+
* last three. It stays derived rather than deleted, because an empty
|
|
627
|
+
* difference of two tables is a measurement and a deleted one is a claim: the
|
|
628
|
+
* day the format grows a seventh slot timeline, this is where it appears, and
|
|
629
|
+
* the blocker's sentence names it without an edit.
|
|
630
|
+
*
|
|
631
|
+
* ⭐ Both sides are derived, and that is the whole reason it exists rather than
|
|
632
|
+
* being spelled into the blocker's sentence. `docs/INGEST.md`'s row for this
|
|
633
|
+
* code named `rgba2` among the timelines nobody carries for as long as that was
|
|
634
|
+
* true, and went on saying it after issue #690 made it false — a hand-kept list
|
|
635
|
+
* beside a derived one, which is the shape this repository refuses everywhere
|
|
636
|
+
* else. The format's own list is `CHANNELS_BY_KIND.slot`, the emitter's is
|
|
637
|
+
* `SLOT_TRACKS`, and the difference is the answer.
|
|
638
|
+
*/
|
|
639
|
+
export const UNSPELT_SLOT_TRACKS = Object.keys(CHANNELS_BY_KIND.slot).filter((name) => !(name in EMITTED_SLOT_TRACKS));
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Everything this module has a branch for, as the branches themselves state it.
|
|
643
|
+
*
|
|
644
|
+
* ⭐ It exists so that a gate can ask the question a suite cannot answer from a
|
|
645
|
+
* list somebody typed: **is every construct `ingest` claims to carry actually
|
|
646
|
+
* exercised by a rig somebody builds?** A decompiler branch no rig reaches is a
|
|
647
|
+
* branch nobody has seen work, which is this repository's own definition of not
|
|
648
|
+
* a gate — and the vocabulary has to come from here, because a second copy in
|
|
649
|
+
* `selftest.ts` would go stale in exactly the direction that hides the hole.
|
|
650
|
+
*/
|
|
651
|
+
export const INGEST_VOCABULARY = {
|
|
652
|
+
attachments: ATTACHMENT_TYPES,
|
|
653
|
+
constraints: Object.keys(CONSTRAINT_FIELDS),
|
|
654
|
+
boneTracks: BONE_TRACK_NAMES,
|
|
655
|
+
slotTracks: SLOT_TRACKS,
|
|
656
|
+
path: Object.keys(PATH_TRACKS),
|
|
657
|
+
physics: Object.keys(PHYSICS_TRACKS),
|
|
658
|
+
slider: Object.keys(SLIDER_TRACKS),
|
|
659
|
+
animationGroups: ANIMATION_GROUPS,
|
|
660
|
+
} as const satisfies Record<string, readonly string[]>;
|
|
661
|
+
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
// the inversions
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Spine's flat weight run back into one `{bone, x, y, weight}` list per vertex.
|
|
668
|
+
*
|
|
669
|
+
* 🔒 Inverts `encodeNamedWeights`, and the inversion is **by name** for exactly
|
|
670
|
+
* the reason that function encodes by name: the run holds positions in the
|
|
671
|
+
* EMITTED bone array, a list no spec writes, so a decompiled `vertices` run
|
|
672
|
+
* would rebind every vertex the moment a bone moved in the array (issue #45).
|
|
673
|
+
* The names are the join key on both sides.
|
|
674
|
+
*/
|
|
675
|
+
function decodeWeights(vertices: readonly number[], boneNames: readonly string[]): Array<Array<Record<string, unknown>>> {
|
|
676
|
+
const out: Array<Array<Record<string, unknown>>> = [];
|
|
677
|
+
let i = 0;
|
|
678
|
+
while (i < vertices.length) {
|
|
679
|
+
const count = vertices[i++];
|
|
680
|
+
const vertex: Array<Record<string, unknown>> = [];
|
|
681
|
+
for (let k = 0; k < count; k++) {
|
|
682
|
+
vertex.push({ bone: boneNames[vertices[i]], x: vertices[i + 1], y: vertices[i + 2], weight: vertices[i + 3] });
|
|
683
|
+
i += 4;
|
|
684
|
+
}
|
|
685
|
+
out.push(vertex);
|
|
686
|
+
}
|
|
687
|
+
return out;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* `"rrggbbaa"` back to `[r, g, b, a]` in 0..1. Inverts `rgbaHex`.
|
|
692
|
+
*
|
|
693
|
+
* ⚠️ One byte per channel is all the file holds, so this is exact in the only
|
|
694
|
+
* direction that matters: the rebuild quantises the same floats to the same
|
|
695
|
+
* bytes. It is not a recovery of whatever the original author typed.
|
|
696
|
+
*/
|
|
697
|
+
function hexToRgba(hex: string): number[] {
|
|
698
|
+
return [0, 2, 4, 6].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The three-channel form, which is what a two-colour key's `dark` is written as
|
|
703
|
+
* — `rrggbb` and no alpha, because `RGBA2Timeline` stores three dark channels
|
|
704
|
+
* and the fourth a shader reads there is the premultiply flag rather than a
|
|
705
|
+
* colour (`compile.ts`'s `rgba2Hex` states the same fact from the emit side).
|
|
706
|
+
*/
|
|
707
|
+
function hexToRgb(hex: string): number[] {
|
|
708
|
+
return [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Read a skeleton, write the two specs that rebuild it.
|
|
713
|
+
*
|
|
714
|
+
* Pure: the same skeleton and the same options give the same specs, every time.
|
|
715
|
+
* The two returned specs have been through `parseRigSpec` and `parseMotionSpec`
|
|
716
|
+
* before they leave — a decompiler that hands back something the compiler's own
|
|
717
|
+
* parser would refuse has produced a file nobody can use, and saying so here
|
|
718
|
+
* names the decompiler instead of leaving `build` to name the file.
|
|
719
|
+
*/
|
|
720
|
+
export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
|
|
721
|
+
const findings: IngestFinding[] = [];
|
|
722
|
+
const note = (kind: IngestFindingKind, code: string, where: string, detail: string): void => {
|
|
723
|
+
findings.push({ code, where, detail, kind });
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const root = obj(skeleton);
|
|
727
|
+
const boneNames: string[] = arr(root.bones).map(nameOf);
|
|
728
|
+
|
|
729
|
+
// -- the generation -------------------------------------------------------
|
|
730
|
+
// 🚨 First, because every walk below reads the file as 4.3 and a file from
|
|
731
|
+
// another generation is one this module cannot honestly claim to have read
|
|
732
|
+
// (issue #706 item 3). It records; it does not refuse — see `readGeneration`.
|
|
733
|
+
readGeneration(root, note);
|
|
734
|
+
|
|
735
|
+
// -- header ---------------------------------------------------------------
|
|
736
|
+
// 🚨 THE SEAM. One function decides the rig spec's `skeleton` block, and the
|
|
737
|
+
// stage is the only value in this whole module that a skeleton cannot answer
|
|
738
|
+
// for. Since #578 a rig spec can SAY that a skeleton declares no stage, and
|
|
739
|
+
// since #714 this is where a file that declares none is carried as saying so.
|
|
740
|
+
const stageBox = opts.stageBox === undefined ? null : readStageBox(root, opts.stageBox, note);
|
|
741
|
+
const rigHeader = ingestHeader(obj(root.skeleton), opts, note, stageBox);
|
|
742
|
+
|
|
743
|
+
// -- physics constraints that drive nothing (issue #731) ------------------
|
|
744
|
+
// Read before the skins, because a skin's `physics` member list is one of the
|
|
745
|
+
// three places such a constraint is named and each has to let go of it.
|
|
746
|
+
const inert = inertPhysics(root);
|
|
747
|
+
const omissions = new Map<string, PhysicsOmission>();
|
|
748
|
+
const omission = (name: string): PhysicsOmission => {
|
|
749
|
+
const found = omissions.get(name);
|
|
750
|
+
if (found !== undefined) return found;
|
|
751
|
+
const made: PhysicsOmission = { timelines: [], skins: [], lastKeys: new Map() };
|
|
752
|
+
omissions.set(name, made);
|
|
753
|
+
return made;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
// -- bones ----------------------------------------------------------------
|
|
757
|
+
// Inverts `buildBone`, which copies every declared field and omits the rest.
|
|
758
|
+
const bones = arr(root.bones).map((raw) => {
|
|
759
|
+
const bone = obj(raw);
|
|
760
|
+
const out: JsonObject = { name: bone.name };
|
|
761
|
+
for (const field of BONE_FIELDS) if (bone[field] !== undefined) out[field] = bone[field];
|
|
762
|
+
for (const key of Object.keys(bone)) {
|
|
763
|
+
if (key === 'name' || BONE_FIELDS.includes(key)) continue;
|
|
764
|
+
note('blocker', 'BONE_FIELD', `bone "${nameOf(bone)}"`, `field "${key}" has no rig-spec field, so it is dropped`);
|
|
765
|
+
}
|
|
766
|
+
return out;
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// -- slots ----------------------------------------------------------------
|
|
770
|
+
// A slot some skin fills but that shows nothing in the setup pose carries NO
|
|
771
|
+
// `attachment` field, and `build` refuses a filled slot with no setup pose
|
|
772
|
+
// ("the compiler will not guess one"). The skeleton does state it: an absent
|
|
773
|
+
// `attachment` on a filled slot means "show nothing", which the rig spec
|
|
774
|
+
// spells `null`. Transcribing an absence is not inventing a value.
|
|
775
|
+
const filled = new Set<string>();
|
|
776
|
+
for (const skin of arr(root.skins)) {
|
|
777
|
+
for (const [slot, placeholders] of objEntries(obj(skin).attachments)) {
|
|
778
|
+
if (Object.keys(placeholders).length) filled.add(slot);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const slots = arr(root.slots).map((raw) => {
|
|
782
|
+
const slot = obj(raw);
|
|
783
|
+
const out: JsonObject = { name: slot.name };
|
|
784
|
+
for (const field of SLOT_FIELDS) if (slot[field] !== undefined) out[field] = slot[field];
|
|
785
|
+
if (out.attachment === undefined && filled.has(nameOf(slot))) out.attachment = null;
|
|
786
|
+
for (const key of Object.keys(slot)) {
|
|
787
|
+
if (key === 'name' || SLOT_FIELDS.includes(key)) continue;
|
|
788
|
+
note('blocker', 'SLOT_FIELD', `slot "${nameOf(slot)}"`, `field "${key}" has no rig-spec field, so it is dropped`);
|
|
789
|
+
}
|
|
790
|
+
return out;
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// -- skins and attachments ------------------------------------------------
|
|
794
|
+
const skins: JsonObject = {};
|
|
795
|
+
for (const skin of arr(root.skins)) {
|
|
796
|
+
const entry = obj(skin);
|
|
797
|
+
const table: JsonObject = {};
|
|
798
|
+
for (const [slot, placeholders] of objEntries(entry.attachments)) {
|
|
799
|
+
const perSlot: JsonObject = {};
|
|
800
|
+
for (const [placeholder, att] of Object.entries(placeholders)) {
|
|
801
|
+
// The stage box is the rebuilt spec's `skeleton.stageBox`, which `build` writes from the stage — not an attachment to transcribe (issue #1168).
|
|
802
|
+
if (stageBox !== null && nameOf(entry) === 'default' && slot === stageBox.slot && placeholder === stageBox.attachment) continue;
|
|
803
|
+
perSlot[placeholder] = ingestAttachment(obj(att), placeholder, boneNames, opts, note, {
|
|
804
|
+
where: `skin "${nameOf(entry)}" slot "${slot}" attachment "${placeholder}"`,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
if (stageBox !== null && nameOf(entry) === 'default' && slot === stageBox.slot) continue;
|
|
808
|
+
table[slot] = perSlot;
|
|
809
|
+
}
|
|
810
|
+
const lists: JsonObject = {};
|
|
811
|
+
let anyList = false;
|
|
812
|
+
for (const list of SKIN_LISTS) {
|
|
813
|
+
if (entry[list] !== undefined) {
|
|
814
|
+
let members = entry[list];
|
|
815
|
+
if (list === 'physics' && Array.isArray(members)) {
|
|
816
|
+
const named = members.filter((member): member is string => typeof member === 'string' && inert.has(member));
|
|
817
|
+
for (const member of named) omission(member).skins.push(nameOf(entry));
|
|
818
|
+
members = members.filter((member) => !(typeof member === 'string' && inert.has(member)));
|
|
819
|
+
// A list that named nothing BUT omitted constraints goes with them: an
|
|
820
|
+
// empty list and an absent one read the same, and only one of them is
|
|
821
|
+
// what the rebuild writes for a skin with no physics member.
|
|
822
|
+
if (named.length > 0 && (members as unknown[]).length === 0) continue;
|
|
823
|
+
}
|
|
824
|
+
lists[list] = members;
|
|
825
|
+
anyList = true;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
// The long form only where the skin activates something; otherwise the short
|
|
829
|
+
// form, which is what every rig in this tree writes and what `splitRigSkin`
|
|
830
|
+
// reads back as the bare attachment table.
|
|
831
|
+
skins[nameOf(entry)] = anyList ? { ...lists, attachments: table } : table;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// -- constraints ----------------------------------------------------------
|
|
835
|
+
// Inverts `buildRigConstraint`. 4.3 puts every type in ONE array and branches
|
|
836
|
+
// on `type`, so an unknown `type` is refused here for the same reason the
|
|
837
|
+
// parser's silence about it is assertion A01: it would simply vanish.
|
|
838
|
+
const constraints: JsonObject[] = [];
|
|
839
|
+
for (const raw of arr(root.constraints)) {
|
|
840
|
+
const constraint = obj(raw);
|
|
841
|
+
const type = typeof constraint.type === 'string' ? constraint.type : '';
|
|
842
|
+
const fields = CONSTRAINT_FIELDS[type];
|
|
843
|
+
const who = `constraint "${nameOf(constraint)}"`;
|
|
844
|
+
if (fields === undefined) {
|
|
845
|
+
note('blocker', 'CONSTRAINT_TYPE', who, `type ${JSON.stringify(constraint.type)} is not one of ${Object.keys(CONSTRAINT_FIELDS).join(', ')}`);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
// Omitted rather than carried, and said below once the timelines it takes
|
|
849
|
+
// with it are known (`PHYSICS_DRIVES_NOTHING`).
|
|
850
|
+
if (type === 'physics' && typeof constraint.name === 'string' && inert.has(constraint.name)) continue;
|
|
851
|
+
const out: JsonObject = { name: constraint.name, type };
|
|
852
|
+
for (const field of fields) if (constraint[field] !== undefined) out[field] = constraint[field];
|
|
853
|
+
for (const key of Object.keys(constraint)) {
|
|
854
|
+
if (key === 'name' || key === 'type' || fields.includes(key)) continue;
|
|
855
|
+
note('blocker', 'CONSTRAINT_FIELD', `${who} (${type})`, `field "${key}" has no rig-spec field, so it is dropped`);
|
|
856
|
+
}
|
|
857
|
+
constraints.push(out);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// -- animations -----------------------------------------------------------
|
|
861
|
+
const animations: JsonObject = {};
|
|
862
|
+
for (const [animName, raw] of objEntries(root.animations)) {
|
|
863
|
+
animations[animName] = ingestAnimation(animName, raw, root, note, inert, (name, property, lastKey) => {
|
|
864
|
+
const one = omission(name);
|
|
865
|
+
one.timelines.push(`animation "${animName}" ${property}`);
|
|
866
|
+
one.lastKeys.set(animName, Math.max(one.lastKeys.get(animName) ?? 0, lastKey));
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// One finding per inert constraint, in the file's own order, naming what went
|
|
871
|
+
// with it. ⚠️ An animation's duration is the largest key time it has LEFT, so
|
|
872
|
+
// an omitted timeline that held the last key shortens the rebuilt animation —
|
|
873
|
+
// the one place this omission is not a no-op, and the detail says so there.
|
|
874
|
+
for (const [name, stated] of inert) {
|
|
875
|
+
const gone = omission(name);
|
|
876
|
+
const shortened = [...gone.lastKeys]
|
|
877
|
+
.map(([animName, lastKey]) => [animName, lastKey, Number(obj(animations[animName]).duration)] as const)
|
|
878
|
+
.filter(([, lastKey, duration]) => lastKey > duration)
|
|
879
|
+
.map(
|
|
880
|
+
([animName, lastKey, duration]) =>
|
|
881
|
+
`animation "${animName}" had its last key at ${lastKey}s on one of them, so the rebuilt animation ends at ${duration}s`,
|
|
882
|
+
);
|
|
883
|
+
note(
|
|
884
|
+
'lossy',
|
|
885
|
+
'PHYSICS_DRIVES_NOTHING',
|
|
886
|
+
`constraint "${name}" (physics)`,
|
|
887
|
+
`drives no component: ${PHYSICS_COMPONENTS.join(', ')} are ` +
|
|
888
|
+
(stated.length ? `absent or at most 0 (it states ${stated.join(', ')})` : 'all absent') +
|
|
889
|
+
', and `PhysicsConstraint.update` applies one only above 0, so it moves no bone and `build` would refuse it ' +
|
|
890
|
+
'by name (A23_PHYSICS_CONSTRAINT_EFFECTIVE). The rig spec omits it' +
|
|
891
|
+
(gone.timelines.length
|
|
892
|
+
? `, and with it the ${gone.timelines.length} timeline(s) keyed to it, which would name a constraint the ` +
|
|
893
|
+
`rebuild does not have: ${gone.timelines.join('; ')}`
|
|
894
|
+
: '; no timeline keys it') +
|
|
895
|
+
(gone.skins.length ? `; it is taken off the physics list of skin(s) ${gone.skins.map((skin) => `"${skin}"`).join(', ')}` : '') +
|
|
896
|
+
(shortened.length
|
|
897
|
+
? `. One thing does move, because a duration is the last key an animation has left: ${shortened.join('; ')}`
|
|
898
|
+
: '. The rebuild differs from the source by exactly these no-ops'),
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// -- constraints the consumer drives (issue #784) --------------------------
|
|
903
|
+
// A constraint resting muted that no animation switches on is exactly what
|
|
904
|
+
// `A47` / `A48` refuse, and the export cannot say whether it is a leftover or
|
|
905
|
+
// a dial a game turns from code: the two are the same bytes. The rebuild
|
|
906
|
+
// reads it as the consumer's — the reading under which the file is correct —
|
|
907
|
+
// and says so twice: in the rig spec, as the declaration the gate reads, and
|
|
908
|
+
// as a finding naming the constraint, so the author sees what the rebuild is
|
|
909
|
+
// claiming. It is a `judgement` for `DURATION`'s reason: a statement the
|
|
910
|
+
// skeleton does not carry, made and printed rather than hidden, with nothing
|
|
911
|
+
// lost — the rebuilt skeleton is the same bytes either way.
|
|
912
|
+
const carried = new Set(constraints.map((c) => constraintAt(String(c.type), String(c.name))));
|
|
913
|
+
const consumerDrivenMix: JsonObject[] = [];
|
|
914
|
+
const animationCount = Object.keys(obj(root.animations)).length;
|
|
915
|
+
for (const { type, name, reads } of consumerDrivenCandidates(root)) {
|
|
916
|
+
if (!carried.has(constraintAt(type, name))) continue;
|
|
917
|
+
const assertion = type === 'ik' ? 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT' : 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT';
|
|
918
|
+
consumerDrivenMix.push({
|
|
919
|
+
constraint: name,
|
|
920
|
+
type,
|
|
921
|
+
why:
|
|
922
|
+
'the source skeleton rests it muted and no animation keys its mix above 0; `rigc ingest` reads it as a mix ' +
|
|
923
|
+
'the consumer sets, which an export cannot state. Delete this entry if it is a leftover',
|
|
924
|
+
});
|
|
925
|
+
note(
|
|
926
|
+
'judgement',
|
|
927
|
+
'CONSUMER_DRIVEN_MIX',
|
|
928
|
+
`constraint "${name}" (${type})`,
|
|
929
|
+
`rests at ${reads.map((mix) => `${mix} 0`).join(', ')} and none of the ${animationCount} ` +
|
|
930
|
+
`animation${animationCount === 1 ? '' : 's'} keys its mix above 0, so nothing in this file ever switches it ` +
|
|
931
|
+
'on — and the file cannot say whether that is a leftover or a mix a game sets from code. `build` refuses the ' +
|
|
932
|
+
`shape by name (${assertion}) unless the rig spec says which, so the rig spec now says the consumer drives ` +
|
|
933
|
+
'it, in invariants.consumerDrivenMix, and the gate SKIPs it by name rather than measuring it. If it is a ' +
|
|
934
|
+
`leftover, delete the entry and rest ${type === 'ik' ? 'its mix' : 'a mix it reads'} above 0 or remove the ` +
|
|
935
|
+
'constraint, and the gate measures it again',
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// -- assemble -------------------------------------------------------------
|
|
940
|
+
const rig: JsonObject = {
|
|
941
|
+
spec: RIG_SPEC_VERSION,
|
|
942
|
+
name: opts.name,
|
|
943
|
+
note: provenanceNote(opts, 'rig', consumerDrivenMix.length > 0),
|
|
944
|
+
};
|
|
945
|
+
if (Object.keys(rigHeader).length) rig.skeleton = rigHeader;
|
|
946
|
+
// Between `skeleton` and `bones`, which is where `RIG_KEYS.RigSpec` puts it —
|
|
947
|
+
// this file's key order is the spec's declared order and nothing sorts it.
|
|
948
|
+
if (opts.images !== undefined) rig.images = opts.images;
|
|
949
|
+
rig.bones = bones;
|
|
950
|
+
rig.slots = slots;
|
|
951
|
+
if (Object.keys(skins).length) rig.skins = skins;
|
|
952
|
+
if (constraints.length) rig.constraints = constraints;
|
|
953
|
+
if (isObj(root.events)) rig.events = { ...root.events };
|
|
954
|
+
// `invariants` is absent but for one field. A skeleton states no invariant,
|
|
955
|
+
// and INGEST §2.1 already says what to do about that: leave the block out,
|
|
956
|
+
// because an assertion with nothing to measure reports SKIP and never a pass.
|
|
957
|
+
// Writing an invariant that turns a check ON here would be certifying a rig
|
|
958
|
+
// nobody measured. `consumerDrivenMix` is the other direction — it turns two
|
|
959
|
+
// checks OFF for the constraints named above, which certifies nothing: what it
|
|
960
|
+
// buys is a SKIP by name, and a finding says each one out loud (issue #784).
|
|
961
|
+
if (consumerDrivenMix.length) rig.invariants = { consumerDrivenMix };
|
|
962
|
+
|
|
963
|
+
const motion: JsonObject = {
|
|
964
|
+
spec: MOTION_SPEC_VERSION,
|
|
965
|
+
archetype: opts.name,
|
|
966
|
+
cut: opts.name,
|
|
967
|
+
note: provenanceNote(opts, 'motion'),
|
|
968
|
+
// Empty on purpose: every curve below is written as a RAW `curve` array.
|
|
969
|
+
// A named easing says "this shape, wherever it is used" and an export carries
|
|
970
|
+
// a different bezier per key per channel, so there is no named easing to
|
|
971
|
+
// recognise — only a shape to copy. `easingCurve`'s output is what a raw
|
|
972
|
+
// curve holds, which is why the rebuild is byte-identical either way.
|
|
973
|
+
easings: {},
|
|
974
|
+
animations,
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
// 🔒 Through the tree's own parsers before they leave. `parseRigSpec` and
|
|
978
|
+
// `parseMotionSpec` are what `build` reads these files with, so a spec this
|
|
979
|
+
// module could produce and `build` would refuse is named here, at the
|
|
980
|
+
// decompiler, rather than three commands later at the file.
|
|
981
|
+
//
|
|
982
|
+
// ⚠️ And the refusal is a FINDING (issue #692). It is the one place in this
|
|
983
|
+
// module where a reader's exit code could come from something other than the
|
|
984
|
+
// findings list, which made it the one shape the census behind the finding
|
|
985
|
+
// codes could not count.
|
|
986
|
+
try {
|
|
987
|
+
return {
|
|
988
|
+
rig: parseRigSpec(rig, `ingest(${opts.source}): rig spec`),
|
|
989
|
+
motion: parseMotionSpec(motion, `ingest(${opts.source}): motion spec`),
|
|
990
|
+
findings,
|
|
991
|
+
};
|
|
992
|
+
} catch (err) {
|
|
993
|
+
if (!(err instanceof CompileError)) throw err;
|
|
994
|
+
note(
|
|
995
|
+
'blocker',
|
|
996
|
+
'SPEC_REFUSED',
|
|
997
|
+
`the specs written from "${opts.source}"`,
|
|
998
|
+
`${err.message} — rigc's own parser refuses what this run wrote, so \`build\` will refuse it too. Both files ` +
|
|
999
|
+
'are on disk so the sentence can be read against the skeleton it came from; nothing rebuilds this ' +
|
|
1000
|
+
'skeleton until the shape it names has a spelling in the spec',
|
|
1001
|
+
);
|
|
1002
|
+
throw new IngestSpecRefused(err.message, findings, rig, motion);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
type Note = (kind: IngestFindingKind, code: string, where: string, detail: string) => void;
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* The generation of the data this module inverts, read off the version the
|
|
1010
|
+
* emitter writes rather than typed beside it.
|
|
1011
|
+
*
|
|
1012
|
+
* ⚠️ `null` here would be a compiler emitting a version string this repository's
|
|
1013
|
+
* own detector cannot read, and the comparison below is written so that it
|
|
1014
|
+
* blocks every file rather than none — a reader that cannot say what it reads
|
|
1015
|
+
* cannot certify anything. `runGenerationSuite`'s positive control is what says
|
|
1016
|
+
* out loud that it is not null.
|
|
1017
|
+
*/
|
|
1018
|
+
const READER_GENERATION = spineGeneration(SPINE_VERSION);
|
|
1019
|
+
|
|
1020
|
+
/** Up to six names, so one finding cannot print a hundred. */
|
|
1021
|
+
function spellSome(names: readonly string[]): string {
|
|
1022
|
+
const shown = names.slice(0, 6).map((name) => `"${name}"`).join(', ');
|
|
1023
|
+
return names.length > 6 ? `${shown} +${names.length - 6} more` : shown;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* What a 4.3 reader loses on THIS file, counted on this file.
|
|
1028
|
+
*
|
|
1029
|
+
* 🚨 Three shapes, and they are the three #706 measured rather than three this
|
|
1030
|
+
* module thought of: constraints parked in the top-level arrays 4.3 folded away
|
|
1031
|
+
* (row 1 — 1,302 shipped skeletons parsed and loaded 0 of 8,672 constraints),
|
|
1032
|
+
* bones carrying the key 4.3 renamed (row 6), and physics constraints omitting a
|
|
1033
|
+
* field whose default is not the same number in 4.2 as in 4.3 (row 4).
|
|
1034
|
+
*
|
|
1035
|
+
* ⚠️ It is a MEASUREMENT and not an inventory: a construct none of the three
|
|
1036
|
+
* describes is lost without being counted here, which is why the empty case says
|
|
1037
|
+
* so rather than saying nothing was lost.
|
|
1038
|
+
*/
|
|
1039
|
+
function generationLosses(root: JsonObject): string[] {
|
|
1040
|
+
const out: string[] = [];
|
|
1041
|
+
|
|
1042
|
+
const parked = TOPLEVEL_CONSTRAINT_ARRAYS.map((kind) => [kind, arr(root[kind]).length] as const).filter(
|
|
1043
|
+
([, count]) => count > 0,
|
|
1044
|
+
);
|
|
1045
|
+
const parkedTotal = parked.reduce((total, [, count]) => total + count, 0);
|
|
1046
|
+
if (parkedTotal > 0) {
|
|
1047
|
+
out.push(
|
|
1048
|
+
`${parkedTotal} constraint(s) sit in top-level arrays (${parked.map(([kind, count]) => `${kind} ${count}`).join(', ')}) ` +
|
|
1049
|
+
'and this reader takes constraints from "constraints" alone, so it reads none of them and the rebuilt rig has none',
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const renamed = arr(root.bones)
|
|
1054
|
+
.filter((bone) => isObj(bone) && LEGACY_BONE_INHERIT_KEY in bone)
|
|
1055
|
+
.map((bone) => nameOf(bone));
|
|
1056
|
+
if (renamed.length > 0) {
|
|
1057
|
+
out.push(
|
|
1058
|
+
`${renamed.length} bone(s) carry "${LEGACY_BONE_INHERIT_KEY}" where 4.3 spells "inherit" (${spellSome(renamed)}), ` +
|
|
1059
|
+
'each dropped as a field the rig spec has no home for — the BONE_FIELD line beside this one — so the ' +
|
|
1060
|
+
'rebuilt bone inherits Normally',
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const physics = [...arr(root.physics), ...arr(root.constraints).filter((one) => isObj(one) && one.type === 'physics')].filter(
|
|
1065
|
+
isObj,
|
|
1066
|
+
);
|
|
1067
|
+
const omitting = PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED.map(
|
|
1068
|
+
(field) => [field, physics.filter((one) => one[field] === undefined).length] as const,
|
|
1069
|
+
).filter(([, count]) => count > 0);
|
|
1070
|
+
if (omitting.length > 0) {
|
|
1071
|
+
out.push(
|
|
1072
|
+
`${physics.length} physics constraint(s), of which ${omitting.map(([field, count]) => `${count} omit "${field}"`).join(' and ')} — ` +
|
|
1073
|
+
"JSON omits a field equal to the parser's default and that default is NOT the same number in 4.2 as in 4.3 " +
|
|
1074
|
+
'(#706 row 4), so the omission means one rig there and a different one here',
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
return out;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* The generation, read before a field of the file is.
|
|
1082
|
+
*
|
|
1083
|
+
* 🚨 **The silence this converts into a name was measured on the branch point.**
|
|
1084
|
+
* A 4.3 emit with its constraints moved into the top-level arrays 4.2 kept them
|
|
1085
|
+
* in came back through `ingest` as a rig spec with **zero** constraints and
|
|
1086
|
+
* **no finding about them at all**; the only blocker was `BONE_FIELD`, about the
|
|
1087
|
+
* bone key. A 3.8 label produced one `LOSS HEADER_REDERIVED` line and exit 0.
|
|
1088
|
+
* That is the shape this whole module exists to refuse — a decompiler that is
|
|
1089
|
+
* quiet about what it dropped.
|
|
1090
|
+
*
|
|
1091
|
+
* ⭐ **One code with the generation in the sentence, rather than one code per
|
|
1092
|
+
* generation.** `IG25` derives `docs/INGEST.md` §2.0's finding table by reading
|
|
1093
|
+
* every `note` call in this file for a code matching `[A-Z_]+`, and compares it
|
|
1094
|
+
* against rows matched with `[A-Z_<>]+`. A composed `GENERATION_${generation}`
|
|
1095
|
+
* would read `GENERATION_3.8` at runtime — digits and a dot — so the call would
|
|
1096
|
+
* be one the scan cannot resolve and the code would be missing from BOTH sides
|
|
1097
|
+
* of that comparison, which is the one failure comparing two sets cannot report.
|
|
1098
|
+
*
|
|
1099
|
+
* ⚠️ The same scan counts its own population with a second, dumber pattern over
|
|
1100
|
+
* the raw text, comments included — so a prose mention of that call spelled with
|
|
1101
|
+
* its opening bracket raises the count without raising the sites, and `IG25`
|
|
1102
|
+
* goes red on a file with nothing wrong in it. It did, on the first green run of
|
|
1103
|
+
* this change: **25 of 26 read**, the 26th being this very paragraph.
|
|
1104
|
+
*
|
|
1105
|
+
* ⚠️ It does NOT refuse the file. Everything here is a finding and both specs
|
|
1106
|
+
* are still written, for the reason `IngestFinding` states: a spec plus a list
|
|
1107
|
+
* of what is missing from it beats no spec. The exit code is the caller's and it
|
|
1108
|
+
* is 1, because this is a `blocker`.
|
|
1109
|
+
*/
|
|
1110
|
+
function readGeneration(root: JsonObject, note: Note): void {
|
|
1111
|
+
const declared = obj(root.skeleton).spine;
|
|
1112
|
+
const generation = typeof declared === 'string' ? spineGeneration(declared) : null;
|
|
1113
|
+
if (generation !== null && generation === READER_GENERATION) return;
|
|
1114
|
+
const stated = typeof declared === 'string' ? `${JSON.stringify(declared)}` : 'no `skeleton.spine` at all';
|
|
1115
|
+
const reads = READER_GENERATION ?? '(none — this build\'s own version string is unreadable)';
|
|
1116
|
+
const losses = generationLosses(root);
|
|
1117
|
+
const measured =
|
|
1118
|
+
losses.length > 0
|
|
1119
|
+
? `Measured on this file: ${losses.join('; ')}.`
|
|
1120
|
+
: 'Measured on this file: no constraint in a top-level array, no bone carrying ' +
|
|
1121
|
+
`"${LEGACY_BONE_INHERIT_KEY}", and no physics constraint omitting a default that moved — which is three ` +
|
|
1122
|
+
'shapes counted and not a guarantee that nothing else differs.';
|
|
1123
|
+
if (generation === null) {
|
|
1124
|
+
note(
|
|
1125
|
+
'blocker',
|
|
1126
|
+
'GENERATION_UNKNOWN',
|
|
1127
|
+
'skeleton.spine',
|
|
1128
|
+
`the file states ${stated} and no Spine generation matches it. A version is read as its LEADING major.minor ` +
|
|
1129
|
+
'token — a down-export states "4.0-from-4.1.24", which is 4.0 data from a 4.1 editor — and the generations ' +
|
|
1130
|
+
`rigc knows are ${SPINE_GENERATIONS.join(', ')}; this reader reads ${reads}. It is NOT read as the nearest ` +
|
|
1131
|
+
'one: a catalogue that handed 19 skeletons labelled "3.8.99" the nearest runtime it had loaded every one of ' +
|
|
1132
|
+
`them and posed 238 of 248 bones as NaN (issue #706 row 7). ${measured}`,
|
|
1133
|
+
);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
note(
|
|
1137
|
+
'blocker',
|
|
1138
|
+
'GENERATION_UNSUPPORTED',
|
|
1139
|
+
'skeleton.spine',
|
|
1140
|
+
`the file states ${stated}, which is Spine ${generation} data, and this reader reads Spine ${reads} only — it ` +
|
|
1141
|
+
`inverts a ${SPINE_VERSION} emitter. A generation mismatch does not throw; it drops what the newer format ` +
|
|
1142
|
+
`moved. ${measured} Reading the file with ${generation}'s OWN defaults is issue #706 item 2 — a ` +
|
|
1143
|
+
'per-generation table extracted by machine from each runtime\'s `SkeletonJson` — and is not in this tool. ' +
|
|
1144
|
+
`Re-export from a ${reads} editor, or transcribe the file by hand (docs/INGEST.md §2).`,
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** The four fields a stage is, in the order the editor and `compile` write them. */
|
|
1149
|
+
const STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Does this header declare a stage?
|
|
1153
|
+
*
|
|
1154
|
+
* 🔒 One reading, three callers below — the refusal, the origin default and the
|
|
1155
|
+
* early return — because they are three statements about the same header and two
|
|
1156
|
+
* spellings of "declares a stage" would be two things that have to agree. The
|
|
1157
|
+
* EXTENT is what declares one: an origin for a box that is not there is a shape
|
|
1158
|
+
* no export carries, which is how `diff`'s `stageFacts` and `compile`'s stage
|
|
1159
|
+
* guard already read it.
|
|
1160
|
+
*/
|
|
1161
|
+
function declaresStage(head: JsonObject): boolean {
|
|
1162
|
+
return head.width !== undefined && head.height !== undefined;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* A stage as one string, for a message that has to put two of them side by side.
|
|
1167
|
+
*
|
|
1168
|
+
* The origin is spelled `0` where it is absent, for the reason `HEADER_ORIGIN`
|
|
1169
|
+
* writes it: inside a declared extent that is what the omission means, so a
|
|
1170
|
+
* refusal that printed the file's box as `undefined,undefined,…` would be
|
|
1171
|
+
* quoting the file against the reading every other part of this tree holds.
|
|
1172
|
+
*/
|
|
1173
|
+
function spellStage(x: unknown, y: unknown, width: unknown, height: unknown): string {
|
|
1174
|
+
return [x ?? 0, y ?? 0, width, height].map((value) => String(value)).join(',');
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* The rig spec's header fields a skeleton's own header can state — every one
|
|
1179
|
+
* but `stageBox`, which is the caller's reading of a slot (`--stage-box`,
|
|
1180
|
+
* issue #1168) and never a key of the Spine header: a header that carried a
|
|
1181
|
+
* key of that name would otherwise be copied into the rebuilt spec as a
|
|
1182
|
+
* request for a box nobody named.
|
|
1183
|
+
*/
|
|
1184
|
+
const HEADER_FIELDS_FROM_FILE: readonly string[] = RIG_KEYS.RigSkeletonHeader.filter((field) => field !== 'stageBox');
|
|
1185
|
+
|
|
1186
|
+
/** The stage box `--stage-box` read (`readStageBox`): its slot, its attachment and the stage its four corners are. */
|
|
1187
|
+
interface StageBoxRead {
|
|
1188
|
+
slot: string;
|
|
1189
|
+
attachment: string;
|
|
1190
|
+
stage: IngestStage;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Read the stage from the bounding box in `slotName` (issue #1168,
|
|
1195
|
+
* `--stage-box`): the box `build` writes for a rig that asks for one
|
|
1196
|
+
* (`skeleton.stageBox`), read back as the rebuild's stage.
|
|
1197
|
+
*
|
|
1198
|
+
* 🔒 **Only a box `build` could write back is read, and everything else is
|
|
1199
|
+
* refused by name** — the slot is named by the caller, so a shape that is not
|
|
1200
|
+
* the stage is a mistake to report, not a guess to make. Refused: a slot the
|
|
1201
|
+
* skeleton does not have; a slot on a bone other than the root, or on a root
|
|
1202
|
+
* that states a setup transform (the box would not be the stage in world
|
|
1203
|
+
* space, and `build` refuses the same); a slot with no attachment in the
|
|
1204
|
+
* `default` skin, more than one, or one in another skin; an attachment that
|
|
1205
|
+
* is not a bounding box, states a `name` other than its placeholder, or is not
|
|
1206
|
+
* four unweighted vertices; four vertices that are not the corners of one
|
|
1207
|
+
* axis-aligned rectangle of positive size.
|
|
1208
|
+
*
|
|
1209
|
+
* The stage is the rectangle's least corner and its extent. What the rebuild
|
|
1210
|
+
* writes differently is one lossy finding, `STAGE_BOX_REWRITTEN`: `build`
|
|
1211
|
+
* writes the corners bottom-left first and counter-clockwise and states no
|
|
1212
|
+
* `color`, so a box in another order or with the editor's colour comes back as
|
|
1213
|
+
* the same rectangle, spelled `build`'s way.
|
|
1214
|
+
*/
|
|
1215
|
+
function readStageBox(root: JsonObject, slotName: string, note: Note): StageBoxRead {
|
|
1216
|
+
const flag = `--stage-box ${slotName}`;
|
|
1217
|
+
const slot = arr(root.slots).map(obj).find((s) => s.name === slotName);
|
|
1218
|
+
if (slot === undefined) {
|
|
1219
|
+
throw new IngestError(`${flag}: the skeleton has no slot "${slotName}"; it declares [${arr(root.slots).map(nameOf).join(', ')}]`);
|
|
1220
|
+
}
|
|
1221
|
+
const bones = arr(root.bones).map(obj);
|
|
1222
|
+
const bone = bones.find((b) => b.name === slot.bone);
|
|
1223
|
+
if (bone === undefined || bone.parent !== undefined) {
|
|
1224
|
+
throw new IngestError(
|
|
1225
|
+
`${flag}: slot "${slotName}" hangs on bone "${String(slot.bone)}", which is not the root. A stage box is the stage's ` +
|
|
1226
|
+
"corners in world space, and build writes one only on the root — on any other bone the box's numbers are not the stage",
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
const moved: string[] = [
|
|
1230
|
+
...['x', 'y', 'rotation', 'shearX', 'shearY'].filter((key) => bone[key] !== undefined && bone[key] !== 0),
|
|
1231
|
+
...['scaleX', 'scaleY'].filter((key) => bone[key] !== undefined && bone[key] !== 1),
|
|
1232
|
+
];
|
|
1233
|
+
if (moved.length > 0) {
|
|
1234
|
+
throw new IngestError(
|
|
1235
|
+
`${flag}: slot "${slotName}" hangs on the root "${nameOf(bone)}", which states ${moved.map((key) => `${key} ${JSON.stringify(bone[key])}`).join(', ')}. ` +
|
|
1236
|
+
"A box on a root that moves at setup is not the stage in world space, and build refuses to write one there",
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
const elsewhere = arr(root.skins).map(obj).filter((skin) => skin.name !== 'default' && Object.keys(obj(obj(skin.attachments)[slotName])).length > 0);
|
|
1240
|
+
if (elsewhere.length > 0) {
|
|
1241
|
+
throw new IngestError(
|
|
1242
|
+
`${flag}: slot "${slotName}" is also filled by skin(s) ${elsewhere.map((skin) => `"${nameOf(skin)}"`).join(', ')}. A stage box is its ` +
|
|
1243
|
+
"slot's one attachment, in the default skin, so a reader of that slot finds the stage and nothing else",
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
const table = obj(obj(arr(root.skins).map(obj).find((skin) => skin.name === 'default')?.attachments)[slotName]);
|
|
1247
|
+
const placeholders = Object.keys(table);
|
|
1248
|
+
if (placeholders.length !== 1) {
|
|
1249
|
+
throw new IngestError(
|
|
1250
|
+
`${flag}: the default skin holds ${placeholders.length === 0 ? 'no attachment' : `${placeholders.length} attachments [${placeholders.join(', ')}]`} on slot "${slotName}"; ` +
|
|
1251
|
+
"a stage box is its slot's one attachment",
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
const attachment = placeholders[0];
|
|
1255
|
+
const att = obj(table[attachment]);
|
|
1256
|
+
const where = `${flag}: attachment "${attachment}" on slot "${slotName}"`;
|
|
1257
|
+
if (att.type !== 'boundingbox') throw new IngestError(`${where} is a ${JSON.stringify(att.type ?? 'region')}, not a bounding box`);
|
|
1258
|
+
if (att.name !== undefined && att.name !== attachment) {
|
|
1259
|
+
throw new IngestError(`${where} states the name ${JSON.stringify(att.name)}; a stage box is named by its placeholder, which is what build writes`);
|
|
1260
|
+
}
|
|
1261
|
+
const vertices = Array.isArray(att.vertices) ? att.vertices : [];
|
|
1262
|
+
if (att.vertexCount !== 4 || vertices.length !== 8 || !vertices.every((v) => typeof v === 'number' && Number.isFinite(v))) {
|
|
1263
|
+
throw new IngestError(
|
|
1264
|
+
`${where} states vertexCount ${JSON.stringify(att.vertexCount)} and ${vertices.length} vertex number(s); the stage is four unweighted ` +
|
|
1265
|
+
'corners — vertexCount 4 and eight finite numbers',
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
const xy = vertices as number[];
|
|
1269
|
+
const xs = [...new Set([xy[0], xy[2], xy[4], xy[6]])].sort((a, b) => a - b);
|
|
1270
|
+
const ys = [...new Set([xy[1], xy[3], xy[5], xy[7]])].sort((a, b) => a - b);
|
|
1271
|
+
const corners = new Set([0, 2, 4, 6].map((i) => `${xy[i]},${xy[i + 1]}`));
|
|
1272
|
+
if (xs.length !== 2 || ys.length !== 2 || corners.size !== 4) {
|
|
1273
|
+
throw new IngestError(`${where} holds [${xy.join(', ')}], which is not the four corners of one axis-aligned rectangle of positive size`);
|
|
1274
|
+
}
|
|
1275
|
+
const stage: IngestStage = { x: xs[0], y: ys[0], width: xs[1] - xs[0], height: ys[1] - ys[0] };
|
|
1276
|
+
const rewritten: string[] = [];
|
|
1277
|
+
const order = [xs[0], ys[0], xs[1], ys[0], xs[1], ys[1], xs[0], ys[1]];
|
|
1278
|
+
if (order.some((v, i) => v !== xy[i])) rewritten.push(`its corners [${xy.join(', ')}] in build's order [${order.join(', ')}], bottom-left first and counter-clockwise`);
|
|
1279
|
+
if (att.color !== undefined) rewritten.push(`no color (the source states ${JSON.stringify(att.color)}, an editor affordance build does not write on a stage box)`);
|
|
1280
|
+
if (rewritten.length > 0) {
|
|
1281
|
+
note('lossy', 'STAGE_BOX_REWRITTEN', `skin "default" slot "${slotName}" attachment "${attachment}"`, `the rebuild writes the same rectangle with ${rewritten.join(', and ')}`);
|
|
1282
|
+
}
|
|
1283
|
+
return { slot: slotName, attachment, stage };
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* The rig spec's `skeleton` block — and the one judgement in this module.
|
|
1288
|
+
*
|
|
1289
|
+
* 🚨 **A skeleton JSON need not carry a box, and a stage cannot be derived.**
|
|
1290
|
+
* The stage is the working area the art was painted in; no pose of the rig
|
|
1291
|
+
* states it. So a file that declares none is written as declaring none
|
|
1292
|
+
* — `"width": null, "height": null`, the rig spec's spelling for that claim since
|
|
1293
|
+
* issue #578 — and `compile` then emits a header with none of the four fields,
|
|
1294
|
+
* which is the file that was read, byte for byte. No finding: nothing was lost,
|
|
1295
|
+
* nothing re-derived and nobody decided anything, and a line saying so would be
|
|
1296
|
+
* a finding about a file that rebuilds exactly (issue #714).
|
|
1297
|
+
*
|
|
1298
|
+
* ⚠️ **That is the shape of a whole production corpus, not a corner.** Until
|
|
1299
|
+
* #714 this branch was a `NO_STAGE` blocker and the only road through it was a
|
|
1300
|
+
* caller's `--stage` — a number the source never stated. Issue #714 counts 48 of
|
|
1301
|
+
* 48 production exports at 4.3.26 carrying no box; all twelve exports under
|
|
1302
|
+
* `examples/` carry all four fields, and take the declared branch below.
|
|
1303
|
+
*
|
|
1304
|
+
* 🔸 **Half a stage is still a blocker, and keeps the code.** A header that
|
|
1305
|
+
* states an origin with no extent, or one extent without the other, declares no
|
|
1306
|
+
* stage — the extent is what declares one — but it is not the absence either:
|
|
1307
|
+
* the rig spec holds a stage as four fields or none (`parseRigSpec` refuses the
|
|
1308
|
+
* pair `null` beside an `x`, and one extent alone is `compile`'s `no stage size`),
|
|
1309
|
+
* so the rebuild cannot carry what the file states. No export measured here has
|
|
1310
|
+
* that shape; the blocker names the fields it does state.
|
|
1311
|
+
*
|
|
1312
|
+
* ⭐ It is still the judgement that costs least to get wrong. `diff` does report
|
|
1313
|
+
* the header's box — `bounds_present` and `bounds_box` (issue #578, renamed by
|
|
1314
|
+
* #907) — but they sit in the `(reported)` block that no rung consults.
|
|
1315
|
+
*
|
|
1316
|
+
* 🔁 **What the box becomes (issue #907).** A header's box is its setup-pose
|
|
1317
|
+
* bounding box — what the format says the four are, and what every editor
|
|
1318
|
+
* export carries there. It is the only box a file has, so it becomes the
|
|
1319
|
+
* rebuild's stage (`A14` and `A19` measure against it, as they did against the
|
|
1320
|
+
* export's header). The rebuild's own header is not carried from it: `build`
|
|
1321
|
+
* computes the setup-pose bounding box of what it draws (`headerBoundsOf` in
|
|
1322
|
+
* `src/compile.ts`), which for a rebuild drawing the source's vertices is
|
|
1323
|
+
* spine-core's `getBounds` over the source on the header's 1e-6 grid at float32 — not the editor's
|
|
1324
|
+
* arithmetic, which on the twelve examples sits up to 0.0071 units away. A
|
|
1325
|
+
* rigc build's own header is likewise its bounding box, not its stage: the
|
|
1326
|
+
* stage is in `skeleton.model.json`, which the caller reads and hands in as
|
|
1327
|
+
* `documentStage` when that document digests this skeleton — and then it is
|
|
1328
|
+
* read in place of the header's box.
|
|
1329
|
+
*
|
|
1330
|
+
* 🔇 **Both of its silences were here, and both were around the DECLARED branch
|
|
1331
|
+
* rather than the missing one.** That branch used to be a bare early return, so
|
|
1332
|
+
* an origin the source omitted left no trace at all (issue #622) and a `--stage`
|
|
1333
|
+
* given beside a declared box was read after it and therefore never (issue #626).
|
|
1334
|
+
* Neither was wrong — the rebuild carried the right numbers both times — which is
|
|
1335
|
+
* exactly the shape this whole module exists to convert into something named:
|
|
1336
|
+
* a decompiler that is right for a reason it never states is a decompiler nobody
|
|
1337
|
+
* can check.
|
|
1338
|
+
*/
|
|
1339
|
+
function ingestHeader(source: JsonObject, opts: IngestOptions, note: Note, box: StageBoxRead | null = null): JsonObject {
|
|
1340
|
+
// The stage box the caller named (issue #1168) is the file's own statement of its stage, so a second source beside it is refused, as `--stage` beside a declared box is.
|
|
1341
|
+
if (box !== null) {
|
|
1342
|
+
const spelled = spellStage(box.stage.x, box.stage.y, box.stage.width, box.stage.height);
|
|
1343
|
+
if (opts.stage !== undefined) {
|
|
1344
|
+
throw new IngestError(
|
|
1345
|
+
`--stage-box ${box.slot} reads a stage of ${spelled} from the skeleton's bounding box "${box.attachment}" and --stage supplied ` +
|
|
1346
|
+
`${spellStage(opts.stage.x, opts.stage.y, opts.stage.width, opts.stage.height)}: two sources for one value. Drop one of the two flags`,
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
const doc = opts.documentStage;
|
|
1350
|
+
if (doc !== undefined && (doc === null || spellStage(doc.x, doc.y, doc.width, doc.height) !== spelled)) {
|
|
1351
|
+
throw new IngestError(
|
|
1352
|
+
`--stage-box ${box.slot} reads a stage of ${spelled} from the skeleton's bounding box "${box.attachment}", and the model document ` +
|
|
1353
|
+
`beside it states ${doc === null ? 'no stage' : spellStage(doc.x, doc.y, doc.width, doc.height)}: two statements of one stage that ` +
|
|
1354
|
+
"disagree, about one build. rigc will not choose between them — A50_STAGE_BOX_IS_THE_STAGE refuses such a build, so one of the files was edited after it",
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
// A rigc build's stage is its model document's (issue #907, `IngestOptions.documentStage`) or its stage box's (issue #1168): read in place of the header's box.
|
|
1359
|
+
const replaced = box !== null ? box.stage : opts.documentStage;
|
|
1360
|
+
const head: JsonObject = replaced === undefined ? source : Object.fromEntries(Object.entries(source).filter(([key]) => !(STAGE_FIELDS as readonly string[]).includes(key)));
|
|
1361
|
+
if (replaced) Object.assign(head, replaced);
|
|
1362
|
+
// 🚨 Before a line of transcription, because a header the caller contradicted
|
|
1363
|
+
// is not a header to start writing a spec from (issue #626).
|
|
1364
|
+
if (declaresStage(head) && opts.stage !== undefined) {
|
|
1365
|
+
throw new IngestError(
|
|
1366
|
+
`the skeleton declares a stage of ${spellStage(head.x, head.y, head.width, head.height)} and --stage supplied ` +
|
|
1367
|
+
`${spellStage(opts.stage.x, opts.stage.y, opts.stage.width, opts.stage.height)}: two sources for one value. ` +
|
|
1368
|
+
'rigc will not overwrite a box the file states — the file is the record of what was measured, and the flag ' +
|
|
1369
|
+
'is for a skeleton that declares none. Drop --stage, or correct `skeleton` in the source if its box is wrong',
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
const out: JsonObject = {};
|
|
1373
|
+
for (const field of HEADER_FIELDS_FROM_FILE) if (head[field] !== undefined) out[field] = head[field];
|
|
1374
|
+
// The stage box is the caller's reading of a slot, never a header key: a Spine header has no such field (issue #1168).
|
|
1375
|
+
if (box !== null) out.stageBox = { slot: box.slot, attachment: box.attachment };
|
|
1376
|
+
for (const key of Object.keys(head)) {
|
|
1377
|
+
if (HEADER_FIELDS_FROM_FILE.includes(key)) continue;
|
|
1378
|
+
if (HEADER_REDERIVED.includes(key)) {
|
|
1379
|
+
// Two-sided on purpose: the same fact reads as bookkeeping when the two
|
|
1380
|
+
// agree and as a warning when they do not, and a reader needs to be told
|
|
1381
|
+
// which — a rebuild of a 4.2 export states 4.3, in one field, silently.
|
|
1382
|
+
const same = head[key] === SPINE_VERSION;
|
|
1383
|
+
note(
|
|
1384
|
+
'lossy',
|
|
1385
|
+
'HEADER_REDERIVED',
|
|
1386
|
+
`skeleton.${key}`,
|
|
1387
|
+
`the source states ${JSON.stringify(head[key])} and the rig spec has no field for it: a rebuild writes the ` +
|
|
1388
|
+
`version of the runtime rigc links, ${SPINE_VERSION}` +
|
|
1389
|
+
(same ? ', which is the same string, so nothing moves' : ' — so this field WILL change on the rebuild'),
|
|
1390
|
+
);
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
note(
|
|
1394
|
+
'lossy',
|
|
1395
|
+
'HEADER_BOOKKEEPING',
|
|
1396
|
+
`skeleton.${key}`,
|
|
1397
|
+
`the editor writes "${key}" and the rig spec has no field for it; it is dropped and nothing reads it back`,
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
if (declaresStage(head)) {
|
|
1401
|
+
// ⭐ **Inside a declared extent, an omitted origin IS `0`** (issue #620),
|
|
1402
|
+
// which is a reading of the format rather than a value invented for a gap:
|
|
1403
|
+
// `compile` assembles `header.x = rig.skeleton?.x ?? 0` under this same
|
|
1404
|
+
// guard, `diff`'s `stageFacts` reads the omission the same way, and
|
|
1405
|
+
// `stageFacts`'s own comment carries the four measurements behind it. So the
|
|
1406
|
+
// spec states what the file meant instead of leaving the rebuild to a
|
|
1407
|
+
// default in another module — and the half that has to be said out loud is
|
|
1408
|
+
// the other one: the rebuilt header SPELLS a field the source omitted
|
|
1409
|
+
// (issue #622).
|
|
1410
|
+
const omitted = ['x', 'y'].filter((field) => head[field] === undefined);
|
|
1411
|
+
if (omitted.length > 0) {
|
|
1412
|
+
out.x = head.x ?? 0;
|
|
1413
|
+
out.y = head.y ?? 0;
|
|
1414
|
+
note(
|
|
1415
|
+
'lossy',
|
|
1416
|
+
'HEADER_ORIGIN',
|
|
1417
|
+
`skeleton.${omitted.join('/')}`,
|
|
1418
|
+
`the source declares a ${String(head.width)}x${String(head.height)} stage and omits ` +
|
|
1419
|
+
`${omitted.map((field) => `"${field}"`).join(' and ')}; inside a declared extent an omitted origin is 0, ` +
|
|
1420
|
+
'which is how `compile` and `diff` read it (#620), so the rig spec states x=' +
|
|
1421
|
+
`${String(out.x)}, y=${String(out.y)} rather than leaving the rebuild to a default in another module. ` +
|
|
1422
|
+
`⚠️ The rebuilt header WILL spell ${omitted.length > 1 ? 'those fields' : 'that field'}: it carries the ` +
|
|
1423
|
+
'setup-pose bounding box `build` computes (#907), whose origin is wherever the art sits',
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
return out;
|
|
1427
|
+
}
|
|
1428
|
+
if (opts.stage === undefined) {
|
|
1429
|
+
const stated = STAGE_FIELDS.filter((field) => head[field] !== undefined);
|
|
1430
|
+
if (stated.length === 0) {
|
|
1431
|
+
// The absence, carried: the pair goes where `RIG_KEYS` orders it, so the
|
|
1432
|
+
// spec reads like one a transcriber would have written by hand.
|
|
1433
|
+
const carried: JsonObject = {};
|
|
1434
|
+
for (const field of HEADER_FIELDS_FROM_FILE) {
|
|
1435
|
+
if (field === 'width' || field === 'height') carried[field] = null;
|
|
1436
|
+
else if (out[field] !== undefined) carried[field] = out[field];
|
|
1437
|
+
}
|
|
1438
|
+
return carried;
|
|
1439
|
+
}
|
|
1440
|
+
const unstated = STAGE_FIELDS.filter((field) => head[field] === undefined);
|
|
1441
|
+
note(
|
|
1442
|
+
'blocker',
|
|
1443
|
+
'NO_STAGE',
|
|
1444
|
+
'skeleton.width/height',
|
|
1445
|
+
`the skeleton states ${stated.map((field) => `"${field}"`).join(', ')} and no ` +
|
|
1446
|
+
`${unstated.map((field) => `"${field}"`).join(', ')}, so it declares no stage — a width and a height are ` +
|
|
1447
|
+
'what declare one — and it is not the absence either. A rig spec holds a stage as four fields or none, so ' +
|
|
1448
|
+
'the rebuild cannot carry what this header states. Give --stage x,y,w,h if the box is known — the value is ' +
|
|
1449
|
+
'the caller\'s, not derived: posing the rig would give the ANIMATED extent, which is a different number from ' +
|
|
1450
|
+
'the setup box — or take the stray field(s) out of the source, and the absence is then carried as it stands',
|
|
1451
|
+
);
|
|
1452
|
+
return out;
|
|
1453
|
+
}
|
|
1454
|
+
Object.assign(out, opts.stage);
|
|
1455
|
+
note(
|
|
1456
|
+
'judgement',
|
|
1457
|
+
'NO_STAGE',
|
|
1458
|
+
'skeleton.width/height',
|
|
1459
|
+
`the skeleton declares no stage and the caller supplied ${opts.stage.x},${opts.stage.y},${opts.stage.width},` +
|
|
1460
|
+
`${opts.stage.height}. Nothing measured it against the art: \`A14\` and \`A19\` measure the art against it ` +
|
|
1461
|
+
'and `diff` reports it, so a wrong box is green everywhere. Without --stage the absence is carried instead, ' +
|
|
1462
|
+
'and the rebuild declares no stage either',
|
|
1463
|
+
);
|
|
1464
|
+
return out;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* One attachment, by type. Inverts `buildRigAttachment`'s five branches.
|
|
1469
|
+
*
|
|
1470
|
+
* The types rigc does not emit are refused BY NAME rather than dropped, which is
|
|
1471
|
+
* the same reason `buildRigAttachment` refuses them: the parser's own behaviour
|
|
1472
|
+
* on a type it does not know is to return null and carry on, so a decompiler
|
|
1473
|
+
* that skipped one would hand back a spec that is quietly missing an attachment.
|
|
1474
|
+
*/
|
|
1475
|
+
function ingestAttachment(
|
|
1476
|
+
att: JsonObject,
|
|
1477
|
+
placeholder: string,
|
|
1478
|
+
boneNames: readonly string[],
|
|
1479
|
+
opts: IngestOptions,
|
|
1480
|
+
note: Note,
|
|
1481
|
+
at: { where: string },
|
|
1482
|
+
): JsonObject {
|
|
1483
|
+
// `readAttachment` reads no `type` as `region` (`SkeletonJson:539`), and so
|
|
1484
|
+
// does `checkRigSpecKeys`. Both defaults are the parser's, not a guess.
|
|
1485
|
+
const type = att.type === undefined ? 'region' : String(att.type);
|
|
1486
|
+
const out: JsonObject = {};
|
|
1487
|
+
|
|
1488
|
+
// A LINKED mesh, in either of the format's two spellings. The format decides
|
|
1489
|
+
// it — one branch of the reader for `mesh` and `linkedmesh`, and a truthy
|
|
1490
|
+
// `source` decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
|
|
1491
|
+
// `source` inverts to a link, and a `linkedmesh` with none does NOT: that map
|
|
1492
|
+
// is read as an ordinary mesh, whose `uvs` it does not have, and the parser
|
|
1493
|
+
// throws on it. Reading the second as a link would be this module inventing a
|
|
1494
|
+
// construct the file does not contain (issue #691).
|
|
1495
|
+
const linked = (type === 'mesh' || type === 'linkedmesh') && typeof att.source === 'string' && att.source.length > 0;
|
|
1496
|
+
|
|
1497
|
+
// The three types the parser gives a texture `path` to — and reads a
|
|
1498
|
+
// `sequence` on. `readAttachment` reaches `getValue(map, "path", name)` in the
|
|
1499
|
+
// `region` branch (`SkeletonJson.js:529`) and in the shared `mesh`/`linkedmesh`
|
|
1500
|
+
// branch (`:560`); `boundingbox`, `path`, `point` and `clipping` are
|
|
1501
|
+
// constructed from the name alone and resolve no region at all.
|
|
1502
|
+
const resolvesRegion = linked || type === 'region' || type === 'mesh';
|
|
1503
|
+
|
|
1504
|
+
/** The texture side, which the skeleton does not encode. Inverts `buildRigRegion`'s tail. */
|
|
1505
|
+
const carryArt = (): void => {
|
|
1506
|
+
if (att.path !== undefined) out.path = att.path;
|
|
1507
|
+
// `buildRigRegion` writes `path` when the image basename differs from the
|
|
1508
|
+
// name the attachment carries — its stated `name`, else the placeholder —
|
|
1509
|
+
// so naming the image after the region this attachment RESOLVES
|
|
1510
|
+
// (`path ?? name ?? placeholder`, the defaults the format's reader applies
|
|
1511
|
+
// at `SkeletonJson.js:526`, `:529`, `:560`) reproduces the same `path`
|
|
1512
|
+
// decision AND the same atlas region name.
|
|
1513
|
+
if (opts.art === 'loose') {
|
|
1514
|
+
const region = att.path ?? (typeof att.name === 'string' ? att.name : placeholder);
|
|
1515
|
+
out.image = `${String(region)}.png`;
|
|
1516
|
+
}
|
|
1517
|
+
if (att.width !== undefined) out.width = att.width;
|
|
1518
|
+
if (att.height !== undefined) out.height = att.height;
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* The vertex array, as one of the two encodings `readVertices` decides between.
|
|
1523
|
+
*
|
|
1524
|
+
* Inverts `buildVertexGeometry` / `encodeNamedWeights`: the run is unweighted
|
|
1525
|
+
* when it is exactly as long as the coordinate count the attachment declares,
|
|
1526
|
+
* and a weight run otherwise. That length comparison is the parser's own.
|
|
1527
|
+
*/
|
|
1528
|
+
const geometry = (declaredPairs: number | undefined): void => {
|
|
1529
|
+
const vertices = numbers(att.vertices);
|
|
1530
|
+
if (vertices === undefined) return;
|
|
1531
|
+
if (declaredPairs !== undefined && vertices.length === declaredPairs * 2) out.vertices = vertices;
|
|
1532
|
+
else out.weights = decodeWeights(vertices, boneNames);
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
const vertexCount = typeof att.vertexCount === 'number' ? att.vertexCount : undefined;
|
|
1536
|
+
|
|
1537
|
+
if (linked) {
|
|
1538
|
+
out.type = 'linkedmesh';
|
|
1539
|
+
carryArt();
|
|
1540
|
+
out.source = att.source;
|
|
1541
|
+
// Only what the file states. `slot`, `skin` and `timelines` each have a
|
|
1542
|
+
// parser default (this attachment's slot, the default skin, true), and
|
|
1543
|
+
// writing one the source omitted would be a rebuild that says more than the
|
|
1544
|
+
// file did — and `buildRigLinkedMesh` drops it again on the way back out.
|
|
1545
|
+
for (const field of ['slot', 'skin', 'timelines', 'color']) if (att[field] !== undefined) out[field] = att[field];
|
|
1546
|
+
const dropped = LINKED_MESH_UNREAD_KEYS.filter((field) => att[field] !== undefined);
|
|
1547
|
+
if (dropped.length > 0) {
|
|
1548
|
+
note(
|
|
1549
|
+
'lossy',
|
|
1550
|
+
'ATTACHMENT_LINK_GEOMETRY',
|
|
1551
|
+
at.where,
|
|
1552
|
+
`the attachment is a LINKED mesh and states ${dropped.map((field) => `\`${field}\``).join(', ')}, which the ` +
|
|
1553
|
+
'parser reads with nothing at all: it returns from the `source` branch before `readVertices` ' +
|
|
1554
|
+
`(\`SkeletonJson.ts:582-586\`), so what this attachment draws is the geometry of ${JSON.stringify(att.source)}. ` +
|
|
1555
|
+
`The rebuild drops ${dropped.length === 1 ? 'it' : 'them'} — the rig spec refuses geometry on a link by name, ` +
|
|
1556
|
+
'and carrying it would write a spec `build` will not take. `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` ' +
|
|
1557
|
+
'is the same fact held against the source file',
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
} else if (type === 'region') {
|
|
1561
|
+
carryArt();
|
|
1562
|
+
for (const field of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'color']) {
|
|
1563
|
+
if (att[field] !== undefined) out[field] = att[field];
|
|
1564
|
+
}
|
|
1565
|
+
} else if (type === 'mesh') {
|
|
1566
|
+
out.type = 'mesh';
|
|
1567
|
+
carryArt();
|
|
1568
|
+
out.uvs = att.uvs;
|
|
1569
|
+
out.triangles = att.triangles;
|
|
1570
|
+
geometry(Array.isArray(att.uvs) ? att.uvs.length / 2 : undefined);
|
|
1571
|
+
// Carried rather than re-derived: `authoredHullAndEdges` takes an authored
|
|
1572
|
+
// pair as written and cross-checks `hull` against the triangles, so stating
|
|
1573
|
+
// both keeps the emitted arrays identical instead of equal-by-derivation.
|
|
1574
|
+
if (att.hull !== undefined) out.hull = att.hull;
|
|
1575
|
+
if (att.edges !== undefined) out.edges = att.edges;
|
|
1576
|
+
if (att.color !== undefined) out.color = att.color;
|
|
1577
|
+
} else if (type === 'boundingbox' || type === 'clipping') {
|
|
1578
|
+
out.type = type;
|
|
1579
|
+
out.vertexCount = att.vertexCount;
|
|
1580
|
+
geometry(vertexCount);
|
|
1581
|
+
if (att.color !== undefined) out.color = att.color;
|
|
1582
|
+
if (type === 'clipping') {
|
|
1583
|
+
for (const field of ['end', 'convex', 'inverse']) if (att[field] !== undefined) out[field] = att[field];
|
|
1584
|
+
}
|
|
1585
|
+
} else if (type === 'path') {
|
|
1586
|
+
out.type = 'path';
|
|
1587
|
+
out.vertexCount = att.vertexCount;
|
|
1588
|
+
geometry(vertexCount);
|
|
1589
|
+
for (const field of ['closed', 'constantSpeed', 'color']) if (att[field] !== undefined) out[field] = att[field];
|
|
1590
|
+
// Carried verbatim (issue #804), where until then it was dropped as `LOSS
|
|
1591
|
+
// PATH_LENGTHS` and re-measured. The editor measures it on the pose the
|
|
1592
|
+
// first update gives the path constraint — constraints applied — and rigc
|
|
1593
|
+
// does not pose, so the re-measure was a different number on every path a
|
|
1594
|
+
// constraint moves at rest, and on every weighted path over a scaled bone.
|
|
1595
|
+
if (att.lengths !== undefined) out.lengths = att.lengths;
|
|
1596
|
+
} else {
|
|
1597
|
+
note(
|
|
1598
|
+
'blocker',
|
|
1599
|
+
`ATTACHMENT_${type.toUpperCase()}`,
|
|
1600
|
+
at.where,
|
|
1601
|
+
`attachment type ${JSON.stringify(type)} is in the Spine 4.3 format and rigc does not emit it (it emits ` +
|
|
1602
|
+
`${ATTACHMENT_TYPES.join(', ')}; point is the one deferred type left — docs/SPEC_COVERAGE.md part 1-6 says ` +
|
|
1603
|
+
'what it would carry). The rebuild will not have this attachment',
|
|
1604
|
+
);
|
|
1605
|
+
return out;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// A numbered image series (issue #729). Carried as the file states it —
|
|
1609
|
+
// the four fields `readSequence` reads — wherever the rig spec can say it;
|
|
1610
|
+
// what is left for the blocker is a block the parser reads into a series
|
|
1611
|
+
// that is not the one written, or one on a kind the parser never reads it on.
|
|
1612
|
+
// `null` is the parser's own absent (`getValue(map, "sequence", null)`), so it
|
|
1613
|
+
// is read as no series rather than as a malformed one.
|
|
1614
|
+
if (att.sequence !== undefined && att.sequence !== null) {
|
|
1615
|
+
const refusal = resolvesRegion
|
|
1616
|
+
? sequenceBlockRefusal(att.sequence)
|
|
1617
|
+
: `the attachment is a ${type}, and \`readAttachment\` reads a \`sequence\` only on a region or a mesh ` +
|
|
1618
|
+
'(`SkeletonJson.js:530`, `:561`) — the parser never read this one, and the rig spec refuses it there by name';
|
|
1619
|
+
if (refusal === null) {
|
|
1620
|
+
const seq = att.sequence as JsonObject;
|
|
1621
|
+
const carried: JsonObject = {};
|
|
1622
|
+
for (const field of ['count', 'start', 'digits', 'setup']) if (seq[field] !== undefined) carried[field] = seq[field];
|
|
1623
|
+
out.sequence = carried;
|
|
1624
|
+
// The frames ARE the art: `<path><number>` regions, which on the loose
|
|
1625
|
+
// route are PNGs of those names. A single `image` would name one region
|
|
1626
|
+
// the series does not have, and the rig spec refuses the pair.
|
|
1627
|
+
delete out.image;
|
|
1628
|
+
} else {
|
|
1629
|
+
note(
|
|
1630
|
+
'blocker',
|
|
1631
|
+
'ATTACHMENT_SEQUENCE',
|
|
1632
|
+
at.where,
|
|
1633
|
+
`the attachment's \`sequence\` block is ${JSON.stringify(att.sequence)}: ${refusal}. The rebuild draws the ` +
|
|
1634
|
+
'single region this attachment names instead of a series',
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
// 🔑 The attachment's own `name`, carried VERBATIM wherever the source states
|
|
1639
|
+
// one — and nowhere else (issue #796). It is the runtime's `Attachment.name`
|
|
1640
|
+
// (`getValue(map, "name", placeholder)`, `SkeletonJson.js:526`) and, with no
|
|
1641
|
+
// `path`, the region the attachment draws; the rig spec has a field for it
|
|
1642
|
+
// and `compile` writes exactly what that field states, so nothing about it is
|
|
1643
|
+
// lost and nothing is re-derived. A name equal to its placeholder is carried
|
|
1644
|
+
// too: the source spelled it, and the rebuild is held to the source's text.
|
|
1645
|
+
// Put right after `type`, the order `RIG_KEYS` gives the field.
|
|
1646
|
+
if (att.name === undefined) return out;
|
|
1647
|
+
const { type: carriedType, ...rest } = out;
|
|
1648
|
+
return carriedType === undefined ? { name: att.name, ...rest } : { type: carriedType, name: att.name, ...rest };
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Why a `sequence` block cannot be carried as written, or `null` when it can —
|
|
1653
|
+
* the rig spec's own refusals (`checkRigSequence`), stated about a file.
|
|
1654
|
+
*
|
|
1655
|
+
* Every one of them is a series the parser loads into something other than
|
|
1656
|
+
* what the file says (issue #729): no `count` is 0 regions, a `setup` past the
|
|
1657
|
+
* end is clamped, a fraction names a region like `stem1.5`.
|
|
1658
|
+
*/
|
|
1659
|
+
function sequenceBlockRefusal(seq: unknown): string | null {
|
|
1660
|
+
if (typeof seq !== 'object' || seq === null || Array.isArray(seq)) {
|
|
1661
|
+
return 'a sequence is an object of `count`, `start`, `digits` and `setup`';
|
|
1662
|
+
}
|
|
1663
|
+
const block = seq as JsonObject;
|
|
1664
|
+
if (block.count === undefined) {
|
|
1665
|
+
return 'it states no `count`, and `readSequence` reads 0 — the attachment loads holding no region and draws nothing';
|
|
1666
|
+
}
|
|
1667
|
+
for (const [field, min] of [['count', 1], ['start', 0], ['digits', 0], ['setup', 0]] as const) {
|
|
1668
|
+
const value = block[field];
|
|
1669
|
+
if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < min)) {
|
|
1670
|
+
return `\`${field}\` is ${JSON.stringify(value)}, and the rig spec takes a whole number of at least ${min} there`;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
if (typeof block.setup === 'number' && block.setup >= (block.count as number)) {
|
|
1674
|
+
return `\`setup\` ${block.setup} is past the end of a ${String(block.count)}-frame series, and \`Sequence.resolveIndex\` clamps it to the last frame`;
|
|
1675
|
+
}
|
|
1676
|
+
return null;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
|
|
1680
|
+
function ingestAnimation(
|
|
1681
|
+
animName: string,
|
|
1682
|
+
anim: JsonObject,
|
|
1683
|
+
root: JsonObject,
|
|
1684
|
+
note: Note,
|
|
1685
|
+
inert: ReadonlyMap<string, unknown>,
|
|
1686
|
+
omitted: (constraint: string, property: string, lastKey: number) => void,
|
|
1687
|
+
): JsonObject {
|
|
1688
|
+
const tracks: JsonObject[] = [];
|
|
1689
|
+
let maxT = 0;
|
|
1690
|
+
const seeT = (t: number): void => {
|
|
1691
|
+
if (t > maxT) maxT = t;
|
|
1692
|
+
};
|
|
1693
|
+
const timeOf = (key: JsonObject): number => {
|
|
1694
|
+
const t = typeof key.time === 'number' ? key.time : 0;
|
|
1695
|
+
seeT(t);
|
|
1696
|
+
return t;
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* A key's easing, as the motion spec spells it.
|
|
1701
|
+
*
|
|
1702
|
+
* Inverts `rawCurve`: `"stepped"` is a named easing the compiler passes
|
|
1703
|
+
* through, and an array is the absolute (time, value) control points, four per
|
|
1704
|
+
* channel, which `curve` takes verbatim. A key with neither is linear.
|
|
1705
|
+
*/
|
|
1706
|
+
const easing = (key: JsonObject, out: JsonObject): void => {
|
|
1707
|
+
if (key.curve === 'stepped') out.ease = 'stepped';
|
|
1708
|
+
else if (key.curve !== undefined) out.curve = key.curve;
|
|
1709
|
+
};
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* A value track of any of the four families. Inverts `compileValueTrack`.
|
|
1713
|
+
*
|
|
1714
|
+
* ⚠️ An EDITOR omits a channel that equals the parser's default, and the
|
|
1715
|
+
* spec's `v` is positional — so an omission is filled at that channel's
|
|
1716
|
+
* default, which is the value the runtime reads there. The emitter leaves
|
|
1717
|
+
* the same channel out again wherever `PARSER_DEFAULTS` has a row for the
|
|
1718
|
+
* key's kind (issue #716), so the rebuild is the source's own text there and
|
|
1719
|
+
* nothing is said. What is reported, once per track, is a filled channel the
|
|
1720
|
+
* emitter WILL write — a kind with no measured row — because that one is a
|
|
1721
|
+
* restatement rather than a copy and a reader should know which.
|
|
1722
|
+
*/
|
|
1723
|
+
const valueTrack = (target: JsonObject, property: string, keys: readonly unknown[], shape: TrackShape, where: string): void => {
|
|
1724
|
+
const fields = shape.map(([field]) => field);
|
|
1725
|
+
const out: JsonObject[] = [];
|
|
1726
|
+
let restated = 0;
|
|
1727
|
+
const family = Object.keys(target)[0];
|
|
1728
|
+
const row = PARSER_DEFAULTS[`${family} ${property} key`];
|
|
1729
|
+
for (const raw of keys) {
|
|
1730
|
+
const key = obj(raw);
|
|
1731
|
+
const entry: JsonObject = { t: timeOf(key) };
|
|
1732
|
+
const filled: string[] = [];
|
|
1733
|
+
// The zero-field branch: `reset` IS the event, so the key carries no value
|
|
1734
|
+
// and the spec spells that `null`.
|
|
1735
|
+
entry.v =
|
|
1736
|
+
shape.length === 0
|
|
1737
|
+
? null
|
|
1738
|
+
: shape.map(([field, dflt]) => {
|
|
1739
|
+
if (key[field] !== undefined) return key[field];
|
|
1740
|
+
filled.push(field);
|
|
1741
|
+
// A string default names another field of THIS key (`mixY` ->
|
|
1742
|
+
// `mixX`); when that one is absent too the chain ends at 1, which
|
|
1743
|
+
// is what `ConstraintChannel.dflt` holds for both.
|
|
1744
|
+
if (typeof dflt !== 'string') return dflt;
|
|
1745
|
+
return key[dflt] === undefined ? 1 : key[dflt];
|
|
1746
|
+
});
|
|
1747
|
+
if (filled.length > 0) {
|
|
1748
|
+
// The key as the emitter will hold it — every channel stated — asked
|
|
1749
|
+
// the one question the emitter asks of it.
|
|
1750
|
+
const emitted: JsonObject = { ...key };
|
|
1751
|
+
shape.forEach(([field], i) => (emitted[field] = (entry.v as JsonObject[string][])[i]));
|
|
1752
|
+
const site = { object: emitted, previous: () => null };
|
|
1753
|
+
restated += filled.filter((field) => row === undefined || !parserOmits(row, site, field)).length;
|
|
1754
|
+
}
|
|
1755
|
+
easing(key, entry);
|
|
1756
|
+
for (const field of Object.keys(key)) {
|
|
1757
|
+
if (field === 'time' || field === 'curve' || fields.includes(field)) continue;
|
|
1758
|
+
note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
|
|
1759
|
+
}
|
|
1760
|
+
out.push(entry);
|
|
1761
|
+
}
|
|
1762
|
+
if (restated > 0) {
|
|
1763
|
+
note(
|
|
1764
|
+
'lossy',
|
|
1765
|
+
'TIMELINE_KEY_RESTATED',
|
|
1766
|
+
where,
|
|
1767
|
+
`${restated} channel value(s) the source omits are written out at the parser's default (${shape
|
|
1768
|
+
.map(([field, dflt]) => `${field}=${String(dflt)}`)
|
|
1769
|
+
.join(', ')}), because the motion spec's \`v\` is positional and the emitter has no measured row ` +
|
|
1770
|
+
`for "${family} ${property}" keys to leave them out by — the same values the runtime reads`,
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
tracks.push({ ...target, property, keys: out });
|
|
1774
|
+
};
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* One family of constraint timelines: `<family>.<constraint>.<timeline>`.
|
|
1778
|
+
*
|
|
1779
|
+
* ⭐ All three tables are now COMPLETE against `SkeletonJson`'s switch for
|
|
1780
|
+
* their group, so the blocker below no longer fires on anything the runtime
|
|
1781
|
+
* plays — it is reachable only for a timeline name the parser itself falls
|
|
1782
|
+
* through (`:1094` for physics, and neither the path nor the slider switch
|
|
1783
|
+
* has a default either). It is kept rather than deleted because `ingest`'s
|
|
1784
|
+
* contract is byte identity and not equivalence: a name nothing reads is
|
|
1785
|
+
* still a name the rebuild does not write. The alternative — demoting it to
|
|
1786
|
+
* `lossy`, on the argument that the rebuilt skeleton plays identically — is
|
|
1787
|
+
* a decision about all three families and is not made here.
|
|
1788
|
+
*/
|
|
1789
|
+
/**
|
|
1790
|
+
* The physics constraints the rebuild carries — every one the file declares
|
|
1791
|
+
* but those `ingest` omits as inert — which is what an unnamed physics
|
|
1792
|
+
* timeline can reach in it.
|
|
1793
|
+
*/
|
|
1794
|
+
const carriedPhysics = arr(root.constraints)
|
|
1795
|
+
.map(obj)
|
|
1796
|
+
.filter((one) => one.type === 'physics' && !(typeof one.name === 'string' && inert.has(one.name)));
|
|
1797
|
+
/** Unnamed physics timelines that reach nothing in the rebuild, said once the duration is known. */
|
|
1798
|
+
const unreached: Array<{ property: string; lastKey: number }> = [];
|
|
1799
|
+
const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
|
|
1800
|
+
for (const [name, timelines] of objEntries(anim[group])) {
|
|
1801
|
+
for (const [property, keys] of arrEntries(timelines)) {
|
|
1802
|
+
// A timeline keyed to a physics constraint `ingest` omitted (issue #731)
|
|
1803
|
+
// goes with it: carried, it names a constraint the rebuilt rig has not
|
|
1804
|
+
// got, and `build` refuses the whole motion spec over it. Its keys are
|
|
1805
|
+
// not seen by `seeT` — they are not in the rebuild — and their latest
|
|
1806
|
+
// time is handed back so the finding can say when that shortened one.
|
|
1807
|
+
if (group === 'physics' && inert.has(name)) {
|
|
1808
|
+
let lastKey = 0;
|
|
1809
|
+
for (const raw of keys) {
|
|
1810
|
+
const t = obj(raw).time;
|
|
1811
|
+
if (typeof t === 'number' && t > lastKey) lastKey = t;
|
|
1812
|
+
}
|
|
1813
|
+
omitted(name, property, lastKey);
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
const shape = shapes[property];
|
|
1817
|
+
const where = `animation "${animName}" ${group} "${name}" ${property}`;
|
|
1818
|
+
if (shape === undefined) {
|
|
1819
|
+
note('blocker', `${group.toUpperCase()}_TIMELINE`, where, `timeline "${property}" is not in the motion spec`);
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
// The empty name is the physics group's timeline that names no
|
|
1823
|
+
// constraint (issue #726), which the motion spec spells `"*"`. It
|
|
1824
|
+
// writes every carried constraint declaring the property global —
|
|
1825
|
+
// `reset` every one — and one that reaches none is a no-op the rebuild
|
|
1826
|
+
// would be refused over by name, so it goes, and is said, instead.
|
|
1827
|
+
if (group === 'physics' && name === '') {
|
|
1828
|
+
const reaches =
|
|
1829
|
+
property === 'reset' ? carriedPhysics.length > 0 : carriedPhysics.some((one) => Boolean(one[`${property}Global`]));
|
|
1830
|
+
if (!reaches) {
|
|
1831
|
+
let lastKey = 0;
|
|
1832
|
+
for (const raw of keys) {
|
|
1833
|
+
const t = obj(raw).time;
|
|
1834
|
+
if (typeof t === 'number' && t > lastKey) lastKey = t;
|
|
1835
|
+
}
|
|
1836
|
+
unreached.push({ property, lastKey });
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
valueTrack({ physics: EVERY_GLOBAL_PHYSICS }, property, keys, shape, where);
|
|
1840
|
+
continue;
|
|
1841
|
+
}
|
|
1842
|
+
valueTrack({ [group]: name }, property, keys, shape, where);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
};
|
|
1846
|
+
|
|
1847
|
+
for (const [bone, timelines] of objEntries(anim.bones)) {
|
|
1848
|
+
for (const [property, keys] of arrEntries(timelines)) {
|
|
1849
|
+
const shape = BONE_TRACKS[property];
|
|
1850
|
+
const where = `animation "${animName}" bone "${bone}" ${property}`;
|
|
1851
|
+
if (property === INHERIT_TRACK.property) {
|
|
1852
|
+
// `{ time, inherit }` -> `{ t, v: mode }`. The mode is carried in the
|
|
1853
|
+
// table's spelling, which is what `build` writes back; a key that omits
|
|
1854
|
+
// it, or spells it with a capital the runtime also folds, is a key the
|
|
1855
|
+
// rebuild states differently and is counted as such. A spelling the
|
|
1856
|
+
// runtime cannot resolve at all is carried as written — the file plays
|
|
1857
|
+
// no mode there, and `build` refuses it by name rather than guessing one.
|
|
1858
|
+
let restated = 0;
|
|
1859
|
+
const out = keys.map((raw) => {
|
|
1860
|
+
const key = obj(raw);
|
|
1861
|
+
const entry: JsonObject = { t: timeOf(key) };
|
|
1862
|
+
const written = key[INHERIT_TRACK.field];
|
|
1863
|
+
const mode = written === undefined ? INHERIT_TRACK.dflt : resolveBoneInherit(written);
|
|
1864
|
+
if (mode !== written && mode !== undefined) restated++;
|
|
1865
|
+
entry.v = mode ?? (written as JsonObject[string]);
|
|
1866
|
+
for (const field of Object.keys(key)) {
|
|
1867
|
+
if (field === 'time' || field === INHERIT_TRACK.field) continue;
|
|
1868
|
+
note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
|
|
1869
|
+
}
|
|
1870
|
+
return entry;
|
|
1871
|
+
});
|
|
1872
|
+
if (restated > 0) {
|
|
1873
|
+
note(
|
|
1874
|
+
'lossy',
|
|
1875
|
+
'TIMELINE_KEY_RESTATED',
|
|
1876
|
+
where,
|
|
1877
|
+
`${restated} key(s) omit the mode or spell it with a capital first letter, and are written out as the ` +
|
|
1878
|
+
`mode the runtime reads there (an omitted one is ${INHERIT_TRACK.dflt}) — the same mode, in the ` +
|
|
1879
|
+
'spelling the editor writes',
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
tracks.push({ bone, property, keys: out });
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
if (shape === undefined) {
|
|
1886
|
+
note(
|
|
1887
|
+
'blocker',
|
|
1888
|
+
'BONE_TIMELINE',
|
|
1889
|
+
where,
|
|
1890
|
+
`timeline "${property}" has no track in the motion spec — a bone track is ${BONE_TRACK_NAMES.join(', ')} ` +
|
|
1891
|
+
'and nothing else, so the rebuild plays nothing here',
|
|
1892
|
+
);
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
valueTrack({ bone }, property, keys, shape, where);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
for (const [slot, timelines] of objEntries(anim.slots)) {
|
|
1900
|
+
for (const [property, keys] of arrEntries(timelines)) {
|
|
1901
|
+
const where = `animation "${animName}" slot "${slot}" ${property}`;
|
|
1902
|
+
if (property === 'attachment') {
|
|
1903
|
+
// Inverts `compileTrack`'s attachment branch: `{time, name}`, where a
|
|
1904
|
+
// null name is "show nothing". Attachment keys are stepped by nature and
|
|
1905
|
+
// carry no curve at all.
|
|
1906
|
+
tracks.push({
|
|
1907
|
+
slot,
|
|
1908
|
+
property: 'attachment',
|
|
1909
|
+
keys: keys.map((raw) => {
|
|
1910
|
+
const key = obj(raw);
|
|
1911
|
+
return { t: timeOf(key), v: key.name === undefined ? null : key.name };
|
|
1912
|
+
}),
|
|
1913
|
+
});
|
|
1914
|
+
} else if (property === 'rgba') {
|
|
1915
|
+
// Inverts `compileTrack`'s rgba branch, whose key is `{time, color}`.
|
|
1916
|
+
tracks.push({
|
|
1917
|
+
slot,
|
|
1918
|
+
property: 'rgba',
|
|
1919
|
+
keys: keys.map((raw) => {
|
|
1920
|
+
const key = obj(raw);
|
|
1921
|
+
const entry: JsonObject = { t: timeOf(key), v: hexToRgba(String(key.color)) };
|
|
1922
|
+
easing(key, entry);
|
|
1923
|
+
return entry;
|
|
1924
|
+
}),
|
|
1925
|
+
});
|
|
1926
|
+
} else if (property === 'rgba2') {
|
|
1927
|
+
// Inverts `compileTrack`'s rgba2 branch, whose key is `{time, light,
|
|
1928
|
+
// dark}`. The spec's `v` concatenates the two in the format's own
|
|
1929
|
+
// channel order — light r g b a, then dark r g b — which is the order
|
|
1930
|
+
// `readCurve` indexes a curve array by, so a key and its curve stay
|
|
1931
|
+
// parallel through the round trip.
|
|
1932
|
+
tracks.push({
|
|
1933
|
+
slot,
|
|
1934
|
+
property: 'rgba2',
|
|
1935
|
+
keys: keys.map((raw) => {
|
|
1936
|
+
const key = obj(raw);
|
|
1937
|
+
const entry: JsonObject = {
|
|
1938
|
+
t: timeOf(key),
|
|
1939
|
+
v: [...hexToRgba(String(key.light)), ...hexToRgb(String(key.dark))],
|
|
1940
|
+
};
|
|
1941
|
+
easing(key, entry);
|
|
1942
|
+
return entry;
|
|
1943
|
+
}),
|
|
1944
|
+
});
|
|
1945
|
+
} else if (property === 'alpha') {
|
|
1946
|
+
// Inverts `compileTrack`'s alpha branch, whose key is `{time, value}` —
|
|
1947
|
+
// the one colour shape the format stores as a number, read by
|
|
1948
|
+
// `readTimeline1` with a per-key default of **0**. That is a value
|
|
1949
|
+
// track's shape exactly, so it goes through the same inversion and
|
|
1950
|
+
// inherits its two findings: an omitted `value` is written out at 0 and
|
|
1951
|
+
// reported as `TIMELINE_KEY_RESTATED`, and a field the shape has no
|
|
1952
|
+
// place for is a `TIMELINE_FIELD` blocker (issue #730).
|
|
1953
|
+
valueTrack({ slot }, property, keys, [['value', 0]], where);
|
|
1954
|
+
} else if (property === 'rgb' || property === 'rgb2') {
|
|
1955
|
+
// Inverts `compileTrack`'s other two separable shapes (issue #730):
|
|
1956
|
+
// `rgb` is `{time, color: "rrggbb"}` and `rgb2` is `{time, light:
|
|
1957
|
+
// "rrggbb", dark: "rrggbb"}`, and the spec's `v` is their channels in
|
|
1958
|
+
// `readCurve`'s order. Each keeps its own name and its own key times:
|
|
1959
|
+
// folding an `rgb` and an `alpha` into one `rgba` would state each
|
|
1960
|
+
// channel at the other's key times, a value nobody keyed.
|
|
1961
|
+
tracks.push({
|
|
1962
|
+
slot,
|
|
1963
|
+
property,
|
|
1964
|
+
keys: keys.map((raw) => {
|
|
1965
|
+
const key = obj(raw);
|
|
1966
|
+
const v =
|
|
1967
|
+
property === 'rgb'
|
|
1968
|
+
? hexToRgb(String(key.color))
|
|
1969
|
+
: [...hexToRgb(String(key.light)), ...hexToRgb(String(key.dark))];
|
|
1970
|
+
const entry: JsonObject = { t: timeOf(key), v };
|
|
1971
|
+
easing(key, entry);
|
|
1972
|
+
return entry;
|
|
1973
|
+
}),
|
|
1974
|
+
});
|
|
1975
|
+
} else {
|
|
1976
|
+
// 🔒 Composed from both tables, so it says the true thing whichever of
|
|
1977
|
+
// two states it is reached in. A name the FORMAT has and the spec does
|
|
1978
|
+
// not is the first; since issue #730 carried the last three there is no
|
|
1979
|
+
// such name (`UNSPELT_SLOT_TRACKS` is empty), and what still reaches
|
|
1980
|
+
// here is a name the format does not have at all — which
|
|
1981
|
+
// `SkeletonJson.readAnimation` throws on (`Invalid timeline type for a
|
|
1982
|
+
// slot`), so the sentence says so instead of printing an empty
|
|
1983
|
+
// "remaining" list about a file no runtime loads.
|
|
1984
|
+
const inFormat = property in CHANNELS_BY_KIND.slot;
|
|
1985
|
+
note(
|
|
1986
|
+
'blocker',
|
|
1987
|
+
'SLOT_TIMELINE',
|
|
1988
|
+
where,
|
|
1989
|
+
(inFormat
|
|
1990
|
+
? `timeline "${property}" is in the format and the motion spec has no track for it`
|
|
1991
|
+
: `timeline "${property}" is not a slot timeline the format has — the runtime's reader throws ` +
|
|
1992
|
+
'"Invalid timeline type for a slot" on it, so no player loads this file') +
|
|
1993
|
+
` — a slot track is ${SLOT_TRACKS.join(' or ')} and nothing else, so the rebuild plays nothing here` +
|
|
1994
|
+
(UNSPELT_SLOT_TRACKS.length > 0
|
|
1995
|
+
? `. The format's remaining slot timelines are ${UNSPELT_SLOT_TRACKS.join(', ')}`
|
|
1996
|
+
: ''),
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
family('path', PATH_TRACKS);
|
|
2003
|
+
family('physics', PHYSICS_TRACKS);
|
|
2004
|
+
family('slider', SLIDER_TRACKS);
|
|
2005
|
+
|
|
2006
|
+
const ik = constraintGroup('ik', animName, anim, root, IK_KEY_DEFAULTS, seeT, note);
|
|
2007
|
+
const transform = constraintGroup('transform', animName, anim, root, TRANSFORM_KEY_DEFAULTS, seeT, note);
|
|
2008
|
+
|
|
2009
|
+
// deform — `attachments.<skin>.<slot>.<attachment>.<timeline>`.
|
|
2010
|
+
// Inverts `compileDeformTrack`, whose emitted key is `{time, offset?, vertices?}`
|
|
2011
|
+
// and whose `offset` is omitted at 0 (the parser's default).
|
|
2012
|
+
const deform: JsonObject[] = [];
|
|
2013
|
+
// sequence — the other attachment timeline, inverting `compileSequenceTrack`:
|
|
2014
|
+
// `{time, mode?, index?, delay?}` with each field written only where the file
|
|
2015
|
+
// wrote it, because each has a parser default (`"hold"`, 0, the previous
|
|
2016
|
+
// key's delay) and restating one would be a rebuild saying more than the file.
|
|
2017
|
+
const sequence: JsonObject[] = [];
|
|
2018
|
+
for (const [skinName, perSkin] of objEntries(anim.attachments)) {
|
|
2019
|
+
for (const [slot, perSlot] of objEntries(perSkin)) {
|
|
2020
|
+
for (const [attachment, timelines] of objEntries(perSlot)) {
|
|
2021
|
+
for (const [property, keys] of arrEntries(timelines)) {
|
|
2022
|
+
const where = `animation "${animName}" ${skinName}/${slot}/${attachment}`;
|
|
2023
|
+
if (property === 'sequence') {
|
|
2024
|
+
const entry: JsonObject = {
|
|
2025
|
+
slot,
|
|
2026
|
+
attachment,
|
|
2027
|
+
keys: keys.map((raw) => {
|
|
2028
|
+
const key = obj(raw);
|
|
2029
|
+
const out: JsonObject = { t: timeOf(key) };
|
|
2030
|
+
for (const field of ['mode', 'index', 'delay']) if (key[field] !== undefined) out[field] = key[field];
|
|
2031
|
+
return out;
|
|
2032
|
+
}),
|
|
2033
|
+
};
|
|
2034
|
+
if (skinName !== 'default') entry.skin = skinName;
|
|
2035
|
+
sequence.push(entry);
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
2038
|
+
if (property !== 'deform') {
|
|
2039
|
+
// Reached only by a name OUTSIDE the format: `readAnimation` tests
|
|
2040
|
+
// an attachment timeline for "deform" and "sequence" and reads
|
|
2041
|
+
// nothing else (`SkeletonJson.js:1147-1201`), so no player plays it.
|
|
2042
|
+
note(
|
|
2043
|
+
'blocker',
|
|
2044
|
+
'ATTACHMENT_TIMELINE',
|
|
2045
|
+
where,
|
|
2046
|
+
`timeline "${property}" is not an attachment timeline the format has — the runtime's reader tests for ` +
|
|
2047
|
+
'"deform" and "sequence" and ignores anything else, and those two are what the motion spec carries',
|
|
2048
|
+
);
|
|
2049
|
+
continue;
|
|
2050
|
+
}
|
|
2051
|
+
const entry: JsonObject = {
|
|
2052
|
+
slot,
|
|
2053
|
+
attachment,
|
|
2054
|
+
keys: keys.map((raw) => {
|
|
2055
|
+
const key = obj(raw);
|
|
2056
|
+
const out: JsonObject = { t: timeOf(key) };
|
|
2057
|
+
if (key.offset !== undefined) out.offset = key.offset;
|
|
2058
|
+
if (key.vertices !== undefined) out.vertices = key.vertices;
|
|
2059
|
+
easing(key, out);
|
|
2060
|
+
return out;
|
|
2061
|
+
}),
|
|
2062
|
+
};
|
|
2063
|
+
// `skin` is absent for the default skin, which is the spec's own
|
|
2064
|
+
// spelling (`MotionDeformTrack.skin`: absent = "default").
|
|
2065
|
+
if (skinName !== 'default') entry.skin = skinName;
|
|
2066
|
+
deform.push(entry);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// drawOrder — inverts `compileDrawOrder`. A key with no `offsets` restores the
|
|
2073
|
+
// setup order; that is the parser's own encoding and the spec spells it the
|
|
2074
|
+
// same way, so an absent array stays absent.
|
|
2075
|
+
let drawOrder: JsonObject[] | undefined;
|
|
2076
|
+
if (Array.isArray(anim.drawOrder)) {
|
|
2077
|
+
drawOrder = anim.drawOrder.map((raw) => {
|
|
2078
|
+
const key = obj(raw);
|
|
2079
|
+
const out: JsonObject = { t: timeOf(key) };
|
|
2080
|
+
if (Array.isArray(key.offsets)) {
|
|
2081
|
+
out.offsets = key.offsets.map((o) => ({ slot: obj(o).slot, offset: obj(o).offset }));
|
|
2082
|
+
}
|
|
2083
|
+
return out;
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// events — inverts `compileEvents`. The payload fields are written only where
|
|
2088
|
+
// the firing overrides the declared event's own, which is what the file holds.
|
|
2089
|
+
let events: JsonObject[] | undefined;
|
|
2090
|
+
if (Array.isArray(anim.events)) {
|
|
2091
|
+
events = anim.events.map((raw) => {
|
|
2092
|
+
const key = obj(raw);
|
|
2093
|
+
const out: JsonObject = { t: timeOf(key), name: key.name };
|
|
2094
|
+
for (const field of ['int', 'float', 'string', 'volume', 'balance']) {
|
|
2095
|
+
if (key[field] !== undefined) out[field] = key[field];
|
|
2096
|
+
}
|
|
2097
|
+
return out;
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
for (const { property, lastKey } of unreached) {
|
|
2102
|
+
const flag = `${property}Global`;
|
|
2103
|
+
note(
|
|
2104
|
+
'lossy',
|
|
2105
|
+
'PHYSICS_GLOBAL_REACHES_NOTHING',
|
|
2106
|
+
`animation "${animName}" physics "" ${property}`,
|
|
2107
|
+
'names no constraint, so the runtime writes it into every physics constraint ' +
|
|
2108
|
+
(property === 'reset' ? 'the skeleton has' : `declaring "${flag}"`) +
|
|
2109
|
+
', and the rebuild carries ' +
|
|
2110
|
+
(carriedPhysics.length === 0
|
|
2111
|
+
? 'no physics constraint'
|
|
2112
|
+
: `none that does (${carriedPhysics.map((one) => `"${String(one.name)}"`).join(', ')})`) +
|
|
2113
|
+
` — it moves nothing, and \`build\` would refuse its \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` track by name. ` +
|
|
2114
|
+
'The motion spec omits it' +
|
|
2115
|
+
(lastKey > maxT
|
|
2116
|
+
? `, and an animation's duration is the last key it has left, so the rebuilt animation ends at ${maxT}s rather than ${lastKey}s`
|
|
2117
|
+
: '; the rebuild differs from the source by exactly this no-op'),
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
for (const group of Object.keys(anim)) {
|
|
2122
|
+
if (ANIMATION_GROUPS.includes(group)) continue;
|
|
2123
|
+
note(
|
|
2124
|
+
'blocker',
|
|
2125
|
+
'ANIMATION_GROUP',
|
|
2126
|
+
`animation "${animName}"`,
|
|
2127
|
+
`group "${group}" has no home in the motion spec — an animation group is ${ANIMATION_GROUPS.join(', ')} and ` +
|
|
2128
|
+
'nothing else, so the rebuild carries nothing from it',
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// 🚨 There is no duration in skeleton JSON. The largest key time is the only
|
|
2133
|
+
// derivable answer and it is what a runtime plays to; it is WRONG for an
|
|
2134
|
+
// animation that holds its last pose past its last key, and nothing in the
|
|
2135
|
+
// file distinguishes the two. Recorded per animation rather than hidden.
|
|
2136
|
+
note(
|
|
2137
|
+
'judgement',
|
|
2138
|
+
'DURATION',
|
|
2139
|
+
`animation "${animName}"`,
|
|
2140
|
+
`skeleton JSON carries no duration; the largest key time (${maxT}) is used, which is what a runtime plays to. ` +
|
|
2141
|
+
'An animation meant to hold past its last key needs the real number stated by hand',
|
|
2142
|
+
);
|
|
2143
|
+
|
|
2144
|
+
const out: JsonObject = { duration: maxT, tracks };
|
|
2145
|
+
if (ik.length) out.ik = ik;
|
|
2146
|
+
if (transform.length) out.transform = transform;
|
|
2147
|
+
if (deform.length) out.deform = deform;
|
|
2148
|
+
if (sequence.length) out.sequence = sequence;
|
|
2149
|
+
if (drawOrder !== undefined) out.drawOrder = drawOrder;
|
|
2150
|
+
if (events !== undefined) out.events = events;
|
|
2151
|
+
return out;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* `ik` / `transform` — one unnamed timeline per constraint.
|
|
2156
|
+
*
|
|
2157
|
+
* Inverts `compileConstraintTrack`, and this is the one inversion that has to
|
|
2158
|
+
* RESTATE rather than copy. Two reasons, and neither invents a value:
|
|
2159
|
+
*
|
|
2160
|
+
* 1. **The uniform field set.** Every field of these keys is optional with a
|
|
2161
|
+
* per-key default, so `compileConstraintTrack` refuses a track whose keys do
|
|
2162
|
+
* not all name the same fields — *"state it on every key or on none"*. An
|
|
2163
|
+
* export does not obey that: it omits a field wherever it equals the default.
|
|
2164
|
+
* So a field ANY key states is written on EVERY key, at the value the parser
|
|
2165
|
+
* would have read there. Identical semantics — and, since issue #716, the
|
|
2166
|
+
* same file wherever `PARSER_DEFAULTS` has the key's row, because the
|
|
2167
|
+
* emitter leaves each such value out again. What is still reported is a
|
|
2168
|
+
* value the emitter writes back.
|
|
2169
|
+
* 2. 🚨 **`rigFlags`.** `compileConstraintTrack` stamps the rig constraint's
|
|
2170
|
+
* non-default `bendPositive`/`compress`/`stretch` onto a key that omits one
|
|
2171
|
+
* (issue #273). On rigc's own output that is self-consistent — rigc already
|
|
2172
|
+
* wrote the flag on every key, so it is read back as stated. On a FOREIGN
|
|
2173
|
+
* export it would change what plays: the export's omission means the per-key
|
|
2174
|
+
* default, and the stamp would substitute the constraint's setup value. So a
|
|
2175
|
+
* flag the constraint declares non-default is written on every key at the
|
|
2176
|
+
* PARSER default, which is what the export actually plays.
|
|
2177
|
+
*/
|
|
2178
|
+
function constraintGroup(
|
|
2179
|
+
group: 'ik' | 'transform',
|
|
2180
|
+
animName: string,
|
|
2181
|
+
anim: JsonObject,
|
|
2182
|
+
root: JsonObject,
|
|
2183
|
+
defaults: Record<string, number | boolean | string>,
|
|
2184
|
+
seeT: (t: number) => void,
|
|
2185
|
+
note: Note,
|
|
2186
|
+
): JsonObject[] {
|
|
2187
|
+
const fields = Object.keys(defaults);
|
|
2188
|
+
const out: JsonObject[] = [];
|
|
2189
|
+
for (const [name, keys] of arrEntries(anim[group])) {
|
|
2190
|
+
const where = `animation "${animName}" ${group} "${name}"`;
|
|
2191
|
+
const stated = new Set<string>();
|
|
2192
|
+
for (const raw of keys) {
|
|
2193
|
+
const key = obj(raw);
|
|
2194
|
+
for (const field of fields) if (key[field] !== undefined) stated.add(field);
|
|
2195
|
+
}
|
|
2196
|
+
if (group === 'ik') {
|
|
2197
|
+
const constraint = arr(root.constraints)
|
|
2198
|
+
.map(obj)
|
|
2199
|
+
.find((c) => nameOf(c) === name && c.type === 'ik');
|
|
2200
|
+
for (const flag of IK_FLAGS) {
|
|
2201
|
+
if (constraint !== undefined && constraint[flag] !== undefined && constraint[flag] !== IK_KEY_DEFAULTS[flag]) {
|
|
2202
|
+
stated.add(flag);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
// A field filled on a key the source left it off is said only where the
|
|
2207
|
+
// emitter will write it: where `PARSER_DEFAULTS`' row for the key would
|
|
2208
|
+
// leave it out again, the rebuild is the source's own text (issue #716).
|
|
2209
|
+
const row = PARSER_DEFAULTS[`${group} key`];
|
|
2210
|
+
let restated = 0;
|
|
2211
|
+
const ks = keys.map((raw) => {
|
|
2212
|
+
const key = obj(raw);
|
|
2213
|
+
const entry: JsonObject = { t: typeof key.time === 'number' ? key.time : 0 };
|
|
2214
|
+
seeT(entry.t as number);
|
|
2215
|
+
const filled: string[] = [];
|
|
2216
|
+
for (const field of fields) {
|
|
2217
|
+
if (!stated.has(field)) continue;
|
|
2218
|
+
if (key[field] !== undefined) entry[field] = key[field];
|
|
2219
|
+
else {
|
|
2220
|
+
const dflt = defaults[field];
|
|
2221
|
+
// `mixY`'s default is the same key's own `mixX`, spelled as that field
|
|
2222
|
+
// name in the table above.
|
|
2223
|
+
entry[field] = typeof dflt === 'string' ? (key[dflt] !== undefined ? key[dflt] : defaults[dflt]) : dflt;
|
|
2224
|
+
filled.push(field);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
if (filled.length > 0) {
|
|
2228
|
+
const emitted: JsonObject = { ...key };
|
|
2229
|
+
for (const field of fields) if (entry[field] !== undefined) emitted[field] = entry[field];
|
|
2230
|
+
const site = { object: emitted, previous: () => null };
|
|
2231
|
+
restated += filled.filter((field) => row === undefined || !parserOmits(row, site, field)).length;
|
|
2232
|
+
}
|
|
2233
|
+
if (key.curve === 'stepped') entry.ease = 'stepped';
|
|
2234
|
+
else if (key.curve !== undefined) entry.curve = key.curve;
|
|
2235
|
+
for (const field of Object.keys(key)) {
|
|
2236
|
+
if (field === 'time' || field === 'curve' || fields.includes(field)) continue;
|
|
2237
|
+
note('blocker', `${group.toUpperCase()}_KEY_FIELD`, where, `key field "${field}" is not part of this timeline's shape`);
|
|
2238
|
+
}
|
|
2239
|
+
return entry;
|
|
2240
|
+
});
|
|
2241
|
+
if (restated > 0) {
|
|
2242
|
+
note(
|
|
2243
|
+
'lossy',
|
|
2244
|
+
'CONSTRAINT_KEY_RESTATED',
|
|
2245
|
+
where,
|
|
2246
|
+
`${restated} value(s) the source omits are restated at the parser's default, because the motion spec ` +
|
|
2247
|
+
'requires one field set per track and the emitter writes them back — the same values the runtime reads, ' +
|
|
2248
|
+
'spelled out',
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
out.push({ constraint: name, keys: ks });
|
|
2252
|
+
}
|
|
2253
|
+
return out;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* The provenance sentence both specs carry (INGEST §2.4's rule).
|
|
2258
|
+
*
|
|
2259
|
+
* ⚠️ A decompiled spec is indistinguishable from an authored one by inspection,
|
|
2260
|
+
* and every gate in this tree will call it green — because it IS green. No gate
|
|
2261
|
+
* catches a missing note, which is exactly why `ingest` writes one itself rather
|
|
2262
|
+
* than leaving it to the caller.
|
|
2263
|
+
*
|
|
2264
|
+
* 🔒 No timestamp, and that is a contract rather than a style: `A18` compares two
|
|
2265
|
+
* independent compiles byte for byte, and a dated note in a spec would break the
|
|
2266
|
+
* first rebuild from it.
|
|
2267
|
+
*/
|
|
2268
|
+
function provenanceNote(opts: IngestOptions, which: 'rig' | 'motion', consumerDriven = false): string {
|
|
2269
|
+
const head =
|
|
2270
|
+
`DECOMPILED from ${opts.source} by \`rigc ingest\` ${opts.version}. Every number here was read out of that ` +
|
|
2271
|
+
'skeleton; nothing was authored, so this file says what the object IS and nothing about why.';
|
|
2272
|
+
if (which === 'rig') {
|
|
2273
|
+
// Only where the declaration was written, so every other decompiled rig spec
|
|
2274
|
+
// keeps its note byte for byte.
|
|
2275
|
+
if (consumerDriven) {
|
|
2276
|
+
return (
|
|
2277
|
+
`${head} \`invariants\` holds only \`consumerDrivenMix\`, the constraints this run read as mixes the ` +
|
|
2278
|
+
'consumer sets (each is a CONSUMER_DRIVEN_MIX finding); it turns a refusal into a SKIP and certifies ' +
|
|
2279
|
+
'nothing. A skeleton declares no other invariant, and an assertion with nothing to measure must SKIP ' +
|
|
2280
|
+
'rather than pass.'
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
return (
|
|
2284
|
+
`${head} \`invariants\` is deliberately absent — a skeleton declares none, and an assertion with nothing to ` +
|
|
2285
|
+
'measure must SKIP rather than pass.'
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
return (
|
|
2289
|
+
`${head} Every curve is a raw \`curve\` array — the absolute (time, value) control points verbatim — because an ` +
|
|
2290
|
+
'export carries a different bezier per key per channel and no named easing can say that. Each `duration` is the ' +
|
|
2291
|
+
'largest key time in its animation, which is the only figure skeleton JSON supports.'
|
|
2292
|
+
);
|
|
2293
|
+
}
|