spine-rigc 0.2.1 → 0.3.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.
package/src/compile.ts CHANGED
@@ -25,10 +25,14 @@ import {
25
25
  parseRigSpec,
26
26
  type RigAttachment,
27
27
  type RigBone,
28
+ type RigBoundingBoxAttachment,
29
+ type RigClippingAttachment,
30
+ type RigEvent,
28
31
  type RigMeshAttachment,
29
32
  type RigMeshBinding,
30
33
  type RigRegionAttachment,
31
34
  type RigSpec,
35
+ type RigVertexGeometry,
32
36
  } from './rig.ts';
33
37
  import { buildRibbonMesh, buildRingMesh, encodeWeightedVertices, MeshError, type MeshBoneRef } from './mesh.ts';
34
38
  import { KEY_TIME_EPSILON } from './timelines.ts';
@@ -48,12 +52,16 @@ import type {
48
52
  FaceManifest,
49
53
  FaceManifestPart,
50
54
  MotionDrawOrderKey,
55
+ MotionEventKey,
51
56
  MotionSpec,
52
57
  MotionTrack,
53
58
  RigInfo,
54
59
  SpineAttachment,
55
60
  SpineBone,
61
+ SpineBoundingBoxAttachment,
62
+ SpineClippingAttachment,
56
63
  SpineConstraint,
64
+ SpineEvent,
57
65
  SpineMeshAttachment,
58
66
  SpineRegionAttachment,
59
67
  SpineSkeletonJson,
@@ -77,6 +85,52 @@ function r6(n: number): number {
77
85
  return v === 0 ? 0 : v;
78
86
  }
79
87
 
88
+ /**
89
+ * A **key time** on that same 1e-6 s grid — rounded DOWN rather than to nearest.
90
+ *
91
+ * ## Why key times get their own quantiser
92
+ *
93
+ * Every other emitted number is a quantity, and for a quantity nearest is the
94
+ * least wrong answer. A key time is not a quantity: it is a **position against a
95
+ * sample grid a player will step**, and the two directions of a half-step error
96
+ * are not equally wrong. Rounded down, a key fires on the sample it was written
97
+ * for, half a millionth of a second early, and nothing can see it. Rounded up, it
98
+ * fires on the NEXT sample — a whole frame late — and on a stepped timeline that
99
+ * is the wrong picture rather than a slightly wrong value.
100
+ *
101
+ * The arithmetic is not exotic, it is the common case: `2/12 s` and `5/30 s` are
102
+ * both 0.16666666…, `r6` emits 0.166667, and 0.166667 is larger than either. The
103
+ * spineboy run's muzzle flare fired one 12 fps frame late for exactly that, with
104
+ * no error and no warning, until the run's own frame self-check caught it (issue
105
+ * #99). ⚠️ An attachment timeline is inherently stepped, so it is where this
106
+ * surfaces first — but a rotate key rounded up is a frame late too; it just hides
107
+ * inside the interpolation.
108
+ *
109
+ * ## Why this is not `Math.floor(n * 1e6)`
110
+ *
111
+ * `n * 1e6` is itself a rounded double: `0.7 * 1e6` is 699999.9999999999, and
112
+ * flooring it would move a time the grid represents **exactly** a whole step down.
113
+ * So the value is rounded to nearest first and stepped back only when the result
114
+ * genuinely overshoots the time it came from. A time already on the grid is
115
+ * therefore emitted unchanged, which is what keeps `A18_DETERMINISTIC_EMIT` and
116
+ * every committed artifact where they were.
117
+ *
118
+ * ## What it means for `KEY_TIME_EPSILON`
119
+ *
120
+ * The tolerance's job narrows rather than moves: an authored key can no longer
121
+ * land past its own `duration` by the compiler's own rounding, so `checkKeyTime`
122
+ * only refuses a key the author really did put past the end. The epsilon stays,
123
+ * because A09 re-checks the same rule on an emitted file read back through a
124
+ * Float32Array — where the grid is coarser and rounds both ways — and because a
125
+ * `duration` is not required to be on the grid either.
126
+ */
127
+ function keyTime(n: number): number {
128
+ let units = Math.round(n * 1e6);
129
+ if (units / 1e6 > n) units -= 1;
130
+ const v = units / 1e6;
131
+ return v === 0 ? 0 : v;
132
+ }
133
+
80
134
  /**
81
135
  * Rule 4, per timeline: no key may land past the animation's declared duration.
82
136
  *
@@ -86,6 +140,12 @@ function r6(n: number): number {
86
140
  * That is exactly how rung 6 lost a one-frame attachment reveal; the tolerance
87
141
  * story is in `KEY_TIME_EPSILON`.
88
142
  *
143
+ * ⚠️ It compares the **emitted** time, not the authored one, and since `keyTime`
144
+ * rounds down that can only be more forgiving than comparing the author's number
145
+ * — by less than one step of the grid. That is the honest side to err on: the
146
+ * emitted time is the one a player samples, and refusing a key that will in fact
147
+ * be reached would be refusing a correct animation.
148
+ *
89
149
  * This is a refusal rather than an assertion because the key is the thing to
90
150
  * change and the motion spec is the file it lives in: the message has to name
91
151
  * the track and the key, and by gate time both are gone — the emitted skeleton
@@ -638,6 +698,7 @@ export function compile(opts: CompileOptions): CompileResult {
638
698
  meshes,
639
699
  slotName: rigSlot.name,
640
700
  anchorBone: rigSlot.bone,
701
+ slotNames: new Set(rig.slots.map((s) => s.name)),
641
702
  });
642
703
  }
643
704
  tableFor(skinName)[rigSlot.name] = perSlot;
@@ -762,6 +823,12 @@ export function compile(opts: CompileOptions): CompileResult {
762
823
  const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
763
824
  if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
764
825
 
826
+ const eventKeys = anim.events ? compileEvents(anim.events, animName, anim.duration, rig.events ?? {}) : null;
827
+ // An event timeline counts towards the animation's length the same as any
828
+ // other: `readAnimation` takes the duration from the longest timeline it
829
+ // built, and `EventTimeline.getDuration()` is its last frame like the rest.
830
+ if (eventKeys) for (const key of eventKeys) compiledDuration = Math.max(compiledDuration, key.time as number);
831
+
765
832
  // Rule 4: the declared duration is verified, because skeleton JSON does not
766
833
  // carry one — the loader takes the max key time.
767
834
  //
@@ -781,6 +848,7 @@ export function compile(opts: CompileOptions): CompileResult {
781
848
  if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
782
849
  if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
783
850
  if (drawOrder) animations[animName].drawOrder = drawOrder;
851
+ if (eventKeys) animations[animName].events = eventKeys;
784
852
  }
785
853
 
786
854
  // -- 6. assemble -----------------------------------------------------------
@@ -795,11 +863,29 @@ export function compile(opts: CompileOptions): CompileResult {
795
863
  if (rig.skeleton?.referenceScale !== undefined) header.referenceScale = rig.skeleton.referenceScale;
796
864
  if (rig.skeleton?.images !== undefined) header.images = rig.skeleton.images;
797
865
 
866
+ // Event definitions. Emitted in the order the rig spec declares them — object
867
+ // key order is the spec's, not a set's, so A18 stays a contract.
868
+ const events: Record<string, SpineEvent> = {};
869
+ for (const [name, def] of Object.entries(rig.events ?? {})) {
870
+ const entry: SpineEvent = {};
871
+ if (def.int !== undefined) entry.int = def.int;
872
+ if (def.float !== undefined) entry.float = r6(def.float);
873
+ if (def.string !== undefined) entry.string = def.string;
874
+ if (def.audio !== undefined) entry.audio = def.audio;
875
+ if (def.volume !== undefined) entry.volume = r6(def.volume);
876
+ if (def.balance !== undefined) entry.balance = r6(def.balance);
877
+ events[name] = entry;
878
+ }
879
+
798
880
  const skeleton: SpineSkeletonJson = {
799
881
  skeleton: header,
800
882
  bones,
801
883
  slots,
802
884
  skins: [...skinTables.entries()].map(([name, attachments]) => ({ name, attachments })),
885
+ // Between `skins` and `animations`, which is where the editor writes it. A
886
+ // conditional spread rather than an assignment after the literal, so the key
887
+ // lands in that position instead of at the end.
888
+ ...(Object.keys(events).length ? { events } : {}),
803
889
  animations,
804
890
  };
805
891
  if (constraints.length) skeleton.constraints = constraints;
@@ -961,6 +1047,8 @@ interface AttachmentContext {
961
1047
  meshes: CompileResult['meshes'];
962
1048
  slotName: string;
963
1049
  anchorBone: string;
1050
+ /** Every slot the rig declares — a clipping attachment's `end` resolves here. */
1051
+ slotNames: Set<string>;
964
1052
  }
965
1053
 
966
1054
  /**
@@ -981,12 +1069,149 @@ function buildRigAttachment(
981
1069
  const type = att.type ?? 'region';
982
1070
  if (type === 'region') return buildRigRegion(att as RigRegionAttachment, placeholder, where, ctx);
983
1071
  if (type === 'mesh') return buildRigMesh(att as RigMeshAttachment, where, ctx);
1072
+ if (type === 'boundingbox') return buildRigBoundingBox(att as RigBoundingBoxAttachment, where, ctx);
1073
+ if (type === 'clipping') return buildRigClipping(att as RigClippingAttachment, where, ctx);
984
1074
  throw new NotImplementedError(
985
1075
  `${where}: attachment type "${String(type)}" is in the Spine 4.3 format and rigc does not emit it yet. ` +
986
- 'Implemented: region, mesh. docs/SPEC_COVERAGE.md part 1-6 lists what the type would have to carry.',
1076
+ 'Implemented: region, mesh, boundingbox, clipping. ' +
1077
+ 'point, path and linkedmesh are deliberately deferred: not one of them appears anywhere in the benchmark ' +
1078
+ 'corpus (docs/SPEC_COVERAGE.md parts 3-1 and 4-2), so none is on the ladder\'s critical path. ' +
1079
+ 'docs/SPEC_COVERAGE.md part 1-6 lists what each type would have to carry.',
987
1080
  );
988
1081
  }
989
1082
 
1083
+ /**
1084
+ * Encode the polygon a bounding box or a clipping attachment carries.
1085
+ *
1086
+ * 🚨 `vertexCount` is required, and everything else here is a cross-check of it.
1087
+ * The parser reads `map.vertexCount << 1` and hands that to `readVertices` as the
1088
+ * length to expect (`:552`, `:632`) — so with the field absent it expects 0,
1089
+ * takes the weighted branch, decodes the coordinate list as
1090
+ * `boneCount, (index, x, y, weight) × n`, and returns an attachment holding
1091
+ * whatever that garbage produced. It loads. It draws nothing (a bounding box
1092
+ * never did) and clips nothing, or clips the wrong shape, in complete silence.
1093
+ *
1094
+ * The two encodings are the mesh's — `encodeNamedWeights` is the same function —
1095
+ * because they are the same field with the same trap: `readVertices` decides
1096
+ * weighted vs unweighted by a length comparison alone, and a coincidental match
1097
+ * reads weight data as coordinates.
1098
+ */
1099
+ function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: AttachmentContext): number[] {
1100
+ const count = att.vertexCount;
1101
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 3) {
1102
+ throw new CompileError(
1103
+ `${where}: vertexCount is ${JSON.stringify(count)}; a polygon needs at least 3 vertices, stated outright — ` +
1104
+ 'the field has no parser default, and an absent one reads as 0 and takes the polygon with it',
1105
+ );
1106
+ }
1107
+ if (att.vertices && att.weights) {
1108
+ throw new CompileError(
1109
+ `${where}: geometry comes as "vertices" or as "weights", never both — "weights" is the by-name form of the same data`,
1110
+ );
1111
+ }
1112
+ if (att.weights) {
1113
+ if (att.boneIndexing === 'raw') {
1114
+ throw new CompileError(`${where}: "boneIndexing": "raw" describes a "vertices" run; "weights" always binds by name`);
1115
+ }
1116
+ if (att.weights.length !== count) {
1117
+ throw new CompileError(`${where}: weights cover ${att.weights.length} vertices but vertexCount is ${count}`);
1118
+ }
1119
+ return encodeNamedWeights(att.weights, where, ctx);
1120
+ }
1121
+ const raw = att.vertices;
1122
+ if (!raw || raw.length === 0) {
1123
+ throw new CompileError(`${where}: needs geometry — "vertices" (x, y per vertex) or "weights" (bound by name)`);
1124
+ }
1125
+ const unweighted = raw.length === count * 2;
1126
+ if (!unweighted) {
1127
+ if (att.boneIndexing !== 'raw') {
1128
+ throw new CompileError(
1129
+ `${where}: vertexCount ${count} wants ${count * 2} unweighted numbers and "vertices" holds ${raw.length}. ` +
1130
+ 'The parser reads that as a WEIGHTED run, whose bone INDEXES point into the emitted bone array — a list ' +
1131
+ 'the spec never writes, so inserting a bone rebinds every vertex in silence. ' +
1132
+ 'Give the bindings by name as "weights": [[{ "bone": …, "x": …, "y": …, "weight": … }, …], …], ' +
1133
+ 'fix vertexCount, or say "boneIndexing": "raw" on this attachment to keep the index form deliberately.',
1134
+ );
1135
+ }
1136
+ // A raw run still has to decode to exactly `vertexCount` vertices, or the
1137
+ // count and the polygon disagree and the parser believes the count.
1138
+ let decoded = 0;
1139
+ for (let i = 0; i < raw.length; decoded++) {
1140
+ const bones = raw[i++];
1141
+ if (!Number.isInteger(bones) || bones < 1) {
1142
+ throw new CompileError(`${where}: the raw weighted run has a bone count of ${String(bones)} at index ${i - 1}`);
1143
+ }
1144
+ i += bones * 4;
1145
+ if (i > raw.length) {
1146
+ throw new CompileError(
1147
+ `${where}: the raw weighted run is truncated — vertex ${decoded} claims ${bones} bone(s) and the array ends first`,
1148
+ );
1149
+ }
1150
+ }
1151
+ if (decoded !== count) {
1152
+ throw new CompileError(`${where}: the raw weighted run decodes to ${decoded} vertices but vertexCount is ${count}`);
1153
+ }
1154
+ // Register the bones it binds so the mesh-bone reports stay complete.
1155
+ for (let i = 0; i < raw.length; ) {
1156
+ const bones = raw[i++];
1157
+ for (let k = 0; k < bones; k++, i += 4) {
1158
+ const bone = ctx.bones[raw[i]];
1159
+ if (bone) ctx.meshBones.add(bone.name);
1160
+ }
1161
+ }
1162
+ }
1163
+ for (const n of raw) {
1164
+ if (!Number.isFinite(n)) throw new CompileError(`${where}: the vertex array holds a non-finite value ${String(n)}`);
1165
+ }
1166
+ return raw.map(r6);
1167
+ }
1168
+
1169
+ function buildRigBoundingBox(
1170
+ att: RigBoundingBoxAttachment,
1171
+ where: string,
1172
+ ctx: AttachmentContext,
1173
+ ): SpineBoundingBoxAttachment {
1174
+ const out: SpineBoundingBoxAttachment = {
1175
+ type: 'boundingbox',
1176
+ vertexCount: att.vertexCount,
1177
+ vertices: buildVertexGeometry(att, where, ctx),
1178
+ };
1179
+ if (att.color !== undefined) out.color = att.color;
1180
+ return out;
1181
+ }
1182
+
1183
+ function buildRigClipping(
1184
+ att: RigClippingAttachment,
1185
+ where: string,
1186
+ ctx: AttachmentContext,
1187
+ ): SpineClippingAttachment {
1188
+ if (att.end !== undefined) {
1189
+ // `skeletonData.findSlot` returns null on a miss and the parser assigns that
1190
+ // null (`:626-627`), so a typo does not fail — the clip simply never ends and
1191
+ // takes every slot below it out of the frame.
1192
+ if (typeof att.end !== 'string' || !ctx.slotNames.has(att.end)) {
1193
+ throw new CompileError(
1194
+ `${where}: end names slot ${JSON.stringify(att.end)}, which this rig does not declare; ` +
1195
+ 'a miss loads as null and the clip then runs to the bottom of the draw order',
1196
+ );
1197
+ }
1198
+ }
1199
+ // Field order is the editor's here — `end`, `convex`, `inverse` before the
1200
+ // geometry — via conditional spreads, because a key assigned after the literal
1201
+ // lands at the end instead. Order carries no meaning in JSON; it is read by
1202
+ // people, and this file's diff against a reference is read a lot.
1203
+ const out: SpineClippingAttachment = {
1204
+ type: 'clipping',
1205
+ ...(att.end !== undefined ? { end: att.end } : {}),
1206
+ ...(att.convex !== undefined ? { convex: att.convex } : {}),
1207
+ ...(att.inverse !== undefined ? { inverse: att.inverse } : {}),
1208
+ vertexCount: att.vertexCount,
1209
+ vertices: buildVertexGeometry(att, where, ctx),
1210
+ };
1211
+ if (att.color !== undefined) out.color = att.color;
1212
+ return out;
1213
+ }
1214
+
990
1215
  function buildRigRegion(
991
1216
  att: RigRegionAttachment,
992
1217
  placeholder: string,
@@ -1606,7 +1831,7 @@ function compileValueTrack(
1606
1831
  for (let i = 0; i < track.keys.length; i++) {
1607
1832
  const key = track.keys[i];
1608
1833
  const next = track.keys[i + 1];
1609
- const time = r6(key.t + shift);
1834
+ const time = keyTime(key.t + shift);
1610
1835
  if (i > 0 && time <= (out[i - 1].time as number)) {
1611
1836
  throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
1612
1837
  }
@@ -1641,7 +1866,7 @@ function compileValueTrack(
1641
1866
  const handles = motion.easings?.[key.ease];
1642
1867
  if (!handles) throw new CompileError(`${where}: unknown easing "${key.ease}"`);
1643
1868
  if (!Array.isArray(next.v)) throw new CompileError(`${where}: next key value must be an array`);
1644
- const t2 = r6(next.t + shift);
1869
+ const t2 = keyTime(next.t + shift);
1645
1870
  const curve: number[] = [];
1646
1871
  for (let c = 0; c < shape.fields.length; c++) {
1647
1872
  curve.push(...bezierForChannel(handles, time, t2, (key.v as number[])[c], (next.v as number[])[c]));
@@ -1703,7 +1928,7 @@ function compileDrawOrder(
1703
1928
  const out: SpineTimelineKey[] = [];
1704
1929
  for (let i = 0; i < keys.length; i++) {
1705
1930
  const key = keys[i];
1706
- const time = r6(key.t);
1931
+ const time = keyTime(key.t);
1707
1932
  if (i > 0 && time <= (out[i - 1].time as number)) {
1708
1933
  throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
1709
1934
  }
@@ -1744,6 +1969,100 @@ function compileDrawOrder(
1744
1969
  return out;
1745
1970
  }
1746
1971
 
1972
+ /**
1973
+ * The whole-animation event timeline (`animations.<a>.events`).
1974
+ *
1975
+ * Four refusals, and the reason each one is here is a different failure mode of
1976
+ * `readAnimation`'s event branch (SkeletonJson.ts:1238-1261):
1977
+ *
1978
+ * 1. **The name must be declared.** `skeletonData.findEvent` returns null and
1979
+ * the parser throws `Event not found` — one of the format's few loud
1980
+ * failures, but it throws in the CONSUMER's process, which is late. Refused
1981
+ * here so the message can name the rig spec's `events` block instead.
1982
+ * 2. **Key times must not go backwards.** The loop writes frame `i` from key
1983
+ * `i` in ARRAY order and never sorts, so a time that decreases produces an
1984
+ * `EventTimeline` whose frames are out of order. Nothing throws; the
1985
+ * timeline's search simply stops finding the firings behind the fold. Equal
1986
+ * times are legal and deliberate — two different events on the same frame is
1987
+ * an ordinary thing to want — so this is non-decreasing, not the strictly
1988
+ * increasing rule a value track lives under (a value track has one value per
1989
+ * time and two keys at one time is a contradiction; two firings are not).
1990
+ * 3. **`volume`/`balance` need the event to carry `audio`.** `:1254-1257` reads
1991
+ * them only inside `if (event.data.audioPath)`, so on a silent event they
1992
+ * are dropped without a word.
1993
+ * 4. **A key past the declared duration.** The same `checkKeyTime` every other
1994
+ * timeline goes through: a firing after the end never fires (issue #54 was
1995
+ * exactly this shape on an attachment reveal).
1996
+ */
1997
+ function compileEvents(
1998
+ keys: MotionEventKey[],
1999
+ animName: string,
2000
+ duration: number,
2001
+ events: Record<string, RigEvent>,
2002
+ ): SpineTimelineKey[] {
2003
+ const where = `animation "${animName}" events`;
2004
+ if (!keys.length) throw new CompileError(`${where}: no keys`);
2005
+
2006
+ const out: SpineTimelineKey[] = [];
2007
+ for (let i = 0; i < keys.length; i++) {
2008
+ const key = keys[i];
2009
+ if (typeof key.name !== 'string' || key.name.length === 0) {
2010
+ throw new CompileError(`${where}: key ${i} has no "name"; an event key fires an event by name`);
2011
+ }
2012
+ const declared = events[key.name];
2013
+ if (declared === undefined) {
2014
+ const known = Object.keys(events);
2015
+ throw new CompileError(
2016
+ `${where} at t=${key.t}: event "${key.name}" is not declared in the rig spec's "events" block; ` +
2017
+ (known.length ? `declared: ${known.join(', ')}` : 'that block is empty or absent'),
2018
+ );
2019
+ }
2020
+ if (!Number.isFinite(key.t)) throw new CompileError(`${where}: key ${i} has a non-finite time ${String(key.t)}`);
2021
+ const time = keyTime(key.t);
2022
+ if (i > 0 && time < (out[i - 1].time as number)) {
2023
+ throw new CompileError(
2024
+ `${where}: key times must not go backwards (at t=${key.t}, after t=${String(out[i - 1].time)}) — ` +
2025
+ 'the parser writes frames in array order and never sorts them',
2026
+ );
2027
+ }
2028
+ checkKeyTime(where, time, key.t, duration);
2029
+
2030
+ const entry: SpineTimelineKey = { time, name: key.name };
2031
+ if (key.int !== undefined) {
2032
+ if (!Number.isInteger(key.int)) {
2033
+ throw new CompileError(`${where} at t=${key.t}: int ${String(key.int)} is not an integer`);
2034
+ }
2035
+ entry.int = key.int;
2036
+ }
2037
+ if (key.float !== undefined) {
2038
+ if (!Number.isFinite(key.float)) {
2039
+ throw new CompileError(`${where} at t=${key.t}: float ${String(key.float)} is not finite`);
2040
+ }
2041
+ entry.float = r6(key.float);
2042
+ }
2043
+ if (key.string !== undefined) {
2044
+ if (typeof key.string !== 'string') {
2045
+ throw new CompileError(`${where} at t=${key.t}: string ${JSON.stringify(key.string)} is not a string`);
2046
+ }
2047
+ entry.string = key.string;
2048
+ }
2049
+ for (const field of ['volume', 'balance'] as const) {
2050
+ const v = key[field];
2051
+ if (v === undefined) continue;
2052
+ if (declared.audio === undefined) {
2053
+ throw new CompileError(
2054
+ `${where} at t=${key.t}: ${field} is set but event "${key.name}" declares no "audio"; ` +
2055
+ `the parser reads ${field} only for an event with an audio path, so it would be dropped in silence`,
2056
+ );
2057
+ }
2058
+ if (!Number.isFinite(v)) throw new CompileError(`${where} at t=${key.t}: ${field} ${String(v)} is not finite`);
2059
+ entry[field] = r6(v);
2060
+ }
2061
+ out.push(entry);
2062
+ }
2063
+ return out;
2064
+ }
2065
+
1747
2066
  function resolveTargets(track: MotionTrack, motion: MotionSpec, animName: string): string[] {
1748
2067
  const named = [track.slot, track.group, track.bone, track.physics].filter((v) => v !== undefined);
1749
2068
  if (named.length > 1) {
@@ -1803,7 +2122,7 @@ function compileTrack(
1803
2122
  for (let i = 0; i < track.keys.length; i++) {
1804
2123
  const key = track.keys[i];
1805
2124
  const next = track.keys[i + 1];
1806
- const time = r6(key.t + shift);
2125
+ const time = keyTime(key.t + shift);
1807
2126
  if (i > 0 && time <= (out[i - 1].time as number)) {
1808
2127
  throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
1809
2128
  }
@@ -1843,7 +2162,7 @@ function compileTrack(
1843
2162
  if (!Array.isArray(next.v)) {
1844
2163
  throw new CompileError(`${where}: rgba key value must be [r,g,b,a]`);
1845
2164
  }
1846
- const t2 = r6(next.t + shift);
2165
+ const t2 = keyTime(next.t + shift);
1847
2166
  // 4 numbers per channel, r g b a — 16 in total. Short arrays become NaN
1848
2167
  // curves with no error.
1849
2168
  const curve: number[] = [];
package/src/ladder.ts CHANGED
@@ -103,7 +103,7 @@ export const LADDER: readonly Rung[] = [
103
103
  {
104
104
  id: 'spineboy',
105
105
  example: 'spineboy',
106
- gates: 'IK, events, bounding box, clipping, unweighted meshes — and scale: 67 bones, 52 slots, 11 animations',
106
+ gates: 'IK, events, bounding box, clipping, unweighted meshes — and scale: ess 18 bones/20 slots/8 animations, pro 67 bones/52 slots/11 animations',
107
107
  skeletons: [
108
108
  { label: 'ess', file: 'spineboy-ess.json', atlas: 'spineboy.atlas', role: 'rung' },
109
109
  // `-pro` is reported and does not count. It is a harder rig than the
package/src/render.ts CHANGED
@@ -909,6 +909,17 @@ export interface FrameGeometry {
909
909
  /** 1 where any piece drew, in `viewport.width * viewport.height` row-major order. */
910
910
  coverage: Uint8Array;
911
911
  footprints: Map<string, Footprint>;
912
+ /**
913
+ * Which owner drew each pixel last, or `-1` — `null` unless `owners` was given.
914
+ *
915
+ * "Last" is the composite's own rule: pieces arrive in draw order, so the owner
916
+ * left in a pixel is the one you would see there. That is deliberately the
917
+ * opposite of `footprints`, which measures each slot on its own pixels
918
+ * *ignoring* what covers it — a footprint answers "where is this part", and
919
+ * this mask answers "whose part is this pixel", and only the second one can be
920
+ * a partition.
921
+ */
922
+ owner: Int32Array | null;
912
923
  }
913
924
 
914
925
  /**
@@ -923,11 +934,19 @@ export interface FrameGeometry {
923
934
  * an occluded part merges into its occluder's component — and that is what the
924
935
  * matcher reports as ambiguity rather than as drift.
925
936
  */
926
- export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport: Viewport): FrameGeometry {
937
+ export function frameGeometry(
938
+ frame: Frame,
939
+ pages: Map<string, Plate>,
940
+ viewport: Viewport,
941
+ /** Slot name → owner id, when the caller also wants the per-pixel owner mask. */
942
+ owners?: Map<string, number>,
943
+ ): FrameGeometry {
927
944
  const coverage = new Uint8Array(viewport.width * viewport.height);
945
+ const owner = owners === undefined ? null : new Int32Array(viewport.width * viewport.height).fill(-1);
928
946
  const footprints = new Map<string, Footprint>();
929
947
  const project = projector(viewport);
930
948
  for (const piece of frame.pieces) {
949
+ const owned = owners === undefined ? -1 : (owners.get(piece.slot) ?? -1);
931
950
  let weight = 0;
932
951
  let sx = 0;
933
952
  let sy = 0;
@@ -937,6 +956,7 @@ export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport:
937
956
  let maxY = -Infinity;
938
957
  rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, _r, _g, _b, a) => {
939
958
  coverage[py * viewport.width + px] = 1;
959
+ if (owner !== null && owned >= 0) owner[py * viewport.width + px] = owned;
940
960
  const w = a / 255;
941
961
  weight += w;
942
962
  sx += (px + 0.5) * w;
@@ -956,7 +976,7 @@ export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport:
956
976
  // answer, and it keeps the map keyed by slot the way the report reads it.
957
977
  footprints.set(piece.slot, previous && previous.pixels > 0 ? mergeFootprints(previous, here) : here);
958
978
  }
959
- return { coverage, footprints };
979
+ return { coverage, footprints, owner };
960
980
  }
961
981
 
962
982
  function mergeFootprints(a: Footprint, b: Footprint): Footprint {