spine-rigc 2.15.0 → 2.15.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/docs/AUTHORING.md CHANGED
@@ -2617,8 +2617,8 @@ mesh rather than a rim prefix, and `A28_RIBBON_ROWS_SHARE_WEIGHTS` **SKIPs** wit
2617
2617
  | --- | --- |
2618
2618
  | every pixel opaque | `every pixel of the 96x64 part reaches alpha 1, so its silhouette IS the part window and a contour mesh of it is a region attachment with extra vertices` |
2619
2619
  | two or more islands | `the art is 2 separate islands and one outline can only enclose the largest (529 of 989 px, 53.49%)` — one outline encloses one region; give each island its own slot |
2620
- | a **hole** (a donut) | **accepted, and the hole is inside the mesh.** Ear clipping has no bridging step, so the outline is the art's *outer* boundary; those pixels draw nothing (their alpha is still 0) and the extra triangles are the whole cost. `build` and `explain` print the hole in pixels, because `coverage` and `overshoot` are both measured against the FILLED silhouette and neither of them moves when a part gains one |
2621
- | a **diagonal pinch** — two parts of the art meeting at one pixel corner | `the alpha silhouette pinches to a single point at pixel corner (2,2) … one outline cannot pass through one point twice` |
2620
+ | a **hole** (a donut) | **accepted, and the hole is inside the mesh.** Ear clipping has no bridging step, so the outline is the art's *outer* boundary; those pixels draw nothing (their alpha is still 0) and the extra triangles are the whole cost. A hole is a transparent pixel that no path of transparent pixels — stepping through edges *or corners* — reaches from the part's border: the island is 4-connected, so a transparent pixel that meets the outside only at a corner is outside, not a hole. `build` and `explain` print the hole in pixels, because `coverage` and `overshoot` are both measured against the FILLED silhouette and neither of them moves when a part gains one |
2621
+ | a **diagonal pinch** — two parts of the art meeting at one pixel corner, with the outside on the other diagonal | `the alpha silhouette pinches to a single point at pixel corner (2,2) … one outline cannot pass through one point twice. Move the alpha threshold so the pinch opens (raise it) or closes (lower it), or author the geometry as weights` — the corner is scanned for on the **filled** silhouette, the one the outline is traced around. A corner where two holes meet, or a hole and the art, is inside that silhouette and the outline never reaches it, so it is **not** a pinch: a 4x4 block with (1,1) and (2,2) clear traces as the square, enclosing 2px of hole. A 3x3 ring with its centre and (2,2) clear is refused at (2,2), because the centre meets the outside there |
2622
2622
  | a neck narrower than `margin` | `the outline crosses itself: edge 0 meets edge 3 after a margin of 3px was pushed out of a silhouette narrower than that` |
2623
2623
  | more outline than `maxVertices` | `the silhouette simplified to 15 vertices at tolerance 1.5, past the 4 this mesh allows` — refused, never silently decimated |
2624
2624
  | a `margin` too small for the `tolerance` | `the mesh covers 91.81% of the art (2936 of 3198 px), under the 99.5% a contour mesh guarantees` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.15.0",
3
+ "version": "2.15.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
@@ -1075,7 +1075,24 @@ export function traceAlphaOutline(
1075
1075
  const inside = new Uint8Array(w * h);
1076
1076
  for (let i = 0; i < inside.length; i++) inside[i] = label[i] === biggest ? 1 : 0;
1077
1077
 
1078
- const at = (x: number, y: number): number => (x < 0 || y < 0 || x >= w || y >= h ? 0 : inside[y * w + x]);
1078
+ // The silhouette the walk traces is the island WITH ITS HOLES FILLED, and it is
1079
+ // built before the pinch scan because the scan has to read the same pixels the
1080
+ // walk does. A corner where two holes meet diagonally is inside the filled
1081
+ // silhouette, and the outer outline never visits it; scanning the island
1082
+ // before the fill refused such a part for a pinch no outline passes through
1083
+ // (issue #1209).
1084
+ //
1085
+ // ⚠️ A hole is background that no 8-connected path of background reaches from
1086
+ // the border, not merely no 4-connected one. The island is 4-connected, so two
1087
+ // background pixels that touch only at a corner are connected through it — the
1088
+ // same duality the walk's clockwise rule keeps. With a 4-connected flood a
1089
+ // transparent pixel that touches the outside only diagonally would read as a
1090
+ // hole, the fill would close the pinch at that corner, and the part would trace
1091
+ // a region the art does not enclose: that is filling a pinch, not a hole. On
1092
+ // every part this function accepts the two floods fill the same pixels — they
1093
+ // can only differ across a corner the scan below refuses.
1094
+ const { filled, holePixels } = fillEnclosed(inside, w, h, 8);
1095
+ const at = (x: number, y: number): number => (x < 0 || y < 0 || x >= w || y >= h ? 0 : filled[y * w + x]);
1079
1096
  /** Outgoing cracks at one lattice corner, as direction indices. */
1080
1097
  const outgoing = (cx: number, cy: number): number[] => {
1081
1098
  const dirs: number[] = [];
@@ -1087,12 +1104,17 @@ export function traceAlphaOutline(
1087
1104
  };
1088
1105
 
1089
1106
  // A DIAGONAL PINCH is refused before the walk, not during it. At a corner where
1090
- // two art pixels meet diagonally with background on the other diagonal, two
1091
- // cracks leave the same point — so the boundary is not a set of simple loops
1092
- // any more, and whichever pairing the walk chooses it either passes through one
1093
- // point twice or leaves part of the outline untraced. Both are silent: the
1094
- // second one produces a perfectly valid mesh of the wrong region. Scanning for
1095
- // it costs one pass and names the pixel corner.
1107
+ // two pixels of the filled silhouette meet diagonally with background on the
1108
+ // other diagonal, two cracks leave the same point — so the boundary is not a
1109
+ // set of simple loops any more, and whichever pairing the walk chooses it
1110
+ // either passes through one point twice or leaves part of the outline
1111
+ // untraced. Both are silent: the second one produces a perfectly valid mesh of
1112
+ // the wrong region. Scanning for it costs one pass and names the pixel corner.
1113
+ // Background here is what the fill left, so both pixels on that diagonal are
1114
+ // reached from outside the part: the corner is on the outer outline itself.
1115
+ // The remedy names a direction for each outcome because a threshold moves art
1116
+ // one way only: raising it removes pixels, which can open a pinch and never
1117
+ // close one, and lowering it adds them.
1096
1118
  for (let cy = 0; cy <= h; cy++) {
1097
1119
  for (let cx = 0; cx <= w; cx++) {
1098
1120
  const tl = at(cx - 1, cy - 1);
@@ -1102,16 +1124,19 @@ export function traceAlphaOutline(
1102
1124
  if ((tl && br && !tr && !bl) || (tr && bl && !tl && !br)) {
1103
1125
  throw new MeshError(
1104
1126
  `the alpha silhouette pinches to a single point at pixel corner (${cx},${cy}), where two parts of the art ` +
1105
- 'meet diagonally — one outline cannot pass through one point twice. Raise the alpha threshold so the ' +
1106
- 'pinch closes or opens, or author the geometry as weights',
1127
+ 'meet diagonally — one outline cannot pass through one point twice. Move the alpha threshold so the ' +
1128
+ 'pinch opens (raise it) or closes (lower it), or author the geometry as weights',
1107
1129
  );
1108
1130
  }
1109
1131
  }
1110
1132
  }
1111
1133
 
1134
+ // The first filled pixel in row order is an island pixel: a hole's upper
1135
+ // neighbour is never background the border reaches, so no hole sits on the
1136
+ // silhouette's top row.
1112
1137
  let startAt = -1;
1113
- for (let i = 0; i < inside.length; i++) {
1114
- if (inside[i]) {
1138
+ for (let i = 0; i < filled.length; i++) {
1139
+ if (filled[i]) {
1115
1140
  startAt = i;
1116
1141
  break;
1117
1142
  }
@@ -1119,10 +1144,9 @@ export function traceAlphaOutline(
1119
1144
  const sx = startAt % w;
1120
1145
  const sy = (startAt - sx) / w;
1121
1146
  // With no pinch anywhere, every corner has at most one outgoing crack, so the
1122
- // boundary is a disjoint union of simple loops and this walk traces exactly
1123
- // one of them. Starting on the top edge of the first art pixel in row order
1124
- // puts it on the OUTER loop; a hole's loop is never entered, which is what
1125
- // makes "holes are filled" a property of the trace rather than a later repair.
1147
+ // boundary is a simple loop and this walk traces it. The holes have no loop
1148
+ // of their own to enter, because the walk reads the filled silhouette: their
1149
+ // pixels are inside the outline and draw nothing, and `holePixels` counts them.
1126
1150
  const outline: Array<[number, number]> = [];
1127
1151
  let cx = sx;
1128
1152
  let cy = sy;
@@ -1144,7 +1168,6 @@ export function traceAlphaOutline(
1144
1168
  dir = next;
1145
1169
  }
1146
1170
 
1147
- const { filled, holePixels } = fillEnclosed(inside, w, h);
1148
1171
  return { outline, artPixels, islandPixels: sizes[biggest - 1], islands: sizes.length, holePixels, filled };
1149
1172
  }
1150
1173
 
@@ -1155,8 +1178,19 @@ export function traceAlphaOutline(
1155
1178
  * than by a winding rule — which is what makes it answer the same question for a
1156
1179
  * mask of one island and a mask of several, and is why the authored-mesh
1157
1180
  * measurement can pass it all the art where the trace passes it one island.
1181
+ *
1182
+ * `background` is how the flood steps: 4 through edges, 8 through corners too.
1183
+ * The trace floods 8 — the dual of its 4-connected island, so background that
1184
+ * meets the outside at a corner is outside — and says why where it calls this.
1185
+ * The authored-mesh measurement floods 4, as it always has; the two agree on
1186
+ * every mask the trace accepts.
1158
1187
  */
1159
- function fillEnclosed(inside: Uint8Array, w: number, h: number): { filled: Uint8Array; holePixels: number } {
1188
+ function fillEnclosed(
1189
+ inside: Uint8Array,
1190
+ w: number,
1191
+ h: number,
1192
+ background: 4 | 8,
1193
+ ): { filled: Uint8Array; holePixels: number } {
1160
1194
  const outsideReach = new Uint8Array(w * h);
1161
1195
  const queue: number[] = [];
1162
1196
  const seed = (x: number, y: number): void => {
@@ -1182,6 +1216,12 @@ function fillEnclosed(inside: Uint8Array, w: number, h: number): { filled: Uint8
1182
1216
  seed(x + 1, y);
1183
1217
  seed(x, y - 1);
1184
1218
  seed(x, y + 1);
1219
+ if (background === 8) {
1220
+ seed(x - 1, y - 1);
1221
+ seed(x + 1, y - 1);
1222
+ seed(x - 1, y + 1);
1223
+ seed(x + 1, y + 1);
1224
+ }
1185
1225
  }
1186
1226
  const filled = new Uint8Array(w * h);
1187
1227
  let holePixels = 0;
@@ -1340,7 +1380,7 @@ export function measureAuthoredMeshFit(
1340
1380
  ): MeshFitReport {
1341
1381
  const { width: w, height: h } = mask;
1342
1382
  const art = artOf(mask, threshold);
1343
- const { filled } = fillEnclosed(art, w, h);
1383
+ const { filled } = fillEnclosed(art, w, h, 4);
1344
1384
  const covered = rasteriseTriangles(points, triangles, w, h);
1345
1385
  let artPixels = 0;
1346
1386
  let coveredArt = 0;