spine-rigc 2.56.0 → 2.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,683 @@
1
+ /**
2
+ * Splitting an authored mesh into its pieces (issue #1414, item 2).
3
+ *
4
+ * One mesh is one outline: `hull` is read off the triangles, and a triangle list
5
+ * whose outline is not one closed loop is refused at compile (`traceOutline`). So
6
+ * an author who deletes the triangles that bridged two islands — two legs joined
7
+ * by transparent cells, say — is left with a mesh no build accepts, and the only
8
+ * way forward is one mesh per island: its UVs, its weights, its triangles and
9
+ * every deform key that moved it, renumbered. This module writes that proposal
10
+ * from the two spec files, and nothing else.
11
+ *
12
+ * ⭐ **A piece is a set of triangles connected through shared vertices**, and
13
+ * nothing else decides it: no distance, no art, no threshold. Two triangles that
14
+ * touch at one vertex are one piece (and that piece is then refused for its pinch
15
+ * by the outline rule, exactly as the compiler would refuse it); two that share
16
+ * nothing are two. A vertex no triangle names draws nothing in either rig, so it
17
+ * belongs to no piece and is dropped, by name.
18
+ *
19
+ * 🔒 **Nothing in the proposal is a value the spec did not state.** Every piece's
20
+ * vertices carry the original's `uvs` and `weights` (or `vertices`) entries
21
+ * unchanged; its triangles are the original's, renumbered; its deform runs are the
22
+ * original's numbers at their new indices, with a `0` wherever the new array has
23
+ * a slot the original run did not cover — which is what the original array held
24
+ * there, because the parser fills everything outside a run with zero (AUTHORING
25
+ * §4.11: a key is a sparse edit, and both encodings are offsets from setup). So a
26
+ * piece poses exactly as its vertices did inside the original mesh; the selftest
27
+ * holds that at tolerance 0, frame by frame, on both posers.
28
+ *
29
+ * Where the pieces go, and why there:
30
+ *
31
+ * - **The first piece stays in the original slot, under the original
32
+ * placeholder.** Everything that names the slot — its setup attachment, its
33
+ * colour and attachment timelines, a clip's range, a stage box — keeps
34
+ * meaning what it meant, so the proposal edits the fewest lines it can.
35
+ * - **Piece k (k ≥ 2) gets a new slot `<slot>_<k>`** (`pieceSlotName`), on the
36
+ * same bone with the same colour, dark colour and blend, placed directly after
37
+ * the original in the slot list — which is the draw order — and filled under
38
+ * the SAME placeholder, so the attachment's `name`, `path` and `image` resolve
39
+ * exactly as before. A derived name that is already a slot is refused by name;
40
+ * the compiler would refuse the duplicate, and a different spelling would be a
41
+ * name this module made up.
42
+ * - **Pieces are numbered by their first triangle in the list**, which is the
43
+ * order the original drew them in: Spine draws a mesh's triangles in list
44
+ * order, so piece 1 is under piece 2 wherever they overlap. When the pieces'
45
+ * triangles interleave in the list that is not the whole truth, and the
46
+ * proposal says so rather than staying silent about it.
47
+ *
48
+ * A piece's vertices keep their original relative order when that order already
49
+ * lists the piece's outline first and in order; otherwise they are renumbered the
50
+ * way the hull refusal asks — the outline walk first, then the interior in
51
+ * original order — so every piece builds without a second hand edit.
52
+ *
53
+ * ⛔ What the proposal refuses rather than guesses, each by name: a generated
54
+ * mesh (its generator builds one outline by construction), a `sequence`, a raw
55
+ * `boneIndexing` run, a deform key stated as a `transform` model (it is evaluated
56
+ * over the attachment's whole geometry, and splitting changes the geometry it
57
+ * reads), the slot filled in another skin, a linked mesh borrowing this one, a
58
+ * clip whose range ends at this slot, a draw-order key or a slot-property group
59
+ * track naming the slot, and a sequence timeline on the attachment. Each would
60
+ * need a decision the spec does not state.
61
+ *
62
+ * Pure: no clock, no randomness, no file system. The CLI (`rigc split`) reads and
63
+ * writes the files; this module maps one pair of parsed specs to another.
64
+ */
65
+ import { checkHullOrder, formatWalk, MeshError, traceOutline } from './mesh.ts';
66
+ import { splitRigSkin, type RigAttachment, type RigMeshAttachment, type RigMeshBinding, type RigSlot, type RigSpec } from './rig.ts';
67
+ import type { MotionDeformKey, MotionDeformTrack, MotionSetupSlot, MotionSpec, MotionTrack } from './types.ts';
68
+
69
+ /** A split refused: `status` 2 when the invocation names something the specs do not have, 1 when the specs hold something the split cannot carry. */
70
+ export class SplitError extends Error {
71
+ constructor(
72
+ message: string,
73
+ readonly status: 1 | 2,
74
+ ) {
75
+ super(message);
76
+ }
77
+ }
78
+
79
+ /** The record `rigc split` writes beside the two proposed specs. */
80
+ export const SPLIT_SPEC = 'rigc-split/1';
81
+
82
+ /** The slot piece `k` (1-based) of a split of `slot` is filed under. Piece 1 keeps the slot itself. */
83
+ export function pieceSlotName(slot: string, k: number): string {
84
+ return k === 1 ? slot : `${slot}_${k}`;
85
+ }
86
+
87
+ /**
88
+ * A name as one POSIX shell word (issue #1442): unchanged when every character
89
+ * is one no shell treats specially, otherwise single-quoted with each `'`
90
+ * written `'\''`. A command line a refusal prints is copied into a shell, and
91
+ * a slot named `left legs` printed bare is two arguments there — the second
92
+ * refusal would then be about a slot nobody named.
93
+ */
94
+ export function shellWord(name: string): string {
95
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(name) ? name : `'${name.replaceAll("'", "'\\''")}'`;
96
+ }
97
+
98
+ /**
99
+ * The mesh attachments a build of `rig` (with no manifest beside it) counts
100
+ * against `invariants.meshSlots`: every `type: "mesh"` in every skin that does
101
+ * not borrow its geometry through `source` — a linked mesh is not counted — the
102
+ * authored ones included, because a stated budget counts them. The selftest
103
+ * holds this to the compile refusal's own count (SPL14).
104
+ */
105
+ function budgetedMeshCount(rig: RigSpec): number {
106
+ let count = 0;
107
+ for (const [name, skin] of Object.entries(rig.skins ?? {})) {
108
+ for (const map of Object.values(splitRigSkin(skin, `skin "${name}"`).attachments)) {
109
+ for (const entry of Object.values(map)) {
110
+ const att: unknown = entry;
111
+ if (isRecord(att) && att.type === 'mesh' && att.source === undefined) count++;
112
+ }
113
+ }
114
+ }
115
+ return count;
116
+ }
117
+
118
+ /** The properties a track keys on a slot, as opposed to a bone or a constraint. */
119
+ const SLOT_PROPERTIES = new Set<string>(['rgba', 'rgb', 'alpha', 'rgba2', 'rgb2', 'attachment']);
120
+
121
+ /**
122
+ * The connected pieces of a triangulation: triangles that share a vertex are one
123
+ * piece. Each piece lists its triangles (indices into the triangle list, in list
124
+ * order) and its vertices (ascending); pieces are ordered by their first
125
+ * triangle. `orphans` are the vertices no triangle names, ascending.
126
+ */
127
+ export interface MeshPieces {
128
+ pieces: Array<{ triangles: number[]; vertices: number[] }>;
129
+ orphans: number[];
130
+ }
131
+
132
+ /** Read the pieces off a triangle list over `vertexCount` vertices. Indices are assumed in range (the caller checks). */
133
+ export function meshPieces(vertexCount: number, triangles: readonly number[]): MeshPieces {
134
+ const parent = Array.from({ length: vertexCount }, (_v, i) => i);
135
+ const find = (v: number): number => {
136
+ let root = v;
137
+ while (parent[root] !== root) root = parent[root];
138
+ while (parent[v] !== root) {
139
+ const next = parent[v];
140
+ parent[v] = root;
141
+ v = next;
142
+ }
143
+ return root;
144
+ };
145
+ const used = new Array<boolean>(vertexCount).fill(false);
146
+ for (let i = 0; i + 2 < triangles.length; i += 3) {
147
+ const [a, b, c] = [triangles[i], triangles[i + 1], triangles[i + 2]];
148
+ used[a] = used[b] = used[c] = true;
149
+ // Union toward the smaller root, so the root is a deterministic function of the list.
150
+ for (const [p, q] of [[a, b], [b, c]]) {
151
+ const rp = find(p);
152
+ const rq = find(q);
153
+ if (rp !== rq) parent[Math.max(rp, rq)] = Math.min(rp, rq);
154
+ }
155
+ }
156
+ const byRoot = new Map<number, { triangles: number[]; vertices: number[] }>();
157
+ for (let t = 0; 3 * t + 2 < triangles.length; t++) {
158
+ const root = find(triangles[3 * t]);
159
+ let piece = byRoot.get(root);
160
+ if (piece === undefined) {
161
+ piece = { triangles: [], vertices: [] };
162
+ byRoot.set(root, piece);
163
+ }
164
+ piece.triangles.push(t);
165
+ }
166
+ for (let v = 0; v < vertexCount; v++) if (used[v]) byRoot.get(find(v))?.vertices.push(v);
167
+ // Map insertion order is first-triangle order, because triangles were visited in list order.
168
+ return { pieces: [...byRoot.values()], orphans: [...Array(vertexCount).keys()].filter((v) => !used[v]) };
169
+ }
170
+
171
+ /** What the caller asks to split. */
172
+ export interface SplitRequest {
173
+ rig: RigSpec;
174
+ motion: MotionSpec;
175
+ slot: string;
176
+ /** The placeholder; absent, the slot's one authored mesh in the skin. */
177
+ attachment?: string;
178
+ /** The skin the attachment is filed under. Absent: `default`. */
179
+ skin?: string;
180
+ /** The rig spec's `images` as the proposal must state it (it is written somewhere else); absent, the field is left as it is. */
181
+ images?: string;
182
+ }
183
+
184
+ /**
185
+ * Two plausible wrong renumberings, planted by the selftest's controls and by
186
+ * nothing else: the vertex data placed one position along from where the record
187
+ * says it is, and every deform key copied to every piece as it stood — its start
188
+ * and its numbers unmoved, cut where the piece's shorter array ends, which is
189
+ * what a hand split that copied the keys and trimmed them until the build
190
+ * passed would hold (untrimmed, the compiler refuses the overrun first, and the
191
+ * pose reading this plant exists to test never runs).
192
+ */
193
+ export interface SplitPlant {
194
+ offByOne?: boolean;
195
+ deformUnrenumbered?: boolean;
196
+ }
197
+
198
+ /** One proposed piece, as the record states it. */
199
+ export interface SplitPieceRecord {
200
+ slot: string;
201
+ attachment: string;
202
+ /** The original vertex index of each of this piece's vertices, in the piece's order: piece vertex `j` is original vertex `vertices[j]`. */
203
+ vertices: number[];
204
+ /** The original triangle index of each of this piece's triangles, in the piece's order. */
205
+ triangles: number[];
206
+ /** The piece's outline vertex count — the `hull` its build derives. */
207
+ hull: number;
208
+ /** Whether the piece's vertices had to be renumbered outline first, rather than kept in original order. */
209
+ renumbered: boolean;
210
+ }
211
+
212
+ /** `split.json`: what was split and where each original vertex and triangle went. */
213
+ export interface SplitRecord {
214
+ spec: typeof SPLIT_SPEC;
215
+ skin: string;
216
+ slot: string;
217
+ attachment: string;
218
+ vertices: number;
219
+ triangles: number;
220
+ pieces: SplitPieceRecord[];
221
+ /** Original vertices no triangle names: in no piece, and drawn by neither rig. */
222
+ dropped: number[];
223
+ }
224
+
225
+ /** The answer: a proposal, or the statement that the mesh is one piece. */
226
+ export type SplitResult =
227
+ | { kind: 'split'; rig: RigSpec; motion: MotionSpec; record: SplitRecord; lines: string[] }
228
+ | { kind: 'whole'; line: string };
229
+
230
+ function isRecord(value: unknown): value is Record<string, unknown> {
231
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
232
+ }
233
+
234
+ function quoteList(names: readonly string[]): string {
235
+ return names.length === 0 ? 'none' : names.map((n) => `"${n}"`).join(', ');
236
+ }
237
+
238
+ /** `[0, 1, 2, …, 9]` with the tail elided past `max` entries — a vertex list as a line prints it. */
239
+ function shortList(values: readonly number[], max = 12): string {
240
+ return values.length <= max ? `[${values.join(', ')}]` : `[${values.slice(0, max).join(', ')}, … ${values.length - max} more]`;
241
+ }
242
+
243
+ /** Rebuild `record` with `entries` inserted directly after `after`'s key, preserving every other key's order. */
244
+ function insertAfter<T>(record: Record<string, T>, after: string, entries: ReadonlyArray<[string, T]>): Record<string, T> {
245
+ const out: Record<string, T> = {};
246
+ for (const [key, value] of Object.entries(record)) {
247
+ out[key] = value;
248
+ if (key === after) for (const [k, v] of entries) out[k] = v;
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * Propose the split of one authored mesh into its pieces.
255
+ *
256
+ * The specs are not edited: the proposal is built on copies. A mesh of one piece
257
+ * answers `whole` with the line that says so; anything the split cannot carry
258
+ * throws `SplitError` naming it.
259
+ */
260
+ export function splitMesh(request: SplitRequest, plant: SplitPlant = {}): SplitResult {
261
+ const rig = structuredClone(request.rig);
262
+ const motion = structuredClone(request.motion);
263
+ const skinName = request.skin ?? 'default';
264
+ const slotName = request.slot;
265
+ const where = (placeholder: string): string => `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`;
266
+
267
+ // --- what the invocation names ----------------------------------------------------------------------------
268
+ const slotNames = rig.slots.map((s) => s.name);
269
+ const slotIndex = slotNames.indexOf(slotName);
270
+ if (slotIndex < 0) throw new SplitError(`the rig spec declares no slot "${slotName}"; it declares ${quoteList(slotNames)}`, 2);
271
+ const skins = rig.skins ?? {};
272
+ const skin = skins[skinName];
273
+ if (skin === undefined) throw new SplitError(`the rig spec declares no skin "${skinName}"; it declares ${quoteList(Object.keys(skins))}`, 2);
274
+ const attachments = splitRigSkin(skin, `skin "${skinName}"`).attachments;
275
+ const filled = attachments[slotName];
276
+ if (filled === undefined) {
277
+ const fillers = Object.keys(skins).filter((name) => splitRigSkin(skins[name], `skin "${name}"`).attachments[slotName] !== undefined);
278
+ throw new SplitError(`skin "${skinName}" fills nothing in slot "${slotName}"; the skins that fill it: ${quoteList(fillers)}`, 2);
279
+ }
280
+ // Any mesh, generated ones included, so that the one mesh a slot holds is named — and refused for its generator by
281
+ // name below — rather than reported missing.
282
+ const isMesh = (att: unknown): boolean => isRecord(att) && att.type === 'mesh';
283
+ let placeholder = request.attachment;
284
+ if (placeholder === undefined) {
285
+ const meshes = Object.keys(filled).filter((p) => isMesh(filled[p]));
286
+ if (meshes.length !== 1) {
287
+ throw new SplitError(
288
+ `slot "${slotName}" in skin "${skinName}" holds ${meshes.length} mesh(es) (${quoteList(meshes)}) among ${quoteList(Object.keys(filled))}; name the one to split with --attachment`,
289
+ 2,
290
+ );
291
+ }
292
+ placeholder = meshes[0];
293
+ }
294
+ const original = filled[placeholder];
295
+ if (original === undefined) {
296
+ throw new SplitError(`slot "${slotName}" in skin "${skinName}" has no attachment "${placeholder}"; it has ${quoteList(Object.keys(filled))}`, 2);
297
+ }
298
+
299
+ // --- what the attachment is --------------------------------------------------------------------------------
300
+ const at = where(placeholder);
301
+ const fields: unknown = original;
302
+ const type = isRecord(fields) && typeof fields.type === 'string' ? fields.type : 'region';
303
+ if (type !== 'mesh') throw new SplitError(`${at} is a "${type}" attachment; only a mesh has triangles to split`, 1);
304
+ const mesh = original as RigMeshAttachment;
305
+ if (mesh.generator !== undefined) {
306
+ throw new SplitError(`${at} is built by a "${mesh.generator.kind}" generator, which builds one outline by construction; there are no authored triangles to split`, 1);
307
+ }
308
+ if (mesh.sequence !== undefined) {
309
+ throw new SplitError(`${at} carries a "sequence"; a numbered series over one triangulation would have to become one per piece, and which frames each piece shows is not something the spec states`, 1);
310
+ }
311
+ if (mesh.boneIndexing === 'raw') {
312
+ throw new SplitError(`${at} states its weights as a raw index run ("boneIndexing": "raw"); write them as "weights", by name, and split that`, 1);
313
+ }
314
+ if (mesh.uvs === undefined || mesh.triangles === undefined || (mesh.vertices === undefined) === (mesh.weights === undefined)) {
315
+ throw new SplitError(`${at} needs uvs, triangles and exactly one of vertices or weights to be split`, 1);
316
+ }
317
+ const uvs = mesh.uvs;
318
+ const triangles = mesh.triangles;
319
+ if (uvs.length % 2 !== 0) throw new SplitError(`${at} has ${uvs.length} uv numbers, which is not a whole number of pairs`, 1);
320
+ const vertexCount = uvs.length / 2;
321
+ if (triangles.length % 3 !== 0) throw new SplitError(`${at} has ${triangles.length} triangle indices, which is not a whole number of triangles`, 1);
322
+ for (let i = 0; i < triangles.length; i++) {
323
+ const v = triangles[i];
324
+ if (!Number.isInteger(v) || v < 0 || v >= vertexCount) {
325
+ throw new SplitError(`${at}: triangle ${Math.floor(i / 3)} names vertex ${v}, and the mesh has vertices 0..${vertexCount - 1}`, 1);
326
+ }
327
+ }
328
+ const weights = mesh.weights;
329
+ const unweighted = mesh.vertices;
330
+ if (weights !== undefined && weights.length !== vertexCount) {
331
+ throw new SplitError(`${at}: weights cover ${weights.length} vertices but there are ${vertexCount} uv pairs`, 1);
332
+ }
333
+ if (unweighted !== undefined && unweighted.length !== uvs.length) {
334
+ throw new SplitError(`${at}: "vertices" is ${unweighted.length} numbers and the ${vertexCount} uv pairs need ${uvs.length}; a weighted run is written as "weights"`, 1);
335
+ }
336
+
337
+ // --- the pieces ------------------------------------------------------------------------------------------
338
+ const { pieces, orphans } = meshPieces(vertexCount, triangles);
339
+ if (pieces.length === 0) throw new SplitError(`${at} has no triangles`, 1);
340
+ if (pieces.length === 1) {
341
+ return {
342
+ kind: 'whole',
343
+ line:
344
+ `${at} is one piece — its ${triangles.length / 3} triangle(s) over ${vertexCount - orphans.length} vertices each reach every other through shared vertices` +
345
+ `${orphans.length === 0 ? '' : ` (and ${orphans.length} vertex(es) no triangle names: ${shortList(orphans)})`}; nothing to split, and nothing written`,
346
+ };
347
+ }
348
+ const names = pieces.map((_p, i) => pieceSlotName(slotName, i + 1));
349
+ for (const name of names.slice(1)) {
350
+ if (slotNames.includes(name)) {
351
+ throw new SplitError(`${at} is ${pieces.length} pieces, and piece ${names.indexOf(name) + 1}'s slot would be "${name}", which the rig spec already declares; rename that slot and split again`, 1);
352
+ }
353
+ }
354
+
355
+ // --- what the rest of the two specs says about the slot ---------------------------------------------------
356
+ const refusals: string[] = [];
357
+ for (const [name, other] of Object.entries(skins)) {
358
+ if (name === skinName) continue;
359
+ if (splitRigSkin(other, `skin "${name}"`).attachments[slotName] !== undefined) {
360
+ refusals.push(`skin "${name}" also fills slot "${slotName}", so the new slots would show this skin's pieces under that skin too`);
361
+ }
362
+ }
363
+ for (const [name, other] of Object.entries(skins)) {
364
+ for (const [ownSlot, map] of Object.entries(splitRigSkin(other, `skin "${name}"`).attachments)) {
365
+ for (const [ph, entry] of Object.entries(map)) {
366
+ const att: unknown = entry;
367
+ if (!isRecord(att)) continue;
368
+ if (att.type === 'linkedmesh' && att.source === placeholder && (att.slot ?? ownSlot) === slotName && (att.skin ?? 'default') === skinName) {
369
+ refusals.push(`skin "${name}" slot "${ownSlot}" attachment "${ph}" is a linked mesh borrowing this mesh's geometry`);
370
+ }
371
+ if (att.type === 'clipping' && att.end === slotName) {
372
+ refusals.push(`skin "${name}" slot "${ownSlot}" attachment "${ph}" is a clip whose range ends at slot "${slotName}", and the new slots would fall outside it`);
373
+ }
374
+ }
375
+ }
376
+ }
377
+ const groups = motion.groups ?? {};
378
+ for (const [animation, anim] of Object.entries(motion.animations)) {
379
+ for (const track of anim.tracks) {
380
+ if (track.group !== undefined && SLOT_PROPERTIES.has(track.property) && (groups[track.group] ?? []).includes(slotName)) {
381
+ refusals.push(`animation "${animation}" keys "${track.property}" on group "${track.group}", which holds slot "${slotName}"; the new slots are in no group`);
382
+ }
383
+ }
384
+ for (const key of anim.drawOrder ?? []) {
385
+ if ((key.offsets ?? []).some((o) => o.slot === slotName)) {
386
+ refusals.push(`animation "${animation}" moves slot "${slotName}" in its draw order at t=${key.t}; where the new slots go then is not stated`);
387
+ break;
388
+ }
389
+ }
390
+ for (const track of anim.sequence ?? []) {
391
+ if ((track.skin ?? 'default') === skinName && track.slot === slotName && track.attachment === placeholder) {
392
+ refusals.push(`animation "${animation}" keys a sequence on ${at}`);
393
+ }
394
+ }
395
+ for (const track of anim.deform ?? []) {
396
+ if ((track.skin ?? 'default') !== skinName || track.slot !== slotName || track.attachment !== placeholder) continue;
397
+ track.keys.forEach((key, i) => {
398
+ if (key.transform !== undefined) {
399
+ refusals.push(`animation "${animation}" deform key ${i} (t=${key.t}) states a "${key.transform.kind}" transform, which is evaluated over the whole attachment's geometry; state it as a "vertices" run to split it`);
400
+ }
401
+ });
402
+ }
403
+ }
404
+ if (refusals.length > 0) throw new SplitError(`${at} is ${pieces.length} pieces, and the split cannot carry what else names it: ${refusals.join('; ')}`, 1);
405
+
406
+ // --- each piece's vertex order ------------------------------------------------------------------------------
407
+ const orders: number[][] = [];
408
+ const hulls: number[] = [];
409
+ const renumbered: boolean[] = [];
410
+ pieces.forEach((piece, p) => {
411
+ const ascending = piece.vertices;
412
+ const localOf = new Map(ascending.map((v, j) => [v, j]));
413
+ const local = piece.triangles.flatMap((t) => [triangles[3 * t], triangles[3 * t + 1], triangles[3 * t + 2]].map((v) => localOf.get(v) as number));
414
+ let outline;
415
+ try {
416
+ outline = traceOutline(ascending.length, local);
417
+ } catch (err) {
418
+ if (err instanceof MeshError) {
419
+ throw new SplitError(`${at}: piece ${p + 1} (vertices ${shortList(ascending)}) is not one mesh either: ${err.message}`, 1);
420
+ }
421
+ throw err;
422
+ }
423
+ let order = ascending;
424
+ let moved = false;
425
+ try {
426
+ checkHullOrder(outline, ascending.length);
427
+ } catch (err) {
428
+ if (!(err instanceof MeshError)) throw err;
429
+ const onOutline = new Set(outline.walk);
430
+ order = [...outline.walk.map((j) => ascending[j]), ...ascending.filter((_v, j) => !onOutline.has(j))];
431
+ moved = true;
432
+ }
433
+ orders.push(order);
434
+ hulls.push(outline.hull);
435
+ renumbered.push(moved);
436
+ });
437
+
438
+ // --- the deform array's layout: influences per vertex, and where each vertex starts --------------------------
439
+ const influences = (v: number): number => (weights === undefined ? 1 : weights[v].length);
440
+ const base: number[] = [];
441
+ let length = 0;
442
+ for (let v = 0; v < vertexCount; v++) {
443
+ base.push(length);
444
+ length += 2 * influences(v);
445
+ }
446
+ const pieceOf = new Array<number>(vertexCount).fill(-1);
447
+ const positionOf = new Array<number>(vertexCount).fill(-1);
448
+ const pieceBase: number[][] = [];
449
+ const pieceLength: number[] = [];
450
+ orders.forEach((order, p) => {
451
+ let at2 = 0;
452
+ const bases: number[] = [];
453
+ order.forEach((v, j) => {
454
+ pieceOf[v] = p;
455
+ positionOf[v] = j;
456
+ bases.push(at2);
457
+ at2 += 2 * influences(v);
458
+ });
459
+ pieceBase.push(bases);
460
+ pieceLength.push(at2);
461
+ });
462
+ /** The vertex that owns original deform index `x`. */
463
+ const ownerOf: number[] = [];
464
+ for (let v = 0; v < vertexCount; v++) for (let k = 0; k < 2 * influences(v); k++) ownerOf.push(v);
465
+
466
+ // --- stated edges between two dropped vertices: no piece holds them, so they are dropped too, by name -----------
467
+ const droppedEdges: string[] = [];
468
+ if (Array.isArray(mesh.edges)) {
469
+ for (let i = 0; i + 1 < mesh.edges.length; i += 2) {
470
+ const [a, b] = [mesh.edges[i] / 2, mesh.edges[i + 1] / 2];
471
+ if (pieceOf[a] < 0 && pieceOf[b] < 0) droppedEdges.push(`edge ${i / 2} (vertex ${a} to vertex ${b})`);
472
+ }
473
+ }
474
+ /** Where a stated edge's vertex went, as a refusal names it. */
475
+ const pieceLabel = (v: number): string => (pieceOf[v] < 0 ? 'in no piece: no triangle names it' : `piece ${pieceOf[v] + 1}`);
476
+
477
+ // --- the pieces' attachments ---------------------------------------------------------------------------------
478
+ const pieceAttachments: RigMeshAttachment[] = orders.map((order, p) => {
479
+ // The plant: the data one position along from where the record says it is.
480
+ const source = plant.offByOne === true ? order.map((_v, j) => order[(j + 1) % order.length]) : order;
481
+ const local = new Map(order.map((v, j) => [v, j]));
482
+ const out: Record<string, unknown> = {};
483
+ for (const [key, value] of Object.entries(mesh)) {
484
+ if (key === 'hull') continue;
485
+ if (key === 'uvs') out.uvs = source.flatMap((v) => [uvs[2 * v], uvs[2 * v + 1]]);
486
+ else if (key === 'triangles') out.triangles = pieces[p].triangles.flatMap((t) => [0, 1, 2].map((c) => local.get(triangles[3 * t + c]) as number));
487
+ else if (key === 'weights' && weights !== undefined) out.weights = source.map((v) => weights[v].map((b: RigMeshBinding) => ({ ...b })));
488
+ else if (key === 'vertices' && unweighted !== undefined) out.vertices = source.flatMap((v) => [unweighted[2 * v], unweighted[2 * v + 1]]);
489
+ else if (key === 'edges' && Array.isArray(value)) {
490
+ const edges: number[] = [];
491
+ for (let i = 0; i + 1 < value.length; i += 2) {
492
+ const [a, b] = [value[i] / 2, value[i + 1] / 2];
493
+ if (pieceOf[a] !== pieceOf[b]) throw new SplitError(`${at}: stated edge ${i / 2} joins vertex ${a} (${pieceLabel(a)}) to vertex ${b} (${pieceLabel(b)}); delete it, or delete "edges" and rigc derives them`, 1);
494
+ if (pieceOf[a] === p) edges.push(2 * (local.get(a) as number), 2 * (local.get(b) as number));
495
+ }
496
+ out.edges = edges;
497
+ } else out[key] = structuredClone(value);
498
+ }
499
+ return out as unknown as RigMeshAttachment;
500
+ });
501
+
502
+ // --- the deform keys -----------------------------------------------------------------------------------------
503
+ const splitKey = (key: MotionDeformKey, animation: string, i: number): MotionDeformKey[] => {
504
+ const timing = (k: MotionDeformKey): MotionDeformKey => ({ t: k.t, ...(k.ease === undefined ? {} : { ease: k.ease }), ...(k.curve === undefined ? {} : { curve: k.curve }) });
505
+ if (plant.deformUnrenumbered === true) {
506
+ return pieces.map((_piece, p) => {
507
+ const copy = structuredClone(key);
508
+ if (copy.vertices === undefined || copy.vertices === null || copy.fromVertex !== undefined) return copy;
509
+ const from = copy.offset ?? 0;
510
+ if (from >= pieceLength[p]) return timing(key);
511
+ copy.vertices = copy.vertices.slice(0, pieceLength[p] - from);
512
+ return copy;
513
+ });
514
+ }
515
+ const run = key.vertices;
516
+ if (run === undefined || run === null) return pieces.map(() => timing(key));
517
+ let start = key.offset ?? 0;
518
+ if (key.fromVertex !== undefined) {
519
+ const fv = key.fromVertex;
520
+ if (!Number.isInteger(fv) || fv < 0 || fv >= vertexCount) throw new SplitError(`animation "${animation}" deform key ${i} (t=${key.t}): "fromVertex" ${fv} is not a vertex of ${at} (0..${vertexCount - 1})`, 1);
521
+ start = base[fv];
522
+ if (weights !== undefined) {
523
+ for (let x = start; x < start + run.length && x < length; x++) {
524
+ if (influences(ownerOf[x]) > 1) {
525
+ throw new SplitError(
526
+ `animation "${animation}" deform key ${i} (t=${key.t}): "fromVertex" counts VERTICES, and this attachment is weighted — vertex ${ownerOf[x]} has ${influences(ownerOf[x])} of them; the compiler refuses this key, so the split does too`,
527
+ 1,
528
+ );
529
+ }
530
+ }
531
+ }
532
+ }
533
+ if (start < 0 || start + run.length > length) {
534
+ throw new SplitError(`animation "${animation}" deform key ${i} (t=${key.t}): the run starts at deform index ${start} and is ${run.length} long, which ends at ${start + run.length}; ${at}'s deform array is ${length} long`, 1);
535
+ }
536
+ const runs = pieces.map(() => new Map<number, number>());
537
+ run.forEach((value, k) => {
538
+ const x = start + k;
539
+ const v = ownerOf[x];
540
+ const p = pieceOf[v];
541
+ if (p < 0) return; // a dropped vertex: no triangle draws it in either rig
542
+ runs[p].set(pieceBase[p][positionOf[v]] + (x - base[v]), value);
543
+ });
544
+ return runs.map((entries) => {
545
+ if (entries.size === 0) return timing(key);
546
+ const indices = [...entries.keys()];
547
+ const lo = Math.min(...indices);
548
+ const hi = Math.max(...indices);
549
+ // A gap is an index the original run did not cover, and the parser left it at zero there too.
550
+ const vertices = Array.from({ length: hi - lo + 1 }, (_v, k) => entries.get(lo + k) ?? 0);
551
+ return { t: key.t, offset: lo, vertices, ...(key.ease === undefined ? {} : { ease: key.ease }), ...(key.curve === undefined ? {} : { curve: key.curve }) };
552
+ });
553
+ };
554
+ const deformLines: string[] = [];
555
+ for (const [animation, anim] of Object.entries(motion.animations)) {
556
+ if (anim.deform === undefined) continue;
557
+ const next: MotionDeformTrack[] = [];
558
+ for (const track of anim.deform) {
559
+ if ((track.skin ?? 'default') !== skinName || track.slot !== slotName || track.attachment !== placeholder) {
560
+ next.push(track);
561
+ continue;
562
+ }
563
+ const perKey = track.keys.map((key, i) => splitKey(key, animation, i));
564
+ names.forEach((name, p) => next.push({ ...track, slot: name, keys: perKey.map((keys) => keys[p]) }));
565
+ const moving = names.map((_n, p) => perKey.filter((keys) => keys[p].vertices !== undefined).length);
566
+ deformLines.push(`deform animation "${animation}": ${track.keys.length} key(s) re-sliced into ${names.length} timelines — ${names.map((n, p) => `"${n}" ${moving[p]} with a run`).join(', ')}`);
567
+ }
568
+ anim.deform = next;
569
+ }
570
+
571
+ // --- the slot tracks the new slots copy --------------------------------------------------------------------
572
+ const trackLines: string[] = [];
573
+ for (const [animation, anim] of Object.entries(motion.animations)) {
574
+ const next: MotionTrack[] = [];
575
+ for (const track of anim.tracks) {
576
+ next.push(track);
577
+ if (track.slot !== slotName || !SLOT_PROPERTIES.has(track.property)) continue;
578
+ for (const name of names.slice(1)) {
579
+ const copy = structuredClone(track);
580
+ copy.slot = name;
581
+ if (track.property === 'attachment') copy.keys = copy.keys.map((key) => ({ ...key, v: key.v === placeholder ? placeholder : null }));
582
+ next.push(copy);
583
+ }
584
+ trackLines.push(
585
+ `track animation "${animation}": slot "${slotName}" "${track.property}" copied to ${quoteList(names.slice(1))}` +
586
+ (track.property === 'attachment' ? ` — a key showing "${placeholder}" shows its piece there, any other key shows nothing there` : ''),
587
+ );
588
+ }
589
+ anim.tracks = next;
590
+ }
591
+
592
+ // --- the rig spec ------------------------------------------------------------------------------------------
593
+ const slotS = rig.slots[slotIndex];
594
+ const newSlots: RigSlot[] = names.slice(1).map((name) => {
595
+ const copy: RigSlot = { ...structuredClone(slotS), name };
596
+ if (slotS.attachment !== undefined) copy.attachment = slotS.attachment === placeholder ? placeholder : null;
597
+ return copy;
598
+ });
599
+ rig.slots.splice(slotIndex + 1, 0, ...newSlots);
600
+ // The skin, short or long form: `splitRigSkin` hands back the live table inside this copy.
601
+ const liveSkin = rig.skins?.[skinName];
602
+ if (liveSkin === undefined) throw new Error('internal: the skin vanished from the copy');
603
+ const table = splitRigSkin(liveSkin, `skin "${skinName}"`).attachments;
604
+ table[slotName] = { ...table[slotName], [placeholder]: pieceAttachments[0] };
605
+ const rebuilt = insertAfter<Record<string, RigAttachment>>(table, slotName, names.slice(1).map((name, i): [string, Record<string, RigAttachment>] => [name, { [placeholder]: pieceAttachments[i + 1] }]));
606
+ for (const key of Object.keys(table)) delete table[key];
607
+ Object.assign(table, rebuilt);
608
+ const folds = rig.invariants?.deformMayFold;
609
+ if (folds !== undefined && rig.invariants !== undefined) {
610
+ rig.invariants.deformMayFold = folds.flatMap((entry) => (entry.slot === slotName ? [entry, ...names.slice(1).map((name) => ({ ...structuredClone(entry), slot: name }))] : [entry]));
611
+ }
612
+ if (request.images !== undefined) rig.images = request.images;
613
+
614
+ // --- the motion spec's setup ------------------------------------------------------------------------------
615
+ const setup = motion.setup;
616
+ const setupS = setup?.[slotName];
617
+ if (setup !== undefined && setupS !== undefined) {
618
+ const entries = names.slice(1).map((name): [string, MotionSetupSlot] => {
619
+ const copy: MotionSetupSlot = structuredClone(setupS);
620
+ if (setupS.attachment !== undefined) copy.attachment = setupS.attachment === placeholder ? placeholder : null;
621
+ return [name, copy];
622
+ });
623
+ motion.setup = insertAfter(setup, slotName, entries);
624
+ }
625
+
626
+ // --- the record and the lines --------------------------------------------------------------------------------
627
+ const record: SplitRecord = {
628
+ spec: SPLIT_SPEC,
629
+ skin: skinName,
630
+ slot: slotName,
631
+ attachment: placeholder,
632
+ vertices: vertexCount,
633
+ triangles: triangles.length / 3,
634
+ pieces: pieces.map((piece, p) => ({
635
+ slot: names[p],
636
+ attachment: placeholder,
637
+ vertices: orders[p],
638
+ triangles: piece.triangles,
639
+ hull: hulls[p],
640
+ renumbered: renumbered[p],
641
+ })),
642
+ dropped: orphans,
643
+ };
644
+ const lines: string[] = [
645
+ `mesh ${at}: ${vertexCount} vertices, ${triangles.length / 3} triangles, ${pieces.length} pieces (triangles that share a vertex are one piece)`,
646
+ ...pieces.map((piece, p) =>
647
+ `piece ${p + 1} slot "${names[p]}"${p === 0 ? ' (the original slot)' : ` (new, drawn after "${names[p - 1]}")`}: ${orders[p].length} vertices, ${piece.triangles.length} triangles, hull ${hulls[p]} — ` +
648
+ (renumbered[p]
649
+ ? `renumbered outline first, as the hull rule needs: piece vertex 0.. is original ${formatWalk(orders[p].slice(0, hulls[p]))}, then the interior ${shortList(orders[p].slice(hulls[p]))}`
650
+ : `original order kept: ${shortList(orders[p])}`),
651
+ ),
652
+ ];
653
+ // Interleaving: a later piece's triangle listed before an earlier piece's last one.
654
+ for (let p = 1; p < pieces.length; p++) {
655
+ const earlierLast = Math.max(...pieces.slice(0, p).map((q) => q.triangles[q.triangles.length - 1]));
656
+ const first = pieces[p].triangles[0];
657
+ if (first < earlierLast) {
658
+ lines.push(
659
+ `order piece ${p + 1}'s triangle ${first} is listed before triangle ${earlierLast} of an earlier piece: the original drew their triangles interleaved, and the split draws each piece whole, after the one before it — where they overlap on screen, the stacking can differ`,
660
+ );
661
+ break;
662
+ }
663
+ }
664
+ if (orphans.length > 0) lines.push(`dropped ${orphans.length} vertex(es) no triangle names, ${shortList(orphans)}: no piece holds them and neither rig draws them`);
665
+ if (droppedEdges.length > 0) {
666
+ const shown = droppedEdges.length <= 12 ? droppedEdges.join(', ') : `${droppedEdges.slice(0, 12).join(', ')}, … ${droppedEdges.length - 12} more`;
667
+ lines.push(`dropped ${droppedEdges.length} stated edge(s) between vertices no triangle names, ${shown}: no piece holds them, so no piece's "edges" states them`);
668
+ }
669
+ lines.push(...deformLines, ...trackLines);
670
+ if (folds?.some((entry) => entry.slot === slotName)) lines.push(`invariant invariants.deformMayFold names slot "${slotName}"; its entry is copied to ${quoteList(names.slice(1))}`);
671
+ const budget = rig.invariants?.meshSlots;
672
+ if (budget !== undefined) {
673
+ // Counted over the proposal itself, the new slots' meshes included: the arithmetic the build's own refusal does.
674
+ const total = budgetedMeshCount(rig);
675
+ const head = `invariant invariants.meshSlots states ${budget}, and the split adds ${names.length - 1} mesh slot(s): the proposal's skins hold ${total} mesh attachment(s)`;
676
+ lines.push(
677
+ total > budget
678
+ ? `${head}, ${total - budget} over the budget, so its build is refused at compile ("${total} mesh slot(s) emitted") unless the rig spec raises invariants.meshSlots by ${total - budget}, to ${total}; the split leaves the budget as the spec states it`
679
+ : `${head}, within the budget; the split leaves it as the spec states it`,
680
+ );
681
+ }
682
+ return { kind: 'split', rig, motion, record, lines };
683
+ }