spine-rigc 2.19.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.19.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": {
@@ -473,6 +473,20 @@ const HAUSDORFF_TOLERANCE = 1e-9;
473
473
  /** The predicate epsilon `segmentsMeet` and `prunePolygon` use (`src/mesh.ts`), for "on the boundary". */
474
474
  const ON_BOUNDARY = 1e-9;
475
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
+
476
490
  // ---------------------------------------------------------------------------
477
491
  // refusals
478
492
  // ---------------------------------------------------------------------------
@@ -708,6 +722,110 @@ function segmentToPolygon(a: Pt, b: Pt, poly: readonly Pt[]): number {
708
722
  return best;
709
723
  }
710
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
+
711
829
  /** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
712
830
  function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
713
831
  let d = Infinity;
@@ -1201,9 +1319,12 @@ interface Edge {
1201
1319
  * graded band, only where the band has width) and `MQ_FILL_DISTANCE` (`h(R)`,
1202
1320
  * sampled, informational).
1203
1321
  *
1204
- * One bound per edge, the smallest applicable anywhere on it (P16): `L0` of
1205
- * every region whose closed polygon it meets, and `L0 + grade·d` of every band
1206
- * 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
1207
1328
  * length most exceeds its own bound — so the row fails exactly when some edge
1208
1329
  * it covers is over — and the row names that edge and that bound.
1209
1330
  */
@@ -1242,15 +1363,29 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1242
1363
  else refusals.set(region.name, `${refusal.code}: ${refusal.message}`);
1243
1364
  }
1244
1365
 
1245
- /** 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. */
1246
1381
  const boundOf = (e: Edge): number | null => {
1247
1382
  let bound: number | null = null;
1383
+ const i = edgeIndex.get(e)!;
1248
1384
  for (const region of live) {
1249
- const d = segmentToPolygon(points[e.a], points[e.b], region.polygon);
1250
- let here: number | null = null;
1251
- if (d === 0) here = region.maxEdgeLength;
1252
- else if (region.transition > 0 && d <= region.transition) here = r6(region.maxEdgeLength + region.grade * d);
1253
- 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;
1254
1389
  }
1255
1390
  return bound;
1256
1391
  };
@@ -1280,7 +1415,8 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1280
1415
  continue;
1281
1416
  }
1282
1417
  const poly: Pt[] = region.polygon;
1283
- 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);
1284
1420
  const maxSpec = spec('MQ_MAX_EDGE', 'px', region.name);
1285
1421
  const inWorst = worstOf(inside);
1286
1422
  if (inWorst === null) {
@@ -1289,10 +1425,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1289
1425
  out.push(measuredRow(maxSpec, r6(inWorst.edge.length), { op: '<=', value: inWorst.bound }, { at: { edge: [inWorst.edge.a, inWorst.edge.b] } }, true));
1290
1426
  }
1291
1427
  if (region.transition > 0) {
1292
- const banded = edges.filter((e) => {
1293
- const d = segmentToPolygon(points[e.a], points[e.b], poly);
1294
- return d > 0 && d <= region.transition;
1295
- });
1428
+ const banded = edges.filter((_, i) => held[i] !== null && held[i]! > 0);
1296
1429
  const bandWorst = worstOf(banded);
1297
1430
  const transitionSpec = spec('MQ_TRANSITION', 'px', region.name);
1298
1431
  if (bandWorst === null) {
package/src/meshreduce.ts CHANGED
@@ -42,9 +42,13 @@
42
42
  * to meet `L(R)`, and removing one is undoing the refinement.
43
43
  *
44
44
  * The refinement measures, takes the first failing region row in report order
45
- * (code, then region name) and splits that row's worst edge: at its midpoint
46
- * when the midpoint lies in the region or its band, otherwise at the point of
47
- * the edge inside them nearest the midpoint. A region no edge meets — one
45
+ * (code, then region name) and splits that row's worst edge. When an end of
46
+ * the edge lies outside the region and its band, the split is where the edge
47
+ * leaves the band, so the piece to that end only touches the band's outer
48
+ * boundary and `edgeIsHeldByRegion` — the one definition the rows read —
49
+ * exempts it (spine-parts#126). Otherwise the split is at the midpoint when
50
+ * the midpoint lies in the region or its band, else at the point of the edge
51
+ * inside them nearest the midpoint. A region no edge meets — one
48
52
  * inside a single triangle — gets the first vertex of its polygon inserted into
49
53
  * the triangle that holds it. Every inserted position is on the `r6` grid.
50
54
  *
@@ -79,6 +83,8 @@ import {
79
83
  checkHullOrder,
80
84
  } from './mesh.ts';
81
85
  import {
86
+ BAND_CONTACT_TOLERANCE,
87
+ edgeIsHeldByRegion,
82
88
  measureMeshQuality,
83
89
  type AttachmentRef,
84
90
  type ArtFitBounds,
@@ -159,6 +165,9 @@ const ON_BOUNDARY = 1e-9;
159
165
  /** How many halvings toward a point known to lie in a region's band the split search tries before it says none was found. */
160
166
  const SPLIT_SEARCH_STEPS = 40;
161
167
 
168
+ /** Iterations of the convex searches `offsetExit` runs along an edge: 2^-60 of an edge is below a double's resolution of it. */
169
+ const EXIT_SEARCH_STEPS = 60;
170
+
162
171
  function nameOf(ref: AttachmentRef): string {
163
172
  return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
164
173
  }
@@ -797,6 +806,64 @@ function splitPoint(a: Pt, b: Pt, region: RefinementRegion): Pt | null {
797
806
  return null;
798
807
  }
799
808
 
809
+ /**
810
+ * The parameter, from `inner` (0) to `outer` (1), of the point of the edge
811
+ * nearest `outer` that lies within `level` of `region`'s polygon — where the
812
+ * edge last leaves that offset of the polygon on its way to `outer` — or null
813
+ * when no point of it comes that near. `outer` lies further than `level`, so
814
+ * walking back from it the offset is first met where some polygon side is
815
+ * exactly `level` away: the largest such parameter over the sides. Each side's
816
+ * distance is convex along the edge, so its minimum is found by golden section
817
+ * and the last parameter within `level` by bisection from there towards `outer`.
818
+ */
819
+ function offsetExit(inner: Pt, outer: Pt, poly: readonly Pt[], level: number): number | null {
820
+ const at = (t: number): Pt => [inner[0] + (outer[0] - inner[0]) * t, inner[1] + (outer[1] - inner[1]) * t];
821
+ const g = (Math.sqrt(5) - 1) / 2;
822
+ let best: number | null = null;
823
+ for (let i = 0; i < poly.length; i++) {
824
+ const c = poly[i];
825
+ const e = poly[(i + 1) % poly.length];
826
+ const f = (t: number): number => distanceToSegment(at(t), c, e);
827
+ let lo = 0;
828
+ let hi = 1;
829
+ for (let k = 0; k < EXIT_SEARCH_STEPS; k++) {
830
+ const p = hi - g * (hi - lo);
831
+ const q = lo + g * (hi - lo);
832
+ if (f(p) <= f(q)) hi = q;
833
+ else lo = p;
834
+ }
835
+ let inside = (lo + hi) / 2;
836
+ if (f(inside) > level) continue;
837
+ let outside = 1;
838
+ for (let k = 0; k < EXIT_SEARCH_STEPS; k++) {
839
+ const mid = (inside + outside) / 2;
840
+ if (f(mid) <= level) inside = mid;
841
+ else outside = mid;
842
+ }
843
+ if (best === null || inside > best) best = inside;
844
+ }
845
+ return best;
846
+ }
847
+
848
+ /**
849
+ * [agreed, spine-parts#126] A split of `inner`–`outer` where it leaves
850
+ * `region`'s band, so that the piece to `outer` only touches the band's outer
851
+ * boundary and is exempt by `edgeIsHeldByRegion` — the one definition the
852
+ * measurement reads. The split is put half of `BAND_CONTACT_TOLERANCE` inside
853
+ * the outer boundary, then on the `r6` grid, so it lies inside the band (P16's
854
+ * checkable form) and within the tolerance of its edge. Null when there is no
855
+ * band, no point of the edge comes that near the polygon, or the point found
856
+ * is an end or fails either condition — each re-checked rather than assumed.
857
+ */
858
+ function outerSplit(inner: Pt, outer: Pt, region: RefinementRegion): Pt | null {
859
+ if (!(region.transition > 0)) return null;
860
+ const t = offsetExit(inner, outer, region.polygon, region.transition - BAND_CONTACT_TOLERANCE / 2);
861
+ if (t === null) return null;
862
+ const q: Pt = [r6(inner[0] + (outer[0] - inner[0]) * t), r6(inner[1] + (outer[1] - inner[1]) * t)];
863
+ if ((q[0] === inner[0] && q[1] === inner[1]) || (q[0] === outer[0] && q[1] === outer[1])) return null;
864
+ return inRegionOrBand(q, region) && !edgeIsHeldByRegion(q, outer, region) ? q : null;
865
+ }
866
+
800
867
  /** Add a vertex at `p` with what interpolation gives it, refusing it under a deform run (P18). */
801
868
  function addVertex(run: Run, p: Pt): number {
802
869
  const got = interpolate(p, run.input, run.boneRank, run.tally, run.who);
@@ -892,16 +959,35 @@ function refineRegions(run: Run): PhaseEnd {
892
959
  const [ra, rb] = target.worst!.at.edge!;
893
960
  const a = canon.order[ra];
894
961
  const b = canon.order[rb];
895
- // An end outside the region and its band further beyond the band than the edge's own bound: every
896
- // split keeps an edge from a vertex inside them to that end, so no insertion P16 allows can meet it.
962
+ // An end outside the region and its band: split where the edge leaves the band, so the piece to that
963
+ // end only touches the band's outer boundary and is exempt (spine-parts#126) — the same predicate the
964
+ // measurement reads decides it, so the next measurement agrees.
965
+ let exited = false;
966
+ for (const [inner, outer] of [[b, a], [a, b]] as const) {
967
+ if (inRegionOrBand(run.work.pos[outer], region)) continue;
968
+ const q = outerSplit(run.work.pos[inner], run.work.pos[outer], region);
969
+ if (q === null) continue;
970
+ splitEdge(run, a, b, q);
971
+ exited = true;
972
+ break;
973
+ }
974
+ if (exited) continue;
975
+ // An end outside the region and its band further beyond it than the edge's own bound, with no split on
976
+ // the band's outer boundary that frees the piece to it — always so with transition 0, where a contact
977
+ // with the authored boundary stays held: every split keeps a held edge from a vertex inside the region
978
+ // and its band to that end, so no insertion P16 allows can meet it.
897
979
  for (const [end, id] of [[ra, a], [rb, b]] as const) {
898
980
  const at = run.work.pos[id];
899
981
  if (inRegionOrBand(at, region)) continue;
900
982
  const beyond = distanceToBoundary(at, region.polygon) - region.transition;
901
983
  if (beyond > target.bound!.value) {
984
+ const why =
985
+ region.transition > 0
986
+ ? 'no point of the edge on the band\'s outer boundary leaves the piece to it exempt, so an edge from inside the region and its band to it stays over the bound'
987
+ : 'with no band, an edge from the region to it touches the authored boundary and stays held (spine-parts#126), so it stays over the bound';
902
988
  return {
903
989
  kind: 'stuck',
904
- constraint: `${rowName(target)} (vertex ${end} of edge ${ra}–${rb} lies ${r6(beyond)} px beyond region "${region.name}"'s ${region.transition} px band, further than the edge's bound ${target.bound!.value}: an edge from inside the region and its band to it stays over the bound, and the refinement inserts only inside them — P16)`,
990
+ constraint: `${rowName(target)} (vertex ${end} of edge ${ra}–${rb} lies ${r6(beyond)} px beyond region "${region.name}"'s ${region.transition} px band, further than the edge's bound ${target.bound!.value}: ${why}, and the refinement inserts only inside them — P16)`,
905
991
  };
906
992
  }
907
993
  }