@rig-c/core 2.65.2 → 2.67.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 +150 -0
- package/package.json +1 -1
- package/src/core/animation.ts +18 -7
- package/src/core/constraints.ts +2 -1
- package/src/core/constraints_physics.ts +2 -0
- package/src/core/deform.ts +136 -53
- package/src/core/entry.ts +8 -4
- package/src/core/index.ts +7 -6
- package/src/core/physics_parameters.ts +72 -0
- package/src/core/raw.ts +82 -5
- package/src/core/track.ts +27 -4
- package/src/core/tracks.ts +30 -1
package/docs/AUTHORING.md
CHANGED
|
@@ -5381,6 +5381,39 @@ agreeing with `render` — and left out, a track steps the physics by the step i
|
|
|
5381
5381
|
exactly as before. Which clock a player with a real clock uses is the player's
|
|
5382
5382
|
question, and rigc does not answer it.
|
|
5383
5383
|
|
|
5384
|
+
A player's `adjust` option on `openTrack` or `openTracks` receives
|
|
5385
|
+
`(locals, ctx)` (issue #1540). `LocalAdjustContext`, `LocalAdjust`,
|
|
5386
|
+
`AdjustableLocals` and `BoneLocals` are types promised through `rig-c/core`.
|
|
5387
|
+
The callback runs once per evaluated pose **after all track composition**,
|
|
5388
|
+
before world transforms, constraints and physics. A crossfade does not call it
|
|
5389
|
+
for the outgoing entry as well. Existing unary callbacks keep the same writes,
|
|
5390
|
+
pose bytes and call schedule.
|
|
5391
|
+
|
|
5392
|
+
- `ctx.trackTime: number | null` is the **current track-0 entry's** unwrapped
|
|
5393
|
+
seconds from its own start. Loops do not wrap it; a held animation clamps its
|
|
5394
|
+
sampling time while this entry clock continues. Replacing the entry starts its
|
|
5395
|
+
clock at 0 even at a nonzero global time. A queued switch preserves the
|
|
5396
|
+
remainder the mixer has already advanced into its new entry.
|
|
5397
|
+
- `ctx.animation: string | null` names that **same entry**. With no current
|
|
5398
|
+
track-0 entry, both fields are null, even if an upper track plays. An explicit
|
|
5399
|
+
empty entry has a null animation name and its own numeric entry time until
|
|
5400
|
+
removed, matching `current(0)`.
|
|
5401
|
+
- `ctx.globalTime: number` is the running sum of this walk's steps, in seconds.
|
|
5402
|
+
On a single track it equals the entry time. It is separate from entry time in
|
|
5403
|
+
a composition, and is never substituted for an absent track-0 time. Neither
|
|
5404
|
+
time includes the optional physics clock's lead.
|
|
5405
|
+
|
|
5406
|
+
With `reset: 'animation'` the initial pose calls the hook at 0;
|
|
5407
|
+
`reset: 'setup'` leaves its initial setup pose unadjusted. Every explicit
|
|
5408
|
+
`step(0)` still evaluates a pose. This API adds no seek or cache: a caller may
|
|
5409
|
+
reuse a cached pose without asking the core to evaluate it again. Time-dependent
|
|
5410
|
+
adjustments replay exactly with the **same reset, environment and step
|
|
5411
|
+
partition**; arbitrary physics histories are not interchangeable. A callback
|
|
5412
|
+
closing over mutable external state still needs its caller's cache invalidated.
|
|
5413
|
+
For example, `adjust: (locals, ctx) => { locals.bone('dial').rotation =
|
|
5414
|
+
30 * (ctx.trackTime ?? 0); }` explicitly chooses a value when there is no base
|
|
5415
|
+
entry; the core supplies null rather than inventing that choice.
|
|
5416
|
+
|
|
5384
5417
|
🌬️ **`wind` and `gravity` are the constraint's response; the world is the
|
|
5385
5418
|
consumer's** (issue #1416). In 4.3 the two are not forces but how strongly the
|
|
5386
5419
|
constraint answers two directions the *skeleton* carries — `Skeleton.windX,
|
|
@@ -5420,6 +5453,47 @@ skeleton's units and axes, and its change of rotation as `physicsRotate(0, 0,
|
|
|
5420
5453
|
Δ)`, before the `step` that poses the frame. Nothing set, every pose is the
|
|
5421
5454
|
one the track posed before these existed (`CO57`).
|
|
5422
5455
|
|
|
5456
|
+
For a live preview of one named constraint (issue #1541), both `LiveTrack` and
|
|
5457
|
+
`LiveTracks` on `rig-c/core` expose:
|
|
5458
|
+
|
|
5459
|
+
```ts
|
|
5460
|
+
track.setPhysicsParameters('spring', { damping: 0.3, wind: 2 });
|
|
5461
|
+
const { override, effective } = track.readPhysicsParameters('spring');
|
|
5462
|
+
track.clearPhysicsParameters('spring');
|
|
5463
|
+
```
|
|
5464
|
+
|
|
5465
|
+
`PhysicsParameters` has exactly six numeric fields: `strength`, `damping`,
|
|
5466
|
+
`inertia`, `wind`, `gravity`, `mix`. `PhysicsParameterPatch` is a partial set of
|
|
5467
|
+
them; setting merges with the instance's existing patch, omitted fields keep
|
|
5468
|
+
their overrides, and an empty patch does nothing. Finite numbers are accepted
|
|
5469
|
+
without clamping or unit conversion. The patch applies after the authored,
|
|
5470
|
+
keyed and slider values, immediately before that active constraint's integrator.
|
|
5471
|
+
It persists across poses, loops and composed-entry replacement or mixing out to
|
|
5472
|
+
nothing, until `clearPhysicsParameters(name)` removes the whole named patch.
|
|
5473
|
+
No document, another instance, or another named constraint is changed.
|
|
5474
|
+
|
|
5475
|
+
`PhysicsParameterRead` contains detached snapshots: `override` is the patch
|
|
5476
|
+
currently in force, or `null`; `effective` is the exact six values last passed
|
|
5477
|
+
to that constraint's step, or `null` when it has never been evaluated. In
|
|
5478
|
+
particular composed tracks before `start()` and inactive constraints have no
|
|
5479
|
+
evaluated values. Returning resolved setup defaults there would invent a pose:
|
|
5480
|
+
the parser has read them, but the integrator has not used them. A single track
|
|
5481
|
+
evaluates pose 0 when opened, including under `reset: 'setup'`. Setting or
|
|
5482
|
+
clearing changes `override` immediately, while `effective` keeps describing the
|
|
5483
|
+
previous evaluation until the next pose. No reset or fake evaluation is done to
|
|
5484
|
+
fill a readout. Values omitted by the author come from the existing resolved
|
|
5485
|
+
records; this API defines no second default table.
|
|
5486
|
+
|
|
5487
|
+
Clear resumes whatever authored, keyed and slider values the next pose supplies,
|
|
5488
|
+
and retains the clock, offsets, velocities and remaining integration time. It
|
|
5489
|
+
does not undo motion or promise transforms equal to an untouched trajectory.
|
|
5490
|
+
An unknown constraint name, a name of another kind, an unknown field (including
|
|
5491
|
+
`mass`), or a non-finite/non-number field throws a named `CoreInputError` before
|
|
5492
|
+
any part of the patch changes. Set/read/clear name errors list the relevant
|
|
5493
|
+
name; field errors also name the field and required finite value or allowed
|
|
5494
|
+
field set. The composed tracks' existing refusal of animation constraint
|
|
5495
|
+
timelines is unchanged; this API adds no timeline mixing or solver behavior.
|
|
5496
|
+
|
|
5423
5497
|
`mix` is a player-side `AnimationStateData` config and is **not** emitted into
|
|
5424
5498
|
skeleton JSON, so the parser is the only check it gets: it is
|
|
5425
5499
|
`{ "default": <seconds>, "pairs"?: [["<from>", "<to>", <seconds>], …] }`, and a
|
|
@@ -12045,3 +12119,79 @@ for `declared` and the run says so on a line of its own.
|
|
|
12045
12119
|
`@rig-c/play` or `rigc preview`.
|
|
12046
12120
|
- **Not a second poser.** Everything it draws is a pose `render` would have drawn at
|
|
12047
12121
|
the same frame; where it disagrees with `render --geometry`, it is wrong (`IN04`).
|
|
12122
|
+
|
|
12123
|
+
## 14. Read a named mesh at setup
|
|
12124
|
+
|
|
12125
|
+
`rig-c/core` promises `readSetupMesh(view, target)` and the types
|
|
12126
|
+
`SetupMeshTarget` and `SetupMesh` (issue #1538). It reads any named mesh or linked
|
|
12127
|
+
mesh, including one the setup pose does not select, without a player, animation
|
|
12128
|
+
step or change to the document's setup selection.
|
|
12129
|
+
|
|
12130
|
+
```ts
|
|
12131
|
+
import { readModel, underNoSkin, readSetupMesh } from 'rig-c/core';
|
|
12132
|
+
import type { SetupMeshTarget, SetupMesh } from 'rig-c/core';
|
|
12133
|
+
|
|
12134
|
+
const view = underNoSkin(readModel(modelText));
|
|
12135
|
+
const target: SetupMeshTarget = { slot: 'flag', attachment: 'alternate' };
|
|
12136
|
+
const mesh: SetupMesh = readSetupMesh(view, target);
|
|
12137
|
+
// mesh.vertices: full-double world x,y pairs, y up; no crop/camera conversion.
|
|
12138
|
+
```
|
|
12139
|
+
|
|
12140
|
+
| field | contract |
|
|
12141
|
+
| --- | --- |
|
|
12142
|
+
| `target.slot`, `target.attachment` | exact slot name and attachment **table key**; a record's display name, path or atlas region is not an alias |
|
|
12143
|
+
| `target.skin` omitted | retain the supplied view, including `readModel`'s `all`; use `underNoSkin` for a fresh skeleton's view |
|
|
12144
|
+
| `target.skin: null` | no skin set: default-table fallback, no skin-required bones or constraints activated |
|
|
12145
|
+
| `target.skin: name` | the named view, with default-table fallback; only the named skin's activation lists apply |
|
|
12146
|
+
| `target.skin: 'all'` | all activation lists; a uniquely filled key resolves, identical definitions keep the existing resolver's first supplying skin, differing definitions are refused because the model does not carry the Spine file's merge order |
|
|
12147
|
+
| `mesh.slot`, `mesh.attachment`, `mesh.skin` | requested slot and table key, and the table's supplying skin (which can be the default fallback); a linked mesh retains its own identity |
|
|
12148
|
+
| `mesh.vertices` | fresh mutable `number[]`, flat x,y in world units; stored coordinates and weights use the poser's float32 reading and influence order, without normalization |
|
|
12149
|
+
| `mesh.triangles`, `mesh.hull` | fresh mutable triangle indices; hull is the number of leading hull **vertices**, or `null` when absent |
|
|
12150
|
+
|
|
12151
|
+
Definition equality ignores object property insertion order at every depth,
|
|
12152
|
+
including weighted binding records. Array positions, object key presence and
|
|
12153
|
+
leaf values remain significant; leaves use `Object.is`, so positive and negative
|
|
12154
|
+
zero are distinct when retained in a model. Reordering object keys alone is not
|
|
12155
|
+
a different definition. A genuine value, shape or array-order difference still
|
|
12156
|
+
produces the same named all-skin refusal; no merge order is inferred.
|
|
12157
|
+
|
|
12158
|
+
The setup matrices come from the raw setup poser's existing constraint path,
|
|
12159
|
+
including setup sliders and reset physics. A selected mesh is bit-identical to
|
|
12160
|
+
`poseRawSetup(view)`'s geometry, including any setup-slider deform. An unselected
|
|
12161
|
+
mesh uses its stored vertices through those same matrices: the API never switches
|
|
12162
|
+
an attachment to ask what a slider or path constraint would do after a switch.
|
|
12163
|
+
A linked mesh borrows its source vertices, triangles and hull; unweighted
|
|
12164
|
+
vertices use the queried slot's bone, weighted vertices their named bindings.
|
|
12165
|
+
Its `timelines` flag controls source-deform inheritance only when selected, as
|
|
12166
|
+
in the existing setup poser. An inactive bone keeps the existing zero world
|
|
12167
|
+
matrix; an attachment read does not activate it.
|
|
12168
|
+
|
|
12169
|
+
Every result is detached: changing its object or arrays changes no document,
|
|
12170
|
+
pose, cached topology or subsequent read. Missing slots, attachments and skins,
|
|
12171
|
+
and region, clipping, path or bounding-box targets, throw `CoreInputError`
|
|
12172
|
+
naming the requested identity and the problem. Examples include
|
|
12173
|
+
`setup mesh slot "s" attachment "missing" under skin null: no such attachment`
|
|
12174
|
+
and `…: region is not a mesh`. Ambiguous all-skin definitions name their skins
|
|
12175
|
+
and say `choose a named skin`; unknown skin retains `underSkin`'s existing
|
|
12176
|
+
`--skin "missing": no such skin` refusal. Existing setup/non-finite refusals
|
|
12177
|
+
propagate; the read does not manufacture a partial result when setup is absent.
|
|
12178
|
+
|
|
12179
|
+
`S1538` controls exercise literal coordinates, detached data, linked meshes,
|
|
12180
|
+
skin lookup and activation, setup-slider deform, and the official runtime's
|
|
12181
|
+
`computeWorldVertices` using ordinary JS output arrays at tolerance 0. The
|
|
12182
|
+
runtime stores local mesh coordinates and weights in float32, but an output
|
|
12183
|
+
`Float32Array` would add a second rounding that this API does not perform.
|
|
12184
|
+
|
|
12185
|
+
**Measured nonselected slider distinction.** In spine-core 4.3.13,
|
|
12186
|
+
`computeWorldVertices(skeleton, slot, …)` over an unselected mesh still reads
|
|
12187
|
+
the **selected slot's deform array**, even for a linked mesh with
|
|
12188
|
+
`timelines: false`. `S1538_09` measures a slider at time 0.5 interpolating
|
|
12189
|
+
local offsets `(1,2)` and `(3,4)` to `(2,3)`, with a selected source mesh,
|
|
12190
|
+
a linked mesh inheriting its timeline, and an independent linked mesh.
|
|
12191
|
+
All three selected reads are exact. Four of the six nonselected calls to the
|
|
12192
|
+
official method reuse the selected deform; their maximum absolute difference
|
|
12193
|
+
from this API is `2.9999999999999982` world units. The other two nonselected
|
|
12194
|
+
calls, with the independent linked mesh selected, agree exactly. The API's
|
|
12195
|
+
nonselected output is the stored source geometry through the unchanged setup
|
|
12196
|
+
matrices; it does not borrow a different selection's deform. The control
|
|
12197
|
+
checks both results and the source of the difference, without an epsilon.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.67.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/core/animation.ts
CHANGED
|
@@ -747,8 +747,18 @@ export interface AdjustableLocals {
|
|
|
747
747
|
bone(name: string): BoneLocals;
|
|
748
748
|
}
|
|
749
749
|
|
|
750
|
-
/**
|
|
751
|
-
export
|
|
750
|
+
/** The pose being adjusted (issue #1540), independent of the physics clock. */
|
|
751
|
+
export interface LocalAdjustContext {
|
|
752
|
+
/** Current track-0 entry's unwrapped seconds, starting at 0; null without that entry. */
|
|
753
|
+
readonly trackTime: number | null;
|
|
754
|
+
/** That same entry's animation name; null without it or for an explicit empty entry. */
|
|
755
|
+
readonly animation: string | null;
|
|
756
|
+
/** Running sum of this walk's steps, in seconds; never the physics clock. */
|
|
757
|
+
readonly globalTime: number;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Called once per evaluated pose, after track composition and before world transforms and physics. Unary callbacks remain assignable. */
|
|
761
|
+
export type LocalAdjust = (locals: AdjustableLocals, ctx: LocalAdjustContext) => void;
|
|
752
762
|
|
|
753
763
|
/** The seven channels a `BoneLocals` reads and writes, each with the value an unstated one reads as (`worldTransforms`' defaults). */
|
|
754
764
|
const LOCAL_CHANNELS = { rotation: 0, x: 0, y: 0, scaleX: 1, scaleY: 1, shearX: 0, shearY: 0 } as const;
|
|
@@ -789,7 +799,7 @@ class StepLocals implements BoneLocals {
|
|
|
789
799
|
}
|
|
790
800
|
|
|
791
801
|
/** The adjustment run over a step's `bones` (`posedBones`' array, in the document's order), which it may replace bone by bone (`BoneLocals`). */
|
|
792
|
-
function adjustLocals(doc: CompiledDocument, bones: ModelBone[], adjust: LocalAdjust): void {
|
|
802
|
+
function adjustLocals(doc: CompiledDocument, bones: ModelBone[], adjust: LocalAdjust, context: LocalAdjustContext): void {
|
|
793
803
|
const views = new Map<string, StepLocals>();
|
|
794
804
|
const names = doc.bones.map((b) => b.name);
|
|
795
805
|
adjust({
|
|
@@ -805,7 +815,7 @@ function adjustLocals(doc: CompiledDocument, bones: ModelBone[], adjust: LocalAd
|
|
|
805
815
|
}
|
|
806
816
|
return view;
|
|
807
817
|
},
|
|
808
|
-
});
|
|
818
|
+
}, context);
|
|
809
819
|
}
|
|
810
820
|
|
|
811
821
|
const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
@@ -925,7 +935,8 @@ export function slotRowsFrom(
|
|
|
925
935
|
export interface PoseStep {
|
|
926
936
|
ctx: PhysicsStepContext;
|
|
927
937
|
before: number;
|
|
928
|
-
|
|
938
|
+
/** Callback and its authoritative evaluation identity travel together; no inferred clock fallback. */
|
|
939
|
+
adjustment?: { callback: LocalAdjust; context: LocalAdjustContext };
|
|
929
940
|
}
|
|
930
941
|
|
|
931
942
|
/**
|
|
@@ -991,7 +1002,7 @@ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines,
|
|
|
991
1002
|
if (constraints !== undefined && step !== undefined) return steppedLocals(doc, bones, plant, () => pathDeformed(doc, posedRecords(constraintRecords(doc), constraints, t), timelines, t, plant), constraints.physicsKeyed ?? [], t, sliders, step);
|
|
992
1003
|
const active = activeBones(doc);
|
|
993
1004
|
// The caller's adjustment, after the timelines and before anything reads the locals (`LocalAdjust`).
|
|
994
|
-
if (step?.
|
|
1005
|
+
if (step?.adjustment !== undefined) adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
|
|
995
1006
|
let world = (plant.evaluate ?? worldTransforms)(bones, active);
|
|
996
1007
|
if (constraints !== undefined) {
|
|
997
1008
|
const setupRecords = constraintRecords(doc);
|
|
@@ -1018,7 +1029,7 @@ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines,
|
|
|
1018
1029
|
function steppedLocals(doc: CompiledDocument, bones: ModelBone[], plant: TimelinePlant, records: () => CoreConstraintRecord[], physicsKeyed: NonNullable<CoreConstraintTimelines['physicsKeyed']>, t: number, sliders: SliderApplication[] | undefined, step: PoseStep): PosedStep {
|
|
1019
1030
|
const active = activeBones(doc);
|
|
1020
1031
|
// The caller's adjustment, after the timelines and before anything reads the locals (`LocalAdjust`).
|
|
1021
|
-
if (step.
|
|
1032
|
+
if (step.adjustment !== undefined) adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
|
|
1022
1033
|
let world = (plant.evaluate ?? worldTransforms)(bones, active);
|
|
1023
1034
|
const stepped = stepPhysicsRecords(records(), physicsKeyed, t, active, step.ctx, step.before);
|
|
1024
1035
|
world = applyConstraints(bones, world, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, undefined, solverRules(plant.solver));
|
package/src/core/constraints.ts
CHANGED
|
@@ -1548,7 +1548,8 @@ export function applyConstraints(bones: readonly ModelBone[], world: ReadonlyMap
|
|
|
1548
1548
|
// Under Physics.none a physics constraint applies nothing; stepped, it moves its bone in world space (`./constraints_physics.ts`).
|
|
1549
1549
|
if (physics !== undefined) {
|
|
1550
1550
|
const w = { ...(state.world.get(c.bone) as CoreWorld) };
|
|
1551
|
-
const
|
|
1551
|
+
const authored = physicsPose?.records.get(c.name) ?? c;
|
|
1552
|
+
const posed = physics.parameters?.apply(authored) ?? authored;
|
|
1552
1553
|
if ((physics.step ?? stepPhysics)(posed, w, bone(state, c.bone).length ?? 0, physics)) {
|
|
1553
1554
|
state.world.set(c.bone, w);
|
|
1554
1555
|
changed = [c.bone];
|
|
@@ -583,6 +583,8 @@ export type PhysicsStepper = (r: CorePhysicsRecord, w: CoreWorld, length: number
|
|
|
583
583
|
/** What a stepped pose carries into every physics constraint it applies. */
|
|
584
584
|
export interface PhysicsStepContext {
|
|
585
585
|
phase: PhysicsPhase;
|
|
586
|
+
/** Live track's final parameter layer, after authored/keyed/slider values (issue #1541). */
|
|
587
|
+
parameters?: { apply(record: CorePhysicsRecord): CorePhysicsRecord };
|
|
586
588
|
/** The skeleton's clock: the sum of every step's delta so far. */
|
|
587
589
|
time: number;
|
|
588
590
|
/** Each constraint's state, by name, carried from update to update. */
|
package/src/core/deform.ts
CHANGED
|
@@ -180,9 +180,9 @@
|
|
|
180
180
|
*/
|
|
181
181
|
import type { ModelSlot, ModelVertices } from '../model.ts';
|
|
182
182
|
import { bezierPolyline, keyIndexAt, type CoreAnimationTimelines, type CoreCurve } from './animation.ts';
|
|
183
|
-
import { activeBones, shownRow, type CompiledDocument, type CoreShown, type CoreSkin, type ShownResolution } from './index.ts';
|
|
183
|
+
import { activeBones, shownAttachment, shownRow, type CompiledDocument, type CoreShown, type CoreSkin, type ShownResolution } from './index.ts';
|
|
184
184
|
import { fillingSkins, slotTimelinesApply, type SlotTimelineGate } from './skins.ts';
|
|
185
|
-
import type {
|
|
185
|
+
import type { ShownGeometry } from './vertices.ts';
|
|
186
186
|
|
|
187
187
|
/** The far end the deform curve's recurrence runs to — the header's measurement. */
|
|
188
188
|
export const DEFORM_CURVE_END = 0.99999999;
|
|
@@ -515,6 +515,111 @@ export function timelineIdentity(slot: string, shown: CoreShown): string | null
|
|
|
515
515
|
|
|
516
516
|
const nameOf = (a: CoreAttachmentTimeline): string => `${a.skin}/${a.slot}/${a.attachment}`;
|
|
517
517
|
|
|
518
|
+
/** The part of an attachment state that depends only on the document view and placeholder. */
|
|
519
|
+
interface AttachmentIdentity {
|
|
520
|
+
readonly entry: ShownGeometry;
|
|
521
|
+
readonly identity: string | null;
|
|
522
|
+
readonly vertices: ModelVertices | null;
|
|
523
|
+
readonly count: number | undefined;
|
|
524
|
+
readonly fillers: readonly string[];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Like shownAttachment's cache, scoped to the view; a new skin view resolves its own records. */
|
|
528
|
+
const identitiesOfView = new WeakMap<CompiledDocument, Map<string, Map<string, AttachmentIdentity | null>>>();
|
|
529
|
+
|
|
530
|
+
function readIdentity(doc: CompiledDocument, resolve: (doc: CompiledDocument, slot: ModelSlot) => ShownResolution, slot: ModelSlot, placeholder: string): AttachmentIdentity | null {
|
|
531
|
+
const s = resolve(doc, { ...slot, setup: placeholder });
|
|
532
|
+
if (s === null || 'conflict' in s || s.record.geometry === undefined) return null;
|
|
533
|
+
const g = s.record.geometry;
|
|
534
|
+
const source = g.kind === 'linkedmesh' ? doc.skins.find((k) => k.name === g.skin)?.attachments[g.slot]?.[g.source] : s.record;
|
|
535
|
+
const sg = source?.geometry;
|
|
536
|
+
return Object.freeze({
|
|
537
|
+
entry: Object.freeze({ slot: slot.name, bone: slot.bone, name: shownRow(s).name, placeholder: s.placeholder, skin: s.skin, geometry: g }),
|
|
538
|
+
identity: timelineIdentity(slot.name, s),
|
|
539
|
+
vertices: sg !== undefined && sg.kind !== 'region' && sg.kind !== 'linkedmesh' ? sg.vertices : null,
|
|
540
|
+
count: source?.sequenceCount,
|
|
541
|
+
fillers: Object.freeze(fillingSkins(doc, slot.name, s.placeholder).map((k) => `"${k}"`)),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function identityOf(doc: CompiledDocument, resolve: (doc: CompiledDocument, slot: ModelSlot) => ShownResolution, slot: ModelSlot, placeholder: string | null): AttachmentIdentity | null {
|
|
546
|
+
if (placeholder === null) return null;
|
|
547
|
+
// A planted resolver may read changing state outside the document. Only the document's own reader is immutable.
|
|
548
|
+
if (resolve !== shownAttachment) return readIdentity(doc, resolve, slot, placeholder);
|
|
549
|
+
let slots = identitiesOfView.get(doc);
|
|
550
|
+
if (slots === undefined) identitiesOfView.set(doc, slots = new Map());
|
|
551
|
+
let placeholders = slots.get(slot.name);
|
|
552
|
+
if (placeholders === undefined) slots.set(slot.name, placeholders = new Map());
|
|
553
|
+
if (placeholders.has(placeholder)) return placeholders.get(placeholder) as AttachmentIdentity | null;
|
|
554
|
+
const identity = readIdentity(doc, resolve, slot, placeholder);
|
|
555
|
+
placeholders.set(placeholder, identity);
|
|
556
|
+
return identity;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Index only immutable timeline identity. Times, mixes and the values they compute are never cached. */
|
|
560
|
+
const attachmentsByIdentity = new WeakMap<readonly CoreAttachmentTimeline[], ReadonlyMap<string, readonly CoreAttachmentTimeline[]>>();
|
|
561
|
+
type AttachmentSlots = AttachmentApplication['timelines']['slots'];
|
|
562
|
+
type AttachmentSwitch = NonNullable<AttachmentSlots[number]['timelines'][number]['keys']>;
|
|
563
|
+
const switchesBySlot = new WeakMap<AttachmentSlots, ReadonlyMap<string, readonly AttachmentSwitch[]>>();
|
|
564
|
+
|
|
565
|
+
function matchingAttachments(timelines: readonly CoreAttachmentTimeline[], identity: string): readonly CoreAttachmentTimeline[] | undefined {
|
|
566
|
+
let index = attachmentsByIdentity.get(timelines);
|
|
567
|
+
if (index === undefined) {
|
|
568
|
+
const rows = new Map<string, CoreAttachmentTimeline[]>();
|
|
569
|
+
for (const a of timelines) {
|
|
570
|
+
const name = nameOf(a);
|
|
571
|
+
const found = rows.get(name);
|
|
572
|
+
if (found === undefined) rows.set(name, [a]);
|
|
573
|
+
else found.push(a);
|
|
574
|
+
}
|
|
575
|
+
attachmentsByIdentity.set(timelines, index = rows);
|
|
576
|
+
}
|
|
577
|
+
return index.get(identity);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function slotSwitches(slots: AttachmentSlots, slot: string): readonly AttachmentSwitch[] | undefined {
|
|
581
|
+
let index = switchesBySlot.get(slots);
|
|
582
|
+
if (index === undefined) {
|
|
583
|
+
const rows = new Map<string, AttachmentSwitch[]>();
|
|
584
|
+
for (const target of slots) {
|
|
585
|
+
for (const tl of target.timelines) {
|
|
586
|
+
if (tl.kind !== 'attachment' || tl.keys === undefined) continue;
|
|
587
|
+
const found = rows.get(target.name);
|
|
588
|
+
if (found === undefined) rows.set(target.name, [tl.keys]);
|
|
589
|
+
else found.push(tl.keys);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
switchesBySlot.set(slots, index = rows);
|
|
593
|
+
}
|
|
594
|
+
return index.get(slot);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Allocated only when a matching timeline or a mixed deform actually has per-pose state. */
|
|
598
|
+
interface AttachmentValues {
|
|
599
|
+
deform: number[] | null;
|
|
600
|
+
frame: number | null;
|
|
601
|
+
seen: string[];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function applyAttachment(now: AttachmentIdentity | null, values: AttachmentValues | null, timelines: readonly CoreAttachmentTimeline[], t: number, blend: AttachmentApplication | null, evalDeform: DeformEvaluator, blendOf: DeformBlender, evalFrame: SequenceEvaluator): AttachmentValues | null {
|
|
605
|
+
if (now === null || now.identity === null) return values;
|
|
606
|
+
const matching = matchingAttachments(timelines, now.identity);
|
|
607
|
+
if (matching === undefined) return values;
|
|
608
|
+
values ??= { deform: null, frame: null, seen: [] };
|
|
609
|
+
if (!values.seen.includes(now.identity)) values.seen.push(now.identity);
|
|
610
|
+
for (const a of matching) {
|
|
611
|
+
if (a.deform !== null && now.vertices !== null) {
|
|
612
|
+
const target = evalDeform(now.vertices, a.deform, t);
|
|
613
|
+
if (target !== null) values.deform = blend === null ? target : blendOf(now.vertices, values.deform, target, blend.alpha, blend.additive);
|
|
614
|
+
}
|
|
615
|
+
if (a.sequence !== null && now.count !== undefined) {
|
|
616
|
+
const f = evalFrame(a.sequence, now.count, t);
|
|
617
|
+
if (f !== null) values.frame = f;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return values;
|
|
621
|
+
}
|
|
622
|
+
|
|
518
623
|
/**
|
|
519
624
|
* Every slot's shown geometry at one pose — the setup's (`sample` null) or a
|
|
520
625
|
* sample's — with the deform array and the series' frame its attachment
|
|
@@ -528,6 +633,11 @@ const nameOf = (a: CoreAttachmentTimeline): string => `${a.skin}/${a.slot}/${a.a
|
|
|
528
633
|
* differently is left to the slots' own absence. `why` names what the core
|
|
529
634
|
* does not pose: a timeline moving a record whose placeholder several skins
|
|
530
635
|
* fill (the header's ⚠️).
|
|
636
|
+
*
|
|
637
|
+
* Identity is kept per document view, slot and placeholder, and timelines
|
|
638
|
+
* indexed by their record identity. A slot with no per-pose values reuses its
|
|
639
|
+
* frozen entry; deform arrays and sequence frames are evaluated afresh.
|
|
640
|
+
* Sliders select identities in application order, never a cached posed value.
|
|
531
641
|
*/
|
|
532
642
|
export function attachmentStates(
|
|
533
643
|
doc: CompiledDocument,
|
|
@@ -557,66 +667,39 @@ export function attachmentStates(
|
|
|
557
667
|
const live = gate(doc, slot, active);
|
|
558
668
|
let placeholder = placeholders.get(slot.name) ?? null;
|
|
559
669
|
const base = live ? mixed?.get(slot.name) : undefined;
|
|
560
|
-
let
|
|
561
|
-
let
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const apply = (timelines: readonly CoreAttachmentTimeline[], t: number, blend: { alpha: number; additive: boolean } | null): void => {
|
|
575
|
-
const now = state();
|
|
576
|
-
if (now === null || now.identity === null) return;
|
|
577
|
-
for (const a of timelines) {
|
|
578
|
-
if (nameOf(a) !== now.identity) continue;
|
|
579
|
-
if (!seen.includes(now.identity)) seen.push(now.identity);
|
|
580
|
-
if (a.deform !== null && now.vertices !== null) {
|
|
581
|
-
const target = evalDeform(now.vertices, a.deform, t);
|
|
582
|
-
if (target !== null) deform = blend === null ? target : blendOf(now.vertices, deform, target, blend.alpha, blend.additive);
|
|
583
|
-
}
|
|
584
|
-
if (a.sequence !== null && now.count !== undefined) {
|
|
585
|
-
const f = evalFrame(a.sequence, now.count, t);
|
|
586
|
-
if (f !== null) frame = f;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
if (sample !== null && live) apply(sample.timelines.attachments, sample.t, null);
|
|
591
|
-
for (const app of live ? sliders : []) {
|
|
592
|
-
// The slider's attachment key first, as the slot timelines are (`applySliderSlots` in `./constraints_slider.ts`); a switch to another placeholder clears what the timelines set.
|
|
593
|
-
for (const target of app.timelines.slots) {
|
|
594
|
-
if (target.name !== slot.name) continue;
|
|
595
|
-
for (const tl of target.timelines) {
|
|
596
|
-
if (tl.kind !== 'attachment' || tl.keys === undefined) continue;
|
|
597
|
-
const i = keyIndexAt(tl.keys, app.at);
|
|
598
|
-
if (i < 0) continue;
|
|
599
|
-
const next = tl.keys[i].name ?? null;
|
|
600
|
-
if (next !== placeholder) {
|
|
601
|
-
deform = null;
|
|
602
|
-
frame = null;
|
|
670
|
+
let values: AttachmentValues | null = base === undefined || (base.deform === null && base.seen.length === 0) ? null : { deform: base.deform, frame: null, seen: [...base.seen] };
|
|
671
|
+
let now = identityOf(doc, resolve, slot, placeholder);
|
|
672
|
+
if (sample !== null && live) values = applyAttachment(now, values, sample.timelines.attachments, sample.t, null, evalDeform, blendOf, evalFrame);
|
|
673
|
+
if (live) for (const app of sliders) {
|
|
674
|
+
// Each slider switches first, clearing only the per-pose values; then its attachment timelines apply to the new identity.
|
|
675
|
+
const switches = slotSwitches(app.timelines.slots, slot.name);
|
|
676
|
+
if (switches !== undefined) for (const keys of switches) {
|
|
677
|
+
const i = keyIndexAt(keys, app.at);
|
|
678
|
+
if (i < 0) continue;
|
|
679
|
+
const next = keys[i].name ?? null;
|
|
680
|
+
if (next !== placeholder) {
|
|
681
|
+
if (values !== null) {
|
|
682
|
+
values.deform = null;
|
|
683
|
+
values.frame = null;
|
|
603
684
|
}
|
|
604
685
|
placeholder = next;
|
|
686
|
+
now = identityOf(doc, resolve, slot, placeholder);
|
|
605
687
|
}
|
|
606
688
|
}
|
|
607
|
-
|
|
689
|
+
values = applyAttachment(now, values, app.timelines.attachments, app.at, app, evalDeform, blendOf, evalFrame);
|
|
608
690
|
}
|
|
609
|
-
const now = state();
|
|
610
691
|
if (now === null) continue;
|
|
611
|
-
|
|
692
|
+
// The cached entry is frozen. A pose with a deform/frame owns its wrapper and values; later poses cannot write into it.
|
|
693
|
+
const entry = values === null || (values.deform === null && values.frame === null) ? now.entry : { ...now.entry };
|
|
612
694
|
shown.push(entry);
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
why.push(`slot "${slot.name}" shows placeholder "${now.s.placeholder}", which skins ${fillers.join(', ')} fill, and ${seen.map((x) => `"${x}"`).join(', ')} key(s) it — which record --skin all shows, and so whether the timeline moves it, is the Spine file's skin order, not the model's`);
|
|
695
|
+
if (values !== null && values.seen.length > 0 && now.fillers.length > 1) {
|
|
696
|
+
why.push(`slot "${slot.name}" shows placeholder "${now.entry.placeholder}", which skins ${now.fillers.join(', ')} fill, and ${values.seen.map((x) => `"${x}"`).join(', ')} key(s) it — which record --skin all shows, and so whether the timeline moves it, is the Spine file's skin order, not the model's`);
|
|
616
697
|
continue;
|
|
617
698
|
}
|
|
618
|
-
if (
|
|
619
|
-
|
|
699
|
+
if (values !== null) {
|
|
700
|
+
if (values.deform !== null) entry.deform = values.deform;
|
|
701
|
+
if (values.frame !== null) entry.frame = values.frame;
|
|
702
|
+
}
|
|
620
703
|
}
|
|
621
704
|
return { shown, why };
|
|
622
705
|
}
|
package/src/core/entry.ts
CHANGED
|
@@ -24,17 +24,21 @@
|
|
|
24
24
|
* a Bézier segment as, so that a fitter holds its tolerance to the poser's
|
|
25
25
|
* own evaluation rather than to a mirror of it, issue #1417 — re-exported,
|
|
26
26
|
* never copied, because a copy is the drift the request exists to remove;
|
|
27
|
-
* `CA12` holds the identity
|
|
27
|
+
* `CA12` holds the identity; `readSetupMesh`, issue #1538, the named setup
|
|
28
|
+
* geometry read requested by the player, with `SetupMesh` and `SetupMeshTarget`;
|
|
29
|
+
* `PhysicsParameters`, `PhysicsParameterPatch`, `PhysicsParameterRead`, the live
|
|
30
|
+
* named constraint overrides requested on issue #1541).
|
|
28
31
|
* The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` rows for
|
|
29
32
|
* `./core`, and a symbol added here and not there is not promised.
|
|
30
33
|
*/
|
|
31
34
|
export { readModel, underSkin, underNoSkin, CoreInputError, CORE_DOCUMENT_SPEC, CORE_DOCUMENT_SPECS, CORE_BLEND_MODES, activeBones } from './index.ts';
|
|
32
35
|
export type { CompiledDocument, CoreSlotRow, CoreBlendMode } from './index.ts';
|
|
33
|
-
export { poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setupBounds, physicsClockStep, PHYSICS_CLOCK_LEAD } from './raw.ts';
|
|
34
|
-
export type { RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
|
|
36
|
+
export { readSetupMesh, poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setupBounds, physicsClockStep, PHYSICS_CLOCK_LEAD } from './raw.ts';
|
|
37
|
+
export type { SetupMesh, SetupMeshTarget, RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
|
|
35
38
|
export { poseWalkSetup, poseLoopingWalk } from './walk.ts';
|
|
36
39
|
export { openTrack } from './track.ts';
|
|
37
|
-
export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust, PhysicsEnvironment } from './track.ts';
|
|
40
|
+
export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust, LocalAdjustContext, PhysicsEnvironment } from './track.ts';
|
|
41
|
+
export type { PhysicsParameters, PhysicsParameterPatch, PhysicsParameterRead } from './physics_parameters.ts';
|
|
38
42
|
export { openTracks } from './tracks.ts';
|
|
39
43
|
export type { LiveTracks, TracksOptions, MixOptions, QueueOptions, TrackEntryView } from './tracks.ts';
|
|
40
44
|
export type { WalkPose } from './walk.ts';
|
package/src/core/index.ts
CHANGED
|
@@ -1196,10 +1196,11 @@ const shownOfView = new WeakMap<CompiledDocument, Map<string, Map<string, ShownR
|
|
|
1196
1196
|
* The answer reads the view's skins and the slot's name and placeholder only,
|
|
1197
1197
|
* so it is resolved once per view, slot and placeholder and kept (issue
|
|
1198
1198
|
* #1134: every sample of a walk resolved every slot again). Read-only to every
|
|
1199
|
-
* caller; a view built anew resolves its own.
|
|
1199
|
+
* caller; a view built anew resolves its own. An explicit placeholder reads
|
|
1200
|
+
* another table key without changing the slot (issue #1538).
|
|
1200
1201
|
*/
|
|
1201
|
-
export function shownAttachment(doc: CompiledDocument, slot: ModelSlot): ShownResolution {
|
|
1202
|
-
if (
|
|
1202
|
+
export function shownAttachment(doc: CompiledDocument, slot: ModelSlot, placeholder: string | null = slot.setup): ShownResolution {
|
|
1203
|
+
if (placeholder === null) return null;
|
|
1203
1204
|
let bySlot = shownOfView.get(doc);
|
|
1204
1205
|
if (bySlot === undefined) {
|
|
1205
1206
|
bySlot = new Map();
|
|
@@ -1210,9 +1211,9 @@ export function shownAttachment(doc: CompiledDocument, slot: ModelSlot): ShownRe
|
|
|
1210
1211
|
byPlaceholder = new Map();
|
|
1211
1212
|
bySlot.set(slot.name, byPlaceholder);
|
|
1212
1213
|
}
|
|
1213
|
-
if (byPlaceholder.has(
|
|
1214
|
-
const resolved = resolveShown(doc, slot.name,
|
|
1215
|
-
byPlaceholder.set(
|
|
1214
|
+
if (byPlaceholder.has(placeholder)) return byPlaceholder.get(placeholder) as ShownResolution;
|
|
1215
|
+
const resolved = resolveShown(doc, slot.name, placeholder);
|
|
1216
|
+
byPlaceholder.set(placeholder, resolved);
|
|
1216
1217
|
return resolved;
|
|
1217
1218
|
}
|
|
1218
1219
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Live, per-instance parameter patches (issue #1541). No model writes or history resets. */
|
|
2
|
+
import { CoreInputError, type CompiledDocument } from './index.ts';
|
|
3
|
+
import type { CorePhysicsRecord } from './constraints_physics.ts';
|
|
4
|
+
|
|
5
|
+
/** The six scalar responses a consumer can override; no units or range conversion. */
|
|
6
|
+
export interface PhysicsParameters {
|
|
7
|
+
readonly strength: number;
|
|
8
|
+
readonly damping: number;
|
|
9
|
+
readonly inertia: number;
|
|
10
|
+
readonly wind: number;
|
|
11
|
+
readonly gravity: number;
|
|
12
|
+
readonly mix: number;
|
|
13
|
+
}
|
|
14
|
+
/** Omitted fields keep their existing override, if any. */
|
|
15
|
+
export type PhysicsParameterPatch = Partial<PhysicsParameters>;
|
|
16
|
+
/** Detached snapshots. `effective` is null until this constraint has been evaluated. */
|
|
17
|
+
export interface PhysicsParameterRead {
|
|
18
|
+
readonly override: PhysicsParameterPatch | null;
|
|
19
|
+
readonly effective: PhysicsParameters | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FIELDS = ['strength', 'damping', 'inertia', 'wind', 'gravity', 'mix'] as const;
|
|
23
|
+
const values = (r: CorePhysicsRecord): PhysicsParameters => ({ strength: r.strength, damping: r.damping, inertia: r.inertia, wind: r.wind, gravity: r.gravity, mix: r.mix });
|
|
24
|
+
|
|
25
|
+
/** Owned by one live track, including all entries of a composed track. */
|
|
26
|
+
export class LivePhysicsParameters {
|
|
27
|
+
private readonly overrides = new Map<string, PhysicsParameterPatch>();
|
|
28
|
+
private readonly evaluated = new Map<string, PhysicsParameters>();
|
|
29
|
+
|
|
30
|
+
constructor(private readonly doc: CompiledDocument) {}
|
|
31
|
+
|
|
32
|
+
private named(name: string): void {
|
|
33
|
+
const constraint = this.doc.constraints.find((c) => c.name === name);
|
|
34
|
+
if (constraint === undefined) throw new CoreInputError(`physics parameters: no constraint "${String(name)}"; this document declares [${this.doc.constraints.map((c) => c.name).join(', ')}]`);
|
|
35
|
+
if (constraint.kind !== 'physics') throw new CoreInputError(`physics parameters: constraint "${name}" is ${constraint.kind}, not physics`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
set(name: string, patch: PhysicsParameterPatch): void {
|
|
39
|
+
this.named(name);
|
|
40
|
+
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) throw new CoreInputError(`physics parameters: constraint "${name}" patch is ${String(patch)}, not an object of finite fields [${FIELDS.join(', ')}]`);
|
|
41
|
+
const checked: Partial<Record<(typeof FIELDS)[number], number>> = {};
|
|
42
|
+
// Validate the complete patch before publishing it, including unknown symbol keys.
|
|
43
|
+
for (const key of Reflect.ownKeys(patch)) {
|
|
44
|
+
if (typeof key !== 'string' || !(FIELDS as readonly string[]).includes(key)) throw new CoreInputError(`physics parameters: constraint "${name}" field "${String(key)}" is unknown; expected [${FIELDS.join(', ')}]`);
|
|
45
|
+
const field = key as (typeof FIELDS)[number];
|
|
46
|
+
const value = patch[field];
|
|
47
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new CoreInputError(`physics parameters: constraint "${name}" field "${field}" is ${String(value)}, not a finite number`);
|
|
48
|
+
checked[field] = value;
|
|
49
|
+
}
|
|
50
|
+
if (Reflect.ownKeys(checked).length > 0) this.overrides.set(name, { ...this.overrides.get(name), ...checked });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
read(name: string): PhysicsParameterRead {
|
|
54
|
+
this.named(name);
|
|
55
|
+
const patch = this.overrides.get(name);
|
|
56
|
+
const effective = this.evaluated.get(name);
|
|
57
|
+
return { override: patch === undefined ? null : { ...patch }, effective: effective === undefined ? null : { ...effective } };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
clear(name: string): void {
|
|
61
|
+
this.named(name);
|
|
62
|
+
this.overrides.delete(name);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Called only for an active constraint, after the sliders and immediately before its step. */
|
|
66
|
+
apply(record: CorePhysicsRecord): CorePhysicsRecord {
|
|
67
|
+
const patch = this.overrides.get(record.name);
|
|
68
|
+
const posed = patch === undefined ? record : { ...record, ...patch };
|
|
69
|
+
this.evaluated.set(record.name, values(posed));
|
|
70
|
+
return posed;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/core/raw.ts
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
* naming why (`CoreInputError`): a renderer handed a pose with a block
|
|
114
114
|
* missing would draw a different picture in silence.
|
|
115
115
|
*/
|
|
116
|
-
import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
|
|
116
|
+
import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, underSkin, 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
119
|
import { freshStepContext, rotatePhysics, steppedPreviousPassWhy, translatePhysics, DEFAULT_PHYSICS_ENVIRONMENT, type PhysicsEnvironment, type PhysicsStepContext } from './constraints_physics.ts';
|
|
@@ -121,8 +121,9 @@ import { attachmentStates } from './deform.ts';
|
|
|
121
121
|
import { drawOrderAt, type CoreDrawOrderKey } from './draw_order.ts';
|
|
122
122
|
import { eventsFired, type CoreEventRow } from './events.ts';
|
|
123
123
|
import { REGION_TRIANGLES, REGION_UVS, type CoreClippedRow, type ShapeClipper } from './clipping.ts';
|
|
124
|
-
import { meshArraysOf, poseGeometry, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
|
|
124
|
+
import { meshArraysOf, poseGeometry, worldVertices, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
|
|
125
125
|
import { RUNTIME_DEG, type CoreWorld } from './world.ts';
|
|
126
|
+
import { fillingSkins } from './skins.ts';
|
|
126
127
|
import type { SliderApplication } from './constraints_slider.ts';
|
|
127
128
|
|
|
128
129
|
/** One bone, posed: the world transform and the runtime getters' four readings (the header's `bones`). */
|
|
@@ -465,6 +466,81 @@ export function poseRawSetup(doc: CompiledDocument, plant: CorePlant = {}): RawP
|
|
|
465
466
|
return setupPoseIn(doc, plant, RAW_MODE);
|
|
466
467
|
}
|
|
467
468
|
|
|
469
|
+
/** A table key to read, with an optional skin-view override; omitted skin keeps the supplied view. */
|
|
470
|
+
export interface SetupMeshTarget {
|
|
471
|
+
readonly slot: string;
|
|
472
|
+
readonly attachment: string;
|
|
473
|
+
/** A named skin (with default fallback), `all`, or `null` for no skin set. */
|
|
474
|
+
readonly skin?: string | null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** A detached named mesh in setup world units, y up; hull counts vertices, not coordinates. */
|
|
478
|
+
export interface SetupMesh {
|
|
479
|
+
slot: string;
|
|
480
|
+
/** The requested attachment table key, even when the record states another display name. */
|
|
481
|
+
attachment: string;
|
|
482
|
+
/** The skin whose table supplied the requested record, including a fallback. */
|
|
483
|
+
skin: string;
|
|
484
|
+
vertices: number[];
|
|
485
|
+
triangles: number[];
|
|
486
|
+
hull: number | null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Model definitions by value: object key order is irrelevant, array positions and signed zero are not. */
|
|
490
|
+
function sameSetupMeshDefinition(a: unknown, b: unknown): boolean {
|
|
491
|
+
if (Object.is(a, b)) return true;
|
|
492
|
+
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false;
|
|
493
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
494
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length !== b.length) return false;
|
|
495
|
+
const left = a as Record<string, unknown>;
|
|
496
|
+
const right = b as Record<string, unknown>;
|
|
497
|
+
const keys = Object.keys(left);
|
|
498
|
+
return keys.length === Object.keys(right).length && keys.every((key) =>
|
|
499
|
+
Object.prototype.hasOwnProperty.call(right, key) && sameSetupMeshDefinition(left[key], right[key]));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Read a named mesh without changing the setup selection or playing anything
|
|
504
|
+
* (issue #1538). Setup constraints and reset physics use the raw poser's own
|
|
505
|
+
* path. A selected mesh includes its setup-slider deform; an unselected mesh
|
|
506
|
+
* reads its stored geometry through those same matrices, with no hypothetical
|
|
507
|
+
* attachment switch or deform. Linked meshes use their source topology and
|
|
508
|
+
* bindings, and the queried slot's bone for unweighted vertices; `timelines`
|
|
509
|
+
* affects only a selected mesh's setup-slider deform, as in `poseRawSetup`.
|
|
510
|
+
*
|
|
511
|
+
* Skin resolution is the supplied view's unless explicitly overridden. Under
|
|
512
|
+
* `all`, differing definitions of the requested mesh are refused: the model
|
|
513
|
+
* does not carry the file order that would choose one. Object property order
|
|
514
|
+
* does not distinguish definitions; array positions and Object.is leaf values
|
|
515
|
+
* do. Identical definitions keep the resolver's first supplying skin. All arrays are fresh.
|
|
516
|
+
*/
|
|
517
|
+
export function readSetupMesh(doc: CompiledDocument, target: SetupMeshTarget): SetupMesh {
|
|
518
|
+
const view = target.skin === undefined ? doc : target.skin === null ? underNoSkin(doc) : underSkin(doc, target.skin);
|
|
519
|
+
const where = `setup mesh slot ${JSON.stringify(target.slot)} attachment ${JSON.stringify(target.attachment)} under skin ${JSON.stringify(view.skin)}`;
|
|
520
|
+
const slot = view.slots.find((s) => s.name === target.slot);
|
|
521
|
+
if (slot === undefined) throw new CoreInputError(`${where}: no such slot`);
|
|
522
|
+
const resolved = shownAttachment(view, slot, target.attachment);
|
|
523
|
+
if (resolved === null) throw new CoreInputError(`${where}: no such attachment`);
|
|
524
|
+
const fills = fillingSkins(view, slot.name, target.attachment);
|
|
525
|
+
const definitions = fills.map((name) => view.skins.find((k) => k.name === name)?.attachments[slot.name]?.[target.attachment]);
|
|
526
|
+
if ('conflict' in resolved || definitions.some((r) => !sameSetupMeshDefinition(r, definitions[0]))) {
|
|
527
|
+
throw new CoreInputError(`${where}: skins [${fills.join(', ')}] fill this attachment differently; choose a named skin`);
|
|
528
|
+
}
|
|
529
|
+
const geometry = resolved.record.geometry;
|
|
530
|
+
const mesh = geometry?.kind === 'linkedmesh'
|
|
531
|
+
? view.skins.find((s) => s.name === geometry.skin)?.attachments[geometry.slot]?.[geometry.source]?.geometry
|
|
532
|
+
: geometry;
|
|
533
|
+
if (mesh?.kind !== 'mesh') throw new CoreInputError(`${where}: ${resolved.record.kind} is not a mesh`);
|
|
534
|
+
const { world, shown, drawOrder } = setupPosed(view, {}, RAW_MODE);
|
|
535
|
+
const selected = shown.find((s) => s.slot === slot.name && s.placeholder === target.attachment && s.skin === resolved.skin);
|
|
536
|
+
const bone = world.get(slot.bone);
|
|
537
|
+
if (bone === undefined) throw new CoreInputError(`${where}: no setup transform for bone ${JSON.stringify(slot.bone)}`);
|
|
538
|
+
const vertices = selected === undefined
|
|
539
|
+
? numbersOf(worldVertices(mesh.vertices, bone, world).map(rawNumber), `${where} vertex`, RAW_MODE)
|
|
540
|
+
: rawDrawn(view, [selected], world, drawOrder, {}).drawn[0].vertices;
|
|
541
|
+
return { slot: slot.name, attachment: target.attachment, skin: resolved.skin, vertices: [...vertices], triangles: [...mesh.triangles], hull: mesh.hull ?? null };
|
|
542
|
+
}
|
|
543
|
+
|
|
468
544
|
/** `poseRawSetup` in `mode` — under `keep`, a value that is not finite stays in the pose (`./walk.ts`). */
|
|
469
545
|
export function setupPoseIn(doc: CompiledDocument, plant: CorePlant, mode: WalkMode): RawPose {
|
|
470
546
|
const { world, shown, slots, drawOrder } = setupPosed(doc, plant, mode);
|
|
@@ -830,8 +906,9 @@ function stepWalk<P>(track: TrackState<P>, dt: number, first = false): P {
|
|
|
830
906
|
// `clipped` rows are empty. The live track's looping mode (`live`) fires its events across the wrap by the rule `./track.ts` states as measured.
|
|
831
907
|
const fire = plant.events ?? eventsFired;
|
|
832
908
|
const keys = anim.timelines.events;
|
|
909
|
+
const adjustment = adjust === undefined ? undefined : { callback: adjust, context: { trackTime, animation: anim.name, globalTime: trackTime } };
|
|
833
910
|
const pose = poseStepFrom(doc, plant, resolve, shape, mode, ctx, first, trackTime, t, {
|
|
834
|
-
world: (sliders) => (shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before,
|
|
911
|
+
world: (sliders) => (shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjustment }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjustment })),
|
|
835
912
|
slots: (sliders, placeholders) => posedSlots(doc, anim.timelines, t, raw, sliders, placeholders),
|
|
836
913
|
drawOrder: anim.timelines.drawOrder,
|
|
837
914
|
at: t,
|
|
@@ -948,8 +1025,8 @@ function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly n
|
|
|
948
1025
|
* `stepRawTrack` steps. Every pose is assembled as the raw entry's
|
|
949
1026
|
* (`RAW_SHAPE`).
|
|
950
1027
|
*/
|
|
951
|
-
export function openRawTrack(doc: CompiledDocument, animation: string, plant: TimelinePlant, reset: RawReset, mode: WalkMode, adjust?: LocalAdjust): { track: TrackState<RawPose>; first: RawPose } {
|
|
952
|
-
return openWalk(doc, animationOf(doc, animation), plant, reset, mode, RAW_SHAPE, adjust);
|
|
1028
|
+
export function openRawTrack(doc: CompiledDocument, animation: string, plant: TimelinePlant, reset: RawReset, mode: WalkMode, adjust?: LocalAdjust, before?: (ctx: PhysicsStepContext) => void): { track: TrackState<RawPose>; first: RawPose } {
|
|
1029
|
+
return openWalk(doc, animationOf(doc, animation), plant, reset, mode, RAW_SHAPE, adjust, before);
|
|
953
1030
|
}
|
|
954
1031
|
|
|
955
1032
|
/** One step of an opened raw track (`openRawTrack`): `walkIn`'s step, the step itself checked by the caller. */
|
package/src/core/track.ts
CHANGED
|
@@ -225,7 +225,9 @@ import { CoreInputError, type CompiledDocument } from './index.ts';
|
|
|
225
225
|
import { checkedClock, environmentOf, openRawTrack, rotateOn, setEnvironmentOn, stepRawTrack, translateOn, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
|
|
226
226
|
import type { LocalAdjust, TimelinePlant } from './animation.ts';
|
|
227
227
|
import type { PhysicsEnvironment } from './constraints_physics.ts';
|
|
228
|
-
|
|
228
|
+
import { LivePhysicsParameters, type PhysicsParameterPatch, type PhysicsParameterRead } from './physics_parameters.ts';
|
|
229
|
+
export type { PhysicsParameters, PhysicsParameterPatch, PhysicsParameterRead } from './physics_parameters.ts';
|
|
230
|
+
export type { AdjustableLocals, BoneLocals, LocalAdjust, LocalAdjustContext } from './animation.ts';
|
|
229
231
|
export type { PhysicsEnvironment } from './constraints_physics.ts';
|
|
230
232
|
|
|
231
233
|
/** 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*). */
|
|
@@ -253,6 +255,12 @@ export interface LiveTrack {
|
|
|
253
255
|
setEnvironment(environment: Partial<PhysicsEnvironment>): void;
|
|
254
256
|
/** The environment the next pose answers. */
|
|
255
257
|
readonly environment: PhysicsEnvironment;
|
|
258
|
+
/** Merges a finite six-field patch for one physics constraint, applied from the next pose until clear. */
|
|
259
|
+
setPhysicsParameters(name: string, patch: PhysicsParameterPatch): void;
|
|
260
|
+
/** Pending patch and the last evaluated six values; null effective means not yet evaluated (e.g. inactive). */
|
|
261
|
+
readPhysicsParameters(name: string): PhysicsParameterRead;
|
|
262
|
+
/** Removes the patch for the next pose without resetting velocities, offsets or the clock. */
|
|
263
|
+
clearPhysicsParameters(name: string): void;
|
|
256
264
|
/** 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
265
|
physicsTranslate(x: number, y: number): void;
|
|
258
266
|
/** 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. */
|
|
@@ -275,8 +283,9 @@ export function openTrack(doc: CompiledDocument, animation: string, options: Tra
|
|
|
275
283
|
if (options.clock !== undefined && typeof options.clock !== 'function') throw new CoreInputError(`the live track's options.clock is ${typeof options.clock}, not a function`);
|
|
276
284
|
const mode = options.loop ? LOOPED : HELD;
|
|
277
285
|
const clock = options.clock === undefined ? undefined : checkedClock(options.clock, 'the live track\'s');
|
|
278
|
-
const
|
|
279
|
-
|
|
286
|
+
const parameters = new LivePhysicsParameters(doc);
|
|
287
|
+
const { track, first } = openRawTrack(doc, animation, plant, options.reset, clock === undefined ? mode : { ...mode, clock: (dt) => clock(dt) }, options.adjust, (ctx) => { ctx.parameters = parameters; });
|
|
288
|
+
return new Track(track, first, parameters);
|
|
280
289
|
}
|
|
281
290
|
|
|
282
291
|
class Track implements LiveTrack {
|
|
@@ -286,7 +295,7 @@ class Track implements LiveTrack {
|
|
|
286
295
|
private latest: number;
|
|
287
296
|
private refused: string | null = null;
|
|
288
297
|
|
|
289
|
-
constructor(track: TrackState<RawPose>, first: RawPose) {
|
|
298
|
+
constructor(track: TrackState<RawPose>, first: RawPose, private readonly parameters: LivePhysicsParameters) {
|
|
290
299
|
this.track = track;
|
|
291
300
|
this.first = first;
|
|
292
301
|
this.latest = first.animationTime;
|
|
@@ -313,6 +322,20 @@ class Track implements LiveTrack {
|
|
|
313
322
|
setEnvironmentOn(this.track.ctx, environment, 'the live track\'s');
|
|
314
323
|
}
|
|
315
324
|
|
|
325
|
+
setPhysicsParameters(name: string, patch: PhysicsParameterPatch): void {
|
|
326
|
+
this.live('setPhysicsParameters');
|
|
327
|
+
this.parameters.set(name, patch);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
readPhysicsParameters(name: string): PhysicsParameterRead {
|
|
331
|
+
return this.parameters.read(name);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
clearPhysicsParameters(name: string): void {
|
|
335
|
+
this.live('clearPhysicsParameters');
|
|
336
|
+
this.parameters.clear(name);
|
|
337
|
+
}
|
|
338
|
+
|
|
316
339
|
physicsTranslate(x: number, y: number): void {
|
|
317
340
|
this.live('physicsTranslate');
|
|
318
341
|
translateOn(this.track.ctx, x, y, 'the live track\'s');
|
package/src/core/tracks.ts
CHANGED
|
@@ -166,6 +166,7 @@ import { deformAt, setupArray, type CoreAttachmentTimeline } from './deform.ts';
|
|
|
166
166
|
import { eventsFired, type CoreEventKey, type CoreEventRow } from './events.ts';
|
|
167
167
|
import { checkedClock, environmentOf, openRawTracksPath, rotateOn, setEnvironmentOn, translateOn, type RawPose, type RawReset, type RawTracksPath } from './raw.ts';
|
|
168
168
|
import type { PhysicsEnvironment } from './constraints_physics.ts';
|
|
169
|
+
import { LivePhysicsParameters, type PhysicsParameterPatch, type PhysicsParameterRead } from './physics_parameters.ts';
|
|
169
170
|
import { slotTimelinesApply } from './skins.ts';
|
|
170
171
|
|
|
171
172
|
/** 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*). */
|
|
@@ -233,6 +234,12 @@ export interface LiveTracks {
|
|
|
233
234
|
setEnvironment(environment: Partial<PhysicsEnvironment>): void;
|
|
234
235
|
/** The environment the next pose answers. */
|
|
235
236
|
readonly environment: PhysicsEnvironment;
|
|
237
|
+
/** Per-instance final parameter layer, as LiveTrack.setPhysicsParameters. Persists across entry changes. */
|
|
238
|
+
setPhysicsParameters(name: string, patch: PhysicsParameterPatch): void;
|
|
239
|
+
/** Detached pending patch and last evaluated six values, or null effective before start / when never active. */
|
|
240
|
+
readPhysicsParameters(name: string): PhysicsParameterRead;
|
|
241
|
+
/** Removes the patch without resetting the physics history, as LiveTrack.clearPhysicsParameters. */
|
|
242
|
+
clearPhysicsParameters(name: string): void;
|
|
236
243
|
/** spine-core's `Skeleton.physicsTranslate`, as `LiveTrack.physicsTranslate`. */
|
|
237
244
|
physicsTranslate(x: number, y: number): void;
|
|
238
245
|
/** spine-core's `Skeleton.physicsRotate`, as `LiveTrack.physicsRotate`. */
|
|
@@ -1322,6 +1329,7 @@ export function openTracks(doc: CompiledDocument, options: TracksOptions, plant:
|
|
|
1322
1329
|
|
|
1323
1330
|
class Tracks implements LiveTracks {
|
|
1324
1331
|
private readonly mixer: Mixer;
|
|
1332
|
+
private readonly parameters: LivePhysicsParameters;
|
|
1325
1333
|
private started = false;
|
|
1326
1334
|
private steps = 0;
|
|
1327
1335
|
private time = 0;
|
|
@@ -1336,6 +1344,8 @@ class Tracks implements LiveTracks {
|
|
|
1336
1344
|
private readonly path: RawTracksPath,
|
|
1337
1345
|
) {
|
|
1338
1346
|
this.mixer = new Mixer(doc, plant);
|
|
1347
|
+
this.parameters = new LivePhysicsParameters(doc);
|
|
1348
|
+
this.path.ctx.parameters = this.parameters;
|
|
1339
1349
|
}
|
|
1340
1350
|
|
|
1341
1351
|
get trackTime(): number {
|
|
@@ -1351,6 +1361,20 @@ class Tracks implements LiveTracks {
|
|
|
1351
1361
|
setEnvironmentOn(this.path.ctx, environment, 'the live tracks\'');
|
|
1352
1362
|
}
|
|
1353
1363
|
|
|
1364
|
+
setPhysicsParameters(name: string, patch: PhysicsParameterPatch): void {
|
|
1365
|
+
this.live('setPhysicsParameters');
|
|
1366
|
+
this.parameters.set(name, patch);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
readPhysicsParameters(name: string): PhysicsParameterRead {
|
|
1370
|
+
return this.parameters.read(name);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
clearPhysicsParameters(name: string): void {
|
|
1374
|
+
this.live('clearPhysicsParameters');
|
|
1375
|
+
this.parameters.clear(name);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1354
1378
|
physicsTranslate(x: number, y: number): void {
|
|
1355
1379
|
this.live('physicsTranslate');
|
|
1356
1380
|
translateOn(this.path.ctx, x, y, 'the live tracks\'');
|
|
@@ -1467,9 +1491,14 @@ class Tracks implements LiveTracks {
|
|
|
1467
1491
|
ctx.time += this.clock === undefined ? dt : this.clock(dt);
|
|
1468
1492
|
const pose = new Pose(doc, this.plant);
|
|
1469
1493
|
const animationTime = this.mixer.apply(pose);
|
|
1494
|
+
const entry = this.mixer.tracks[0] ?? null;
|
|
1495
|
+
const adjustment = this.adjust === undefined ? undefined : {
|
|
1496
|
+
callback: this.adjust,
|
|
1497
|
+
context: { trackTime: entry?.trackTime ?? null, animation: entry?.anim.name ?? null, globalTime: this.time },
|
|
1498
|
+
};
|
|
1470
1499
|
const mixed = new Map(doc.slots.map((s, k) => [s.name, { deform: pose.slots[k].deform, seen: pose.slots[k].seen }]));
|
|
1471
1500
|
return path.step(first, this.time, animationTime, {
|
|
1472
|
-
world: (sliders) => steppedLocalsWorld(doc, pose.bones, path.raw, sliders, { ctx, before,
|
|
1501
|
+
world: (sliders) => steppedLocalsWorld(doc, pose.bones, path.raw, sliders, { ctx, before, adjustment }, true),
|
|
1473
1502
|
slots: (sliders, placeholders) => slotRowsFrom(doc, path.raw, sliders, placeholders, (_slot, _live): SlotBase => {
|
|
1474
1503
|
const s = pose.slots[doc.slots.indexOf(_slot)];
|
|
1475
1504
|
return { placeholder: s.placeholder, light: s.light, dark: s.dark };
|