spine-rigc 1.9.0 → 1.9.1
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/cli.ts +9 -0
- package/docs/AUTHORING.md +2 -2
- package/package.json +1 -1
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a12.ts +30 -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/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/bone_timelines.ts +56 -0
- package/src/assertions/model/constraints.ts +34 -16
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/index.ts +64 -2
- package/src/assertions/model/linked_meshes.ts +2 -2
- package/src/assertions/model/mesh_attachments.ts +1 -1
- package/src/assertions/model/region_joins.ts +20 -12
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +18 -8
- package/src/assertions/model/skin_members.ts +5 -3
- package/src/assertions/model/skin_meshes.ts +1 -1
- package/src/assertions/model/slot_colour.ts +5 -4
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +9 -8
- package/src/compile.ts +114 -14
- package/src/deformmeasure.ts +35 -2270
- package/src/deformstructure.ts +60 -18
- package/src/deformsurvey.ts +2295 -0
- package/src/render.ts +80 -4
- package/src/validate.ts +361 -1103
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A39, the body (issue #1025, cut 4c-3 of step 4c of #380): a deform keeps
|
|
3
|
+
* every triangle's winding.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the deform
|
|
6
|
+
* survey as a fact (`../facts/deform_survey.ts`), where it called
|
|
7
|
+
* `surveyDeformKeys` on the loaded skeleton itself, and the rig info as an
|
|
8
|
+
* argument. The four sentences it prints a dial, a tie and a frame with moved
|
|
9
|
+
* with it, unchanged. The argument for the rule — the frame, why `archetype`,
|
|
10
|
+
* the keys it reads no winding off, the spans — is above its `check` call,
|
|
11
|
+
* which stays in `validate()`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Verdicts } from '../harness.ts';
|
|
14
|
+
import type { DeformSurveyFacts } from '../facts/deform_survey.ts';
|
|
15
|
+
import type { RigInfo } from '../../types.ts';
|
|
16
|
+
import { unreachableWhy, type DeformDialDispute, type DeformDialTie, type DeformReach, type DialSpan } from '../../deformsurvey.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The clause A39 puts after an animation's name when the frame it measured is
|
|
20
|
+
* not the track (issue #407).
|
|
21
|
+
*
|
|
22
|
+
* Empty on the track, which is what every animation no slider applies gets — so
|
|
23
|
+
* a message about a rig with no sliders in it reads exactly as it always has.
|
|
24
|
+
*/
|
|
25
|
+
function frameClause(reach: DeformReach): string {
|
|
26
|
+
return reach.kind === 'slider' ? ` (applied by slider "${reach.slider}", not played on a track)` : '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A dial's reach as A39's stats line spells it, or `none` when it can select no
|
|
31
|
+
* part of its animation at all.
|
|
32
|
+
*
|
|
33
|
+
* Six decimals because a key time has six: a reach whose end is printed coarser
|
|
34
|
+
* than the times it is compared against cannot be read against them.
|
|
35
|
+
*/
|
|
36
|
+
function dialSpanText(span: DialSpan | null): string {
|
|
37
|
+
return span === null ? 'none' : `${span.lo.toFixed(6)}..${span.hi.toFixed(6)}s`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One disputed dial on A39's stats line — both answers, both reaches, and the
|
|
42
|
+
* frames the artifact's answer could not have posed (issue #427).
|
|
43
|
+
*
|
|
44
|
+
* ⭐ **`outside:` is always there, `none` included.** The comparison it reports is
|
|
45
|
+
* what decides whether a disagreement changed anything the survey measured, and a
|
|
46
|
+
* comparison that came out equal must not look like one nobody made. It is the
|
|
47
|
+
* difference between "both answers pose the same frames, so the disagreement is a
|
|
48
|
+
* fact about rigc and not about this rig" and "these key times were surveyed
|
|
49
|
+
* through a field the skeleton does not name and no settable value of the one it
|
|
50
|
+
* does reaches them".
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ No spaces anywhere in it: the stats line is `k=v` pairs joined by spaces, and
|
|
53
|
+
* a value with a space in it turns one reading into two.
|
|
54
|
+
*/
|
|
55
|
+
function dialDisputeText(dispute: DeformDialDispute): string {
|
|
56
|
+
const stated = dispute.statedResponse === null ? 'unmeasured' : dispute.statedResponse.toExponential(3);
|
|
57
|
+
return (
|
|
58
|
+
`${dispute.slider}|artifact:${dispute.bone}.${dispute.stated}@${stated}` +
|
|
59
|
+
`|reaches:${dialSpanText(dispute.statedReach)}` +
|
|
60
|
+
`|probe:${dispute.bone}.${dispute.drive}@${dispute.driveResponse.toExponential(3)}` +
|
|
61
|
+
`|reaches:${dialSpanText(dispute.driveReach)}` +
|
|
62
|
+
`|outside:${dispute.outside.length === 0 ? 'none' : dispute.outside.map((t) => `${t.toFixed(6)}s`).join('+')}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One tied dial on the same line, in the shape that cannot be read as a dispute. */
|
|
67
|
+
function dialTieText(tie: DeformDialTie): string {
|
|
68
|
+
const rivals = tie.rivals.map((r) => `${tie.bone}.${r.field}@${r.response.toExponential(3)}`).join('+');
|
|
69
|
+
return `${tie.slider}|artifact:${tie.bone}.${tie.drive}@${tie.driveResponse.toExponential(3)}|tied:${rivals}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function a39DeformKeepsTriangleWinding({ fail, skip, stats }: Verdicts, facts: DeformSurveyFacts, rig: RigInfo | undefined): void {
|
|
73
|
+
if (!rig) {
|
|
74
|
+
return skip(
|
|
75
|
+
'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
|
|
76
|
+
'no rig info (validating a bare directory), so the rig cannot say which slots fold on purpose',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const survey = facts.survey(new Set(rig.deformMayFold));
|
|
80
|
+
// 🚨 Which dial was turned, when rigc's two halves did not simply agree
|
|
81
|
+
// about that — and BEFORE any of the returns below, because every one of
|
|
82
|
+
// them is a run that posed frames through this dial (issue #427).
|
|
83
|
+
//
|
|
84
|
+
// The verdict used to live in `DeformReach.label`, which `explain` prints
|
|
85
|
+
// and nothing else does, so a `build`-only run — the normal loop, and the
|
|
86
|
+
// one an agent that cannot see the rig actually runs — never learned that
|
|
87
|
+
// the artifact and the probe named different fields.
|
|
88
|
+
//
|
|
89
|
+
// ⛔ Not a refusal, and the measurement rather than taste is why (#427).
|
|
90
|
+
// The frames this survey posed were each checked against `SliderPose.time`
|
|
91
|
+
// by the runtime itself, so a disagreement cannot make it pose one that
|
|
92
|
+
// does not happen; and the field it drives is the largest response the
|
|
93
|
+
// probe found, so it cannot make it miss one that does. What a
|
|
94
|
+
// disagreement CAN do is leave the rig naming a property no settable value
|
|
95
|
+
// of turns far enough — which is what `outside` measures and what an
|
|
96
|
+
// author can act on. A refusal would refuse a rig spine-core poses
|
|
97
|
+
// correctly at every key, with no edit that would make it green.
|
|
98
|
+
//
|
|
99
|
+
// 🔒 A tie is not a disagreement and gets a line that cannot be read as
|
|
100
|
+
// one: there the probe named no field, the artifact broke the tie, and the
|
|
101
|
+
// parent-45° geometry that reaches it is legitimate.
|
|
102
|
+
if (survey.dialTies.length) {
|
|
103
|
+
stats.deformDialsTied = survey.dialTies.length;
|
|
104
|
+
stats.deformDialTied = survey.dialTies.map(dialTieText).join(',');
|
|
105
|
+
}
|
|
106
|
+
if (survey.dialDisputes.length) {
|
|
107
|
+
stats.deformDialsDisagreed = survey.dialDisputes.length;
|
|
108
|
+
stats.deformDialDisagreed = survey.dialDisputes.map(dialDisputeText).join(',');
|
|
109
|
+
}
|
|
110
|
+
/** A key this rule is refusing, by the triple that identifies it. */
|
|
111
|
+
const refusedKey = new Set<string>();
|
|
112
|
+
for (const key of survey.keys) {
|
|
113
|
+
// ⭐ The one thing this rule cannot say about a key that draws nothing.
|
|
114
|
+
// Its own message below states the harm as "draws its texture
|
|
115
|
+
// backwards", and that sentence is false when no pixel of the mesh
|
|
116
|
+
// lands at this time — the slot has faded to alpha 0, or shows another
|
|
117
|
+
// attachment. So the key is measured, reported and not gated (issue
|
|
118
|
+
// #401). Per key and per time: the SAME slot folding at full alpha in
|
|
119
|
+
// another animation, or at another key, is refused as before, which is
|
|
120
|
+
// what makes this a measurement rather than a second `deformMayFold`.
|
|
121
|
+
if (key.draw.blank !== null) continue;
|
|
122
|
+
// And the one thing it cannot say about a key at a time no dial selects
|
|
123
|
+
// (issue #407): the frame posed is not this key's, so its geometry
|
|
124
|
+
// belongs to some other time and a winding read off it would be a
|
|
125
|
+
// measurement of the wrong thing. Named below, on the stats line.
|
|
126
|
+
if (key.dial?.unreachable === true) continue;
|
|
127
|
+
if (key.reversed.length === 0) continue;
|
|
128
|
+
refusedKey.add(`${key.animation} ${key.slot} ${key.attachment} ${key.key}`);
|
|
129
|
+
const shown = key.reversed
|
|
130
|
+
.slice(0, 4)
|
|
131
|
+
.map((r) => `${r.triangle} [${r.ids.join(',')}] ${r.before.toFixed(3)} -> ${r.after.toFixed(3)}px²`);
|
|
132
|
+
const more = key.reversed.length > shown.length ? `, and ${key.reversed.length - shown.length} more` : '';
|
|
133
|
+
fail(
|
|
134
|
+
'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
|
|
135
|
+
// ⚠️ The frame is in the message whenever it is not the track, because
|
|
136
|
+
// the same key can be refused in one frame and passed over in another
|
|
137
|
+
// — two sliders applying one animation are two frames — and a message
|
|
138
|
+
// that named only the key would be ambiguous about which (issue #407).
|
|
139
|
+
`animation "${key.animation}"${frameClause(key.reach)} deform ${key.slot}/${key.attachment} ` +
|
|
140
|
+
`key ${key.key} (t=${key.time}s): ` +
|
|
141
|
+
`${key.reversed.length} of ${key.triangles} triangle(s) reverse winding — triangle ${shown.join('; triangle ')}` +
|
|
142
|
+
`${more}. The mesh has turned inside out there and draws its texture backwards` +
|
|
143
|
+
// The alpha is in the message whenever it is not full, because the
|
|
144
|
+
// one thing that would make this key exempt is alpha exactly 0 and
|
|
145
|
+
// an author who has already faded the part half out needs to be
|
|
146
|
+
// told that half is not none (issue #401).
|
|
147
|
+
(key.draw.alpha === 1
|
|
148
|
+
? ''
|
|
149
|
+
: ` at alpha ${key.draw.alpha.toFixed(4)} — visible at that strength, and only alpha exactly 0 draws ` +
|
|
150
|
+
'no pixels at all') +
|
|
151
|
+
'. Fix the key\'s ' +
|
|
152
|
+
'offsets in the motion spec\'s deform timeline (a projection past its fold angle is the usual ' +
|
|
153
|
+
'cause — docs/FACE.md §4.2 has the closed form), or, if this slot folds on purpose, declare it ' +
|
|
154
|
+
`in the rig spec as invariants.deformMayFold: [{ "slot": "${key.slot}", "why": … }]`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
// --- and the folds no key lands on (issue #403) ------------------------
|
|
158
|
+
let spanFolds = 0;
|
|
159
|
+
for (const span of survey.spans) {
|
|
160
|
+
if (span.fold === null) continue;
|
|
161
|
+
// Suppressed when a bounding key already says it: one defect, one
|
|
162
|
+
// message. The span check is here for what the keys cannot see.
|
|
163
|
+
const bounded = `${span.animation} ${span.slot} ${span.attachment} `;
|
|
164
|
+
if (refusedKey.has(bounded + span.fromKey) || refusedKey.has(bounded + span.toKey)) continue;
|
|
165
|
+
spanFolds++;
|
|
166
|
+
const at = span.fold;
|
|
167
|
+
const shown = at.measure.reversed
|
|
168
|
+
.slice(0, 4)
|
|
169
|
+
.map((r) => `${r.triangle} [${r.ids.join(',')}] ${r.before.toFixed(3)} -> ${r.after.toFixed(3)}px²`);
|
|
170
|
+
const more =
|
|
171
|
+
at.measure.reversed.length > shown.length ? `, and ${at.measure.reversed.length - shown.length} more` : '';
|
|
172
|
+
// A stepped segment interpolates NOTHING — it holds the earlier key's
|
|
173
|
+
// geometry across the whole span — so saying "the runtime interpolates"
|
|
174
|
+
// there would be telling the author to look for a defect in the wrong
|
|
175
|
+
// place. What changed across a stepped span is what the slot DRAWS.
|
|
176
|
+
const held = span.curve === 'stepped';
|
|
177
|
+
fail(
|
|
178
|
+
'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
|
|
179
|
+
`animation "${span.animation}"${frameClause(span.reach)} deform ${span.slot}/${span.attachment} ` +
|
|
180
|
+
`BETWEEN key ${span.fromKey} ` +
|
|
181
|
+
`(t=${span.fromTime}s) and key ${span.toKey} (t=${span.toTime}s), at t=${at.time.toFixed(6)}s` +
|
|
182
|
+
(held ? ' (a stepped segment)' : ` — ${(at.percent * 100).toFixed(1)}% of the way from one to the other`) +
|
|
183
|
+
`: ${at.measure.reversed.length} of ${at.measure.triangles} triangle(s) reverse winding — ` +
|
|
184
|
+
`triangle ${shown.join('; triangle ')}${more}. NO KEY LANDS THERE: ` +
|
|
185
|
+
(held
|
|
186
|
+
? `a stepped segment interpolates nothing, it HOLDS key ${span.fromKey}'s geometry across the whole ` +
|
|
187
|
+
'span — so the fold is that key\'s and what changes here is what the slot draws'
|
|
188
|
+
: 'the runtime interpolates between the two keys, and the mesh is inside out for part of the way') +
|
|
189
|
+
', drawing its texture backwards' +
|
|
190
|
+
(at.measure.draw.alpha === 1
|
|
191
|
+
? ''
|
|
192
|
+
: ` at alpha ${at.measure.draw.alpha.toFixed(4)} — visible at that strength, and only alpha exactly 0 ` +
|
|
193
|
+
'draws no pixels at all') +
|
|
194
|
+
'. ' +
|
|
195
|
+
(held
|
|
196
|
+
? `Fix key ${span.fromKey}'s offsets, or keep the slot drawing nothing for as long as it holds them`
|
|
197
|
+
: 'Add a key inside the span so the geometry the runtime passes through is geometry you wrote, or ' +
|
|
198
|
+
"move the two keys' offsets closer together (a projection past its fold angle is the usual cause " +
|
|
199
|
+
'— docs/FACE.md §4.2 has the closed form)') +
|
|
200
|
+
(at.measure.draw.alpha === 1
|
|
201
|
+
? ''
|
|
202
|
+
: '; if the part is being faded out over this turn, land the alpha-0 key BEFORE the folding key ' +
|
|
203
|
+
'rather than on it, so every frame that folds is a frame that draws nothing (docs/FACE.md §9.2)') +
|
|
204
|
+
`, or, if this slot folds on purpose, declare it in the rig spec as invariants.deformMayFold: ` +
|
|
205
|
+
`[{ "slot": "${span.slot}", "why": … }]`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (survey.timelines === 0) {
|
|
209
|
+
return skip('A39_DEFORM_KEEPS_TRIANGLE_WINDING', 'no animation carries a deform timeline');
|
|
210
|
+
}
|
|
211
|
+
if (survey.keys.length === 0) {
|
|
212
|
+
const why = [
|
|
213
|
+
survey.exempted.length ? `the rig declares ${survey.exempted.join(', ')} as deformMayFold` : '',
|
|
214
|
+
survey.notAMesh.length ? `${survey.notAMesh.join(', ')} deform an attachment with no triangles` : '',
|
|
215
|
+
].filter(Boolean);
|
|
216
|
+
return skip(
|
|
217
|
+
'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
|
|
218
|
+
`no deform timeline here has a winding to keep: ${why.join('; ') || 'every mesh keyed has no triangles'}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
// ⚠️ Silence is not a pass. A key passed over because nothing of it is
|
|
222
|
+
// drawn has to be visible on a green run too — this is the only surface
|
|
223
|
+
// `validate` has, and `explain`'s DEFORM block prints the whole sentence
|
|
224
|
+
// beside the key's own figures.
|
|
225
|
+
// ⚠️ Unreachable first and `blank` second, in the survey's own order, so
|
|
226
|
+
// the two counts partition the ungated keys instead of double-counting a
|
|
227
|
+
// key that is both.
|
|
228
|
+
const unreachable = survey.keys.filter((k) => k.dial?.unreachable === true);
|
|
229
|
+
const blank = survey.keys.filter((k) => k.dial?.unreachable !== true && k.draw.blank !== null);
|
|
230
|
+
const ungated = blank.length + unreachable.length;
|
|
231
|
+
const name = (k: (typeof survey.keys)[number]): string =>
|
|
232
|
+
`${k.animation}/${k.slot}/${k.attachment}#${k.key}:${k.draw.showsThisMesh ? 'alpha0' : 'notShown'}`;
|
|
233
|
+
// ⚠️ `&& spanFolds === 0` because a rig whose every key draws nothing can
|
|
234
|
+
// still fold at a time between two of them that DOES draw — that is issue
|
|
235
|
+
// #403's own case, and a SKIP printed over a refusal would be this rule
|
|
236
|
+
// reporting "nothing to measure" about the thing it just measured.
|
|
237
|
+
if (ungated === survey.keys.length && spanFolds === 0) {
|
|
238
|
+
const first = blank[0] ?? unreachable[0];
|
|
239
|
+
return skip(
|
|
240
|
+
'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
|
|
241
|
+
`no deform key here is measurable in the frame its animation is reached in — ` +
|
|
242
|
+
`${first.animation} ${first.slot}/${first.attachment} key ${first.key}: ` +
|
|
243
|
+
`${first.draw.blank ?? unreachableWhy(first)}` +
|
|
244
|
+
(survey.keys.length > 1 ? `, and ${survey.keys.length - 1} more key(s) like it` : '') +
|
|
245
|
+
(unreachable.length
|
|
246
|
+
? `. ${unreachable.length} of them at a time no dial selects, which is a rig defect this rule does ` +
|
|
247
|
+
'not refuse and does not pass over in silence either'
|
|
248
|
+
: '') +
|
|
249
|
+
(survey.spans.length
|
|
250
|
+
? `. The ${survey.spans.length} span(s) between them were scanned too and none folds where anything ` +
|
|
251
|
+
'is drawn'
|
|
252
|
+
: ''),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
stats.deformKeysMeasured = survey.keys.length - ungated;
|
|
256
|
+
stats.deformTrianglesMeasured = survey.trianglesMeasured;
|
|
257
|
+
stats.deformTrianglesCollapsed = survey.collapsed;
|
|
258
|
+
// Which frame each animation was posed in (issue #407) — printed only when
|
|
259
|
+
// a slider chose one, because on every other rig it says "a track" about
|
|
260
|
+
// every animation and a stats line that never varies is not a reading.
|
|
261
|
+
const frames = [...new Map(survey.keys.map((k) => [`${k.animation}/${k.reach.slider ?? 'track'}`, k])).values()];
|
|
262
|
+
if (frames.some((k) => k.reach.kind === 'slider')) {
|
|
263
|
+
stats.deformFrames = frames
|
|
264
|
+
.map((k) => `${k.animation}:${k.reach.kind === 'slider' ? `slider/${k.reach.slider}` : 'track'}`)
|
|
265
|
+
.join(',');
|
|
266
|
+
}
|
|
267
|
+
if (blank.length) {
|
|
268
|
+
stats.deformKeysNotDrawn = blank.length;
|
|
269
|
+
stats.deformNotDrawn = blank.map(name).join(',');
|
|
270
|
+
if (survey.notDrawnReversed) stats.deformNotDrawnReversed = survey.notDrawnReversed;
|
|
271
|
+
}
|
|
272
|
+
// 🚨 A key at a time no dial can select is NOT a pass and NOT a refusal —
|
|
273
|
+
// it is a rig whose slider cannot reach its own animation's key, named
|
|
274
|
+
// here so a green run cannot be read as having measured it (issue #407).
|
|
275
|
+
if (unreachable.length) {
|
|
276
|
+
stats.deformKeysUnreachable = unreachable.length;
|
|
277
|
+
stats.deformUnreachable = unreachable
|
|
278
|
+
.map((k) => `${k.animation}/${k.slot}/${k.attachment}#${k.key}@${k.dial?.applied.toFixed(6) ?? '?'}`)
|
|
279
|
+
.join(',');
|
|
280
|
+
if (survey.notReachableReversed) stats.deformUnreachableReversed = survey.notReachableReversed;
|
|
281
|
+
}
|
|
282
|
+
if (survey.exempted.length) stats.deformFoldExempt = survey.exempted.join(',');
|
|
283
|
+
// ⚠️ The between-keys scan on the stats line, on a GREEN run too (issue
|
|
284
|
+
// #403). `deformSpansScanned` is the positive control an agent can read —
|
|
285
|
+
// a scan that ran and found nothing has to be distinguishable from a scan
|
|
286
|
+
// that never ran — and `deformSpanProbes` is what it cost: 0 on a rig the
|
|
287
|
+
// closed form flags nothing in, one posed measurement per flagged window
|
|
288
|
+
// otherwise.
|
|
289
|
+
stats.deformSpansScanned = survey.spans.length;
|
|
290
|
+
// ⚠️ And the ones it could NOT scan, for the same reason the line above
|
|
291
|
+
// exists: a span bounded by a key at a time no dial selects would be
|
|
292
|
+
// solved over two poses of some other time, so it is skipped — and a skip
|
|
293
|
+
// nobody can see is the silence this whole surface is against (#407).
|
|
294
|
+
if (survey.spansNotScanned) stats.deformSpansNotScanned = survey.spansNotScanned;
|
|
295
|
+
if (survey.spanProbes) stats.deformSpanProbes = survey.spanProbes;
|
|
296
|
+
if (survey.spansNotDrawn) stats.deformSpansNotDrawn = survey.spansNotDrawn;
|
|
297
|
+
// A prediction nothing reproduced. Never a refusal — that would be the
|
|
298
|
+
// false red issues #44 and #262 already cost this file — and never a
|
|
299
|
+
// silence either: the one case that reaches it is a weighted mesh whose
|
|
300
|
+
// bones move across the span, where the closed form's fixed-pose
|
|
301
|
+
// assumption is the thing that did not hold.
|
|
302
|
+
if (survey.spansUnconfirmed) stats.deformSpansUnconfirmed = survey.spansUnconfirmed;
|
|
303
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A43, the body (issue #1025, cut 4c-3 of step 4c of #380): the two-colour
|
|
3
|
+
* tint loads and poses as written.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads, which is
|
|
6
|
+
* `TwoColourFacts` (`../facts/two_colour.ts`) where it read the skeleton JSON
|
|
7
|
+
* and the loaded skeleton: the dark colour each slot states and the one the
|
|
8
|
+
* runtime loaded, the file's slot timelines, whether an animation exists, and
|
|
9
|
+
* a slot's two colours posed at a key's time — which `validate()` takes from
|
|
10
|
+
* spine-core and the model side from rigc's core. The argument for the rule is
|
|
11
|
+
* above its `check` call, which stays in `validate()`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Verdicts } from '../harness.ts';
|
|
14
|
+
import type { TwoColourFacts } from '../facts/two_colour.ts';
|
|
15
|
+
import { SKIP_NO_TWO_COLOR_TINT } from '../reasons.ts';
|
|
16
|
+
import { atStoredKey, isObj } from '../values.ts';
|
|
17
|
+
|
|
18
|
+
export function a43TwoColorTintLoadsAndPosesAsWritten({ fail, skip, stats }: Verdicts, facts: TwoColourFacts): void {
|
|
19
|
+
/** `rrggbb`, or `rrggbbaa` whose last pair the format drops. Null for anything else. */
|
|
20
|
+
const readDarkHex = (hex: string): [number, number, number] | null => {
|
|
21
|
+
const body = hex.startsWith('#') ? hex.slice(1) : hex;
|
|
22
|
+
if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
|
|
23
|
+
return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
|
|
24
|
+
};
|
|
25
|
+
/** `rrggbbaa`, or `rrggbb` the runtime opens at alpha 1. Null for anything else. */
|
|
26
|
+
const readLightHex = (hex: string): [number, number, number, number] | null => {
|
|
27
|
+
const body = hex.startsWith('#') ? hex.slice(1) : hex;
|
|
28
|
+
if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
|
|
29
|
+
const rgb = [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255);
|
|
30
|
+
return [rgb[0], rgb[1], rgb[2], body.length === 8 ? Number.parseInt(body.slice(6, 8), 16) / 255 : 1];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Half a quantisation step. A channel is one byte in the file and a
|
|
34
|
+
* `Float32Array` entry in a timeline, so the widest honest gap between the
|
|
35
|
+
* number written and the number posed is well under `1/510`.
|
|
36
|
+
*/
|
|
37
|
+
const STEP = 1 / 510;
|
|
38
|
+
const off = (found: number, want: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > STEP;
|
|
39
|
+
const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
|
|
40
|
+
|
|
41
|
+
// -- the subjects, read off the FILE ---------------------------------
|
|
42
|
+
const declared = new Map<string, string>();
|
|
43
|
+
for (const { slot, dark } of facts.slotDarks) declared.set(slot, dark);
|
|
44
|
+
// `rgb2` joined in issue #730, and it is this rule's subject rather than a
|
|
45
|
+
// new one's because it is the same tint with the light alpha left out:
|
|
46
|
+
// `RGB2Timeline` writes the light rgb and the dark colour, and on a slot
|
|
47
|
+
// with no dark colour it throws in `apply1` exactly as `RGBA2Timeline`
|
|
48
|
+
// does (measured on a forged file: `TypeError: null is not an object
|
|
49
|
+
// (evaluating 'dark.r = …')`). The one thing it does differently is the
|
|
50
|
+
// alpha it does NOT pose, which is `A45`'s question, not this one's.
|
|
51
|
+
const keyed: Array<{ anim: string; slot: string; timeline: 'rgba2' | 'rgb2'; keys: unknown[] }> = [];
|
|
52
|
+
for (const { animation: animName, slot: slotName, timelines } of facts.slotTimelines) {
|
|
53
|
+
for (const timeline of ['rgba2', 'rgb2'] as const) {
|
|
54
|
+
const keys = timelines[timeline];
|
|
55
|
+
if (Array.isArray(keys)) keyed.push({ anim: animName, slot: slotName, timeline, keys });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (declared.size === 0 && keyed.length === 0) {
|
|
59
|
+
return skip('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', SKIP_NO_TWO_COLOR_TINT);
|
|
60
|
+
}
|
|
61
|
+
stats.darkSlots = declared.size;
|
|
62
|
+
stats.rgba2Timelines = keyed.filter((t) => t.timeline === 'rgba2').length;
|
|
63
|
+
stats.rgb2Timelines = keyed.filter((t) => t.timeline === 'rgb2').length;
|
|
64
|
+
|
|
65
|
+
// -- clause 1: the setup pose ----------------------------------------
|
|
66
|
+
for (const [name, hex] of declared) {
|
|
67
|
+
const want = readDarkHex(hex);
|
|
68
|
+
const loaded = facts.loadedDark(name);
|
|
69
|
+
// ⚠️ "The parser kept nothing" is asked FIRST, and the order is the
|
|
70
|
+
// finding rather than a style: an empty string is both unreadable as a
|
|
71
|
+
// colour and dropped outright, and only the second sentence is about
|
|
72
|
+
// what the loaded skeleton holds. Asked the other way round, `""` was
|
|
73
|
+
// reported as "not six hex digits" — true, and about the file, on the
|
|
74
|
+
// one input where the file is not what went wrong.
|
|
75
|
+
if (loaded === null) {
|
|
76
|
+
fail(
|
|
77
|
+
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
78
|
+
`slot "${name}" states dark ${JSON.stringify(hex)} and the loaded skeleton holds no dark colour for ` +
|
|
79
|
+
'it at all — the slot reader takes `dark` through a truthiness test, so a falsy value is dropped ' +
|
|
80
|
+
'in silence and the slot is tinted with one colour',
|
|
81
|
+
);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (want === null) {
|
|
85
|
+
fail(
|
|
86
|
+
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
87
|
+
`slot "${name}" states dark ${JSON.stringify(hex)}, which is not six hex digits — the parser reads ` +
|
|
88
|
+
'fixed two-character slices and stores whatever `parseInt` returns, so the loaded colour is ' +
|
|
89
|
+
`(${show([loaded.r, loaded.g, loaded.b])}) rather than a failure`,
|
|
90
|
+
);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const found: [number, number, number] = [loaded.r, loaded.g, loaded.b];
|
|
94
|
+
if (found.some((n, i) => off(n, want[i]))) {
|
|
95
|
+
fail(
|
|
96
|
+
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
97
|
+
`slot "${name}" states dark ${JSON.stringify(hex)} and the runtime loaded (${show(found)}), wanted ` +
|
|
98
|
+
`(${show(want)})`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -- clauses 2 and 3: every rgba2 and rgb2 timeline --------------------
|
|
104
|
+
//
|
|
105
|
+
// ⚠️ Clause 2 poisons its whole ANIMATION, not only its own timeline: the
|
|
106
|
+
// throw happens inside `state.apply`, which applies every timeline of the
|
|
107
|
+
// animation at once, so posing a second — correct — `rgba2` timeline in the
|
|
108
|
+
// same animation would take this assertion down with a `threw:` line
|
|
109
|
+
// instead of the two named failures it has already worked out.
|
|
110
|
+
const cannotPose = new Set(keyed.filter((t) => !declared.has(t.slot)).map((t) => t.anim));
|
|
111
|
+
for (const { anim: animName, slot: slotName, timeline, keys } of keyed) {
|
|
112
|
+
const where = `animation "${animName}" slot "${slotName}" ${timeline}`;
|
|
113
|
+
if (!declared.has(slotName)) {
|
|
114
|
+
fail(
|
|
115
|
+
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
116
|
+
`${where}: slot "${slotName}" declares no setup "dark", so the runtime allocates no dark colour for ` +
|
|
117
|
+
`it and applying this animation throws instead of tinting — give the slot a \`dark\`, or key ` +
|
|
118
|
+
`"${timeline === 'rgba2' ? 'rgba' : 'rgb'}"`,
|
|
119
|
+
);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (cannotPose.has(animName)) continue;
|
|
123
|
+
if (!facts.hasAnimation(animName)) {
|
|
124
|
+
fail('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', `${where}: the loaded skeleton has no animation "${animName}"`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
for (const rawKey of keys) {
|
|
128
|
+
if (!isObj(rawKey)) continue;
|
|
129
|
+
const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
|
|
130
|
+
const wantLight = typeof rawKey.light === 'string' ? readLightHex(rawKey.light) : null;
|
|
131
|
+
const wantDark = typeof rawKey.dark === 'string' ? readDarkHex(rawKey.dark) : null;
|
|
132
|
+
// ⚠️ Posed BEFORE the key's own spelling is judged, so that a key whose
|
|
133
|
+
// hex cannot be read is still reported with the colour the runtime
|
|
134
|
+
// actually holds. `Color.setFromString` slices fixed offsets and stores
|
|
135
|
+
// whatever `parseInt` gives back, so the value found is the product
|
|
136
|
+
// here — "not six hex digits" alone would be the value REQUIRED twice
|
|
137
|
+
// over and the found value nowhere.
|
|
138
|
+
// At the key as the runtime stores it — see `atStoredKey` (#771).
|
|
139
|
+
const posed = facts.posedTint(animName, slotName, atStoredKey(time));
|
|
140
|
+
const light = posed?.light;
|
|
141
|
+
const dark = posed?.dark ?? null;
|
|
142
|
+
if (!light || dark === null) {
|
|
143
|
+
fail(
|
|
144
|
+
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
145
|
+
`${where} (t=${time}): the posed skeleton has no ${light ? 'dark colour' : 'slot'} to read`,
|
|
146
|
+
);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const foundLight: [number, number, number, number] = [light.r, light.g, light.b, light.a];
|
|
150
|
+
const foundDark: [number, number, number] = [dark.r, dark.g, dark.b];
|
|
151
|
+
if (wantLight === null || wantDark === null) {
|
|
152
|
+
fail(
|
|
153
|
+
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
154
|
+
`${where} (t=${time}): the key states light ${JSON.stringify(rawKey.light)} and dark ` +
|
|
155
|
+
`${JSON.stringify(rawKey.dark)} — an ${timeline} key needs both, each six or eight hex digits — and the ` +
|
|
156
|
+
`runtime poses light (${show(foundLight)}), dark (${show(foundDark)})`,
|
|
157
|
+
);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// An `rgb2` key's light colour is three channels, and the posed alpha
|
|
161
|
+
// is not its to state — `RGB2Timeline` leaves it where it was — so the
|
|
162
|
+
// comparison is over the channels the timeline writes. For `rgba2`
|
|
163
|
+
// that is all four, and the line reads as it always did.
|
|
164
|
+
const lightChannels = timeline === 'rgba2' ? 4 : 3;
|
|
165
|
+
if (foundLight.slice(0, lightChannels).some((n, i) => off(n, wantLight[i]))) {
|
|
166
|
+
fail(
|
|
167
|
+
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
168
|
+
`${where} (t=${time}): light posed (${show(foundLight.slice(0, lightChannels))}), the key states ` +
|
|
169
|
+
`${JSON.stringify(rawKey.light)} = (${show(wantLight.slice(0, lightChannels))})`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (foundDark.some((n, i) => off(n, wantDark[i]))) {
|
|
173
|
+
fail(
|
|
174
|
+
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
175
|
+
`${where} (t=${time}): dark posed (${show(foundDark)}), the key states ${JSON.stringify(rawKey.dark)} ` +
|
|
176
|
+
`= (${show(wantDark)})`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|