spine-rigc 2.55.0 → 2.57.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 +4 -2
- package/docs/AUTHORING.md +549 -8
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +4 -1
- package/src/anglerange.ts +190 -0
- package/src/cli/core_commands.ts +218 -13
- package/src/cli/shared.ts +160 -9
- package/src/compile.ts +69 -8
- package/src/core/entry.ts +6 -1
- package/src/deformgen.ts +333 -2
- package/src/meshsplit.ts +683 -0
- package/src/motion.ts +20 -8
- package/src/render_shared.ts +30 -0
- package/src/rig.ts +95 -3
- package/src/stop.ts +472 -0
- package/src/transform.ts +20 -0
- package/src/viewportpin.ts +168 -0
- package/src/whirl.ts +580 -0
package/src/whirl.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far each physics constraint's bone turns in one step — the whirl
|
|
3
|
+
* reading `rigc explain` prints under its physics block (issue #1423) — and,
|
|
4
|
+
* on the same walk, the lever reading below it (issue #1427, *the lever
|
|
5
|
+
* reading* at the foot of this file).
|
|
6
|
+
*
|
|
7
|
+
* ⭐ **A reading, not a gate.** An `inertia` above 1 is kept by the editor and
|
|
8
|
+
* posed as written: the Spine 4.3.26 editor imports 2, 3 and 10 and exports
|
|
9
|
+
* them unchanged, the export posing identically to the build, and the user
|
|
10
|
+
* guide describes the field without a cap (issue #1423's measurement). So
|
|
11
|
+
* there is no source a refusal could cite, and what is left is to make the
|
|
12
|
+
* motion visible: a link that turns half a turn between two frames draws a
|
|
13
|
+
* direction the eye cannot follow, on a build that passes every assertion.
|
|
14
|
+
* Whether that is wanted is the author's. This names the bone, the animation,
|
|
15
|
+
* the degrees and the step, and decides nothing.
|
|
16
|
+
*
|
|
17
|
+
* ## The walk
|
|
18
|
+
*
|
|
19
|
+
* Each animation is walked as a player that starts it plays it: a fresh
|
|
20
|
+
* skeleton with no skin set, at the setup pose with every physics state reset
|
|
21
|
+
* there (pose 0), then the animation applied in steps of `1 / WHIRL_HZ`
|
|
22
|
+
* seconds until the track time reaches the duration (at least one step) —
|
|
23
|
+
* the raw entry's walk under `reset: 'setup'`, the physics clock moved by the
|
|
24
|
+
* step itself, which is spine-core's `update(1/60)` on a skeleton stepped at
|
|
25
|
+
* that rate (the recipe `CO56` holds the core to, bit for bit).
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ **Why from the setup pose, and not render's walk.** `render` resets the
|
|
28
|
+
* physics at the animation's first pose, so a bone the first key moves away
|
|
29
|
+
* from the setup pose is already there when the physics starts. A player does
|
|
30
|
+
* not do that: its skeleton starts at the setup pose, and the first step moves
|
|
31
|
+
* every keyed bone from there at once. The constrained bone's inertia answers
|
|
32
|
+
* that step — and it is where the largest turns were measured: on issue
|
|
33
|
+
* #1422's probe (a 1-link rotate drive under a parent keyed ±15°), inertia 10
|
|
34
|
+
* turns its bone 153.38° in step 1 from the setup pose, and walked from the
|
|
35
|
+
* animation's first pose at most 4.10°, 3.82° and 13.68° at `fps` 15, 30 and
|
|
36
|
+
* 60. A reading over render's walk would print the second and miss the motion
|
|
37
|
+
* the card is about — `WH03` plants it.
|
|
38
|
+
*
|
|
39
|
+
* ## The turn
|
|
40
|
+
*
|
|
41
|
+
* The turn of a bone from one pose to the next is the signed angle between
|
|
42
|
+
* its world x axis at the two poses — `atan2(a₀c₁ − c₀a₁, a₀a₁ + c₀c₁)` over
|
|
43
|
+
* the two matrices' first columns, in degrees, in (−180, 180] — so it is the
|
|
44
|
+
* change of the direction the bone is drawn along, whatever moved it: its own
|
|
45
|
+
* physics offset, its parent, a key, a mirrored scale. The figure is the
|
|
46
|
+
* largest magnitude over every step of the walk, at the earliest step on a
|
|
47
|
+
* tie. A turn near 180° in one step is a direction the drawing cannot tell
|
|
48
|
+
* from its opposite.
|
|
49
|
+
*
|
|
50
|
+
* Every physics constraint gets a line, whichever components it drives: the
|
|
51
|
+
* turn is a property of the posed bone, and the drives that do not rotate it
|
|
52
|
+
* still turn it when they move a parent's scale through zero or when a parent
|
|
53
|
+
* swings it. A skin-required constraint does not step on a skeleton with no
|
|
54
|
+
* skin set, so its line says it was not read rather than printing the turn of
|
|
55
|
+
* a bone its physics never moved. An animation a slider applies is a table
|
|
56
|
+
* posed at a value the consumer holds, not a span of time (`A39`'s reading,
|
|
57
|
+
* issue #407), so it is named and not walked — the animation that moves the
|
|
58
|
+
* slider's value is walked, and the slider is applied on each of its steps.
|
|
59
|
+
*
|
|
60
|
+
* Pure: no clock, no randomness, no runtime. Posed by rigc's core.
|
|
61
|
+
*/
|
|
62
|
+
import { posedBones } from './core/animation.ts';
|
|
63
|
+
import { stepPhysics, type PhysicsStepper } from './core/constraints_physics.ts';
|
|
64
|
+
import { type CompiledDocument, CoreInputError, underNoSkin } from './core/index.ts';
|
|
65
|
+
import { poseRawAnimationEach, type RawBone, type RawReset } from './core/raw.ts';
|
|
66
|
+
import { indexRuns } from './folds.ts';
|
|
67
|
+
|
|
68
|
+
/** The walk's rate: steps per second. The runtime's own physics default, and the rate a player steps a skeleton at. */
|
|
69
|
+
export const WHIRL_HZ = 60;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The reading's plants — the readings the card's measurement rejects, each a
|
|
73
|
+
* control's (`WH03`) and never a caller's:
|
|
74
|
+
* - `frame: 'parent'` reads the turn in the parent bone's frame — the bone's
|
|
75
|
+
* world turn less its parent's — which is a reading that ignores the
|
|
76
|
+
* parent's motion;
|
|
77
|
+
* - `reset: 'animation'` walks as `render` does, the physics reset at the
|
|
78
|
+
* animation's first pose, so the step from the setup pose is never taken.
|
|
79
|
+
*/
|
|
80
|
+
export interface WhirlPlant {
|
|
81
|
+
frame?: 'parent';
|
|
82
|
+
reset?: RawReset;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One physics constraint's largest turn over one animation's walk. */
|
|
86
|
+
export interface WhirlTurn {
|
|
87
|
+
constraint: string;
|
|
88
|
+
bone: string;
|
|
89
|
+
/** The largest |turn| in one step, degrees; `null` when not read (`why`). */
|
|
90
|
+
degrees: number | null;
|
|
91
|
+
/** The step it happens at — the turn from pose `step − 1` to pose `step` — and that pose's track time, seconds. */
|
|
92
|
+
step: number;
|
|
93
|
+
time: number;
|
|
94
|
+
/** Why the constraint was not read; `null` when it was. */
|
|
95
|
+
why: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One animation's walk and its constraints' turns. */
|
|
99
|
+
export interface WhirlAnimation {
|
|
100
|
+
animation: string;
|
|
101
|
+
/** How many steps of `1 / WHIRL_HZ` the walk took. */
|
|
102
|
+
steps: number;
|
|
103
|
+
turns: WhirlTurn[];
|
|
104
|
+
/** The core's refusal, when it would not pose the walk; then every turn is unread. */
|
|
105
|
+
refused: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The physics constraints of a document, in declaration order: name, bone, and whether a skeleton with no skin set steps it. */
|
|
109
|
+
export function physicsBones(doc: CompiledDocument): Array<{ name: string; bone: string; steps: boolean }> {
|
|
110
|
+
const out: Array<{ name: string; bone: string; steps: boolean }> = [];
|
|
111
|
+
for (const c of doc.constraints) {
|
|
112
|
+
const r = c.record;
|
|
113
|
+
if (r?.kind !== 'physics') continue;
|
|
114
|
+
out.push({ name: r.name, bone: r.bone, steps: !r.skin || r.listedBySkin });
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The signed turn of a bone's world x axis from `from` to `to`, degrees in (−180, 180]. */
|
|
120
|
+
export function axisTurn(from: Pick<RawBone, 'a' | 'c'>, to: Pick<RawBone, 'a' | 'c'>): number {
|
|
121
|
+
return (Math.atan2(from.a * to.c - from.c * to.a, from.a * to.a + from.c * to.c) * 180) / Math.PI;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `axisTurn` less the parent's own, wrapped to (−180, 180] — the `frame: 'parent'` plant. */
|
|
125
|
+
function parentFrameTurn(from: Pick<RawBone, 'a' | 'c'>, to: Pick<RawBone, 'a' | 'c'>, parentFrom: Pick<RawBone, 'a' | 'c'>, parentTo: Pick<RawBone, 'a' | 'c'>): number {
|
|
126
|
+
const d = axisTurn(from, to) - axisTurn(parentFrom, parentTo);
|
|
127
|
+
return d > 180 ? d - 360 : d <= -180 ? d + 360 : d;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One constraint's running figure over a walk: the largest |turn| so far, its
|
|
132
|
+
* step and time (step 0 before any), and the first step whose turn is not a
|
|
133
|
+
* number. The core passes a bone's matrix on as posed, NaN included, and
|
|
134
|
+
* `turn > largest` is false for NaN — so without `lost` a bone that overflowed
|
|
135
|
+
* would print the finite figure it reached before, which is the silence this
|
|
136
|
+
* reading exists to remove (`WH04`).
|
|
137
|
+
*/
|
|
138
|
+
export interface TurnRecord {
|
|
139
|
+
degrees: number;
|
|
140
|
+
step: number;
|
|
141
|
+
time: number;
|
|
142
|
+
lost: { step: number; time: number } | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A record no step has reached. */
|
|
146
|
+
export function freshTurnRecord(): TurnRecord {
|
|
147
|
+
return { degrees: 0, step: 0, time: 0, lost: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `turn` (signed, degrees) taken at `step`, `time` — kept when its magnitude is the largest so far, the earliest on a tie. */
|
|
151
|
+
export function recordTurn(record: TurnRecord, turn: number, step: number, time: number): void {
|
|
152
|
+
const size = Math.abs(turn);
|
|
153
|
+
if (!Number.isFinite(size)) record.lost ??= { step, time };
|
|
154
|
+
else if (size > record.degrees) {
|
|
155
|
+
record.degrees = size;
|
|
156
|
+
record.step = step;
|
|
157
|
+
record.time = time;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** How many steps of `1 / WHIRL_HZ` an animation of `duration` seconds is walked for: to the duration, at least one. */
|
|
162
|
+
export function whirlSteps(duration: number): number {
|
|
163
|
+
return Math.max(1, Math.ceil(duration * WHIRL_HZ));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every animation of `doc` walked from the setup pose (the header's *The
|
|
168
|
+
* walk*), and each physics constraint's largest turn in one step. A document
|
|
169
|
+
* with no physics constraint reads nothing and returns no animation.
|
|
170
|
+
*/
|
|
171
|
+
export function whirlReading(doc: CompiledDocument, plant: WhirlPlant = {}): WhirlAnimation[] {
|
|
172
|
+
const view = underNoSkin(doc);
|
|
173
|
+
const constraints = physicsBones(view);
|
|
174
|
+
if (constraints.length === 0) return [];
|
|
175
|
+
const out: WhirlAnimation[] = [];
|
|
176
|
+
for (const animation of view.animations) {
|
|
177
|
+
const steps = whirlSteps(animation.timelines.duration);
|
|
178
|
+
// A slider's table is posed at a value the consumer holds, not played in time (A39's reading, issue #407): a step
|
|
179
|
+
// through it is a moment the object never claimed, so it is named and not walked. Whatever moves the slider's
|
|
180
|
+
// value is an animation of its own, and that one is walked.
|
|
181
|
+
const sliders = view.constraints.filter((c) => c.kind === 'slider' && c.animation === animation.name).map((c) => `"${c.name}"`);
|
|
182
|
+
if (sliders.length > 0) {
|
|
183
|
+
const why = `slider ${sliders.join(', ')} applies it at a value, not in time, so there is no step to read — the animation that moves the slider's value is walked instead`;
|
|
184
|
+
out.push({ animation: animation.name, steps: 0, refused: null, turns: constraints.map((c) => ({ constraint: c.name, bone: c.bone, degrees: null, step: 0, time: 0, why })) });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const best = constraints.map(() => freshTurnRecord());
|
|
188
|
+
let prev: Map<string, RawBone> | null = null;
|
|
189
|
+
let refused: string | null = null;
|
|
190
|
+
try {
|
|
191
|
+
poseRawAnimationEach(view, animation.name, new Array<number>(steps).fill(1 / WHIRL_HZ), {}, plant.reset ?? 'setup', (pose, index) => {
|
|
192
|
+
const bones = new Map(pose.bones.map((b) => [b.name, b] as const));
|
|
193
|
+
if (prev !== null) {
|
|
194
|
+
for (let i = 0; i < constraints.length; i++) {
|
|
195
|
+
const name = constraints[i].bone;
|
|
196
|
+
const from = prev.get(name);
|
|
197
|
+
const to = bones.get(name);
|
|
198
|
+
if (from === undefined || to === undefined) continue;
|
|
199
|
+
const parent = to.parent === null ? undefined : bones.get(to.parent);
|
|
200
|
+
const parentFrom = to.parent === null ? undefined : prev.get(to.parent);
|
|
201
|
+
const turn = plant.frame === 'parent' && parent !== undefined && parentFrom !== undefined ? parentFrameTurn(from, to, parentFrom, parent) : axisTurn(from, to);
|
|
202
|
+
recordTurn(best[i], turn, index, pose.trackTime);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
prev = bones;
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (!(err instanceof CoreInputError)) throw err;
|
|
209
|
+
refused = err.message;
|
|
210
|
+
}
|
|
211
|
+
out.push({
|
|
212
|
+
animation: animation.name,
|
|
213
|
+
steps,
|
|
214
|
+
refused,
|
|
215
|
+
turns: constraints.map((c, i) => {
|
|
216
|
+
const gone = best[i].lost;
|
|
217
|
+
const why =
|
|
218
|
+
refused !== null
|
|
219
|
+
? `the core refused to pose the walk — ${refused}`
|
|
220
|
+
: !c.steps
|
|
221
|
+
? 'skin-required, and a skeleton with no skin set does not step it'
|
|
222
|
+
: gone !== null
|
|
223
|
+
? `its bone's world direction is not a number from step ${gone.step} (t=${gone.time.toFixed(4)}s)`
|
|
224
|
+
: null;
|
|
225
|
+
return { constraint: c.name, bone: c.bone, degrees: why === null ? best[i].degrees : null, step: best[i].step, time: best[i].time, why };
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The header line `explain` prints above the reading: what was walked and how, and that it decides nothing. */
|
|
233
|
+
export const WHIRL_HEADER =
|
|
234
|
+
`turn per step (a reading, never a gate: each animation walked by rigc's core as a player starts it — from the setup pose, ` +
|
|
235
|
+
`physics reset there, no skin set — in steps of 1/${WHIRL_HZ} s to its duration; the largest change of each physics bone's world ` +
|
|
236
|
+
'direction from one step to the next)';
|
|
237
|
+
|
|
238
|
+
/** One printed line per constraint per animation. */
|
|
239
|
+
export function whirlLines(reading: readonly WhirlAnimation[]): string[] {
|
|
240
|
+
const lines: string[] = [];
|
|
241
|
+
for (const a of reading) {
|
|
242
|
+
for (const t of a.turns) {
|
|
243
|
+
const head = `${JSON.stringify(a.animation)}: physics "${t.constraint}" (bone "${t.bone}")`;
|
|
244
|
+
lines.push(
|
|
245
|
+
t.degrees === null
|
|
246
|
+
? `${head} not read — ${t.why}`
|
|
247
|
+
: t.step === 0
|
|
248
|
+
? `${head} does not turn in any of ${a.steps} step(s)`
|
|
249
|
+
: `${head} turns at most ${t.degrees.toFixed(2)}° in one step — step ${t.step} of ${a.steps} (t=${t.time.toFixed(4)}s)`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return lines;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// the lever reading (issue #1427)
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Where a scaleX drive's inertia lever divides by a collapsed length — the
|
|
262
|
+
* second reading `rigc explain` prints under its physics block, when asked
|
|
263
|
+
* for with `--lever-fold <fraction>` (issue #1427).
|
|
264
|
+
*
|
|
265
|
+
* ## The mechanism it reads
|
|
266
|
+
*
|
|
267
|
+
* A physics constraint that drives `scaleX` kicks its scale offset by its
|
|
268
|
+
* bone's motion over a length: `scaleOffset += (dx·cos + dy·sin) · inertia /
|
|
269
|
+
* rr` (`./core/constraints_physics.ts`, *The step*; spine-core 4.3.13's
|
|
270
|
+
* `update` divides by the same `r`). `limit` caps `dx` and `dy`; nothing caps
|
|
271
|
+
* the division, and the kick is skipped only when `rr` is not above 0. What
|
|
272
|
+
* `rr` is depends on the drive:
|
|
273
|
+
*
|
|
274
|
+
* - with `rotate` or `shearX` driven too, `rr = length · |world x axis|` — the
|
|
275
|
+
* bone's world x-axis length (`√(a² + c²)` of the world matrix the step is
|
|
276
|
+
* handed, before the constraint moves it) times its length;
|
|
277
|
+
* - with `scaleX` alone, `rr = length · |world x axis| − scaleLag · shown`
|
|
278
|
+
* — the same product less the last integration's move of the offset,
|
|
279
|
+
* weighted by how much of it was shown.
|
|
280
|
+
*
|
|
281
|
+
* ⚠️ **The divisor, not the world length alone, and this is measured, not
|
|
282
|
+
* argued.** Issue #1422 traced a 5-link scaleX-only chain (inertia 10, `fps`
|
|
283
|
+
* 15, 0.95 of the step bound) whose link 3 jumped its offset 24.3 → 349 in one
|
|
284
|
+
* step, and read the cause as the bone's world length near 0. Walked here,
|
|
285
|
+
* that step's lever reads link 3's world x axis at 0.5636 of its setup length
|
|
286
|
+
* — and divides by 0.0588 of its setup divisor, because `scaleLag · shown`
|
|
287
|
+
* (25.24 × 1) takes almost all of `length · |world x axis|` (28.18) away. A
|
|
288
|
+
* reading of the world length alone misses that step at any fraction below
|
|
289
|
+
* 0.5636 and names steps where the kick is skipped altogether (the same link
|
|
290
|
+
* three steps earlier reads 0.481 of its setup length while `rr` is −8.47, so
|
|
291
|
+
* nothing is divided); `LV04` plants it. On a drive that rotates too the two
|
|
292
|
+
* are the same number, so nothing the card meant is lost.
|
|
293
|
+
*
|
|
294
|
+
* Whether a chain gets there depends on how far an offset swings, which no
|
|
295
|
+
* authored number states (#1422's ruling), so this is a reading and not a
|
|
296
|
+
* refusal.
|
|
297
|
+
*
|
|
298
|
+
* ## What it names
|
|
299
|
+
*
|
|
300
|
+
* Per animation, for every constraint that drives `scaleX`:
|
|
301
|
+
*
|
|
302
|
+
* - **the collapsed divisions** — the steps where the constraint's lever
|
|
303
|
+
* divides (`rr` above 0) by less than `fraction` × the divisor it would take
|
|
304
|
+
* at the setup pose, `length · |world x axis|` there (no lag has been
|
|
305
|
+
* integrated yet). A lever divides on a step where the constraint is not
|
|
306
|
+
* pending (the first update after a reset takes the bone where it is and
|
|
307
|
+
* integrates nothing), its `mix` is not 0 and its `inertia` is not 0 — with
|
|
308
|
+
* `inertia` 0 the quotient is multiplied by 0. Each line gives the least
|
|
309
|
+
* divisor, as that fraction, with the world x-axis length the lever read on
|
|
310
|
+
* that step beside it. `fraction` is the caller's, stated: there is no
|
|
311
|
+
* default, because what counts as collapsed is not a number the rig carries.
|
|
312
|
+
* - **the jumps** — the steps where the scale factor the constraint applies,
|
|
313
|
+
* `1 + scaleOffset · mix · scaleX`, moves by more than 1 from one step to
|
|
314
|
+
* the next: by more than the bone's own setup scale, since the factor is 1
|
|
315
|
+
* at the setup pose and multiplies whatever scale the bone already has.
|
|
316
|
+
* That threshold is the card's, and it is the setup scale — not a choice.
|
|
317
|
+
*
|
|
318
|
+
* The walk is the whirl's (*The walk*, above): the setup pose with every
|
|
319
|
+
* physics state reset, then steps of `1 / WHIRL_HZ` to the duration, posed by
|
|
320
|
+
* rigc's core — the walk #1422 traced on spine-core (CO56's recipe), so the
|
|
321
|
+
* steps named are the steps a player draws. The reading hooks the core's
|
|
322
|
+
* per-constraint step, reads the state the step is about to divide with, and
|
|
323
|
+
* calls `stepPhysics` itself, unchanged, so what it reads is what poses.
|
|
324
|
+
*
|
|
325
|
+
* ⚠️ **The world matrix, not the parent's local scale.** Physics moves the
|
|
326
|
+
* world matrix and never a bone's local transform, so a parent's local
|
|
327
|
+
* `scaleX` stays at its setup value through every collapse this card is
|
|
328
|
+
* about, and a reading of it names nothing on #1422's chain (`LV03` plants
|
|
329
|
+
* it).
|
|
330
|
+
*
|
|
331
|
+
* A reading, not a gate: it decides nothing, and `explain`'s exit is the same
|
|
332
|
+
* whatever it prints.
|
|
333
|
+
*/
|
|
334
|
+
export interface LeverPlant {
|
|
335
|
+
/**
|
|
336
|
+
* `LV03`'s and `LV04`'s plants, a control's and never a caller's:
|
|
337
|
+
* - `'parent-local-scale'` — the constrained bone's parent's local
|
|
338
|
+
* `scaleX` (setup and keys) in place of the lever's divisor;
|
|
339
|
+
* - `'world-length'` — `length · |world x axis|` alone, the scaleX-only
|
|
340
|
+
* drive's `scaleLag · shown` left out, which is the card's own wording.
|
|
341
|
+
*/
|
|
342
|
+
measure?: 'parent-local-scale' | 'world-length';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** One constraint's lever over one animation's walk. */
|
|
346
|
+
export interface LeverRead {
|
|
347
|
+
constraint: string;
|
|
348
|
+
bone: string;
|
|
349
|
+
/** The divisor the lever would take at the setup pose — `length · |world x axis|` there; `null` when not read (`why`). */
|
|
350
|
+
setup: number | null;
|
|
351
|
+
/** Every step whose lever divided by less than `fraction` × `setup`, ascending: the least divisor on it, and the world x-axis length the lever read there as a share of the setup pose's. */
|
|
352
|
+
folds: LeverCollapse[];
|
|
353
|
+
/** The least of those divisors — the earliest on a tie; `null` when there is none. */
|
|
354
|
+
least: LeverCollapse | null;
|
|
355
|
+
/** Every step whose applied scale factor moved by more than 1, ascending: the offset before and after, and the factor's move. */
|
|
356
|
+
jumps: LeverJump[];
|
|
357
|
+
/** The largest of those moves — the earliest on a tie; `null` when there is none. */
|
|
358
|
+
largest: LeverJump | null;
|
|
359
|
+
/** Why the constraint was not read; `null` when it was. */
|
|
360
|
+
why: string | null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** One step a lever divided by a collapsed divisor: the step, its time, the divisor and the world x axis as a share of the setup pose's. */
|
|
364
|
+
export interface LeverCollapse {
|
|
365
|
+
step: number;
|
|
366
|
+
time: number;
|
|
367
|
+
divisor: number;
|
|
368
|
+
world: number;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** One step a scale factor moved by more than its setup scale: the step, its time, the offset before and after, and the factor's move. */
|
|
372
|
+
export interface LeverJump {
|
|
373
|
+
step: number;
|
|
374
|
+
time: number;
|
|
375
|
+
from: number;
|
|
376
|
+
to: number;
|
|
377
|
+
size: number;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** One animation's walk and its scaleX constraints' levers. */
|
|
381
|
+
export interface LeverAnimation {
|
|
382
|
+
animation: string;
|
|
383
|
+
steps: number;
|
|
384
|
+
reads: LeverRead[];
|
|
385
|
+
refused: string | null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The whole reading: the fraction it was taken at, the constraints with nothing to read, and each animation's walk. */
|
|
389
|
+
export interface LeverReading {
|
|
390
|
+
fraction: number;
|
|
391
|
+
/** Physics constraints that drive no `scaleX`, in declaration order — no lever of theirs divides by a length. */
|
|
392
|
+
unread: string[];
|
|
393
|
+
animations: LeverAnimation[];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** What the reading's hook saw of one constraint on one pose. */
|
|
397
|
+
interface LeverSeen {
|
|
398
|
+
/** The least divisor a lever divided by on the pose, with the world x-axis length it read there; `null` when none divided. */
|
|
399
|
+
divided: { divisor: number; world: number } | null;
|
|
400
|
+
/** `length · |world x axis|` as the pose's first step was handed it — the setup pose's is the reference. */
|
|
401
|
+
handed: number;
|
|
402
|
+
/** The world x-axis length the pose's first step was handed. */
|
|
403
|
+
world: number;
|
|
404
|
+
/** The scale offset after the pose's last step. */
|
|
405
|
+
offset: number;
|
|
406
|
+
/** `mix · scaleX` as posed: what one unit of offset moves the applied factor by. */
|
|
407
|
+
weight: number;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** A fraction `--lever-fold` can state — finite, above 0 and at most 1 — or the sentence refusing it. */
|
|
411
|
+
export function leverFractionWhy(value: number): string | null {
|
|
412
|
+
return Number.isFinite(value) && value > 0 && value <= 1 ? null : `${String(value)} is not a fraction above 0 and at most 1`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Every animation of `doc` walked as the whirl walks it, and each scaleX
|
|
417
|
+
* constraint's collapsed divisions and jumps (the header above). `fraction`
|
|
418
|
+
* is the caller's stated threshold; a value `leverFractionWhy` refuses is
|
|
419
|
+
* refused here too, by name.
|
|
420
|
+
*/
|
|
421
|
+
export function leverReading(doc: CompiledDocument, fraction: number, plant: LeverPlant = {}): LeverReading {
|
|
422
|
+
const bad = leverFractionWhy(fraction);
|
|
423
|
+
if (bad !== null) throw new CoreInputError(`the lever reading's fraction: ${bad}`);
|
|
424
|
+
const view = underNoSkin(doc);
|
|
425
|
+
const all = physicsBones(view);
|
|
426
|
+
const drivesScale = new Set<string>();
|
|
427
|
+
for (const c of view.constraints) if (c.record?.kind === 'physics' && c.record.scaleX > 0) drivesScale.add(c.record.name);
|
|
428
|
+
const constraints = all.filter((c) => drivesScale.has(c.name));
|
|
429
|
+
const out: LeverReading = { fraction, unread: all.filter((c) => !drivesScale.has(c.name)).map((c) => c.name), animations: [] };
|
|
430
|
+
if (constraints.length === 0) return out;
|
|
431
|
+
const bones = new Map(view.bones.map((b) => [b.name, b] as const));
|
|
432
|
+
for (const animation of view.animations) {
|
|
433
|
+
const steps = whirlSteps(animation.timelines.duration);
|
|
434
|
+
const fresh = (why: string | null): LeverRead[] => constraints.map((c) => ({ constraint: c.name, bone: c.bone, setup: null, folds: [], least: null, jumps: [], largest: null, why }));
|
|
435
|
+
// A slider's table is posed at a value, not in time — the whirl's rule, for the same reason.
|
|
436
|
+
const sliders = view.constraints.filter((c) => c.kind === 'slider' && c.animation === animation.name).map((c) => `"${c.name}"`);
|
|
437
|
+
if (sliders.length > 0) {
|
|
438
|
+
out.animations.push({ animation: animation.name, steps: 0, refused: null, reads: fresh(`slider ${sliders.join(', ')} applies it at a value, not in time, so there is no step to read — the animation that moves the slider's value is walked instead`) });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// The plant's figure, set per pose as it is visited: the parent's local |scaleX| as the animation poses it then.
|
|
442
|
+
let locals: Map<string, number> | null = null;
|
|
443
|
+
const local = (bone: string): number => {
|
|
444
|
+
const parent = bones.get(bone)?.parent;
|
|
445
|
+
return parent === undefined || locals === null ? Number.NaN : (locals.get(parent) ?? Number.NaN);
|
|
446
|
+
};
|
|
447
|
+
// What the hook saw on the pose being made, by constraint; emptied as each pose is visited.
|
|
448
|
+
let seen = new Map<string, LeverSeen>();
|
|
449
|
+
const hook: PhysicsStepper = (r, w, length, ctx) => {
|
|
450
|
+
const s = ctx.states.get(r.name);
|
|
451
|
+
const world = Math.sqrt(w.a * w.a + w.c * w.c);
|
|
452
|
+
const product = length * world;
|
|
453
|
+
// `rr` as the step is about to form it (the header's *The mechanism*): read off the state before the step moves it.
|
|
454
|
+
const lagged = r.rotate > 0 || r.shearX > 0 || plant.measure === 'world-length' || s === undefined ? 0 : s.scaleLag * Math.max(0, 1 - s.remaining / r.step);
|
|
455
|
+
const divides = ctx.phase !== 'reset' && s !== undefined && !s.pending && r.mix !== 0 && r.inertia !== 0;
|
|
456
|
+
const divisor = plant.measure === 'parent-local-scale' ? length * local(r.bone) : product - lagged;
|
|
457
|
+
const moved = stepPhysics(r, w, length, ctx);
|
|
458
|
+
const before = seen.get(r.name);
|
|
459
|
+
const earlier = before?.divided ?? null;
|
|
460
|
+
const divided = !divides || !(divisor > 0) ? earlier : earlier === null || divisor < earlier.divisor ? { divisor, world } : earlier;
|
|
461
|
+
const handed = plant.measure === 'parent-local-scale' ? length * local(r.bone) : product;
|
|
462
|
+
seen.set(r.name, { divided, handed: before?.handed ?? handed, world: before?.world ?? world, offset: ctx.states.get(r.name)?.scaleOffset ?? 0, weight: r.mix * r.scaleX });
|
|
463
|
+
return moved;
|
|
464
|
+
};
|
|
465
|
+
const reads = fresh(null);
|
|
466
|
+
const setupWorld = constraints.map((): number => Number.NaN);
|
|
467
|
+
const last = constraints.map((): number | null => null);
|
|
468
|
+
const lost = constraints.map((): { step: number; time: number } | null => null);
|
|
469
|
+
const localsAt = (time: number): Map<string, number> => new Map(posedBones(view, animation.timelines, time).map((b) => [b.name, Math.abs(b.scaleX ?? 1)] as const));
|
|
470
|
+
if (plant.measure === 'parent-local-scale') locals = localsAt(0);
|
|
471
|
+
let refused: string | null = null;
|
|
472
|
+
try {
|
|
473
|
+
poseRawAnimationEach(view, animation.name, new Array<number>(steps).fill(1 / WHIRL_HZ), { physicsStep: hook }, 'setup', (pose, index) => {
|
|
474
|
+
for (let i = 0; i < constraints.length; i++) {
|
|
475
|
+
const s = seen.get(constraints[i].name);
|
|
476
|
+
if (s === undefined || lost[i] !== null) continue;
|
|
477
|
+
const r = reads[i];
|
|
478
|
+
if (index === 0) {
|
|
479
|
+
r.setup = s.handed;
|
|
480
|
+
setupWorld[i] = s.world;
|
|
481
|
+
last[i] = s.offset;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if ((s.divided !== null && !Number.isFinite(s.divided.divisor)) || !Number.isFinite(s.world) || !Number.isFinite(s.offset)) {
|
|
485
|
+
lost[i] = { step: index, time: pose.trackTime };
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (s.divided !== null && r.setup !== null && s.divided.divisor < fraction * r.setup) {
|
|
489
|
+
const collapse = { step: index, time: pose.trackTime, divisor: s.divided.divisor, world: s.divided.world / setupWorld[i] };
|
|
490
|
+
r.folds.push(collapse);
|
|
491
|
+
if (r.least === null || collapse.divisor < r.least.divisor) r.least = collapse;
|
|
492
|
+
}
|
|
493
|
+
const from = last[i];
|
|
494
|
+
if (from !== null) {
|
|
495
|
+
const size = Math.abs(s.offset - from) * s.weight;
|
|
496
|
+
if (size > 1) {
|
|
497
|
+
const jump = { step: index, time: pose.trackTime, from, to: s.offset, size };
|
|
498
|
+
r.jumps.push(jump);
|
|
499
|
+
if (r.largest === null || size > r.largest.size) r.largest = jump;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
last[i] = s.offset;
|
|
503
|
+
}
|
|
504
|
+
seen = new Map();
|
|
505
|
+
// The next pose's steps read the parent's local scale at the time that pose is applied at.
|
|
506
|
+
if (plant.measure === 'parent-local-scale') locals = localsAt(Math.min(pose.trackTime + 1 / WHIRL_HZ, animation.timelines.duration));
|
|
507
|
+
});
|
|
508
|
+
} catch (err) {
|
|
509
|
+
if (!(err instanceof CoreInputError)) throw err;
|
|
510
|
+
refused = err.message;
|
|
511
|
+
}
|
|
512
|
+
for (let i = 0; i < constraints.length; i++) {
|
|
513
|
+
const r = reads[i];
|
|
514
|
+
const gone = lost[i];
|
|
515
|
+
const why =
|
|
516
|
+
refused !== null
|
|
517
|
+
? `the core refused to pose the walk — ${refused}`
|
|
518
|
+
: !constraints[i].steps
|
|
519
|
+
? 'skin-required, and a skeleton with no skin set does not step it'
|
|
520
|
+
: r.setup === null
|
|
521
|
+
? 'its constraint was not stepped at the setup pose, so there is no setup divisor to compare with'
|
|
522
|
+
: !(r.setup > 0 && Number.isFinite(r.setup))
|
|
523
|
+
? `its setup divisor (length × world x axis) is ${String(r.setup)}, so no fraction of it is a divisor a step can fall below`
|
|
524
|
+
: gone !== null
|
|
525
|
+
? `its lever's divisor, its world x axis or its scale offset is not a number from step ${gone.step} (t=${gone.time.toFixed(4)}s)`
|
|
526
|
+
: null;
|
|
527
|
+
// A reading that stopped is not read at all, rather than the steps it reached before it stopped (the whirl's `WH04`, for the same silence).
|
|
528
|
+
if (why !== null) Object.assign(r, { folds: [], least: null, jumps: [], largest: null, why });
|
|
529
|
+
}
|
|
530
|
+
out.animations.push({ animation: animation.name, steps, refused, reads });
|
|
531
|
+
}
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The header line `explain` prints above the lever reading, with the fraction it was taken at. */
|
|
536
|
+
export function leverHeader(fraction: number): string {
|
|
537
|
+
return (
|
|
538
|
+
`lever under ${String(fraction)}× setup (a reading, never a gate: the turn lines' walk; for each physics constraint that drives scaleX, ` +
|
|
539
|
+
`the steps its inertia lever divides by less than ${String(fraction)}× the divisor it takes at the setup pose — length × world x axis, ` +
|
|
540
|
+
"less scaleLag × shown on a drive that does not rotate — and the steps the scale factor it applies moves by more than the bone's setup scale)"
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** A figure as the lines print it: four significant figures. */
|
|
545
|
+
const fig = (v: number): string => v.toPrecision(4);
|
|
546
|
+
|
|
547
|
+
/** The printed lines: the constraints with nothing to read, then per animation and scaleX constraint one line, or two when it both collapses and jumps. */
|
|
548
|
+
export function leverLines(reading: LeverReading): string[] {
|
|
549
|
+
const lines: string[] = [];
|
|
550
|
+
if (reading.unread.length > 0) {
|
|
551
|
+
const one = reading.unread.length === 1;
|
|
552
|
+
lines.push(`physics ${reading.unread.map((n) => `"${n}"`).join(', ')} drive${one ? 's' : ''} no scaleX, so no inertia lever of ${one ? 'its' : 'theirs'} divides by a length — not read`);
|
|
553
|
+
}
|
|
554
|
+
for (const a of reading.animations) {
|
|
555
|
+
for (const r of a.reads) {
|
|
556
|
+
const head = `${JSON.stringify(a.animation)}: physics "${r.constraint}" (bone "${r.bone}")`;
|
|
557
|
+
if (r.why !== null || r.setup === null) {
|
|
558
|
+
lines.push(`${head} not read — ${r.why ?? 'no setup divisor'}`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (r.least === null && r.largest === null) {
|
|
562
|
+
lines.push(`${head} reads nothing in ${a.steps} step(s): no lever divides by less than ${String(reading.fraction)}× its setup divisor ${fig(r.setup)}, and no step moves its scale factor by its setup scale`);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (r.least !== null) {
|
|
566
|
+
lines.push(
|
|
567
|
+
`${head} lever divides by less than ${String(reading.fraction)}× its setup divisor ${fig(r.setup)} on ${r.folds.length} of ${a.steps} step(s) [${indexRuns(r.folds.map((f) => f.step))}] — ` +
|
|
568
|
+
`least ${fig(r.least.divisor)} (${fig(r.least.divisor / r.setup)}× setup; world x axis ${fig(r.least.world)}× setup) at step ${r.least.step} (t=${r.least.time.toFixed(4)}s)`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (r.largest !== null) {
|
|
572
|
+
lines.push(
|
|
573
|
+
`${head} scale factor moves by more than its setup scale in one step on ${r.jumps.length} of ${a.steps} step(s) [${indexRuns(r.jumps.map((j) => j.step))}] — ` +
|
|
574
|
+
`largest ${fig(r.largest.size)}, scale offset ${fig(r.largest.from)} → ${fig(r.largest.to)} at step ${r.largest.step} (t=${r.largest.time.toFixed(4)}s)`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return lines;
|
|
580
|
+
}
|