@rig-c/parts 0.0.0-stage → 1.5.4

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Local deformation regions' weights (issue #84, step 2): a declared region
3
+ * takes a share of a vertex for its own control bone, and the rest of the
4
+ * vertex is shared among the mesh's segment bones by `influences()`
5
+ * (`src/weights.ts`) exactly as the lattice shares it.
6
+ *
7
+ * ## The falloff, as a definition
8
+ *
9
+ * A region's bone takes the weight
10
+ *
11
+ * g = 1 inside the region (its boundary included)
12
+ * g = 1 − d / band at a distance d from the region, 0 < d < band
13
+ * g = 0 at d ≥ band (for band 0: everywhere outside)
14
+ *
15
+ * where d is the Euclidean distance to the circle (|p − c| − r) or to the
16
+ * nearest point of the polygon's edges. That is the declared falloff — linear,
17
+ * no smoothstep, no tuned curve. The two decisions that are not a value in
18
+ * between — "inside" (g = 1) and "at or past the band" (g = 0) — are made
19
+ * EXACTLY: every region number is a multiple of 1/256 px (the config loader
20
+ * refuses anything else) and every vertex this package writes is too
21
+ * (`src/contour.ts`'s grid, moved by a whole-pixel offset), so both run on
22
+ * integers — squared distances compared without a square root, the polygon's
23
+ * inside by the crossing rule on integer orientations, its distance to an edge
24
+ * compared squared in `BigInt`. Only a value strictly in between is computed
25
+ * in doubles (`Math.sqrt` and the reference's segment arithmetic), and it is
26
+ * clamped to [0, 1].
27
+ *
28
+ * ## The vertex's weights (see {@link localInfluences})
29
+ *
30
+ * With `S = influences(p, segments, r, limits, exponent)` — today's, under the
31
+ * mesh's declared exponent (issue #161; 2 where none is declared). The
32
+ * cap and the floor are `limits`: `MAX_INFLUENCES` (4) and `MIN_WEIGHT`
33
+ * (0.03) for the contour mode, the author's `influences` for the automatic
34
+ * mode (issue #126, P19):
35
+ *
36
+ * - **no region reaches the vertex** (every g = 0): the weights are `S`, and
37
+ * the rig stage rounds them exactly as the lattice does — a contour mesh
38
+ * with no region is weighted as the lattice would weight the same points;
39
+ * - **g = 1**: the region's bone alone, weight 1;
40
+ * - **0 < g < 1**: the region's bone takes g and the segment bones share
41
+ * 1 − g in `S`'s proportions. A region bone that is also a segment bone
42
+ * takes g + (1 − g)·S(bone), one entry. **The cap:** when the region's bone
43
+ * is not in `S` and `S` already holds the cap's count of bones, `S`'s
44
+ * lightest bone (its last) is dropped and the rest renormalised before the
45
+ * scaling, so g itself is never cut. **The floor:** the minimum weight is
46
+ * applied inside `S` (as today) and not again to the scaled shares — a
47
+ * share (1 − g)·S(b) under 0.03 is the declared falloff near the region, not
48
+ * noise to drop, and dropping it would put a step into the linear ramp.
49
+ * The rounding: see `roundShares` in `src/rig.ts`.
50
+ *
51
+ * ## Overlapping regions
52
+ *
53
+ * Refused where it would decide a weight: a vertex that two regions both give
54
+ * g > 0 is named, with both regions and both g (`RIG_CONTOUR_REGIONS_OVERLAP`
55
+ * in the rig stage). Rejected: (a) a geometric test that two supports
56
+ * (region ∪ band) are disjoint — exact for two circles, but for a polygon the
57
+ * band's outer edge is a curve at every convex corner, and comparing it with
58
+ * another region is a sum of square roots, not decidable exactly on the grid;
59
+ * (b) a combination (a sum renormalised when it passes 1, or the larger g) —
60
+ * definable, but either rewrites the declared g = 1 inside one region wherever
61
+ * another's band reaches it. The rule as tested is what the weights need:
62
+ * each vertex holds one region's falloff. Two supports that overlap only
63
+ * between vertices are not refused, and there the mesh interpolates between
64
+ * vertices that each hold one region.
65
+ */
66
+ import type { Point } from './config.ts';
67
+ import { GRID } from './contour.ts';
68
+ import { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type InfluenceLimits, type Segment, segmentDistance } from './weights.ts';
69
+
70
+ /**
71
+ * A region as the weights read it: rig px (or any frame, as long as the point
72
+ * is in the same one), y down — the fields a contour region and an automatic
73
+ * mode's weight-and-density region (`src/automesh.ts`) both carry; a
74
+ * density-only automatic region (issue #155) has no bone and is never read here.
75
+ */
76
+ export type WeightRegion =
77
+ | { name: string; shape: 'circle'; cx: number; cy: number; r: number; band: number; bone: string }
78
+ | { name: string; shape: 'polygon'; points: Point[]; band: number; bone: string };
79
+
80
+ const units = (v: number, what: string): number => {
81
+ const u = v * GRID;
82
+ if (!Number.isInteger(u) || !Number.isSafeInteger(u * u * 4)) throw new Error(`localweights: ${what} ${v} is not a multiple of 1/${GRID} px within the exact range; the config loader refuses such a region`);
83
+ return u;
84
+ };
85
+
86
+ /** Twice the signed area of a, b, c on integer units: exact while every difference is under 2^25. */
87
+ function orient(ax: number, ay: number, bx: number, by: number, cx: number, cy: number): number {
88
+ return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
89
+ }
90
+
91
+ /** Is p on the segment a–b (exact, integer units)? */
92
+ function onSegment(px: number, py: number, ax: number, ay: number, bx: number, by: number): boolean {
93
+ if (orient(ax, ay, bx, by, px, py) !== 0) return false;
94
+ return px >= Math.min(ax, bx) && px <= Math.max(ax, bx) && py >= Math.min(ay, by) && py <= Math.max(ay, by);
95
+ }
96
+
97
+ /** Is the squared distance from p to a–b at least `r2`? Exact, integer units, `BigInt` where a product could pass 2^53. */
98
+ function atLeast(px: number, py: number, ax: number, ay: number, bx: number, by: number, r2: bigint): boolean {
99
+ const dx = bx - ax;
100
+ const dy = by - ay;
101
+ const vx = px - ax;
102
+ const vy = py - ay;
103
+ const along = BigInt(vx) * BigInt(dx) + BigInt(vy) * BigInt(dy);
104
+ const len2 = BigInt(dx) * BigInt(dx) + BigInt(dy) * BigInt(dy);
105
+ const sq = (x: number, y: number): bigint => BigInt(x) * BigInt(x) + BigInt(y) * BigInt(y);
106
+ if (along <= 0n) return sq(vx, vy) >= r2;
107
+ if (along >= len2) return sq(px - bx, py - by) >= r2;
108
+ const cross = BigInt(dx) * BigInt(vy) - BigInt(dy) * BigInt(vx);
109
+ return cross * cross >= r2 * len2;
110
+ }
111
+
112
+ /** Strictly inside the ring by the crossing rule, on integer units (a point on an edge is asked separately). */
113
+ function insideRing(px: number, py: number, xs: readonly number[], ys: readonly number[]): boolean {
114
+ let inside = false;
115
+ for (let i = 0, j = xs.length - 1; i < xs.length; j = i++) {
116
+ if (ys[j] > py === ys[i] > py) continue;
117
+ const o = orient(xs[j], ys[j], xs[i], ys[i], px, py);
118
+ if (ys[i] > ys[j] ? o > 0 : o < 0) inside = !inside;
119
+ }
120
+ return inside;
121
+ }
122
+
123
+ /**
124
+ * The declared falloff: the weight a region's bone takes at `p` (same frame as
125
+ * the region). `p` must be on the 1/256 px grid; see the module header for
126
+ * which decisions are exact.
127
+ */
128
+ export function regionWeight(p: Point, reg: WeightRegion): number {
129
+ const px = units(p[0], 'point x');
130
+ const py = units(p[1], 'point y');
131
+ const B = units(reg.band, 'band');
132
+ if (reg.shape === 'circle') {
133
+ const dx = px - units(reg.cx, 'cx');
134
+ const dy = py - units(reg.cy, 'cy');
135
+ const R = units(reg.r, 'r');
136
+ const d2 = BigInt(dx) * BigInt(dx) + BigInt(dy) * BigInt(dy);
137
+ if (d2 <= BigInt(R) * BigInt(R)) return 1;
138
+ if (B === 0 || d2 >= BigInt(R + B) * BigInt(R + B)) return 0;
139
+ const d = Math.sqrt(Number(d2)) / GRID - reg.r;
140
+ return Math.min(1, Math.max(0, 1 - d / reg.band));
141
+ }
142
+ const xs = reg.points.map((q) => units(q[0], 'polygon x'));
143
+ const ys = reg.points.map((q) => units(q[1], 'polygon y'));
144
+ const n = xs.length;
145
+ for (let i = 0; i < n; i++) if (onSegment(px, py, xs[i], ys[i], xs[(i + 1) % n], ys[(i + 1) % n])) return 1;
146
+ if (insideRing(px, py, xs, ys)) return 1;
147
+ if (B === 0) return 0;
148
+ const B2 = BigInt(B) * BigInt(B);
149
+ let far = true;
150
+ for (let i = 0; i < n && far; i++) far = atLeast(px, py, xs[i], ys[i], xs[(i + 1) % n], ys[(i + 1) % n], B2);
151
+ if (far) return 0;
152
+ let d = Infinity;
153
+ for (let i = 0; i < n; i++) d = Math.min(d, segmentDistance(p, reg.points[i], reg.points[(i + 1) % n]));
154
+ return Math.min(1, Math.max(0, 1 - d / reg.band));
155
+ }
156
+
157
+ /** A vertex that two regions both reach: the two, by index in the mesh's list, with each one's g. */
158
+ export interface RegionOverlap {
159
+ first: number;
160
+ second: number;
161
+ g1: number;
162
+ g2: number;
163
+ }
164
+
165
+ export interface LocalInfluence {
166
+ /** Unrounded; they sum to 1 up to the doubles' rounding. Order: see {@link localInfluences}. */
167
+ influences: Influence[];
168
+ /** The region that reached the vertex (g > 0), by index, or -1. */
169
+ region: number;
170
+ g: number;
171
+ }
172
+
173
+ /**
174
+ * A vertex's weights under the mesh's regions (module header). `shares`, when
175
+ * given, is `S` from another rule — bone heat's shares at the vertex
176
+ * (`src/heat.ts`, issue #161), already capped and floored by `limits` — and
177
+ * the regions apply to it exactly as to the distance rule's. Returns the
178
+ * overlap instead when two regions both reach the vertex. Order: with no
179
+ * region, `S`'s own order; with a region, the region's bone first, then `S`'s
180
+ * bones in `S`'s order.
181
+ */
182
+ export function localInfluences(
183
+ p: Point,
184
+ segments: readonly Segment[],
185
+ r: number,
186
+ regions: readonly WeightRegion[],
187
+ limits: InfluenceLimits = DEFAULT_LIMITS,
188
+ exponent: number = DISTANCE_EXPONENT,
189
+ shares?: Influence[],
190
+ ): LocalInfluence | RegionOverlap {
191
+ let region = -1;
192
+ let g = 0;
193
+ for (let k = 0; k < regions.length; k++) {
194
+ const gk = regionWeight(p, regions[k]);
195
+ if (gk <= 0) continue;
196
+ if (region >= 0) return { first: region, second: k, g1: g, g2: gk };
197
+ region = k;
198
+ g = gk;
199
+ }
200
+ const S = shares ?? influences(p, segments, r, limits, exponent);
201
+ if (region < 0) return { influences: S, region, g: 0 };
202
+ const bone = regions[region].bone;
203
+ if (g >= 1) return { influences: [{ bone, weight: 1 }], region, g };
204
+ let base = S;
205
+ if (!S.some((e) => e.bone === bone) && S.length >= limits.maxInfluences) {
206
+ const kept = S.slice(0, limits.maxInfluences - 1);
207
+ let s = 0;
208
+ for (const e of kept) s += e.weight;
209
+ base = kept.map((e) => ({ bone: e.bone, weight: e.weight / s }));
210
+ }
211
+ const out: Influence[] = [{ bone, weight: g }];
212
+ for (const e of base) {
213
+ if (e.bone === bone) out[0] = { bone, weight: out[0].weight + (1 - g) * e.weight };
214
+ else out.push({ bone: e.bone, weight: (1 - g) * e.weight });
215
+ }
216
+ return { influences: out, region, g };
217
+ }
package/src/mesh.ts ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * The lattice mesh: one square grid over a part, every cell that holds an art
3
+ * pixel kept as two triangles, so the mesh covers 100 % of the art by
4
+ * construction.
5
+ *
6
+ * Ported from the reference implementation's `build_mesh` / `one_loop`. What
7
+ * it does, in order:
8
+ *
9
+ * 1. **Cells.** A grid of `grid`-pixel squares over the (padded) part image;
10
+ * the last column and row are clipped to the image. A cell is kept when any
11
+ * pixel inside it has alpha above {@link ART_ALPHA}.
12
+ * 2. **One loop.** rig-c refuses a mesh whose triangles' outline is not
13
+ * one closed loop, and a See-through layer is often two islands (a hairpin
14
+ * head and its tassel) or has a hole. So the kept cells are made one
15
+ * simply-connected region: holes filled (`scipy.ndimage.binary_fill_holes`,
16
+ * `fillHoles` here), every other 4-connected island bridged to the largest
17
+ * by a straight run of cells, and every diagonal pinch filled. The extra
18
+ * cells hold no art and draw nothing. The reference stopped after
19
+ * {@link ONE_LOOP_PASSES} passes and returned whatever it had; this port
20
+ * then CHECKS the outline and refuses a lattice that is still not one loop
21
+ * (`RIG_LATTICE_ONE_LOOP`), rather than hand rigc a mesh it would refuse
22
+ * with less to say about why.
23
+ * 3. **Triangles.** Vertices are numbered in the order the kept cells first
24
+ * touch them (row-major cells, corners top-left, top-right, bottom-right,
25
+ * bottom-left); each cell is two triangles with the diagonal alternating by
26
+ * `(i + j) % 2` so the lattice has no preferred shear. Each triangle is
27
+ * built clockwise on screen (top-left, top-right, bottom-right), the
28
+ * reference's order, and written with its last two corners swapped
29
+ * ({@link counterClockwiseInSpineWorld}): the y flip to Spine's world does
30
+ * not turn a loop over, so a triangle clockwise on screen is clockwise in
31
+ * Spine world, and the winding rig-c reads a mesh in is
32
+ * counter-clockwise there (issue #126; rigc#1236). The swap is made after
33
+ * the boundary walk below, so no vertex index and no hull moves with it.
34
+ * 4. **Boundary first.** Spine's `hull` is a count: the first `hull` vertices
35
+ * are the outline, in order. The outline is walked from the edges used by
36
+ * exactly one triangle, then every interior vertex follows in index order.
37
+ * 5. **Coverage.** Every triangle is rasterised (`cv2.fillPoly`, `fillPoly`
38
+ * here) and the fraction of art pixels it covers is reported. By
39
+ * construction it is 1; it is measured anyway, because a claim that holds
40
+ * by construction is exactly the kind nobody re-reads.
41
+ *
42
+ * Every tie and every scan order above is the reference's, because each one
43
+ * decides a vertex index, and a vertex index is what a weight is written
44
+ * against.
45
+ */
46
+ import { type Problem } from './errors.ts';
47
+ import { connectedComponents, fillHoles, fillPoly, type Mask, newMask } from './raster/index.ts';
48
+ import { rint } from './round.ts';
49
+
50
+ /** A pixel counts as art when its alpha is above this — the reference's `im[..., 3] > 8`. */
51
+ export const ART_ALPHA = 8;
52
+
53
+ /** How many hole-fill / bridge / pinch passes `oneLoop` makes before it stops — the reference's `range(50)`. */
54
+ export const ONE_LOOP_PASSES = 50;
55
+
56
+ export interface Lattice {
57
+ /** Cells across and down. */
58
+ nx: number;
59
+ ny: number;
60
+ /** Column and row edges in image pixels, `nx + 1` and `ny + 1` of them, the last clipped to the image. */
61
+ xs: number[];
62
+ ys: number[];
63
+ /** `ny` rows of `nx` cells, 1 = kept. */
64
+ cells: Mask;
65
+ }
66
+
67
+ /** The grid over an image and which of its cells hold an art pixel. */
68
+ export function latticeCells(art: Mask, grid: number): Lattice {
69
+ const { width: w, height: h } = art;
70
+ const nx = Math.ceil(w / grid);
71
+ const ny = Math.ceil(h / grid);
72
+ const xs = Array.from({ length: nx + 1 }, (_, i) => Math.min(i * grid, w));
73
+ const ys = Array.from({ length: ny + 1 }, (_, j) => Math.min(j * grid, h));
74
+ const cells = newMask(nx, ny);
75
+ for (let j = 0; j < ny; j++) {
76
+ for (let i = 0; i < nx; i++) {
77
+ let any = 0;
78
+ for (let y = ys[j]; y < ys[j + 1] && any === 0; y++) {
79
+ for (let x = xs[i]; x < xs[i + 1]; x++) {
80
+ if (art.data[y * w + x] !== 0) {
81
+ any = 1;
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ cells.data[j * nx + i] = any;
87
+ }
88
+ }
89
+ return { nx, ny, xs, ys, cells };
90
+ }
91
+
92
+ /** `np.linspace(0, 1, num)` element by element: `k * (1 / (num - 1))`, the last exactly 1. */
93
+ function linspace01(num: number): number[] {
94
+ const step = 1 / (num - 1);
95
+ return Array.from({ length: num }, (_, k) => (k === num - 1 ? 1 : k * step));
96
+ }
97
+
98
+ export interface OneLoopResult {
99
+ cells: Mask;
100
+ /** Passes made; a pass that changes nothing ends the loop. */
101
+ passes: number;
102
+ /** Whether the last pass changed nothing — false when the pass limit ran out first. */
103
+ settled: boolean;
104
+ }
105
+
106
+ /**
107
+ * The reference's `one_loop`: fill holes, bridge islands to the largest,
108
+ * fill diagonal pinches, repeat until a pass changes nothing or `maxPasses`
109
+ * run out.
110
+ *
111
+ * A bridge from island cell (j0, i0) to main cell (j1, i1) — the pair at the
112
+ * least squared distance, first in row-major order of island then main —
113
+ * sets, at every `t` of `linspace(0, 1, 2 * (|dj| + |di|) + 2)`, the cells
114
+ * (round(j0 + dj t), i0), (j1, round(i0 + di t)) and (round(j0 + dj t),
115
+ * round(i0 + di t)), with NumPy's half-to-even rounding. The first two are an
116
+ * L-shaped 4-connected run, so the bridge always connects.
117
+ */
118
+ export function oneLoop(start: Mask, maxPasses: number = ONE_LOOP_PASSES): OneLoopResult {
119
+ const nx = start.width;
120
+ const ny = start.height;
121
+ let cells: Mask = { width: nx, height: ny, data: new Uint8Array(start.data) };
122
+ for (let pass = 0; pass < maxPasses; pass++) {
123
+ cells = fillHoles(cells);
124
+ const comp = connectedComponents(cells, 4);
125
+ const islands = comp.count - 1;
126
+ if (islands > 1) {
127
+ let main = 1;
128
+ for (let k = 2; k < comp.count; k++) if (comp.stats[k].area > comp.stats[main].area) main = k;
129
+ const members = (label: number): Array<[number, number]> => {
130
+ const out: Array<[number, number]> = [];
131
+ for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) if (comp.labels[j * nx + i] === label) out.push([j, i]);
132
+ return out;
133
+ };
134
+ const mainCells = members(main);
135
+ for (let k = 1; k < comp.count; k++) {
136
+ if (k === main) continue;
137
+ const other = members(k);
138
+ let best = Infinity;
139
+ let a = 0;
140
+ let b = 0;
141
+ for (let p = 0; p < other.length; p++) {
142
+ for (let q = 0; q < mainCells.length; q++) {
143
+ const dj = other[p][0] - mainCells[q][0];
144
+ const di = other[p][1] - mainCells[q][1];
145
+ const d = dj * dj + di * di;
146
+ if (d < best) {
147
+ best = d;
148
+ a = p;
149
+ b = q;
150
+ }
151
+ }
152
+ }
153
+ const [j0, i0] = other[a];
154
+ const [j1, i1] = mainCells[b];
155
+ const set = (j: number, i: number): void => {
156
+ cells.data[j * nx + i] = 1;
157
+ };
158
+ for (const t of linspace01(2 * (Math.abs(j1 - j0) + Math.abs(i1 - i0)) + 2)) {
159
+ set(rint(j0 + (j1 - j0) * t), i0);
160
+ set(j1, rint(i0 + (i1 - i0) * t));
161
+ set(rint(j0 + (j1 - j0) * t), rint(i0 + (i1 - i0) * t));
162
+ }
163
+ }
164
+ continue;
165
+ }
166
+ const snap = new Uint8Array(cells.data);
167
+ const at = (j: number, i: number): number => snap[j * nx + i];
168
+ let pinch = false;
169
+ for (let j = 0; j < ny - 1; j++) {
170
+ for (let i = 0; i < nx - 1; i++) {
171
+ if (at(j, i) && at(j + 1, i + 1) && !at(j, i + 1) && !at(j + 1, i)) {
172
+ cells.data[j * nx + i + 1] = 1;
173
+ pinch = true;
174
+ } else if (at(j, i + 1) && at(j + 1, i) && !at(j, i) && !at(j + 1, i + 1)) {
175
+ cells.data[j * nx + i] = 1;
176
+ pinch = true;
177
+ }
178
+ }
179
+ }
180
+ if (!pinch) return { cells, passes: pass + 1, settled: true };
181
+ }
182
+ return { cells, passes: maxPasses, settled: false };
183
+ }
184
+
185
+ /**
186
+ * Each triple `[a, b, c]` written `[a, c, b]`: the same triangles over the same
187
+ * vertices, wound the other way round. A triangle list built clockwise on
188
+ * screen (positive area in crop pixels, y down) is clockwise in Spine world as
189
+ * well — flipping y changes the sign of a signed area, but Spine's world is
190
+ * drawn with y up, so the loop turns the same way on screen as there — and
191
+ * rig-c reads every mesh as counter-clockwise in Spine world (its
192
+ * `MQ_ORIENTATION`; rigc#1236 turned its own `contour` and `ring` generators
193
+ * for the same reason). The lattice ({@link triangulate}) and the contour mesh
194
+ * (`src/contour.ts`) both build clockwise on screen and write through this,
195
+ * once, at their output; nothing downstream turns them again (issue #126).
196
+ */
197
+ export function counterClockwiseInSpineWorld(triangles: readonly number[]): number[] {
198
+ const out: number[] = [];
199
+ for (let t = 0; t < triangles.length; t += 3) out.push(triangles[t], triangles[t + 2], triangles[t + 1]);
200
+ return out;
201
+ }
202
+
203
+ export interface LatticeMesh {
204
+ /** Vertex positions in image pixels, boundary first. */
205
+ vertices: Array<[number, number]>;
206
+ /** Three vertex indices per triangle, into `vertices`, each counter-clockwise in Spine world. */
207
+ triangles: number[];
208
+ /** The first `hull` vertices are the outline. */
209
+ hull: number;
210
+ /** Closed outlines the triangles make — 1 for a mesh rigc will take. */
211
+ loops: number;
212
+ /** Boundary vertices touched by more or fewer than two boundary edges — 0 for a mesh rigc will take. */
213
+ pinchedVertices: number;
214
+ cells: number;
215
+ passes: number;
216
+ }
217
+
218
+ /**
219
+ * Triangles over the kept cells, boundary vertices first. Returns the loop
220
+ * count and the pinched-vertex count rather than refusing, so the caller names
221
+ * the part in its refusal.
222
+ */
223
+ export function triangulate(lattice: Lattice, cells: Mask, passes: number): LatticeMesh {
224
+ const { nx, ny, xs, ys } = lattice;
225
+ const vid = new Int32Array((nx + 1) * (ny + 1)).fill(-1);
226
+ const pts: Array<[number, number]> = [];
227
+ const tri: number[] = [];
228
+ let kept = 0;
229
+ for (let j = 0; j < ny; j++) {
230
+ for (let i = 0; i < nx; i++) {
231
+ if (cells.data[j * nx + i] === 0) continue;
232
+ kept++;
233
+ const c: number[] = [];
234
+ for (const [jj, ii] of [
235
+ [j, i],
236
+ [j, i + 1],
237
+ [j + 1, i + 1],
238
+ [j + 1, i],
239
+ ]) {
240
+ const k = jj * (nx + 1) + ii;
241
+ if (vid[k] < 0) {
242
+ vid[k] = pts.length;
243
+ pts.push([xs[ii], ys[jj]]);
244
+ }
245
+ c.push(vid[k]);
246
+ }
247
+ if ((i + j) % 2 === 0) tri.push(c[0], c[1], c[2], c[0], c[2], c[3]);
248
+ else tri.push(c[0], c[1], c[3], c[1], c[2], c[3]);
249
+ }
250
+ }
251
+ // Boundary edges: used by exactly one triangle, counted in first-seen order
252
+ // (the reference's Counter), then adjacency in that same order.
253
+ const edgeCount = new Map<string, { a: number; b: number; n: number }>();
254
+ for (let t = 0; t < tri.length; t += 3) {
255
+ for (const [p, q] of [
256
+ [tri[t], tri[t + 1]],
257
+ [tri[t + 1], tri[t + 2]],
258
+ [tri[t + 2], tri[t]],
259
+ ]) {
260
+ const a = Math.min(p, q);
261
+ const b = Math.max(p, q);
262
+ const key = `${a},${b}`;
263
+ const e = edgeCount.get(key);
264
+ if (e === undefined) edgeCount.set(key, { a, b, n: 1 });
265
+ else e.n++;
266
+ }
267
+ }
268
+ const adj = new Map<number, number[]>();
269
+ const link = (p: number, q: number): void => {
270
+ const list = adj.get(p);
271
+ if (list === undefined) adj.set(p, [q]);
272
+ else list.push(q);
273
+ };
274
+ for (const { a, b, n } of edgeCount.values()) {
275
+ if (n !== 1) continue;
276
+ link(a, b);
277
+ link(b, a);
278
+ }
279
+ const order: number[] = [];
280
+ const seen = new Set<number>();
281
+ let loops = 0;
282
+ for (const s of adj.keys()) {
283
+ if (seen.has(s)) continue;
284
+ loops++;
285
+ let cur = s;
286
+ let prev = -1;
287
+ while (!seen.has(cur)) {
288
+ seen.add(cur);
289
+ order.push(cur);
290
+ const next = (adj.get(cur) ?? []).filter((v) => v !== prev && !seen.has(v));
291
+ if (next.length === 0) break;
292
+ prev = cur;
293
+ cur = next[0];
294
+ }
295
+ }
296
+ let pinchedVertices = 0;
297
+ for (const list of adj.values()) if (list.length !== 2) pinchedVertices++;
298
+ const hull = order.length;
299
+ for (let v = 0; v < pts.length; v++) if (!seen.has(v)) order.push(v);
300
+ const remap = new Int32Array(pts.length);
301
+ order.forEach((v, i) => (remap[v] = i));
302
+ return {
303
+ vertices: order.map((v) => pts[v]),
304
+ triangles: counterClockwiseInSpineWorld(tri.map((v) => remap[v])),
305
+ hull,
306
+ loops,
307
+ pinchedVertices,
308
+ cells: kept,
309
+ passes,
310
+ };
311
+ }
312
+
313
+ /** The fraction of art pixels the triangles cover, rasterised as `cv2.fillPoly` would. */
314
+ export function artCoverage(mesh: LatticeMesh, art: Mask): number {
315
+ const ras = newMask(art.width, art.height);
316
+ for (let t = 0; t < mesh.triangles.length; t += 3) {
317
+ const corner = (k: number): [number, number] => {
318
+ const [x, y] = mesh.vertices[mesh.triangles[t + k]];
319
+ return [rint(x), rint(y)];
320
+ };
321
+ fillPoly(ras, [corner(0), corner(1), corner(2)]);
322
+ }
323
+ let artPx = 0;
324
+ let covered = 0;
325
+ for (let i = 0; i < art.data.length; i++) {
326
+ if (art.data[i] === 0) continue;
327
+ artPx++;
328
+ if (ras.data[i] !== 0) covered++;
329
+ }
330
+ return artPx === 0 ? 0 : covered / artPx;
331
+ }
332
+
333
+ /**
334
+ * The whole lattice step for one part: cells, one loop, triangles. Refusals
335
+ * name the part: a part with no art pixel has no cell to keep, and a lattice
336
+ * still not one closed outline after the passes is refused rather than handed on.
337
+ */
338
+ export function latticeMesh(part: string, art: Mask, grid: number, maxPasses: number = ONE_LOOP_PASSES): LatticeMesh | Problem {
339
+ const lattice = latticeCells(art, grid);
340
+ let any = false;
341
+ for (let i = 0; i < lattice.cells.data.length && !any; i++) any = lattice.cells.data[i] !== 0;
342
+ if (!any) {
343
+ return {
344
+ code: 'RIG_PART_HAS_ART',
345
+ object: `mesh "${part}"`,
346
+ detail: `its ${art.width}x${art.height} image has no pixel with alpha above ${ART_ALPHA}, so the lattice keeps no cell; a mesh needs at least one art pixel`,
347
+ };
348
+ }
349
+ const loop = oneLoop(lattice.cells, maxPasses);
350
+ const mesh = triangulate(lattice, loop.cells, loop.passes);
351
+ if (mesh.loops !== 1 || mesh.pinchedVertices !== 0) {
352
+ return {
353
+ code: 'RIG_LATTICE_ONE_LOOP',
354
+ object: `mesh "${part}"`,
355
+ detail:
356
+ `after ${loop.passes} pass(es) of hole-fill, island-join and pinch-fill (${loop.settled ? 'settled' : `the limit of ${maxPasses} ran out`}) ` +
357
+ `its ${lattice.nx}x${lattice.ny} lattice at grid ${grid} has ${mesh.loops} outline loop(s) and ${mesh.pinchedVertices} pinched boundary vertex(es); ` +
358
+ 'exactly one closed loop and none pinched are required (rig-c refuses any other outline)',
359
+ };
360
+ }
361
+ return mesh;
362
+ }