spine-rigc 2.51.1 → 2.53.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/docs/AUTHORING.md CHANGED
@@ -3119,7 +3119,7 @@ beside it say how a distance becomes a share.
3119
3119
  | --- | --- |
3120
3120
  | `cell` | **required.** The lattice's square, in the part's pixels — a whole number of at least 1. No default: it is a length on this art, and how fine a mesh the part needs is not something rigc can read off it |
3121
3121
  | `bones` | **required, at least one entry.** Which segments may pull the part. An entry is one of three forms, below |
3122
- | `falloff` | **required**, because `radius` is: `{ "power"?, "radius", "maxBones"?, "minWeight"? }` |
3122
+ | `falloff` | **required unless `influence` is stated**, because `radius` is: `{ "power"?, "radius", "maxBones"?, "minWeight"? }`. Stated beside `influence`, refused — the two are two models of one share |
3123
3123
  | `radius` | **required.** Added to every distance before the power is taken, in the part's pixels, so a vertex ON a segment has a finite pull and the blend between two segments is about this wide. Positive |
3124
3124
  | `power` | the exponent a pull falls off by: `w = 1 / (d + radius)^power`. Positive. Default `2` |
3125
3125
  | `maxBones` | at most this many bones pull one vertex — the strongest ones. A whole number of at least 1. Default `4` |
@@ -3127,6 +3127,8 @@ beside it say how a distance becomes a share.
3127
3127
  | `alpha` | the alpha at or above which a pixel counts as art, `1`..`255`. Default `1` — `contour`'s own |
3128
3128
  | `anchor` | where the **slot bone** sits in the part's pixels, `[x, y]`, y down. Default the window's centre — the placement every generator on this route uses (the ⭐ above). State it when the art is not drawn centred on its bone: unlike a pinned generator, this one's weights depend on where the art is relative to the bones |
3129
3129
 
3130
+ | `influence` | instead of `falloff`: a pull that **reaches zero** at its own `radius` — `{ "radius", "across", "max", "shape" }`, every field required. Below |
3131
+
3130
3132
  **The three forms of a `bones` entry**, each resolved by name against the rig:
3131
3133
 
3132
3134
  | Entry | Its segment(s) |
@@ -3154,6 +3156,70 @@ of each segment, the strongest `maxBones` kept and normalised, any share under
3154
3156
  `minWeight` dropped, the rest normalised again — so every vertex closes at 1 by
3155
3157
  construction. The emitted mesh is a weighted one, bound by name.
3156
3158
 
3159
+ ##### `influence` — a vertex follows every link near it (cloth)
3160
+
3161
+ `falloff`'s pull, `1 / (d + radius)^power`, is positive at **every** distance: a
3162
+ wider blend needs a larger `radius`, which flattens the blend everywhere at once,
3163
+ and a blend only ends where `minWeight` drops a share. A cloth surface wants the
3164
+ other model — each vertex blends the springs of the chains around it, so the
3165
+ sheet bends as one surface rather than as strips — and that needs a pull that
3166
+ falls to **exactly zero** at a stated distance. `influence` is that model.
3167
+
3168
+ ```json
3169
+ "generator": {
3170
+ "kind": "segments",
3171
+ "cell": 8,
3172
+ "bones": [["skirt_0_0", "skirt_0_1", "skirt_0_2"], ["skirt_1_0", "skirt_1_1", "skirt_1_2"]],
3173
+ "influence": { "radius": 40, "across": true, "max": 6, "shape": "smooth" }
3174
+ }
3175
+ ```
3176
+
3177
+ | Field | Meaning |
3178
+ | --- | --- |
3179
+ | `radius` | **required.** How far a link reaches, in the part's pixels (on this route, the skeleton's units): a link whose segment is `radius` or farther from a vertex never pulls it. Positive. No default — a length on this art |
3180
+ | `across` | **required.** `true`: every link of every `bones` entry within `radius` pulls. `false`: only the links of **one** entry — the entry holding the link nearest the vertex, the earlier entry on a tie — so a chain moves its own strip and nothing across. No default: whether a chain may pull its neighbour's cloth is the decision |
3181
+ | `max` | **required.** At most this many links pull one vertex — the strongest; equal pulls keep the order `bones` first names their bones. A whole number of at least 1 |
3182
+ | `shape` | **required.** How a pull falls from 1 at `d = 0` to 0 at `d = radius`, with `x = d / radius`: `"linear"` is `1 - x`; `"smooth"` is `1 - x²(3 - 2x)`, which also arrives at both ends with zero slope, so a link enters and leaves a vertex's blend without a crease |
3183
+
3184
+ Per vertex, in order: `d` is the distance to each link's **segment** — its
3185
+ nearest point, the reading `falloff` takes — at the setup pose; with `across:
3186
+ false` only the nearest entry's links stay; each link with `d < radius` pulls
3187
+ `shape(d / radius)` (a bone two segments name keeps the nearer, and a link whose
3188
+ pull is not above 0 in doubles — a hair inside the reach — pulls nothing); the
3189
+ strongest `max` are kept and normalised; a share the 6-decimal grid would write
3190
+ as 0 is dropped and the rest normalised again; the strongest closes the vertex
3191
+ at exactly 1. **The distance is to the segment, not to the bone's origin**,
3192
+ because a vertex on the middle of a link *is* on that link: an origin distance
3193
+ would put it half a link away, and hand the joint at the link's tip to the child
3194
+ whose origin sits there.
3195
+
3196
+ 🔸 **One bone order by construction.** Every vertex lists its bones in the order
3197
+ `bones` names them, so the attachment has one order and
3198
+ `A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER` holds without the compiler's
3199
+ ascending rewrite (§5.2) moving a byte — the order in the file is the spec's.
3200
+
3201
+ `build` prints a `reach` line under the `influence` line: the block as stated,
3202
+ how many vertices had **more links in reach than `max`** — the vertices the cap
3203
+ dropped a link from — and the most links in reach at one vertex (`widest`),
3204
+ which is the `max` that would drop none:
3205
+
3206
+ ```
3207
+ influence max 6 bone(s) per vertex, mean 4.87, 0 of 450 vertices (0.00%) on a single bone
3208
+ reach radius 40px, smooth, across entries, max 6: 146 of 450 vertices had more links in reach than max (widest 10)
3209
+ ```
3210
+
3211
+ ⚠️ **What a tight cap costs, measured.** On the selftest's `skirt_probe`
3212
+ (`fixtures/public.ts`: six chains of five links, a physics constraint on every
3213
+ link, the hip swayed) with `radius: 40`, `rotate` physics on and `render --folds`
3214
+ at 30 fps, `max: 2` turned 106 of the 816 triangles over on its worst frame and
3215
+ `max: 6` turned 4; `max: 6` with `radius: 20` (no vertex capped) turned 105, and
3216
+ `across: false` 91. With the physics removed the same sway folds none at either
3217
+ cap. So on that piece the folds followed how many neighbouring links a vertex
3218
+ blends — a narrow reach and a tight cap over a wide reach both fold — and a
3219
+ wide reach with a cap that drops few links folded least. One piece and one
3220
+ sway: it says what to look at with `render --folds` on your own, not what `max`
3221
+ your cloth needs.
3222
+
3157
3223
  `build` and `explain` print what it measured, under the mesh's line. This is
3158
3224
  the selftest's own `segments_probe` (`fixtures/public.ts` in the repository) —
3159
3225
  one 96x64 plate of two islands under two two-link chains, `cell: 8`,
@@ -3199,6 +3265,9 @@ rows.
3199
3265
  | a chain link at its next link's origin | `… the two bones share their setup origin (…), so the link has no length and no direction to weight along` |
3200
3266
  | an empty chain, a span with a malformed end, an entry of none of the three forms | `is an empty chain`; `both ends are [x, y] in the part's pixels`; `an entry is a bone name, a chain as a list of bone names, or { "bone", "from", "to" }` |
3201
3267
  | no `falloff`, a `radius` or `power` at or below 0, a malformed `anchor`, a `cell` under 1, no `image` | each named by its field — `radius` has no default because it is a length on this art |
3268
+ | both `falloff` and `influence` | `the "segments" generator states both "falloff" and "influence" … a mesh is weighted by one of them: remove the other` |
3269
+ | an `influence` field absent or out of range — `radius` at or below 0, `across` not a boolean, `max` not a whole number of at least 1, `shape` not `"linear"` or `"smooth"` | each named with the value found and the one required: `"influence.max" is 0; it is how many links may pull one vertex …`, `"influence.shape" is "cubic"; one of "linear", "smooth" …` |
3270
+ | a vertex no link reaches under `influence` | `vertex 0, at (0, 0) in the part's pixels, is reached by no link — the nearest is "skirt_0_0" at 17.8885, and "influence.radius" is 4` — **refused, not handed the nearest link**: raise `radius` over that distance, or name a segment nearer that part of the art |
3202
3271
  | a part on a packed page that declares a `scale:` | refused: `cell` and `radius` are the part's pixels and this generator does not convert them to a page's texels. Build from the loose part, or a pack at scale 1 |
3203
3272
 
3204
3273
  #### 3.4.8 A skin that switches bones and constraints on
@@ -4953,6 +5022,41 @@ agreeing with `render` — and left out, a track steps the physics by the step i
4953
5022
  exactly as before. Which clock a player with a real clock uses is the player's
4954
5023
  question, and rigc does not answer it.
4955
5024
 
5025
+ 🌬️ **`wind` and `gravity` are the constraint's response; the world is the
5026
+ consumer's** (issue #1416). In 4.3 the two are not forces but how strongly the
5027
+ constraint answers two directions the *skeleton* carries — `Skeleton.windX,
5028
+ windY` and `gravityX, gravityY`, which start at `1, 0` and `0, 1` — so a
5029
+ constraint stating `wind: 20` is pushed along whatever direction the player
5030
+ sets, times 20 (and on `x` and `y` times `referenceScale`; on a rotation, at
5031
+ a lever of the bone's `length` over `referenceScale`). A live track of the
5032
+ core entry sets that direction with `setEnvironment({ wind: [x, y], gravity:
5033
+ [x, y] })` — x to the right, **y down**, either half left out to keep the one
5034
+ that stands — and the Spine export carries only the response, so an official
5035
+ runtime whose `Skeleton` is given the same four numbers poses what the core
5036
+ poses, to the bit (`CO56`). Two consequences an author has to know:
5037
+
5038
+ - **A constraint stating `wind: 0` feels no wind**, whatever the player sets.
5039
+ State a `wind` on every link that should answer a gust; leave it 0 on one
5040
+ that should not.
5041
+ - **Until the player sets one, `wind` pushes to the right.** The initial
5042
+ direction is the runtime's `[1, 0]`, not `[0, 0]`, so a rig stating `wind`
5043
+ leans at rest on every player that never calls `setEnvironment` — the
5044
+ official runtimes included. A player that wants calm at rest sets `wind:
5045
+ [0, 0]` and blows by setting it back. rigc does not change the initial
5046
+ value: that would make the export pose differently in the runtime than in
5047
+ the core.
5048
+
5049
+ Moving the rig is the consumer's too: `physicsTranslate(x, y)` and
5050
+ `physicsRotate(x, y, degrees)` on a live track are the runtime's methods of
5051
+ those names — the whole rig moved or turned since the last pose, in the
5052
+ skeleton's world (y up, degrees counter-clockwise) — and every constraint's
5053
+ next update sees its bone move by that much, so a rig walked across the screen
5054
+ trails its coat. A player forwards what spine-core's `SkeletonPhysicsMovement`
5055
+ forwards: its display object's change of position since the last frame in the
5056
+ skeleton's units and axes, and its change of rotation as `physicsRotate(0, 0,
5057
+ Δ)`, before the `step` that poses the frame. Nothing set, every pose is the
5058
+ one the track posed before these existed (`CO57`).
5059
+
4956
5060
  `mix` is a player-side `AnimationStateData` config and is **not** emitted into
4957
5061
  skeleton JSON, so the parser is the only check it gets: it is
4958
5062
  `{ "default": <seconds>, "pairs"?: [["<from>", "<to>", <seconds>], …] }`, and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.51.1",
3
+ "version": "2.53.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
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)`
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 {
@@ -189,6 +189,34 @@
189
189
  * reading is rejected as a schedule no player runs; the core suite's `SC`
190
190
  * controls plant it and turn the probe red on the steps after the key.
191
191
  *
192
+ * ## The environment (issue #1416)
193
+ *
194
+ * A constraint's `wind` and `gravity` are not forces: they are how strongly it
195
+ * answers two directions the SKELETON carries — spine-core 4.3.13's
196
+ * `Skeleton.windX, windY` and `gravityX, gravityY`, initially `1, 0` and `0,
197
+ * 1` (`DEFAULT_PHYSICS_ENVIRONMENT`). The step weighs them as the runtime
198
+ * does: for `x` and `y`, `ax = (wind·windX + gravity·gravityX)·R` and `ay =
199
+ * (wind·windY + gravity·gravityY)·R`, `velocity += (ax − offset·strength)·…`
200
+ * on `x` and `−= (ay + offset·strength)·…` on `y`; for the rotation and the
201
+ * scale the same two sums without `R`, `(ax·sin + ay·cos)·length/R` and `ax·cos
202
+ * − ay·sin` in place of the wind and gravity terms above. The skeleton's own
203
+ * scale, which the runtime multiplies the `x` and `y` sums by, is 1 here. So
204
+ * the environment's y points DOWN — `[0, 1]` pulls toward the bottom of the
205
+ * screen — and with the initial values every expression is the one this file
206
+ * computed before the environment existed: `wind·1 + gravity·0` is `wind` for
207
+ * every finite `gravity` (a `wind` of −0 aside, which it reads as +0, as the
208
+ * runtime does), and the render and emit bases did not move.
209
+ *
210
+ * The consumer sets it (`setEnvironment` on a live track, `./track.ts`), and
211
+ * moves the rig from outside with `translatePhysics` and `rotatePhysics` —
212
+ * `Skeleton.physicsTranslate` and `physicsRotate`, which shift what each
213
+ * constraint last saw (`ux, uy, cx, cy`) so its next update reads the bone as
214
+ * having moved. A constraint with no state yet is pending, and its first
215
+ * update overwrites both, so it is left alone, as the runtime's translation of
216
+ * it is read by nothing. The core suite's `CO56` holds all of it to spine-core
217
+ * given the same values at tolerance 0, and `CO57` that nothing set poses what
218
+ * was posed before.
219
+ *
192
220
  * ## What is not exact, and why
193
221
  *
194
222
  * Over 3,000 random rigs of three shapes (all five inherit modes; ik and
@@ -215,6 +243,9 @@
215
243
  */
216
244
  import { channelAt, keyIndexAt, poseAnimations, sampleTime, type CoreAnimationPose, type CoreCurve, type CoreKey, type SamplePhase, type TimelinePlant } from './animation.ts';
217
245
  import { RUNTIME_PI, type CoreWorld } from './world.ts';
246
+
247
+ /** Degrees to radians as the runtime converts them (`MathUtils.degRad`, `pi / 180` with the runtime's pi). */
248
+ const RUNTIME_RAD = RUNTIME_PI / 180;
218
249
  import { previousPassSlotBones, type CoreConstraintRecord } from './constraints.ts';
219
250
  import { activeBones, type CompiledDocument } from './index.ts';
220
251
 
@@ -577,6 +608,71 @@ export interface PhysicsStepContext {
577
608
  keyedNow?: Set<string>;
578
609
  /** A plant only (issue #1049, `sliderPhysicsFromCurrent: false`): each physics record's setup values, before the step's own animation. */
579
610
  setupRecords?: Map<string, CorePhysicsRecord>;
611
+ /**
612
+ * The world every constraint answers (issue #1416, the header's *The
613
+ * environment*): the skeleton's wind and gravity directions, as a caller set
614
+ * them on the live track. Absent, spine-core's own initial values
615
+ * (`DEFAULT_PHYSICS_ENVIRONMENT`), which is every walk but a live one a
616
+ * caller set it on.
617
+ */
618
+ env?: PhysicsEnvironment;
619
+ }
620
+
621
+ /**
622
+ * The world a physics constraint answers (issue #1416): spine-core 4.3.13's
623
+ * four skeleton fields `windX`, `windY`, `gravityX` and `gravityY`, as
624
+ * `wind: [windX, windY]` and `gravity: [gravityX, gravityY]` — the
625
+ * DIRECTIONS the constraints' own `wind` and `gravity` act along, which are
626
+ * each constraint's response to them (the header's *The environment*). In the
627
+ * runtime's convention: x to the right, **y down** — a positive y component
628
+ * pulls a constraint toward the bottom of the screen, as `gravity: [0, 1]`
629
+ * does.
630
+ */
631
+ export interface PhysicsEnvironment {
632
+ readonly wind: readonly [number, number];
633
+ readonly gravity: readonly [number, number];
634
+ }
635
+
636
+ /** spine-core 4.3.13's initial `Skeleton.windX, windY, gravityX, gravityY` — `1, 0, 0, 1` — the world of every walk nobody set one on. */
637
+ export const DEFAULT_PHYSICS_ENVIRONMENT: PhysicsEnvironment = Object.freeze({ wind: Object.freeze([1, 0] as const), gravity: Object.freeze([0, 1] as const) });
638
+
639
+ /**
640
+ * `Skeleton.physicsTranslate(x, y)` on the states in `ctx` (issue #1416): every
641
+ * constraint's next update sees its bone moved by `(x, y)` in the skeleton's
642
+ * world (y up) — the consumer moved the whole rig by that much since the last
643
+ * pose. A constraint with no state yet has not been updated, and its first
644
+ * update is pending, so the runtime's translation of it is read by nothing.
645
+ */
646
+ export function translatePhysics(ctx: PhysicsStepContext, x: number, y: number): void {
647
+ for (const s of ctx.states.values()) {
648
+ s.ux -= x;
649
+ s.uy -= y;
650
+ s.cx -= x;
651
+ s.cy -= y;
652
+ }
653
+ }
654
+
655
+ /**
656
+ * `Skeleton.physicsRotate(x, y, degrees)` on the states in `ctx` (issue
657
+ * #1416): every constraint's next update sees its bone turned by `degrees`
658
+ * (counter-clockwise, the runtime's pi) about `(x, y)` in the skeleton's world —
659
+ * a translation of each constraint by how far that turn carries the origin it
660
+ * last saw, as `PhysicsConstraint.rotate` computes it.
661
+ */
662
+ export function rotatePhysics(ctx: PhysicsStepContext, x: number, y: number, degrees: number): void {
663
+ const r = degrees * RUNTIME_RAD;
664
+ const cos = Math.cos(r);
665
+ const sin = Math.sin(r);
666
+ for (const s of ctx.states.values()) {
667
+ const dx = s.cx - x;
668
+ const dy = s.cy - y;
669
+ const tx = dx * cos - dy * sin - dx;
670
+ const ty = dx * sin + dy * cos - dy;
671
+ s.ux -= tx;
672
+ s.uy -= ty;
673
+ s.cx -= tx;
674
+ s.cy -= ty;
675
+ }
580
676
  }
581
677
 
582
678
  /** A constraint's state in `ctx`, created fresh on its first update. */
@@ -634,6 +730,7 @@ export function stepPhysics(r: CorePhysicsRecord, w: CoreWorld, length: number,
634
730
  s.lastTime = ctx.time;
635
731
  const bx = w.worldX;
636
732
  const by = w.worldY;
733
+ const env = ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
637
734
  let z = 0;
638
735
  if (s.pending) {
639
736
  s.pending = false;
@@ -664,16 +761,19 @@ export function stepPhysics(r: CorePhysicsRecord, w: CoreWorld, length: number,
664
761
  const e = r.strength;
665
762
  const wind = r.wind * f;
666
763
  const g = r.gravity * f;
764
+ // The environment's directions (issue #1416), as the runtime weighs them; the skeleton's scale is 1 here, so its factor is left out.
765
+ const ax = wind * env.wind[0] + g * env.gravity[0];
766
+ const ay = wind * env.wind[1] + g * env.gravity[1];
667
767
  const xs = s.xOffset;
668
768
  const ys = s.yOffset;
669
769
  do {
670
770
  if (x) {
671
- s.xVelocity += (wind - s.xOffset * e) * m;
771
+ s.xVelocity += (ax - s.xOffset * e) * m;
672
772
  s.xOffset += s.xVelocity * t;
673
773
  s.xVelocity *= d;
674
774
  }
675
775
  if (y) {
676
- s.yVelocity -= (g + s.yOffset * e) * m;
776
+ s.yVelocity -= (ay + s.yOffset * e) * m;
677
777
  s.yOffset += s.yVelocity * t;
678
778
  s.yVelocity *= d;
679
779
  }
@@ -720,20 +820,20 @@ export function stepPhysics(r: CorePhysicsRecord, w: CoreWorld, length: number,
720
820
  if (d === -1) d = Math.pow(r.damping, 60 * t);
721
821
  const m = r.massInverse * t;
722
822
  const e = r.strength;
723
- const wind = r.wind;
724
- const g = r.gravity;
823
+ const ax = r.wind * env.wind[0] + r.gravity * env.gravity[0];
824
+ const ay = r.wind * env.wind[1] + r.gravity * env.gravity[1];
725
825
  const h = l / f;
726
826
  const rs = s.rotateOffset;
727
827
  const ss = s.scaleOffset;
728
828
  for (;;) {
729
829
  a -= t;
730
830
  if (scaleX) {
731
- s.scaleVelocity += (wind * c - g * sn - s.scaleOffset * e) * m;
831
+ s.scaleVelocity += (ax * c - ay * sn - s.scaleOffset * e) * m;
732
832
  s.scaleOffset += s.scaleVelocity * t;
733
833
  s.scaleVelocity *= d;
734
834
  }
735
835
  if (rotateOrShearX) {
736
- s.rotateVelocity -= ((wind * sn + g * c) * h + s.rotateOffset * e) * m;
836
+ s.rotateVelocity -= ((ax * sn + ay * c) * h + s.rotateOffset * e) * m;
737
837
  s.rotateOffset += s.rotateVelocity * t;
738
838
  s.rotateVelocity *= d;
739
839
  if (a < t) break;
package/src/core/entry.ts CHANGED
@@ -18,7 +18,9 @@
18
18
  * ⚠️ A symbol is listed here only because a dependant was seen using it,
19
19
  * or asked for it in writing before it could be (`openTrack`, issue #1276;
20
20
  * `openTracks`, issue #1336; `physicsClockStep` and `PHYSICS_CLOCK_LEAD`,
21
- * the clock a live track's `clock` option takes, issue #1396).
21
+ * the clock a live track's `clock` option takes, issue #1396;
22
+ * `PhysicsEnvironment`, what a live track's `setEnvironment` takes, issue
23
+ * #1416).
22
24
  * The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` rows for
23
25
  * `./core`, and a symbol added here and not there is not promised.
24
26
  */
@@ -28,7 +30,7 @@ export { poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setup
28
30
  export type { RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
29
31
  export { poseWalkSetup, poseLoopingWalk } from './walk.ts';
30
32
  export { openTrack } from './track.ts';
31
- export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust } from './track.ts';
33
+ export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust, PhysicsEnvironment } from './track.ts';
32
34
  export { openTracks } from './tracks.ts';
33
35
  export type { LiveTracks, TracksOptions, MixOptions, QueueOptions, TrackEntryView } from './tracks.ts';
34
36
  export type { WalkPose } from './walk.ts';
package/src/core/raw.ts CHANGED
@@ -116,7 +116,7 @@
116
116
  import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
117
  import { posedBoneWorld, posedBoneWorldAlone, posedSlots, type CoreAnimationTimelines, type LocalAdjust, type TimelinePlant } from './animation.ts';
118
118
  import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
119
- import { freshStepContext, steppedPreviousPassWhy } from './constraints_physics.ts';
119
+ import { freshStepContext, rotatePhysics, steppedPreviousPassWhy, translatePhysics, DEFAULT_PHYSICS_ENVIRONMENT, type PhysicsEnvironment, type PhysicsStepContext } from './constraints_physics.ts';
120
120
  import { attachmentStates } from './deform.ts';
121
121
  import { drawOrderAt, type CoreDrawOrderKey } from './draw_order.ts';
122
122
  import { eventsFired, type CoreEventRow } from './events.ts';
@@ -575,6 +575,53 @@ export function checkedClock(clock: (dt: number) => number, who: string): (dt: n
575
575
  };
576
576
  }
577
577
 
578
+ /**
579
+ * A live track's `setEnvironment` (issue #1416, `./track.ts` and `./tracks.ts`):
580
+ * the world every physics constraint answers from the next pose on, set on the
581
+ * track's physics context — `wind` and `gravity` each a pair of finite numbers,
582
+ * either left out to keep the one that stands, and nothing else — or refused by
583
+ * name, before anything moves. The pair is copied: the caller's array changed
584
+ * later moves nothing.
585
+ */
586
+ export function setEnvironmentOn(ctx: PhysicsStepContext, env: unknown, who: string): void {
587
+ if (typeof env !== 'object' || env === null || Array.isArray(env)) throw new CoreInputError(`${who} setEnvironment was given ${env === null ? 'null' : Array.isArray(env) ? 'an array' : typeof env}, not an object { wind?: [x, y], gravity?: [x, y] }`);
588
+ const given = env as Record<string, unknown>;
589
+ for (const key of Object.keys(given)) {
590
+ if (key !== 'wind' && key !== 'gravity') throw new CoreInputError(`${who} setEnvironment names "${key}", which is not one of [wind, gravity]`);
591
+ }
592
+ const pair = (key: 'wind' | 'gravity'): readonly [number, number] | undefined => {
593
+ const v = given[key];
594
+ if (v === undefined) return undefined;
595
+ if (!Array.isArray(v) || v.length !== 2 || v.some((n) => typeof n !== 'number' || !Number.isFinite(n))) throw new CoreInputError(`${who} setEnvironment's ${key} is ${JSON.stringify(v)}, not a pair of finite numbers [x, y] (x to the right, y down)`);
596
+ return Object.freeze([v[0] as number, v[1] as number] as const);
597
+ };
598
+ const wind = pair('wind');
599
+ const gravity = pair('gravity');
600
+ const standing = ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
601
+ ctx.env = Object.freeze({ wind: wind ?? standing.wind, gravity: gravity ?? standing.gravity });
602
+ }
603
+
604
+ /** The environment a live track's physics answers now (`setEnvironmentOn`): spine-core's initial one where none was set. */
605
+ export function environmentOf(ctx: PhysicsStepContext): PhysicsEnvironment {
606
+ return ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
607
+ }
608
+
609
+ /** A live track's `physicsTranslate` (issue #1416): `x` and `y` finite, or refused by name before anything moves; then `translatePhysics`. */
610
+ export function translateOn(ctx: PhysicsStepContext, x: number, y: number, who: string): void {
611
+ for (const [k, v] of [['x', x], ['y', y]] as const) {
612
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw new CoreInputError(`${who} physicsTranslate's ${k} is ${String(v)}, not a finite distance in the skeleton's world`);
613
+ }
614
+ translatePhysics(ctx, x, y);
615
+ }
616
+
617
+ /** A live track's `physicsRotate` (issue #1416): `x`, `y` and `degrees` finite, or refused by name before anything moves; then `rotatePhysics`. */
618
+ export function rotateOn(ctx: PhysicsStepContext, x: number, y: number, degrees: number, who: string): void {
619
+ for (const [k, v] of [['x', x], ['y', y], ['degrees', degrees]] as const) {
620
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw new CoreInputError(`${who} physicsRotate's ${k} is ${String(v)}, not a finite number`);
621
+ }
622
+ rotatePhysics(ctx, x, y, degrees);
623
+ }
624
+
578
625
  /**
579
626
  * The animation time a looping track applies its animation at (`./walk.ts`,
580
627
  * *The time*): the track time wrapped by the duration, and 0 over a duration
package/src/core/track.ts CHANGED
@@ -174,6 +174,36 @@
174
174
  * `AnimationState` updated by the step and its `Skeleton` by the clock), and
175
175
  * `CO55` that the times and the events are those of the walk without it.
176
176
  *
177
+ * ## The environment, and the rig moved from outside (issue #1416)
178
+ *
179
+ * The rig states how each physics constraint answers the world — its `wind`
180
+ * and `gravity` — and the consumer states the world. `setEnvironment({ wind,
181
+ * gravity })` sets it from the next pose on and it stands until set again,
182
+ * as spine-core's four `Skeleton` fields do: `wind` is `[windX, windY]`,
183
+ * `gravity` is `[gravityX, gravityY]`, x to the right and **y down**, and
184
+ * until set it is the runtime's own `{ wind: [1, 0], gravity: [0, 1] }`
185
+ * (`./constraints_physics.ts`, *The environment*). Either half may be left
186
+ * out to keep the one that stands. So a gust is the consumer setting `wind`
187
+ * to `[0, 0]` at rest and `[k, 0]` while it blows, and a constraint that
188
+ * states `wind: 0` does not feel it — the response is the rig's, the gust the
189
+ * scene's.
190
+ *
191
+ * `physicsTranslate(x, y)` and `physicsRotate(x, y, degrees)` are the
192
+ * runtime's `Skeleton` methods of those names: the consumer moved or turned
193
+ * the whole rig since the last pose — in the skeleton's world, y up, degrees
194
+ * counter-clockwise — and every constraint's next update sees its bone move
195
+ * by that much, so a rig walked across the screen trails what hangs from it.
196
+ * A player forwards what spine-core's `SkeletonPhysicsMovement` forwards: the
197
+ * change of its display object's position since the last frame, converted into
198
+ * the skeleton's units and axes, and the change of its rotation about the
199
+ * skeleton's origin, `physicsRotate(0, 0, Δ)`, both before the `step` that
200
+ * poses the frame.
201
+ *
202
+ * `CO56` holds the poses to spine-core 4.3.13 given the same values on its
203
+ * `Skeleton`, and holds red the readings the mapping rejects; `CO57` holds that
204
+ * nothing set — or the runtime's own values set on every step and a move of 0
205
+ * — poses what the track posed before the methods existed, bit for bit.
206
+ *
177
207
  * ## The oracle chain
178
208
  *
179
209
  * Nothing here is held to spine-core directly but the events: the live track
@@ -192,9 +222,11 @@
192
222
  * history, so a seek is "open, then step to the time".
193
223
  */
194
224
  import { CoreInputError, type CompiledDocument } from './index.ts';
195
- import { checkedClock, openRawTrack, stepRawTrack, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
225
+ import { checkedClock, environmentOf, openRawTrack, rotateOn, setEnvironmentOn, stepRawTrack, translateOn, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
196
226
  import type { LocalAdjust, TimelinePlant } from './animation.ts';
227
+ import type { PhysicsEnvironment } from './constraints_physics.ts';
197
228
  export type { AdjustableLocals, BoneLocals, LocalAdjust } from './animation.ts';
229
+ export type { PhysicsEnvironment } from './constraints_physics.ts';
198
230
 
199
231
  /** How a live track is opened: whether it loops, and where its physics is reset (`RawReset`) — both the caller's, neither with a default — the caller's adjustment of every pose's bone locals (the header's *The adjustment*), and what each step moves the physics clock by (the header's *The physics clock*). */
200
232
  export interface TrackOptions {
@@ -217,6 +249,14 @@ export interface LiveTrack {
217
249
  readonly animationTime: number;
218
250
  /** The runtime's duration of the animation (`CoreAnimationTimelines.duration`). */
219
251
  readonly duration: number;
252
+ /** Sets the world every physics constraint answers from the next pose on (the header's *The environment*): `wind` and `gravity` as spine-core's `Skeleton.windX, windY` and `gravityX, gravityY` — x right, y down — either left out to keep the one that stands. Until set, `{ wind: [1, 0], gravity: [0, 1] }`, the runtime's own. */
253
+ setEnvironment(environment: Partial<PhysicsEnvironment>): void;
254
+ /** The environment the next pose answers. */
255
+ readonly environment: PhysicsEnvironment;
256
+ /** spine-core's `Skeleton.physicsTranslate`: the consumer moved the whole rig by `(x, y)` in the skeleton's world (y up) since the last pose, and every physics constraint's next update sees it. */
257
+ physicsTranslate(x: number, y: number): void;
258
+ /** spine-core's `Skeleton.physicsRotate`: the consumer turned the whole rig by `degrees` (counter-clockwise) about `(x, y)` in the skeleton's world since the last pose. */
259
+ physicsRotate(x: number, y: number, degrees: number): void;
220
260
  }
221
261
 
222
262
  /** The two modes: a track that holds at its duration is the raw entry's walk; a looping one fires its events across the wrap and cuts its clipped rows (`live`). Neither keeps a non-finite value. */
@@ -264,6 +304,30 @@ class Track implements LiveTrack {
264
304
  return this.track.duration;
265
305
  }
266
306
 
307
+ get environment(): PhysicsEnvironment {
308
+ return environmentOf(this.track.ctx);
309
+ }
310
+
311
+ setEnvironment(environment: Partial<PhysicsEnvironment>): void {
312
+ this.live('setEnvironment');
313
+ setEnvironmentOn(this.track.ctx, environment, 'the live track\'s');
314
+ }
315
+
316
+ physicsTranslate(x: number, y: number): void {
317
+ this.live('physicsTranslate');
318
+ translateOn(this.track.ctx, x, y, 'the live track\'s');
319
+ }
320
+
321
+ physicsRotate(x: number, y: number, degrees: number): void {
322
+ this.live('physicsRotate');
323
+ rotateOn(this.track.ctx, x, y, degrees, 'the live track\'s');
324
+ }
325
+
326
+ /** A refused track refuses every call that would move it, naming the step it was refused at. */
327
+ private live(call: string): void {
328
+ if (this.refused !== null) throw new CoreInputError(`the live track was refused at step ${this.refused}, after its time and physics had moved; open the track again (${call} not made)`);
329
+ }
330
+
267
331
  step(dt: number): RawPose {
268
332
  const n = this.steps + 1;
269
333
  if (this.refused !== null) throw new CoreInputError(`the live track was refused at step ${this.refused}, after its time and physics had moved; open the track again`);
@@ -164,7 +164,8 @@ import { activeBones, CoreInputError, rawNumber, readColour, shownAttachment, ty
164
164
  import { channelAt, keyIndexAt, SLOT_COLOUR_TIMELINES, slotRowsFrom, steppedLocalsWorld, type BoneTimelineKind, type CoreKey, type LocalAdjust, type SlotBase, type SlotColourKind, type TimelinePlant } from './animation.ts';
165
165
  import { deformAt, setupArray, type CoreAttachmentTimeline } from './deform.ts';
166
166
  import { eventsFired, type CoreEventKey, type CoreEventRow } from './events.ts';
167
- import { checkedClock, openRawTracksPath, type RawPose, type RawReset, type RawTracksPath } from './raw.ts';
167
+ import { checkedClock, environmentOf, openRawTracksPath, rotateOn, setEnvironmentOn, translateOn, type RawPose, type RawReset, type RawTracksPath } from './raw.ts';
168
+ import type { PhysicsEnvironment } from './constraints_physics.ts';
168
169
  import { slotTimelinesApply } from './skins.ts';
169
170
 
170
171
  /** How the live tracks are opened: where the physics is reset (`RawReset`, the caller's, no default), the caller's adjustment of every pose's bone locals, and what each step moves the physics clock by (the header's *The physics clock*). */
@@ -228,6 +229,14 @@ export interface LiveTracks {
228
229
  current(track: number): TrackEntryView | null;
229
230
  /** The running sum of the steps taken, 0 before the first. */
230
231
  readonly trackTime: number;
232
+ /** Sets the world every physics constraint answers from the next pose on, as `LiveTrack.setEnvironment` (`./track.ts`, *The environment*). */
233
+ setEnvironment(environment: Partial<PhysicsEnvironment>): void;
234
+ /** The environment the next pose answers. */
235
+ readonly environment: PhysicsEnvironment;
236
+ /** spine-core's `Skeleton.physicsTranslate`, as `LiveTrack.physicsTranslate`. */
237
+ physicsTranslate(x: number, y: number): void;
238
+ /** spine-core's `Skeleton.physicsRotate`, as `LiveTrack.physicsRotate`. */
239
+ physicsRotate(x: number, y: number, degrees: number): void;
231
240
  }
232
241
 
233
242
  /**
@@ -1333,6 +1342,25 @@ class Tracks implements LiveTracks {
1333
1342
  return this.time;
1334
1343
  }
1335
1344
 
1345
+ get environment(): PhysicsEnvironment {
1346
+ return environmentOf(this.path.ctx);
1347
+ }
1348
+
1349
+ setEnvironment(environment: Partial<PhysicsEnvironment>): void {
1350
+ this.live('setEnvironment');
1351
+ setEnvironmentOn(this.path.ctx, environment, 'the live tracks\'');
1352
+ }
1353
+
1354
+ physicsTranslate(x: number, y: number): void {
1355
+ this.live('physicsTranslate');
1356
+ translateOn(this.path.ctx, x, y, 'the live tracks\'');
1357
+ }
1358
+
1359
+ physicsRotate(x: number, y: number, degrees: number): void {
1360
+ this.live('physicsRotate');
1361
+ rotateOn(this.path.ctx, x, y, degrees, 'the live tracks\'');
1362
+ }
1363
+
1336
1364
  private live(call: string): void {
1337
1365
  if (this.refused !== null) throw new CoreInputError(`the live tracks were refused at ${this.refused}, after their entries and physics had moved; open them again (${call} not made)`);
1338
1366
  }
package/src/mesh.ts CHANGED
@@ -2432,3 +2432,139 @@ export function segmentShares(
2432
2432
  });
2433
2433
  return { weights };
2434
2434
  }
2435
+
2436
+ // ---------------------------------------------------------------------------
2437
+ // influence — a falloff that reaches zero at a stated distance (issue #1415)
2438
+ // ---------------------------------------------------------------------------
2439
+ //
2440
+ // `segmentShares` above never lets a segment go: `1 / (d + r)^p` is positive at
2441
+ // every distance, so the only way to widen a blend is a larger `r`, which
2442
+ // flattens it everywhere at once, and the only way to end one is `minWeight`,
2443
+ // a share rather than a distance. A cloth surface wants the other model: a
2444
+ // vertex follows every link NEAR it, by a pull that falls to exactly zero at a
2445
+ // stated reach, so a link enters and leaves a vertex's blend continuously.
2446
+ //
2447
+ // 1. **Distance.** `d` is the distance from the vertex to each link's
2448
+ // segment — its nearest point, `segmentDistance`, the same reading the
2449
+ // falloff takes — at the setup pose. To the segment rather than to the
2450
+ // bone's origin: a vertex on the middle of a link IS on that link, and an
2451
+ // origin metric would put it half a link away while handing the joint at
2452
+ // the link's tip to the child whose origin sits there.
2453
+ // 2. **Across.** With `across: false` only the entry (a chain, a bone, a
2454
+ // span) holding the nearest link is kept — the earlier entry on a tie.
2455
+ // 3. **Reach.** A link with `d < radius` pulls `shape(d / radius)`, and one
2456
+ // whose pull is not above 0 — a hair inside the reach, in doubles — pulls
2457
+ // nothing and is out of reach; a bone two segments name keeps the nearer.
2458
+ // A vertex no link reaches is refused by the caller, naming the nearest
2459
+ // distance.
2460
+ // 4. **Cap.** The strongest `max` pulls are kept — equal pulls in the order
2461
+ // the bones were first named — and normalised; a share the 6-decimal grid
2462
+ // would write as 0 is dropped and the rest normalised again.
2463
+ // 5. **Order.** The kept bones are written in the order they were first
2464
+ // named, on every vertex, so an attachment has one bone order by
2465
+ // construction (`A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER`); the
2466
+ // strongest closes the vertex at exactly 1 on the grid.
2467
+
2468
+ /** One candidate segment with the `bones` entry it came from — what `across: false` chooses between. */
2469
+ export interface EntrySegment extends WorldSegment {
2470
+ /** Index of the `bones` entry this segment belongs to. */
2471
+ entry: number;
2472
+ }
2473
+
2474
+ /** The four fields an `influence` block states — every one of them required by the caller. */
2475
+ export interface SegmentsInfluence {
2476
+ radius: number;
2477
+ across: boolean;
2478
+ max: number;
2479
+ shape: 'linear' | 'smooth';
2480
+ }
2481
+
2482
+ /** A pull at `x = d / radius` in `[0, 1]`: 1 at the segment, 0 at the reach. */
2483
+ export function influencePull(shape: SegmentsInfluence['shape'], x: number): number {
2484
+ return shape === 'linear' ? 1 - x : 1 - x * x * (3 - 2 * x);
2485
+ }
2486
+
2487
+ /** The steps a selftest plant may switch off, one key each — never passed by a build. */
2488
+ export interface InfluencePlant {
2489
+ ignoreCap?: true;
2490
+ ignoreReach?: true;
2491
+ ignoreAcross?: true;
2492
+ skipNormalise?: true;
2493
+ }
2494
+
2495
+ /** What `influenceShares` found at one vertex. */
2496
+ export type InfluenceShares =
2497
+ | {
2498
+ /** The bindings, in first-named bone order, closing at exactly 1 on the 6-decimal grid. */
2499
+ weights: MeshVertexWeight[];
2500
+ /** How many bones were in reach before the cap — over `max` means the cap dropped some. */
2501
+ inReach: number;
2502
+ }
2503
+ | {
2504
+ /** No link is within `radius`: the nearest one's distance, and its bone. */
2505
+ nearest: { bone: number; distance: number };
2506
+ };
2507
+
2508
+ /**
2509
+ * The shares on one vertex under an `influence` block — see the section header.
2510
+ * `plant` exists for the selftest's negative controls: each key switches one
2511
+ * step off, so a control can show its own check going red on weights that
2512
+ * break exactly that step. A build never passes it.
2513
+ */
2514
+ export function influenceShares(
2515
+ p: readonly [number, number],
2516
+ segments: readonly EntrySegment[],
2517
+ influence: SegmentsInfluence,
2518
+ plant: InfluencePlant = {},
2519
+ ): InfluenceShares {
2520
+ const distances = segments.map((s) => segmentDistance(p, s.a, s.b));
2521
+ let home = -1;
2522
+ let homeD = Infinity;
2523
+ let nearestBone = 0;
2524
+ distances.forEach((d, i) => {
2525
+ if (d < homeD) {
2526
+ homeD = d;
2527
+ home = segments[i].entry;
2528
+ nearestBone = segments[i].bone;
2529
+ }
2530
+ });
2531
+ const byBone = new Map<number, number>();
2532
+ segments.forEach((s, i) => {
2533
+ if (!influence.across && plant.ignoreAcross === undefined && s.entry !== home) return;
2534
+ const d = distances[i];
2535
+ if (!(d < influence.radius) && plant.ignoreReach === undefined) return;
2536
+ const was = byBone.get(s.bone);
2537
+ if (was === undefined || d < was) byBone.set(s.bone, d);
2538
+ });
2539
+ // Strongest first; equal pulls by the order the bones were first named. A link a hair inside the reach can
2540
+ // pull 0 — or a rounding's worth under it — in doubles (`1 - x²(3 - 2x)` at x one ulp under 1): it pulls
2541
+ // nothing, so it is out of reach like the links past it, and no share is ever made up for it.
2542
+ const pulls = [...byBone.entries()]
2543
+ .map(([bone, d]) => ({ bone, pull: influencePull(influence.shape, Math.min(d / influence.radius, 1)) }))
2544
+ .filter((e) => e.pull > 0 || plant.ignoreReach !== undefined)
2545
+ .sort((x, y) => y.pull - x.pull || x.bone - y.bone);
2546
+ if (pulls.length === 0) return { nearest: { bone: nearestBone, distance: homeD } };
2547
+ const inReach = pulls.length;
2548
+ const capped = plant.ignoreCap === undefined ? pulls.slice(0, influence.max) : pulls;
2549
+ // Only the planted `ignoreReach` keeps a pull of 0 (a link at or past the reach): it is given one grid step,
2550
+ // so the plant still binds that link, which is the plant's whole point.
2551
+ const raw = capped.map((e) => ({ bone: e.bone, pull: e.pull > 0 ? e.pull : 0.000001 }));
2552
+ let sum = 0;
2553
+ for (const e of raw) sum += e.pull;
2554
+ const normalise = plant.skipNormalise === undefined;
2555
+ const first = raw.map((e) => ({ bone: e.bone, share: normalise ? e.pull / sum : e.pull }));
2556
+ const kept = first.filter((e) => r6(e.share) !== 0);
2557
+ let sum2 = 0;
2558
+ for (const e of kept) sum2 += e.share;
2559
+ const rounded = kept.map((e) => r6(normalise ? e.share / sum2 : e.share));
2560
+ // `kept[0]` is the strongest — the filter keeps the order — and it closes the vertex at exactly 1.
2561
+ if (normalise) {
2562
+ let others = 0;
2563
+ for (let k = 1; k < rounded.length; k++) others += rounded[k];
2564
+ rounded[0] = r6(1 - others);
2565
+ }
2566
+ const weights: MeshVertexWeight[] = kept
2567
+ .map((e, k) => ({ bone: 'control' as const, control: e.bone, weight: rounded[k] }))
2568
+ .sort((x, y) => x.control - y.control);
2569
+ return { weights, inReach };
2570
+ }
package/src/rig.ts CHANGED
@@ -683,6 +683,53 @@ export interface RigSegmentsFalloff {
683
683
  minWeight?: number;
684
684
  }
685
685
 
686
+ /** The two curves an `influence` pull falls to zero along — see `RigSegmentsInfluence.shape`. */
687
+ export const RIG_INFLUENCE_SHAPES = ['linear', 'smooth'] as const;
688
+
689
+ /**
690
+ * A falloff that **reaches zero** at a stated distance, so a vertex follows
691
+ * every chain link near it and no link far from it (issue #1415) — the model a
692
+ * cloth surface needs, where a vertex between two chains blends the springs of
693
+ * both and the surface bends as one sheet rather than as strips.
694
+ *
695
+ * Per vertex: `d` is the distance to each link's **segment** (its nearest
696
+ * point, the same distance `falloff` measures), read at the setup pose. A link
697
+ * with `d < radius` pulls `shape(d / radius)` — one whose pull is not above 0
698
+ * in doubles, a hair inside the reach, pulls nothing; the strongest `max` pulls are
699
+ * kept — equal pulls in the order `bones` first names their bones — and
700
+ * normalised; a share the 6-decimal grid would write as 0 is dropped and the
701
+ * rest normalised again. Each vertex lists its bones in the order `bones`
702
+ * names them, so the attachment has one bone order by construction (A52).
703
+ *
704
+ * ⭐ Every number is a field and none has a default: the reach and the cap are
705
+ * judgements about this cloth, and the shape is the one thing that decides
706
+ * how a link enters a vertex's blend.
707
+ */
708
+ export interface RigSegmentsInfluence {
709
+ /**
710
+ * **Required.** How far a link reaches, in the part's pixels (the skeleton's
711
+ * units on this generator's route): a link whose segment is `radius` or
712
+ * farther from a vertex never pulls it. Positive.
713
+ */
714
+ radius: number;
715
+ /**
716
+ * **Required.** `true`: every link of every `bones` entry within `radius`
717
+ * pulls. `false`: only the links of ONE entry — the entry holding the link
718
+ * nearest the vertex, the earlier entry on a tie — so a chain moves its own
719
+ * strip and nothing across.
720
+ */
721
+ across: boolean;
722
+ /** **Required.** At most this many links pull one vertex — the strongest. A whole number of at least 1. */
723
+ max: number;
724
+ /**
725
+ * **Required.** How a pull falls from 1 at `d = 0` to 0 at `d = radius`, with
726
+ * `x = d / radius`: `"linear"` is `1 - x`; `"smooth"` is `1 - x²(3 - 2x)`,
727
+ * which also arrives at both ends with zero slope, so a link enters and leaves
728
+ * a vertex's blend without a crease.
729
+ */
730
+ shape: (typeof RIG_INFLUENCE_SHAPES)[number];
731
+ }
732
+
686
733
  /**
687
734
  * A lattice over the part's alpha, weighted by distance to named bone segments
688
735
  * (`buildSegmentsLattice` and `segmentWeights` in [`mesh.ts`](mesh.ts)).
@@ -703,8 +750,14 @@ export interface RigSegmentsGenerator {
703
750
  * or a `RigSegmentSpan` stated outright.
704
751
  */
705
752
  bones: Array<string | string[] | RigSegmentSpan>;
706
- /** The falloff — see `RigSegmentsFalloff`. `radius` is required, so the block is too. */
707
- falloff: RigSegmentsFalloff;
753
+ /**
754
+ * The falloff — see `RigSegmentsFalloff`. Exactly one of `falloff` and
755
+ * `influence` is stated: both turn a distance into a share, in two models,
756
+ * and a mesh weighted by both would be weighted by neither.
757
+ */
758
+ falloff?: RigSegmentsFalloff;
759
+ /** A falloff that reaches zero at a stated distance — see `RigSegmentsInfluence`. */
760
+ influence?: RigSegmentsInfluence;
708
761
  /** Alpha at or above which a pixel counts as art, 1..255. Default 1 — `contour`'s own. */
709
762
  alpha?: number;
710
763
  /**
@@ -1965,8 +2018,9 @@ export const RIG_KEYS = {
1965
2018
  RigRibbonGenerator: ['kind', 'size', 'rows', 'chain'],
1966
2019
  RigContourGenerator: ['kind', 'tolerance', 'margin', 'maxVertices', 'alpha', 'depth', 'soft'],
1967
2020
  RigGridGenerator: ['kind', 'us', 'vs', 'cols', 'rows', 'depth', 'soft'],
1968
- RigSegmentsGenerator: ['kind', 'cell', 'bones', 'falloff', 'alpha', 'anchor'],
2021
+ RigSegmentsGenerator: ['kind', 'cell', 'bones', 'falloff', 'influence', 'alpha', 'anchor'],
1969
2022
  RigSegmentsFalloff: ['power', 'radius', 'maxBones', 'minWeight'],
2023
+ RigSegmentsInfluence: ['radius', 'across', 'max', 'shape'],
1970
2024
  RigSegmentSpan: ['bone', 'from', 'to'],
1971
2025
  RigMeshBias: ['axis_deg', 'ramp'],
1972
2026
  RigDepthMap: ['image', 'near', 'zScale', 'gamma', 'contrast', 'bias'],
@@ -2101,8 +2155,11 @@ export const RIG_TYPES = {
2101
2155
  soft: 'object',
2102
2156
  },
2103
2157
  RigGridGenerator: { kind: 'enum', us: 'number[]', vs: 'number[]', cols: 'number', rows: 'number', depth: 'object', soft: 'object' },
2104
- RigSegmentsGenerator: { kind: 'enum', cell: 'number', bones: 'mixed', falloff: 'object', alpha: 'number', anchor: 'number[]' },
2158
+ RigSegmentsGenerator: {
2159
+ kind: 'enum', cell: 'number', bones: 'mixed', falloff: 'object', influence: 'object', alpha: 'number', anchor: 'number[]',
2160
+ },
2105
2161
  RigSegmentsFalloff: { power: 'number', radius: 'number', maxBones: 'number', minWeight: 'number' },
2162
+ RigSegmentsInfluence: { radius: 'number', across: 'boolean', max: 'number', shape: 'enum' },
2106
2163
  RigSegmentSpan: { bone: 'string', from: 'number[]', to: 'number[]' },
2107
2164
  RigMeshBias: { axis_deg: 'number', ramp: 'number[]' },
2108
2165
  RigDepthMap: { image: 'string', near: 'enum', zScale: 'number', gamma: 'number', contrast: 'number', bias: 'number' },
@@ -2170,6 +2227,7 @@ export const RIG_ENUMS = {
2170
2227
  RigContourGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2171
2228
  RigGridGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2172
2229
  RigSegmentsGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2230
+ RigSegmentsInfluence: { shape: { owner: 'segmentsInfluenceOf' } },
2173
2231
  RigDepthMap: { near: { owner: 'sampleMeshDepth' } },
2174
2232
  } as const satisfies SpecEnumTable<typeof RIG_TYPES>;
2175
2233
 
@@ -2438,6 +2496,7 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
2438
2496
  at(gen.depth, 'RigDepthMap', `${who} generator.depth`, [...here, 'generator', 'depth']);
2439
2497
  at(gen.soft, 'RigSoftRegion', `${who} generator.soft`, [...here, 'generator', 'soft']);
2440
2498
  at(gen.falloff, 'RigSegmentsFalloff', `${who} generator.falloff`, [...here, 'generator', 'falloff']);
2499
+ at(gen.influence, 'RigSegmentsInfluence', `${who} generator.influence`, [...here, 'generator', 'influence']);
2441
2500
  for (const [i, entry] of (Array.isArray(gen.bones) ? gen.bones : []).entries()) {
2442
2501
  at(entry, 'RigSegmentSpan', `${who} generator.bones[${i}]`, [...here, 'generator', 'bones', i]);
2443
2502
  }
package/src/types.ts CHANGED
@@ -1679,6 +1679,20 @@ export interface CompileResult {
1679
1679
  artCells: number;
1680
1680
  keptCells: number;
1681
1681
  islands: number;
1682
+ /**
1683
+ * Present only when the mesh is weighted by an `influence` block (issue
1684
+ * #1415): the block as stated, how many vertices had more links in reach
1685
+ * than `max` — the vertices the cap dropped a link from — and the most
1686
+ * links in reach at any one vertex, which is the `max` that would drop none.
1687
+ */
1688
+ reach?: {
1689
+ radius: number;
1690
+ across: boolean;
1691
+ max: number;
1692
+ shape: 'linear' | 'smooth';
1693
+ capped: number;
1694
+ widest: number;
1695
+ };
1682
1696
  };
1683
1697
  /**
1684
1698
  * What a depth map put on this mesh's vertices, when one was named.