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.
Files changed (62) hide show
  1. package/cli.ts +48 -17
  2. package/docs/AUTHORING.md +2 -2
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a09.ts +82 -0
  5. package/src/assertions/bodies/a10.ts +116 -0
  6. package/src/assertions/bodies/a12.ts +30 -0
  7. package/src/assertions/bodies/a24.ts +68 -0
  8. package/src/assertions/bodies/a25.ts +39 -0
  9. package/src/assertions/bodies/a26.ts +61 -0
  10. package/src/assertions/bodies/a29.ts +34 -0
  11. package/src/assertions/bodies/a30.ts +50 -0
  12. package/src/assertions/bodies/a32.ts +44 -0
  13. package/src/assertions/bodies/a34.ts +133 -0
  14. package/src/assertions/bodies/a39.ts +303 -0
  15. package/src/assertions/bodies/a40.ts +128 -0
  16. package/src/assertions/bodies/a43.ts +181 -0
  17. package/src/assertions/bodies/a46.ts +224 -0
  18. package/src/assertions/facts/animation_durations.ts +37 -0
  19. package/src/assertions/facts/bone_timelines.ts +37 -0
  20. package/src/assertions/facts/constraint_targets.ts +56 -0
  21. package/src/assertions/facts/deform_survey.ts +27 -0
  22. package/src/assertions/facts/event_keys.ts +55 -0
  23. package/src/assertions/facts/sequences.ts +85 -0
  24. package/src/assertions/facts/skeleton_roster.ts +45 -0
  25. package/src/assertions/facts/slider_composition.ts +78 -0
  26. package/src/assertions/facts/stepped_poses.ts +74 -0
  27. package/src/assertions/facts/two_colour.ts +52 -0
  28. package/src/assertions/inward_advance.ts +58 -0
  29. package/src/assertions/model/animation_durations.ts +57 -0
  30. package/src/assertions/model/bone_timelines.ts +56 -0
  31. package/src/assertions/model/constraint_targets.ts +82 -0
  32. package/src/assertions/model/constraints.ts +34 -16
  33. package/src/assertions/model/deform_survey.ts +24 -0
  34. package/src/assertions/model/event_keys.ts +45 -0
  35. package/src/assertions/model/index.ts +83 -2
  36. package/src/assertions/model/linked_meshes.ts +2 -2
  37. package/src/assertions/model/mesh_attachments.ts +1 -1
  38. package/src/assertions/model/region_joins.ts +20 -12
  39. package/src/assertions/model/sequences.ts +157 -0
  40. package/src/assertions/model/skeleton_roster.ts +23 -0
  41. package/src/assertions/model/skin_entries.ts +18 -8
  42. package/src/assertions/model/skin_members.ts +5 -3
  43. package/src/assertions/model/skin_meshes.ts +1 -1
  44. package/src/assertions/model/slider_composition.ts +191 -0
  45. package/src/assertions/model/slot_colour.ts +5 -4
  46. package/src/assertions/model/stepped_poses.ts +89 -0
  47. package/src/assertions/model/two_colour.ts +61 -0
  48. package/src/assertions/model/vertex_polygons.ts +9 -8
  49. package/src/bonedist.ts +18 -6
  50. package/src/compile.ts +114 -14
  51. package/src/core/additive.ts +468 -0
  52. package/src/core/constraints.ts +12 -2
  53. package/src/core/constraints_physics.ts +31 -8
  54. package/src/core/raw.ts +77 -16
  55. package/src/core/walk.ts +202 -0
  56. package/src/deformmeasure.ts +35 -2270
  57. package/src/deformstructure.ts +60 -18
  58. package/src/deformsurvey.ts +2295 -0
  59. package/src/nonfinite.ts +54 -0
  60. package/src/render.ts +200 -59
  61. package/src/render_core.ts +5 -0
  62. package/src/validate.ts +791 -1558
package/src/compile.ts CHANGED
@@ -318,9 +318,24 @@ const FRAME = 1 / 60;
318
318
  * returns, so the refusal below is raised there, inside `compile`, in these
319
319
  * words. Exported so the selftest hands it to the emitter the way the assembly
320
320
  * does (`MA03`, `MA08`).
321
+ *
322
+ * 🔢 **It returns the order the FILE keys, which is the comparator's order
323
+ * with every integer-like name first** (issue #1034). `animations` is a JSON
324
+ * object, and an object lists a key that is an array index (`5`, `10` — not
325
+ * `01`, `-a` or `2b`) before every other key, in ascending order, whatever
326
+ * order it was filled in. Measured on a probe keying `5, -a, 10, 2b, 01`: the
327
+ * comparator orders `-a, 01, 2b, 5, 10`, and `skeleton.json`, spine-core's
328
+ * `animations` and the `/3` document's `editorOrder` all list
329
+ * `5, 10, -a, 01, 2b`. The emitter keys the object either way, so no byte
330
+ * moves; what moves is every READER of a `rigc-compiled/2` or `/1` document,
331
+ * which derives the file's order by calling this function and was handed the
332
+ * comparator's list (`fileAnimationOrder` below). The comparator's own list is
333
+ * `editorNamesInOrder(names, 'animations')`. Skins are an array and keep the
334
+ * comparator's order (`editorSkinOrder`), and a skin's slot keys come back as
335
+ * an object's keys (`editorSlotKeyOrder`), which are the file's already.
321
336
  */
322
337
  export function editorAnimationOrder(names: readonly string[]): string[] {
323
- return editorNamesInOrder(names, 'animations');
338
+ return keyedOrder(editorNamesInOrder(names, 'animations'));
324
339
  }
325
340
 
326
341
  /**
@@ -3514,17 +3529,12 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3514
3529
  // applies them, so a name pair the editor could key two ways is refused here
3515
3530
  // in the order it was refused at emission before this entry existed.
3516
3531
  //
3517
- // ⚠️ A name list the file KEYS is stated as the keys of an object filled in
3518
- // that order, not as the sorted list: the file's `animations` and a skin's
3519
- // `attachments` are JSON objects, and an object lists an integer-like key
3520
- // ("2", "10") before every other key, in ascending order, whatever order it
3521
- // was filled in. That is the order `skeleton.json` spells and its parser
3522
- // reads, so it is the order stated here; the skins are an array and keep the
3523
- // sort's.
3524
- const editorOrder: ModelEditorOrder = {
3525
- skins: EDITOR_ORDERS.skins(skins).map((skin) => ({ name: skin.name, slots: Object.keys(EDITOR_ORDERS.slotKeys(skin.attachments)) })),
3526
- animations: keyedOrder(EDITOR_ORDERS.animations([...animations.keys()])),
3527
- };
3532
+ // ⚠️ A name list the file KEYS is the keys of an object filled in that
3533
+ // order: an object lists an integer-like key ("2", "10") before every other
3534
+ // key. Since issue #1034 that is `editorAnimationOrder`'s own result rather
3535
+ // than a step applied here, so a reader deriving the order of a `/2` document
3536
+ // (`fileAnimationOrder`) is handed the same list this states.
3537
+ const editorOrder: ModelEditorOrder = editorOrderOver(skins, [...animations.keys()]);
3528
3538
 
3529
3539
  for (const slot of slots) {
3530
3540
  if (!boneNames.has(slot.bone)) throw new CompileError(`slot "${slot.name}" has no bone`);
@@ -3559,13 +3569,103 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3559
3569
  */
3560
3570
  const EDITOR_ORDERS = { skins: editorSkinOrder, slotKeys: editorSlotKeyOrder, animations: editorAnimationOrder } as const;
3561
3571
 
3562
- /** The order an object filled with `names`, in that order, lists its keys in — the order a JSON object keyed by them is spelled in. */
3563
- function keyedOrder(names: readonly string[]): string[] {
3572
+ /**
3573
+ * The order an object filled with `names`, in that order, lists its keys in —
3574
+ * the order a JSON object keyed by them is spelled in, and the order
3575
+ * spine-core reads it in: every array-index name (`5`, `10`; not `01`, `-a`,
3576
+ * `2b`) first, ascending, then the rest as filled (issue #1034).
3577
+ *
3578
+ * ⭐ It is the rule for EVERY object the Spine file keys by a name, not only
3579
+ * `animations`: the emitter fills each such object in the model's order — an
3580
+ * animation's `slots`, `bones`, `ik`, `transform`, `path`, `physics` and
3581
+ * `slider` groups and its `attachments` at all three levels (skin, slot,
3582
+ * attachment) — so a reader walking one of them in the file's order walks
3583
+ * this over the model's order (`src/deformstructure.ts`'s deform timelines,
3584
+ * `src/assertions/model/constraints.ts`'s constraint timelines). Measured on a
3585
+ * build whose `ik` group keys `leg_f_ik` then `5`: the file and spine-core list
3586
+ * `5, leg_f_ik`.
3587
+ */
3588
+ export function keyedOrder(names: readonly string[]): string[] {
3564
3589
  const keyed: Record<string, true> = {};
3565
3590
  for (const name of names) keyed[name] = true;
3566
3591
  return Object.keys(keyed);
3567
3592
  }
3568
3593
 
3594
+ /** A skin as far as its order reads it: its name and its table, keyed by slot. */
3595
+ interface OrderedSkin {
3596
+ name: string;
3597
+ attachments: Readonly<Record<string, unknown>>;
3598
+ }
3599
+
3600
+ /**
3601
+ * The orders the Spine file lists `skins`, each skin's slot keys and
3602
+ * `animations` in, over a model's skins and animation names: `EDITOR_ORDERS`,
3603
+ * the functions `compile` hands the emitter, so this is the emitted order by
3604
+ * construction rather than a second statement of it (issue #1034). `compileModel`
3605
+ * states it in the model (`editorOrder`); `fileAnimationOrder` and `fileSkinOrder` derive it for a
3606
+ * document that does not state it. A name set the editor could key two ways is
3607
+ * refused here by name, as at emission.
3608
+ */
3609
+ export function editorOrderOver(skins: readonly OrderedSkin[], animations: readonly string[]): ModelEditorOrder {
3610
+ return { skins: editorSkinsOver(skins), animations: EDITOR_ORDERS.animations(animations) };
3611
+ }
3612
+
3613
+ /** `editorOrderOver`'s skins alone: each skin's name and slot keys, in the file's order. */
3614
+ function editorSkinsOver(skins: readonly OrderedSkin[]): ModelEditorOrder['skins'] {
3615
+ return EDITOR_ORDERS.skins(skins).map((skin) => ({ name: skin.name, slots: Object.keys(EDITOR_ORDERS.slotKeys(skin.attachments)) }));
3616
+ }
3617
+
3618
+ /** What a reader of a model document holds that the file's orders are read or derived from: `CompiledDocument`'s fields. */
3619
+ export interface OrderedDocument {
3620
+ skins: readonly OrderedSkin[];
3621
+ animations: readonly { name: string }[];
3622
+ stated: { editorOrder: ModelEditorOrder } | null;
3623
+ }
3624
+
3625
+ /**
3626
+ * The order the Spine file lists a model document's animations in — the one
3627
+ * place a reader of the document gets it (issue #1034), with
3628
+ * `fileSkinOrder` its sibling for the skins and their slot keys.
3629
+ *
3630
+ * - A `rigc-compiled/3` document STATES it (`editorOrder`, issue #1026), and
3631
+ * that statement is the one source: nothing derives it beside the document.
3632
+ * - A `/2` or `/1` document does not, and it is derived by the emitter's own
3633
+ * rule over the document's names (`EDITOR_ORDERS`, as `editorOrderOver`
3634
+ * runs it) — the file's order for every name, integer-like ones included
3635
+ * (`editorAnimationOrder`'s 🔢). A name set the editor could key two ways is
3636
+ * refused by name (`CompileError`), as at emission.
3637
+ */
3638
+ export function fileAnimationOrder(doc: OrderedDocument): readonly string[] {
3639
+ return doc.stated !== null ? doc.stated.editorOrder.animations : EDITOR_ORDERS.animations(doc.animations.map((a) => a.name));
3640
+ }
3641
+
3642
+ /** `fileAnimationOrder`'s sibling: the skins, each with its slot keys, in the order the Spine file lists them — stated by a `/3` document, derived for a `/2` or `/1` one. */
3643
+ export function fileSkinOrder(doc: OrderedDocument): ModelEditorOrder['skins'] {
3644
+ return doc.stated !== null ? doc.stated.editorOrder.skins : editorSkinsOver(doc.skins);
3645
+ }
3646
+
3647
+ /**
3648
+ * `skins` in the order `order` lists them (`fileSkinOrder`), each
3649
+ * with its slot keys in that order. The k-th entry of a name is the k-th skin
3650
+ * of that name, so two skins answering to one name — a defect another check
3651
+ * names — are neither merged nor dropped. A skin `order` does not list, or an
3652
+ * entry naming no skin left, is this caller's defect (`readModel` refuses an
3653
+ * `editorOrder` that is not a permutation of the document's skins), so it
3654
+ * throws rather than walking a guess.
3655
+ */
3656
+ export function skinsInFileOrder<T extends { name: string }>(skins: readonly T[], order: ModelEditorOrder['skins']): Array<{ skin: T; slots: readonly string[] }> {
3657
+ const left = [...skins];
3658
+ const out: Array<{ skin: T; slots: readonly string[] }> = [];
3659
+ for (const entry of order) {
3660
+ const at = left.findIndex((skin) => skin.name === entry.name);
3661
+ if (at < 0) throw new Error(`internal: the file's skin order names skin "${entry.name}", which no skin left in the walk answers to`);
3662
+ out.push({ skin: left[at], slots: entry.slots });
3663
+ left.splice(at, 1);
3664
+ }
3665
+ if (left.length > 0) throw new Error(`internal: the file's skin order leaves out skin(s) ${left.map((skin) => `"${skin.name}"`).join(', ')}`);
3666
+ return out;
3667
+ }
3668
+
3569
3669
  /**
3570
3670
  * The fields `CompileResult` carries from the model, in the order it has
3571
3671
  * always carried them (`CarriedFromCompileResult`), by reference.
@@ -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
  /**