@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.
- package/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- 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
|
+
}
|