spine-rigc 1.9.1 → 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.
@@ -0,0 +1,468 @@
1
+ /**
2
+ * What one timeline does when it is applied additively — the question A40
3
+ * asks of the timeline a later slider shares with an earlier one (issue
4
+ * #1025, cut 4c-5 of step 4c of #380). The validator asks the runtime by
5
+ * posing (`timelineAddBehaviour` in `src/validate.ts`): the timeline applied
6
+ * with `add` set, at alpha 1, `MixFrom.current`, no events, TWICE, at every
7
+ * time its own keys name, the midpoints between them and one past the last,
8
+ * from two start states — the setup pose, and the setup pose with every number
9
+ * a pose holds moved by `0.375`, every slot showing nothing and the draw order
10
+ * turned by one — under no skin and under every skin. A second application
11
+ * that moves the pose again is `accumulates`; one that moved it the first time
12
+ * and not the second is `overwrites`; one that never moves it is `inert`; the
13
+ * strongest any cell reads wins. This module answers the same question from
14
+ * the model document, with the core's own readings.
15
+ *
16
+ * ## The class is not a function of the timeline's kind alone
17
+ *
18
+ * Measured first, by posing the runtime's probe over a seeded population that
19
+ * reaches every timeline the catalogue lists (`src/timelines.ts`), each in
20
+ * three value variants — keys away from the setup, keys stating the setup
21
+ * values, keys stating the identity (0, scale 1, white, no draw-order move) —
22
+ * under no skin, the default skin and a second one, with targets active under
23
+ * one skin only (234 timelines, the core suite's `CO27`): of the 36
24
+ * spellings, 18 read one class on every timeline and 18 did not — every
25
+ * adding kind read `accumulates` on some timelines and `inert` on others, and
26
+ * a deform read all three. The kind decides what an application CAN do; three
27
+ * facts of the rig decide what it does:
28
+ *
29
+ * - **Whether its target is active under some skin.** A timeline whose bone,
30
+ * slot bone or constraint is inactive under every view writes nothing:
31
+ * `inert`, whatever its kind. The rules are the core's own — `activeBones`,
32
+ * `slotTimelinesApply`, `constraintInactiveWhy` (an ik is gated on its
33
+ * target, a transform on its source, a path on its slot's bone, a physics
34
+ * constraint on its bone, a slider on its dial) — measured equal here on an
35
+ * ik over a skin-required bone (applied) and onto one (not applied).
36
+ * - **Whether a deform or sequence timeline's attachment is shown.** Both
37
+ * are gated on the slot showing the record they are keyed on, or a linked
38
+ * mesh that plays its timelines (`timelineIdentity`); from the displaced
39
+ * state the slot shows nothing, so only the setup state can be written. A
40
+ * deform keyed on a record no view shows at setup is `inert`; a deform
41
+ * whose every offset is 0 fills the empty deform array once and adds 0 the
42
+ * second time — `overwrites`, not `accumulates`.
43
+ * - **Whether the value it adds is 0.** The ten bone value timelines, the
44
+ * transform mixes, a path's position and mix, physics wind and gravity and
45
+ * a slider's time and mix ADD: `current + v` (a scale `current + (v·setup −
46
+ * setup)`, the setup's and not the current's — a displaced `scaleX` 1.875
47
+ * over setup 1.5 keyed 3 read 4.875). Keyed to 0 (a scale to 1) they write
48
+ * nothing at all: `inert`.
49
+ *
50
+ * So the class is a computation over the document, and the table below
51
+ * carries only what the kind contributes — what its application does when it
52
+ * writes, the runtime class the timeline loads as and the properties it
53
+ * registers. The rest is computed per cell, as the probe reads it.
54
+ *
55
+ * ## The cells, per kind
56
+ *
57
+ * - `adds`: each field `once = current + d`, `twice = once + d`, `d` the
58
+ * keyed value at the time (`channelAt`, the runtime's curve as the core
59
+ * reproduces it), a scale's `v·setup − setup`; `current` the setup value,
60
+ * or the setup value plus `0.375`. A deform: the setup state holds no
61
+ * deform, `once` is the keyed array (`deformAt`) and `twice` the core's
62
+ * additive blend of it over itself at alpha 1 (`blendDeform`).
63
+ * - `writes`: the keyed value replaces the current one — a number through the
64
+ * setup blend at alpha 1, `current + (v − current)`, a mass as its inverse; a
65
+ * colour channel the keyed value clamped to [0, 1]; an ik's flags as keyed;
66
+ * an inherit mode, an attachment by the name it resolves to under the view
67
+ * (`shownAttachment`), a draw order (`drawOrderAt`) against the setup order
68
+ * or the order turned by one; a sequence writes its frame wherever its
69
+ * record is shown (the setup pose holds no frame index).
70
+ * - `writes nothing`: an event timeline (a slider fires none) and a physics
71
+ * `reset` (no time passes between the two applications).
72
+ *
73
+ * A physics timeline naming no constraint (`*`) writes every active record
74
+ * whose `…Global` flag for its value is on (`unnamedPhysicsTargets`).
75
+ *
76
+ * ## Held
77
+ *
78
+ * The core suite's `CO27` poses the runtime's probe over the seeded
79
+ * population and holds every cell's letter, and every added field's three
80
+ * values at tolerance 0, to this module's; one row of the table flipped per
81
+ * class turns exactly the timelines using it red.
82
+ *
83
+ * ## Purity
84
+ *
85
+ * As the rest of the core: nothing from the Spine runtime package, nothing
86
+ * from `src/transform.ts`, no clock, no randomness, no I/O.
87
+ */
88
+ import type { ModelBone, ModelVertices } from '../model.ts';
89
+ import { channelAt, keyIndexAt, SLOT_COLOUR_TIMELINES, type BoneTimelineKind, type CoreKey, type SlotTimelineKind } from './animation.ts';
90
+ import { constraintInactiveWhy, TRANSFORM_MIXES, TRANSFORM_PROPERTIES, type CoreConstraintRecord, type CoreIkKey } from './constraints.ts';
91
+ import { EVERY_GLOBAL_PHYSICS, physicsActive, unnamedPhysicsTargets, type CorePhysicsRecord, type PhysicsTimelineKind } from './constraints_physics.ts';
92
+ import { blendDeform, deformAt, timelineIdentity, type CoreDeformKey, type CoreSequenceKey } from './deform.ts';
93
+ import { drawOrderAt, type CoreDrawOrderKey } from './draw_order.ts';
94
+ import { activeBones, foldInheritMode, readColour, shownAttachment, shownRow, type CompiledDocument, type CoreAttachment } from './index.ts';
95
+ import { slotTimelinesApply } from './skins.ts';
96
+
97
+ /** What a kind's application does when it writes (the header's *The cells, per kind*). */
98
+ export type AdditiveMode = 'adds' | 'writes' | 'writes nothing';
99
+
100
+ /** What a timeline's property ids are followed by: its target's index (and, for an attachment's, the attachment), or nothing. */
101
+ export type AdditiveAddress = 'bone' | 'slot' | 'attachment' | 'constraint' | 'none';
102
+
103
+ /** One timeline spelling: the runtime class it loads as, the properties it registers in order, what its ids carry, and its mode. */
104
+ export interface AdditiveRow {
105
+ runtimeClass: string;
106
+ properties: readonly string[];
107
+ address: AdditiveAddress;
108
+ mode: AdditiveMode;
109
+ }
110
+
111
+ const row = (runtimeClass: string, properties: readonly string[], address: AdditiveAddress, mode: AdditiveMode): AdditiveRow => ({ runtimeClass, properties, address, mode });
112
+
113
+ /**
114
+ * Every timeline spelling a document's animation can hold, as measured (the
115
+ * header): the class name each loads as and the property ids it registers were
116
+ * read off the loaded timeline objects, and the mode off the probe's cells over
117
+ * the seeded population.
118
+ */
119
+ export const ADDITIVE_APPLY: Readonly<Record<string, AdditiveRow>> = {
120
+ 'bone rotate': row('RotateTimeline', ['rotate'], 'bone', 'adds'),
121
+ 'bone translate': row('TranslateTimeline', ['x', 'y'], 'bone', 'adds'),
122
+ 'bone translatex': row('TranslateXTimeline', ['x'], 'bone', 'adds'),
123
+ 'bone translatey': row('TranslateYTimeline', ['y'], 'bone', 'adds'),
124
+ 'bone scale': row('ScaleTimeline', ['scaleX', 'scaleY'], 'bone', 'adds'),
125
+ 'bone scalex': row('ScaleXTimeline', ['scaleX'], 'bone', 'adds'),
126
+ 'bone scaley': row('ScaleYTimeline', ['scaleY'], 'bone', 'adds'),
127
+ 'bone shear': row('ShearTimeline', ['shearX', 'shearY'], 'bone', 'adds'),
128
+ 'bone shearx': row('ShearXTimeline', ['shearX'], 'bone', 'adds'),
129
+ 'bone sheary': row('ShearYTimeline', ['shearY'], 'bone', 'adds'),
130
+ 'bone inherit': row('InheritTimeline', ['inherit'], 'bone', 'writes'),
131
+ 'slot attachment': row('AttachmentTimeline', ['attachment'], 'slot', 'writes'),
132
+ 'slot rgba': row('RGBATimeline', ['rgb', 'alpha'], 'slot', 'writes'),
133
+ 'slot rgb': row('RGBTimeline', ['rgb'], 'slot', 'writes'),
134
+ 'slot alpha': row('AlphaTimeline', ['alpha'], 'slot', 'writes'),
135
+ 'slot rgba2': row('RGBA2Timeline', ['rgb', 'alpha', 'rgb2'], 'slot', 'writes'),
136
+ 'slot rgb2': row('RGB2Timeline', ['rgb', 'rgb2'], 'slot', 'writes'),
137
+ 'attachment deform': row('DeformTimeline', ['deform'], 'attachment', 'adds'),
138
+ 'attachment sequence': row('SequenceTimeline', ['sequence'], 'attachment', 'writes'),
139
+ ik: row('IkConstraintTimeline', ['ikConstraint'], 'constraint', 'writes'),
140
+ transform: row('TransformConstraintTimeline', ['transformConstraint'], 'constraint', 'adds'),
141
+ 'path position': row('PathConstraintPositionTimeline', ['pathConstraintPosition'], 'constraint', 'adds'),
142
+ 'path spacing': row('PathConstraintSpacingTimeline', ['pathConstraintSpacing'], 'constraint', 'writes'),
143
+ 'path mix': row('PathConstraintMixTimeline', ['pathConstraintMix'], 'constraint', 'adds'),
144
+ 'physics inertia': row('PhysicsConstraintInertiaTimeline', ['physicsConstraintInertia'], 'constraint', 'writes'),
145
+ 'physics strength': row('PhysicsConstraintStrengthTimeline', ['physicsConstraintStrength'], 'constraint', 'writes'),
146
+ 'physics damping': row('PhysicsConstraintDampingTimeline', ['physicsConstraintDamping'], 'constraint', 'writes'),
147
+ 'physics mass': row('PhysicsConstraintMassTimeline', ['physicsConstraintMass'], 'constraint', 'writes'),
148
+ 'physics wind': row('PhysicsConstraintWindTimeline', ['physicsConstraintWind'], 'constraint', 'adds'),
149
+ 'physics gravity': row('PhysicsConstraintGravityTimeline', ['physicsConstraintGravity'], 'constraint', 'adds'),
150
+ 'physics mix': row('PhysicsConstraintMixTimeline', ['physicsConstraintMix'], 'constraint', 'writes'),
151
+ // A reset registers its property with no constraint index, named or not (measured: both read `physicsConstraintReset`).
152
+ 'physics reset': row('PhysicsConstraintResetTimeline', ['physicsConstraintReset'], 'none', 'writes nothing'),
153
+ 'slider time': row('SliderTimeline', ['sliderTime'], 'constraint', 'adds'),
154
+ 'slider mix': row('SliderMixTimeline', ['sliderMix'], 'constraint', 'adds'),
155
+ drawOrder: row('DrawOrderTimeline', ['drawOrder'], 'none', 'writes'),
156
+ events: row('EventTimeline', ['event'], 'none', 'writes nothing'),
157
+ };
158
+
159
+ /** One timeline of a document's animation, as the core reads it — enough to pose the probe. */
160
+ export type AdditiveTimeline =
161
+ | { group: 'bone'; word: BoneTimelineKind; bone: string; keys: readonly CoreKey[] }
162
+ | { group: 'slot'; word: SlotTimelineKind; slot: string; keys: readonly CoreKey[] }
163
+ | { group: 'ik'; word: 'ik'; constraint: string; keys: readonly CoreIkKey[] }
164
+ | { group: 'transform'; word: 'transform'; constraint: string; keys: readonly CoreKey[] }
165
+ | { group: 'path'; word: 'position' | 'spacing' | 'mix'; constraint: string; keys: readonly CoreKey[] }
166
+ | { group: 'physics'; word: PhysicsTimelineKind; constraint: string; keys: readonly CoreKey[] }
167
+ | { group: 'slider'; word: 'time' | 'mix'; constraint: string; keys: readonly CoreKey[] }
168
+ | { group: 'attachment'; word: 'deform'; skin: string; slot: string; attachment: string; keys: readonly CoreDeformKey[] }
169
+ | { group: 'attachment'; word: 'sequence'; skin: string; slot: string; attachment: string; keys: readonly CoreSequenceKey[] }
170
+ | { group: 'drawOrder'; word: 'drawOrder'; keys: readonly CoreDrawOrderKey[] }
171
+ | { group: 'events'; word: 'events'; times: readonly number[] };
172
+
173
+ /** The spelling a timeline is a row of. */
174
+ export function additiveSpelling(t: AdditiveTimeline): string {
175
+ if (t.group === 'ik' || t.group === 'transform' || t.group === 'drawOrder' || t.group === 'events') return t.group;
176
+ return `${t.group} ${t.word}`;
177
+ }
178
+
179
+ /** One view the probe poses under: the name the runtime's skin carries (`(none)` for no skin set) and the document under it. */
180
+ export interface AdditiveView {
181
+ name: string;
182
+ doc: CompiledDocument;
183
+ }
184
+
185
+ /** What the probe moved one field to: before either application, after one, after two — each as the pose prints it. */
186
+ export interface AdditiveValue {
187
+ /** `bone:<name>.<field>`, `constraint:<name>.<field>` or `slot:<name>.deform`, the pose field's own name. */
188
+ field: string;
189
+ t: number;
190
+ before: string;
191
+ once: string;
192
+ twice: string;
193
+ }
194
+
195
+ /** One view under one start state: the letter the probe reads (`A` a second application moved it, `W` only the first did, `-` nothing), and the values an adding kind moved. */
196
+ export interface AdditiveCell {
197
+ view: string;
198
+ state: 'setup' | 'displaced';
199
+ letter: 'A' | 'W' | '-';
200
+ values: AdditiveValue[];
201
+ }
202
+
203
+ /** The class the probe reads: the strongest any cell reads. */
204
+ export type AdditiveBehaviour = 'accumulates' | 'overwrites' | 'inert';
205
+
206
+ /** The probe's displacement of every number a pose holds — the validator's `PROBE_DISPLACEMENT`, exact in binary. */
207
+ export const ADDITIVE_DISPLACEMENT = 0.375;
208
+
209
+ /** A plant: rows of the table read otherwise (the core suite's `CO27`). */
210
+ export interface AdditivePlant {
211
+ rows?: Readonly<Record<string, AdditiveRow>>;
212
+ }
213
+
214
+ /** Every time the probe applies a timeline at: its keys' own times, the midpoints between them, and one past the last. */
215
+ export function additiveTimes(times: readonly number[]): number[] {
216
+ const between: number[] = [];
217
+ for (let i = 1; i < times.length; i++) between.push((times[i - 1] + times[i]) / 2);
218
+ return [...times, ...between, (times.length ? times[times.length - 1] : 0) + 1];
219
+ }
220
+
221
+ const spell = (v: number): string => String(v);
222
+ const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
223
+
224
+ /** One field moved by an adding kind: its two sums from `current`. */
225
+ function added(values: AdditiveValue[], field: string, t: number, current: number, d: number): { wrote: boolean; again: boolean } {
226
+ const once = current + d;
227
+ const twice = once + d;
228
+ values.push({ field, t, before: spell(current), once: spell(once), twice: spell(twice) });
229
+ return { wrote: spell(once) !== spell(current), again: spell(twice) !== spell(once) };
230
+ }
231
+
232
+ /** The keyed channels at `t`, the probe's times all at or after the first key. */
233
+ function channelsAt(keys: readonly CoreKey[], t: number): number[] {
234
+ const i = keyIndexAt(keys, t);
235
+ if (i < 0) return [];
236
+ return keys[i].values.map((_v, c) => channelAt(keys, i, c, t));
237
+ }
238
+
239
+ /** A number written through the setup blend at alpha 1: whether it moves `current`. */
240
+ const blendMoves = (current: number, v: number): boolean => spell(current + (v - current)) !== spell(current);
241
+
242
+ /** The setup value of a bone field. */
243
+ function boneSetup(b: ModelBone, field: 'x' | 'y' | 'rotation' | 'scaleX' | 'scaleY' | 'shearX' | 'shearY'): number {
244
+ return b[field] ?? (field === 'scaleX' || field === 'scaleY' ? 1 : 0);
245
+ }
246
+
247
+ const BONE_FIELDS: Readonly<Record<Exclude<BoneTimelineKind, 'inherit'>, ReadonlyArray<'x' | 'y' | 'rotation' | 'scaleX' | 'scaleY' | 'shearX' | 'shearY'>>> = {
248
+ rotate: ['rotation'],
249
+ translate: ['x', 'y'],
250
+ translatex: ['x'],
251
+ translatey: ['y'],
252
+ scale: ['scaleX', 'scaleY'],
253
+ scalex: ['scaleX'],
254
+ scaley: ['scaleY'],
255
+ shear: ['shearX', 'shearY'],
256
+ shearx: ['shearX'],
257
+ sheary: ['shearY'],
258
+ };
259
+
260
+ /** A constraint's record under a view, by kind and name. */
261
+ function recordOf(view: CompiledDocument, kind: CoreConstraintRecord['kind'], name: string): CoreConstraintRecord | undefined {
262
+ return view.constraints.find((c) => c.kind === kind && c.name === name)?.record;
263
+ }
264
+
265
+ /**
266
+ * Whether a constraint's timelines write into its pose under a view whose
267
+ * active bones are `active` — the solver's rule (`constraintInactiveWhy`,
268
+ * `physicsActive`) except for a slider, whose timelines write whatever its dial
269
+ * bone is: measured, a slider dialled by a skin-required bone keyed `mix`
270
+ * accumulated under no skin and the default skin, where the bone is inactive,
271
+ * as under the skin that lists the bone. The solver leaves such a slider
272
+ * unapplied; whether the runtime applies it was not measured here (the
273
+ * header's ⚠️).
274
+ */
275
+ function applies(r: CoreConstraintRecord, active: ReadonlySet<string>): boolean {
276
+ if (r.kind === 'physics') return physicsActive(r, active);
277
+ const why = constraintInactiveWhy(r, active);
278
+ return why === null || (r.kind === 'slider' && why !== 'skin');
279
+ }
280
+
281
+ /** The fields an adding constraint timeline moves, with their setup values. */
282
+ function constraintFields(t: AdditiveTimeline, r: CoreConstraintRecord): Array<{ field: string; setup: number; channel: number }> {
283
+ if (t.group === 'transform' && r.kind === 'transform') return TRANSFORM_PROPERTIES.map((p, channel) => ({ field: TRANSFORM_MIXES[p], setup: r.mixes[p], channel }));
284
+ if (t.group === 'path' && r.kind === 'path') {
285
+ if (t.word === 'position') return [{ field: 'position', setup: r.position, channel: 0 }];
286
+ if (t.word === 'mix') return [{ field: 'mixRotate', setup: r.mixRotate, channel: 0 }, { field: 'mixX', setup: r.mixX, channel: 1 }, { field: 'mixY', setup: r.mixY, channel: 2 }];
287
+ return [{ field: 'spacing', setup: r.spacing, channel: 0 }];
288
+ }
289
+ if (t.group === 'physics' && r.kind === 'physics' && t.word !== 'reset') {
290
+ if (t.word === 'mass') return [{ field: 'massInverse', setup: r.massInverse, channel: 0 }];
291
+ return [{ field: t.word, setup: r[t.word], channel: 0 }];
292
+ }
293
+ if (t.group === 'slider' && r.kind === 'slider') return [{ field: t.word, setup: r[t.word], channel: 0 }];
294
+ if (t.group === 'ik' && r.kind === 'ik') return [{ field: 'mix', setup: r.mix, channel: 0 }, { field: 'softness', setup: r.softness, channel: 1 }];
295
+ return [];
296
+ }
297
+
298
+ /** The vertices a shown record's deform array is computed on: its own, or a linked mesh's source's. */
299
+ function deformVertices(view: CompiledDocument, record: CoreAttachment): ModelVertices | null {
300
+ const g = record.geometry;
301
+ if (g === undefined) return null;
302
+ const source = g.kind === 'linkedmesh' ? view.skins.find((k) => k.name === g.skin)?.attachments[g.slot]?.[g.source]?.geometry : g;
303
+ return source !== undefined && source.kind !== 'region' && source.kind !== 'linkedmesh' ? source.vertices : null;
304
+ }
305
+
306
+ /** One view, one start state: the letter and the values (the header's cells). */
307
+ function cellOf(view: AdditiveView, state: 'setup' | 'displaced', t: AdditiveTimeline, mode: AdditiveMode): AdditiveCell {
308
+ const doc = view.doc;
309
+ const active = activeBones(doc);
310
+ const shift = state === 'setup' ? 0 : ADDITIVE_DISPLACEMENT;
311
+ const values: AdditiveValue[] = [];
312
+ let wrote = false;
313
+ let again = false;
314
+ const note = (r: { wrote: boolean; again: boolean }): void => {
315
+ wrote ||= r.wrote;
316
+ again ||= r.again;
317
+ };
318
+ const times = additiveTimes(t.group === 'events' ? t.times : t.keys.map((k) => k.time));
319
+ const done = (): AdditiveCell => ({ view: view.name, state, letter: again ? 'A' : wrote ? 'W' : '-', values });
320
+ if (mode === 'writes nothing') return done();
321
+ switch (t.group) {
322
+ case 'bone': {
323
+ const b = doc.bones.find((x) => x.name === t.bone);
324
+ if (b === undefined || !active.has(b.name)) return done();
325
+ if (t.word === 'inherit') {
326
+ // The displaced pose holds the mode's number plus 0.375, which no mode is; the setup pose holds the setup mode.
327
+ const setup = foldInheritMode(b.inheritMode ?? 'normal') ?? 'normal';
328
+ for (const time of times) {
329
+ const i = keyIndexAt(t.keys, time);
330
+ if (i < 0) continue;
331
+ if (state === 'displaced' || t.keys[i].mode !== setup) wrote = true;
332
+ }
333
+ return done();
334
+ }
335
+ const fields = BONE_FIELDS[t.word];
336
+ for (const time of times) {
337
+ const v = channelsAt(t.keys, time);
338
+ fields.forEach((field, c) => {
339
+ const setup = boneSetup(b, field);
340
+ const d = mode === 'adds' ? (field === 'scaleX' || field === 'scaleY' ? v[c] * setup - setup : v[c]) : Number.NaN;
341
+ if (mode === 'adds') note(added(values, `bone:${b.name}.${field}`, time, setup + shift, d));
342
+ else note({ wrote: blendMoves(setup + shift, v[c]), again: false });
343
+ });
344
+ }
345
+ return done();
346
+ }
347
+ case 'slot': {
348
+ const slot = doc.slots.find((s) => s.name === t.slot);
349
+ if (slot === undefined || !slotTimelinesApply(doc, slot, active)) return done();
350
+ if (t.word === 'attachment') {
351
+ const nameShown = (placeholder: string | null): string | null => {
352
+ if (placeholder === null) return null;
353
+ const s = shownAttachment(doc, { ...slot, setup: placeholder });
354
+ return s === null || 'conflict' in s ? null : shownRow(s).name;
355
+ };
356
+ // The setup pose shows the setup attachment; the displaced pose shows nothing.
357
+ const current = state === 'setup' ? nameShown(slot.setup) : null;
358
+ for (const time of times) {
359
+ const i = keyIndexAt(t.keys, time);
360
+ if (i >= 0 && nameShown(t.keys[i].name ?? null) !== current) wrote = true;
361
+ }
362
+ return done();
363
+ }
364
+ const shape = SLOT_COLOUR_TIMELINES[t.word];
365
+ const light = readColour(slot.color ?? 'ffffffff');
366
+ const dark = slot.dark === undefined ? null : readColour(slot.dark);
367
+ for (const time of times) {
368
+ const v = channelsAt(t.keys, time);
369
+ shape.channels.forEach((channel: string, c: number) => {
370
+ const second = channel.endsWith('2');
371
+ const at = ['r', 'g', 'b', 'a'].indexOf(second ? channel.slice(0, 1) : channel);
372
+ const setup = second ? (dark === null ? Number.NaN : dark[at]) : light[at];
373
+ if (spell(clamp01(v[c])) !== spell(setup + shift)) wrote = true;
374
+ });
375
+ }
376
+ return done();
377
+ }
378
+ case 'attachment': {
379
+ // The displaced pose shows nothing on any slot, and both timelines are gated on what a slot shows.
380
+ if (state === 'displaced') return done();
381
+ const identity = `${t.skin}/${t.slot}/${t.attachment}`;
382
+ for (const slot of doc.slots) {
383
+ if (!slotTimelinesApply(doc, slot, active)) continue;
384
+ const shown = shownAttachment(doc, slot);
385
+ if (shown === null || 'conflict' in shown || timelineIdentity(slot.name, shown) !== identity) continue;
386
+ if (t.word === 'sequence') {
387
+ if (shown.record.sequenceCount !== undefined && t.keys.length > 0) wrote = true;
388
+ continue;
389
+ }
390
+ const vertices = deformVertices(doc, shown.record);
391
+ if (vertices === null) continue;
392
+ for (const time of times) {
393
+ const target = deformAt(vertices, t.keys, time);
394
+ if (target === null) continue;
395
+ // From no deform, the additive blend at alpha 1 is the target (`blendDeform`); a second one adds `target − setup` to it.
396
+ const once = blendDeform(vertices, null, target, 1, true);
397
+ const twice = blendDeform(vertices, once, target, 1, true);
398
+ values.push({ field: `slot:${slot.name}.deform`, t: time, before: '[]', once: `[${once.join(',')}]`, twice: `[${twice.join(',')}]` });
399
+ wrote = true;
400
+ if (once.join(',') !== twice.join(',')) again = true;
401
+ }
402
+ }
403
+ return done();
404
+ }
405
+ case 'drawOrder': {
406
+ const count = doc.slots.length;
407
+ const setup = Array.from({ length: count }, (_v, i) => i);
408
+ const current = state === 'setup' ? setup : [...setup.slice(1), ...setup.slice(0, 1)];
409
+ for (const time of times) {
410
+ const order = drawOrderAt(count, t.keys, time);
411
+ if (order !== null && order.join(',') !== current.join(',')) wrote = true;
412
+ }
413
+ return done();
414
+ }
415
+ case 'events':
416
+ // A kind that writes moves the pose; an event timeline under a slider fires nothing (the table's mode), so only a plant reaches this.
417
+ if (mode === 'writes') wrote = true;
418
+ return done();
419
+ default: {
420
+ const records: CoreConstraintRecord[] =
421
+ t.group === 'physics' && t.constraint === EVERY_GLOBAL_PHYSICS
422
+ ? unnamedPhysicsTargets(
423
+ doc.constraints.flatMap((c): CorePhysicsRecord[] => (c.record?.kind === 'physics' ? [c.record] : [])),
424
+ t.word,
425
+ (r) => physicsActive(r, active),
426
+ )
427
+ : [recordOf(doc, t.group, t.constraint)].filter((r): r is CoreConstraintRecord => r !== undefined && applies(r, active));
428
+ for (const r of records) {
429
+ // A kind that writes with no field of its own to compare — only a plant makes a `reset` one — moves the pose wherever it applies.
430
+ if (t.group === 'physics' && t.word === 'reset' && mode === 'writes') wrote = true;
431
+ const fields = constraintFields(t, r);
432
+ for (const time of times) {
433
+ const v = channelsAt(t.keys, time);
434
+ for (const { field, setup, channel } of fields) {
435
+ const current = setup + shift;
436
+ if (mode === 'adds') note(added(values, `constraint:${r.name}.${field}`, time, current, v[channel]));
437
+ // A mass is keyed as a mass and posed as its inverse.
438
+ else if (field === 'massInverse') note({ wrote: spell(1 / (1 / current + (v[channel] - 1 / current))) !== spell(current), again: false });
439
+ else note({ wrote: blendMoves(current, v[channel]), again: false });
440
+ }
441
+ // An ik key's flags: the displaced pose moved the bend direction (a number); the setup pose holds the setup flags.
442
+ if (t.group === 'ik' && r.kind === 'ik') {
443
+ const i = keyIndexAt(t.keys, time);
444
+ const flags = i < 0 ? undefined : t.keys[i].flags;
445
+ if (flags !== undefined && (state === 'displaced' || flags.bendPositive !== r.bendPositive || flags.compress !== r.compress || flags.stretch !== r.stretch)) wrote = true;
446
+ }
447
+ }
448
+ }
449
+ return done();
450
+ }
451
+ }
452
+ }
453
+
454
+ /** Every cell of the probe over `views` — each view under the setup state, then the displaced one. */
455
+ export function additiveCells(views: readonly AdditiveView[], t: AdditiveTimeline, plant: AdditivePlant = {}): AdditiveCell[] {
456
+ const rows = plant.rows ?? ADDITIVE_APPLY;
457
+ const spelled = additiveSpelling(t);
458
+ const r = rows[spelled];
459
+ if (r === undefined) throw new Error(`internal: "${spelled}" is no row of the additive table`);
460
+ return views.flatMap((view) => [cellOf(view, 'setup', t, r.mode), cellOf(view, 'displaced', t, r.mode)]);
461
+ }
462
+
463
+ /** The class the probe reads over its cells: `accumulates` where any cell does, `overwrites` where any cell only wrote, `inert` otherwise. */
464
+ export function additiveBehaviour(cells: readonly AdditiveCell[]): AdditiveBehaviour {
465
+ if (cells.some((c) => c.letter === 'A')) return 'accumulates';
466
+ return cells.some((c) => c.letter === 'W') ? 'overwrites' : 'inert';
467
+ }
468
+
@@ -1402,12 +1402,22 @@ function solveTransform(state: SolverState, c: CoreTransformRecord): { changed:
1402
1402
 
1403
1403
  /** Why a constraint is not applied under the skin view posed (the header's measured rule), or null when it is. */
1404
1404
  function inactiveWhy(state: SolverState, c: CoreConstraintRecord): string | null {
1405
+ return constraintInactiveWhy(c, state.active, state.rules);
1406
+ }
1407
+
1408
+ /**
1409
+ * Why a constraint is not applied under a skin view whose active bones are
1410
+ * `active` (the header's measured rule), or null when it is — `inactiveWhy`'s
1411
+ * reading, exported so the additive probe (`./additive.ts`, issue #1025) asks
1412
+ * the rule the solver applies rather than a copy of it.
1413
+ */
1414
+ export function constraintInactiveWhy(c: CoreConstraintRecord, active: ReadonlySet<string>, rules: Readonly<SolverRules> = RUNTIME_SOLVER_RULES): string | null {
1405
1415
  // A skin-required constraint of any kind is applied when an applied skin's list for its kind names it (issue #932, card #961; physics first by issue #956).
1406
1416
  if (c.skin && !c.listedBySkin) return 'skin';
1407
1417
  // A path constraint is active when its slot's bone is (`./constraints_path.ts`, *Which constraints run*); every other kind when every bone it names is.
1408
1418
  // A transform constraint is applied when its source is, to every bone it names, inactive ones included (issue #979: an inactive bone among them did not stop it moving the others, and it wrote into the inactive one). An ik naming an inactive bone is left unapplied: the runtime applies it from ancestors it has not brought up to date (the header's *Unposed bones*).
1409
- const named = c.kind === 'path' ? [c.slotBone] : c.kind === 'ik' ? (state.rules.ikOverInactiveFresh ? [c.target] : [...c.bones, c.target]) : c.kind === 'transform' ? (state.rules.transformIgnoresBoneActivity ? [c.source] : [...c.bones, c.source]) : c.kind === 'slider' ? (c.bone === null ? [] : [c.bone]) : [c.bone];
1410
- return named.every((n) => state.active.has(n)) ? null : 'inactive bone';
1419
+ const named = c.kind === 'path' ? [c.slotBone] : c.kind === 'ik' ? (rules.ikOverInactiveFresh ? [c.target] : [...c.bones, c.target]) : c.kind === 'transform' ? (rules.transformIgnoresBoneActivity ? [c.source] : [...c.bones, c.source]) : c.kind === 'slider' ? (c.bone === null ? [] : [c.bone]) : [c.bone];
1420
+ return named.every((n) => active.has(n)) ? null : 'inactive bone';
1411
1421
  }
1412
1422
 
1413
1423
  /**
@@ -431,6 +431,16 @@ export interface PhysicsTimelinePlant {
431
431
  search?: (keys: readonly CoreKey[], t: number) => number;
432
432
  }
433
433
 
434
+ /**
435
+ * The records a physics timeline that names no constraint (`*`) writes: every
436
+ * active one whose `…Global` flag for `kind` is on, and for `reset` every
437
+ * active one (the header's *The timelines*), in the order given. `posedPhysics`
438
+ * asks it, and so does the additive probe (`./additive.ts`, issue #1025).
439
+ */
440
+ export function unnamedPhysicsTargets<R extends CorePhysicsRecord>(records: readonly R[], kind: PhysicsTimelineKind, active: (r: CorePhysicsRecord) => boolean): R[] {
441
+ return records.filter((r) => active(r) && (kind === 'reset' || r.global[kind]));
442
+ }
443
+
434
444
  /**
435
445
  * Every physics record posed by the animation's physics timelines at `t`, in
436
446
  * the timelines' order (the header's *The timelines*), and the names of the
@@ -438,18 +448,16 @@ export interface PhysicsTimelinePlant {
438
448
  * `last` to `t` (`resetCrossed`). `active`
439
449
  * says whether a constraint is applied at all (its skin and its bone).
440
450
  */
441
- export function posedPhysics(records: readonly CorePhysicsRecord[], timelines: readonly CorePhysicsTimeline[], t: number, last: number, active: (r: CorePhysicsRecord) => boolean, plant: PhysicsTimelinePlant = {}): { records: Map<string, CorePhysicsRecord>; reset: Set<string> } {
451
+ export function posedPhysics(records: readonly CorePhysicsRecord[], timelines: readonly CorePhysicsTimeline[], t: number, last: number, active: (r: CorePhysicsRecord) => boolean, plant: PhysicsTimelinePlant = {}, wrapped = false): { records: Map<string, CorePhysicsRecord>; reset: Set<string> } {
442
452
  const search = plant.search ?? keyIndexAt;
443
453
  const channel = plant.channel ?? channelAt;
444
454
  const setupOf = new Map(records.map((r) => [r.name, r]));
445
455
  const posed = new Map(records.map((r) => [r.name, { ...r }]));
446
456
  const reset = new Set<string>();
447
457
  for (const tl of timelines) {
448
- const targets = tl.name === EVERY_GLOBAL_PHYSICS
449
- ? [...posed.values()].filter((r) => active(r) && (tl.kind === 'reset' || r.global[tl.kind]))
450
- : [posed.get(tl.name) as CorePhysicsRecord].filter((r) => active(r));
458
+ const targets = tl.name === EVERY_GLOBAL_PHYSICS ? unnamedPhysicsTargets([...posed.values()], tl.kind, active) : [posed.get(tl.name) as CorePhysicsRecord].filter((r) => active(r));
451
459
  if (tl.kind === 'reset') {
452
- if (resetCrossed(tl.keys, last, t)) for (const r of targets) reset.add(r.name);
460
+ if (resetCrossed(tl.keys, last, t, wrapped)) for (const r of targets) reset.add(r.name);
453
461
  continue;
454
462
  }
455
463
  const i = search(tl.keys, t);
@@ -473,9 +481,18 @@ export function posedPhysics(records: readonly CorePhysicsRecord[], timelines: r
473
481
  * issue #960): exactly when a key lies in `(last, t]` — so a key is crossed
474
482
  * once, by the step that reaches it, and `last` is −1 before the walk's
475
483
  * first apply, where a key at 0 is crossed.
484
+ *
485
+ * `wrapped` is a looping track's step whose animation time went DOWN (issue
486
+ * #1025, `./walk.ts`): the key crossed is then one after `last` or one at or
487
+ * before `t` — `(last, ∞)` and `(−1, t]` — measured against spine-core's
488
+ * `PhysicsConstraint.reset` calls on A10's walk. A step that wraps the track
489
+ * one or more times while its animation time still rises is not `wrapped`:
490
+ * the runtime fired only `(last, t]` there (a key at 0.25 of a 0.3 s
491
+ * animation stepped by 0.7 s, crossed by the track twice between two applies
492
+ * at 0.1 and 0.2, fired on neither).
476
493
  */
477
- export function resetCrossed(keys: readonly CoreKey[], last: number, t: number): boolean {
478
- return keys.some((k) => last < k.time && k.time <= t);
494
+ export function resetCrossed(keys: readonly CoreKey[], last: number, t: number, wrapped = false): boolean {
495
+ return wrapped ? keys.some((k) => k.time > last || k.time <= t) : keys.some((k) => last < k.time && k.time <= t);
479
496
  }
480
497
 
481
498
  // ---------------------------------------------------------------------------
@@ -546,6 +563,12 @@ export interface PhysicsStepContext {
546
563
  * to the step's time (`resetCrossed`).
547
564
  */
548
565
  last: number;
566
+ /**
567
+ * The walk's track loops (`./walk.ts`, issue #1025): a step whose animation
568
+ * time went down from `last` wrapped, and crosses the keys `resetCrossed`
569
+ * names for that. Absent on every walk but the looping one.
570
+ */
571
+ loop?: boolean;
549
572
  }
550
573
 
551
574
  /** A constraint's state in `ctx`, created fresh on its first update. */
@@ -788,7 +811,7 @@ export function physicsRows(records: readonly CorePhysicsRecord[], round: (v: nu
788
811
  */
789
812
  export function stepPhysicsRecords(records: readonly CoreConstraintRecord[], keyed: readonly CorePhysicsTimeline[], t: number, active: ReadonlySet<string>, ctx: PhysicsStepContext, before: number): CoreConstraintRecord[] {
790
813
  const physics = records.filter((r): r is CorePhysicsRecord => r.kind === 'physics');
791
- const posed = posedPhysics(physics, keyed, t, ctx.last, (r) => physicsActive(r, active));
814
+ const posed = posedPhysics(physics, keyed, t, ctx.last, (r) => physicsActive(r, active), {}, ctx.loop === true && t < ctx.last);
792
815
  ctx.last = t;
793
816
  for (const name of posed.reset) resetPhysicsState(physicsState(ctx, name), before);
794
817
  return records.map((r) => (r.kind === 'physics' ? (posed.records.get(r.name) as CorePhysicsRecord) : r));