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.
@@ -32,6 +32,7 @@ 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 { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
35
36
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
36
37
  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
38
  import { type CompileResult } from '../types.ts';
@@ -472,6 +473,72 @@ function readPositiveNumber(flags: Record<string, string>, key: string, fallback
472
473
  return value;
473
474
  }
474
475
 
476
+ /**
477
+ * `--environment <schedule.json>` read (issue #1416, `../environment.ts`), or
478
+ * refused as a usage error before anything is read off the candidate: a path
479
+ * with nothing at it, a file that is not a schedule, and every malformed entry,
480
+ * by its index, its field, the value found and what is required.
481
+ */
482
+ function readEnvironmentFlag(flags: Record<string, string>): { path: string; steps: EnvironmentStep[] } | undefined {
483
+ const given = flags.environment;
484
+ if (given === undefined) return undefined;
485
+ const path = resolve(given);
486
+ if (!existsSync(path) || !statSync(path).isFile()) throw new UsageError(`--environment ${given}: nothing at ${path} — it takes a schedule file { "spec": "rigc-environment/1", "steps": [...] }`);
487
+ try {
488
+ return { path, steps: readEnvironmentSchedule(readFileSync(path, 'utf8'), given) };
489
+ } catch (err) {
490
+ if (err instanceof EnvironmentError) throw new UsageError(err.message);
491
+ throw err;
492
+ }
493
+ }
494
+
495
+ /**
496
+ * The refusals of a well-formed schedule that this candidate, at this rate,
497
+ * would quietly not draw (issue #1416) — each one a render that would print a
498
+ * plausible frame set showing nothing the schedule asked for:
499
+ *
500
+ * - a skeleton with no animation, whose one frame is the setup pose, where no
501
+ * physics step runs;
502
+ * - a half the schedule changes that no physics constraint answers — none
503
+ * states it non-zero and no animation keys it — so every frame would draw as
504
+ * without the flag;
505
+ * - two steps on one frame of the render's grid, the first never posed;
506
+ * - a step after the last frame of every set drawn.
507
+ *
508
+ * `durations` are the poser's, of the sets this run draws.
509
+ */
510
+ function environmentRefusal(given: string, steps: readonly EnvironmentStep[], skeletonText: string, fps: number, durations: ReadonlyArray<{ name: string; duration: number }>): string | null {
511
+ 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`;
512
+ const response = environmentResponse(skeletonText);
513
+ if (response.declared.length === 0) return `--environment ${given}: the skeleton declares no physics constraint, so no environment moves anything in it`;
514
+ for (const half of ['wind', 'gravity'] as const) {
515
+ const changing = steps.map((step, i) => (step[half] === undefined ? -1 : i)).filter((i) => i >= 0);
516
+ if (changing.length > 0 && response[half].length === 0) {
517
+ return (
518
+ `--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 — ` +
519
+ `so every frame would draw as it does without the schedule. A constraint's own ${half} is its response (AUTHORING §4.6)`
520
+ );
521
+ }
522
+ }
523
+ const collision = environmentCollisions(steps, fps)[0];
524
+ if (collision !== undefined) {
525
+ return (
526
+ `--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}, ` +
527
+ `t=${(collision.frame / fps).toFixed(4)}s — so entry ${collision.entries[0]} would never be posed. Raise --fps, or merge them`
528
+ );
529
+ }
530
+ const lastFrames = durations.map(({ name, duration }) => ({ name, last: Math.round(duration * fps) }));
531
+ const longest = lastFrames.reduce((a, b) => (b.last > a.last ? b : a));
532
+ const late = steps.findIndex((step) => environmentFrame(step.t, fps) > longest.last);
533
+ if (late >= 0) {
534
+ return (
535
+ `--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}, ` +
536
+ `t=${(longest.last / fps).toFixed(4)}s at ${fps} fps — so it would change nothing`
537
+ );
538
+ }
539
+ return null;
540
+ }
541
+
475
542
  /**
476
543
  * render — the frame series, drawn by the same rasteriser `check` measures with.
477
544
  *
@@ -487,6 +554,8 @@ export function cmdRender(flags: Record<string, string>): void {
487
554
  const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
488
555
  const atlasDir = dirname(atlasPath);
489
556
  const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
557
+ // Read before anything is read off the candidate, so a malformed schedule is refused before a line is printed.
558
+ const environment = readEnvironmentFlag(flags);
490
559
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
491
560
  const outRoot = resolve(flags.out ?? 'render');
492
561
  const geometry = flags.geometry !== undefined;
@@ -532,8 +601,9 @@ export function cmdRender(flags: Record<string, string>): void {
532
601
  // core poses — `skeleton.model.json` beside the pair — reads its names, its
533
602
  // subset roster and its pages without loading spine-core at all, and with a
534
603
  // rigc-compiled/2 document without its atlas either (issue #1020).
604
+ const skeletonText = readSkeletonText(skeletonPath);
535
605
  const { choice, facts, pages } = loadCandidate(
536
- { skeletonText: readSkeletonText(skeletonPath), atlasText, atlasDir, label: skeletonPath },
606
+ { skeletonText, atlasText, atlasDir, label: skeletonPath },
537
607
  { skeleton: skeletonPath, atlas: atlasPath },
538
608
  posersAsked(flags),
539
609
  );
@@ -546,12 +616,17 @@ export function cmdRender(flags: Record<string, string>): void {
546
616
  // `src/render.ts` has one of its own. The inferred shape is the render one.
547
617
  // The subset rides on the same object and `framingViewport` takes it off, so
548
618
  // the frames draw the subset and the box is still the whole rig's.
619
+ // The environment rides on the same object for the same reason (issue #1416): the framing reads it, so the box holds
620
+ // what the frames under it draw.
549
621
  const pose =
550
- skin === undefined && subset === undefined
622
+ skin === undefined && subset === undefined && environment === undefined
551
623
  ? undefined
552
- : { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset) };
624
+ : { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset), ...(environment === undefined ? {} : { environment: environment.steps }) };
553
625
  if (skin !== undefined) console.log(` .. skin ${skin}`);
554
626
  if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
627
+ if (environment !== undefined && facts.animations.length === 0) {
628
+ throw new UsageError(environmentRefusal(flags.environment, environment.steps, skeletonText, fps, []) ?? '');
629
+ }
555
630
 
556
631
  // Rigc's own core when the candidate is a rigc build, and spine-core
557
632
  // otherwise, or where the core refuses the input by name. Never silently:
@@ -565,6 +640,13 @@ export function cmdRender(flags: Record<string, string>): void {
565
640
  // re-poses the whole input on spine-core rather than leaving half of a frame
566
641
  // set drawn by each.
567
642
  const posed = throughPoser(choice, (poser, roster) => {
643
+ // A schedule this candidate would not draw is refused before anything is posed, off the durations of the poser that
644
+ // poses it (issue #1416) — a fallback re-runs this with the other poser's, which the core holds equal.
645
+ if (environment !== undefined) {
646
+ const drawn = poser.animations.filter((a) => only === undefined || a.name === only);
647
+ const refusal = environmentRefusal(flags.environment, environment.steps, skeletonText, fps, drawn);
648
+ if (refusal !== null) throw new UsageError(refusal);
649
+ }
568
650
  // `null` is a skeleton that posed no vertex at all. One that posed a vertex
569
651
  // it cannot frame — Infinity or NaN — is thrown from the framing as a
570
652
  // `GeometryError` naming the number (issue #873), and one whose every vertex
@@ -598,7 +680,7 @@ export function cmdRender(flags: Record<string, string>): void {
598
680
  if (geometry) {
599
681
  for (const [name, frames] of sampled) {
600
682
  const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
601
- geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
683
+ geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps)));
602
684
  }
603
685
  }
604
686
  // The fold reading, off the very attachments the frames were drawn from, against the rest table the geometry
@@ -616,6 +698,13 @@ export function cmdRender(flags: Record<string, string>): void {
616
698
  });
617
699
  const { viewport, sampled, geometryTexts, foldReadings } = posed.value;
618
700
  console.log(` .. poser ${posed.note}`);
701
+ if (environment !== undefined) {
702
+ const response = environmentResponse(skeletonText);
703
+ console.log(
704
+ ` .. 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) — ` +
705
+ `wind answered by ${response.wind.join(', ') || 'none'}; gravity by ${response.gravity.join(', ') || 'none'}`,
706
+ );
707
+ }
619
708
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
620
709
  if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
621
710
 
@@ -682,6 +771,8 @@ export function cmdRender(flags: Record<string, string>): void {
682
771
  // Written only when a subset was asked for, for the same reason: a render of
683
772
  // every slot says nothing and stays the bytes it always was (issue #835).
684
773
  ...subsetFields(subset),
774
+ // Written only when a schedule was given (issue #1416): a render without one says nothing and stays the bytes it was.
775
+ ...(environment === undefined ? {} : { environment: environment.steps }),
685
776
  background: BACKGROUND,
686
777
  // The one spelling `geometry.json` repeats, so the two files cannot state
687
778
  // two boxes for one grid.
package/src/cli/shared.ts CHANGED
@@ -458,6 +458,11 @@ export function meshInfluenceNote(m: CompileResult['meshes'][number]): string {
458
458
  `\n influence max ${inf.maxBones} bone(s) per vertex, mean ${inf.meanBones.toFixed(2)}, ` +
459
459
  `${inf.singleBone} of ${m.vertices} vertices (${single.toFixed(2)}%) on a single bone` +
460
460
  (unbound.length ? `; named and bound by no vertex: ${unbound.join(', ')}` : '') +
461
+ (inf.reach === undefined
462
+ ? ''
463
+ : `\n reach radius ${inf.reach.radius}px, ${inf.reach.shape}, ` +
464
+ `${inf.reach.across ? 'across entries' : 'nearest entry only'}, max ${inf.reach.max}: ` +
465
+ `${inf.reach.capped} of ${m.vertices} vertices had more links in reach than max (widest ${inf.reach.widest})`) +
461
466
  `\n lattice cell ${inf.cell}px, ${inf.cols}x${inf.rows} cells, ${inf.artCells} with art, ${inf.keptCells} kept` +
462
467
  (inf.islands > 1 || joined > 0
463
468
  ? ` (${inf.islands} island(s) joined into one outline, ${joined} cell(s) added that hold no art)`
@@ -1008,6 +1013,13 @@ const FLAG_MEANINGS: Record<string, string> = {
1008
1013
  '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). ' +
1009
1014
  'One line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. ' +
1010
1015
  'A reading, not a gate. Not with --slot/--hide: it reads every mesh the pose shows',
1016
+ environment:
1017
+ 'the world the rig\'s physics answers, in time: a file { "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, ' +
1018
+ '"wind"?: [x, y], "gravity"?: [x, y] }, …] }, x right and y down, each step holding from its t until the next and set ' +
1019
+ 'before the step that poses the first frame at or after it, on either poser. Before the first step the world is the ' +
1020
+ 'runtime\'s, wind [1, 0] and gravity [0, 1]. frames.json and geometry.json record it, and the framing reads it. A ' +
1021
+ 'malformed file, a half no physics constraint answers, two steps on one frame and a step after every set\'s last frame ' +
1022
+ 'are refused by name. An inspection of what the rig offers, not a scene',
1011
1023
  poser:
1012
1024
  "`render`, `inspect` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
1013
1025
  'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
@@ -1189,6 +1201,7 @@ const FLAG_VALUES: Record<string, string> = {
1189
1201
  'pack-shape': 'rect|polygon',
1190
1202
  'texture-from': '<path>',
1191
1203
  poser: 'core|spine',
1204
+ environment: '<schedule.json>',
1192
1205
  reference: '<dir|skeleton.json>',
1193
1206
  'reference-atlas': '<path>',
1194
1207
  bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
@@ -1594,8 +1607,9 @@ export const COMMANDS: CommandDoc[] = [
1594
1607
  usage: [
1595
1608
  'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
1596
1609
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1610
+ 'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
1597
1611
  ],
1598
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'poser', 'out'],
1612
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'environment', 'poser', 'out'],
1599
1613
  overrides: {
1600
1614
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1601
1615
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
package/src/compile.ts CHANGED
@@ -63,6 +63,8 @@ import {
63
63
  type RigEvent,
64
64
  type RigLinkedMeshAttachment,
65
65
  type RigMeshAttachment,
66
+ type RigSegmentsFalloff,
67
+ RIG_INFLUENCE_SHAPES,
66
68
  type RigMeshBinding,
67
69
  type RigPathAttachment,
68
70
  type RigRegionAttachment,
@@ -87,9 +89,12 @@ import {
87
89
  ringControlAngles,
88
90
  buildSegmentsLattice,
89
91
  segmentShares,
92
+ influenceShares,
90
93
  traceOutline,
91
94
  type MeshBoneRef,
92
- type WorldSegment,
95
+ type EntrySegment,
96
+ type SegmentsInfluence,
97
+ type SegmentsFalloff,
93
98
  type MeshFitReport,
94
99
  type MeshGeometry,
95
100
  type MeshVertexWeight,
@@ -6502,6 +6507,95 @@ function finitePair(v: unknown): [number, number] | null {
6502
6507
  : null;
6503
6508
  }
6504
6509
 
6510
+ /** The `falloff` block's four numbers, each refused by name when it cannot be one. */
6511
+ function segmentsFalloffOf(f: RigSegmentsFalloff, where: string): SegmentsFalloff {
6512
+ if (typeof f.radius !== 'number' || !Number.isFinite(f.radius) || !(f.radius > 0)) {
6513
+ throw new CompileError(
6514
+ `${where}: "falloff.radius" is ${JSON.stringify(f.radius)}; it is added to every distance before the power is ` +
6515
+ "taken, a positive number of the part's pixels — at 0 a vertex on a segment would carry an infinite pull",
6516
+ );
6517
+ }
6518
+ const power = f.power ?? SEGMENTS_DEFAULTS.power;
6519
+ if (typeof power !== 'number' || !Number.isFinite(power) || !(power > 0)) {
6520
+ throw new CompileError(
6521
+ `${where}: "falloff.power" is ${JSON.stringify(power)}; it is the exponent a pull falls off with distance by, ` +
6522
+ 'a positive number — at 0 or below the far segment pulls as hard as the near one, or harder',
6523
+ );
6524
+ }
6525
+ const maxBones = f.maxBones ?? SEGMENTS_DEFAULTS.maxBones;
6526
+ if (typeof maxBones !== 'number' || !Number.isInteger(maxBones) || maxBones < 1) {
6527
+ throw new CompileError(
6528
+ `${where}: "falloff.maxBones" is ${JSON.stringify(maxBones)}; it is how many bones may pull one vertex, a ` +
6529
+ 'whole number of at least 1',
6530
+ );
6531
+ }
6532
+ const minWeight = f.minWeight ?? SEGMENTS_DEFAULTS.minWeight;
6533
+ if (typeof minWeight !== 'number' || !Number.isFinite(minWeight) || minWeight < SEGMENTS_WEIGHT_STEP || minWeight >= 1) {
6534
+ throw new CompileError(
6535
+ `${where}: "falloff.minWeight" is ${JSON.stringify(minWeight)}; it is the share under which a bone's pull ` +
6536
+ `is dropped, so it lies in ${SEGMENTS_WEIGHT_STEP}..1 with 1 excluded — at 1 or above every share is dropped, ` +
6537
+ `and under ${SEGMENTS_WEIGHT_STEP}, one step of the 6-decimal grid weights are written on, a kept binding ` +
6538
+ 'could be written as 0',
6539
+ );
6540
+ }
6541
+ return { power, radius: f.radius, maxBones, minWeight };
6542
+ }
6543
+
6544
+ /**
6545
+ * The `influence` block's four fields (issue #1415), each refused by name: none
6546
+ * has a default, because the reach and the cap are judgements about this
6547
+ * cloth and the shape decides how a link enters a vertex's blend. Stated
6548
+ * beside `falloff` it is refused too — the two are two models of one share.
6549
+ */
6550
+ function segmentsInfluenceOf(
6551
+ generator: Extract<NonNullable<RigMeshAttachment['generator']>, { kind: 'segments' }>,
6552
+ where: string,
6553
+ ): SegmentsInfluence {
6554
+ const spec: unknown = generator.influence;
6555
+ if (generator.falloff !== undefined) {
6556
+ throw new CompileError(
6557
+ `${where}: the "segments" generator states both "falloff" and "influence". Both turn a distance into a ` +
6558
+ 'share — "falloff" as 1 / (d + radius)^power over every segment, "influence" as a pull that reaches zero at ' +
6559
+ 'its "radius" — and a mesh is weighted by one of them: remove the other',
6560
+ );
6561
+ }
6562
+ if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) {
6563
+ throw new CompileError(
6564
+ `${where}: "influence" is ${JSON.stringify(spec)}; it is { "radius", "across", "max", "shape" }, every one required`,
6565
+ );
6566
+ }
6567
+ const i = spec as Partial<Record<keyof SegmentsInfluence, unknown>>;
6568
+ if (typeof i.radius !== 'number' || !Number.isFinite(i.radius) || !(i.radius > 0)) {
6569
+ throw new CompileError(
6570
+ `${where}: "influence.radius" is ${JSON.stringify(i.radius) ?? 'absent'}; it is how far a link reaches, in ` +
6571
+ "the part's pixels, a positive number with no default — a length on this art: a link whose segment is that " +
6572
+ 'far from a vertex or farther never pulls it',
6573
+ );
6574
+ }
6575
+ if (typeof i.across !== 'boolean') {
6576
+ throw new CompileError(
6577
+ `${where}: "influence.across" is ${JSON.stringify(i.across) ?? 'absent'}; it is true (every link within ` +
6578
+ '"radius" pulls, across the entries of "bones") or false (only the links of the entry nearest the vertex), ' +
6579
+ "with no default — whether a chain may pull its neighbour's cloth is the decision this field is",
6580
+ );
6581
+ }
6582
+ if (typeof i.max !== 'number' || !Number.isInteger(i.max) || i.max < 1) {
6583
+ throw new CompileError(
6584
+ `${where}: "influence.max" is ${JSON.stringify(i.max) ?? 'absent'}; it is how many links may pull one vertex, ` +
6585
+ 'the strongest kept, a whole number of at least 1 with no default',
6586
+ );
6587
+ }
6588
+ const shape = RIG_INFLUENCE_SHAPES.find((name) => name === i.shape);
6589
+ if (shape === undefined) {
6590
+ throw new CompileError(
6591
+ `${where}: "influence.shape" is ${JSON.stringify(i.shape) ?? 'absent'}; one of ` +
6592
+ `${RIG_INFLUENCE_SHAPES.map((name) => JSON.stringify(name)).join(', ')} — how a pull falls from 1 on a link ` +
6593
+ 'to 0 at "radius", with no default: "linear" is 1 - d/radius, "smooth" arrives at both ends with zero slope',
6594
+ );
6595
+ }
6596
+ return { radius: i.radius, across: i.across, max: i.max, shape };
6597
+ }
6598
+
6505
6599
  /**
6506
6600
  * Build a `segments` mesh: a lattice over the part's alpha, weighted by distance
6507
6601
  * to the bone segments `bones` names.
@@ -6550,45 +6644,18 @@ function buildSegmentsAttachment(
6550
6644
  );
6551
6645
  }
6552
6646
 
6553
- // -- the falloff -----------------------------------------------------------
6647
+ // -- the influence (issue #1415), or else the falloff ----------------------
6648
+ const influence = generator.influence === undefined ? null : segmentsInfluenceOf(generator, where);
6554
6649
  const falloffSpec: unknown = generator.falloff;
6555
- if (typeof falloffSpec !== 'object' || falloffSpec === null || Array.isArray(falloffSpec)) {
6650
+ if (influence === null && (typeof falloffSpec !== 'object' || falloffSpec === null || Array.isArray(falloffSpec))) {
6556
6651
  throw new CompileError(
6557
6652
  `${where}: the "segments" generator states no "falloff" block. Its "radius" — added to every distance, in ` +
6558
6653
  "the part's pixels — has no default, because it is a length on this art: " +
6559
- '"falloff": { "radius": <pixels> } is the least it takes',
6560
- );
6561
- }
6562
- const f = generator.falloff;
6563
- if (typeof f.radius !== 'number' || !Number.isFinite(f.radius) || !(f.radius > 0)) {
6564
- throw new CompileError(
6565
- `${where}: "falloff.radius" is ${JSON.stringify(f.radius)}; it is added to every distance before the power is ` +
6566
- "taken, a positive number of the part's pixels — at 0 a vertex on a segment would carry an infinite pull",
6567
- );
6568
- }
6569
- const power = f.power ?? SEGMENTS_DEFAULTS.power;
6570
- if (typeof power !== 'number' || !Number.isFinite(power) || !(power > 0)) {
6571
- throw new CompileError(
6572
- `${where}: "falloff.power" is ${JSON.stringify(power)}; it is the exponent a pull falls off with distance by, ` +
6573
- 'a positive number — at 0 or below the far segment pulls as hard as the near one, or harder',
6574
- );
6575
- }
6576
- const maxBones = f.maxBones ?? SEGMENTS_DEFAULTS.maxBones;
6577
- if (typeof maxBones !== 'number' || !Number.isInteger(maxBones) || maxBones < 1) {
6578
- throw new CompileError(
6579
- `${where}: "falloff.maxBones" is ${JSON.stringify(maxBones)}; it is how many bones may pull one vertex, a ` +
6580
- 'whole number of at least 1',
6581
- );
6582
- }
6583
- const minWeight = f.minWeight ?? SEGMENTS_DEFAULTS.minWeight;
6584
- if (typeof minWeight !== 'number' || !Number.isFinite(minWeight) || minWeight < SEGMENTS_WEIGHT_STEP || minWeight >= 1) {
6585
- throw new CompileError(
6586
- `${where}: "falloff.minWeight" is ${JSON.stringify(minWeight)}; it is the share under which a bone's pull ` +
6587
- `is dropped, so it lies in ${SEGMENTS_WEIGHT_STEP}..1 with 1 excluded — at 1 or above every share is dropped, ` +
6588
- `and under ${SEGMENTS_WEIGHT_STEP}, one step of the 6-decimal grid weights are written on, a kept binding ` +
6589
- 'could be written as 0',
6654
+ '"falloff": { "radius": <pixels> } is the least it takes (or an "influence" block, whose pull reaches zero ' +
6655
+ 'at its own "radius")',
6590
6656
  );
6591
6657
  }
6658
+ const falloff = influence === null ? segmentsFalloffOf(generator.falloff!, where) : null;
6592
6659
  const threshold = generator.alpha ?? SEGMENTS_DEFAULTS.alpha;
6593
6660
 
6594
6661
  // -- the lattice -----------------------------------------------------------
@@ -6650,7 +6717,7 @@ function buildSegmentsAttachment(
6650
6717
  const parentOf = new Map(ctx.bones.map((b) => [b.name, b.parent ?? null]));
6651
6718
  const named: string[] = [];
6652
6719
  const refs: MeshBoneRef[] = [];
6653
- const segments: WorldSegment[] = [];
6720
+ const segments: EntrySegment[] = [];
6654
6721
  const boneOf = (name: unknown, at: string): { index: number; transform: BoneTransform } => {
6655
6722
  if (typeof name !== 'string' || !parentOf.has(name)) {
6656
6723
  throw new CompileError(`${where}: ${at} names mesh bone ${JSON.stringify(name)}, which is not in the rig's bone list`);
@@ -6680,7 +6747,7 @@ function buildSegmentsAttachment(
6680
6747
  const at = `"bones"[${i}]`;
6681
6748
  if (typeof entry === 'string') {
6682
6749
  const { index, transform } = boneOf(entry, at);
6683
- segments.push({ bone: index, a: [transform.worldX, transform.worldY], b: tipOf(entry, transform, at, false) });
6750
+ segments.push({ bone: index, entry: i, a: [transform.worldX, transform.worldY], b: tipOf(entry, transform, at, false) });
6684
6751
  return;
6685
6752
  }
6686
6753
  if (Array.isArray(entry)) {
@@ -6692,7 +6759,7 @@ function buildSegmentsAttachment(
6692
6759
  const name = link.name as string;
6693
6760
  const origin: [number, number] = [link.transform.worldX, link.transform.worldY];
6694
6761
  if (k === links.length - 1) {
6695
- segments.push({ bone: link.index, a: origin, b: tipOf(name, link.transform, `${at}[${k}]`, true) });
6762
+ segments.push({ bone: link.index, entry: i, a: origin, b: tipOf(name, link.transform, `${at}[${k}]`, true) });
6696
6763
  return;
6697
6764
  }
6698
6765
  const next = links[k + 1];
@@ -6714,7 +6781,7 @@ function buildSegmentsAttachment(
6714
6781
  `"${nextName}" its own position, or name "${name}" alone with a "length"`,
6715
6782
  );
6716
6783
  }
6717
- segments.push({ bone: link.index, a: origin, b });
6784
+ segments.push({ bone: link.index, entry: i, a: origin, b });
6718
6785
  });
6719
6786
  return;
6720
6787
  }
@@ -6729,7 +6796,7 @@ function buildSegmentsAttachment(
6729
6796
  `${JSON.stringify(from === null ? span.from : span.to)}; both ends are [x, y] in the part's pixels, y down`,
6730
6797
  );
6731
6798
  }
6732
- segments.push({ bone: index, a: measuredAt(from[0], from[1]), b: measuredAt(to[0], to[1]) });
6799
+ segments.push({ bone: index, entry: i, a: measuredAt(from[0], from[1]), b: measuredAt(to[0], to[1]) });
6733
6800
  return;
6734
6801
  }
6735
6802
  throw new CompileError(
@@ -6739,10 +6806,27 @@ function buildSegmentsAttachment(
6739
6806
  });
6740
6807
 
6741
6808
  // -- the weights -----------------------------------------------------------
6742
- const falloff = { power, radius: f.radius, maxBones, minWeight };
6809
+ let capped = 0;
6810
+ let widest = 0;
6743
6811
  const weights: MeshVertexWeight[][] = lattice.points.map(([px, py], v) => {
6744
- const got = segmentShares(measuredAt(px, py), segments, falloff);
6812
+ if (influence !== null) {
6813
+ const reached = influenceShares(measuredAt(px, py), segments, influence);
6814
+ if ('weights' in reached) {
6815
+ if (reached.inReach > influence.max) capped++;
6816
+ widest = Math.max(widest, reached.inReach);
6817
+ return reached.weights;
6818
+ }
6819
+ throw new CompileError(
6820
+ `${where}: vertex ${v}, at (${px}, ${py}) in the part's pixels, is reached by no link — the nearest is ` +
6821
+ `"${named[reached.nearest.bone]}" at ${reached.nearest.distance.toFixed(4)}, and "influence.radius" is ` +
6822
+ `${influence.radius}${influence.across ? '' : ' (with "across": false, the nearest entry\'s links only)'}. ` +
6823
+ 'A vertex needs a link, and rigc will not hand it one the reach does not: raise "radius" over that distance, ' +
6824
+ 'or name a segment nearer this part of the art',
6825
+ );
6826
+ }
6827
+ const got = segmentShares(measuredAt(px, py), segments, falloff!);
6745
6828
  if ('weights' in got) return got.weights;
6829
+ const { maxBones, minWeight } = falloff!;
6746
6830
  throw new CompileError(
6747
6831
  `${where}: vertex ${v}, at (${px}, ${py}) in the part's pixels, keeps no bone — the ${got.dropped.length} ` +
6748
6832
  `strongest pull(s) share it as ${got.dropped.map((d) => `"${named[d.bone]}" ${d.share.toFixed(4)}`).join(', ')}, ` +
@@ -6803,6 +6887,7 @@ function buildSegmentsAttachment(
6803
6887
  artCells: lattice.report.artCells,
6804
6888
  keptCells: lattice.report.keptCells,
6805
6889
  islands: lattice.report.islands,
6890
+ ...(influence === null ? {} : { reach: { ...influence, capped, widest } }),
6806
6891
  },
6807
6892
  });
6808
6893
  return {
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
  /**