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,582 @@
1
+ /**
2
+ * The render's second poser (issue #968, step 3d of issue #380): the posing
3
+ * seam of `src/render.ts` (`Poser` / `Posed`) implemented over rigc's own
4
+ * core — the compiled model document (`skeleton.model.json`) posed by
5
+ * `src/core/`, and the atlas the render samples read by rigc's own reader
6
+ * (`src/atlas.ts`). Nothing here links spine-core, and nothing here is reached
7
+ * from `src/core/`: the core stays pure, and this module is the adapter from
8
+ * its raw entry to the renderer's shapes.
9
+ *
10
+ * ⭐ Why a module of its own rather than a second block in `src/render.ts`.
11
+ * `render.ts` links spine-core (the atlas pages, texture substitution and
12
+ * `spinePoser`), so a core poser written there would share an import list
13
+ * with the runtime it replaces — and "the core poser imports nothing from
14
+ * spine-core" could then only be said, not read off the file. Here it can be:
15
+ * the value imports below are the core, the atlas reader and nothing else, and
16
+ * the one thing taken from `render.ts` is its types (erased at run time). The
17
+ * modules that link spine-core stay three (`CUR07`).
18
+ *
19
+ * ## What each part of a pose is read from
20
+ *
21
+ * - **The walk** is `poseRawAnimation` (`src/core/raw.ts`, issue #966) with
22
+ * `count` steps of `1/fps` and the reset taken at the animation
23
+ * (`'animation'`): pose `i` is the running sum of `i` steps, applied at
24
+ * `min(sum, duration)`, physics reset at pose 0 and stepped by `1/fps` after
25
+ * — the recipe `spinePoser` runs, measured bit-exact against it on every
26
+ * frame of the nineteen tree rows by the core suite's `CR03`. The setup pose
27
+ * is `poseRawSetup`.
28
+ * - **Vertices, triangles, bones, slot colours, draw order** are the raw
29
+ * pose's, unchanged: the same doubles `computeWorldVertices`,
30
+ * `getWorldRotationX` and the slot pose hold (`CR03`).
31
+ * - **The page and page UVs** are `./core/uvs.ts`'s (issue #967): which
32
+ * region a drawn attachment samples (`drawnRegions` over the pose's `shown`
33
+ * records and draw order), a region's four UVs (`regionPageUvs`) and a
34
+ * mesh's (`meshPageUvs`), each held to `sequence.getUVs(index)` at
35
+ * tolerance 0 on every corpus by `core_gate`'s `uvs` blocks.
36
+ * - **The tint** is the slot's light colour times the attachment's colour,
37
+ * channel by channel, as `src/render.ts`'s `tintOf` forms it; the dark
38
+ * colour is the slot's, absent where the slot carries none.
39
+ * - **The clip.** Which slot a clip covers is the core's walk
40
+ * (`./core/clipping.ts`, issue #964), whose rows the raw pose carries with
41
+ * the attachment's LOCAL UVs. The render samples PAGE UVs, so each clipped
42
+ * row is cut again with `clipThrough` over the page UVs — the UV rule is
43
+ * linear in the corner UVs, and the vertices and triangles depend on
44
+ * positions alone, so the second cut must return the core's vertices,
45
+ * triangles and verdict to the bit, and a disagreement is thrown as a
46
+ * defect here rather than drawn. The polygon is the clip slot's world
47
+ * polygon from the pose's `clips` rows, and the walk that pairs a slot with
48
+ * the clip over it is the core's rule restated: a clip whose bone is active
49
+ * starts when none is active, and ends after its end slot is walked.
50
+ * - **A concave or inverse clip** is cut through the core's own convex
51
+ * decomposition (`clipThrough`, issue #964), and each drawn triangle carries
52
+ * its source triangle (`clipSourceOf`, `Mesh.source`): the rasteriser
53
+ * samples it at the source triangle's affine UV, so the pixels are the
54
+ * spine-core render's whatever pieces either clipper cut (`RC08`). A clip
55
+ * that is not simple is refused by the raw entry, naming the slot
56
+ * (`CoreInputError`); the render then falls back to `spinePoser` for that
57
+ * input and says so (`throughPoser` in `src/render.ts`).
58
+ *
59
+ * ## The skin
60
+ *
61
+ * `--skin <name>` poses `underSkin(doc, name)` — the named skin's record,
62
+ * else the default skin's; the named skin's bones and constraints —
63
+ * measured against spine-core's `setSkin(name)` by the per-skin gate
64
+ * (issue #932). No `--skin` is spine-core's "no skin set", which resolves
65
+ * every slot through the default skin alone: posed as `underSkin(doc,
66
+ * 'default')`, the reading `CR03` measured bit-exact against it on every tree
67
+ * row. ⚠️ That equivalence is measured only where the default skin names no
68
+ * skin-required bone and no constraint — which of the two readings activates
69
+ * such a bone with no skin set was not measured — so a document whose default
70
+ * skin names one is REFUSED by the core poser, by name, and renders through
71
+ * spine-core; so is a document with skins and no default one.
72
+ */
73
+ import type {
74
+ AttachmentPose,
75
+ AttachmentRest,
76
+ BoneSnapshot,
77
+ DrawOptions,
78
+ Piece,
79
+ PieceTexture,
80
+ PoseOptions,
81
+ Posed,
82
+ Poser,
83
+ SlotSubset,
84
+ } from './render.ts';
85
+ import { atlasRegionLookup, parseAtlasText, type AtlasRegion } from './atlas.ts';
86
+ import { spineFileSha256 } from './model.ts';
87
+ import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
88
+ import { CoreInputError, readModel, sourceOfDoc, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
89
+ import { poseRawAnimation, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
90
+ import { CORE_ALL_SKINS, CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
91
+ import { drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvSource } from './core/uvs.ts';
92
+ import { regionCorners, worldVertices } from './core/vertices.ts';
93
+ import type { CoreWorld } from './core/world.ts';
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // the slot subset, spelled once for both posers
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Why a slot subset cannot be drawn — a `--slot`/`--hide` naming no slot, a slot
101
+ * whose art only another skin carries, or both flags at once (issue #835).
102
+ *
103
+ * A class of its own so `cli.ts` can turn it into a usage refusal (exit 2,
104
+ * nothing written) without reading a message to decide what kind it is. It
105
+ * lives here, and `src/render.ts` re-exports it, so both posers throw the one
106
+ * class.
107
+ */
108
+ export class SlotSubsetError extends Error {}
109
+
110
+ /** The flag spelling each half of a subset is refused under — the UI's, since that is who reads it. */
111
+ const SUBSET_FLAG = { slots: '--slot', hidden: '--hide' } as const;
112
+
113
+ /** What `subsetOver` reads off a skeleton: its slots in draw order, which skins carry a slot's art, and its default skin. */
114
+ export interface SubsetRoster {
115
+ /** Every declared slot, in the setup draw order. */
116
+ declared: readonly string[];
117
+ /** The skins, in the skeleton's order, holding at least one attachment for `slot`. */
118
+ carriers(slot: string): string[];
119
+ /** The default skin's name, or `null` when there is none. */
120
+ defaultSkin: string | null;
121
+ }
122
+
123
+ /**
124
+ * `slotSubsetOf`'s rule over a roster rather than a parsed Spine skeleton, so
125
+ * the spine-core poser and the core poser refuse a subset in the same words —
126
+ * see `slotSubsetOf` in `src/render.ts` for the rule and its three refusals.
127
+ */
128
+ export function subsetOver(
129
+ roster: SubsetRoster,
130
+ opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined,
131
+ skin: string | undefined,
132
+ ): SlotSubset | undefined {
133
+ if (opts?.slots !== undefined && opts.hidden !== undefined) {
134
+ throw new SlotSubsetError(
135
+ '--slot and --hide are one statement two ways; name the slots to draw or the slots to hide, not both',
136
+ );
137
+ }
138
+ const mode = opts?.slots !== undefined ? 'slots' : opts?.hidden !== undefined ? 'hidden' : undefined;
139
+ if (mode === undefined) return undefined;
140
+ const asked = (mode === 'slots' ? opts?.slots : opts?.hidden) ?? [];
141
+ const flag = SUBSET_FLAG[mode];
142
+ const declared = [...roster.declared];
143
+
144
+ const unknown = asked.filter((name) => !declared.includes(name));
145
+ if (unknown.length > 0 || asked.length === 0) {
146
+ const named =
147
+ unknown.length === 0
148
+ ? 'was given no slot name'
149
+ : `${unknown.map((name) => JSON.stringify(name)).join(', ')} ${unknown.length === 1 ? 'names' : 'name'} no slot`;
150
+ throw new SlotSubsetError(
151
+ `${flag} ${named}; this skeleton declares, in draw order: ${declared.join(', ') || 'none'} (${declared.length})`,
152
+ );
153
+ }
154
+
155
+ const resolving = new Set([skin ?? null, roster.defaultSkin]);
156
+ const underThisPose =
157
+ skin === undefined ? 'under no skin (the default skin alone)' : `under skin ${JSON.stringify(skin)}`;
158
+ for (const name of asked) {
159
+ const carriers = roster.carriers(name);
160
+ if (carriers.length === 0 || carriers.some((s) => resolving.has(s))) continue;
161
+ const skins = carriers.map((s) => JSON.stringify(s));
162
+ throw new SlotSubsetError(
163
+ `${flag} ${JSON.stringify(name)} draws nothing ${underThisPose}: its attachments are declared only under ` +
164
+ `${skins.length === 1 ? 'skin' : 'skins'} ${skins.join(', ')} — pass --skin ${
165
+ skins.length === 1 ? skins[0] : 'with one of them'
166
+ }`,
167
+ );
168
+ }
169
+ const chosen = new Set(asked);
170
+ return { mode, names: declared.filter((name) => chosen.has(name)) };
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // a bone the posed skin leaves inactive, spelled once for both posers
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * The snapshot of a bone the posed skin leaves UNPOSED — inactive itself (a
179
+ * skin-required bone the skin does not name) or below an inactive bone: the
180
+ * zero transform, every number `0` (issue #968).
181
+ *
182
+ * ⚠️ "Below an inactive bone" is the half the runtime's flag does not say. A
183
+ * bone that is not skin-required reads `active` true under an inactive parent
184
+ * (measured: `arm` skin-required and unnamed, its child `hand` not
185
+ * skin-required — spine-core reads `arm=false hand=true`), yet its matrix is
186
+ * never computed: it stays the zero matrix over every frame unless a
187
+ * constraint writes into it. So the predicate is the bone's own flag and every
188
+ * ancestor's (`unposedBones`).
189
+ *
190
+ * ⭐ Why the seam defines it rather than relaying either runtime. An inactive
191
+ * bone is not posed: neither poser computes its world transform, so its
192
+ * matrix holds whatever a constraint listing it happened to write into a zero
193
+ * matrix. Measured on hand-written rigs under no skin (a skin-required `arm`
194
+ * and its child `hand`, both inactive): a one-bone ik on `hand` left
195
+ * spine-core's matrix at zeros and the core's at NaN; a two-bone ik on `arm,
196
+ * hand` left spine-core's `b` and `d` at `-0` — so `getWorldRotationY` read
197
+ * −179.99999734 (`atan2(−0, …)` at the runtime's pi) — and the core's at `+0`,
198
+ * reading 0; a world transform constraint wrote `worldX` 19.99999979 and a
199
+ * `-0` into both alike. The private corpus met the second case on two rigs:
200
+ * 508 rotation readings of 179.99999734 against 0, every pixel identical. None
201
+ * of those numbers is a pose — a rotation of ±180 is the sign of a zero, and
202
+ * the core's NaN the same degenerate arithmetic taken another way — so the
203
+ * snapshot says what is true of the bone, that it is not posed, in both
204
+ * posers alike. The drawn pieces are untouched: they are posed vertices, not
205
+ * snapshots.
206
+ */
207
+ export function inactiveBoneSnapshot(name: string): BoneSnapshot {
208
+ return { name, worldX: 0, worldY: 0, a: 0, b: 0, c: 0, d: 0, rotationX: 0, rotationY: 0, scaleX: 0, scaleY: 0 };
209
+ }
210
+
211
+ /** The bones the posed skin leaves unposed — inactive, or below an inactive bone — from each bone's parent and flag, parents first. */
212
+ export function unposedBones(bones: ReadonlyArray<{ name: string; parent: string | null; active: boolean }>): Set<string> {
213
+ const out = new Set<string>();
214
+ for (const b of bones) if (!b.active || (b.parent !== null && out.has(b.parent))) out.add(b.name);
215
+ return out;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // a clipped piece's source triangles, spelled once for both posers
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * `Mesh.source` of a clipped piece (issue #964): for each drawn triangle, in
224
+ * order, its source triangle's world corners and page UVs — and, with
225
+ * `artUvs`, their original-art UVs — six numbers each, read off the UNCLIPPED
226
+ * piece's own arrays. `sources[i]` is the source triangle of drawn triangle `i`
227
+ * (an index into `triangles` by threes). The rasteriser samples a drawn
228
+ * triangle at that source triangle's affine map, so the pixels do not depend on
229
+ * which convex pieces a clipper cut.
230
+ */
231
+ export function clipSourceOf(
232
+ world: ArrayLike<number>,
233
+ uvs: ArrayLike<number>,
234
+ triangles: ArrayLike<number>,
235
+ sources: readonly number[],
236
+ artUvs?: ArrayLike<number>,
237
+ ): { world: number[]; uvs: number[]; artUvs: number[] | undefined } {
238
+ const w: number[] = [];
239
+ const u: number[] = [];
240
+ const a: number[] | undefined = artUvs === undefined ? undefined : [];
241
+ for (const t of sources) {
242
+ for (let k = 0; k < 3; k++) {
243
+ const i = triangles[3 * t + k];
244
+ w.push(world[2 * i], world[2 * i + 1]);
245
+ u.push(uvs[2 * i], uvs[2 * i + 1]);
246
+ if (a !== undefined && artUvs !== undefined) a.push(artUvs[2 * i], artUvs[2 * i + 1]);
247
+ }
248
+ }
249
+ return { world: w, uvs: u, artUvs: a };
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // the core poser
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /** The runtime's own triangulation of a region's quad — `src/render.ts`'s `QUAD_TRIANGLES`. */
257
+ const QUAD_TRIANGLES: readonly number[] = [0, 1, 2, 2, 3, 0];
258
+
259
+ /** Everything a core pose reads besides the pose: the document under one skin, and the atlas. */
260
+ interface CoreInput {
261
+ doc: CompiledDocument;
262
+ source: UvSource;
263
+ /** The atlas region a name draws, as rigc's reader holds it — what an original-art UV reads its trim from. */
264
+ region: (name: string) => AtlasRegion | null;
265
+ }
266
+
267
+ /**
268
+ * The document posed with no skin set — spine-core's initial state, every slot
269
+ * resolved through the default skin alone — or refused by name where that
270
+ * equivalence was not measured (the header's *The skin*).
271
+ */
272
+ function noSkinView(doc: CompiledDocument): CompiledDocument {
273
+ if (doc.skins.length === 0) return underSkin(doc, CORE_ALL_SKINS);
274
+ const fallback = doc.skins.find((k) => k.name === CORE_DEFAULT_SKIN);
275
+ if (fallback === undefined) {
276
+ throw new CoreInputError(
277
+ `no skin was set, and this document declares skins [${doc.skins.map((k) => k.name).join(', ')}] and no "${CORE_DEFAULT_SKIN}" one — ` +
278
+ 'what spine-core shows with no skin set over such a document was not measured',
279
+ );
280
+ }
281
+ const constraints = Object.entries(fallback.constraints).flatMap(([kind, names]) => names.map((n) => `${kind} "${n}"`));
282
+ if (fallback.bones.length > 0 || constraints.length > 0) {
283
+ throw new CoreInputError(
284
+ `no skin was set, and the "${CORE_DEFAULT_SKIN}" skin names ${[...fallback.bones.map((b) => `bone "${b}"`), ...constraints].join(', ')} — ` +
285
+ 'whether spine-core activates what the default skin names when no skin is set was not measured, so the core does not pose it',
286
+ );
287
+ }
288
+ return underSkin(doc, CORE_DEFAULT_SKIN);
289
+ }
290
+
291
+ /** A slot row's number, which the raw entry writes as a double — `null` only for a value that is not finite. */
292
+ function channel(value: number | null, slot: string): number {
293
+ if (value === null) throw new CoreInputError(`slot "${slot}": the raw pose computed a colour channel that is not finite`);
294
+ return value;
295
+ }
296
+
297
+ /** The world transforms of a raw pose's bones, by name — what the rest table poses through. */
298
+ function worldOf(pose: RawPose): Map<string, CoreWorld> {
299
+ return new Map(pose.bones.map((b) => [b.name, { a: b.a, b: b.b, c: b.c, d: b.d, worldX: b.worldX, worldY: b.worldY }]));
300
+ }
301
+
302
+ /** `regionKey` in `src/render.ts`: the trimmed name and the sequence index — the key a substitution matches on. */
303
+ function regionKey(region: AtlasRegion): string {
304
+ return `${region.name.trim()}#${region.index}`;
305
+ }
306
+
307
+ /** `artUvsOf` in `src/render.ts`, over rigc's atlas reader: a mesh's own UVs, a region's kept rectangle in the drawing's space. */
308
+ function artUvsOf(drawn: RawDrawn, region: AtlasRegion): PieceTexture | undefined {
309
+ if (drawn.kind === 'mesh') return { region: regionKey(region), artUvs: [...drawn.uvs] };
310
+ if (region.degrees !== 0) return undefined;
311
+ const ow = region.originalWidth;
312
+ const oh = region.originalHeight;
313
+ if (!(ow > 0) || !(oh > 0)) return undefined;
314
+ const s0 = region.offsetX / ow;
315
+ const s1 = (region.offsetX + region.width) / ow;
316
+ const tBottom = 1 - region.offsetY / oh;
317
+ const tTop = 1 - (region.offsetY + region.height) / oh;
318
+ return { region: regionKey(region), artUvs: [s0, tBottom, s0, tTop, s1, tTop, s1, tBottom] };
319
+ }
320
+
321
+ /**
322
+ * The clip polygon over each slot of the draw order, where a clip covers it —
323
+ * the core's walk (`poseClipped` in `./core/clipping.ts`) restated to pair a
324
+ * slot with its polygon; `clippedPieces` holds its answer to the core's own
325
+ * rows.
326
+ */
327
+ function clipCover(pose: RawPose, active: ReadonlySet<string>): Map<string, ClipShape> {
328
+ const polygons = new Map(pose.clips.map((c) => [c[0], { end: c[2], polygon: c[3] }]));
329
+ const kinds = new Map(pose.shown.map((s) => [s.slot, s]));
330
+ const cover = new Map<string, ClipShape>();
331
+ let current: { end: string | null; shape: ClipShape } | null = null;
332
+ for (const slot of pose.drawOrder) {
333
+ const shown = kinds.get(slot);
334
+ const g = shown?.geometry;
335
+ const clip = g?.kind === 'clipping' ? polygons.get(slot) : undefined;
336
+ if (shown !== undefined && g?.kind === 'clipping' && clip !== undefined) {
337
+ if (current !== null && current.end === slot) current = null;
338
+ if (active.has(shown.bone) && current === null) current = { end: clip.end, shape: { polygon: clip.polygon, inverse: g.inverse, convex: g.convex } };
339
+ continue;
340
+ }
341
+ if (current !== null) cover.set(slot, current.shape);
342
+ if (current !== null && current.end === slot) current = null;
343
+ }
344
+ return cover;
345
+ }
346
+
347
+ /** One posed moment of the core, read through the seam's `Posed`. */
348
+ function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper): Posed {
349
+ const slotRows = new Map<string, CoreSlotRow>(pose.slots.map((row) => [row[0], row]));
350
+ let regions: Map<string, DrawnRegion> | null = null;
351
+ const regionsOf = (): Map<string, DrawnRegion> => {
352
+ if (regions !== null) return regions;
353
+ const { drawn, why } = drawnRegions(input.doc, pose.shown, pose.drawOrder, input.source);
354
+ if (drawn === null) throw new CoreInputError(`the page UVs are not posed: ${why}`);
355
+ regions = new Map(drawn.map((d) => [d.slot, d]));
356
+ return regions;
357
+ };
358
+ const tintOf = (d: RawDrawn): [number, number, number, number] => {
359
+ const row = slotRows.get(d.slot);
360
+ if (row === undefined) throw new CoreInputError(`slot "${d.slot}" drew and has no slot row`);
361
+ return [
362
+ channel(row[2], d.slot) * d.colour[0],
363
+ channel(row[3], d.slot) * d.colour[1],
364
+ channel(row[4], d.slot) * d.colour[2],
365
+ channel(row[5], d.slot) * d.colour[3],
366
+ ];
367
+ };
368
+ const darkOf = (d: RawDrawn): [number, number, number] | undefined => {
369
+ const dark = slotRows.get(d.slot)?.[6] ?? null;
370
+ return dark === null ? undefined : [channel(dark[0], d.slot), channel(dark[1], d.slot), channel(dark[2], d.slot)];
371
+ };
372
+ return {
373
+ pieces: (draw: DrawOptions): Piece[] => pieces(input, pose, draw, regionsOf(), tintOf, darkOf, through),
374
+ bones: (): BoneSnapshot[] => {
375
+ const unposed = unposedBones(pose.bones);
376
+ return pose.bones.map((b) => !unposed.has(b.name) ? ({
377
+ name: b.name,
378
+ worldX: b.worldX,
379
+ worldY: b.worldY,
380
+ a: b.a,
381
+ b: b.b,
382
+ c: b.c,
383
+ d: b.d,
384
+ rotationX: b.rotationX,
385
+ rotationY: b.rotationY,
386
+ scaleX: b.scaleX,
387
+ scaleY: b.scaleY,
388
+ }) : inactiveBoneSnapshot(b.name));
389
+ },
390
+ attachments: (): AttachmentPose[] =>
391
+ pose.drawn.map((d) => ({ slot: d.slot, attachment: d.attachment, vertices: [...d.vertices], color: tintOf(d) })),
392
+ };
393
+ }
394
+
395
+ /** The drawables of one core pose, in draw order — `drawPieces` in `src/render.ts`, read off the raw pose. */
396
+ function pieces(
397
+ input: CoreInput,
398
+ pose: RawPose,
399
+ draw: DrawOptions,
400
+ regions: Map<string, DrawnRegion>,
401
+ tintOf: (d: RawDrawn) => [number, number, number, number],
402
+ darkOf: (d: RawDrawn) => [number, number, number] | undefined,
403
+ through: ShapeClipper,
404
+ ): Piece[] {
405
+ const named = draw.subset === undefined ? undefined : new Set(draw.subset.names);
406
+ const cover = draw.unclipped ? new Map<string, ClipShape>() : clipCover(pose, new Set(pose.bones.filter((b) => b.active).map((b) => b.name)));
407
+ const rows = new Map(pose.clipped.map((row) => [row[0], row]));
408
+ if (!draw.unclipped) {
409
+ const walked = [...cover.keys()].filter((slot) => pose.drawn.some((d) => d.slot === slot)).join();
410
+ const cored = pose.clipped.map((row) => row[0]).join();
411
+ if (walked !== cored) throw new Error(`the clip cover [${walked}] is not the core's clipped roster [${cored}] — the two walks disagree`);
412
+ }
413
+ const out: Piece[] = [];
414
+ for (const d of pose.drawn) {
415
+ const drawn = draw.subset === undefined || named === undefined || named.has(d.slot) === (draw.subset.mode === 'slots');
416
+ if (!drawn) continue;
417
+ const found = regions.get(d.slot);
418
+ if (found === undefined) throw new CoreInputError(`slot "${d.slot}" drew "${d.attachment}", and no atlas region was resolved for it`);
419
+ const uvs = found.art === null ? regionPageUvs(found.found.region, found.found.page) : meshPageUvs(found.found.region, found.found.page, found.art);
420
+ const atlasRegion = draw.texture ? input.region(found.region) : null;
421
+ if (draw.texture && atlasRegion === null) throw new CoreInputError(`slot "${d.slot}": atlas region "${found.region}" is not in the atlas`);
422
+ const texture = atlasRegion === null ? undefined : artUvsOf(d, atlasRegion);
423
+ const common = { tint: tintOf(d), dark: darkOf(d), slot: d.slot, page: found.found.page.name };
424
+ const world = [...d.vertices];
425
+ const piece: Piece =
426
+ d.kind === 'mesh'
427
+ ? { kind: 'mesh', ...common, texture, world, uvs, triangles: [...d.triangles] }
428
+ : { kind: 'region', ...common, texture, world, uvs };
429
+ const shape = cover.get(d.slot);
430
+ out.push(shape === undefined ? piece : clippedPiece(piece, d, shape, uvs, rows.get(d.slot), through));
431
+ }
432
+ return out;
433
+ }
434
+
435
+ /**
436
+ * `piece` cut by the clip over it, over the page UVs (the header's *The clip*),
437
+ * or `piece` itself when the clipper cut nothing — `clippedPiece` in
438
+ * `src/render.ts`. The cut's vertices, triangles and verdict are held to the
439
+ * core's own row for the slot.
440
+ */
441
+ function clippedPiece(piece: Piece, d: RawDrawn, shape: ClipShape, uvs: number[], row: RawPose['clipped'][number] | undefined, through: ShapeClipper): Piece {
442
+ const cut = through(shape, d.vertices, d.triangles, uvs);
443
+ if (cut === null) throw new Error(`slot "${d.slot}": the clip over it is one the core does not draw, and the raw pose carried it — a defect in src/render_core.ts`);
444
+ if (row === undefined || (row[2] === 1) !== cut.clipped || row[3].join() !== cut.vertices.join() || row[5].join() !== cut.triangles.join()) {
445
+ throw new Error(`slot "${d.slot}": the clip over the page UVs cut other geometry than the core's clipped row — a defect in src/render_core.ts`);
446
+ }
447
+ if (!cut.clipped) return piece;
448
+ let texture = piece.texture;
449
+ const source = clipSourceOf(d.vertices, uvs, d.triangles, cut.sources, texture?.artUvs);
450
+ if (texture !== undefined) {
451
+ const art = through(shape, d.vertices, d.triangles, texture.artUvs);
452
+ if (art === null || art.uvs.length !== cut.uvs.length) {
453
+ throw new Error(
454
+ `slot "${piece.slot}": the clip cut ${cut.uvs.length / 2} vertices for the page UVs and ` +
455
+ `${(art?.uvs.length ?? 0) / 2} for the original-art UVs over the same geometry`,
456
+ );
457
+ }
458
+ texture = { region: texture.region, artUvs: art.uvs, sourceArtUvs: source.artUvs };
459
+ }
460
+ const { tint, dark, slot, page } = piece;
461
+ return { kind: 'mesh', tint, dark, slot, page, texture, world: cut.vertices, uvs: cut.uvs, triangles: cut.triangles, source: { world: source.world, uvs: source.uvs } };
462
+ }
463
+
464
+ /**
465
+ * The rest table (`restOf` in `src/render.ts`): every (slot, attachment) the
466
+ * frames show, in order of first appearance, posed on the setup bones with no
467
+ * deform — the attachment looked up by that name as a placeholder in the
468
+ * posed skin, then the default skin, as `Skeleton.getAttachment` does.
469
+ */
470
+ function restOf(view: CompiledDocument, setup: RawPose, shown: readonly AttachmentPose[][]): AttachmentRest[] {
471
+ const world = worldOf(setup);
472
+ const bones = new Map(view.slots.map((s) => [s.name, s.bone]));
473
+ const sourceOf = sourceOfDoc(view);
474
+ const seen = new Map<string, Set<string>>();
475
+ const out: AttachmentRest[] = [];
476
+ for (const entries of shown) {
477
+ for (const entry of entries) {
478
+ const names = seen.get(entry.slot) ?? new Set<string>();
479
+ if (names.has(entry.attachment)) continue;
480
+ names.add(entry.attachment);
481
+ seen.set(entry.slot, names);
482
+ const skin = lookupSkins(view).find((k) => k.attachments[entry.slot]?.[entry.attachment] !== undefined);
483
+ const record = skin?.attachments[entry.slot]?.[entry.attachment];
484
+ const g = record?.geometry;
485
+ const boneName = bones.get(entry.slot);
486
+ const bone = boneName === undefined ? undefined : world.get(boneName);
487
+ if (skin === undefined || g === undefined || bone === undefined || (g.kind !== 'region' && g.kind !== 'mesh' && g.kind !== 'linkedmesh')) {
488
+ throw new Error(
489
+ `slot ${JSON.stringify(entry.slot)} showed attachment ${JSON.stringify(entry.attachment)} in a frame, and ` +
490
+ 'the setup skeleton resolves no region or mesh of that name there',
491
+ );
492
+ }
493
+ if (g.kind === 'region') {
494
+ const rect = g.region.atlas;
495
+ if (rect === null) throw new CoreInputError(`slot "${entry.slot}" region "${entry.attachment}": the model states the build had no atlas rectangle for it`);
496
+ out.push({ slot: entry.slot, attachment: entry.attachment, kind: 'region', vertices: regionCorners({ ...g.region, atlas: rect }, bone), triangles: [...QUAD_TRIANGLES] });
497
+ continue;
498
+ }
499
+ const mesh = g.kind === 'mesh' ? g : view.skins.find((k) => k.name === g.skin)?.attachments[g.slot]?.[g.source]?.geometry;
500
+ const vertices = g.kind === 'mesh' ? g.vertices : sourceOf(g.skin, g.slot, g.source);
501
+ if (mesh === undefined || mesh.kind !== 'mesh' || typeof vertices === 'string') throw new CoreInputError(`slot "${entry.slot}": the linked mesh "${entry.attachment}" resolves no source mesh`);
502
+ if (mesh.hull === undefined) throw new CoreInputError(`slot "${entry.slot}" mesh "${entry.attachment}": the model states no hull`);
503
+ out.push({
504
+ slot: entry.slot,
505
+ attachment: entry.attachment,
506
+ kind: 'mesh',
507
+ vertices: worldVertices(vertices, bone, world),
508
+ triangles: [...mesh.triangles],
509
+ hull: mesh.hull,
510
+ uvs: [...mesh.uvs],
511
+ });
512
+ }
513
+ }
514
+ return out;
515
+ }
516
+
517
+ /**
518
+ * `Poser` over rigc's own core: `modelText` a `rigc-compiled/1` document
519
+ * (`skeleton.model.json`), `atlasText` the atlas the render samples — the one
520
+ * `build` wrote beside it. With `skeleton`, the Spine file beside the document
521
+ * is held to the digest the document records (`spine.sha256`, issue #968) and
522
+ * refused, naming both digests, when it is not that build's. Refused by
523
+ * `CoreInputError`, naming why, where the document or the atlas cannot be read; a pose the core leaves a block of out
524
+ * is refused the same way when it is asked for (the header).
525
+ */
526
+ export function corePoser(modelText: string, atlasText: string, where = 'skeleton.model.json', skeleton?: { path: string; bytes: Uint8Array }, through: ShapeClipper = clipThrough): Poser {
527
+ const doc = readModel(modelText, where);
528
+ // The document poses the rig it was built with; the Spine file beside it must be that build's (issue #968).
529
+ if (skeleton !== undefined) {
530
+ const found = spineFileSha256(skeleton.bytes);
531
+ if (found !== doc.spine.sha256) {
532
+ throw new CoreInputError(
533
+ `${skeleton.path} is not the skeleton.json ${where} was written beside: its sha256 is ${found}, the document records ${doc.spine.sha256} — ` +
534
+ 'the Spine file was edited or replaced after the build, and the core would draw the build\'s rig instead of it',
535
+ );
536
+ }
537
+ }
538
+ let parsed: unknown;
539
+ try {
540
+ parsed = JSON.parse(modelText);
541
+ } catch (err) {
542
+ throw new CoreInputError(`${where}: not JSON — ${(err as Error).message}`);
543
+ }
544
+ const sequences = readUvSequences(parsed);
545
+ const atlas = parseAtlasText(atlasText);
546
+ const lookup = atlasRegionLookup(atlas);
547
+ const views = new Map<string, CompiledDocument>();
548
+ const inputOf = (skin: string | undefined): CoreInput => {
549
+ const key = skin === undefined ? '' : `=${skin}`;
550
+ let view = views.get(key);
551
+ if (view === undefined) {
552
+ view = skin === undefined ? noSkinView(doc) : underSkin(doc, skin);
553
+ views.set(key, view);
554
+ }
555
+ return { doc: view, source: { lookup, sequences }, region: (name) => lookup(name)?.region ?? null };
556
+ };
557
+ const roster: SubsetRoster = {
558
+ declared: doc.slots.map((s) => s.name),
559
+ carriers: (slot) => doc.skins.filter((k) => Object.keys(k.attachments[slot] ?? {}).length > 0).map((k) => k.name),
560
+ defaultSkin: doc.skins.some((k) => k.name === CORE_DEFAULT_SKIN) ? CORE_DEFAULT_SKIN : null,
561
+ };
562
+ return {
563
+ animations: doc.animations.map((a) => ({ name: a.name, duration: a.timelines.duration })),
564
+ bones: doc.bones.map((b) => ({ name: b.name, parent: b.parent ?? null })),
565
+ slots: doc.slots.map((s) => ({ name: s.name, bone: s.bone })),
566
+ subset: (opts, skin) => subsetOver(roster, opts, skin),
567
+ setup: (skin) => {
568
+ const input = inputOf(skin);
569
+ return corePosed(input, poseRawSetup(input.doc, { through }), through);
570
+ },
571
+ animation: (name, skin, fps, count, visit) => {
572
+ const input = inputOf(skin);
573
+ const step = 1 / fps;
574
+ const poses = poseRawAnimation(input.doc, name, new Array<number>(count).fill(step), { through }, 'animation');
575
+ poses.forEach((pose, i) => visit(i, corePosed(input, pose, through)));
576
+ },
577
+ rest: (skin, shown) => {
578
+ const input = inputOf(skin);
579
+ return restOf(input.doc, poseRawSetup(input.doc), shown);
580
+ },
581
+ };
582
+ }
package/src/rig.ts CHANGED
@@ -289,11 +289,32 @@ export interface RigBone {
289
289
  // slots — `root.slots[]` (SkeletonJson.ts:121-141)
290
290
  // ---------------------------------------------------------------------------
291
291
 
292
- /** `SlotData.ts:64`. */
293
- export type RigSlotBlend = 'normal' | 'additive' | 'multiply' | 'screen';
294
-
292
+ /**
293
+ * `SlotData.ts:64`, read by `SkeletonJson.js:124` through `Utils.enumValue`, so
294
+ * only the first letter's case is free: `additive` and `Additive` read
295
+ * `Additive`, while `ADDITIVE`, `mUlTiPlY` and `foo` read as no mode with no
296
+ * error (issue #946, measured with `tools/pose_oracle.ts dump` on spine-core
297
+ * 4.3.13). The parser refuses those by name and the emitter writes the
298
+ * spelling as stated, which is one the runtime resolves.
299
+ */
300
+ export type RigSlotBlend =
301
+ | 'normal'
302
+ | 'additive'
303
+ | 'multiply'
304
+ | 'screen'
305
+ | 'Normal'
306
+ | 'Additive'
307
+ | 'Multiply'
308
+ | 'Screen';
309
+
310
+ /** The four modes, as a refusal names them; each also reads with its first letter upper-cased. */
295
311
  export const RIG_SLOT_BLEND: readonly RigSlotBlend[] = ['normal', 'additive', 'multiply', 'screen'];
296
312
 
313
+ /** Whether the runtime resolves a stated blend to a mode: its first letter folded, the rest exactly one of the four. */
314
+ export function isRigSlotBlend(value: unknown): value is RigSlotBlend {
315
+ return typeof value === 'string' && (RIG_SLOT_BLEND as readonly string[]).includes(value.charAt(0).toLowerCase() + value.slice(1));
316
+ }
317
+
297
318
  /**
298
319
  * One slot. **The array order IS the draw order** — there is no separate setup
299
320
  * draw-order field anywhere in the format.
@@ -338,7 +359,7 @@ export interface RigSlot {
338
359
  color?: string;
339
360
  /** Two-colour tint, `rrggbb`. 🚫 `A12_NO_DARK_COLOR` under `spine-html`. */
340
361
  dark?: string;
341
- /** Default `normal`. */
362
+ /** Default `normal`. Only the first letter's case is free (`RigSlotBlend`). */
342
363
  blend?: RigSlotBlend;
343
364
  }
344
365
 
@@ -2517,8 +2538,15 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2517
2538
  `${where}: slot "${slot.name}" names bone ${JSON.stringify(slot.bone)}, which this rig does not declare`,
2518
2539
  );
2519
2540
  }
2520
- if (slot.blend !== undefined && !RIG_SLOT_BLEND.some((v) => v.toLowerCase() === String(slot.blend).toLowerCase())) {
2521
- throw new CompileError(`${where}: slot "${slot.name}" has blend ${JSON.stringify(slot.blend)}; known: ${RIG_SLOT_BLEND.join(', ')}`);
2541
+ // Issue #946: this used to compare case-insensitively, so `ADDITIVE` built
2542
+ // green and the runtime read the slot as no mode at all. The rule is now the
2543
+ // runtime's own, the one `buildRigConstraint`'s enums state.
2544
+ if (slot.blend !== undefined && !isRigSlotBlend(slot.blend)) {
2545
+ throw new CompileError(
2546
+ `${where}: slot "${slot.name}" has blend ${JSON.stringify(slot.blend)}; known: ${RIG_SLOT_BLEND.join(', ')} ` +
2547
+ "(only the first letter's case is free — the parser's enumValue uppercases that one character and nothing " +
2548
+ 'else, and an unresolved name becomes undefined without an error)',
2549
+ );
2522
2550
  }
2523
2551
  }
2524
2552