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/LICENSE +21 -0
- package/NOTICE.md +76 -0
- package/README.md +558 -0
- package/cli.ts +739 -0
- package/docs/AUTHORING.md +1303 -0
- package/docs/SPEC_COVERAGE.md +1109 -0
- package/package.json +65 -0
- package/src/check.ts +1714 -0
- package/src/compile.ts +1861 -0
- package/src/diff.ts +847 -0
- package/src/errors.ts +22 -0
- package/src/framing.ts +539 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +433 -0
- package/src/png.ts +50 -0
- package/src/render.ts +974 -0
- package/src/rig.ts +731 -0
- package/src/slots.ts +603 -0
- package/src/timelines.ts +253 -0
- package/src/transform.ts +130 -0
- package/src/types.ts +586 -0
- package/src/validate.ts +1586 -0
- package/tools/font5x7.ts +101 -0
- package/tools/plate.ts +286 -0
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
|
+
}
|