spine-rigc 1.5.3 → 1.6.2

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.
@@ -0,0 +1,347 @@
1
+ /**
2
+ * The core's raw entry (issue #966, step 3b of issue #380): one pose, or one
3
+ * animation walked at the caller's own steps, returned as the doubles the
4
+ * core computed — no grid — with everything a renderer or a deform measurer
5
+ * reads past the oracle's rows: each bone's world rotation and scale
6
+ * readings, each drawn attachment's local UVs, triangles, hull, sequence
7
+ * frame and colour, the clip polygons, the triangles drawn under a clip, and
8
+ * the events fired.
9
+ *
10
+ * The oracle's document (`tools/pose_oracle.ts`) samples an animation on a
11
+ * phase grid, re-applying it from the setup pose at every sample; the two
12
+ * consumers step instead. `src/render.ts` resets a track at 0 and then takes
13
+ * exactly one step of `1/fps` per frame; `src/deformmeasure.ts` resets and
14
+ * takes one step of `time`. So the entry takes a list of steps rather than a
15
+ * list of times, and this header states what one step is, as measured.
16
+ *
17
+ * ## One step — the walk, measured
18
+ *
19
+ * Measured against spine-core 4.3.13 running `src/render.ts`'s recipe
20
+ * (`AnimationState` with one non-looping track: frame 0 `apply`,
21
+ * `Skeleton.update(0)`, `updateWorldTransform(Physics.reset)`; every later
22
+ * frame `AnimationState.update(dt)`, `apply`, `Skeleton.update(dt)`,
23
+ * `updateWorldTransform(Physics.update)`), read off `TrackEntry.trackTime`,
24
+ * `TrackEntry.getAnimationTime()` and every bone's `appliedPose` in full
25
+ * doubles (the PR of issue #966 carries the table):
26
+ *
27
+ * - **The track time is the running sum of the steps**, `T += dt` in double,
28
+ * not `i·dt`, and the animation is applied at the sum. Over every
29
+ * animation of the nineteen tree rows `TrackEntry.trackTime` equalled the
30
+ * running sum on 2,612 of 2,612 frames at 12 fps (12,850 of 12,850 at 60,
31
+ * 5,173 of 5,173 at 24) and `i·dt` on only 662 (1,168; 810): at 12 fps
32
+ * frame 6 reads `0.49999999999999994`, not `0.5`, and frame 10
33
+ * `0.8333333333333334`, not `0.8333333333333333`. `src/render.ts` files a
34
+ * frame under `i·dt`, which is its label and not the time posed.
35
+ * - **A non-looping track holds at its duration**: the animation time is
36
+ * `min(T, duration)`, where `duration` is the runtime's (the last key of
37
+ * every timeline, as float32 — `CoreAnimationTimelines.duration`). With
38
+ * `round(duration·fps)` frames the last one overshoots on 20 animation
39
+ * frames of the 2,612 at 12 fps (19 at 60, 25 at 24), and
40
+ * `getAnimationTime()` read the duration on every one of them.
41
+ * - **The physics clock moves by the step itself**, `time += dt` (the
42
+ * skeleton's `update`), and a physics constraint integrates the time it
43
+ * moved (`./constraints_physics.ts`). Pose 0 is the reset, every physics
44
+ * state reset there (where, below).
45
+ * - **Events fire over `(previous animation time, animation time]`**, the
46
+ * first step's interval opening at −1 — so held past the duration, a step
47
+ * fires nothing. Measured against `AnimationState`'s event listener on
48
+ * every frame above.
49
+ * - **Where the reset is taken is the caller's** (`RawReset`): render's
50
+ * frame 0 applies the animation at 0 and resets there (`animation`);
51
+ * deformmeasure's `poseAt` resets at the setup pose and then applies the
52
+ * animation in its one step (`setup`). On a physics rig the two differ by
53
+ * whole units, not bits: of the 162 one-jump poses (three per animation),
54
+ * 12 read off under `animation` — six on `7-anticipation`'s physics rig (a
55
+ * cape bone 1.7° off at 0.617 s) and six on `gallery/look`'s — and none
56
+ * under `setup`.
57
+ *
58
+ * Every step poses the animation from the setup pose at its animation time
59
+ * (the setup blend at alpha 1), as the oracle's stepped dump does, applied
60
+ * from the animation time of the step before (−1 before the first): a
61
+ * physics `reset` key fires on the step that crosses it, once. Until issue
62
+ * #960 this walk fired such a key at every step from it on (the oracle's
63
+ * reading then), and against the recipe above a probe with a key at 0.5
64
+ * read 32 of 61 frames bit-exact at 60 fps, the rest off by up to 53 units;
65
+ * with the key crossed once, 61 of 61 at 60 fps and 13 of 13 at 12. None of
66
+ * the nineteen tree rows keys a `reset`, which is why the raw gate never
67
+ * saw it. The one difference from `--physics step` is where the steps fall,
68
+ * which is the caller's.
69
+ *
70
+ * ## What a pose carries
71
+ *
72
+ * - `bones` — the world matrix and origin, and the four readings the
73
+ * runtime's getters give: `rotationX = atan2(c, a)·DEG`, `rotationY =
74
+ * atan2(d, b)·DEG`, `scaleX = √(a² + c²)`, `scaleY = √(b² + d²)`, with
75
+ * `DEG = 180 / π` at the runtime's π (`RUNTIME_DEG` in `./world.ts`) —
76
+ * measured bit-exact against `BonePose.getWorldRotationX/Y` and
77
+ * `getWorldScaleX/Y` on every bone of 2,793 poses of the nineteen tree
78
+ * rows (the setup, every 12 fps frame, three jumps per animation).
79
+ * - `slots` — the oracle's slot row as doubles (`posedSlots`).
80
+ * - `drawn` — every region and mesh a slot shows, in draw order: its world
81
+ * vertices (the oracle's `attachments` row as doubles), its LOCAL UVs as the
82
+ * runtime holds them (a region's unit corners `0 1, 0 0, 1 0, 1 1` in the
83
+ * corner order; a mesh's `uvs` — a linked mesh's source's — as the
84
+ * document spells them: `MeshAttachment.regionUVs` read the doubles, not
85
+ * their float32, on every mesh of the nineteen rows — `6-arcs`'
86
+ * `2.554152e-7` where `Math.fround` gives `2.5541518766658555e-7`), its
87
+ * triangles (a region's `0 1 2 2
88
+ * 3 0`), its hull (a mesh's; `null` for a region), its sequence frame (the
89
+ * frame a sequence timeline set, else the series' setup frame, else 0 —
90
+ * the runtime gives every region and mesh a series, one frame long when
91
+ * the record states none, and `Sequence.resolveIndex` read 0 on every such
92
+ * attachment of the nineteen rows) and its own colour (the record's `color`, white
93
+ * unstated) — so the caller forms the tint, slot colour times attachment
94
+ * colour, itself. Where a region sits on a page is not the model's
95
+ * (`ModelAtlasRect`'s 🔸); the page UVs are issue #967's.
96
+ * - `clips`, `clipped` — the oracle's rows as doubles; `clipped` is what the
97
+ * core DRAWS, which is the oracle's block wherever the core poses it and,
98
+ * under a clip that is not strictly convex or is inverse (a block the core
99
+ * leaves out), the core's own convex decomposition (`clipThrough` in
100
+ * `./clipping.ts`, issue #964), held to spine-core by the render's pixels.
101
+ * - `events` — the oracle's event rows as doubles.
102
+ *
103
+ * ⛔ **Nothing is posed that the core would leave out.** Where the oracle's
104
+ * core dump names a block absent — a constraint the core cannot pose
105
+ * exactly, skins disagreeing over a placeholder under the merged view, a
106
+ * region with no atlas rectangle — the raw entry refuses the whole call,
107
+ * naming why (`CoreInputError`): a renderer handed a pose with a block
108
+ * missing would draw a different picture in silence.
109
+ */
110
+ import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
111
+ import { posedBoneWorld, posedSlots, type TimelinePlant } from './animation.ts';
112
+ import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
113
+ import { freshStepContext, steppedPreviousPassWhy } from './constraints_physics.ts';
114
+ import { attachmentStates } from './deform.ts';
115
+ import { drawOrderAt } from './draw_order.ts';
116
+ import { eventsFired } from './events.ts';
117
+ import { REGION_TRIANGLES, REGION_UVS } from './clipping.ts';
118
+ import { poseGeometry, type ShownGeometry } from './vertices.ts';
119
+ import { RUNTIME_DEG, type CoreWorld } from './world.ts';
120
+ import type { SliderApplication } from './constraints_slider.ts';
121
+
122
+ /** One bone, posed: the world transform and the runtime getters' four readings (the header's `bones`). */
123
+ export interface RawBone {
124
+ name: string;
125
+ parent: string | null;
126
+ active: boolean;
127
+ a: number;
128
+ b: number;
129
+ c: number;
130
+ d: number;
131
+ worldX: number;
132
+ worldY: number;
133
+ rotationX: number;
134
+ rotationY: number;
135
+ scaleX: number;
136
+ scaleY: number;
137
+ }
138
+
139
+ /** One region or mesh drawn, in draw order (the header's `drawn`). */
140
+ export interface RawDrawn {
141
+ slot: string;
142
+ attachment: string;
143
+ kind: 'region' | 'mesh';
144
+ vertices: number[];
145
+ uvs: number[];
146
+ triangles: number[];
147
+ hull: number | null;
148
+ sequenceIndex: number;
149
+ colour: [number, number, number, number];
150
+ }
151
+
152
+ /** One clip polygon: `[slot, attachment, end, polygon]` as doubles. */
153
+ export type RawClip = [string, string, string | null, number[]];
154
+ /** One attachment drawn under a clip: `[slot, attachment, clipped, vertices, uvs, triangles]` as the clipper returns them. */
155
+ export type RawClipped = [string, string, 0 | 1, number[], number[], number[]];
156
+ /** One event fired: `[name, time, int, float, string]`. */
157
+ export type RawEvent = [string, number, number, number, string | null];
158
+
159
+ /**
160
+ * Where a walk's physics is reset (the header's *One step*): `animation` —
161
+ * the animation applied at 0 and the reset taken there (`src/render.ts`'s
162
+ * frame 0); `setup` — the reset taken at the setup pose before the animation
163
+ * is applied (`src/deformmeasure.ts`'s `poseAt`), pose 0 then the setup pose.
164
+ */
165
+ export type RawReset = 'animation' | 'setup';
166
+
167
+ /** One pose — the setup's, or one step of an animation's walk. */
168
+ export interface RawPose {
169
+ /** The track time (the running sum of the steps), 0 for the setup pose. */
170
+ trackTime: number;
171
+ /** The time the animation was applied at: the track time held at the duration. */
172
+ animationTime: number;
173
+ bones: RawBone[];
174
+ slots: CoreSlotRow[];
175
+ drawOrder: string[];
176
+ drawn: RawDrawn[];
177
+ clips: RawClip[];
178
+ clipped: RawClipped[];
179
+ events: RawEvent[];
180
+ // --- #968 render: begin ---
181
+ /**
182
+ * What each slot shows, in draw order — the records `drawn` was posed from
183
+ * (skin, placeholder, series frame, deform). `src/render_core.ts` resolves
184
+ * each drawn attachment's atlas region and page UVs through it, with
185
+ * `./uvs.ts`'s `drawnRegions` (issue #967's gated rule), rather than walking
186
+ * which record a slot shows a second time.
187
+ */
188
+ shown: ShownGeometry[];
189
+ // --- #968 render: end ---
190
+ }
191
+
192
+ const RAW_PLANT: CorePlant = { round: rawNumber };
193
+
194
+ // --- #968 render: begin ---
195
+ /** The shown records in `order`, the pose's draw order (a pose's `shown`). */
196
+ function drawOrderOf(shown: readonly ShownGeometry[], order: readonly string[]): ShownGeometry[] {
197
+ const rank = new Map(order.map((n, r) => [n, r]));
198
+ return [...shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
199
+ }
200
+ // --- #968 render: end ---
201
+
202
+ /** A double the entry computed: never `null`, since a non-finite pose is refused rather than passed on. */
203
+ function finite(v: number | null, what: string): number {
204
+ if (v === null) throw new CoreInputError(`the raw pose computed a non-finite ${what}`);
205
+ return v;
206
+ }
207
+
208
+ /** The bones of a pose from the world transforms (the header's `bones`). */
209
+ function rawBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>): RawBone[] {
210
+ const active = activeBones(doc);
211
+ return doc.bones.map((b): RawBone => {
212
+ const w = world.get(b.name);
213
+ if (w === undefined) throw new CoreInputError(`bone "${b.name}" has no world transform`);
214
+ return {
215
+ name: b.name,
216
+ parent: b.parent ?? null,
217
+ active: active.has(b.name),
218
+ a: w.a,
219
+ b: w.b,
220
+ c: w.c,
221
+ d: w.d,
222
+ worldX: w.worldX,
223
+ worldY: w.worldY,
224
+ rotationX: Math.atan2(w.c, w.a) * RUNTIME_DEG,
225
+ rotationY: Math.atan2(w.d, w.b) * RUNTIME_DEG,
226
+ scaleX: Math.sqrt(w.a * w.a + w.c * w.c),
227
+ scaleY: Math.sqrt(w.b * w.b + w.d * w.d),
228
+ };
229
+ });
230
+ }
231
+
232
+ /** Every region and mesh the shown records draw, with what the renderer reads past the vertices (the header's `drawn`). */
233
+ function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant): { drawn: RawDrawn[]; clips: RawClip[]; clipped: RawClipped[] } {
234
+ const geometry = poseGeometry(shown, world, sourceOfDoc(doc), rawNumber, { region: plant.region, vertices: plant.vertices }, drawWalkOf(doc, order, plant));
235
+ if (geometry.attachments === null) throw new CoreInputError(`the raw pose leaves the attachments out: ${geometry.attachmentsWhy}`);
236
+ // The drawn rows (issue #964): the oracle's `clipped` rows where it is posed, and a concave or inverse clip cut through the core's own decomposition.
237
+ if (geometry.drawnClipped === null) throw new CoreInputError(`the raw pose leaves the clipped triangles out: ${geometry.drawnClippedWhy}`);
238
+ // Under a concave or inverse clip the oracle's `clipped` block is absent (the runtime's own triangle list); what the core draws is `drawnClipped`,
239
+ // its own decomposition, and the render samples each drawn triangle at its source triangle's affine UV, so the pixels are the decomposition's
240
+ // coverage alone (issue #964, `Mesh.source` in src/render.ts).
241
+ const rows = geometry.attachments;
242
+ const drawn: RawDrawn[] = [];
243
+ let k = 0;
244
+ for (const s of shown) {
245
+ const g = s.geometry;
246
+ if (g.kind !== 'region' && g.kind !== 'mesh' && g.kind !== 'linkedmesh') continue;
247
+ const row = rows[k++];
248
+ if (row === undefined || row[0] !== s.slot) throw new CoreInputError(`slot "${s.slot}": the attachment rows are not in the shown records' order`);
249
+ const record = doc.skins.find((x) => x.name === s.skin)?.attachments[s.slot]?.[s.placeholder];
250
+ const colour = record?.color === undefined ? ([1, 1, 1, 1] as [number, number, number, number]) : readColour(record.color);
251
+ let uvs: number[];
252
+ let triangles: number[];
253
+ let hull: number | null = null;
254
+ if (g.kind === 'region') {
255
+ uvs = [...REGION_UVS];
256
+ triangles = [...REGION_TRIANGLES];
257
+ } else {
258
+ const source = g.kind === 'mesh' ? g : doc.skins.find((x) => x.name === g.skin)?.attachments[g.slot]?.[g.source]?.geometry;
259
+ if (source === undefined || source.kind !== 'mesh') throw new CoreInputError(`slot "${s.slot}": the linked mesh's source carries no mesh`);
260
+ uvs = [...source.uvs];
261
+ triangles = [...source.triangles];
262
+ hull = source.hull ?? null;
263
+ }
264
+ const sequenceIndex = s.frame ?? record?.sequenceSetup ?? 0;
265
+ drawn.push({ slot: s.slot, attachment: row[1], kind: row[2], vertices: row[3].map((v) => finite(v, `vertex of slot "${s.slot}"`)), uvs, triangles, hull, sequenceIndex, colour });
266
+ }
267
+ const clips = geometry.clips.map((c): RawClip => [c[0], c[1], c[2], c[3].map((v) => finite(v, `clip vertex of slot "${c[0]}"`))]);
268
+ const clipped = geometry.drawnClipped.map((c): RawClipped => [c[0], c[1], c[2], c[3].map((v) => finite(v, `clipped vertex of slot "${c[0]}"`)), c[4].map((v) => finite(v, `clipped uv of slot "${c[0]}"`)), c[5]]);
269
+ return { drawn, clips, clipped };
270
+ }
271
+
272
+ /**
273
+ * The setup pose as the renderer poses a skeleton with no animation — the
274
+ * setup pose, every physics state reset — in full doubles. Refused by name
275
+ * where the core leaves a block of it out.
276
+ */
277
+ export function poseRawSetup(doc: CompiledDocument, plant: CorePlant = {}): RawPose {
278
+ const posed = poseSetup(doc, { ...plant, ...RAW_PLANT }, freshStepContext(plant.physicsStep));
279
+ // `setup.clipped` is the oracle's block, left out under a concave or inverse clip whose triangle list is the runtime's own; what the core draws there is `rawDrawn`'s to refuse or pose (issue #964).
280
+ const absent = posed.absent.filter(([block]) => block !== 'setup.clipped');
281
+ if (absent.length > 0) throw new CoreInputError(`the raw setup pose leaves ${absent.map(([b, why]) => `${b} out (${why})`).join('; ')}`);
282
+ const { setup, world, shown } = posed;
283
+ if (world === null || shown === null || setup.slots === null || setup.drawOrder === null) throw new CoreInputError('the raw setup pose was not posed');
284
+ const drawn = rawDrawn(doc, shown, world, setup.drawOrder, plant);
285
+ return { trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots: setup.slots, drawOrder: setup.drawOrder, ...drawn, events: [], shown: drawOrderOf(shown, setup.drawOrder) };
286
+ }
287
+
288
+ /**
289
+ * One animation walked at the caller's steps (the header's *One step*): the
290
+ * reset pose at 0, then one pose per step of `steps` — `steps.length + 1`
291
+ * poses in all. `src/render.ts`'s walk is `count` steps of `1/fps`;
292
+ * `src/deformmeasure.ts`'s is one step of `time`. A step must be a finite
293
+ * time at or above 0; the animation must be the document's; a construct the
294
+ * core would leave out refuses the call by name. `reset` is where the
295
+ * physics is reset (`RawReset`): `animation` for render's walk, `setup` for
296
+ * deformmeasure's one jump.
297
+ */
298
+ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant = {}, reset: RawReset = 'animation'): RawPose[] {
299
+ const anim = doc.animations.find((a) => a.name === animation);
300
+ if (anim === undefined) throw new CoreInputError(`animation "${animation}" is not one of this document's [${doc.animations.map((a) => a.name).join(', ')}]`);
301
+ const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
302
+ if (bad >= 0) throw new CoreInputError(`step ${bad} is ${steps[bad]}, not a finite time at or above 0`);
303
+ const why = constraintsAbsentWhy(doc) ?? pathAnimationsWhy(doc) ?? steppedPreviousPassWhy(doc);
304
+ if (why !== null) throw new CoreInputError(`the raw walk leaves the bones out: ${why}`);
305
+ const raw: TimelinePlant = { ...plant, ...RAW_PLANT };
306
+ const resolve = plant.shown ?? shownAttachment;
307
+ const duration = anim.timelines.duration;
308
+ const ctx = freshStepContext(plant.physicsStep);
309
+ const poses: RawPose[] = [];
310
+ let trackTime = 0;
311
+ let last = -1;
312
+ if (reset === 'setup') {
313
+ // The reset taken at the setup pose, before the animation is applied (`src/deformmeasure.ts`'s `poseAt`): pose 0 is the setup pose.
314
+ const setup = poseSetup(doc, { ...plant, ...RAW_PLANT }, ctx);
315
+ const absent = setup.absent.filter(([block]) => block !== 'setup.clipped');
316
+ if (absent.length > 0) throw new CoreInputError(`the raw walk's reset pose leaves ${absent.map(([b, w]) => `${b} out (${w})`).join('; ')}`);
317
+ if (setup.world === null || setup.shown === null || setup.setup.slots === null || setup.setup.drawOrder === null) throw new CoreInputError('the raw walk\'s reset pose was not posed');
318
+ ctx.phase = 'update';
319
+ poses.push({ trackTime: 0, animationTime: 0, bones: rawBones(doc, setup.world), slots: setup.setup.slots, drawOrder: setup.setup.drawOrder, ...rawDrawn(doc, setup.shown, setup.world, setup.setup.drawOrder, plant), events: [], shown: drawOrderOf(setup.shown, setup.setup.drawOrder) });
320
+ }
321
+ for (let i = reset === 'setup' ? 1 : 0; i <= steps.length; i++) {
322
+ const dt = i === 0 ? 0 : steps[i - 1];
323
+ trackTime += dt;
324
+ const t = trackTime < duration ? trackTime : duration;
325
+ const before = ctx.time;
326
+ ctx.time += dt;
327
+ const sliders: SliderApplication[] = [];
328
+ const posed = posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before });
329
+ if (i === 0) ctx.phase = 'update';
330
+ const placeholders = new Map<string, string | null>();
331
+ const slots = posedSlots(doc, anim.timelines, t, raw, sliders, placeholders);
332
+ if (slots.conflicts.length > 0) throw new CoreInputError(`the raw walk leaves the slots out: ${slots.conflicts.join('; ')}`);
333
+ const evalOrder = plant.drawOrder ?? drawOrderAt;
334
+ let order = evalOrder(doc.slots.length, anim.timelines.drawOrder, t) ?? doc.slots.map((_s, k) => k);
335
+ for (const app of sliders) order = evalOrder(doc.slots.length, app.timelines.drawOrder, app.at) ?? order;
336
+ const drawOrder = order.map((k) => doc.slots[k].name);
337
+ const states = attachmentStates(doc, resolve, placeholders, { timelines: anim.timelines, t }, sliders, plant);
338
+ if (states.why.length > 0) throw new CoreInputError(`the raw walk leaves the attachments out: ${states.why.join('; ')}`);
339
+ const rank = new Map(drawOrder.map((n, r) => [n, r]));
340
+ const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
341
+ const drawn = rawDrawn(doc, shown, posed.world, drawOrder, plant);
342
+ const events = (plant.events ?? eventsFired)(anim.timelines.events, last, t, rawNumber).map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]);
343
+ last = t;
344
+ poses.push({ trackTime, animationTime: t, bones: rawBones(doc, posed.world), slots: slots.rows, drawOrder, ...drawn, events, shown });
345
+ }
346
+ return poses;
347
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The skin a document is posed under (issue #932): every skin merged — the
3
+ * oracle's `--skin all` — or one skin by name, `--skin <name>`. Each rule
4
+ * below was measured by posing hand-written skeletons through
5
+ * `tools/pose_oracle.ts dump --skin <name>` (spine-core 4.3.13) and reading
6
+ * the rows it printed; the core suite's `CN` controls hold the same
7
+ * skeletons against the core at tolerance 0.
8
+ *
9
+ * - **What a slot shows under a named skin**: the named skin's record for
10
+ * the placeholder, else the default skin's, else nothing. Three skins
11
+ * `default`, `s1`, `s2` over five slots — a placeholder the default and
12
+ * `s1` and `s2` fill, one the default alone fills, one `s1` alone, one no
13
+ * skin, one `s1` and the default — dumped under `default`, `s1` and `s2`
14
+ * in two file orders (`default, s1, s2` and `s2, s1, default`): `s1`
15
+ * showed `s1`'s record where it filled the placeholder and the default's
16
+ * where only the default did; `s2` showed `s2`'s, then the default's, and
17
+ * nothing for the placeholder only `s1` fills; a placeholder no skin fills
18
+ * showed nothing under every skin; the two file orders read the same row
19
+ * under every named skin. An attachment key switching the slot at a
20
+ * sample resolves the same way. So a named skin has no order question:
21
+ * nothing a placeholder shows depends on the file's skin order, which the
22
+ * model does not hold (the slots' ⚠️ in `./index.ts`).
23
+ * - **The bones a named skin activates**: a skin-required bone is active
24
+ * exactly when the NAMED skin names it or a bone below it — the default
25
+ * skin's `bones` list does not count. Measured on the same probe: a
26
+ * skin-required bone only the default names read active under `default`
27
+ * and inactive under `s1` and `s2`; one `s1` names, and its skin-required
28
+ * parent, active under `s1` only; a bone that is not skin-required under
29
+ * an inactive one stayed active under every skin.
30
+ * - **The constraints a named skin applies**: a skin-required constraint of
31
+ * any kind is applied exactly when the named skin's list for its kind names
32
+ * it — the default skin's list does not count either (`./constraints.ts`'s
33
+ * header, the nine readings per kind).
34
+ *
35
+ * - **A slot on an inactive bone is not animated** (the commander's private
36
+ * finding on this issue: a 19-skin rig read DIFF on 18 of its 19 skin
37
+ * runs). A slot whose bone the applied skin leaves inactive keeps its
38
+ * setup colour, dark colour and attachment under every slot timeline —
39
+ * `rgba`, `rgb`, `alpha`, `rgba2`, `rgb2` and `attachment` — whether an
40
+ * animation plays it or a slider applies it, and a deform timeline on its
41
+ * attachment does not apply either (a mesh weighted to an ACTIVE bone, so
42
+ * the deform would show: moved by 5 under `all` and `s1`, unmoved under
43
+ * `default`, which leaves the slot's bone inactive). Measured on
44
+ * hand-written skeletons under `--skin all`, `s1` (naming the bone) and
45
+ * `default` (not): each timeline moved the slot under the first two and
46
+ * not under the third, and the setup row read the setup under all three
47
+ * (a slider's key included). A slot on an ACTIVE bone that shows nothing
48
+ * (no skin holds its placeholder) IS animated: every colour key applied,
49
+ * and an attachment key switched it. A draw-order key moves a slot on an
50
+ * inactive bone like any other. A sequence timeline is held by the same
51
+ * gate; its effect on such a slot is not observable in the dump — a
52
+ * region's corners on an inactive bone are all zeros whatever the frame.
53
+ * So the predicate is the slot bone's activity (`slotTimelinesApply`),
54
+ * not what the slot shows; under `all` every bone some skin names is
55
+ * active, so a one-skin rig is unchanged.
56
+ *
57
+ * Under `all` every skin is applied at once: `bones` and the constraint
58
+ * lists of every skin count, and a placeholder several skins fill shows the
59
+ * LAST of them in the Spine file's order — the case `./index.ts` leaves out
60
+ * by name, and the one the per-skin dumps exist to judge.
61
+ */
62
+ import type { ModelSlot } from '../model.ts';
63
+ import type { CompiledDocument, CoreSkin } from './index.ts';
64
+
65
+ /** The skin option that merges every skin — the oracle's `--skin all`. */
66
+ export const CORE_ALL_SKINS = 'all';
67
+
68
+ /** The skin a document names its default skin with — Spine's `SkeletonData.defaultSkin` is the skin of this name. */
69
+ export const CORE_DEFAULT_SKIN = 'default';
70
+
71
+ /** The skins whose `bones` and constraint lists are applied: every skin under `all`, the named skin alone otherwise (the header's measurement). */
72
+ export function appliedSkins(doc: CompiledDocument): CoreSkin[] {
73
+ return doc.skin === CORE_ALL_SKINS ? doc.skins : doc.skins.filter((k) => k.name === doc.skin);
74
+ }
75
+
76
+ /** Under a named skin, the skins a placeholder is looked up in, in precedence order: the named skin, then the default skin (the header's measurement). */
77
+ export function lookupSkins(doc: CompiledDocument): CoreSkin[] {
78
+ if (doc.skin === CORE_ALL_SKINS) return doc.skins;
79
+ const named = doc.skins.find((k) => k.name === doc.skin);
80
+ const fallback = doc.skin === CORE_DEFAULT_SKIN ? undefined : doc.skins.find((k) => k.name === CORE_DEFAULT_SKIN);
81
+ return [named, fallback].filter((k): k is CoreSkin => k !== undefined);
82
+ }
83
+
84
+ /**
85
+ * The skins that could show a slot's placeholder: under `all` every skin
86
+ * filling it — several is the file-order question — and under a named skin
87
+ * the one it resolves to, or none.
88
+ */
89
+ export function fillingSkins(doc: CompiledDocument, slot: string, placeholder: string): string[] {
90
+ if (doc.skin === CORE_ALL_SKINS) return doc.skins.filter((k) => k.attachments[slot]?.[placeholder] !== undefined).map((k) => k.name);
91
+ const first = lookupSkins(doc).find((k) => k.attachments[slot]?.[placeholder] !== undefined);
92
+ return first === undefined ? [] : [first.name];
93
+ }
94
+
95
+ /** Whether a slot's timelines — its colour and attachment timelines, and the deform and sequence timelines on what it shows — apply: `slotTimelinesApply` unless a plant passes another. */
96
+ export type SlotTimelineGate = (doc: CompiledDocument, slot: ModelSlot, active: ReadonlySet<string>) => boolean;
97
+
98
+ /** A slot's timelines apply exactly when its bone is active under the skin posed (the header's measurement). */
99
+ export const slotTimelinesApply: SlotTimelineGate = (_doc, slot, active) => active.has(slot.bone);
100
+
101
+ /** Whether an applied skin's list for `kind` names the constraint — what applies a skin-required one (`./constraints.ts`'s header). */
102
+ export function listedByAppliedSkin(doc: CompiledDocument, kind: keyof CoreSkin['constraints'], name: string): boolean {
103
+ return appliedSkins(doc).some((k) => k.constraints[kind].includes(name));
104
+ }