spine-rigc 0.2.1

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/src/mesh.ts ADDED
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Procedural mesh geometry.
3
+ *
4
+ * The shape this builds is the one a deformable aperture asks for: **the rim is
5
+ * nailed down and only the inside moves.** For a face cut that ring is the mouth
6
+ * aperture, and the rim already exists as data — the mask polygon in the cut
7
+ * manifest is exactly the contour where the generated part fades into untouched
8
+ * base pixels. Pin those vertices to the slot bone at weight 1 and the seam
9
+ * cannot move, which is the whole reason a mesh is safe here at all.
10
+ *
11
+ * Three rings and a hub:
12
+ *
13
+ * rim ring = the part WINDOW edge weight 1.0 -> anchor bone
14
+ * seam ring = the manifest polygon weight 1.0 -> anchor bone
15
+ * inner ring = the polygon scaled toward the weight w -> control bone
16
+ * aperture centre by `inner`
17
+ * hub = the aperture centre weight 1.0 -> control bone
18
+ *
19
+ * The rim ring is not decoration and the first cut of this code did not have it.
20
+ * A generated part carries alpha well OUTSIDE its mask polygon — thousands of
21
+ * pixels of it, on the one this was measured against — and that band is the
22
+ * feather, the soft ramp that makes generated pixels blend into the base at all.
23
+ * A mesh whose outer ring is the polygon simply does not draw them, and a render
24
+ * probe caught exactly that: a halo of difference reaching several pixels past
25
+ * the polygon, versus the rigid build of the same part. So the
26
+ * mesh covers the whole region (rim ring on the window edge, uv 0 and 1) and the
27
+ * polygon becomes an interior ring — pinned, because it is still the seam.
28
+ *
29
+ * The control bone carries every key, so key count is independent of vertex
30
+ * count and a physics constraint could later be hung on the same bone for free.
31
+ * There are no deform timelines: deforming the vertices directly is the fallback
32
+ * for when procedural weighting fails, and it has not.
33
+ *
34
+ * Everything here is pure and integer-stable: same manifest in, same floats out
35
+ * (assertion A18 recompiles and compares bytes).
36
+ */
37
+
38
+ export interface MeshSpecInput {
39
+ /** Polygon in part-local pixels, y down, in manifest order. */
40
+ hull: Array<[number, number]>;
41
+ /** Aperture centre in part-local pixels, y down. */
42
+ center: [number, number];
43
+ /** Inner ring position: 0 = at the centre, 1 = on the hull. */
44
+ inner: number;
45
+ /** Part window size in pixels, for UVs. */
46
+ size: [number, number];
47
+ /** Optional directional weighting across the mouth line — see `sideWeight`. */
48
+ bias?: { axis_deg: number; ramp: [number, number] };
49
+ /**
50
+ * Screen-space angle of each control bone as seen from the aperture centre, in
51
+ * control-bone order. One entry keeps the single-bone behaviour exactly; more
52
+ * than one splits the ring's authority by angular position, which is how four
53
+ * grips make a ring expand unevenly without a key per vertex.
54
+ */
55
+ controlAngles?: number[];
56
+ }
57
+
58
+ export interface MeshVertexWeight {
59
+ /** 'anchor' pins to the slot bone, 'control' to a control/chain bone. */
60
+ bone: 'anchor' | 'control';
61
+ /** Index into the control-bone list. Absent means 0. */
62
+ control?: number;
63
+ weight: number;
64
+ }
65
+
66
+ export interface MeshGeometry {
67
+ kind: 'ring' | 'ribbon';
68
+ /** Vertex positions in part-local pixels, y down. */
69
+ points: Array<[number, number]>;
70
+ /** Normalised region UVs, v measured from the top edge. */
71
+ uvs: number[];
72
+ /** Triangle indices, counter-clockwise in Spine world (y up). */
73
+ triangles: number[];
74
+ /** Per-vertex weights, parallel to `points`. */
75
+ weights: MeshVertexWeight[][];
76
+ /** Hull vertex count — emitted as `hull`, which the loader doubles. */
77
+ hullVertices: number;
78
+ }
79
+
80
+ /**
81
+ * A two-wide strip along a bone chain — a trickle, a strap, a tail. It changes
82
+ * length without changing width and its path curves; region scale cannot express
83
+ * either, because scaling a region longer also makes it fatter.
84
+ *
85
+ * The width guarantee is structural, not a hope: the two vertices of a row carry
86
+ * IDENTICAL weights, so whatever the chain does to one it does to the other, and
87
+ * their separation can only rotate. Assertion A28 checks exactly that, which
88
+ * turns "length without width" from a claim into a property of the file.
89
+ */
90
+ export interface RibbonSpecInput {
91
+ /** Part window size in pixels. The strip spans it, so uv 0 and 1 are covered. */
92
+ size: [number, number];
93
+ /** Cross rows, entry first. Triangles = 2 * (rows - 1). */
94
+ rows: number;
95
+ /** Number of chain bones after the anchor. */
96
+ chainCount: number;
97
+ }
98
+
99
+ export class MeshError extends Error {}
100
+
101
+ /** Round to 6 decimals and never emit "-0" (byte-stable output). */
102
+ function r6(n: number): number {
103
+ const v = Math.round(n * 1e6) / 1e6;
104
+ return v === 0 ? 0 : v;
105
+ }
106
+
107
+ /**
108
+ * Smoothstep falloff from the hub (r=0, full control) to the hull (r=1, none).
109
+ *
110
+ * A linear ramp puts a visible crease on the inner ring because the second
111
+ * derivative jumps there; smoothstep is flat at both ends, so the deformation
112
+ * dies into the pinned rim instead of hitting it.
113
+ */
114
+ export function falloff(r: number): number {
115
+ const t = Math.max(0, Math.min(1, r));
116
+ return r6(1 - (3 * t * t - 2 * t * t * t));
117
+ }
118
+
119
+ /**
120
+ * Directional authority across an axis: 0 on the negative side, 1 on the
121
+ * positive side, smoothstep over `ramp`.
122
+ *
123
+ * This is what makes a jaw read as a jaw. A radial falloff alone deforms the
124
+ * ring symmetrically, so opening the mouth drags the upper lip down with the
125
+ * lower one and takes the upper teeth with it — measured on this art, the teeth
126
+ * sit 15px on the negative side of the mouth line, squarely inside the moving
127
+ * zone. Ramping authority across the line leaves everything above it pinned.
128
+ */
129
+ export function sideWeight(signedDistance: number, ramp: [number, number]): number {
130
+ const [d0, d1] = ramp;
131
+ if (!(d1 > d0)) throw new MeshError(`bias ramp must increase, got [${d0}, ${d1}]`);
132
+ const t = Math.max(0, Math.min(1, (signedDistance - d0) / (d1 - d0)));
133
+ return r6(3 * t * t - 2 * t * t * t);
134
+ }
135
+
136
+ /** Signed area of a polygon in the given (y-down) coordinates. */
137
+ export function signedArea(poly: Array<[number, number]>): number {
138
+ let a = 0;
139
+ for (let i = 0; i < poly.length; i++) {
140
+ const [x0, y0] = poly[i];
141
+ const [x1, y1] = poly[(i + 1) % poly.length];
142
+ a += x0 * y1 - x1 * y0;
143
+ }
144
+ return a / 2;
145
+ }
146
+
147
+ /**
148
+ * Is every hull edge visible from `center`? If not, scaling the polygon toward
149
+ * the centre can fold the inner ring through the rim and the triangles cross —
150
+ * a mesh that loads with no error and renders as folded meat. Better to refuse.
151
+ */
152
+ export function isStarShaped(poly: Array<[number, number]>, center: [number, number]): boolean {
153
+ const area = signedArea(poly);
154
+ const [cx, cy] = center;
155
+ for (let i = 0; i < poly.length; i++) {
156
+ const [x0, y0] = poly[i];
157
+ const [x1, y1] = poly[(i + 1) % poly.length];
158
+ const cross = (x1 - x0) * (cy - y0) - (y1 - y0) * (cx - x0);
159
+ if (cross * area <= 0) return false;
160
+ }
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * Cast a ray from `center` through `through` and return the point where it
166
+ * leaves the [0,w]x[0,h] window. The polygon lives inside the window, so the
167
+ * ray always exits after it — this is what keeps the rim ring in 1:1
168
+ * correspondence with the seam ring, and a clean quad strip between them.
169
+ */
170
+ export function rayToWindowEdge(
171
+ center: [number, number],
172
+ through: [number, number],
173
+ size: [number, number],
174
+ ): [number, number] {
175
+ const [cx, cy] = center;
176
+ const [px, py] = through;
177
+ const [w, h] = size;
178
+ const dx = px - cx;
179
+ const dy = py - cy;
180
+ if (dx === 0 && dy === 0) throw new MeshError('a polygon vertex sits exactly on the aperture centre');
181
+ let t = Infinity;
182
+ if (dx > 0) t = Math.min(t, (w - cx) / dx);
183
+ if (dx < 0) t = Math.min(t, (0 - cx) / dx);
184
+ if (dy > 0) t = Math.min(t, (h - cy) / dy);
185
+ if (dy < 0) t = Math.min(t, (0 - cy) / dy);
186
+ if (!Number.isFinite(t) || t <= 0) throw new MeshError('ray to the window edge did not converge');
187
+ return [r6(cx + dx * t), r6(cy + dy * t)];
188
+ }
189
+
190
+ export function buildRingMesh(input: MeshSpecInput): MeshGeometry {
191
+ const { hull, center, inner, size } = input;
192
+ const n = hull.length;
193
+ if (n < 6) throw new MeshError(`hull needs at least 6 points, got ${n}`);
194
+ if (!(inner > 0 && inner < 1)) throw new MeshError(`inner must be in (0,1), got ${inner}`);
195
+ const [w, h] = size;
196
+ if (!(w > 0 && h > 0)) throw new MeshError(`bad part size ${w}x${h}`);
197
+
198
+ for (const [x, y] of hull) {
199
+ if (x < 0 || y < 0 || x > w || y > h) {
200
+ throw new MeshError(`hull point (${x},${y}) is outside the ${w}x${h} part window`);
201
+ }
202
+ }
203
+ if (!isStarShaped(hull, center)) {
204
+ throw new MeshError('hull is not star-shaped about the aperture centre; the inner ring would fold');
205
+ }
206
+ const [cx, cy] = center;
207
+ if (cx < 0 || cy < 0 || cx > w || cy > h) {
208
+ throw new MeshError(`aperture centre (${cx},${cy}) is outside the ${w}x${h} part window`);
209
+ }
210
+
211
+ const points: Array<[number, number]> = [];
212
+ const weights: MeshVertexWeight[][] = [];
213
+ const pin = (x: number, y: number) => {
214
+ points.push([r6(x), r6(y)]);
215
+ weights.push([{ bone: 'anchor', weight: 1 }]);
216
+ };
217
+
218
+ // ring 0 — the window edge. Covers the feather, so nothing the part draws is
219
+ // outside the mesh; pinned, so uv 0/1 stay put.
220
+ for (const [x, y] of hull) {
221
+ const [ex, ey] = rayToWindowEdge(center, [x, y], size);
222
+ pin(ex, ey);
223
+ }
224
+ // ring 1 — the mask contour. This IS the seam: pinned at weight 1.
225
+ for (const [x, y] of hull) pin(x, y);
226
+ // ring 2 — the aperture ring, shared between the two bones by the falloff and,
227
+ // when a bias axis is declared, by which side of the mouth line it lands on.
228
+ const wInner = falloff(inner);
229
+ const axis = input.bias
230
+ ? ([Math.cos((input.bias.axis_deg * Math.PI) / 180), Math.sin((input.bias.axis_deg * Math.PI) / 180)] as const)
231
+ : null;
232
+ // Normal of the mouth line, pointing to the jaw side (screen y down).
233
+ const normal = axis ? ([-axis[1], axis[0]] as const) : null;
234
+ const sideOf = (x: number, y: number): number => {
235
+ if (!normal || !input.bias) return 1;
236
+ return sideWeight((x - cx) * normal[0] + (y - cy) * normal[1], input.bias.ramp);
237
+ };
238
+ // Angular split across several control bones. With one control this collapses
239
+ // to "all authority to control 0", which is the single-bone path byte for byte.
240
+ const controlAngles = input.controlAngles ?? [0];
241
+ if (controlAngles.length < 1) throw new MeshError('a ring mesh needs at least one control bone');
242
+ const sorted = controlAngles
243
+ .map((deg, index) => ({ index, deg: ((deg % 360) + 360) % 360 }))
244
+ .sort((p, q) => (p.deg === q.deg ? p.index - q.index : p.deg - q.deg));
245
+ for (let i = 1; i < sorted.length; i++) {
246
+ if (sorted[i].deg === sorted[i - 1].deg) {
247
+ throw new MeshError(`two control bones share the angle ${sorted[i].deg} degrees about the aperture centre`);
248
+ }
249
+ }
250
+ /** Which controls own the authority at this angle, and in what proportion. */
251
+ const splitByAngle = (x: number, y: number): Array<{ index: number; share: number }> => {
252
+ if (sorted.length === 1) return [{ index: sorted[0].index, share: 1 }];
253
+ const deg = ((Math.atan2(y - cy, x - cx) * 180) / Math.PI + 360) % 360;
254
+ let k = sorted.length - 1; // the wrap-around arc, unless we find a better one
255
+ for (let i = 0; i < sorted.length; i++) {
256
+ const next = (i + 1) % sorted.length;
257
+ const from = sorted[i].deg;
258
+ const to = sorted[next].deg + (next === 0 ? 360 : 0);
259
+ const d = deg < from ? deg + 360 : deg;
260
+ if (d >= from && d < to) {
261
+ k = i;
262
+ break;
263
+ }
264
+ }
265
+ const next = (k + 1) % sorted.length;
266
+ const from = sorted[k].deg;
267
+ const to = sorted[next].deg + (next === 0 ? 360 : 0);
268
+ const d = deg < from ? deg + 360 : deg;
269
+ const t = to === from ? 0 : (d - from) / (to - from);
270
+ // Smoothstep for the same reason the radial falloff uses it: a linear blend
271
+ // puts a crease exactly on the bone's angle.
272
+ const s = r6(3 * t * t - 2 * t * t * t);
273
+ const out: Array<{ index: number; share: number }> = [];
274
+ if (s < 1) out.push({ index: sorted[k].index, share: r6(1 - s) });
275
+ if (s > 0) out.push({ index: sorted[next].index, share: s });
276
+ return out;
277
+ };
278
+ const share = (x: number, y: number, base: number) => {
279
+ const w = r6(base * sideOf(x, y));
280
+ points.push([r6(x), r6(y)]);
281
+ // A zero weight is not a weight: the validator rejects it (A20), and the
282
+ // loader would happily read it as a bone that owns nothing.
283
+ if (w <= 0) {
284
+ weights.push([{ bone: 'anchor', weight: 1 }]);
285
+ return;
286
+ }
287
+ const parts = splitByAngle(x, y);
288
+ const vertex: MeshVertexWeight[] = [];
289
+ if (w < 1) vertex.push({ bone: 'anchor', weight: r6(1 - w) });
290
+ for (const part of parts) {
291
+ const weight = r6(w * part.share);
292
+ if (weight > 0) vertex.push({ bone: 'control', control: part.index, weight });
293
+ }
294
+ weights.push(vertex);
295
+ };
296
+ for (const [x, y] of hull) {
297
+ share(cx + (x - cx) * inner, cy + (y - cy) * inner, wInner);
298
+ }
299
+ // hub — at the centre of the aperture, so the bias ramp decides its share too.
300
+ share(cx, cy, 1);
301
+
302
+ const uvs: number[] = [];
303
+ for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
304
+
305
+ // Counter-clockwise in Spine world: the manifest polygon runs clockwise on
306
+ // screen (y down), and the y flip into world space reverses that.
307
+ const triangles: number[] = [];
308
+ const hub = 3 * n;
309
+ const strip = (outerBase: number, innerBase: number) => {
310
+ for (let i = 0; i < n; i++) {
311
+ const j = (i + 1) % n;
312
+ triangles.push(outerBase + i, outerBase + j, innerBase + j);
313
+ triangles.push(outerBase + i, innerBase + j, innerBase + i);
314
+ }
315
+ };
316
+ strip(0, n); // window edge -> seam
317
+ strip(n, 2 * n); // seam -> aperture ring
318
+ for (let i = 0; i < n; i++) {
319
+ const j = (i + 1) % n;
320
+ triangles.push(2 * n + i, 2 * n + j, hub);
321
+ }
322
+
323
+ return { kind: 'ring', points, uvs, triangles, weights, hullVertices: n };
324
+ }
325
+
326
+ /**
327
+ * Build a ribbon strip.
328
+ *
329
+ * Vertices run in PERIMETER order — left side entry-to-tip, then right side
330
+ * tip-to-entry — so the emitted `hull` is the real outline rather than a
331
+ * convenient prefix. That matters because `hull` is data other tools read, and a
332
+ * strip's outline genuinely is all of its vertices.
333
+ *
334
+ * Weights: knot 0 is the anchor bone at the entry point, knots 1..C are the chain
335
+ * bones, spaced evenly along the strip. A row between two knots blends linearly
336
+ * between them, and BOTH vertices of the row get the same blend. The entry row is
337
+ * pinned to the anchor at weight 1, which is what keeps the drip's origin at the
338
+ * entry point while the rest of it falls — assertion A21's ribbon branch.
339
+ */
340
+ export function buildRibbonMesh(input: RibbonSpecInput): MeshGeometry {
341
+ const { rows, chainCount } = input;
342
+ const [w, h] = input.size;
343
+ if (!(w > 0 && h > 0)) throw new MeshError(`bad part size ${w}x${h}`);
344
+ if (!Number.isInteger(rows) || rows < 3) throw new MeshError(`ribbon needs at least 3 rows, got ${rows}`);
345
+ if (!Number.isInteger(chainCount) || chainCount < 1) {
346
+ throw new MeshError(`ribbon needs at least one chain bone, got ${chainCount}`);
347
+ }
348
+
349
+ const points: Array<[number, number]> = [];
350
+ const weights: MeshVertexWeight[][] = [];
351
+ const rowWeights: MeshVertexWeight[][] = [];
352
+ for (let i = 0; i < rows; i++) {
353
+ // s runs 0 at the entry to 1 at the tip; knots sit at k / chainCount.
354
+ const s = (i / (rows - 1)) * chainCount;
355
+ const lo = Math.min(Math.floor(s), chainCount - 1);
356
+ const t = r6(s - lo);
357
+ const vertex: MeshVertexWeight[] = [];
358
+ // knot index 0 is the anchor bone; 1..chainCount are chain[0..chainCount-1].
359
+ const push = (knot: number, weight: number) => {
360
+ if (weight <= 0) return;
361
+ if (knot === 0) vertex.push({ bone: 'anchor', weight: r6(weight) });
362
+ else vertex.push({ bone: 'control', control: knot - 1, weight: r6(weight) });
363
+ };
364
+ push(lo, 1 - t);
365
+ push(lo + 1, t);
366
+ rowWeights.push(vertex);
367
+ }
368
+ const rowY = (i: number) => r6((i / (rows - 1)) * h);
369
+ // left side, entry -> tip
370
+ for (let i = 0; i < rows; i++) {
371
+ points.push([0, rowY(i)]);
372
+ weights.push(rowWeights[i]);
373
+ }
374
+ // right side, tip -> entry
375
+ for (let i = rows - 1; i >= 0; i--) {
376
+ points.push([r6(w), rowY(i)]);
377
+ weights.push(rowWeights[i]);
378
+ }
379
+
380
+ const uvs: number[] = [];
381
+ for (const [x, y] of points) uvs.push(r6(x / w), r6(y / h));
382
+
383
+ // Quad strip. Left row i is index i; right row i is index (2*rows - 1 - i).
384
+ const triangles: number[] = [];
385
+ const L = (i: number) => i;
386
+ const R = (i: number) => 2 * rows - 1 - i;
387
+ for (let i = 0; i < rows - 1; i++) {
388
+ triangles.push(L(i), L(i + 1), R(i + 1));
389
+ triangles.push(L(i), R(i + 1), R(i));
390
+ }
391
+
392
+ return { kind: 'ribbon', points, uvs, triangles, weights, hullVertices: 2 * rows };
393
+ }
394
+
395
+ /** One bone a weighted vertex can bind to: its index, and its world inverse. */
396
+ export interface MeshBoneRef {
397
+ index: number;
398
+ /** Spine world point -> this bone's local space, at the setup pose. */
399
+ toBind: (worldX: number, worldY: number) => [number, number];
400
+ }
401
+
402
+ /**
403
+ * Weighted-mesh `vertices` encoding: per vertex, boneCount then
404
+ * (boneIndex, bindX, bindY, weight) repeated.
405
+ *
406
+ * Bind coordinates are in each bone's LOCAL space, so a rotated bone needs a real
407
+ * inverse transform — see `src/transform.ts` for why the old "world minus origin"
408
+ * shortcut had to go and what it would have failed like.
409
+ *
410
+ * The encoding is chosen by a length comparison alone, so
411
+ * there is no field that says "weighted" — get the run lengths wrong and the
412
+ * loader reads weights as coordinates without a word.
413
+ */
414
+ export function encodeWeightedVertices(
415
+ geometry: MeshGeometry,
416
+ /** Part-local pixel -> Spine world. */
417
+ toWorld: (x: number, y: number) => [number, number],
418
+ bones: { anchor: MeshBoneRef; controls: MeshBoneRef[] },
419
+ ): number[] {
420
+ const out: number[] = [];
421
+ geometry.points.forEach(([px, py], i) => {
422
+ const [wx, wy] = toWorld(px, py);
423
+ const vw = geometry.weights[i];
424
+ out.push(vw.length);
425
+ for (const { bone, control, weight } of vw) {
426
+ const ref = bone === 'anchor' ? bones.anchor : bones.controls[control ?? 0];
427
+ if (!ref) throw new MeshError(`vertex ${i} binds to control bone ${control ?? 0}, which the rig does not have`);
428
+ const [bx, by] = ref.toBind(wx, wy);
429
+ out.push(ref.index, bx, by, r6(weight));
430
+ }
431
+ });
432
+ return out;
433
+ }
package/src/png.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * PNG header reader.
3
+ *
4
+ * The only thing rigc needs from a part PNG is its true pixel size and whether
5
+ * it carries an alpha channel, and both live in the IHDR chunk that every PNG
6
+ * puts first. Parsing 26 bytes keeps the compiler dependency-free: no image
7
+ * library, and nothing on the module path but rigc itself.
8
+ *
9
+ * Measuring instead of trusting is the whole point: an atlas `size:` that
10
+ * disagrees with the file loads clean and collapses the UVs silently.
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+
14
+ const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
15
+
16
+ /** PNG colour types that carry a per-pixel alpha channel. */
17
+ const COLOUR_TYPE_HAS_ALPHA: Record<number, boolean> = {
18
+ 0: false, // greyscale
19
+ 2: false, // truecolour
20
+ 3: false, // indexed (may have tRNS, but not a straight alpha channel)
21
+ 4: true, // greyscale + alpha
22
+ 6: true, // truecolour + alpha
23
+ };
24
+
25
+ export interface PngInfo {
26
+ width: number;
27
+ height: number;
28
+ bitDepth: number;
29
+ colourType: number;
30
+ hasAlpha: boolean;
31
+ }
32
+
33
+ export function readPngInfo(path: string): PngInfo {
34
+ const buf = readFileSync(path);
35
+ if (buf.length < 26) throw new Error(`not a PNG (too short): ${path}`);
36
+ for (let i = 0; i < SIGNATURE.length; i++) {
37
+ if (buf[i] !== SIGNATURE[i]) throw new Error(`not a PNG (bad signature): ${path}`);
38
+ }
39
+ if (buf.toString('latin1', 12, 16) !== 'IHDR') {
40
+ throw new Error(`PNG does not start with IHDR: ${path}`);
41
+ }
42
+ const colourType = buf.readUInt8(25);
43
+ return {
44
+ width: buf.readUInt32BE(16),
45
+ height: buf.readUInt32BE(20),
46
+ bitDepth: buf.readUInt8(24),
47
+ colourType,
48
+ hasAlpha: COLOUR_TYPE_HAS_ALPHA[colourType] ?? false,
49
+ };
50
+ }