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/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) visit(stepWalk(track, dt), visited++);
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
+ }