spine-rigc 2.53.0 → 2.55.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 +266 -18
- package/package.json +1 -1
- package/src/assertions/bodies/a23.ts +254 -5
- package/src/check.ts +12 -0
- package/src/cli/core_commands.ts +168 -8
- package/src/cli/shared.ts +20 -1
- package/src/core/raw.ts +29 -6
- package/src/environment.ts +193 -0
- package/src/passthrough.ts +575 -0
- package/src/render.ts +15 -1
- package/src/render_core.ts +10 -6
- package/src/render_shared.ts +37 -2
- package/src/timelines.ts +130 -23
- package/src/types.ts +4 -3
package/src/cli/shared.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { checkAgainstFrames, type CheckOptions, CheckPlates, type CheckReport }
|
|
|
38
38
|
import { compile, CompileError, droppedStateReason, headerBoundsOf, type CompileOptions } from '../compile.ts';
|
|
39
39
|
import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
|
|
40
40
|
import { FOLDS_FILE } from '../folds.ts';
|
|
41
|
+
import { PASS_THROUGH_FILE, PASS_THROUGH_RUN } from '../passthrough.ts';
|
|
41
42
|
import { parseJsonWithPosition } from '../json-position.ts';
|
|
42
43
|
import { RUNG_IDS } from '../ladder.ts';
|
|
43
44
|
import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
|
|
@@ -1013,6 +1014,20 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1013
1014
|
'with where they are (the world box of their posed vertices) and how big (their setup-pose area, and its share of the mesh\'s). ' +
|
|
1014
1015
|
'One line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. ' +
|
|
1015
1016
|
'A reading, not a gate. Not with --slot/--hide: it reads every mesh the pose shows',
|
|
1017
|
+
'pass-through':
|
|
1018
|
+
`also read where a cloth slot passes through a leg slot, for each <cloth>:<leg> pair, and write ${PASS_THROUGH_FILE} into ` +
|
|
1019
|
+
'each frame directory: the frames where the cloth draws over the leg and its drawn side lies inside the leg\'s drawn ' +
|
|
1020
|
+
`outline by ${PASS_THROUGH_RUN} steps or more — rows read in the cloth's setup pose from its hem's corner up, the run stepped in ` +
|
|
1021
|
+
'world units, one texel of the coarser drawing to a step — each with the depth and the world box. One line per pair, and one per frame that passes through. ' +
|
|
1022
|
+
'The order is the declaration: the first name must draw over the second for a frame to be read. A reading, not a gate. ' +
|
|
1023
|
+
'An unknown slot, a slot that draws on no frame, a malformed list and a pair named twice are refused by name. Not with --slot/--hide',
|
|
1024
|
+
environment:
|
|
1025
|
+
'the world the rig\'s physics answers, in time: a file { "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, ' +
|
|
1026
|
+
'"wind"?: [x, y], "gravity"?: [x, y] }, …] }, x right and y down, each step holding from its t until the next and set ' +
|
|
1027
|
+
'before the step that poses the first frame at or after it, on either poser. Before the first step the world is the ' +
|
|
1028
|
+
'runtime\'s, wind [1, 0] and gravity [0, 1]. frames.json and geometry.json record it, and the framing reads it. A ' +
|
|
1029
|
+
'malformed file, a half no physics constraint answers, two steps on one frame and a step after every set\'s last frame ' +
|
|
1030
|
+
'are refused by name. An inspection of what the rig offers, not a scene',
|
|
1016
1031
|
poser:
|
|
1017
1032
|
"`render`, `inspect` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
|
|
1018
1033
|
'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
|
|
@@ -1194,6 +1209,8 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1194
1209
|
'pack-shape': 'rect|polygon',
|
|
1195
1210
|
'texture-from': '<path>',
|
|
1196
1211
|
poser: 'core|spine',
|
|
1212
|
+
environment: '<schedule.json>',
|
|
1213
|
+
'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
|
|
1197
1214
|
reference: '<dir|skeleton.json>',
|
|
1198
1215
|
'reference-atlas': '<path>',
|
|
1199
1216
|
bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
|
|
@@ -1599,8 +1616,10 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1599
1616
|
usage: [
|
|
1600
1617
|
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
|
|
1601
1618
|
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
1619
|
+
'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
|
|
1620
|
+
'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
|
|
1602
1621
|
],
|
|
1603
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'poser', 'out'],
|
|
1622
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'pass-through', 'environment', 'poser', 'out'],
|
|
1604
1623
|
overrides: {
|
|
1605
1624
|
out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
|
|
1606
1625
|
fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
|
package/src/core/raw.ts
CHANGED
|
@@ -499,8 +499,26 @@ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps
|
|
|
499
499
|
* (`physicsClockStep` below, issue #1380); the track time and
|
|
500
500
|
* the animation time are the steps' running sum either way.
|
|
501
501
|
*/
|
|
502
|
-
export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void, clock?: (dt: number) => number): void {
|
|
503
|
-
walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_MODE : { ...RAW_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit);
|
|
502
|
+
export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void, clock?: (dt: number) => number, environment?: StepEnvironment): void {
|
|
503
|
+
walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_MODE : { ...RAW_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit, environment);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* What a batch walk sets on its physics before a pose (issue #1416, part C):
|
|
508
|
+
* pose `index`'s change to the world every physics constraint answers — either
|
|
509
|
+
* half, merged over the one standing as a live track's `setEnvironment` merges
|
|
510
|
+
* it (`setEnvironmentOn`) — or `undefined` for none. Pose 0 is the reset pose;
|
|
511
|
+
* pose `k + 1` is the one step `k` makes. `rigc render --environment` is the
|
|
512
|
+
* caller (`src/render_core.ts`), and spine-core's poser sets the same values on
|
|
513
|
+
* its `Skeleton` before the same update (`src/render.ts`), so the two posers
|
|
514
|
+
* see one world at every step.
|
|
515
|
+
*/
|
|
516
|
+
export type StepEnvironment = (index: number) => Partial<PhysicsEnvironment> | undefined;
|
|
517
|
+
|
|
518
|
+
/** `environment`'s change for pose `index`, set on `ctx` as a live track sets one — validated and merged by `setEnvironmentOn`. */
|
|
519
|
+
function setStepEnvironment(ctx: PhysicsStepContext, environment: StepEnvironment | undefined, index: number): void {
|
|
520
|
+
const change = environment?.(index);
|
|
521
|
+
if (change !== undefined) setEnvironmentOn(ctx, change, `the walk's, at pose ${index},`);
|
|
504
522
|
}
|
|
505
523
|
|
|
506
524
|
/**
|
|
@@ -755,10 +773,12 @@ function animationOf(doc: CompiledDocument, animation: string): CompiledDocument
|
|
|
755
773
|
* `reset: 'setup'` pose 0 is the setup pose, reset; under `animation` it is the
|
|
756
774
|
* animation applied at 0 and reset there, a step of 0 (`stepWalk`'s `first`).
|
|
757
775
|
*/
|
|
758
|
-
function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations'][number], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, adjust?: LocalAdjust): { track: TrackState<P>; first: P } {
|
|
776
|
+
function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations'][number], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, adjust?: LocalAdjust, before?: (ctx: PhysicsStepContext) => void): { track: TrackState<P>; first: P } {
|
|
759
777
|
const why = constraintsAbsentWhy(doc) ?? pathAnimationsWhy(doc) ?? steppedPreviousPassWhy(doc);
|
|
760
778
|
if (why !== null) throw new CoreInputError(`the raw walk leaves the bones out: ${why}`);
|
|
761
779
|
const ctx = freshStepContext(plant.physicsStep);
|
|
780
|
+
// What the caller sets before pose 0 (issue #1416, part C): the batch walk's environment for the reset pose.
|
|
781
|
+
before?.(ctx);
|
|
762
782
|
if (mode.loop && mode.wrapResets !== false) ctx.loop = true;
|
|
763
783
|
const track: TrackState<P> = { doc, anim, duration: anim.timelines.duration, mode, plant, raw: { ...plant, ...roundPlant(mode) }, resolve: plant.shown ?? shownAttachment, shape, ctx, adjust, trackTime: 0, last: -1 };
|
|
764
784
|
if (reset === 'setup') {
|
|
@@ -890,14 +910,17 @@ export function openRawTracksPath(doc: CompiledDocument, plant: TimelinePlant):
|
|
|
890
910
|
}
|
|
891
911
|
|
|
892
912
|
/** `walkIn`'s walk, each pose assembled in `shape` and handed to `visit` in order as it is posed (`poseRawAnimationEach`): the walk opened, then stepped over `steps`. */
|
|
893
|
-
function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, visit: (pose: P, index: number) => void): void {
|
|
913
|
+
function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, visit: (pose: P, index: number) => void, environment?: StepEnvironment): void {
|
|
894
914
|
const anim = animationOf(doc, animation);
|
|
895
915
|
const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
|
|
896
916
|
if (bad >= 0) throw new CoreInputError(`step ${bad} is ${steps[bad]}, not a finite time at or above 0`);
|
|
897
|
-
const { track, first } = openWalk(doc, anim, plant, reset, mode, shape);
|
|
917
|
+
const { track, first } = openWalk(doc, anim, plant, reset, mode, shape, undefined, environment === undefined ? undefined : (ctx) => setStepEnvironment(ctx, environment, 0));
|
|
898
918
|
let visited = 0;
|
|
899
919
|
visit(first, visited++);
|
|
900
|
-
for (const dt of steps)
|
|
920
|
+
for (const dt of steps) {
|
|
921
|
+
setStepEnvironment(track.ctx, environment, visited);
|
|
922
|
+
visit(stepWalk(track, dt), visited++);
|
|
923
|
+
}
|
|
901
924
|
}
|
|
902
925
|
|
|
903
926
|
/**
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The world a render's physics answers, in time — `rigc render --environment`
|
|
3
|
+
* (issue #1416, part C).
|
|
4
|
+
*
|
|
5
|
+
* A rig's physics constraints state how much each one answers wind and
|
|
6
|
+
* gravity (their own `wind` and `gravity`, AUTHORING §4.6). The world they
|
|
7
|
+
* answer is the consumer's: the two direction vectors spine-core keeps on its
|
|
8
|
+
* `Skeleton` (`windX, windY`, `gravityX, gravityY`), which a live track sets
|
|
9
|
+
* with `setEnvironment` (`./core/track.ts`). A render has no consumer, so this
|
|
10
|
+
* file is one: a list of steps in time, each changing the world from its `t`
|
|
11
|
+
* until the next, which both render posers set before the step that poses each
|
|
12
|
+
* frame (`src/render_core.ts`, `src/render.ts`).
|
|
13
|
+
*
|
|
14
|
+
* ⭐ It inspects what the rig offers; it does not compose a scene. Which gust
|
|
15
|
+
* blows when is the consumer's, and a schedule here is a probe of the response
|
|
16
|
+
* — the same probe on both posers, so the export's answer to it is held to the
|
|
17
|
+
* core's (the render-environment suite).
|
|
18
|
+
*
|
|
19
|
+
* The coordinates are the runtime's own and the live track's: x to the right,
|
|
20
|
+
* **y down** (a positive y pulls toward the bottom of the screen, as the
|
|
21
|
+
* runtime's initial gravity `[0, 1]` does). Until the first step the world is
|
|
22
|
+
* the runtime's initial one, wind `[1, 0]` and gravity `[0, 1]`, which is what a
|
|
23
|
+
* render without the flag answers — so a calm start has to be stated.
|
|
24
|
+
*
|
|
25
|
+
* Nothing here links spine-core, and nothing here reads a clock: the schedule
|
|
26
|
+
* is resolved onto a frame grid by arithmetic on the frame index alone.
|
|
27
|
+
*/
|
|
28
|
+
import type { PhysicsEnvironment } from './core/constraints_physics.ts';
|
|
29
|
+
|
|
30
|
+
/** The tag a schedule file states, refused by name when it states another. */
|
|
31
|
+
export const ENVIRONMENT_SPEC = 'rigc-environment/1';
|
|
32
|
+
|
|
33
|
+
/** One pose's change to the world: either half, the other kept as it stands. */
|
|
34
|
+
export type EnvironmentChange = Partial<PhysicsEnvironment>;
|
|
35
|
+
|
|
36
|
+
/** One step of a schedule: from `t` seconds on, until the next step, the world is changed by `wind` and/or `gravity`. */
|
|
37
|
+
export interface EnvironmentStep {
|
|
38
|
+
t: number;
|
|
39
|
+
wind?: [number, number];
|
|
40
|
+
gravity?: [number, number];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A schedule refused — by entry index, field, the value found and what is required. */
|
|
44
|
+
export class EnvironmentError extends Error {}
|
|
45
|
+
|
|
46
|
+
const TOP_KEYS = ['spec', 'steps'] as const;
|
|
47
|
+
const STEP_KEYS = ['t', 'wind', 'gravity'] as const;
|
|
48
|
+
const PAIR = 'a pair of finite numbers [x, y] (x to the right, y down)';
|
|
49
|
+
|
|
50
|
+
/** A value as a refusal shows it: JSON, except that a number that is not finite is written as itself rather than as `null`. */
|
|
51
|
+
function shown(value: unknown): string {
|
|
52
|
+
if (typeof value === 'number') return String(value);
|
|
53
|
+
if (Array.isArray(value)) return `[${value.map(shown).join(', ')}]`;
|
|
54
|
+
if (value === undefined) return 'nothing';
|
|
55
|
+
return JSON.stringify(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What a value is, for the refusal of a file or an entry that is not an object. */
|
|
59
|
+
function kindOf(value: unknown): string {
|
|
60
|
+
if (value === null) return 'null';
|
|
61
|
+
if (Array.isArray(value)) return 'an array';
|
|
62
|
+
return `${typeof value === 'object' ? 'an' : 'a'} ${typeof value} ${typeof value === 'object' ? '' : shown(value)}`.trimEnd();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A schedule file's text read, or refused by name before anything is posed.
|
|
67
|
+
* `where` is how the refusal names the file. Every step is returned with its
|
|
68
|
+
* keys in one order — `t`, `wind`, `gravity` — whatever order the file wrote
|
|
69
|
+
* them in, so what a render records of it does not depend on the spelling.
|
|
70
|
+
*/
|
|
71
|
+
export function readEnvironmentSchedule(text: string, where: string): EnvironmentStep[] {
|
|
72
|
+
const refuse = (what: string): EnvironmentError => new EnvironmentError(`--environment ${where}: ${what}`);
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(text);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw refuse(`not JSON — ${(err as Error).message}`);
|
|
78
|
+
}
|
|
79
|
+
const shape = `{ "spec": "${ENVIRONMENT_SPEC}", "steps": [{ "t": <seconds>, "wind"?: [x, y], "gravity"?: [x, y] }, …] }`;
|
|
80
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw refuse(`the file holds ${kindOf(parsed)}, not an object ${shape}`);
|
|
81
|
+
const top = parsed as Record<string, unknown>;
|
|
82
|
+
for (const key of Object.keys(top)) {
|
|
83
|
+
if (!(TOP_KEYS as readonly string[]).includes(key)) throw refuse(`the file names "${key}", which is not one of [${TOP_KEYS.join(', ')}]`);
|
|
84
|
+
}
|
|
85
|
+
if (top.spec !== ENVIRONMENT_SPEC) throw refuse(`"spec" is ${shown(top.spec)}; render reads "${ENVIRONMENT_SPEC}"`);
|
|
86
|
+
const steps = top.steps;
|
|
87
|
+
if (!Array.isArray(steps) || steps.length === 0) throw refuse(`"steps" is ${shown(steps)}, not a non-empty array of { "t": <seconds>, "wind"?: [x, y], "gravity"?: [x, y] }`);
|
|
88
|
+
const out: EnvironmentStep[] = [];
|
|
89
|
+
steps.forEach((entry: unknown, i) => {
|
|
90
|
+
const at = `entry ${i} of "steps"`;
|
|
91
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw refuse(`${at} is ${kindOf(entry)}, not an object { "t": <seconds>, "wind"?: [x, y], "gravity"?: [x, y] }`);
|
|
92
|
+
const given = entry as Record<string, unknown>;
|
|
93
|
+
for (const key of Object.keys(given)) {
|
|
94
|
+
if (!(STEP_KEYS as readonly string[]).includes(key)) throw refuse(`${at} names "${key}", which is not one of [${STEP_KEYS.join(', ')}]`);
|
|
95
|
+
}
|
|
96
|
+
const t = given.t;
|
|
97
|
+
if (typeof t !== 'number' || !Number.isFinite(t) || t < 0) throw refuse(`${at}'s "t" is ${shown(t)}, not a finite time in seconds at or above 0`);
|
|
98
|
+
const previous = out[i - 1];
|
|
99
|
+
if (previous !== undefined && !(t > previous.t)) {
|
|
100
|
+
throw refuse(`${at}'s "t" is ${shown(t)}, not after entry ${i - 1}'s ${shown(previous.t)} — steps are listed in ascending time, each holding until the next`);
|
|
101
|
+
}
|
|
102
|
+
const pair = (key: 'wind' | 'gravity'): [number, number] | undefined => {
|
|
103
|
+
const v = given[key];
|
|
104
|
+
if (v === undefined) return undefined;
|
|
105
|
+
if (!Array.isArray(v) || v.length !== 2 || v.some((n) => typeof n !== 'number' || !Number.isFinite(n))) throw refuse(`${at}'s "${key}" is ${shown(v)}, not ${PAIR}`);
|
|
106
|
+
return [v[0] as number, v[1] as number];
|
|
107
|
+
};
|
|
108
|
+
const wind = pair('wind');
|
|
109
|
+
const gravity = pair('gravity');
|
|
110
|
+
if (wind === undefined && gravity === undefined) throw refuse(`${at} (t ${shown(t)}) states neither "wind" nor "gravity", so it changes nothing`);
|
|
111
|
+
out.push({ t, ...(wind === undefined ? {} : { wind }), ...(gravity === undefined ? {} : { gravity }) });
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The frame a step at `t` seconds is first posed on at `fps`: the first frame
|
|
118
|
+
* `i` whose time `i / fps` is at or after `t`. Its change is set before the
|
|
119
|
+
* step that poses that frame, so the physics integrates the new world over the
|
|
120
|
+
* frame's whole interval. Exact for the predicate as written: `i / fps` is the
|
|
121
|
+
* division correctly rounded, so a `t` that names a frame's time lands on it.
|
|
122
|
+
*/
|
|
123
|
+
export function environmentFrame(t: number, fps: number): number {
|
|
124
|
+
let i = Math.max(0, Math.ceil(t * fps));
|
|
125
|
+
while (i > 0 && (i - 1) / fps >= t) i--;
|
|
126
|
+
while (i / fps < t) i++;
|
|
127
|
+
return i;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A schedule on the frame grid of `fps`: each frame's change, or `undefined`.
|
|
132
|
+
* Steps landing on one frame are merged in order, a later half over an
|
|
133
|
+
* earlier — which only the framing meets (`FRAMING_FPS`), since `render`
|
|
134
|
+
* refuses a schedule two of whose steps share a frame of its own grid
|
|
135
|
+
* (`environmentCollisions`).
|
|
136
|
+
*/
|
|
137
|
+
export function environmentAt(steps: readonly EnvironmentStep[], fps: number): (index: number) => EnvironmentChange | undefined {
|
|
138
|
+
const byFrame = new Map<number, EnvironmentChange>();
|
|
139
|
+
for (const step of steps) {
|
|
140
|
+
const frame = environmentFrame(step.t, fps);
|
|
141
|
+
const before = byFrame.get(frame) ?? {};
|
|
142
|
+
byFrame.set(frame, { ...before, ...(step.wind === undefined ? {} : { wind: step.wind }), ...(step.gravity === undefined ? {} : { gravity: step.gravity }) });
|
|
143
|
+
}
|
|
144
|
+
return (index) => byFrame.get(index);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The steps that share a frame of the `fps` grid, by frame — each group's first step would never be posed. */
|
|
148
|
+
export function environmentCollisions(steps: readonly EnvironmentStep[], fps: number): Array<{ frame: number; entries: number[] }> {
|
|
149
|
+
const groups = new Map<number, number[]>();
|
|
150
|
+
steps.forEach((step, i) => {
|
|
151
|
+
const frame = environmentFrame(step.t, fps);
|
|
152
|
+
groups.set(frame, [...(groups.get(frame) ?? []), i]);
|
|
153
|
+
});
|
|
154
|
+
return [...groups].filter(([, entries]) => entries.length > 1).map(([frame, entries]) => ({ frame, entries }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Which physics constraints of a Spine 4.3 skeleton answer each half of the
|
|
159
|
+
* world: `declared` every physics constraint by name; `wind` and `gravity` the
|
|
160
|
+
* ones stating a non-zero `wind` or `gravity` (the runtime reads an absent one
|
|
161
|
+
* as 0) and, as `"<constraint>" keyed by "<animation>"`, every physics
|
|
162
|
+
* timeline keying that field (`""` keys every constraint). Read off the
|
|
163
|
+
* skeleton's JSON as plain JSON, the same text both posers pose.
|
|
164
|
+
*/
|
|
165
|
+
export interface EnvironmentResponse {
|
|
166
|
+
declared: string[];
|
|
167
|
+
wind: string[];
|
|
168
|
+
gravity: string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function objectOf(value: unknown): Record<string, unknown> {
|
|
172
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function environmentResponse(skeletonText: string): EnvironmentResponse {
|
|
176
|
+
const root = objectOf(JSON.parse(skeletonText));
|
|
177
|
+
const constraints = Array.isArray(root.constraints) ? root.constraints.map(objectOf).filter((c) => c.type === 'physics') : [];
|
|
178
|
+
const declared = constraints.map((c) => String(c.name));
|
|
179
|
+
const out: EnvironmentResponse = { declared, wind: [], gravity: [] };
|
|
180
|
+
for (const half of ['wind', 'gravity'] as const) {
|
|
181
|
+
for (const c of constraints) {
|
|
182
|
+
const v = c[half];
|
|
183
|
+
if (typeof v === 'number' && v !== 0) out[half].push(`"${String(c.name)}" (${half} ${v})`);
|
|
184
|
+
}
|
|
185
|
+
for (const [animation, body] of Object.entries(objectOf(root.animations))) {
|
|
186
|
+
for (const [target, timelines] of Object.entries(objectOf(objectOf(body).physics))) {
|
|
187
|
+
const keys = objectOf(timelines)[half];
|
|
188
|
+
if (Array.isArray(keys) && keys.length > 0) out[half].push(`${target === '' ? 'every constraint' : `"${target}"`} keyed by "${animation}"`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|