spine-rigc 2.52.0 → 2.54.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 +199 -2
- package/package.json +1 -1
- package/src/assertions/bodies/a23.ts +174 -0
- package/src/check.ts +12 -0
- package/src/cli/core_commands.ts +95 -4
- package/src/cli/shared.ts +15 -1
- package/src/compile.ts +126 -41
- package/src/core/raw.ts +29 -6
- package/src/environment.ts +193 -0
- package/src/mesh.ts +136 -0
- package/src/render.ts +15 -1
- package/src/render_core.ts +10 -6
- package/src/render_shared.ts +37 -2
- package/src/rig.ts +63 -4
- package/src/timelines.ts +61 -0
- package/src/types.ts +14 -0
|
@@ -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
|
+
}
|
package/src/mesh.ts
CHANGED
|
@@ -2432,3 +2432,139 @@ export function segmentShares(
|
|
|
2432
2432
|
});
|
|
2433
2433
|
return { weights };
|
|
2434
2434
|
}
|
|
2435
|
+
|
|
2436
|
+
// ---------------------------------------------------------------------------
|
|
2437
|
+
// influence — a falloff that reaches zero at a stated distance (issue #1415)
|
|
2438
|
+
// ---------------------------------------------------------------------------
|
|
2439
|
+
//
|
|
2440
|
+
// `segmentShares` above never lets a segment go: `1 / (d + r)^p` is positive at
|
|
2441
|
+
// every distance, so the only way to widen a blend is a larger `r`, which
|
|
2442
|
+
// flattens it everywhere at once, and the only way to end one is `minWeight`,
|
|
2443
|
+
// a share rather than a distance. A cloth surface wants the other model: a
|
|
2444
|
+
// vertex follows every link NEAR it, by a pull that falls to exactly zero at a
|
|
2445
|
+
// stated reach, so a link enters and leaves a vertex's blend continuously.
|
|
2446
|
+
//
|
|
2447
|
+
// 1. **Distance.** `d` is the distance from the vertex to each link's
|
|
2448
|
+
// segment — its nearest point, `segmentDistance`, the same reading the
|
|
2449
|
+
// falloff takes — at the setup pose. To the segment rather than to the
|
|
2450
|
+
// bone's origin: a vertex on the middle of a link IS on that link, and an
|
|
2451
|
+
// origin metric would put it half a link away while handing the joint at
|
|
2452
|
+
// the link's tip to the child whose origin sits there.
|
|
2453
|
+
// 2. **Across.** With `across: false` only the entry (a chain, a bone, a
|
|
2454
|
+
// span) holding the nearest link is kept — the earlier entry on a tie.
|
|
2455
|
+
// 3. **Reach.** A link with `d < radius` pulls `shape(d / radius)`, and one
|
|
2456
|
+
// whose pull is not above 0 — a hair inside the reach, in doubles — pulls
|
|
2457
|
+
// nothing and is out of reach; a bone two segments name keeps the nearer.
|
|
2458
|
+
// A vertex no link reaches is refused by the caller, naming the nearest
|
|
2459
|
+
// distance.
|
|
2460
|
+
// 4. **Cap.** The strongest `max` pulls are kept — equal pulls in the order
|
|
2461
|
+
// the bones were first named — and normalised; a share the 6-decimal grid
|
|
2462
|
+
// would write as 0 is dropped and the rest normalised again.
|
|
2463
|
+
// 5. **Order.** The kept bones are written in the order they were first
|
|
2464
|
+
// named, on every vertex, so an attachment has one bone order by
|
|
2465
|
+
// construction (`A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER`); the
|
|
2466
|
+
// strongest closes the vertex at exactly 1 on the grid.
|
|
2467
|
+
|
|
2468
|
+
/** One candidate segment with the `bones` entry it came from — what `across: false` chooses between. */
|
|
2469
|
+
export interface EntrySegment extends WorldSegment {
|
|
2470
|
+
/** Index of the `bones` entry this segment belongs to. */
|
|
2471
|
+
entry: number;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/** The four fields an `influence` block states — every one of them required by the caller. */
|
|
2475
|
+
export interface SegmentsInfluence {
|
|
2476
|
+
radius: number;
|
|
2477
|
+
across: boolean;
|
|
2478
|
+
max: number;
|
|
2479
|
+
shape: 'linear' | 'smooth';
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/** A pull at `x = d / radius` in `[0, 1]`: 1 at the segment, 0 at the reach. */
|
|
2483
|
+
export function influencePull(shape: SegmentsInfluence['shape'], x: number): number {
|
|
2484
|
+
return shape === 'linear' ? 1 - x : 1 - x * x * (3 - 2 * x);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/** The steps a selftest plant may switch off, one key each — never passed by a build. */
|
|
2488
|
+
export interface InfluencePlant {
|
|
2489
|
+
ignoreCap?: true;
|
|
2490
|
+
ignoreReach?: true;
|
|
2491
|
+
ignoreAcross?: true;
|
|
2492
|
+
skipNormalise?: true;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
/** What `influenceShares` found at one vertex. */
|
|
2496
|
+
export type InfluenceShares =
|
|
2497
|
+
| {
|
|
2498
|
+
/** The bindings, in first-named bone order, closing at exactly 1 on the 6-decimal grid. */
|
|
2499
|
+
weights: MeshVertexWeight[];
|
|
2500
|
+
/** How many bones were in reach before the cap — over `max` means the cap dropped some. */
|
|
2501
|
+
inReach: number;
|
|
2502
|
+
}
|
|
2503
|
+
| {
|
|
2504
|
+
/** No link is within `radius`: the nearest one's distance, and its bone. */
|
|
2505
|
+
nearest: { bone: number; distance: number };
|
|
2506
|
+
};
|
|
2507
|
+
|
|
2508
|
+
/**
|
|
2509
|
+
* The shares on one vertex under an `influence` block — see the section header.
|
|
2510
|
+
* `plant` exists for the selftest's negative controls: each key switches one
|
|
2511
|
+
* step off, so a control can show its own check going red on weights that
|
|
2512
|
+
* break exactly that step. A build never passes it.
|
|
2513
|
+
*/
|
|
2514
|
+
export function influenceShares(
|
|
2515
|
+
p: readonly [number, number],
|
|
2516
|
+
segments: readonly EntrySegment[],
|
|
2517
|
+
influence: SegmentsInfluence,
|
|
2518
|
+
plant: InfluencePlant = {},
|
|
2519
|
+
): InfluenceShares {
|
|
2520
|
+
const distances = segments.map((s) => segmentDistance(p, s.a, s.b));
|
|
2521
|
+
let home = -1;
|
|
2522
|
+
let homeD = Infinity;
|
|
2523
|
+
let nearestBone = 0;
|
|
2524
|
+
distances.forEach((d, i) => {
|
|
2525
|
+
if (d < homeD) {
|
|
2526
|
+
homeD = d;
|
|
2527
|
+
home = segments[i].entry;
|
|
2528
|
+
nearestBone = segments[i].bone;
|
|
2529
|
+
}
|
|
2530
|
+
});
|
|
2531
|
+
const byBone = new Map<number, number>();
|
|
2532
|
+
segments.forEach((s, i) => {
|
|
2533
|
+
if (!influence.across && plant.ignoreAcross === undefined && s.entry !== home) return;
|
|
2534
|
+
const d = distances[i];
|
|
2535
|
+
if (!(d < influence.radius) && plant.ignoreReach === undefined) return;
|
|
2536
|
+
const was = byBone.get(s.bone);
|
|
2537
|
+
if (was === undefined || d < was) byBone.set(s.bone, d);
|
|
2538
|
+
});
|
|
2539
|
+
// Strongest first; equal pulls by the order the bones were first named. A link a hair inside the reach can
|
|
2540
|
+
// pull 0 — or a rounding's worth under it — in doubles (`1 - x²(3 - 2x)` at x one ulp under 1): it pulls
|
|
2541
|
+
// nothing, so it is out of reach like the links past it, and no share is ever made up for it.
|
|
2542
|
+
const pulls = [...byBone.entries()]
|
|
2543
|
+
.map(([bone, d]) => ({ bone, pull: influencePull(influence.shape, Math.min(d / influence.radius, 1)) }))
|
|
2544
|
+
.filter((e) => e.pull > 0 || plant.ignoreReach !== undefined)
|
|
2545
|
+
.sort((x, y) => y.pull - x.pull || x.bone - y.bone);
|
|
2546
|
+
if (pulls.length === 0) return { nearest: { bone: nearestBone, distance: homeD } };
|
|
2547
|
+
const inReach = pulls.length;
|
|
2548
|
+
const capped = plant.ignoreCap === undefined ? pulls.slice(0, influence.max) : pulls;
|
|
2549
|
+
// Only the planted `ignoreReach` keeps a pull of 0 (a link at or past the reach): it is given one grid step,
|
|
2550
|
+
// so the plant still binds that link, which is the plant's whole point.
|
|
2551
|
+
const raw = capped.map((e) => ({ bone: e.bone, pull: e.pull > 0 ? e.pull : 0.000001 }));
|
|
2552
|
+
let sum = 0;
|
|
2553
|
+
for (const e of raw) sum += e.pull;
|
|
2554
|
+
const normalise = plant.skipNormalise === undefined;
|
|
2555
|
+
const first = raw.map((e) => ({ bone: e.bone, share: normalise ? e.pull / sum : e.pull }));
|
|
2556
|
+
const kept = first.filter((e) => r6(e.share) !== 0);
|
|
2557
|
+
let sum2 = 0;
|
|
2558
|
+
for (const e of kept) sum2 += e.share;
|
|
2559
|
+
const rounded = kept.map((e) => r6(normalise ? e.share / sum2 : e.share));
|
|
2560
|
+
// `kept[0]` is the strongest — the filter keeps the order — and it closes the vertex at exactly 1.
|
|
2561
|
+
if (normalise) {
|
|
2562
|
+
let others = 0;
|
|
2563
|
+
for (let k = 1; k < rounded.length; k++) others += rounded[k];
|
|
2564
|
+
rounded[0] = r6(1 - others);
|
|
2565
|
+
}
|
|
2566
|
+
const weights: MeshVertexWeight[] = kept
|
|
2567
|
+
.map((e, k) => ({ bone: 'control' as const, control: e.bone, weight: rounded[k] }))
|
|
2568
|
+
.sort((x, y) => x.control - y.control);
|
|
2569
|
+
return { weights, inReach };
|
|
2570
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -130,6 +130,7 @@ import { dirname, join } from 'node:path';
|
|
|
130
130
|
import { Plate, readPlate } from '../tools/plate.ts';
|
|
131
131
|
import { clipSourceOf, corePoser, inactiveBoneSnapshot, physicsClockStep, subsetOver, unposedBones } from './render_core.ts';
|
|
132
132
|
import { type PosedVertices, type WorldTransform } from './nonfinite.ts';
|
|
133
|
+
import type { EnvironmentChange } from './environment.ts';
|
|
133
134
|
import {
|
|
134
135
|
atlasScales,
|
|
135
136
|
choosePosers,
|
|
@@ -500,7 +501,7 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
500
501
|
slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
|
|
501
502
|
subset: (opts, skin) => slotSubsetOf(data, opts, skin),
|
|
502
503
|
setup: (skin) => spinePosed(setupPosed(data, skin)),
|
|
503
|
-
animation: (name, skin, fps, count, visit) => {
|
|
504
|
+
animation: (name, skin, fps, count, visit, environment) => {
|
|
504
505
|
const skeleton = skeletonUnderSkin(data, skin);
|
|
505
506
|
const state = new AnimationState(new AnimationStateData(data));
|
|
506
507
|
// Not looping: the last frame sits at the animation's duration, and a looping
|
|
@@ -515,10 +516,12 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
515
516
|
if (i > 0) {
|
|
516
517
|
state.update(step);
|
|
517
518
|
state.apply(skeleton);
|
|
519
|
+
setSkeletonEnvironment(skeleton, environment?.(i));
|
|
518
520
|
skeleton.update(clock);
|
|
519
521
|
skeleton.updateWorldTransform(Physics.update);
|
|
520
522
|
} else {
|
|
521
523
|
state.apply(skeleton);
|
|
524
|
+
setSkeletonEnvironment(skeleton, environment?.(i));
|
|
522
525
|
skeleton.update(0);
|
|
523
526
|
skeleton.updateWorldTransform(Physics.reset);
|
|
524
527
|
}
|
|
@@ -529,6 +532,17 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
529
532
|
};
|
|
530
533
|
}
|
|
531
534
|
|
|
535
|
+
/**
|
|
536
|
+
* `render --environment`'s change for one frame (issue #1416), set on spine-core's own `Skeleton` before the update that
|
|
537
|
+
* poses the frame: `wind` is `windX, windY` and `gravity` is `gravityX, gravityY`, both in the runtime's convention, x
|
|
538
|
+
* right and y down — the live track's `setEnvironment` mapping, held to these four fields by the core suite's `CO56`. A
|
|
539
|
+
* half left out keeps the one standing, as the core merges it.
|
|
540
|
+
*/
|
|
541
|
+
function setSkeletonEnvironment(skeleton: Skeleton, change: EnvironmentChange | undefined): void {
|
|
542
|
+
if (change?.wind !== undefined) [skeleton.windX, skeleton.windY] = change.wind;
|
|
543
|
+
if (change?.gravity !== undefined) [skeleton.gravityX, skeleton.gravityY] = change.gravity;
|
|
544
|
+
}
|
|
545
|
+
|
|
532
546
|
/** A spine-core skeleton as it stands posed, read through the seam's `Posed`. */
|
|
533
547
|
function spinePosed(skeleton: Skeleton): Posed {
|
|
534
548
|
return {
|
package/src/render_core.ts
CHANGED
|
@@ -100,7 +100,7 @@ import { atlasRegionLookup, parseAtlasText } from './atlas.ts';
|
|
|
100
100
|
import { pagesOfAtlas, spineFileSha256, type ModelPage } from './model.ts';
|
|
101
101
|
import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
|
|
102
102
|
import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
|
|
103
|
-
import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
|
|
103
|
+
import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose, type StepEnvironment } from './core/raw.ts';
|
|
104
104
|
import type { TimelinePlant } from './core/animation.ts';
|
|
105
105
|
import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
|
|
106
106
|
import { documentPageLookup, drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvRegion, type UvSource } from './core/uvs.ts';
|
|
@@ -363,10 +363,14 @@ export function keptPieceArrays(): PieceArrays {
|
|
|
363
363
|
* one pose is held at a time rather than the animation's whole series. A plant
|
|
364
364
|
* passes another (`RC44`).
|
|
365
365
|
*/
|
|
366
|
-
export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void) => void;
|
|
366
|
+
export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void, environment?: StepEnvironment) => void;
|
|
367
367
|
|
|
368
|
-
/**
|
|
369
|
-
|
|
368
|
+
/**
|
|
369
|
+
* The walk `corePoser` steps with unless a plant passes another: one pose posed, drawn and released before the next, the
|
|
370
|
+
* physics clock moved by `physicsClockStep` (issue #1380), and `render --environment`'s change for each pose set before it
|
|
371
|
+
* is posed (issue #1416, `StepEnvironment`).
|
|
372
|
+
*/
|
|
373
|
+
export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit, environment) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep, environment);
|
|
370
374
|
|
|
371
375
|
/**
|
|
372
376
|
* The render's physics clock (issue #1380): defined beside the walk that takes it (`./core/raw.ts`), where the mesh
|
|
@@ -750,7 +754,7 @@ export function corePoser(
|
|
|
750
754
|
const input = inputOf(skin);
|
|
751
755
|
return corePosed(input, poseRawSetup(input.doc, { through }), through);
|
|
752
756
|
},
|
|
753
|
-
animation: (name, skin, fps, count, visit) => {
|
|
757
|
+
animation: (name, skin, fps, count, visit, environment) => {
|
|
754
758
|
const input = inputOf(skin);
|
|
755
759
|
const step = 1 / fps;
|
|
756
760
|
// Each pose is drawn as it is posed and released before the next (issue #1180), so the walk holds one pose and not
|
|
@@ -765,7 +769,7 @@ export function corePoser(
|
|
|
765
769
|
} catch (thrown) {
|
|
766
770
|
drawFailed.push(thrown);
|
|
767
771
|
}
|
|
768
|
-
});
|
|
772
|
+
}, environment);
|
|
769
773
|
if (drawFailed.length > 0) throw drawFailed[0];
|
|
770
774
|
},
|
|
771
775
|
rest: (skin, shown) => {
|
package/src/render_shared.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { spinePosingFor, type SpineSkeletonData } from './spine_side.ts';
|
|
39
39
|
import { walkTimelines } from './timelines.ts';
|
|
40
40
|
import { firstNonFinite, type PosedVertices, type WorldTransform } from './nonfinite.ts';
|
|
41
|
+
import { environmentAt, type EnvironmentChange, type EnvironmentStep } from './environment.ts';
|
|
41
42
|
|
|
42
43
|
// ---------------------------------------------------------------------------
|
|
43
44
|
// the Spine side, through the seam (issue #1052)
|
|
@@ -211,6 +212,16 @@ export interface FramesSidecar {
|
|
|
211
212
|
slots?: string[];
|
|
212
213
|
/** The slots these frames leave out, when `render --hide` named them — see `slots`. */
|
|
213
214
|
hidden?: string[];
|
|
215
|
+
/**
|
|
216
|
+
* The schedule the physics answered, when `render --environment` gave one
|
|
217
|
+
* (issue #1416) — its steps as read, each `{ t, wind?, gravity? }`.
|
|
218
|
+
*
|
|
219
|
+
* ⭐ Absent on a render without it, for `skin`'s reason: every frame set
|
|
220
|
+
* written before the field existed says the same. A frame set carrying it is
|
|
221
|
+
* a picture of the rig in a world `check` does not pose, so `check` refuses
|
|
222
|
+
* it as a reference by name.
|
|
223
|
+
*/
|
|
224
|
+
environment?: EnvironmentStep[];
|
|
214
225
|
/** The colour the frames were cleared to, straight RGBA 0..255. */
|
|
215
226
|
background: RGBA;
|
|
216
227
|
viewport: {
|
|
@@ -391,6 +402,19 @@ export interface PoseOptions {
|
|
|
391
402
|
* second render writes.
|
|
392
403
|
*/
|
|
393
404
|
unclipped?: boolean;
|
|
405
|
+
/**
|
|
406
|
+
* The world the physics answers, in time — `render --environment` (issue
|
|
407
|
+
* #1416, `./environment.ts`): each step's change set before the step that
|
|
408
|
+
* poses the first frame at or after its `t`, on the sampler's own frame grid,
|
|
409
|
+
* on whichever poser draws. Absent, nothing is set and every pose is the one
|
|
410
|
+
* it always was.
|
|
411
|
+
*
|
|
412
|
+
* ⭐ **It is part of the shot, so the framing reads it.** Unlike `slots` and
|
|
413
|
+
* `hidden`, which say which pixels are drawn of one pose, an environment
|
|
414
|
+
* changes the pose — a gust can carry a coat past the box a still rig fills —
|
|
415
|
+
* so `framingViewport` keeps it and the box holds what the frames draw.
|
|
416
|
+
*/
|
|
417
|
+
environment?: readonly EnvironmentStep[];
|
|
394
418
|
}
|
|
395
419
|
|
|
396
420
|
/**
|
|
@@ -1025,8 +1049,10 @@ export interface Poser {
|
|
|
1025
1049
|
/**
|
|
1026
1050
|
* Animation `name` under `skin`, not looping: `visit(i, posed)` for every
|
|
1027
1051
|
* `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
|
|
1052
|
+
* `environment(i)`, when given, is the change to the world the physics
|
|
1053
|
+
* answers set before the step that poses frame `i` (issue #1416).
|
|
1028
1054
|
*/
|
|
1029
|
-
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void): void;
|
|
1055
|
+
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined): void;
|
|
1030
1056
|
/** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
|
|
1031
1057
|
rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
|
|
1032
1058
|
}
|
|
@@ -1091,7 +1117,12 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
|
|
|
1091
1117
|
const count = Math.round(animation.duration * fps);
|
|
1092
1118
|
const draw = drawResolver(poser, opts);
|
|
1093
1119
|
const frames: Frame[] = [];
|
|
1094
|
-
|
|
1120
|
+
const visit = (i: number, posed: Posed): void => {
|
|
1121
|
+
frames.push(frameOf(i, i * step, posed, draw(), opts));
|
|
1122
|
+
};
|
|
1123
|
+
// Without an environment the call is the one it always was, so a poser that takes none is asked nothing new.
|
|
1124
|
+
if (opts?.environment === undefined) poser.animation(name, opts?.skin, fps, count, visit);
|
|
1125
|
+
else poser.animation(name, opts.skin, fps, count, visit, environmentAt(opts.environment, fps));
|
|
1095
1126
|
return frames;
|
|
1096
1127
|
}
|
|
1097
1128
|
|
|
@@ -1411,6 +1442,8 @@ export interface GeometryFile {
|
|
|
1411
1442
|
/** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
|
|
1412
1443
|
animation: string | null;
|
|
1413
1444
|
skin?: string;
|
|
1445
|
+
/** The schedule the physics answered (`render --environment`, issue #1416) — `frames.json`'s own `environment`; absent without it. */
|
|
1446
|
+
environment?: EnvironmentStep[];
|
|
1414
1447
|
fps: number;
|
|
1415
1448
|
/** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
|
|
1416
1449
|
viewport: FramesSidecar['viewport'];
|
|
@@ -1470,6 +1503,7 @@ export function geometryFileOf(
|
|
|
1470
1503
|
frames: Frame[],
|
|
1471
1504
|
viewport: Viewport,
|
|
1472
1505
|
skin: string | undefined,
|
|
1506
|
+
environment?: EnvironmentStep[],
|
|
1473
1507
|
): GeometryFile {
|
|
1474
1508
|
const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
|
|
1475
1509
|
if (frame.bones === undefined || frame.attachments === undefined) {
|
|
@@ -1493,6 +1527,7 @@ export function geometryFileOf(
|
|
|
1493
1527
|
coordinates: GEOMETRY_COORDINATES,
|
|
1494
1528
|
animation,
|
|
1495
1529
|
...(skin === undefined ? {} : { skin }),
|
|
1530
|
+
...(environment === undefined ? {} : { environment }),
|
|
1496
1531
|
fps,
|
|
1497
1532
|
viewport: sidecarViewport(viewport),
|
|
1498
1533
|
bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
|
package/src/rig.ts
CHANGED
|
@@ -683,6 +683,53 @@ export interface RigSegmentsFalloff {
|
|
|
683
683
|
minWeight?: number;
|
|
684
684
|
}
|
|
685
685
|
|
|
686
|
+
/** The two curves an `influence` pull falls to zero along — see `RigSegmentsInfluence.shape`. */
|
|
687
|
+
export const RIG_INFLUENCE_SHAPES = ['linear', 'smooth'] as const;
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* A falloff that **reaches zero** at a stated distance, so a vertex follows
|
|
691
|
+
* every chain link near it and no link far from it (issue #1415) — the model a
|
|
692
|
+
* cloth surface needs, where a vertex between two chains blends the springs of
|
|
693
|
+
* both and the surface bends as one sheet rather than as strips.
|
|
694
|
+
*
|
|
695
|
+
* Per vertex: `d` is the distance to each link's **segment** (its nearest
|
|
696
|
+
* point, the same distance `falloff` measures), read at the setup pose. A link
|
|
697
|
+
* with `d < radius` pulls `shape(d / radius)` — one whose pull is not above 0
|
|
698
|
+
* in doubles, a hair inside the reach, pulls nothing; the strongest `max` pulls are
|
|
699
|
+
* kept — equal pulls in the order `bones` first names their bones — and
|
|
700
|
+
* normalised; a share the 6-decimal grid would write as 0 is dropped and the
|
|
701
|
+
* rest normalised again. Each vertex lists its bones in the order `bones`
|
|
702
|
+
* names them, so the attachment has one bone order by construction (A52).
|
|
703
|
+
*
|
|
704
|
+
* ⭐ Every number is a field and none has a default: the reach and the cap are
|
|
705
|
+
* judgements about this cloth, and the shape is the one thing that decides
|
|
706
|
+
* how a link enters a vertex's blend.
|
|
707
|
+
*/
|
|
708
|
+
export interface RigSegmentsInfluence {
|
|
709
|
+
/**
|
|
710
|
+
* **Required.** How far a link reaches, in the part's pixels (the skeleton's
|
|
711
|
+
* units on this generator's route): a link whose segment is `radius` or
|
|
712
|
+
* farther from a vertex never pulls it. Positive.
|
|
713
|
+
*/
|
|
714
|
+
radius: number;
|
|
715
|
+
/**
|
|
716
|
+
* **Required.** `true`: every link of every `bones` entry within `radius`
|
|
717
|
+
* pulls. `false`: only the links of ONE entry — the entry holding the link
|
|
718
|
+
* nearest the vertex, the earlier entry on a tie — so a chain moves its own
|
|
719
|
+
* strip and nothing across.
|
|
720
|
+
*/
|
|
721
|
+
across: boolean;
|
|
722
|
+
/** **Required.** At most this many links pull one vertex — the strongest. A whole number of at least 1. */
|
|
723
|
+
max: number;
|
|
724
|
+
/**
|
|
725
|
+
* **Required.** How a pull falls from 1 at `d = 0` to 0 at `d = radius`, with
|
|
726
|
+
* `x = d / radius`: `"linear"` is `1 - x`; `"smooth"` is `1 - x²(3 - 2x)`,
|
|
727
|
+
* which also arrives at both ends with zero slope, so a link enters and leaves
|
|
728
|
+
* a vertex's blend without a crease.
|
|
729
|
+
*/
|
|
730
|
+
shape: (typeof RIG_INFLUENCE_SHAPES)[number];
|
|
731
|
+
}
|
|
732
|
+
|
|
686
733
|
/**
|
|
687
734
|
* A lattice over the part's alpha, weighted by distance to named bone segments
|
|
688
735
|
* (`buildSegmentsLattice` and `segmentWeights` in [`mesh.ts`](mesh.ts)).
|
|
@@ -703,8 +750,14 @@ export interface RigSegmentsGenerator {
|
|
|
703
750
|
* or a `RigSegmentSpan` stated outright.
|
|
704
751
|
*/
|
|
705
752
|
bones: Array<string | string[] | RigSegmentSpan>;
|
|
706
|
-
/**
|
|
707
|
-
|
|
753
|
+
/**
|
|
754
|
+
* The falloff — see `RigSegmentsFalloff`. Exactly one of `falloff` and
|
|
755
|
+
* `influence` is stated: both turn a distance into a share, in two models,
|
|
756
|
+
* and a mesh weighted by both would be weighted by neither.
|
|
757
|
+
*/
|
|
758
|
+
falloff?: RigSegmentsFalloff;
|
|
759
|
+
/** A falloff that reaches zero at a stated distance — see `RigSegmentsInfluence`. */
|
|
760
|
+
influence?: RigSegmentsInfluence;
|
|
708
761
|
/** Alpha at or above which a pixel counts as art, 1..255. Default 1 — `contour`'s own. */
|
|
709
762
|
alpha?: number;
|
|
710
763
|
/**
|
|
@@ -1965,8 +2018,9 @@ export const RIG_KEYS = {
|
|
|
1965
2018
|
RigRibbonGenerator: ['kind', 'size', 'rows', 'chain'],
|
|
1966
2019
|
RigContourGenerator: ['kind', 'tolerance', 'margin', 'maxVertices', 'alpha', 'depth', 'soft'],
|
|
1967
2020
|
RigGridGenerator: ['kind', 'us', 'vs', 'cols', 'rows', 'depth', 'soft'],
|
|
1968
|
-
RigSegmentsGenerator: ['kind', 'cell', 'bones', 'falloff', 'alpha', 'anchor'],
|
|
2021
|
+
RigSegmentsGenerator: ['kind', 'cell', 'bones', 'falloff', 'influence', 'alpha', 'anchor'],
|
|
1969
2022
|
RigSegmentsFalloff: ['power', 'radius', 'maxBones', 'minWeight'],
|
|
2023
|
+
RigSegmentsInfluence: ['radius', 'across', 'max', 'shape'],
|
|
1970
2024
|
RigSegmentSpan: ['bone', 'from', 'to'],
|
|
1971
2025
|
RigMeshBias: ['axis_deg', 'ramp'],
|
|
1972
2026
|
RigDepthMap: ['image', 'near', 'zScale', 'gamma', 'contrast', 'bias'],
|
|
@@ -2101,8 +2155,11 @@ export const RIG_TYPES = {
|
|
|
2101
2155
|
soft: 'object',
|
|
2102
2156
|
},
|
|
2103
2157
|
RigGridGenerator: { kind: 'enum', us: 'number[]', vs: 'number[]', cols: 'number', rows: 'number', depth: 'object', soft: 'object' },
|
|
2104
|
-
RigSegmentsGenerator: {
|
|
2158
|
+
RigSegmentsGenerator: {
|
|
2159
|
+
kind: 'enum', cell: 'number', bones: 'mixed', falloff: 'object', influence: 'object', alpha: 'number', anchor: 'number[]',
|
|
2160
|
+
},
|
|
2105
2161
|
RigSegmentsFalloff: { power: 'number', radius: 'number', maxBones: 'number', minWeight: 'number' },
|
|
2162
|
+
RigSegmentsInfluence: { radius: 'number', across: 'boolean', max: 'number', shape: 'enum' },
|
|
2106
2163
|
RigSegmentSpan: { bone: 'string', from: 'number[]', to: 'number[]' },
|
|
2107
2164
|
RigMeshBias: { axis_deg: 'number', ramp: 'number[]' },
|
|
2108
2165
|
RigDepthMap: { image: 'string', near: 'enum', zScale: 'number', gamma: 'number', contrast: 'number', bias: 'number' },
|
|
@@ -2170,6 +2227,7 @@ export const RIG_ENUMS = {
|
|
|
2170
2227
|
RigContourGenerator: { kind: { owner: 'checkRigSpecKeys' } },
|
|
2171
2228
|
RigGridGenerator: { kind: { owner: 'checkRigSpecKeys' } },
|
|
2172
2229
|
RigSegmentsGenerator: { kind: { owner: 'checkRigSpecKeys' } },
|
|
2230
|
+
RigSegmentsInfluence: { shape: { owner: 'segmentsInfluenceOf' } },
|
|
2173
2231
|
RigDepthMap: { near: { owner: 'sampleMeshDepth' } },
|
|
2174
2232
|
} as const satisfies SpecEnumTable<typeof RIG_TYPES>;
|
|
2175
2233
|
|
|
@@ -2438,6 +2496,7 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
|
|
|
2438
2496
|
at(gen.depth, 'RigDepthMap', `${who} generator.depth`, [...here, 'generator', 'depth']);
|
|
2439
2497
|
at(gen.soft, 'RigSoftRegion', `${who} generator.soft`, [...here, 'generator', 'soft']);
|
|
2440
2498
|
at(gen.falloff, 'RigSegmentsFalloff', `${who} generator.falloff`, [...here, 'generator', 'falloff']);
|
|
2499
|
+
at(gen.influence, 'RigSegmentsInfluence', `${who} generator.influence`, [...here, 'generator', 'influence']);
|
|
2441
2500
|
for (const [i, entry] of (Array.isArray(gen.bones) ? gen.bones : []).entries()) {
|
|
2442
2501
|
at(entry, 'RigSegmentSpan', `${who} generator.bones[${i}]`, [...here, 'generator', 'bones', i]);
|
|
2443
2502
|
}
|