spine-rigc 1.9.0 → 1.9.2
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 +48 -17
- package/docs/AUTHORING.md +2 -2
- package/package.json +1 -1
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -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/a34.ts +133 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -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/constraint_targets.ts +56 -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/slider_composition.ts +78 -0
- package/src/assertions/facts/stepped_poses.ts +74 -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/constraint_targets.ts +82 -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 +83 -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/slider_composition.ts +191 -0
- package/src/assertions/model/slot_colour.ts +5 -4
- package/src/assertions/model/stepped_poses.ts +89 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +9 -8
- package/src/bonedist.ts +18 -6
- package/src/compile.ts +114 -14
- package/src/core/additive.ts +468 -0
- package/src/core/constraints.ts +12 -2
- package/src/core/constraints_physics.ts +31 -8
- package/src/core/raw.ts +77 -16
- package/src/core/walk.ts +202 -0
- package/src/deformmeasure.ts +35 -2270
- package/src/deformstructure.ts +60 -18
- package/src/deformsurvey.ts +2295 -0
- package/src/nonfinite.ts +54 -0
- package/src/render.ts +200 -59
- package/src/render_core.ts +5 -0
- package/src/validate.ts +791 -1558
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A30, the body (issue #1025, cut 4c-4 of #380): inward travel stops where the
|
|
3
|
+
* drawn cover runs out — the measured `stroke.cap_containment_ceiling` — and
|
|
4
|
+
* no bone under the axis scales while that ceiling is declared.
|
|
5
|
+
*
|
|
6
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the travel
|
|
7
|
+
* and the scale timelines are read off the bone timelines as a fact
|
|
8
|
+
* (`../facts/bone_timelines.ts`, through `../inward_advance.ts`) rather than
|
|
9
|
+
* off the raw JSON, and the rig info is the caller's. Why this is not a
|
|
10
|
+
* restatement of A29, and why a scale key is refused rather than measured,
|
|
11
|
+
* stay above the `check` call in `validate()`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Verdicts } from '../harness.ts';
|
|
14
|
+
import type { BoneTimelineFacts } from '../facts/bone_timelines.ts';
|
|
15
|
+
import type { RigInfo } from '../../types.ts';
|
|
16
|
+
import { deepestInwardAdvance } from '../inward_advance.ts';
|
|
17
|
+
import { isObj } from '../values.ts';
|
|
18
|
+
|
|
19
|
+
export function a30StrokeWithinCapContainment({ fail, skip, stats }: Verdicts, facts: BoneTimelineFacts, input: { rig?: RigInfo }): void {
|
|
20
|
+
const rig = input.rig;
|
|
21
|
+
if (!rig) return skip('A30_STROKE_WITHIN_CAP_CONTAINMENT', 'no rig info (validating a bare directory)');
|
|
22
|
+
if (!rig.capContainmentCeiling) {
|
|
23
|
+
return skip(
|
|
24
|
+
'A30_STROKE_WITHIN_CAP_CONTAINMENT',
|
|
25
|
+
'the manifest declares no `stroke.cap_containment_ceiling`, so this cut has no measured containment ceiling',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const deep = deepestInwardAdvance(facts, rig);
|
|
29
|
+
stats.capCeiling = rig.capContainmentCeiling;
|
|
30
|
+
stats.deepestAdvance = Math.round(deep.total * 1000) / 1000;
|
|
31
|
+
if (deep.total > rig.capContainmentCeiling + 1e-6) {
|
|
32
|
+
fail(
|
|
33
|
+
'A30_STROKE_WITHIN_CAP_CONTAINMENT',
|
|
34
|
+
`${deep.describe()} but the leading contour leaves the occluder's opaque footprint at ` +
|
|
35
|
+
`${rig.capContainmentCeiling}px — the part would be drawn where it should be covered`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const subtree = new Set(rig.axisSubtree);
|
|
39
|
+
for (const { animation: animName, bone: boneName, timelines } of facts.boneTimelines) {
|
|
40
|
+
if (!subtree.has(boneName) || !isObj(timelines)) continue;
|
|
41
|
+
for (const name of Object.keys(timelines)) {
|
|
42
|
+
if (name !== 'scale' && name !== 'scalex' && name !== 'scaley') continue;
|
|
43
|
+
fail(
|
|
44
|
+
'A30_STROKE_WITHIN_CAP_CONTAINMENT',
|
|
45
|
+
`"${animName}" gives "${boneName}" a ${name} timeline while a cap-containment ceiling is declared; ` +
|
|
46
|
+
'the ceiling was measured on the undeformed contour, so a scaled plate is outside its evidence',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A32, the body (issue #1025, cut 4c-4 of #380): an event key that sets
|
|
3
|
+
* `volume` or `balance` fires an event that declares an audio path — and the
|
|
4
|
+
* clauses that stay with the round trip, printed where they always printed.
|
|
5
|
+
*
|
|
6
|
+
* Split per clause (`../facts/event_keys.ts`). The rig's clause moved here: a
|
|
7
|
+
* readable document can state a key's `volume` on an event with no `audio`,
|
|
8
|
+
* and the file the emitter writes from it is the file this clause refuses. The
|
|
9
|
+
* other four — no string `name`, an undeclared event, a time that is not a
|
|
10
|
+
* finite number, a time before the key before it — are states the model
|
|
11
|
+
* document's reader refuses by name, so no readable document reaches them;
|
|
12
|
+
* `validate()` runs them over the skeleton JSON and hands their findings in
|
|
13
|
+
* (`kept`), and this body prints them at their place, in their order, before
|
|
14
|
+
* the moved clause and instead of it where one ended the key's reading. The
|
|
15
|
+
* three failure modes and why the rule reads the raw JSON stay above the
|
|
16
|
+
* `check` call in `validate()`.
|
|
17
|
+
*/
|
|
18
|
+
import type { Verdicts } from '../harness.ts';
|
|
19
|
+
import type { EventKeyFacts } from '../facts/event_keys.ts';
|
|
20
|
+
|
|
21
|
+
/** Where a key is, as every A32 line names it — the kept clauses in `src/validate.ts` name it this way too. */
|
|
22
|
+
export function eventKeyAt(animation: string, index: number): string {
|
|
23
|
+
return `animation "${animation}" event key ${index}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function a32EventKeysResolve({ fail, skip }: Verdicts, facts: EventKeyFacts): void {
|
|
27
|
+
if (!facts.parsed) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton JSON did not parse (A00 owns that failure)');
|
|
28
|
+
if (!facts.animations) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton declares no animations');
|
|
29
|
+
for (const key of facts.keys) {
|
|
30
|
+
for (const finding of key.kept) fail('A32_EVENT_KEYS_RESOLVE', finding);
|
|
31
|
+
if (key.stopped) continue;
|
|
32
|
+
const at = eventKeyAt(key.animation, key.index);
|
|
33
|
+
for (const field of ['volume', 'balance'] as const) {
|
|
34
|
+
if (key.sets[field] && !key.audio) {
|
|
35
|
+
fail(
|
|
36
|
+
'A32_EVENT_KEYS_RESOLVE',
|
|
37
|
+
`${at}: "${key.name}" sets ${field}, but the event declares no audio path — the parser reads ` +
|
|
38
|
+
`${field} only for an event that has one, so it is dropped in silence`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (facts.timelines === 0) return skip('A32_EVENT_KEYS_RESOLVE', 'no animation carries an event timeline');
|
|
44
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A34, the body (issue #1025, cut 4c-5 of step 4c of #380): a constraint
|
|
3
|
+
* timeline aims at a constraint of that type.
|
|
4
|
+
*
|
|
5
|
+
* The argument — the two shapes, the lookup by name AND type, the empty key
|
|
6
|
+
* array the parser skips in silence, the physics group that names no
|
|
7
|
+
* constraint — stands above the `check` call in `validate()`.
|
|
8
|
+
*
|
|
9
|
+
* Moved out of `src/validate.ts` whole, every clause with it (cut 4c-4
|
|
10
|
+
* deferred it whole because its one rig clause shares this walk with the
|
|
11
|
+
* physics branch): which constraint an entry names, what it holds and whom a
|
|
12
|
+
* physics timeline naming none reaches are the rig's, and a document's records
|
|
13
|
+
* state each — except what no readable document can carry, which only the
|
|
14
|
+
* Spine text holds (a name the skeleton lacks, a name of another type, an
|
|
15
|
+
* empty key array, a bare key array where named timelines go): the model
|
|
16
|
+
* side's walk never meets those, and the reader refuses the document that
|
|
17
|
+
* would state them.
|
|
18
|
+
*/
|
|
19
|
+
import type { Verdicts } from '../harness.ts';
|
|
20
|
+
import type { ConstraintTargetFacts, TargetKeyArray } from '../facts/constraint_targets.ts';
|
|
21
|
+
import { CHANNELS_BY_KIND } from '../../timelines.ts';
|
|
22
|
+
|
|
23
|
+
export function a34ConstraintTimelineTargets({ fail, skip }: Verdicts, facts: ConstraintTargetFacts): void {
|
|
24
|
+
if (facts.groupsByAnimation === null) return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton declares no animations');
|
|
25
|
+
// name -> the KINDS declared under it, because that is the namespace the
|
|
26
|
+
// lookup this assertion is about resolves in: `findConstraint(name, type)`
|
|
27
|
+
// tests the type first, so a skeleton may carry `leg` as an ik constraint
|
|
28
|
+
// AND as a transform one and each group finds its own (issue #692). A map
|
|
29
|
+
// keyed by the name alone let the second of a pair overwrite the first, and
|
|
30
|
+
// this assertion then reported a correct file as a type mismatch.
|
|
31
|
+
const kindsOf = new Map<string, string[]>();
|
|
32
|
+
for (const entry of facts.constraints) {
|
|
33
|
+
if (entry.name !== null) kindsOf.set(entry.name, [...(kindsOf.get(entry.name) ?? []), entry.type]);
|
|
34
|
+
}
|
|
35
|
+
/** The file's physics constraints, which the unnamed physics group is read against. */
|
|
36
|
+
const rawPhysics = facts.constraints.filter((entry) => entry.type === 'physics');
|
|
37
|
+
let sawATimeline = false;
|
|
38
|
+
/** One target of one group: the name resolves, the type matches, keys exist. */
|
|
39
|
+
const checkTarget = (at: string, group: string, name: string, keyArrays: readonly TargetKeyArray[]): void => {
|
|
40
|
+
sawATimeline = true;
|
|
41
|
+
const declared = kindsOf.get(name) ?? [];
|
|
42
|
+
if (declared.length === 0) {
|
|
43
|
+
const known = [...kindsOf.entries()].filter(([, kinds]) => kinds.includes(group)).map(([n]) => n);
|
|
44
|
+
fail(
|
|
45
|
+
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
46
|
+
`${at}: the skeleton's constraints array has no "${name}"` +
|
|
47
|
+
(known.length ? ` (${group} constraints: ${known.join(', ')})` : `, and no ${group} constraint at all`),
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!declared.includes(group)) {
|
|
52
|
+
fail(
|
|
53
|
+
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
54
|
+
`${at}: "${name}" is declared as a "${declared.join('"/"')}" constraint, so the ${group} lookup misses it and the loader throws`,
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
checkKeys(at, keyArrays);
|
|
59
|
+
};
|
|
60
|
+
/** The key arrays of one group entry that resolved: each one is walked, so each has to hold a key. */
|
|
61
|
+
const checkKeys = (at: string, keyArrays: readonly TargetKeyArray[]): void => {
|
|
62
|
+
if (keyArrays.length === 0) {
|
|
63
|
+
fail(
|
|
64
|
+
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
65
|
+
`${at}: the constraint is named and carries no timeline at all; the group is walked and nothing happens`,
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const { timeline, keys } of keyArrays) {
|
|
70
|
+
if ('count' in keys && keys.count > 0) continue;
|
|
71
|
+
fail(
|
|
72
|
+
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
73
|
+
`${at}${timeline ? ` timeline "${timeline}"` : ''}: the key array is ` +
|
|
74
|
+
`${'count' in keys ? 'empty' : keys.spelled}; the parser reads key 0, finds nothing and ` +
|
|
75
|
+
'skips the whole timeline without a word',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
for (const { name: animName, groups } of facts.groupsByAnimation) {
|
|
80
|
+
for (const { group, entries } of groups) {
|
|
81
|
+
for (const { name, bare, keyArrays } of entries) {
|
|
82
|
+
// group.<constraint> = keys[]
|
|
83
|
+
if (group === 'ik' || group === 'transform') {
|
|
84
|
+
checkTarget(`animation "${animName}" ${group} timeline "${name}"`, group, name, keyArrays);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// group.<constraint>.<timeline> = keys[]
|
|
88
|
+
const at = `animation "${animName}" ${group} constraint "${name}"`;
|
|
89
|
+
if (bare !== null) {
|
|
90
|
+
sawATimeline = true;
|
|
91
|
+
fail(
|
|
92
|
+
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
93
|
+
`${at}: this group maps a constraint to NAMED timelines ` +
|
|
94
|
+
`(${Object.keys(CHANNELS_BY_KIND[group]).join('/')}), and this one holds ${bare} — ` +
|
|
95
|
+
'a bare key array here is the ik/transform shape and is walked as an object',
|
|
96
|
+
);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// The empty name is not a miss: it is the physics group's global form,
|
|
100
|
+
// which `SkeletonJson` loads as `constraintIndex -1` rather than looking
|
|
101
|
+
// anything up, and which writes every physics constraint that declares
|
|
102
|
+
// the timeline's property global (issue #726). Refusing it as "no
|
|
103
|
+
// constraint called ''" was a sentence that is false about the file.
|
|
104
|
+
// What CAN be wrong with it is that it reaches nobody: the parser
|
|
105
|
+
// accepts it, the runtime walks every constraint, and none of them
|
|
106
|
+
// takes the key.
|
|
107
|
+
if (group === 'physics' && name === '') {
|
|
108
|
+
sawATimeline = true;
|
|
109
|
+
for (const { timeline: timelineName } of keyArrays) {
|
|
110
|
+
const reach = facts.reach(timelineName);
|
|
111
|
+
// A name the parser skips is skipped here too: no timeline exists
|
|
112
|
+
// to reach anybody, and whether the NAME is right is not a target
|
|
113
|
+
// question.
|
|
114
|
+
if (reach === null || reach.reached.length > 0) continue;
|
|
115
|
+
fail(
|
|
116
|
+
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
117
|
+
`${at} timeline "${timelineName}": a physics group that names no constraint writes every physics ` +
|
|
118
|
+
`constraint ${reach.resets ? 'the skeleton has' : `declaring "${timelineName}Global": true`}, and ` +
|
|
119
|
+
(rawPhysics.length === 0 ? 'the skeleton has no physics constraint' : `none of ${rawPhysics.map((one) => `"${one.spelled}"`).join(', ')} does`) +
|
|
120
|
+
' — the parser loads it, the runtime walks every constraint, and no constraint takes the key',
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
checkKeys(at, keyArrays);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
checkTarget(at, group, name, keyArrays);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!sawATimeline) {
|
|
131
|
+
return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'no animation carries a constraint timeline');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -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,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A40, the body (issue #1025, cut 4c-5 of step 4c of #380): two sliders on
|
|
3
|
+
* one property, and the later one erases the other.
|
|
4
|
+
*
|
|
5
|
+
* The argument — why this is `validity`, the three shapes excluded
|
|
6
|
+
* structurally (authority below 1, a skin switch, different properties), the
|
|
7
|
+
* second clause and the events clause removed — stands above the `check` call
|
|
8
|
+
* in `validate()`. What a timeline does with `add` is the one fact here that
|
|
9
|
+
* is behaviour rather than a record: it is posed, and the body asks for it
|
|
10
|
+
* (`behaviour`) only for the later timeline of a shared property, once per
|
|
11
|
+
* timeline.
|
|
12
|
+
*
|
|
13
|
+
* Moved out of `src/validate.ts` whole: the sliders, their setup mix, their
|
|
14
|
+
* `additive` flag, the skins that list them, the animations they apply and the
|
|
15
|
+
* properties those animations' timelines register are the rig's, and a
|
|
16
|
+
* document's records state each; the class a timeline's additive application
|
|
17
|
+
* falls in is the core's computation over the document
|
|
18
|
+
* (`src/core/additive.ts`), held to the runtime's probe by the core suite's
|
|
19
|
+
* `CO27`. The words the sentences print are the facts' own: the runtime class
|
|
20
|
+
* a timeline loads as, the property's name, the target as the sentence names
|
|
21
|
+
* it.
|
|
22
|
+
*/
|
|
23
|
+
import type { Verdicts } from '../harness.ts';
|
|
24
|
+
import type { ConstraintFacts } from '../facts/constraints.ts';
|
|
25
|
+
import type { AddBehaviour, SliderCompositionFacts, SliderFact, SliderTimelineFact } from '../facts/slider_composition.ts';
|
|
26
|
+
import { switchedOn } from '../constraint_words.ts';
|
|
27
|
+
|
|
28
|
+
export function a40SlidersComposeOnASharedTarget({ fail, skip, stats }: Verdicts, facts: SliderCompositionFacts, constraints: ConstraintFacts): void {
|
|
29
|
+
const sliders = facts.sliders;
|
|
30
|
+
if (sliders.length < 2) {
|
|
31
|
+
return skip(
|
|
32
|
+
'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
|
|
33
|
+
`the skeleton declares ${sliders.length} slider constraint${sliders.length === 1 ? '' : 's'}, and one slider has nothing to compose with`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
// Clause 1 of the "could this be correct?" list above the check.
|
|
37
|
+
// "Keyed at all" rather than "keyed live", and on purpose: this clause asks
|
|
38
|
+
// whether the mix can MOVE from its setup value, so any mix timeline
|
|
39
|
+
// disqualifies it — the question `keyedBy` answered here, through the one
|
|
40
|
+
// reading `switchedOn` gives (`../constraint_words.ts`, over the same facts
|
|
41
|
+
// the moved constraint bodies read — issue #1025), and unchanged by issue
|
|
42
|
+
// #752.
|
|
43
|
+
const mixKeyed = switchedOn(constraints, (timeline) => timeline.kind === 'slider' && timeline.word === 'mix', 1, () => true);
|
|
44
|
+
const authoritative = sliders.filter((s) => s.mix >= 1 && !mixKeyed.has(s.index));
|
|
45
|
+
if (authoritative.length < 2) {
|
|
46
|
+
return skip(
|
|
47
|
+
'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
|
|
48
|
+
`${authoritative.length} of the ${sliders.length} slider constraints apply at full authority; below mix 1 an ` +
|
|
49
|
+
'apply is a lerp from the current pose rather than an overwrite, so what the others do to a shared property is a weighting',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
/** Which skins switch a slider on, or null when it is active under every skin. */
|
|
53
|
+
const skinsOf = new Map<SliderFact, Set<string> | null>();
|
|
54
|
+
for (const slider of authoritative) skinsOf.set(slider, slider.skinRequired ? new Set(slider.skins) : null);
|
|
55
|
+
/** Clause 2: can these two ever run in the same frame? */
|
|
56
|
+
const canOverlap = (a: SliderFact, b: SliderFact): boolean => {
|
|
57
|
+
const skinsA = skinsOf.get(a) ?? null;
|
|
58
|
+
const skinsB = skinsOf.get(b) ?? null;
|
|
59
|
+
if (!skinsA || !skinsB) return true;
|
|
60
|
+
for (const name of skinsA) {
|
|
61
|
+
if (skinsB.has(name)) return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
65
|
+
type User = { slider: SliderFact; timeline: SliderTimelineFact; animation: string; at: number; property: string; names: string };
|
|
66
|
+
/** property id -> the sliders whose animation keys it, in constraints-array order. */
|
|
67
|
+
const byProperty = new Map<string, User[]>();
|
|
68
|
+
for (const slider of authoritative) {
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
const animation = slider.animation;
|
|
71
|
+
if (animation === null) continue;
|
|
72
|
+
animation.timelines.forEach((timeline, at) => {
|
|
73
|
+
for (const { id, property, names } of timeline.properties) {
|
|
74
|
+
if (seen.has(id)) continue;
|
|
75
|
+
seen.add(id);
|
|
76
|
+
byProperty.set(id, [...(byProperty.get(id) ?? []), { slider, timeline, animation: animation.name, at, property, names }]);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Posed once per timeline, because the answer is the class's and the rigs that reach here share timelines. */
|
|
81
|
+
const behaviour = new Map<string, AddBehaviour>();
|
|
82
|
+
const addBehaviourOf = (user: User): AddBehaviour => {
|
|
83
|
+
const key = `${user.animation}\u0000${user.at}`;
|
|
84
|
+
const known = behaviour.get(key);
|
|
85
|
+
if (known !== undefined) return known;
|
|
86
|
+
const measured = facts.behaviour(user.animation, user.at);
|
|
87
|
+
behaviour.set(key, measured);
|
|
88
|
+
return measured;
|
|
89
|
+
};
|
|
90
|
+
let shared = 0;
|
|
91
|
+
for (const [, users] of byProperty) {
|
|
92
|
+
if (users.length < 2) continue;
|
|
93
|
+
shared++;
|
|
94
|
+
const at = (slider: SliderFact): string => `"${slider.name}" (constraints[${slider.index}], additive: ${String(slider.additive)})`;
|
|
95
|
+
const chain = users.map((u) => at(u.slider)).join(', ');
|
|
96
|
+
for (let j = 1; j < users.length; j++) {
|
|
97
|
+
const later = users[j];
|
|
98
|
+
const composes = addBehaviourOf(later);
|
|
99
|
+
if (composes === 'accumulates' && later.slider.additive) continue;
|
|
100
|
+
// Nothing a second slider could take away: applied with the arguments
|
|
101
|
+
// a slider passes — `firedEvents` null among them — this timeline
|
|
102
|
+
// moves no pose at all.
|
|
103
|
+
if (composes === 'inert') continue;
|
|
104
|
+
const erased = users.slice(0, j).filter((e) => canOverlap(e.slider, later.slider));
|
|
105
|
+
if (!erased.length) continue;
|
|
106
|
+
const erasedNames = `${erased.map((e) => `"${e.slider.name}"`).join(', ')} contribute${erased.length === 1 ? 's' : ''}`;
|
|
107
|
+
const why =
|
|
108
|
+
composes === 'accumulates'
|
|
109
|
+
? `slider "${later.slider.name}" applies animation "${later.slider.animation?.name}" with additive false, and at ` +
|
|
110
|
+
'mix 1 a non-additive apply writes the value outright (`getRelativeValue` returns `setup + value`, ' +
|
|
111
|
+
'`getAbsoluteValue` returns `value`) rather than adding to the pose it found. Set `"additive": true` on ' +
|
|
112
|
+
`slider "${later.slider.name}" in the rig spec — rigc will not choose that flag for you — or key this ` +
|
|
113
|
+
`property from one slider only. [measured] \`${later.timeline.runtimeClass}.apply\` posed twice with ` +
|
|
114
|
+
'`add` set accumulates, so that flag is the repair here'
|
|
115
|
+
: `the ${later.property} timeline they share writes its value outright whatever the ` +
|
|
116
|
+
`flags say — [measured] \`${later.timeline.runtimeClass}.apply\` posed twice with \`add\` set left the ` +
|
|
117
|
+
'same value there rather than adding to it — so `"additive": true` would NOT compose these. Key this ' +
|
|
118
|
+
'property from one slider only, or move both edits into the one animation a single slider applies';
|
|
119
|
+
fail(
|
|
120
|
+
'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
|
|
121
|
+
`${later.names} is keyed by the animations of ${users.length} sliders — ${chain} — and every ` +
|
|
122
|
+
`one of them applies at mix 1. Today ${at(later.slider)} wins that property and ${erasedNames} ` +
|
|
123
|
+
`nothing to it: ${why}.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
stats.sliderSharedTargets = shared;
|
|
128
|
+
}
|