spine-rigc 2.54.0 → 2.56.0
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/README.md +1 -1
- package/docs/AUTHORING.md +590 -30
- package/package.json +1 -1
- package/src/anglerange.ts +190 -0
- package/src/assertions/bodies/a23.ts +80 -5
- package/src/cli/core_commands.ts +236 -14
- package/src/cli/shared.ts +53 -8
- package/src/compile.ts +45 -6
- package/src/core/entry.ts +6 -1
- package/src/deformgen.ts +333 -2
- package/src/motion.ts +20 -8
- package/src/passthrough.ts +575 -0
- package/src/render_shared.ts +30 -0
- package/src/rig.ts +95 -3
- package/src/stop.ts +472 -0
- package/src/timelines.ts +75 -29
- package/src/transform.ts +20 -0
- package/src/types.ts +4 -3
- package/src/viewportpin.ts +168 -0
- package/src/whirl.ts +580 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.56.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far one bone turns against another over an animation — the reading behind
|
|
3
|
+
* `rigc render --angle-range <bone>:<relative-to>[,…]` (issue #1414).
|
|
4
|
+
*
|
|
5
|
+
* ⭐ **A reading, not a gate**, as `--folds` and `--pass-through` are. A physics
|
|
6
|
+
* value has no scale an author can see: §4.6 lists `inertia`, `strength` and the
|
|
7
|
+
* rest with their bounds, and nothing said what `inertia` 0.5 does to a 54-unit
|
|
8
|
+
* chain under a hip swing — the first try on a real rig was four times too big.
|
|
9
|
+
* The answer is a number the pose already holds: how far the link turned against
|
|
10
|
+
* the bone it hangs from. So this reads it off the frames `render` draws and
|
|
11
|
+
* decides nothing.
|
|
12
|
+
*
|
|
13
|
+
* ## What the range is
|
|
14
|
+
*
|
|
15
|
+
* At each frame the bones are posed by whichever poser draws the frames, and each
|
|
16
|
+
* bone's **world direction** is that poser's own `rotationX` — the direction its x
|
|
17
|
+
* axis points, degrees counter-clockwise, y up (`BoneSnapshot`), never a second
|
|
18
|
+
* opinion recomputed from the matrix here. The relative angle is
|
|
19
|
+
*
|
|
20
|
+
* rotationX(bone) − rotationX(relative-to)
|
|
21
|
+
*
|
|
22
|
+
* taken into (−180, 180] on the first frame read, and then **followed**: each
|
|
23
|
+
* later frame's difference is moved by whole turns to within half a turn of the
|
|
24
|
+
* frame before it. So a link that sweeps 200° reads 200°, not −160°, and one that
|
|
25
|
+
* sways through ±180° does not read a jump of a whole turn. Following assumes no
|
|
26
|
+
* two consecutive frames are half a turn or more apart, which a frame grid that
|
|
27
|
+
* coarse could not show anyway.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ It is the angle **drawn**, not the bone's local `rotation`. The two agree for
|
|
30
|
+
* a child of the other bone when the parent carries no non-uniform scale or shear;
|
|
31
|
+
* otherwise they differ, and the drawn one is what a viewer sees.
|
|
32
|
+
*
|
|
33
|
+
* The range is over the frames on the render's own grid — `--fps`, from t = 0 to
|
|
34
|
+
* the animation's last frame — so a peak between two frames is not read. A bone
|
|
35
|
+
* the posed skin leaves unposed, or one scaled to nothing, has no direction: its
|
|
36
|
+
* world matrix is zero, and the reading is refused naming the bone and the frame
|
|
37
|
+
* rather than read as 0°.
|
|
38
|
+
*
|
|
39
|
+
* Pure: no clock, no randomness, no runtime. The bone snapshots are handed in by
|
|
40
|
+
* whichever poser drew the frames.
|
|
41
|
+
*/
|
|
42
|
+
import type { BoneSnapshot } from './render_shared.ts';
|
|
43
|
+
|
|
44
|
+
/** One pair as the flag names it: the bone whose angle is read, and the bone it is read against. */
|
|
45
|
+
export interface BonePair {
|
|
46
|
+
bone: string;
|
|
47
|
+
relativeTo: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A malformed `--angle-range` value, or a pose the reading has no angle for — said by name. */
|
|
51
|
+
export class AngleRangeError extends Error {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `--angle-range`'s value — `<bone>:<relative-to>[,<bone>:<relative-to>…]` —
|
|
55
|
+
* read against the skeleton's bones, or refused naming the entry: an empty
|
|
56
|
+
* entry, one without exactly one `:`, an empty name, a bone the skeleton does not
|
|
57
|
+
* have (with the bones it does), a bone read against itself (always 0°), and a
|
|
58
|
+
* pair named twice.
|
|
59
|
+
*/
|
|
60
|
+
export function readBonePairs(raw: string, bones: readonly string[]): BonePair[] {
|
|
61
|
+
const form = '<bone>:<relative-to>[,<bone>:<relative-to>…]';
|
|
62
|
+
const pairs: BonePair[] = [];
|
|
63
|
+
const entries = raw.split(',');
|
|
64
|
+
for (let i = 0; i < entries.length; i++) {
|
|
65
|
+
const entry = entries[i].trim();
|
|
66
|
+
if (entry === '') throw new AngleRangeError(`--angle-range ${JSON.stringify(raw)}: entry ${i} is empty — it takes ${form}`);
|
|
67
|
+
const parts = entry.split(':');
|
|
68
|
+
if (parts.length !== 2) {
|
|
69
|
+
throw new AngleRangeError(`--angle-range: entry ${i} ${JSON.stringify(entry)} has ${parts.length - 1} ":" — it takes ${form}, one ":" per pair`);
|
|
70
|
+
}
|
|
71
|
+
const [bone, relativeTo] = parts.map((p) => p.trim());
|
|
72
|
+
for (const [role, name] of [['bone', bone], ['relative-to bone', relativeTo]] as const) {
|
|
73
|
+
if (name === '') throw new AngleRangeError(`--angle-range: entry ${i} ${JSON.stringify(entry)} names no ${role} — it takes ${form}`);
|
|
74
|
+
if (!bones.includes(name)) {
|
|
75
|
+
throw new AngleRangeError(`--angle-range: entry ${i} names ${role} ${JSON.stringify(name)}, which this skeleton does not have; its bones are [${bones.join(', ')}]`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (bone === relativeTo) {
|
|
79
|
+
throw new AngleRangeError(`--angle-range: entry ${i} reads bone ${JSON.stringify(bone)} against itself, which is 0° on every frame — name a second bone`);
|
|
80
|
+
}
|
|
81
|
+
const again = pairs.findIndex((p) => p.bone === bone && p.relativeTo === relativeTo);
|
|
82
|
+
if (again >= 0) throw new AngleRangeError(`--angle-range: entry ${i} names "${bone}:${relativeTo}" again — entry ${again} already reads it`);
|
|
83
|
+
pairs.push({ bone, relativeTo });
|
|
84
|
+
}
|
|
85
|
+
return pairs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One sampled frame: its index, its time, and every bone's world transform. */
|
|
89
|
+
export interface AngleFrame {
|
|
90
|
+
index: number;
|
|
91
|
+
time: number;
|
|
92
|
+
bones: readonly BoneSnapshot[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One extreme of a range: the angle, degrees, and the frame that reached it first. */
|
|
96
|
+
export interface AngleExtreme {
|
|
97
|
+
degrees: number;
|
|
98
|
+
index: number;
|
|
99
|
+
time: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One pair's reading over one set. */
|
|
103
|
+
export interface AngleRangeReading {
|
|
104
|
+
bone: string;
|
|
105
|
+
relativeTo: string;
|
|
106
|
+
/** Frames read — every frame of the set. */
|
|
107
|
+
frames: number;
|
|
108
|
+
low: AngleExtreme;
|
|
109
|
+
high: AngleExtreme;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Wrong readings the suite plants to see its controls go red (issue #1414):
|
|
114
|
+
* `worldAngle` reads the bone's own world direction and ignores the bone it is
|
|
115
|
+
* read against; `noFollow` leaves each frame's difference in (−180, 180] rather
|
|
116
|
+
* than following it from the frame before.
|
|
117
|
+
*/
|
|
118
|
+
export interface AnglePlant {
|
|
119
|
+
worldAngle?: true;
|
|
120
|
+
noFollow?: true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `d` taken into (−180, 180]. */
|
|
124
|
+
function wrapped(d: number): number {
|
|
125
|
+
let r = d % 360;
|
|
126
|
+
if (r <= -180) r += 360;
|
|
127
|
+
if (r > 180) r -= 360;
|
|
128
|
+
return r;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A world matrix with no direction — every entry zero: an unposed bone, or one scaled to nothing. */
|
|
132
|
+
function directionless(b: BoneSnapshot): boolean {
|
|
133
|
+
return b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Each pair's range over `frames`, in the pairs' order — refused by
|
|
138
|
+
* `AngleRangeError`, naming the bone and the frame, where a bone has no
|
|
139
|
+
* direction (`directionless`), and naming the bone where a frame carries no
|
|
140
|
+
* snapshot of it at all (frames sampled without `{ bones: true }`).
|
|
141
|
+
*/
|
|
142
|
+
export function angleRangesOf(pairs: readonly BonePair[], frames: readonly AngleFrame[], where: string, plant: AnglePlant = {}): AngleRangeReading[] {
|
|
143
|
+
return pairs.map(({ bone, relativeTo }) => {
|
|
144
|
+
let previous: number | null = null;
|
|
145
|
+
let low: AngleExtreme | null = null;
|
|
146
|
+
let high: AngleExtreme | null = null;
|
|
147
|
+
for (const frame of frames) {
|
|
148
|
+
const at = (name: string): BoneSnapshot => {
|
|
149
|
+
const found = frame.bones.find((b) => b.name === name);
|
|
150
|
+
if (found === undefined) throw new AngleRangeError(`--angle-range, ${where} frame ${frame.index}: no world transform for bone ${JSON.stringify(name)} was sampled`);
|
|
151
|
+
if (directionless(found)) {
|
|
152
|
+
throw new AngleRangeError(
|
|
153
|
+
`--angle-range, ${where} frame ${frame.index} (t=${frame.time.toFixed(4)}s): bone ${JSON.stringify(name)} has no direction — its world matrix is zero ` +
|
|
154
|
+
'(a bone the posed skin leaves unposed, or one scaled to nothing), so it has no angle to read',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return found;
|
|
158
|
+
};
|
|
159
|
+
const a = at(bone);
|
|
160
|
+
const b = at(relativeTo);
|
|
161
|
+
const difference = plant.worldAngle === true ? a.rotationX : a.rotationX - b.rotationX;
|
|
162
|
+
let angle = wrapped(difference);
|
|
163
|
+
if (previous !== null && plant.noFollow !== true) angle += 360 * Math.round((previous - angle) / 360);
|
|
164
|
+
previous = angle;
|
|
165
|
+
if (low === null || angle < low.degrees) low = { degrees: angle, index: frame.index, time: frame.time };
|
|
166
|
+
if (high === null || angle > high.degrees) high = { degrees: angle, index: frame.index, time: frame.time };
|
|
167
|
+
}
|
|
168
|
+
if (low === null || high === null) throw new AngleRangeError(`--angle-range, ${where}: no frame was sampled`);
|
|
169
|
+
return { bone, relativeTo, frames: frames.length, low, high };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Degrees to four places, with a rounding to zero printed as `0.0000` rather than `-0.0000`. */
|
|
174
|
+
export function degreesText(d: number): string {
|
|
175
|
+
const text = d.toFixed(4);
|
|
176
|
+
return text === '-0.0000' ? '0.0000' : text;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The printed line: the set, the pair, the low and the high with the frame each
|
|
181
|
+
* was first reached on, the span, and the grid it was read on.
|
|
182
|
+
*/
|
|
183
|
+
export function angleRangeLine(animation: string | null, fps: number, r: AngleRangeReading): string {
|
|
184
|
+
const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
|
|
185
|
+
return (
|
|
186
|
+
`${where}: bone ${JSON.stringify(r.bone)} against ${JSON.stringify(r.relativeTo)} ranges ${degreesText(r.low.degrees)}° to ${degreesText(r.high.degrees)}° ` +
|
|
187
|
+
`(a span of ${degreesText(r.high.degrees - r.low.degrees)}°) over ${r.frames} frame(s) at ${fps} fps; ` +
|
|
188
|
+
`lowest at frame ${r.low.index} (t=${r.low.time.toFixed(4)}s), highest at frame ${r.high.index} (t=${r.high.time.toFixed(4)}s)`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -74,7 +74,8 @@ const SETUP_POSE_SAYS: Record<string, (pose: PhysicsSetup, animations: number, r
|
|
|
74
74
|
// on the arm's own words, which `T100` holds.
|
|
75
75
|
strength: (pose, _animations, rule) => `has strength ${pose.strength}; ${setupBasisSays(rule, pose.strength)}`,
|
|
76
76
|
// The bound is read off the row, so the setup pose and a key cannot state two
|
|
77
|
-
// intervals; the reason is the arm the value took
|
|
77
|
+
// intervals; the reason is the arm the value took — above 1, at 1 (issue
|
|
78
|
+
// #1422, inside from #794 until then) or below 0.
|
|
78
79
|
damping: (pose, _animations, rule) =>
|
|
79
80
|
`has damping ${pose.damping}; must be ${rule.states} — the per-step decay is \`damping ** (60 * step)\`, and ` +
|
|
80
81
|
setupBasisSays(rule, pose.damping),
|
|
@@ -92,10 +93,11 @@ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
|
|
|
92
93
|
|
|
93
94
|
// Every failure mode here is silent. The five component fields default to
|
|
94
95
|
// 0, so a constraint can drive nothing at all; `mix` 0 mutes it; `mass` 0
|
|
95
|
-
// becomes an infinite massInverse; and `damping` above 1 never settles,
|
|
96
|
-
// on a mesh-driving bone means the canvas re-rasterises forever. 1 itself
|
|
97
|
-
// inside the bound
|
|
98
|
-
//
|
|
96
|
+
// becomes an infinite massInverse; and `damping` at or above 1 never settles,
|
|
97
|
+
// which on a mesh-driving bone means the canvas re-rasterises forever. 1 itself
|
|
98
|
+
// was inside the bound from issue #794 to #1422, as a jiggle held rather than a
|
|
99
|
+
// fault; it is out because a decay of 1 puts the step's eigenvalues on the unit
|
|
100
|
+
// circle, and a chain of such links was measured resonating without limit.
|
|
99
101
|
//
|
|
100
102
|
// 🔑 **Two arms, one criterion.** The second arm below reads every physics
|
|
101
103
|
// TIMELINE key, and it is written against `PHYSICS_POSE_RULES` — the table
|
|
@@ -410,12 +412,72 @@ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, f
|
|
|
410
412
|
if (combo.strength.value === setup.strength.value && combo.massInverse.value === setup.massInverse.value && combo.damping.value === setup.damping.value) continue;
|
|
411
413
|
const at = physicsStepStability(combo.strength.value, combo.massInverse.value, combo.damping.value, physics.step);
|
|
412
414
|
if (at.stable) continue;
|
|
415
|
+
// A decay rounded to 1 by the setup damping itself is the setup line's, whatever else this animation keys.
|
|
416
|
+
if (!(at.d < 1) && at.k < at.bound && combo.damping.from === 'setup') continue;
|
|
413
417
|
fail(
|
|
414
418
|
'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
|
|
415
419
|
`animation "${animation}" can pose physics "${facts.constraints[index]?.name ?? `#${index}`}" at ${runawaySays(combo, physics.step, at)}`,
|
|
416
420
|
);
|
|
417
421
|
}
|
|
418
422
|
}
|
|
423
|
+
|
|
424
|
+
// --- strength held at 0 for as long as an animation plays (issue #1422) ---
|
|
425
|
+
//
|
|
426
|
+
// A strength KEY of 0 is accepted (issue #727) because it releases the
|
|
427
|
+
// constraint for a span and a later key pulls the offset back. An animation
|
|
428
|
+
// whose strength timelines pose 0 at EVERY time has no later key: [measured on
|
|
429
|
+
// #1422, x drive, the runtime's default world, spine-core and rigc's core
|
|
430
|
+
// identical bit for bit] the offset grows exactly linearly while it plays —
|
|
431
|
+
// 1.33e4 at 60 s, 5.33e4 at 240 s, 1.33e5 at 600 s — and A10's walk of
|
|
432
|
+
// max(duration, 1) s sees a finite number and passes it.
|
|
433
|
+
//
|
|
434
|
+
// 🔑 **"Every time it plays", not "looped".** Whether an animation loops is
|
|
435
|
+
// not in the artifact: skeleton JSON has no loop flag, the motion spec's
|
|
436
|
+
// `loop` is a player hint the compiler never reads (`MotionAnimation.loop`),
|
|
437
|
+
// and an editor export carries neither — and when it plays, and for how long,
|
|
438
|
+
// is the consumer's. The criterion that needs none of that is the one the
|
|
439
|
+
// runtime makes true either way: looped, the animation never leaves its
|
|
440
|
+
// values; played once, a track holds its last frame, which is 0. So the
|
|
441
|
+
// clause reads the timeline alone — every key and every Bézier sample, as
|
|
442
|
+
// the pose field it lands in, and the first key at t = 0, since before a
|
|
443
|
+
// later first key the timeline poses the setup value, which the strength row
|
|
444
|
+
// holds above 0.
|
|
445
|
+
//
|
|
446
|
+
// Per animation and constraint: when two strength timelines reach one
|
|
447
|
+
// constraint in one animation (a named one and the global one), it is held
|
|
448
|
+
// only if both are — a timeline that lifts it is a later key for the other.
|
|
449
|
+
// A constraint driving nothing is named by the first clause, and one resting
|
|
450
|
+
// at strength 0 or below by its row, so neither is read here.
|
|
451
|
+
const strengthRule = physicsRuleFor('strength');
|
|
452
|
+
const heldAtZero = new Map<string, Map<number, { keys: number; held: boolean }>>();
|
|
453
|
+
for (const timeline of facts.timelines) {
|
|
454
|
+
if (timeline.kind !== 'physics' || timeline.word !== 'strength') continue;
|
|
455
|
+
const first = timeline.frames[0];
|
|
456
|
+
const posed = (value: number): number => (timeline.posed === undefined ? value : timeline.posed(value));
|
|
457
|
+
const held = first !== undefined && first.time <= 0 && timeline.channelValues(0).every((value) => posed(value) === 0);
|
|
458
|
+
const byConstraint = heldAtZero.get(timeline.animation) ?? new Map<number, { keys: number; held: boolean }>();
|
|
459
|
+
heldAtZero.set(timeline.animation, byConstraint);
|
|
460
|
+
for (const index of timeline.reach) {
|
|
461
|
+
const was = byConstraint.get(index);
|
|
462
|
+
byConstraint.set(index, { keys: (was?.keys ?? 0) + timeline.frames.length, held: (was?.held ?? true) && held });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
for (const [animation, byConstraint] of heldAtZero) {
|
|
466
|
+
for (const [index, { keys, held }] of byConstraint) {
|
|
467
|
+
const constraint = facts.constraints[index];
|
|
468
|
+
const physics = constraint?.physics;
|
|
469
|
+
if (!held || constraint === undefined || physics === undefined || strengthRule === undefined) continue;
|
|
470
|
+
if (!PHYSICS_COMPONENTS.some((k) => physics.components[k] > 0) || !strengthRule.poseOk(physics.setup.strength)) continue;
|
|
471
|
+
fail(
|
|
472
|
+
'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
|
|
473
|
+
`animation "${animation}" poses physics "${constraint.name}" strength 0 at every time it plays — ${keys} key(s) from t=0, ` +
|
|
474
|
+
`every one 0 and no curve between them lifting it, so ${physicsOutsideSays(strengthRule, 0)}: a strength key of 0 is ` +
|
|
475
|
+
'accepted for a span because a later key pulls the offset back, and this animation has none, so for as long as it ' +
|
|
476
|
+
'plays, looped or held on its last frame, a push (wind, gravity, the bone moving) carries the offset away and it ' +
|
|
477
|
+
'grows without limit; key strength above 0 somewhere in the animation, or remove the timeline',
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
419
481
|
}
|
|
420
482
|
|
|
421
483
|
/** The three timelines whose values the stability clause combines. */
|
|
@@ -453,10 +515,23 @@ function plain(value: number): string {
|
|
|
453
515
|
* The stability clause's sentence: the four authored numbers, `k` and the bound
|
|
454
516
|
* as the runtime forms them, what the step does at that pose, and the four
|
|
455
517
|
* levers that move it back under the bound.
|
|
518
|
+
*
|
|
519
|
+
* ⚠️ One pose fails with `k` under the bound: a damping the row accepts — below
|
|
520
|
+
* 1 — whose decay `damping ** (60 * step)` the runtime's doubles round to exactly
|
|
521
|
+
* 1 (issue #1422: the largest double below 1 does from fps 121 on). That is the
|
|
522
|
+
* damping row's own fault, a velocity that never decays, reached through the
|
|
523
|
+
* rate, so it says so rather than printing a `k` that is under its bound.
|
|
456
524
|
*/
|
|
457
525
|
function runawaySays(combo: StabilityCombo, step: number, at: PhysicsStepStability): string {
|
|
458
526
|
const src = (c: Candidate): string => (c.from === 'setup' ? '' : ` (${c.from})`);
|
|
459
527
|
const fps = plain(1 / step);
|
|
528
|
+
if (!(at.d < 1)) {
|
|
529
|
+
return (
|
|
530
|
+
`damping ${combo.damping.value}${src(combo.damping)} at fps ${fps} — damping^(60·step) = ${at.d} as the runtime forms it, ` +
|
|
531
|
+
'so although the damping is below 1 no velocity ever decays: the step\'s eigenvalues lie on the unit circle, the fault ' +
|
|
532
|
+
'the damping row refuses 1 for, reached here by rounding; lower damping or lower fps'
|
|
533
|
+
);
|
|
534
|
+
}
|
|
460
535
|
const places = apart(at.k, at.bound, 4);
|
|
461
536
|
return (
|
|
462
537
|
`strength ${combo.strength.value}${src(combo.strength)}, mass ${plain(1 / combo.massInverse.value)} (massInverse ${combo.massInverse.value})${src(combo.massInverse)}, ` +
|