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.
@@ -24,7 +24,9 @@ import {
24
24
  physicsKeyRefusal,
25
25
  physicsOutsideSays,
26
26
  physicsRuleFor,
27
+ physicsStepStability,
27
28
  type PhysicsPoseRule,
29
+ type PhysicsStepStability,
28
30
  } from '../../timelines.ts';
29
31
 
30
32
  /**
@@ -72,7 +74,8 @@ const SETUP_POSE_SAYS: Record<string, (pose: PhysicsSetup, animations: number, r
72
74
  // on the arm's own words, which `T100` holds.
73
75
  strength: (pose, _animations, rule) => `has strength ${pose.strength}; ${setupBasisSays(rule, pose.strength)}`,
74
76
  // The bound is read off the row, so the setup pose and a key cannot state two
75
- // intervals; the reason is the arm the value took, since the ends are inside (#794).
77
+ // intervals; the reason is the arm the value took — above 1, at 1 (issue
78
+ // #1422, inside from #794 until then) or below 0.
76
79
  damping: (pose, _animations, rule) =>
77
80
  `has damping ${pose.damping}; must be ${rule.states} — the per-step decay is \`damping ** (60 * step)\`, and ` +
78
81
  setupBasisSays(rule, pose.damping),
@@ -90,10 +93,11 @@ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
90
93
 
91
94
  // Every failure mode here is silent. The five component fields default to
92
95
  // 0, so a constraint can drive nothing at all; `mix` 0 mutes it; `mass` 0
93
- // becomes an infinite massInverse; and `damping` above 1 never settles, which
94
- // on a mesh-driving bone means the canvas re-rasterises forever. 1 itself is
95
- // inside the bound since issue #794: it never decays, which is finite and a
96
- // choice, and what it costs a consumer's frame is the consumer's to judge.
96
+ // becomes an infinite massInverse; and `damping` at or above 1 never settles,
97
+ // which on a mesh-driving bone means the canvas re-rasterises forever. 1 itself
98
+ // was inside the bound from issue #794 to #1422, as a jiggle held rather than a
99
+ // fault; it is out because a decay of 1 puts the step's eigenvalues on the unit
100
+ // circle, and a chain of such links was measured resonating without limit.
97
101
  //
98
102
  // 🔑 **Two arms, one criterion.** The second arm below reads every physics
99
103
  // TIMELINE key, and it is written against `PHYSICS_POSE_RULES` — the table
@@ -165,6 +169,9 @@ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, f
165
169
  });
166
170
  }
167
171
  let mutedUntilKeyed = 0;
172
+ // Each constraint whose setup values the stability clause read, by position — the
173
+ // floor every animation's combination is taken from below.
174
+ const setupCombos = new Map<number, StabilityCombo>();
168
175
  facts.constraints.forEach((constraint, index) => {
169
176
  const physics = constraint.physics;
170
177
  if (physics === undefined) return;
@@ -245,6 +252,24 @@ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, f
245
252
  if (physics.step <= 0 || !Number.isFinite(physics.step)) {
246
253
  fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has step ${physics.step} (fps must be > 0)`);
247
254
  }
255
+ // --- the step itself has to settle (issue #1416, part B) ------------------
256
+ //
257
+ // The rows above judge each value against its own interval; this is the one
258
+ // joint condition, `physicsStepStability`'s. It is read only where the rows
259
+ // it combines all hold — a value its own row refuses is named there, and a
260
+ // second line about the same number would be the same fault twice — and
261
+ // only on a constraint that drives something, which the first clause names
262
+ // otherwise.
263
+ if (components.length && stableInputs(pose, physics.step)) {
264
+ const setupCombo: StabilityCombo = {
265
+ strength: { value: pose.strength, from: 'setup' },
266
+ massInverse: { value: pose.massInverse, from: 'setup' },
267
+ damping: { value: pose.damping, from: 'setup' },
268
+ };
269
+ const at = physicsStepStability(pose.strength, pose.massInverse, pose.damping, physics.step);
270
+ if (!at.stable) fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has ${runawaySays(setupCombo, physics.step, at)}`);
271
+ setupCombos.set(index, setupCombo);
272
+ }
248
273
  });
249
274
  // What the PASS would otherwise not say: this rig rests with physics off
250
275
  // on that many constraints and an animation is what switches them on. A
@@ -302,4 +327,228 @@ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, f
302
327
  }
303
328
  }
304
329
  stats.physicsTimelineKeys = keysRead;
330
+
331
+ // --- the step has to settle on every pose an animation reaches (#1416) ----
332
+ //
333
+ // 🔑 **Which combinations are read.** Per animation and per constraint its
334
+ // `strength`, `mass` or `damping` timelines reach (a named one, or each a
335
+ // global one reaches): the LARGEST strength, the LARGEST massInverse and the
336
+ // SMALLEST damping that animation can pose on that constraint, judged
337
+ // together. A value's candidates are every key and every Bézier sample of
338
+ // every such timeline, as the pose field it lands in (`posed`) — and the
339
+ // setup value as well wherever the animation keys that value not at all, or
340
+ // keys it first after t = 0, because before its first key a timeline poses the
341
+ // setup value. Each extreme is exact for its own value: the runtime moves
342
+ // a value linearly between those points, `massInverse` is monotone in the
343
+ // mass it inverts, and the bound is monotone in damping.
344
+ //
345
+ // ⚠️ The JOINT is an upper bound rather than a time-aligned pose: an
346
+ // animation that raises strength while it raises mass, so the ratio holds,
347
+ // is judged at the larger strength against the smaller mass. That is the
348
+ // conservative direction — no pose this animation reaches is less stable
349
+ // than the combination read — and it was chosen over evaluating the three
350
+ // curves at shared times because the facts carry each curve's values and
351
+ // not its sample times, and because a single held key crossing the bound is
352
+ // already a runaway. A line names which key or setup supplied each value, so
353
+ // a refusal of that kind says so.
354
+ //
355
+ // A combination equal to the setup's is not repeated: the setup line names
356
+ // it already. A value its own row refuses is left out of the extremes — it
357
+ // was named above, by its key.
358
+ const keyed = new Map<string, Map<number, Partial<Record<StabilityWord, Candidate[]>>>>();
359
+ for (const timeline of facts.timelines) {
360
+ if (timeline.kind !== 'physics' || !isStabilityWord(timeline.word)) continue;
361
+ const rule = physicsRuleFor(timeline.word);
362
+ if (rule === undefined) continue;
363
+ const word = timeline.word;
364
+ const values = timeline.channelValues(0);
365
+ const firstTime = timeline.frames[0]?.time ?? 0;
366
+ const candidates: Candidate[] = [];
367
+ // `channelValues` lists each key and then the Bézier samples of the segment
368
+ // it opens, so the next key's value is that key and anything else is the
369
+ // curve leaving the key before it.
370
+ let next = 0;
371
+ for (const value of values) {
372
+ const frame = timeline.frames[next];
373
+ const isKey = frame !== undefined && frame.value === value;
374
+ if (isKey) next++;
375
+ const posedValue = timeline.posed === undefined ? rule.toPose(value) : timeline.posed(value);
376
+ if (physicsKeyRefusal(rule, value, posedValue) !== null) continue;
377
+ candidates.push({
378
+ value: posedValue,
379
+ from: isKey ? `the key at t=${frame.time.toFixed(6)}s` : `the curve after the key at t=${(timeline.frames[next - 1]?.time ?? 0).toFixed(6)}s`,
380
+ });
381
+ }
382
+ const byConstraint = keyed.get(timeline.animation) ?? new Map<number, Partial<Record<StabilityWord, Candidate[]>>>();
383
+ keyed.set(timeline.animation, byConstraint);
384
+ for (const index of timeline.reach) {
385
+ const setup = setupCombos.get(index);
386
+ if (setup === undefined) continue;
387
+ const slot = byConstraint.get(index) ?? {};
388
+ byConstraint.set(index, slot);
389
+ const list = slot[word] ?? [];
390
+ list.push(...candidates);
391
+ // Before the first key the timeline poses the setup value.
392
+ if (firstTime > 0) list.push(setup[STABILITY_FIELD[word]]);
393
+ slot[word] = list;
394
+ }
395
+ }
396
+ for (const [animation, byConstraint] of keyed) {
397
+ for (const [index, slot] of byConstraint) {
398
+ const setup = setupCombos.get(index);
399
+ const physics = facts.constraints[index]?.physics;
400
+ if (setup === undefined || physics === undefined) continue;
401
+ const pick = (word: StabilityWord, worse: (a: number, b: number) => boolean): Candidate => {
402
+ const list = slot[word];
403
+ // A value this animation never keys, or keys only with values refused above, stays at setup.
404
+ if (list === undefined || list.length === 0) return setup[STABILITY_FIELD[word]];
405
+ return list.reduce((best, one) => (worse(one.value, best.value) ? one : best));
406
+ };
407
+ const combo: StabilityCombo = {
408
+ strength: pick('strength', (a, b) => a > b),
409
+ massInverse: pick('mass', (a, b) => a > b),
410
+ damping: pick('damping', (a, b) => a < b),
411
+ };
412
+ if (combo.strength.value === setup.strength.value && combo.massInverse.value === setup.massInverse.value && combo.damping.value === setup.damping.value) continue;
413
+ const at = physicsStepStability(combo.strength.value, combo.massInverse.value, combo.damping.value, physics.step);
414
+ if (at.stable) continue;
415
+ // A decay rounded to 1 by the setup damping itself is the setup line's, whatever else this animation keys.
416
+ if (!(at.d < 1) && at.k < at.bound && combo.damping.from === 'setup') continue;
417
+ fail(
418
+ 'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
419
+ `animation "${animation}" can pose physics "${facts.constraints[index]?.name ?? `#${index}`}" at ${runawaySays(combo, physics.step, at)}`,
420
+ );
421
+ }
422
+ }
423
+
424
+ // --- strength held at 0 for as long as an animation plays (issue #1422) ---
425
+ //
426
+ // A strength KEY of 0 is accepted (issue #727) because it releases the
427
+ // constraint for a span and a later key pulls the offset back. An animation
428
+ // whose strength timelines pose 0 at EVERY time has no later key: [measured on
429
+ // #1422, x drive, the runtime's default world, spine-core and rigc's core
430
+ // identical bit for bit] the offset grows exactly linearly while it plays —
431
+ // 1.33e4 at 60 s, 5.33e4 at 240 s, 1.33e5 at 600 s — and A10's walk of
432
+ // max(duration, 1) s sees a finite number and passes it.
433
+ //
434
+ // 🔑 **"Every time it plays", not "looped".** Whether an animation loops is
435
+ // not in the artifact: skeleton JSON has no loop flag, the motion spec's
436
+ // `loop` is a player hint the compiler never reads (`MotionAnimation.loop`),
437
+ // and an editor export carries neither — and when it plays, and for how long,
438
+ // is the consumer's. The criterion that needs none of that is the one the
439
+ // runtime makes true either way: looped, the animation never leaves its
440
+ // values; played once, a track holds its last frame, which is 0. So the
441
+ // clause reads the timeline alone — every key and every Bézier sample, as
442
+ // the pose field it lands in, and the first key at t = 0, since before a
443
+ // later first key the timeline poses the setup value, which the strength row
444
+ // holds above 0.
445
+ //
446
+ // Per animation and constraint: when two strength timelines reach one
447
+ // constraint in one animation (a named one and the global one), it is held
448
+ // only if both are — a timeline that lifts it is a later key for the other.
449
+ // A constraint driving nothing is named by the first clause, and one resting
450
+ // at strength 0 or below by its row, so neither is read here.
451
+ const strengthRule = physicsRuleFor('strength');
452
+ const heldAtZero = new Map<string, Map<number, { keys: number; held: boolean }>>();
453
+ for (const timeline of facts.timelines) {
454
+ if (timeline.kind !== 'physics' || timeline.word !== 'strength') continue;
455
+ const first = timeline.frames[0];
456
+ const posed = (value: number): number => (timeline.posed === undefined ? value : timeline.posed(value));
457
+ const held = first !== undefined && first.time <= 0 && timeline.channelValues(0).every((value) => posed(value) === 0);
458
+ const byConstraint = heldAtZero.get(timeline.animation) ?? new Map<number, { keys: number; held: boolean }>();
459
+ heldAtZero.set(timeline.animation, byConstraint);
460
+ for (const index of timeline.reach) {
461
+ const was = byConstraint.get(index);
462
+ byConstraint.set(index, { keys: (was?.keys ?? 0) + timeline.frames.length, held: (was?.held ?? true) && held });
463
+ }
464
+ }
465
+ for (const [animation, byConstraint] of heldAtZero) {
466
+ for (const [index, { keys, held }] of byConstraint) {
467
+ const constraint = facts.constraints[index];
468
+ const physics = constraint?.physics;
469
+ if (!held || constraint === undefined || physics === undefined || strengthRule === undefined) continue;
470
+ if (!PHYSICS_COMPONENTS.some((k) => physics.components[k] > 0) || !strengthRule.poseOk(physics.setup.strength)) continue;
471
+ fail(
472
+ 'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
473
+ `animation "${animation}" poses physics "${constraint.name}" strength 0 at every time it plays — ${keys} key(s) from t=0, ` +
474
+ `every one 0 and no curve between them lifting it, so ${physicsOutsideSays(strengthRule, 0)}: a strength key of 0 is ` +
475
+ 'accepted for a span because a later key pulls the offset back, and this animation has none, so for as long as it ' +
476
+ 'plays, looped or held on its last frame, a push (wind, gravity, the bone moving) carries the offset away and it ' +
477
+ 'grows without limit; key strength above 0 somewhere in the animation, or remove the timeline',
478
+ );
479
+ }
480
+ }
481
+ }
482
+
483
+ /** The three timelines whose values the stability clause combines. */
484
+ type StabilityWord = 'strength' | 'mass' | 'damping';
485
+ const STABILITY_FIELD: Record<StabilityWord, keyof StabilityCombo> = { strength: 'strength', mass: 'massInverse', damping: 'damping' };
486
+ function isStabilityWord(word: string): word is StabilityWord {
487
+ return word === 'strength' || word === 'mass' || word === 'damping';
488
+ }
489
+
490
+ /** One value the stability clause read, and where it came from: `setup`, or a key or curve of the animation. */
491
+ interface Candidate {
492
+ readonly value: number;
493
+ readonly from: string;
494
+ }
495
+
496
+ /** The three pose values the stability clause judges together. */
497
+ interface StabilityCombo {
498
+ readonly strength: Candidate;
499
+ readonly massInverse: Candidate;
500
+ readonly damping: Candidate;
501
+ }
502
+
503
+ /** Whether every value the stability clause combines is inside its own row, so the joint condition is the only one left to read. */
504
+ function stableInputs(pose: PhysicsSetup, step: number): boolean {
505
+ if (!(step > 0) || !Number.isFinite(step)) return false;
506
+ return PHYSICS_POSE_RULES.every((rule) => rule.timeline === 'mix' || rule.poseOk(pose[rule.field]));
507
+ }
508
+
509
+ /** A number as an author typed it: `1 / (1 / 45)` prints 45 rather than its last-bit neighbour. */
510
+ function plain(value: number): string {
511
+ return String(Number(value.toPrecision(12)));
512
+ }
513
+
514
+ /**
515
+ * The stability clause's sentence: the four authored numbers, `k` and the bound
516
+ * as the runtime forms them, what the step does at that pose, and the four
517
+ * levers that move it back under the bound.
518
+ *
519
+ * ⚠️ One pose fails with `k` under the bound: a damping the row accepts — below
520
+ * 1 — whose decay `damping ** (60 * step)` the runtime's doubles round to exactly
521
+ * 1 (issue #1422: the largest double below 1 does from fps 121 on). That is the
522
+ * damping row's own fault, a velocity that never decays, reached through the
523
+ * rate, so it says so rather than printing a `k` that is under its bound.
524
+ */
525
+ function runawaySays(combo: StabilityCombo, step: number, at: PhysicsStepStability): string {
526
+ const src = (c: Candidate): string => (c.from === 'setup' ? '' : ` (${c.from})`);
527
+ const fps = plain(1 / step);
528
+ if (!(at.d < 1)) {
529
+ return (
530
+ `damping ${combo.damping.value}${src(combo.damping)} at fps ${fps} — damping^(60·step) = ${at.d} as the runtime forms it, ` +
531
+ 'so although the damping is below 1 no velocity ever decays: the step\'s eigenvalues lie on the unit circle, the fault ' +
532
+ 'the damping row refuses 1 for, reached here by rounding; lower damping or lower fps'
533
+ );
534
+ }
535
+ const places = apart(at.k, at.bound, 4);
536
+ return (
537
+ `strength ${combo.strength.value}${src(combo.strength)}, mass ${plain(1 / combo.massInverse.value)} (massInverse ${combo.massInverse.value})${src(combo.massInverse)}, ` +
538
+ `fps ${fps}, damping ${combo.damping.value}${src(combo.damping)} — strength·massInverse·step² = ${at.k.toFixed(places)} must be < ` +
539
+ `2·(1 + damping^(60·step)) = ${at.bound.toFixed(places)}: at or past that bound the step stops settling — one step can ` +
540
+ `multiply the offset by ${at.growth.toFixed(apart(at.growth, 1, 3))} every 1/${fps} s — so a push (wind, gravity, a key, the ` +
541
+ 'bone moving) never dies out, and past the bound it grows without limit; lower strength, raise mass, raise fps or raise damping'
542
+ );
543
+ }
544
+
545
+ /**
546
+ * The fewest decimal places, from `min`, at which `a` and `b` print apart — so
547
+ * a value one part in 10⁹ past the bound does not print as equal to it. Two
548
+ * equal numbers print at `min`.
549
+ */
550
+ function apart(a: number, b: number, min: number): number {
551
+ if (a === b) return min;
552
+ for (let places = min; places < 17; places++) if (a.toFixed(places) !== b.toFixed(places)) return places;
553
+ return 17;
305
554
  }
package/src/check.ts CHANGED
@@ -1286,6 +1286,18 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1286
1286
  `${options.skin === undefined ? 'with no --skin' : `with --skin ${JSON.stringify(options.skin)}`}.`,
1287
1287
  );
1288
1288
  }
1289
+ // A frame set drawn under `render --environment` (issue #1416) is a picture of the rig in a world `check` does not
1290
+ // pose: it poses the candidate under the runtime's own wind and gravity, so the score would be a number about the
1291
+ // difference between two worlds. Refused by name, as a subset is, rather than scored.
1292
+ const referenceEnvironment = located.sidecar?.environment;
1293
+ if (referenceEnvironment !== undefined) {
1294
+ throw new CheckError(
1295
+ `${FRAMES_SIDECAR} records that these frames were rendered under an environment schedule (\`rigc render ` +
1296
+ `--environment\`, ${Array.isArray(referenceEnvironment) ? referenceEnvironment.length : 0} step(s)), and check poses the candidate ` +
1297
+ "under none — the runtime's own wind [1, 0] and gravity [0, 1] — so the comparison would be a number about the " +
1298
+ 'difference between two worlds. Render the reference frames without --environment.',
1299
+ );
1300
+ }
1289
1301
  if (referenceSkin === null && options.skin !== undefined) {
1290
1302
  notes.push(
1291
1303
  `the candidate is posed under skin ${JSON.stringify(options.skin)} and the reference frames record no skin ` +
@@ -32,6 +32,8 @@ import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
32
32
  import { parseMotionSpec } from '../motion.ts';
33
33
  import { type InspectCompare, type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readBonePatterns, readShow, readTimes, readZoom } from '../inspect.ts';
34
34
  import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine } from '../folds.ts';
35
+ import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passThroughFileOf, passThroughOf, passThroughText, PassThroughError, readPassPairs } from '../passthrough.ts';
36
+ import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
35
37
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
36
38
  import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
37
39
  import { type CompileResult } from '../types.ts';
@@ -472,6 +474,88 @@ function readPositiveNumber(flags: Record<string, string>, key: string, fallback
472
474
  return value;
473
475
  }
474
476
 
477
+ /**
478
+ * `--environment <schedule.json>` read (issue #1416, `../environment.ts`), or
479
+ * refused as a usage error before anything is read off the candidate: a path
480
+ * with nothing at it, a file that is not a schedule, and every malformed entry,
481
+ * by its index, its field, the value found and what is required.
482
+ */
483
+ function readEnvironmentFlag(flags: Record<string, string>): { path: string; steps: EnvironmentStep[] } | undefined {
484
+ const given = flags.environment;
485
+ if (given === undefined) return undefined;
486
+ const path = resolve(given);
487
+ if (!existsSync(path) || !statSync(path).isFile()) throw new UsageError(`--environment ${given}: nothing at ${path} — it takes a schedule file { "spec": "rigc-environment/1", "steps": [...] }`);
488
+ try {
489
+ return { path, steps: readEnvironmentSchedule(readFileSync(path, 'utf8'), given) };
490
+ } catch (err) {
491
+ if (err instanceof EnvironmentError) throw new UsageError(err.message);
492
+ throw err;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * The refusals of a well-formed schedule that this candidate, at this rate,
498
+ * would quietly not draw (issue #1416) — each one a render that would print a
499
+ * plausible frame set showing nothing the schedule asked for:
500
+ *
501
+ * - a skeleton with no animation, whose one frame is the setup pose, where no
502
+ * physics step runs;
503
+ * - a half the schedule changes that no physics constraint answers — none
504
+ * states it non-zero and no animation keys it — so every frame would draw as
505
+ * without the flag;
506
+ * - two steps on one frame of the render's grid, the first never posed;
507
+ * - a step after the last frame of every set drawn.
508
+ *
509
+ * `durations` are the poser's, of the sets this run draws.
510
+ */
511
+ function environmentRefusal(given: string, steps: readonly EnvironmentStep[], skeletonText: string, fps: number, durations: ReadonlyArray<{ name: string; duration: number }>): string | null {
512
+ if (durations.length === 0) return `--environment ${given}: the skeleton has no animation — its one frame is the setup pose, where no physics step runs, so no environment moves it`;
513
+ const response = environmentResponse(skeletonText);
514
+ if (response.declared.length === 0) return `--environment ${given}: the skeleton declares no physics constraint, so no environment moves anything in it`;
515
+ for (const half of ['wind', 'gravity'] as const) {
516
+ const changing = steps.map((step, i) => (step[half] === undefined ? -1 : i)).filter((i) => i >= 0);
517
+ if (changing.length > 0 && response[half].length === 0) {
518
+ return (
519
+ `--environment ${given}: entry ${changing[0]} changes ${half}, and no physics constraint answers it — [${response.declared.map((n) => JSON.stringify(n)).join(', ')}] each state ${half} 0 and no animation keys it — ` +
520
+ `so every frame would draw as it does without the schedule. A constraint's own ${half} is its response (AUTHORING §4.6)`
521
+ );
522
+ }
523
+ }
524
+ const collision = environmentCollisions(steps, fps)[0];
525
+ if (collision !== undefined) {
526
+ return (
527
+ `--environment ${given}: entries [${collision.entries.join(', ')}] (t ${collision.entries.map((i) => steps[i].t).join(', ')}) land on one frame at ${fps} fps — frame ${collision.frame}, ` +
528
+ `t=${(collision.frame / fps).toFixed(4)}s — so entry ${collision.entries[0]} would never be posed. Raise --fps, or merge them`
529
+ );
530
+ }
531
+ const lastFrames = durations.map(({ name, duration }) => ({ name, last: Math.round(duration * fps) }));
532
+ const longest = lastFrames.reduce((a, b) => (b.last > a.last ? b : a));
533
+ const late = steps.findIndex((step) => environmentFrame(step.t, fps) > longest.last);
534
+ if (late >= 0) {
535
+ return (
536
+ `--environment ${given}: entry ${late}'s t ${steps[late].t} falls after the last frame of every set drawn — the longest, "${longest.name}", ends at frame ${longest.last}, ` +
537
+ `t=${(longest.last / fps).toFixed(4)}s at ${fps} fps — so it would change nothing`
538
+ );
539
+ }
540
+ return null;
541
+ }
542
+
543
+ /**
544
+ * `--pass-through <cloth>:<leg>[,…]` read against the skeleton's slots (issue
545
+ * #1413, `../passthrough.ts`), or refused as a usage error before anything is
546
+ * posed: a malformed list, an empty name, a slot the skeleton does not have, a
547
+ * pair named twice — each by its entry. `undefined` without the flag.
548
+ */
549
+ function readPassThroughFlag(raw: string | undefined, facts: SkeletonFacts): PassPair[] | undefined {
550
+ if (raw === undefined) return undefined;
551
+ try {
552
+ return readPassPairs(raw, facts.slots.map((slot) => slot.name));
553
+ } catch (err) {
554
+ if (err instanceof PassThroughError) throw new UsageError(err.message);
555
+ throw err;
556
+ }
557
+ }
558
+
475
559
  /**
476
560
  * render — the frame series, drawn by the same rasteriser `check` measures with.
477
561
  *
@@ -487,6 +571,8 @@ export function cmdRender(flags: Record<string, string>): void {
487
571
  const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
488
572
  const atlasDir = dirname(atlasPath);
489
573
  const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
574
+ // Read before anything is read off the candidate, so a malformed schedule is refused before a line is printed.
575
+ const environment = readEnvironmentFlag(flags);
490
576
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
491
577
  const outRoot = resolve(flags.out ?? 'render');
492
578
  const geometry = flags.geometry !== undefined;
@@ -511,6 +597,8 @@ export function cmdRender(flags: Record<string, string>): void {
511
597
  }
512
598
  }
513
599
  }
600
+ // The pass-through reading (issue #1413) is its own flag for --folds' reason: without it, every file is the bytes it was.
601
+ const passThrough = flags['pass-through'];
514
602
  // The same refusal for the same reason: the reading is of every mesh the pose shows, whatever a picture leaves out.
515
603
  if (folds) {
516
604
  for (const flag of ['slot', 'hide'] as const) {
@@ -524,6 +612,19 @@ export function cmdRender(flags: Record<string, string>): void {
524
612
  }
525
613
  }
526
614
 
615
+ // And for the pass-through reading: it reads the two slots as the frame draws them, and a subset changes which draw.
616
+ if (passThrough !== undefined) {
617
+ for (const flag of ['slot', 'hide'] as const) {
618
+ if (flags[flag] !== undefined) {
619
+ throw new UsageError(
620
+ `--pass-through takes no --${flag}: it reads the cloth and the leg as the whole rig draws them, which a subset of the ` +
621
+ `drawn slots would change. Run \`rigc render --pass-through\` on the whole rig for the reading, and --${flag} ${flags[flag]} ` +
622
+ 'without --pass-through for the pictures — both land on the same grid',
623
+ );
624
+ }
625
+ }
626
+ }
627
+
527
628
  console.log('rigc render');
528
629
  console.log(` .. skeleton ${skeletonPath}`);
529
630
  console.log(` .. atlas ${atlasPath}${atlasText === null ? ` — not there (${ATLAS_ABSENT})` : ''}`);
@@ -532,26 +633,33 @@ export function cmdRender(flags: Record<string, string>): void {
532
633
  // core poses — `skeleton.model.json` beside the pair — reads its names, its
533
634
  // subset roster and its pages without loading spine-core at all, and with a
534
635
  // rigc-compiled/2 document without its atlas either (issue #1020).
636
+ const skeletonText = readSkeletonText(skeletonPath);
535
637
  const { choice, facts, pages } = loadCandidate(
536
- { skeletonText: readSkeletonText(skeletonPath), atlasText, atlasDir, label: skeletonPath },
638
+ { skeletonText, atlasText, atlasDir, label: skeletonPath },
537
639
  { skeleton: skeletonPath, atlas: atlasPath },
538
640
  posersAsked(flags),
539
641
  );
540
642
  const only = readAnimationFlag(flags, [...facts.animations]);
541
643
  const skin = readSkinFlag(flags, [...facts.skins]);
542
644
  const subset = readSlotSubsetFlags(flags, facts, skin);
645
+ const passPairs = readPassThroughFlag(passThrough, facts);
543
646
  // One object, so the framing and the frames cannot be posed under two
544
647
  // different skins — which would frame one shot with another shot's box.
545
648
  // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
546
649
  // `src/render.ts` has one of its own. The inferred shape is the render one.
547
650
  // The subset rides on the same object and `framingViewport` takes it off, so
548
651
  // the frames draw the subset and the box is still the whole rig's.
652
+ // The environment rides on the same object for the same reason (issue #1416): the framing reads it, so the box holds
653
+ // what the frames under it draw.
549
654
  const pose =
550
- skin === undefined && subset === undefined
655
+ skin === undefined && subset === undefined && environment === undefined
551
656
  ? undefined
552
- : { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset) };
657
+ : { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset), ...(environment === undefined ? {} : { environment: environment.steps }) };
553
658
  if (skin !== undefined) console.log(` .. skin ${skin}`);
554
659
  if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
660
+ if (environment !== undefined && facts.animations.length === 0) {
661
+ throw new UsageError(environmentRefusal(flags.environment, environment.steps, skeletonText, fps, []) ?? '');
662
+ }
555
663
 
556
664
  // Rigc's own core when the candidate is a rigc build, and spine-core
557
665
  // otherwise, or where the core refuses the input by name. Never silently:
@@ -565,6 +673,13 @@ export function cmdRender(flags: Record<string, string>): void {
565
673
  // re-poses the whole input on spine-core rather than leaving half of a frame
566
674
  // set drawn by each.
567
675
  const posed = throughPoser(choice, (poser, roster) => {
676
+ // A schedule this candidate would not draw is refused before anything is posed, off the durations of the poser that
677
+ // poses it (issue #1416) — a fallback re-runs this with the other poser's, which the core holds equal.
678
+ if (environment !== undefined) {
679
+ const drawn = poser.animations.filter((a) => only === undefined || a.name === only);
680
+ const refusal = environmentRefusal(flags.environment, environment.steps, skeletonText, fps, drawn);
681
+ if (refusal !== null) throw new UsageError(refusal);
682
+ }
568
683
  // `null` is a skeleton that posed no vertex at all. One that posed a vertex
569
684
  // it cannot frame — Infinity or NaN — is thrown from the framing as a
570
685
  // `GeometryError` naming the number (issue #873), and one whose every vertex
@@ -587,7 +702,7 @@ export function cmdRender(flags: Record<string, string>): void {
587
702
  // `--geometry` rides on the SAME call (issue #864): the bones and the whole
588
703
  // attachments are read off the skeleton at the step that drew each frame, so
589
704
  // the export's grid is this frame set's by construction.
590
- const sampling = geometry ? { ...pose, bones: true, geometry: true } : folds ? { ...pose, geometry: true } : pose;
705
+ const sampling = geometry ? { ...pose, bones: true, geometry: true } : folds || passPairs !== undefined ? { ...pose, geometry: true } : pose;
591
706
  const sampled: Map<string, Frame[]> =
592
707
  only === undefined
593
708
  ? sampleAll(poser, fps, sampling)
@@ -598,7 +713,7 @@ export function cmdRender(flags: Record<string, string>): void {
598
713
  if (geometry) {
599
714
  for (const [name, frames] of sampled) {
600
715
  const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
601
- geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
716
+ geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps)));
602
717
  }
603
718
  }
604
719
  // The fold reading, off the very attachments the frames were drawn from, against the rest table the geometry
@@ -612,10 +727,45 @@ export function cmdRender(flags: Record<string, string>): void {
612
727
  foldReadings.set(name, foldsFileOf(animation, fps, skin, foldsOf(poser.rest(skin, shown), read)));
613
728
  }
614
729
  }
615
- return { viewport, sampled, geometryTexts, foldReadings };
730
+ // The pass-through reading, off the very pieces the frames are drawn from, in their draw order — one per set, every
731
+ // pair the flag names. A pair whose slot draws on no frame of any set drawn is refused rather than read as 0.
732
+ const passReadings = new Map<string, ReturnType<typeof passThroughFileOf>>();
733
+ if (passPairs !== undefined) {
734
+ const draws = new Set<string>();
735
+ for (const frames of sampled.values()) for (const frame of frames) for (const piece of frame.pieces) draws.add(piece.slot);
736
+ for (const pair of passPairs) {
737
+ for (const slot of [pair.cloth, pair.leg]) {
738
+ if (!draws.has(slot)) {
739
+ throw new UsageError(
740
+ `--pass-through: slot ${JSON.stringify(slot)} draws on no frame of [${[...sampled.keys()].join(', ')}]${skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`} ` +
741
+ '— it shows no region or mesh there, so it has no drawn outline to read',
742
+ );
743
+ }
744
+ }
745
+ }
746
+ for (const [name, frames] of sampled) {
747
+ const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
748
+ const shown = frames.map((frame) => frame.attachments ?? []);
749
+ const read = frames.map((frame) => ({ index: frame.index, time: frame.time, pieces: frame.pieces, attachments: frame.attachments ?? [] }));
750
+ try {
751
+ passReadings.set(name, passThroughFileOf(animation, fps, skin, passThroughOf(passPairs, poser.rest(skin, shown), read, pages)));
752
+ } catch (err) {
753
+ if (err instanceof PassThroughError) throw new UsageError(`--pass-through, ${JSON.stringify(name)}: ${err.message}`);
754
+ throw err;
755
+ }
756
+ }
757
+ }
758
+ return { viewport, sampled, geometryTexts, foldReadings, passReadings };
616
759
  });
617
- const { viewport, sampled, geometryTexts, foldReadings } = posed.value;
760
+ const { viewport, sampled, geometryTexts, foldReadings, passReadings } = posed.value;
618
761
  console.log(` .. poser ${posed.note}`);
762
+ if (environment !== undefined) {
763
+ const response = environmentResponse(skeletonText);
764
+ console.log(
765
+ ` .. world ${environment.steps.length} step(s) from ${environment.path} (x right, y down; the runtime's wind [1, 0] and gravity [0, 1] before the first) — ` +
766
+ `wind answered by ${response.wind.join(', ') || 'none'}; gravity by ${response.gravity.join(', ') || 'none'}`,
767
+ );
768
+ }
619
769
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
620
770
  if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
621
771
 
@@ -644,6 +794,8 @@ export function cmdRender(flags: Record<string, string>): void {
644
794
  if (geometryOut !== undefined) writeFileSync(join(dir, GEOMETRY_FILE), geometryOut);
645
795
  const foldReading = foldReadings.get(name);
646
796
  if (foldReading !== undefined) writeFileSync(join(dir, FOLDS_FILE), foldsText(foldReading));
797
+ const passReading = passReadings.get(name);
798
+ if (passReading !== undefined) writeFileSync(join(dir, PASS_THROUGH_FILE), passThroughText(passReading));
647
799
  const duration = frames[frames.length - 1].time;
648
800
  sets.push({
649
801
  dir: dirName,
@@ -657,7 +809,9 @@ export function cmdRender(flags: Record<string, string>): void {
657
809
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
658
810
  const folding = foldReading?.meshes.filter((m) => m.worst !== null).length ?? 0;
659
811
  const foldNote = foldReading === undefined ? '' : ` + ${FOLDS_FILE} (${folding} of ${foldReading.meshes.length} mesh(es) fold)`;
660
- const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}`;
812
+ const passing = passReading?.pairs.filter((p) => p.frames.length > 0).length ?? 0;
813
+ const passNote = passReading === undefined ? '' : ` + ${PASS_THROUGH_FILE} (${passing} of ${passReading.pairs.length} pair(s) pass through)`;
814
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}`;
661
815
  console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
662
816
  for (const mesh of foldReading?.meshes ?? []) {
663
817
  const key = `${mesh.slot}\u0000${mesh.attachment}`;
@@ -667,6 +821,10 @@ export function cmdRender(flags: Record<string, string>): void {
667
821
  const line = foldLine(foldReading?.animation ?? null, fps, mesh);
668
822
  if (line !== null) console.log(` .. folds ${line}`);
669
823
  }
824
+ for (const pair of passReading?.pairs ?? []) {
825
+ console.log(` .. pass ${passSummaryLine(passReading?.animation ?? null, fps, pair)}`);
826
+ for (const frame of pair.frames) console.log(` .. pass ${passFrameLine(passReading?.animation ?? null, pair, frame)}`);
827
+ }
670
828
  }
671
829
 
672
830
  // The sidecar is what makes this a frame SET rather than a pile of pictures:
@@ -682,6 +840,8 @@ export function cmdRender(flags: Record<string, string>): void {
682
840
  // Written only when a subset was asked for, for the same reason: a render of
683
841
  // every slot says nothing and stays the bytes it always was (issue #835).
684
842
  ...subsetFields(subset),
843
+ // Written only when a schedule was given (issue #1416): a render without one says nothing and stays the bytes it was.
844
+ ...(environment === undefined ? {} : { environment: environment.steps }),
685
845
  background: BACKGROUND,
686
846
  // The one spelling `geometry.json` repeats, so the two files cannot state
687
847
  // two boxes for one grid.