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.
- package/README.md +2 -1
- package/cli.ts +131 -403
- package/docs/AUTHORING.md +63 -14
- package/docs/INGEST.md +1 -0
- package/docs/MOTION.md +1 -0
- package/package.json +1 -1
- package/src/atlas.ts +25 -0
- package/src/check.ts +113 -24
- package/src/checkpics.ts +2 -0
- package/src/compile.ts +641 -506
- package/src/core/animation.ts +982 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1721 -0
- package/src/core/constraints_path.ts +942 -0
- package/src/core/constraints_physics.ts +830 -0
- package/src/core/constraints_slider.ts +505 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1103 -0
- package/src/core/raw.ts +347 -0
- package/src/core/skins.ts +104 -0
- package/src/core/uvs.ts +448 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/world.ts +233 -0
- package/src/deformmeasure.ts +337 -73
- package/src/deformreport.ts +370 -0
- package/src/depth.ts +101 -2
- package/src/emit_spine.ts +860 -0
- package/src/mesh.ts +24 -15
- package/src/model.ts +958 -0
- package/src/render.ts +649 -143
- package/src/render_core.ts +582 -0
- package/src/rig.ts +34 -6
- package/src/transform.ts +26 -4
- package/src/types.ts +18 -0
- package/src/validate.ts +30 -2
|
@@ -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
|
-
/**
|
|
293
|
-
|
|
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
|
-
|
|
2521
|
-
|
|
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
|
|