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.
@@ -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 {
@@ -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
- /** The walk `corePoser` steps with unless a plant passes another: one pose posed, drawn and released before the next, the physics clock moved by `physicsClockStep` (issue #1380). */
369
- export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep);
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) => {
@@ -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
- poser.animation(name, opts?.skin, fps, count, (i, posed) => frames.push(frameOf(i, i * step, posed, draw(), opts)));
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
- /** The falloff — see `RigSegmentsFalloff`. `radius` is required, so the block is too. */
707
- falloff: RigSegmentsFalloff;
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: { kind: 'enum', cell: 'number', bones: 'mixed', falloff: 'object', alpha: 'number', anchor: 'number[]' },
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
  }