@rig-c/core 2.70.0 → 2.72.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.
@@ -0,0 +1,736 @@
1
+ /**
2
+ * The runtime poser's load-time half (issue #1591, stage 1): a skin view of
3
+ * the compiled model (`readModel`, then `underSkin` / `underNoSkin` — the
4
+ * oracle's reader and its views, read and never written) compiled once into
5
+ * index-resolved bones and slots, a constraint plan, compiled timelines and
6
+ * the sizes of every buffer a pose needs (`./pose.ts`).
7
+ *
8
+ * ## Refused by name, at load
9
+ *
10
+ * A construct the runtime does not pose yet is never guessed at. `compileRuntime`
11
+ * collects every one it meets, each with the object it was found on
12
+ * (`RuntimeMissing`), and:
13
+ *
14
+ * - **refuses the model** (`RuntimeRefusal`, every missing feature listed)
15
+ * when one is a property of the whole model — a path, physics or slider constraint, a transform constraint reading or
16
+ * writing local values or adding (`localSource`, `localTarget`,
17
+ * `additive`), an ik or transform constraint over a bone the view leaves
18
+ * inactive, a world-space transform onto a `noScale` or
19
+ * `noScaleOrReflection` bone, a clipping attachment or a region with no
20
+ * atlas rectangle shown at setup, a placeholder the skins fill
21
+ * differently, or a block the oracle itself leaves out;
22
+ * - **refuses an animation** (`RuntimeModel.refused`; `animation(name)`
23
+ * throws the same `RuntimeRefusal`) when the construct is the animation's
24
+ * — an `inherit` timeline, a deform or sequence timeline, a draw-order
25
+ * timeline, a dark-colour (`rgba2`, `rgb2`) timeline, or an
26
+ * attachment key showing a clipping attachment or a region with no atlas
27
+ * rectangle. Every other animation of the model is posed.
28
+ *
29
+ * `RUNTIME_FEATURES` is the closed list of what can be named. Stage 1 reads
30
+ * named/no-skin views and delivers event keys, with immutable payloads compiled
31
+ * at load. `ignore: ['events']` remains an explicit diagnostic opt-out; it is
32
+ * never the default. A merged view with conflicting attachments still refuses
33
+ * by name, as the oracle itself cannot resolve that view without file order.
34
+ *
35
+ * ## What the oracle decides, the runtime reads
36
+ *
37
+ * Which bones are active, which constraints apply under the view, what a slot
38
+ * shows for a placeholder and the name and path its row carries, a slot's
39
+ * colour and blend, a linked mesh's source — each is the oracle's own answer,
40
+ * read here once (`activeBones`, `constraintInactiveWhy`, `shownAttachment`,
41
+ * `shownRow`, `readColour`, `readBlend`, `sourceOfDoc`). What the runtime
42
+ * computes is the pose, and that is what the gate holds equal
43
+ * (`tools/core_gate.ts`, *The runtime poser*).
44
+ *
45
+ * Pure, as all of `src/`: nothing from the Spine runtime package, no clock,
46
+ * no randomness, no I/O.
47
+ */
48
+ import type { ModelVertices } from '../model.ts';
49
+ import { activeBones, constraintRecords, CORE_REGION_KINDS, foldInheritMode, readBlend, readColour, shownAttachment, shownRow, sourceOfDoc, underSkin, type CompiledDocument, type CoreAnimation, type CoreBlendMode } from '../core/index.ts';
50
+ import { constraintInactiveWhy, constraintsAbsentWhy, TRANSFORM_PROPERTIES, type CoreIkRecord, type CoreTransformRecord } from '../core/constraints.ts';
51
+ import { RUNTIME_PI } from '../core/world.ts';
52
+ import { compileCurve, type RuntimeCurve } from './curves.ts';
53
+
54
+ /** The stage of issue #1591 this poser implements. */
55
+ export const RUNTIME_STAGE = 1;
56
+
57
+ /** Every construct the runtime poser can refuse, by the name its refusals carry. */
58
+ export const RUNTIME_FEATURES = [
59
+ 'path-constraint',
60
+ 'physics-constraint',
61
+ 'slider-constraint',
62
+ 'local-transform',
63
+ 'additive-transform',
64
+ 'inactive-constrained-bone',
65
+ 'noscale-world-target',
66
+ 'clipping',
67
+ 'null-atlas-region',
68
+ 'skin-conflict',
69
+ 'oracle-absent',
70
+ 'inherit-timeline',
71
+ 'deform-timeline',
72
+ 'sequence-timeline',
73
+ 'draw-order-timeline',
74
+ 'dark-colour-timeline',
75
+ ] as const;
76
+ export type RuntimeFeature = (typeof RUNTIME_FEATURES)[number];
77
+
78
+ /** One construct not posed: what, on which object, and the animation it was found in (`null` for the model's own). */
79
+ export interface RuntimeMissing {
80
+ readonly feature: RuntimeFeature;
81
+ readonly detail: string;
82
+ readonly animation: string | null;
83
+ }
84
+
85
+ /** Spelled once, so a refusal and a report say the same words. */
86
+ export function missingText(m: RuntimeMissing): string {
87
+ return `${m.feature}: ${m.detail}${m.animation === null ? '' : ` (animation "${m.animation}")`}`;
88
+ }
89
+
90
+ /** What the runtime poser does not pose, named — the model's, or one animation's. */
91
+ export class RuntimeRefusal extends Error {
92
+ readonly missing: readonly RuntimeMissing[];
93
+ constructor(missing: readonly RuntimeMissing[]) {
94
+ super(`not posed by the runtime poser (stage ${RUNTIME_STAGE}), ${missing.length} construct(s) missing: ${missing.map(missingText).join('; ')}`);
95
+ this.missing = missing;
96
+ }
97
+ }
98
+
99
+ /** A call the runtime poser cannot answer: a name the model does not have, a time that is not finite. */
100
+ export class RuntimeInputError extends Error {}
101
+
102
+ /** What a consumer states at load. */
103
+ export interface RuntimeOptions {
104
+ /** Constructs that move no pose, posed as absent on request. `events` is the only one. */
105
+ readonly ignore?: readonly 'events'[];
106
+ }
107
+
108
+ /**
109
+ * What a control passes in place of a part of the load (the selftest's
110
+ * `runtime-poser` suite); nothing else passes one. Each acts at load only, so
111
+ * a pose runs the same code with or without one.
112
+ */
113
+ export interface RuntimePlant {
114
+ /** Added to one bone's setup local value: the planted perturbation. */
115
+ readonly setupNudge?: { readonly bone: string; readonly field: number; readonly delta: number };
116
+ /** A weighted binding's bone index rewritten: the wrong-index plant. */
117
+ readonly bindingIndex?: (index: number, bones: number) => number;
118
+ /** Refusals let through, the construct posed as if absent: the smuggling plant. */
119
+ readonly admit?: readonly RuntimeFeature[];
120
+ /** The update order's scratch sized by the bone count instead of the longest target list: the short-buffer plant. */
121
+ readonly scratchByBones?: boolean;
122
+ /** Wrong-skin source selected by a control, never by a consumer. */
123
+ readonly attachmentSkin?: string;
124
+ /** Drops a key at zero on a wrap, the event boundary plant. */
125
+ readonly eventWrapOffByOne?: boolean;
126
+ /** The old restore behavior, which repeats keys up to the restored time. */
127
+ readonly restoreEventsFromStart?: boolean;
128
+ }
129
+
130
+ // A bone's local values, seven per bone.
131
+ export const L_X = 0;
132
+ export const L_Y = 1;
133
+ export const L_ROTATION = 2;
134
+ export const L_SCALE_X = 3;
135
+ export const L_SCALE_Y = 4;
136
+ export const L_SHEAR_X = 5;
137
+ export const L_SHEAR_Y = 6;
138
+ export const LOCALS = 7;
139
+ /** The local fields by the name a `ModelBone` gives them, in `L_*` order. */
140
+ export const LOCAL_FIELD_NAMES = ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY'] as const;
141
+
142
+ // A bone's world transform, six per bone: the matrix `[a b; c d]` and the origin.
143
+ export const W_A = 0;
144
+ export const W_B = 1;
145
+ export const W_C = 2;
146
+ export const W_D = 3;
147
+ export const W_X = 4;
148
+ export const W_Y = 5;
149
+ export const WORLDS = 6;
150
+
151
+ // The five inherit modes.
152
+ export const MODE_NORMAL = 0;
153
+ export const MODE_ONLY_TRANSLATION = 1;
154
+ export const MODE_NO_ROTATION_OR_REFLECTION = 2;
155
+ export const MODE_NO_SCALE = 3;
156
+ export const MODE_NO_SCALE_OR_REFLECTION = 4;
157
+ const MODE_CODES = { normal: MODE_NORMAL, onlyTranslation: MODE_ONLY_TRANSLATION, noRotationOrReflection: MODE_NO_ROTATION_OR_REFLECTION, noScale: MODE_NO_SCALE, noScaleOrReflection: MODE_NO_SCALE_OR_REFLECTION } as const;
158
+
159
+ // How an attachment draws.
160
+ export const DRAW_NONE = 0;
161
+ export const DRAW_REGION = 1;
162
+ export const DRAW_MESH = 2;
163
+ export const DRAW_WEIGHTED = 3;
164
+
165
+ // An ik constraint's scaleY mode.
166
+ export const SCALE_Y_NONE = 0;
167
+ export const SCALE_Y_UNIFORM = 1;
168
+ export const SCALE_Y_VOLUME = 2;
169
+
170
+ // An ik constraint's three flags, packed.
171
+ export const IK_BEND_POSITIVE = 1;
172
+ export const IK_COMPRESS = 2;
173
+ export const IK_STRETCH = 4;
174
+
175
+ /** Degrees to radians, with the runtime's pi (`src/core/world.ts`). */
176
+ export const RAD = RUNTIME_PI / 180;
177
+
178
+ /**
179
+ * One record a slot can show, resolved at load: the row's name and path (the
180
+ * oracle's `shownRow`), how it draws, its bone, where its world vertices go in
181
+ * a pose's buffer and the numbers they are computed from — a region's four
182
+ * corners before the bone (`regionCorners`' local half, the same operations),
183
+ * an unweighted mesh's coordinates through `Math.fround`, a weighted mesh's
184
+ * bindings (bone index, float32 coordinates, float32 weight) in their order.
185
+ */
186
+ export interface RuntimeAttachment {
187
+ readonly slot: number;
188
+ readonly name: string;
189
+ /** Exact resolved source table identity; display names need not be unique. */
190
+ readonly skin: string;
191
+ readonly placeholder: string;
192
+ readonly path: string | null;
193
+ readonly draw: number;
194
+ readonly bone: number;
195
+ /** Offset into `RuntimePose.vertices`, and how many numbers (two per vertex). */
196
+ readonly offset: number;
197
+ readonly count: number;
198
+ readonly local: Float64Array;
199
+ readonly bindingCounts: Int32Array;
200
+ readonly bindingBones: Int32Array;
201
+ readonly bindingX: Float64Array;
202
+ readonly bindingY: Float64Array;
203
+ readonly bindingWeights: Float64Array;
204
+ }
205
+
206
+ /** One slot: its bone, whether its timelines apply, what it shows at setup (an attachment index or −1), its colours and blend. */
207
+ export interface RuntimeSlot {
208
+ readonly name: string;
209
+ readonly bone: number;
210
+ readonly live: boolean;
211
+ readonly setupAttachment: number;
212
+ readonly light: Float64Array;
213
+ readonly dark: Float64Array | null;
214
+ readonly blend: CoreBlendMode;
215
+ }
216
+
217
+ /** An ik constraint as the plan runs it. */
218
+ export interface RuntimeIk {
219
+ readonly kind: 'ik';
220
+ readonly name: string;
221
+ readonly index: number;
222
+ readonly applied: boolean;
223
+ readonly bones: Int32Array;
224
+ readonly target: number;
225
+ readonly scaleY: number;
226
+ readonly mix: number;
227
+ readonly softness: number;
228
+ readonly flags: number;
229
+ }
230
+
231
+ /** A world-space, absolute transform constraint as the plan runs it: each source property's offset and its targets'. */
232
+ export interface RuntimeTransform {
233
+ readonly kind: 'transform';
234
+ readonly name: string;
235
+ readonly index: number;
236
+ readonly applied: boolean;
237
+ readonly bones: Int32Array;
238
+ readonly source: number;
239
+ readonly fromProperty: Int32Array;
240
+ readonly fromOffset: Float64Array;
241
+ readonly toStart: Int32Array;
242
+ readonly toEnd: Int32Array;
243
+ readonly toProperty: Int32Array;
244
+ readonly toOffset: Float64Array;
245
+ readonly toScale: Float64Array;
246
+ readonly toMax: Float64Array;
247
+ /** The constraint's offsets and its setup mixes, by `TRANSFORM_PROPERTIES` index. */
248
+ readonly offsets: Float64Array;
249
+ readonly mixes: Float64Array;
250
+ readonly clamp: boolean;
251
+ }
252
+
253
+ export type RuntimeConstraint = RuntimeIk | RuntimeTransform;
254
+
255
+ /** A bone timeline: the bone, its curve, and per channel the local field it writes and whether it multiplies the setup value (scale) or adds to it. */
256
+ export interface RuntimeBoneTimeline {
257
+ readonly bone: number;
258
+ readonly curve: RuntimeCurve;
259
+ readonly cursor: number;
260
+ readonly fields: Int32Array;
261
+ readonly multiply: Uint8Array;
262
+ }
263
+
264
+ /** A slot timeline: an attachment timeline (each key's attachment index, −1 nothing) or a colour timeline (each channel's index into the light colour). */
265
+ export interface RuntimeSlotTimeline {
266
+ readonly slot: number;
267
+ readonly curve: RuntimeCurve;
268
+ readonly cursor: number;
269
+ readonly attachments: Int32Array | null;
270
+ readonly channels: Int32Array | null;
271
+ }
272
+
273
+ /** A constraint timeline: its constraint's index in the plan, its curve, and an ik key's flags. */
274
+ export interface RuntimeConstraintTimeline {
275
+ readonly constraint: number;
276
+ readonly curve: RuntimeCurve;
277
+ readonly cursor: number;
278
+ readonly flags: Uint8Array | null;
279
+ }
280
+
281
+ /** One immutable event delivery, reused by every pose of this model. */
282
+ export interface RuntimeEvent {
283
+ readonly name: string;
284
+ readonly time: number;
285
+ readonly int: number;
286
+ readonly float: number;
287
+ readonly string: string;
288
+ readonly volume: number;
289
+ readonly balance: number;
290
+ }
291
+
292
+ /** One covered animation, compiled. */
293
+ export interface RuntimeAnimation {
294
+ readonly name: string;
295
+ readonly duration: number;
296
+ readonly bones: readonly RuntimeBoneTimeline[];
297
+ readonly slots: readonly RuntimeSlotTimeline[];
298
+ /** Per constraint of the plan, its timeline in this animation or `null`. */
299
+ readonly constraints: ReadonlyArray<RuntimeConstraintTimeline | null>;
300
+ /** How many key cursors a pose keeps for it. */
301
+ readonly cursors: number;
302
+ /** Number of authored event keys (including an explicitly muted diagnostic). */
303
+ readonly events: number;
304
+ readonly eventKeys: readonly RuntimeEvent[];
305
+ }
306
+
307
+ /** The compiled model a pose is made of (`compileRuntime`). Read-only once built. */
308
+ export interface RuntimeModel {
309
+ /** Control-only boundary defect; fixed at load. */
310
+ readonly eventWrapOffByOne: boolean;
311
+ readonly restoreEventsFromStart: boolean;
312
+ readonly doc: CompiledDocument;
313
+ readonly boneNames: readonly string[];
314
+ readonly parents: Int32Array;
315
+ readonly modes: Uint8Array;
316
+ readonly active: Uint8Array;
317
+ readonly lengths: Float64Array;
318
+ /** Every bone's setup local values, `LOCALS` per bone. */
319
+ readonly setup: Float64Array;
320
+ /** Each bone's subtree, itself first, ascending: `subtrees[subtreeStart[b] … subtreeStart[b + 1])`. */
321
+ readonly subtreeStart: Int32Array;
322
+ readonly subtrees: Int32Array;
323
+ readonly slots: readonly RuntimeSlot[];
324
+ readonly attachments: readonly RuntimeAttachment[];
325
+ readonly vertexCount: number;
326
+ readonly constraints: readonly RuntimeConstraint[];
327
+ readonly animations: ReadonlyMap<string, RuntimeAnimation>;
328
+ /** The animations refused, in the document's order, each with what it misses. */
329
+ readonly refused: ReadonlyArray<{ readonly animation: string; readonly missing: readonly RuntimeMissing[] }>;
330
+ /** The most key cursors any covered animation needs. */
331
+ readonly cursors: number;
332
+ /**
333
+ * How many bones one constraint can change at once: the longest target list,
334
+ * and never less than the bone count. A transform constraint may name a bone
335
+ * more than once (`readConstraintRecord` reads the list as written, and the
336
+ * oracle applies each entry in turn), so the list, not the skeleton, bounds it.
337
+ */
338
+ readonly scratch: number;
339
+ /** A covered animation by name; a refused one throws its `RuntimeRefusal`, an unknown one a `RuntimeInputError`. */
340
+ animation(name: string): RuntimeAnimation;
341
+ }
342
+
343
+ /** The ten bone timeline kinds that key numbers: per channel, the local field and whether it multiplies (`src/core/animation.ts`, *The bone timelines*). */
344
+ const BONE_KINDS: Record<string, ReadonlyArray<readonly [number, 0 | 1]>> = {
345
+ rotate: [[L_ROTATION, 0]],
346
+ translate: [[L_X, 0], [L_Y, 0]],
347
+ translatex: [[L_X, 0]],
348
+ translatey: [[L_Y, 0]],
349
+ scale: [[L_SCALE_X, 1], [L_SCALE_Y, 1]],
350
+ scalex: [[L_SCALE_X, 1]],
351
+ scaley: [[L_SCALE_Y, 1]],
352
+ shear: [[L_SHEAR_X, 0], [L_SHEAR_Y, 0]],
353
+ shearx: [[L_SHEAR_X, 0]],
354
+ sheary: [[L_SHEAR_Y, 0]],
355
+ };
356
+
357
+ /** The light colour's channels each colour timeline sets, in its curve's channel order. */
358
+ const COLOUR_CHANNELS: Record<string, readonly number[]> = { rgba: [0, 1, 2, 3], rgb: [0, 1, 2], alpha: [3] };
359
+
360
+ /** Where refusals are gathered while a model compiles. */
361
+ class Refusals {
362
+ readonly model: RuntimeMissing[] = [];
363
+ readonly byAnimation = new Map<string, RuntimeMissing[]>();
364
+ constructor(private readonly admitted: ReadonlySet<RuntimeFeature>) {}
365
+
366
+ /** Records the construct unless a plant admits it. The caller poses the construct as absent either way. */
367
+ note(feature: RuntimeFeature, detail: string, animation: string | null = null): void {
368
+ if (this.admitted.has(feature)) return;
369
+ const m: RuntimeMissing = { feature, detail, animation };
370
+ if (animation === null) this.model.push(m);
371
+ else {
372
+ const list = this.byAnimation.get(animation) ?? [];
373
+ list.push(m);
374
+ this.byAnimation.set(animation, list);
375
+ }
376
+ }
377
+ }
378
+
379
+ /** The view compiled for the runtime poser, or every construct it misses refused by name — the header. */
380
+ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions = {}, plant: RuntimePlant = {}): RuntimeModel {
381
+ const refusals = new Refusals(new Set(plant.admit ?? []));
382
+ const ignoreEvents = (options.ignore ?? []).includes('events');
383
+
384
+ const absentWhy = constraintsAbsentWhy(doc);
385
+ if (absentWhy !== null) refusals.note('oracle-absent', `the oracle leaves the bones out: ${absentWhy}`);
386
+
387
+ // --- bones ---
388
+ const nb = doc.bones.length;
389
+ const boneIndex = new Map(doc.bones.map((b, i) => [b.name, i]));
390
+ const indexOf = (name: string): number => {
391
+ const i = boneIndex.get(name);
392
+ if (i === undefined) throw new RuntimeInputError(`bone "${name}" is not a bone of this document (readModel refuses it first)`);
393
+ return i;
394
+ };
395
+ const active = activeBones(doc);
396
+ const parents = new Int32Array(nb);
397
+ const modes = new Uint8Array(nb);
398
+ const activeFlags = new Uint8Array(nb);
399
+ const lengths = new Float64Array(nb);
400
+ const setup = new Float64Array(nb * LOCALS);
401
+ doc.bones.forEach((b, i) => {
402
+ parents[i] = b.parent === undefined ? -1 : indexOf(b.parent);
403
+ if (parents[i] >= i) throw new RuntimeInputError(`bone "${b.name}" is listed before its parent (readModel refuses it first)`);
404
+ const mode = b.inheritMode === undefined ? 'normal' : foldInheritMode(b.inheritMode);
405
+ if (mode === null) throw new RuntimeInputError(`bone "${b.name}": inheritMode ${JSON.stringify(b.inheritMode)} is no mode (readModel refuses it first)`);
406
+ modes[i] = MODE_CODES[mode];
407
+ activeFlags[i] = active.has(b.name) ? 1 : 0;
408
+ lengths[i] = b.length ?? 0;
409
+ const o = i * LOCALS;
410
+ setup[o + L_X] = b.x ?? 0;
411
+ setup[o + L_Y] = b.y ?? 0;
412
+ setup[o + L_ROTATION] = b.rotation ?? 0;
413
+ setup[o + L_SCALE_X] = b.scaleX ?? 1;
414
+ setup[o + L_SCALE_Y] = b.scaleY ?? 1;
415
+ setup[o + L_SHEAR_X] = b.shearX ?? 0;
416
+ setup[o + L_SHEAR_Y] = b.shearY ?? 0;
417
+ });
418
+ if (plant.setupNudge !== undefined) setup[indexOf(plant.setupNudge.bone) * LOCALS + plant.setupNudge.field] += plant.setupNudge.delta;
419
+ // Each bone's subtree, in bone order: bones are listed parents first, so each index is appended to its own list and its ancestors' in ascending order.
420
+ const lists: number[][] = doc.bones.map(() => []);
421
+ for (let k = 0; k < nb; k++) for (let at = k; at >= 0; at = parents[at]) lists[at].push(k);
422
+ const subtreeStart = new Int32Array(nb + 1);
423
+ const flat: number[] = [];
424
+ for (let k = 0; k < nb; k++) {
425
+ subtreeStart[k] = flat.length;
426
+ for (const x of lists[k]) flat.push(x);
427
+ }
428
+ subtreeStart[nb] = flat.length;
429
+
430
+ // --- constraints, in the document's order ---
431
+ const constraints: RuntimeConstraint[] = [];
432
+ for (const c of doc.constraints) if (c.kind === 'path' || c.kind === 'physics' || c.kind === 'slider') refusals.note(`${c.kind}-constraint`, `${c.kind} constraint "${c.name}"`);
433
+ for (const r of constraintRecords(doc)) {
434
+ if (r.kind !== 'ik' && r.kind !== 'transform') continue;
435
+ const applied = constraintInactiveWhy(r, active) === null;
436
+ const bones = Int32Array.from(r.bones.map(indexOf));
437
+ if (applied && r.bones.some((b) => !active.has(b))) refusals.note('inactive-constrained-bone', `${r.kind} constraint "${r.name}" names bone(s) ${r.bones.filter((b) => !active.has(b)).map((b) => `"${b}"`).join(', ')}, which the view leaves inactive — what the oracle writes there depends on the pass before`);
438
+ const index = constraints.length;
439
+ if (r.kind === 'ik') constraints.push(compileIk(r, index, applied, bones, indexOf));
440
+ else {
441
+ if (r.localSource || r.localTarget) refusals.note('local-transform', `transform constraint "${r.name}" ${[r.localSource ? 'reads local values (localSource)' : '', r.localTarget ? 'writes local values (localTarget)' : ''].filter((x) => x !== '').join(' and ')}`);
442
+ if (r.additive) refusals.note('additive-transform', `transform constraint "${r.name}" is additive`);
443
+ const t = compileTransform(r, index, applied, bones, indexOf);
444
+ if (applied) {
445
+ for (let k = 0; k < bones.length; k++) {
446
+ const m = modes[bones[k]];
447
+ if (parents[bones[k]] >= 0 && (m === MODE_NO_SCALE || m === MODE_NO_SCALE_OR_REFLECTION)) refusals.note('noscale-world-target', `transform constraint "${r.name}" moves bone "${r.bones[k]}" in world space, and its local values are read back in a noScale mode`);
448
+ }
449
+ }
450
+ constraints.push(t);
451
+ }
452
+ }
453
+
454
+ // --- slots and the records they show ---
455
+ const attachments: RuntimeAttachment[] = [];
456
+ let vertexCount = 0;
457
+ const sourceOf = sourceOfDoc(doc);
458
+ const attachmentView = plant.attachmentSkin === undefined ? doc : underSkin(doc, plant.attachmentSkin);
459
+ /** Per slot, each placeholder's attachment index (−1 shows nothing) and what it misses, resolved once and its refusal noted for every animation that shows it. */
460
+ const resolvedBySlot = doc.slots.map(() => new Map<string | null, { index: number; missing: readonly [RuntimeFeature, string] | null }>());
461
+ const resolve = (si: number, placeholder: string | null, animation: string | null): number => {
462
+ const cache = resolvedBySlot[si];
463
+ let kept = cache.get(placeholder);
464
+ if (kept === undefined) {
465
+ kept = resolveRecord(si, placeholder);
466
+ cache.set(placeholder, kept);
467
+ }
468
+ if (kept.missing !== null) refusals.note(kept.missing[0], kept.missing[1], animation);
469
+ return kept.index;
470
+ };
471
+ const resolveRecord = (si: number, placeholder: string | null): { index: number; missing: readonly [RuntimeFeature, string] | null } => {
472
+ const slot = doc.slots[si];
473
+ const shown = placeholder === null ? null : shownAttachment(attachmentView, slot, placeholder);
474
+ if (shown === null) return { index: -1, missing: null };
475
+ if ('conflict' in shown) return { index: -1, missing: ['skin-conflict', `slot "${slot.name}" placeholder "${placeholder}" is filled by skins that disagree`] };
476
+ const row = shownRow(shown);
477
+ const g = shown.record.geometry;
478
+ const at = { slot: si, name: row.name, skin: shown.skin, placeholder: shown.placeholder, path: CORE_REGION_KINDS.has(shown.record.kind) ? row.path : null, bone: indexOf(slot.bone), offset: vertexCount };
479
+ let made: RuntimeAttachment | null = null;
480
+ let missing: readonly [RuntimeFeature, string] | null = null;
481
+ if (g?.kind === 'clipping') missing = ['clipping', `slot "${slot.name}" shows clipping attachment "${row.name}"`];
482
+ else if (g?.kind === 'region') {
483
+ const r = g.region;
484
+ if (r.atlas === null) missing = ['null-atlas-region', `slot "${slot.name}" shows region "${row.name}", whose atlas rectangle the model states as null`];
485
+ else {
486
+ // `regionCorners`' local half (src/core/vertices.ts), the same operations in the same order: only `a·lx + b·ly + worldX` depends on the pose.
487
+ const W = r.width;
488
+ const H = r.height;
489
+ const sx = r.scaleX ?? 1;
490
+ const sy = r.scaleY ?? 1;
491
+ const rect = r.atlas;
492
+ const kx = (W / rect.originalWidth) * sx;
493
+ const ky = (H / rect.originalHeight) * sy;
494
+ const x1 = (-W / 2) * sx + rect.offsetX * kx;
495
+ const y1 = (-H / 2) * sy + rect.offsetY * ky;
496
+ const x2 = x1 + rect.width * kx;
497
+ const y2 = y1 + rect.height * ky;
498
+ const angle = (r.rotation ?? 0) * RAD;
499
+ const cos = Math.cos(angle);
500
+ const sin = Math.sin(angle);
501
+ const x = r.x ?? 0;
502
+ const y = r.y ?? 0;
503
+ const local = new Float64Array(8);
504
+ const corners = [x1, y1, x1, y2, x2, y2, x2, y1];
505
+ for (let k = 0; k < 8; k += 2) {
506
+ const px = corners[k];
507
+ const py = corners[k + 1];
508
+ local[k] = px * cos + x - py * sin;
509
+ local[k + 1] = py * cos + y + px * sin;
510
+ }
511
+ made = vertexAttachment(at, DRAW_REGION, local, 8);
512
+ }
513
+ } else if (g?.kind === 'mesh' || g?.kind === 'linkedmesh') {
514
+ const source = g.kind === 'mesh' ? g.vertices : sourceOf(g.skin, g.slot, g.source);
515
+ if (typeof source === 'string') throw new RuntimeInputError(`slot "${slot.name}": ${source} (readModel refuses it first)`);
516
+ made = meshAttachment(at, source, indexOf, plant.bindingIndex, nb);
517
+ }
518
+ // A bounding box, a path, a refused record admitted by a plant: the row's name and path, and nothing drawn.
519
+ if (made === null) made = vertexAttachment(at, DRAW_NONE, new Float64Array(0), 0);
520
+ attachments.push(made);
521
+ vertexCount += made.count;
522
+ return { index: attachments.length - 1, missing };
523
+ };
524
+ const slots: RuntimeSlot[] = doc.slots.map((slot, si) => {
525
+ const bone = indexOf(slot.bone);
526
+ const light = slot.color === undefined ? [1, 1, 1, 1] : readColour(slot.color);
527
+ return {
528
+ name: slot.name,
529
+ bone,
530
+ // A slot on a bone the view leaves inactive is not animated (`slotTimelinesApply` in src/core/skins.ts).
531
+ live: active.has(slot.bone),
532
+ setupAttachment: resolve(si, slot.setup, null),
533
+ light: Float64Array.from(light),
534
+ dark: slot.dark === undefined ? null : Float64Array.from(readColour(slot.dark).slice(0, 3)),
535
+ blend: readBlend(slot),
536
+ };
537
+ });
538
+ const slotIndex = new Map(doc.slots.map((s, i) => [s.name, i]));
539
+
540
+ // --- animations ---
541
+ const animations = new Map<string, RuntimeAnimation>();
542
+ const refused: Array<{ animation: string; missing: RuntimeMissing[] }> = [];
543
+ let cursorMax = 0;
544
+ for (const anim of doc.animations) {
545
+ const compiled = compileAnimation(anim, refusals, ignoreEvents, boneIndex, slotIndex, slots, constraints, (si, ph) => resolve(si, ph, anim.name));
546
+ const missing = refusals.byAnimation.get(anim.name);
547
+ if (missing !== undefined && missing.length > 0) refused.push({ animation: anim.name, missing });
548
+ else {
549
+ animations.set(anim.name, compiled);
550
+ cursorMax = Math.max(cursorMax, compiled.cursors);
551
+ }
552
+ }
553
+
554
+ if (refusals.model.length > 0) throw new RuntimeRefusal(refusals.model);
555
+ const names = doc.animations.map((a) => a.name);
556
+ return {
557
+ eventWrapOffByOne: plant.eventWrapOffByOne === true,
558
+ restoreEventsFromStart: plant.restoreEventsFromStart === true,
559
+ doc,
560
+ boneNames: doc.bones.map((b) => b.name),
561
+ parents,
562
+ modes,
563
+ active: activeFlags,
564
+ lengths,
565
+ setup,
566
+ subtreeStart,
567
+ subtrees: Int32Array.from(flat),
568
+ slots,
569
+ attachments,
570
+ vertexCount,
571
+ constraints,
572
+ animations,
573
+ refused,
574
+ cursors: cursorMax,
575
+ scratch: plant.scratchByBones === true ? Math.max(1, nb) : Math.max(1, nb, ...constraints.map((c) => c.bones.length)),
576
+ animation(name: string): RuntimeAnimation {
577
+ const found = animations.get(name);
578
+ if (found !== undefined) return found;
579
+ const no = refused.find((r) => r.animation === name);
580
+ if (no !== undefined) throw new RuntimeRefusal(no.missing);
581
+ throw new RuntimeInputError(`animation ${JSON.stringify(name)} is not one of this document's [${names.join(', ')}]`);
582
+ },
583
+ };
584
+ }
585
+
586
+ type AttachmentHead = { slot: number; name: string; skin: string; placeholder: string; path: string | null; bone: number; offset: number };
587
+
588
+ const NO_INTS = new Int32Array(0);
589
+ const NO_NUMBERS = new Float64Array(0);
590
+
591
+ function vertexAttachment(at: AttachmentHead, draw: number, local: Float64Array, count: number): RuntimeAttachment {
592
+ return { ...at, draw, local, count, bindingCounts: NO_INTS, bindingBones: NO_INTS, bindingX: NO_NUMBERS, bindingY: NO_NUMBERS, bindingWeights: NO_NUMBERS };
593
+ }
594
+
595
+ /** A mesh's (or a linked mesh's source's) vertices: every stored number through `Math.fround` (src/core/vertices.ts, *A mesh's vertices*). */
596
+ function meshAttachment(at: AttachmentHead, vertices: ModelVertices, indexOf: (name: string) => number, rewrite: RuntimePlant['bindingIndex'], bones: number): RuntimeAttachment {
597
+ if (!vertices.weighted) {
598
+ const n = vertices.xy.length - (vertices.xy.length % 2);
599
+ const local = new Float64Array(n);
600
+ for (let i = 0; i < n; i++) local[i] = Math.fround(vertices.xy[i]);
601
+ return vertexAttachment(at, DRAW_MESH, local, n);
602
+ }
603
+ const counts: number[] = [];
604
+ const ids: number[] = [];
605
+ const xs: number[] = [];
606
+ const ys: number[] = [];
607
+ const ws: number[] = [];
608
+ for (const influences of vertices.bindings) {
609
+ counts.push(influences.length);
610
+ for (const b of influences) {
611
+ const i = indexOf(b.bone);
612
+ ids.push(rewrite === undefined ? i : rewrite(i, bones));
613
+ xs.push(Math.fround(b.x));
614
+ ys.push(Math.fround(b.y));
615
+ ws.push(Math.fround(b.weight));
616
+ }
617
+ }
618
+ return { ...at, draw: DRAW_WEIGHTED, local: NO_NUMBERS, count: counts.length * 2, bindingCounts: Int32Array.from(counts), bindingBones: Int32Array.from(ids), bindingX: Float64Array.from(xs), bindingY: Float64Array.from(ys), bindingWeights: Float64Array.from(ws) };
619
+ }
620
+
621
+ function compileIk(r: CoreIkRecord, index: number, applied: boolean, bones: Int32Array, indexOf: (name: string) => number): RuntimeIk {
622
+ return {
623
+ kind: 'ik', name: r.name, index, applied, bones, target: indexOf(r.target),
624
+ scaleY: r.scaleY === 'uniform' ? SCALE_Y_UNIFORM : r.scaleY === 'volume' ? SCALE_Y_VOLUME : SCALE_Y_NONE,
625
+ mix: r.mix, softness: r.softness, flags: ikFlags(r.bendPositive, r.compress, r.stretch),
626
+ };
627
+ }
628
+
629
+ function ikFlags(bendPositive: boolean, compress: boolean, stretch: boolean): number {
630
+ return (bendPositive ? IK_BEND_POSITIVE : 0) | (compress ? IK_COMPRESS : 0) | (stretch ? IK_STRETCH : 0);
631
+ }
632
+
633
+ function compileTransform(r: CoreTransformRecord, index: number, applied: boolean, bones: Int32Array, indexOf: (name: string) => number): RuntimeTransform {
634
+ const fromProperty: number[] = [];
635
+ const fromOffset: number[] = [];
636
+ const toStart: number[] = [];
637
+ const toEnd: number[] = [];
638
+ const toProperty: number[] = [];
639
+ const toOffset: number[] = [];
640
+ const toScale: number[] = [];
641
+ const toMax: number[] = [];
642
+ for (const from of r.properties) {
643
+ fromProperty.push(TRANSFORM_PROPERTIES.indexOf(from.property));
644
+ fromOffset.push(from.offset);
645
+ toStart.push(toProperty.length);
646
+ for (const to of from.to) {
647
+ toProperty.push(TRANSFORM_PROPERTIES.indexOf(to.property));
648
+ toOffset.push(to.offset);
649
+ toScale.push(to.scale);
650
+ toMax.push(to.max);
651
+ }
652
+ toEnd.push(toProperty.length);
653
+ }
654
+ return {
655
+ kind: 'transform', name: r.name, index, applied, bones, source: indexOf(r.source),
656
+ fromProperty: Int32Array.from(fromProperty), fromOffset: Float64Array.from(fromOffset), toStart: Int32Array.from(toStart), toEnd: Int32Array.from(toEnd),
657
+ toProperty: Int32Array.from(toProperty), toOffset: Float64Array.from(toOffset), toScale: Float64Array.from(toScale), toMax: Float64Array.from(toMax),
658
+ offsets: Float64Array.from(TRANSFORM_PROPERTIES.map((p) => r.offsets[p])),
659
+ mixes: Float64Array.from(TRANSFORM_PROPERTIES.map((p) => r.mixes[p])),
660
+ clamp: r.clamp,
661
+ };
662
+ }
663
+
664
+ /**
665
+ * One animation compiled, its constructs not yet posed noted against it. The
666
+ * oracle's lookups are kept: a bone or slot keyed by two targets takes the
667
+ * LAST target (`posedBones`, `posedSlots` build a map of them), a constraint
668
+ * keyed by two timelines the FIRST (`posedRecords` finds it).
669
+ */
670
+ function compileAnimation(
671
+ anim: CoreAnimation,
672
+ refusals: Refusals,
673
+ ignoreEvents: boolean,
674
+ boneIndex: ReadonlyMap<string, number>,
675
+ slotIndex: ReadonlyMap<string, number>,
676
+ slots: readonly RuntimeSlot[],
677
+ constraints: readonly RuntimeConstraint[],
678
+ resolve: (slot: number, placeholder: string | null) => number,
679
+ ): RuntimeAnimation {
680
+ const tl = anim.timelines;
681
+ const name = anim.name;
682
+ let cursors = 0;
683
+ const deform = tl.attachments.filter((a) => a.deform !== null);
684
+ const sequence = tl.attachments.filter((a) => a.sequence !== null);
685
+ if (deform.length > 0) refusals.note('deform-timeline', `${deform.length} deform timeline(s): ${deform.slice(0, 3).map((a) => `${a.skin}/${a.slot}/${a.attachment}`).join(', ')}${deform.length > 3 ? ', …' : ''}`, name);
686
+ if (sequence.length > 0) refusals.note('sequence-timeline', `${sequence.length} sequence timeline(s): ${sequence.slice(0, 3).map((a) => `${a.skin}/${a.slot}/${a.attachment}`).join(', ')}${sequence.length > 3 ? ', …' : ''}`, name);
687
+ if (tl.drawOrder.length > 0) refusals.note('draw-order-timeline', `${tl.drawOrder.length} draw-order key(s)`, name);
688
+
689
+ const lastBy = <T extends { name: string }>(targets: readonly T[]): Map<string, T> => new Map(targets.map((t) => [t.name, t]));
690
+ const bones: RuntimeBoneTimeline[] = [];
691
+ for (const target of lastBy(tl.bones).values()) {
692
+ const bone = boneIndex.get(target.name);
693
+ if (bone === undefined) continue;
694
+ for (const t of target.timelines) {
695
+ if (t.kind === 'inherit') {
696
+ refusals.note('inherit-timeline', `bone "${target.name}" keys its inherit mode`, name);
697
+ continue;
698
+ }
699
+ const spec = BONE_KINDS[t.kind];
700
+ bones.push({ bone, curve: compileCurve(t.keys, spec.length), cursor: cursors++, fields: Int32Array.from(spec.map((s) => s[0])), multiply: Uint8Array.from(spec.map((s) => s[1])) });
701
+ }
702
+ }
703
+ const slotTimelines: RuntimeSlotTimeline[] = [];
704
+ const keyedSlots = [...lastBy(tl.slots).values()].flatMap((target) => {
705
+ const si = slotIndex.get(target.name);
706
+ return si === undefined ? [] : [{ si, target }];
707
+ }).sort((a, b) => a.si - b.si);
708
+ for (const { si, target } of keyedSlots) {
709
+ // A slot on an inactive bone is not animated: its timelines are read and posed by nothing.
710
+ if (!slots[si].live) continue;
711
+ for (const t of target.timelines) {
712
+ if (t.kind === 'attachment') {
713
+ const ids = Int32Array.from(t.keys.map((k) => resolve(si, k.name ?? null)));
714
+ slotTimelines.push({ slot: si, curve: compileCurve(t.keys, 0), cursor: cursors++, attachments: ids, channels: null });
715
+ } else if (t.kind === 'rgba2' || t.kind === 'rgb2') {
716
+ refusals.note('dark-colour-timeline', `slot "${target.name}" keys an ${t.kind} timeline`, name);
717
+ } else {
718
+ const channels = COLOUR_CHANNELS[t.kind];
719
+ slotTimelines.push({ slot: si, curve: compileCurve(t.keys, channels.length), cursor: cursors++, attachments: null, channels: Int32Array.from(channels) });
720
+ }
721
+ }
722
+ }
723
+ const constraintTimelines: Array<RuntimeConstraintTimeline | null> = constraints.map(() => null);
724
+ for (const c of constraints) {
725
+ if (c.kind === 'ik') {
726
+ const keyed = anim.constraints.ik.find((x) => x.name === c.name);
727
+ if (keyed === undefined) continue;
728
+ constraintTimelines[c.index] = { constraint: c.index, curve: compileCurve(keyed.keys, 2), cursor: cursors++, flags: Uint8Array.from(keyed.keys.map((k) => ikFlags(k.flags.bendPositive, k.flags.compress, k.flags.stretch))) };
729
+ } else {
730
+ const keyed = anim.constraints.transform.find((x) => x.name === c.name);
731
+ if (keyed === undefined) continue;
732
+ constraintTimelines[c.index] = { constraint: c.index, curve: compileCurve(keyed.keys, TRANSFORM_PROPERTIES.length), cursor: cursors++, flags: null };
733
+ }
734
+ }
735
+ return { name, duration: tl.duration, bones, slots: slotTimelines, constraints: constraintTimelines, cursors, events: tl.events.length, eventKeys: Object.freeze(ignoreEvents ? [] : tl.events.map(key => Object.freeze({ name: key.name, time: key.time, int: key.int, float: key.float, string: key.string, volume: key.volume ?? 0, balance: key.balance ?? 0 }))) };
736
+ }