spine-rigc 2.18.0 → 2.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.18.0",
3
+ "version": "2.19.1",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/mesh.ts CHANGED
@@ -49,7 +49,10 @@ import type { ModelBinding, ModelVertices } from './model.ts';
49
49
  // `spine-rigc/mesh` names. That module imports helpers from this one, so the
50
50
  // two form an import cycle: safe only because neither reads the other's
51
51
  // bindings while it is being evaluated — every use is inside a function.
52
+ // The reduction (`reduceMesh`, stage B2 of #1224) sits in the same cycle, by
53
+ // the same rule.
52
54
  export * from './meshquality.ts';
55
+ export * from './meshreduce.ts';
53
56
 
54
57
  export interface MeshSpecInput {
55
58
  /** Polygon in part-local pixels, y down, in manifest order. */
@@ -6,9 +6,9 @@
6
6
  * attachment draws, and the bounds the caller declared, measured into one
7
7
  * `mesh-quality-report/1` document (`writeMeshQualityReport` writes its text).
8
8
  * Every type the contract declares is declared here too, including the ones
9
- * only the reduction (`reduceMesh`, stage B2) and the motion comparison
10
- * (stage C) will fill, so that those stages build on one set of types rather
11
- * than restating them.
9
+ * only the reduction (`reduceMesh`, stage B2, in `src/meshreduce.ts`) and the
10
+ * motion comparison (stage C) fill, so that those stages build on one set of
11
+ * types rather than restating them.
12
12
  *
13
13
  * ## What it is for
14
14
  *
@@ -181,7 +181,28 @@ export interface InfluenceLimits {
181
181
  minWeight: number;
182
182
  }
183
183
 
184
- /** Everything a reduction reads (stage B2). No field has a default inside the operation. */
184
+ /**
185
+ * One deform key of a timeline the reduced attachment is keyed under (§6, P18),
186
+ * in the form the compiler emits it: a `vertices` run is the emitted `offset`
187
+ * (an index into the deform array — two numbers per vertex unweighted, two per
188
+ * influence weighted, AUTHORING §4.11) and the numbers copied in from there; a
189
+ * `transform` key is a model the compile evaluates over the attachment's own
190
+ * geometry (§4.11.1), so it has no run to remap; a `setup` key carries no run.
191
+ */
192
+ export type DeformKeyInput =
193
+ | { time: number; kind: 'setup' }
194
+ | { time: number; kind: 'vertices'; offset: number; vertices: number[] }
195
+ | { time: number; kind: 'transform' };
196
+
197
+ /** One deform timeline: the animation, the attachment it is keyed on, and its keys in order. */
198
+ export interface DeformTimelineInput {
199
+ animation: string;
200
+ /** The reduced attachment itself, or one of `MeshReductionInput.linkedMeshes`. */
201
+ attachment: AttachmentRef;
202
+ keys: DeformKeyInput[];
203
+ }
204
+
205
+ /** Everything a reduction reads (stage B2, `reduceMesh` in `src/meshreduce.ts`). No field has a default inside the operation. */
185
206
  export interface MeshReductionInput {
186
207
  attachment: AttachmentRef;
187
208
  art: ArtInput;
@@ -198,8 +219,16 @@ export interface MeshReductionInput {
198
219
  boneOrder: string[] | null;
199
220
  /** P5: the preset the caller expanded, if any, echoed and never read. */
200
221
  preset: { name: string; version: string } | null;
201
- /** Work bound — *Termination reasons*. */
222
+ /** Work bound — *Termination reasons*. Every refinement insertion and every removal attempted counts one. */
202
223
  budget: { maxCandidates: number };
224
+ /** P9: the fewest art samples the attachment's raster rows are taken over. A whole number >= 1. */
225
+ minArtSamples: number;
226
+ /** P9: one floor per region, by region name — every region named once. */
227
+ regionArtSamples: Array<{ region: string; minArtSamples: number }>;
228
+ /** P18: every deform timeline keyed on the attachment or on one of its linked meshes; empty when none. */
229
+ deform: DeformTimelineInput[];
230
+ /** Every linked mesh of the source (they inherit the new topology); empty when none. */
231
+ linkedMeshes: AttachmentRef[];
203
232
  }
204
233
 
205
234
  /**
@@ -341,6 +370,29 @@ export interface CandidateReport {
341
370
  accepted: boolean;
342
371
  /** Opt-in (P7): every row's value at every frame. Absent unless asked for. */
343
372
  perFrame?: Array<{ code: string; frame: string; value: number | null }>;
373
+ /** A `reduce` whose result exists: what the operation changed. Absent on a `measure` and when no mesh is returned. */
374
+ changes?: ReductionChanges;
375
+ }
376
+
377
+ /**
378
+ * What a reduction changed, counted rather than described — the figures the
379
+ * contract says are reported (§6, P18 and P19) and that no geometry row carries.
380
+ */
381
+ export interface ReductionChanges {
382
+ /** Source vertices the reduction removed. */
383
+ removedVertices: number;
384
+ /** Vertices the refinement inserted (§5). */
385
+ insertedVertices: number;
386
+ /** P19: positive interpolated shares that are 0 on the 6-decimal weight grid, dropped rather than written as 0. */
387
+ sharesDroppedOnGrid: number;
388
+ /** Interpolated shares pruned by `InfluenceLimits` — over `maxInfluences`, or under a nonzero `minWeight`. */
389
+ sharesPruned: number;
390
+ /** P18: `vertices` keys remapped, each with the source vertices whose offsets were dropped. */
391
+ deformRemapped: Array<{ animation: string; attachment: AttachmentRef; key: number; droppedVertices: number[] }>;
392
+ /** P18: `transform` keys, re-evaluated over the new geometry at compile rather than remapped. */
393
+ deformReevaluated: Array<{ animation: string; attachment: AttachmentRef; key: number }>;
394
+ /** Every linked mesh of the source: each inherits the new topology. */
395
+ linkedMeshes: AttachmentRef[];
344
396
  }
345
397
 
346
398
  export interface MeshQualityReport {
@@ -421,6 +473,20 @@ const HAUSDORFF_TOLERANCE = 1e-9;
421
473
  /** The predicate epsilon `segmentsMeet` and `prunePolygon` use (`src/mesh.ts`), for "on the boundary". */
422
474
  const ON_BOUNDARY = 1e-9;
423
475
 
476
+ /**
477
+ * How near a region's band's outer boundary, in drawing px, an edge's nearest
478
+ * point has to come to count as touching it rather than entering the band —
479
+ * the precision of "a single point on the outer boundary" in
480
+ * `edgeIsHeldByRegion`, ten units of the `r6` grid. The refinement puts the
481
+ * vertex it splits an edge at half of this inside the outer boundary and then
482
+ * on the grid, which moves it by at most √2 × 5e-7 px, so the piece beyond it
483
+ * lands inside this tolerance from either side.
484
+ */
485
+ export const BAND_CONTACT_TOLERANCE = 1e-5;
486
+
487
+ /** Halvings of the parameter interval in the convex searches along an edge (`edgeIsHeldByRegion`): 2^-60 of an edge is below a double's resolution of it. */
488
+ const EDGE_SEARCH_STEPS = 60;
489
+
424
490
  // ---------------------------------------------------------------------------
425
491
  // refusals
426
492
  // ---------------------------------------------------------------------------
@@ -656,6 +722,110 @@ function segmentToPolygon(a: Pt, b: Pt, poly: readonly Pt[]): number {
656
722
  return best;
657
723
  }
658
724
 
725
+ /**
726
+ * The parameter in `[0, 1]` along `a`–`b` nearest which `c`–`d` lies — the
727
+ * minimum of the distance from `a + t(b − a)` to the segment `c`–`d`, which is
728
+ * convex in `t`, found by golden-section search. A flat minimum (parallel
729
+ * segments) returns some point of it; the callers never read one there.
730
+ */
731
+ function nearestParameter(a: Pt, b: Pt, c: Pt, d: Pt): number {
732
+ const at = (t: number): number => distanceToSegment([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], c, d);
733
+ const g = (Math.sqrt(5) - 1) / 2;
734
+ let lo = 0;
735
+ let hi = 1;
736
+ for (let i = 0; i < EDGE_SEARCH_STEPS; i++) {
737
+ const p = hi - g * (hi - lo);
738
+ const q = lo + g * (hi - lo);
739
+ if (at(p) <= at(q)) hi = q;
740
+ else lo = p;
741
+ }
742
+ return (lo + hi) / 2;
743
+ }
744
+
745
+ /**
746
+ * Does `a`–`b` run along the outer boundary of a band of width `transition`
747
+ * for a positive length — some polygon edge it overlaps in projection by more
748
+ * than `BAND_CONTACT_TOLERANCE`, at that edge's perpendicular distance
749
+ * `transition` (within the tolerance) at both ends of the overlap, so at every
750
+ * point between them? The caller has already found the edge's nearest approach
751
+ * to the polygon within the tolerance of `transition`.
752
+ */
753
+ function runsAlongOuterBoundary(a: Pt, b: Pt, poly: readonly Pt[], transition: number): boolean {
754
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
755
+ const n = poly.length;
756
+ for (let i = 0; i < n; i++) {
757
+ const c = poly[i];
758
+ const e = poly[(i + 1) % n];
759
+ const side = Math.hypot(e[0] - c[0], e[1] - c[1]);
760
+ if (side === 0) continue;
761
+ const ux = (e[0] - c[0]) / side;
762
+ const uy = (e[1] - c[1]) / side;
763
+ const sa = (a[0] - c[0]) * ux + (a[1] - c[1]) * uy;
764
+ const sb = (b[0] - c[0]) * ux + (b[1] - c[1]) * uy;
765
+ let t0 = 0;
766
+ let t1 = 1;
767
+ if (sa === sb) {
768
+ if (sa < 0 || sa > side) continue;
769
+ } else {
770
+ const p = (0 - sa) / (sb - sa);
771
+ const q = (side - sa) / (sb - sa);
772
+ t0 = Math.max(0, Math.min(p, q));
773
+ t1 = Math.min(1, Math.max(p, q));
774
+ }
775
+ if ((t1 - t0) * length <= BAND_CONTACT_TOLERANCE) continue;
776
+ const off = (t: number): number => Math.abs(ux * (a[1] + (b[1] - a[1]) * t - c[1]) - uy * (a[0] + (b[0] - a[0]) * t - c[0]));
777
+ if (Math.abs(off(t0) - transition) <= BAND_CONTACT_TOLERANCE && Math.abs(off(t1) - transition) <= BAND_CONTACT_TOLERANCE) return true;
778
+ }
779
+ return false;
780
+ }
781
+
782
+ /**
783
+ * [agreed, spine-parts#126] Is the edge `a`–`b` held by `region` — does the
784
+ * region's density bound (`MQ_MAX_EDGE` or `MQ_TRANSITION`) apply to it? The
785
+ * one definition both `measureMeshQuality` and `reduceMesh`'s refinement read.
786
+ *
787
+ * The region's active domain is its closed polygon and, when `transition > 0`,
788
+ * the band of that width outside it. An edge is held when it meets that domain,
789
+ * except for one case: its intersection with the domain is a **single point on
790
+ * the band's outer boundary** and the rest of the edge lies outside — an edge
791
+ * that only touches the band from outside is exempt.
792
+ *
793
+ * - An edge that meets the closed polygon is held, whatever the band.
794
+ * - With `transition: 0` there is no outer band, and a contact with the
795
+ * authored boundary is held under the closed-region rule — never exempt.
796
+ * - A positive-length intersection is held, including a segment lying along
797
+ * the band's outer boundary.
798
+ * - Two separate touches of the outer boundary are two points, not one, and
799
+ * the edge is held.
800
+ *
801
+ * "On the outer boundary" is within `BAND_CONTACT_TOLERANCE`. Each region is
802
+ * read on its own: an exemption from one never removes another's bound.
803
+ */
804
+ export function edgeIsHeldByRegion(a: readonly [number, number], b: readonly [number, number], region: RefinementRegion): boolean {
805
+ const poly: readonly Pt[] = region.polygon;
806
+ if (segmentMeetsPolygon(a, b, poly)) return true;
807
+ const transition = region.transition;
808
+ if (!(transition > 0)) return false;
809
+ const d = segmentToPolygon(a, b, poly);
810
+ if (d > transition) return false;
811
+ if (d < transition - BAND_CONTACT_TOLERANCE) return true;
812
+ if (runsAlongOuterBoundary(a, b, poly, transition)) return true;
813
+ // Where on the edge each polygon side is touched: one point, or several apart.
814
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
815
+ let first = Infinity;
816
+ let last = -Infinity;
817
+ const n = poly.length;
818
+ for (let i = 0; i < n; i++) {
819
+ const c = poly[i];
820
+ const e = poly[(i + 1) % n];
821
+ if (segmentDistance(a, b, c, e) > transition) continue;
822
+ const t = nearestParameter(a, b, c, e);
823
+ first = Math.min(first, t);
824
+ last = Math.max(last, t);
825
+ }
826
+ return (last - first) * length > BAND_CONTACT_TOLERANCE;
827
+ }
828
+
659
829
  /** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
660
830
  function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
661
831
  let d = Infinity;
@@ -1149,9 +1319,12 @@ interface Edge {
1149
1319
  * graded band, only where the band has width) and `MQ_FILL_DISTANCE` (`h(R)`,
1150
1320
  * sampled, informational).
1151
1321
  *
1152
- * One bound per edge, the smallest applicable anywhere on it (P16): `L0` of
1153
- * every region whose closed polygon it meets, and `L0 + grade·d` of every band
1154
- * it lies in, `d` its distance from that region. A row's value is the edge whose
1322
+ * Which edges a region holds is `edgeIsHeldByRegion`'s answer and nothing
1323
+ * else's: an edge that only touches the band's outer boundary at one point,
1324
+ * from outside, is not held (spine-parts#126). One bound per edge, the smallest
1325
+ * applicable anywhere on it (P16): `L0` of every region whose closed polygon it
1326
+ * meets, and `L0 + grade·d` of every other region that holds it, `d` its
1327
+ * distance from that region. A row's value is the edge whose
1155
1328
  * length most exceeds its own bound — so the row fails exactly when some edge
1156
1329
  * it covers is over — and the row names that edge and that bound.
1157
1330
  */
@@ -1190,15 +1363,29 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1190
1363
  else refusals.set(region.name, `${refusal.code}: ${refusal.message}`);
1191
1364
  }
1192
1365
 
1193
- /** Every bound that applies to an edge, from every measurable region, and the smallest. */
1366
+ /**
1367
+ * Per measurable region, per edge (in `edges` order): the edge's distance
1368
+ * from the closed polygon when the region holds it (`edgeIsHeldByRegion`,
1369
+ * the one definition the refinement reads too), null when it does not.
1370
+ */
1371
+ const holds = new Map<RefinementRegion, Array<number | null>>();
1372
+ for (const region of live) {
1373
+ holds.set(
1374
+ region,
1375
+ edges.map((e) => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null)),
1376
+ );
1377
+ }
1378
+ const edgeIndex = new Map(edges.map((e, i) => [e, i]));
1379
+
1380
+ /** Every bound that applies to an edge, from every measurable region that holds it, and the smallest. */
1194
1381
  const boundOf = (e: Edge): number | null => {
1195
1382
  let bound: number | null = null;
1383
+ const i = edgeIndex.get(e)!;
1196
1384
  for (const region of live) {
1197
- const d = segmentToPolygon(points[e.a], points[e.b], region.polygon);
1198
- let here: number | null = null;
1199
- if (d === 0) here = region.maxEdgeLength;
1200
- else if (region.transition > 0 && d <= region.transition) here = r6(region.maxEdgeLength + region.grade * d);
1201
- if (here !== null && (bound === null || here < bound)) bound = here;
1385
+ const d = holds.get(region)![i];
1386
+ if (d === null) continue;
1387
+ const here = d === 0 ? region.maxEdgeLength : r6(region.maxEdgeLength + region.grade * d);
1388
+ if (bound === null || here < bound) bound = here;
1202
1389
  }
1203
1390
  return bound;
1204
1391
  };
@@ -1228,7 +1415,8 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1228
1415
  continue;
1229
1416
  }
1230
1417
  const poly: Pt[] = region.polygon;
1231
- const inside = edges.filter((e) => segmentMeetsPolygon(points[e.a], points[e.b], poly));
1418
+ const held = holds.get(region)!;
1419
+ const inside = edges.filter((_, i) => held[i] === 0);
1232
1420
  const maxSpec = spec('MQ_MAX_EDGE', 'px', region.name);
1233
1421
  const inWorst = worstOf(inside);
1234
1422
  if (inWorst === null) {
@@ -1237,10 +1425,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1237
1425
  out.push(measuredRow(maxSpec, r6(inWorst.edge.length), { op: '<=', value: inWorst.bound }, { at: { edge: [inWorst.edge.a, inWorst.edge.b] } }, true));
1238
1426
  }
1239
1427
  if (region.transition > 0) {
1240
- const banded = edges.filter((e) => {
1241
- const d = segmentToPolygon(points[e.a], points[e.b], poly);
1242
- return d > 0 && d <= region.transition;
1243
- });
1428
+ const banded = edges.filter((_, i) => held[i] !== null && held[i]! > 0);
1244
1429
  const bandWorst = worstOf(banded);
1245
1430
  const transitionSpec = spec('MQ_TRANSITION', 'px', region.name);
1246
1431
  if (bandWorst === null) {
@@ -1470,6 +1655,18 @@ function candidateJson(c: CandidateReport): Json {
1470
1655
  accepted: c.accepted,
1471
1656
  };
1472
1657
  if (c.perFrame !== undefined) out.perFrame = c.perFrame.map((p) => ({ code: p.code, frame: p.frame, value: p.value }));
1658
+ if (c.changes !== undefined) {
1659
+ const k = c.changes;
1660
+ out.changes = {
1661
+ removedVertices: k.removedVertices,
1662
+ insertedVertices: k.insertedVertices,
1663
+ sharesDroppedOnGrid: k.sharesDroppedOnGrid,
1664
+ sharesPruned: k.sharesPruned,
1665
+ deformRemapped: k.deformRemapped.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key, droppedVertices: [...d.droppedVertices] })),
1666
+ deformReevaluated: k.deformReevaluated.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key })),
1667
+ linkedMeshes: k.linkedMeshes.map(attachmentJson),
1668
+ };
1669
+ }
1473
1670
  return out;
1474
1671
  }
1475
1672