@rig-c/parts 1.23.0 → 1.25.0
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/cli.ts +2 -1
- package/docs/AUTHORING.md +48 -5
- package/docs/STABILITY.md +5 -3
- package/package.json +2 -2
- package/src/assemble.ts +25 -6
- package/src/automesh.ts +2 -1
- package/src/check.ts +184 -33
- package/src/cloth.ts +72 -3
- package/src/config.ts +107 -6
- package/src/instruments.ts +32 -0
- package/src/limbs.ts +171 -0
- package/src/propose.ts +269 -32
- package/src/rig.ts +112 -12
- package/src/squeeze.ts +161 -1
package/src/propose.ts
CHANGED
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
* hip would sit above `HIP_MIN_FRACTION` of the figure's height (a long
|
|
22
22
|
* robe tagged `bottomwear` starts at the collar): then `hip` is the waist,
|
|
23
23
|
* the narrowest torso row between the shoulder line and the figure's
|
|
24
|
-
* middle, and 0.32 of the figure height when no row narrows enough.
|
|
24
|
+
* middle, and 0.32 of the figure height when no row narrows enough. With
|
|
25
|
+
* `cloth.bottomwear.part` naming a dress (issue #402), a `bottomwear` part
|
|
26
|
+
* lying on the dress's tree links rides them (issue #416), by the rule a
|
|
27
|
+
* layer on a tail does (below), and gets no trees of its own.
|
|
25
28
|
* - `topwear` -> two outer-robe chains, only when it hangs below the hip line;
|
|
26
29
|
* a side whose link falls off the art is dropped.
|
|
27
30
|
* - `handwear-*` -> two blobs: one sleeve chain per blob, its mesh taking that
|
|
@@ -43,7 +46,12 @@
|
|
|
43
46
|
* layer with hanging strands ({@link Strand}) gets one pendulum chain per
|
|
44
47
|
* strand instead of the single pendant chain, and one note naming every
|
|
45
48
|
* strand and what became of it — a strand left without a chain is never
|
|
46
|
-
* silent (issue #24).
|
|
49
|
+
* silent (issue #24). Under the `back hair` cloth recipe (issue #391) a
|
|
50
|
+
* layer of either tag lying on a drafted tail rides the links it overlaps
|
|
51
|
+
* instead: its pixels over the tail's art are each given to the nearest span
|
|
52
|
+
* of the back hair's mesh (`tailOverlap`, `src/cloth.ts`), and when one is a
|
|
53
|
+
* link the part's mesh takes those spans as its segments and no rule above
|
|
54
|
+
* runs for it; a note names each span with its pixel count.
|
|
47
55
|
* - anything no rule claims rides its nearest trunk bone as a region —
|
|
48
56
|
* including every part from an author-made layer (`<run>:author:<label>`,
|
|
49
57
|
* issue #256), which is no See-through tag and so has no role: no rule
|
|
@@ -138,8 +146,9 @@ import { configRoles, type RoleOf } from './structure.ts';
|
|
|
138
146
|
import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
|
|
139
147
|
import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
|
|
140
148
|
import { edgeLength } from './length.ts';
|
|
141
|
-
import {
|
|
142
|
-
import {
|
|
149
|
+
import { limbWidth } from './limbs.ts';
|
|
150
|
+
import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
|
|
151
|
+
import { RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
|
|
143
152
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
144
153
|
|
|
145
154
|
// ---------------------------------------------------------------------------
|
|
@@ -1364,6 +1373,8 @@ export function proposeWithBasis(
|
|
|
1364
1373
|
const clothConstraints: ConfigConstraint[] = [];
|
|
1365
1374
|
const clothUsed = new Set<ClothTarget>();
|
|
1366
1375
|
const recipeOf = (tag: ClothTarget): ClothRecipe | null => cloth?.[tag] ?? null;
|
|
1376
|
+
// Issue #391: the tails the back hair recipe drafts, each with its mesh's spans, which an accessory wrapped round one rides.
|
|
1377
|
+
const tails: Tail[] = [];
|
|
1367
1378
|
const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
|
|
1368
1379
|
const trees = draftTrees(input, r);
|
|
1369
1380
|
const constants = [konst('cloth_chains', r.chains), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
|
|
@@ -1547,7 +1558,14 @@ export function proposeWithBasis(
|
|
|
1547
1558
|
all.push(...trees);
|
|
1548
1559
|
}
|
|
1549
1560
|
notes.push(clothNote(p.name, 'back hair', all, rcp, n, ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'));
|
|
1550
|
-
meshes[p.name] = { grid: g, r, segments: segs };
|
|
1561
|
+
meshes[p.name] = { grid: g, r: rcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
1562
|
+
// Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
|
|
1563
|
+
const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
|
|
1564
|
+
for (const t of all) {
|
|
1565
|
+
const line = [...t.points, t.tip];
|
|
1566
|
+
t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true }));
|
|
1567
|
+
}
|
|
1568
|
+
tails.push({ part: p.name, mask, spans });
|
|
1551
1569
|
} else {
|
|
1552
1570
|
const mask = P.alpha(p);
|
|
1553
1571
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
@@ -1714,7 +1732,8 @@ export function proposeWithBasis(
|
|
|
1714
1732
|
tracks.push({ chain: name, amps: [0.8, 1.6, 2.6, 3.6], period: 4.0, phase: 0.16, lag: 0.07 });
|
|
1715
1733
|
k++;
|
|
1716
1734
|
}
|
|
1717
|
-
|
|
1735
|
+
const frontRcp = recipeOf('front hair');
|
|
1736
|
+
meshes[p.name] = { grid: g, r: frontRcp?.split?.hold_tip === true && k > 0 ? Math.max(r, g) : r, segments: segs };
|
|
1718
1737
|
}
|
|
1719
1738
|
|
|
1720
1739
|
// ---- accessories: rigid body + pendant chain where the part narrows, or one pendulum chain per hanging strand
|
|
@@ -1726,7 +1745,39 @@ export function proposeWithBasis(
|
|
|
1726
1745
|
// gets the bone and chain, the rest ride the head rigidly. A stable sort,
|
|
1727
1746
|
// as Python's `sorted` is.
|
|
1728
1747
|
const cands = [...P.byTag(tag)].sort((a, b) => b.opaque_px - a.opaque_px);
|
|
1748
|
+
// Issue #391: a layer of the tag lying on a tail the back hair recipe drafted rides the links it overlaps — the
|
|
1749
|
+
// spans of the tail its pixels over the tail's art fall nearest — and not head: its mesh names those bones and no
|
|
1750
|
+
// others, so every vertex weights by the rule the tail's own art does. The links are read off the overlap, never
|
|
1751
|
+
// off the part's name. A layer over no link keeps its rule below. Only with the recipe: without it no tail moves
|
|
1752
|
+
// by the world, and nothing here runs.
|
|
1753
|
+
const rides = (q: PartRecord): boolean => {
|
|
1754
|
+
const segments: Segment[] = [];
|
|
1755
|
+
const links: string[] = [];
|
|
1756
|
+
const said: string[] = [];
|
|
1757
|
+
for (const tail of tails) {
|
|
1758
|
+
const o = tailOverlap(tail, P.alpha(q), q);
|
|
1759
|
+
if (!tail.spans.some((sp, k) => sp.link && o.counts[k] > 0)) continue;
|
|
1760
|
+
const on: string[] = [];
|
|
1761
|
+
tail.spans.forEach((sp, k) => {
|
|
1762
|
+
if (o.counts[k] === 0) return;
|
|
1763
|
+
on.push(`${sp.link ? sp.bone : `${sp.bone}'s fixed segment`} ${o.counts[k]}`);
|
|
1764
|
+
// A link is named, so its segment is the link as the bones declare it; the head's span is written out, as the tail's mesh writes it.
|
|
1765
|
+
segments.push(sp.link ? sp.bone : [sp.bone, sp.a, sp.b]);
|
|
1766
|
+
if (sp.link) links.push(sp.bone);
|
|
1767
|
+
});
|
|
1768
|
+
said.push(`${o.over} of its ${o.total} px lie over ${tail.part}'s art, nearest ${tail.part}'s spans ${on.join(', ')} px`);
|
|
1769
|
+
}
|
|
1770
|
+
if (links.length === 0) return false;
|
|
1771
|
+
const [g, r] = gridR(q);
|
|
1772
|
+
delete regions[q.name];
|
|
1773
|
+
meshes[q.name] = { grid: g, r, segments };
|
|
1774
|
+
notes.push(
|
|
1775
|
+
`${q.name} (${q.from}): wraps a tail config.cloth."back hair" drafts — ${said.join('; ')} — so its mesh's segments are those spans and it rides the links ${links.join(', ')} with the tail, not head; no ${tag} bone, pendant or strand chain is drafted for it (issue #391)`,
|
|
1776
|
+
);
|
|
1777
|
+
return true;
|
|
1778
|
+
};
|
|
1729
1779
|
for (const q of cands.slice(1)) {
|
|
1780
|
+
if (rides(q)) continue;
|
|
1730
1781
|
regions[q.name] = 'head';
|
|
1731
1782
|
notes.push(`${q.name} (${q.from}): second ${tag} layer, rigid on head`);
|
|
1732
1783
|
// A region cannot swing, so strands found on it are only said.
|
|
@@ -1735,6 +1786,7 @@ export function proposeWithBasis(
|
|
|
1735
1786
|
if (strands.length > 0) notes.push(strandNote(q, strands, [], strands, 'it rides the head as a region'));
|
|
1736
1787
|
}
|
|
1737
1788
|
for (const p of cands.slice(0, 1)) {
|
|
1789
|
+
if (rides(p)) continue;
|
|
1738
1790
|
const [g, r] = gridR(p);
|
|
1739
1791
|
const fullMask = dilate(P.alpha(p), 7);
|
|
1740
1792
|
const onArt = (pts: ReadonlyArray<readonly [number, number]>): boolean => pts.every((q) => isOn(fullMask, q[0], q[1]));
|
|
@@ -2157,12 +2209,8 @@ export function proposeWithBasis(
|
|
|
2157
2209
|
}
|
|
2158
2210
|
|
|
2159
2211
|
// ---- skirt (bottomwear, and the topwear part config.cloth.bottomwear.part names: issue #402)
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
const [g, r] = gridR(p);
|
|
2163
|
-
const mask = P.alpha(p);
|
|
2164
|
-
const bot = p.y + p.h;
|
|
2165
|
-
const y0 = hip[1] + 20;
|
|
2212
|
+
// The fixed segments of a skirt's mesh, and the note when it hangs from the waist (pushed by the drafting loop only).
|
|
2213
|
+
const skirtFrame = (p: PartRecord, mask: Mask): { segs: Segment[]; hangs: string | null } => {
|
|
2166
2214
|
let segs: Segment[] = [
|
|
2167
2215
|
['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
|
|
2168
2216
|
['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
|
|
@@ -2184,26 +2232,67 @@ export function proposeWithBasis(
|
|
|
2184
2232
|
const down: readonly [number, number] = trunk === null ? [0, 1] : trunk.u;
|
|
2185
2233
|
const artTop = artExtent(mask, p, hip, down)[0];
|
|
2186
2234
|
const chestAt = (chest[0] - hip[0]) * down[0] + (chest[1] - hip[1]) * down[1];
|
|
2187
|
-
if (artTop
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2235
|
+
if (artTop < chestAt) return { segs, hangs: null };
|
|
2236
|
+
return {
|
|
2237
|
+
segs: segs.filter((s) => typeof s === 'string' || s[0] !== 'chest'),
|
|
2238
|
+
hangs: `${p.name} (${p.from}): its art starts ${pyFixed(artTop - chestAt, 1)} px below chest's origin, so it hangs from the waist — its mesh takes the hip line and its skirt chains, no chest segment, and the breath does not carry its root`,
|
|
2239
|
+
};
|
|
2240
|
+
};
|
|
2241
|
+
// Where the recipe's trees hang on a skirt: from 20 px below the hip to 4 px above the part's bottom, across its art.
|
|
2242
|
+
const skirtInput = (p: PartRecord, mask: Mask, prefix: string): TreeInput => ({
|
|
2243
|
+
prefix,
|
|
2244
|
+
parent: 'hip',
|
|
2245
|
+
rootY: hip[1] + 20,
|
|
2246
|
+
tipY: p.y + p.h - 4,
|
|
2247
|
+
xAt: (y, f) => bandX(mask, y, { frac: f }),
|
|
2248
|
+
fallbackX: (f) => p.x + f * p.w,
|
|
2249
|
+
frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
|
|
2250
|
+
edges: 'both',
|
|
2251
|
+
});
|
|
2252
|
+
// Issue #416: a bottomwear part lying on the trees of the dress config.cloth.bottomwear.part names (a ruffle hem
|
|
2253
|
+
// See-through returned on its own) rides the links it overlaps, as a layer wrapped round a tail does (issue #391),
|
|
2254
|
+
// and gets no trees of its own: one skirt carries one set of trees. The dress's mesh is the tail — its fixed
|
|
2255
|
+
// segments and every tree link, in segment order — and the links are read off the overlap, never off the part's
|
|
2256
|
+
// name. Which parts ride is read here, before any bone is named, off the trees as the recipe places them (a tree's
|
|
2257
|
+
// points do not depend on its prefix); a part over no link keeps its own trees below. Only with the field.
|
|
2258
|
+
const dressTail = (p: PartRecord, segs: readonly Segment[], trees: readonly TreeChain[]): Tail => {
|
|
2259
|
+
const spans: TailSpan[] = [];
|
|
2260
|
+
for (const s of segs) if (typeof s !== 'string') spans.push({ bone: s[0], a: s[1], b: s[2], link: false });
|
|
2261
|
+
for (const t of trees) {
|
|
2262
|
+
const line = [...t.points, t.tip];
|
|
2263
|
+
t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true }));
|
|
2192
2264
|
}
|
|
2265
|
+
return { part: p.name, mask: P.alpha(p), spans };
|
|
2266
|
+
};
|
|
2267
|
+
const overLinks = (tail: Tail, q: PartRecord): { o: TailOverlap; rides: boolean } => {
|
|
2268
|
+
const o = tailOverlap(tail, P.alpha(q), q);
|
|
2269
|
+
return { o, rides: tail.spans.some((sp, k) => sp.link && o.counts[k] > 0) };
|
|
2270
|
+
};
|
|
2271
|
+
const dressRecipe = recipeOf('bottomwear');
|
|
2272
|
+
const riders: PartRecord[] =
|
|
2273
|
+
namedSkirt === null || dressRecipe === null
|
|
2274
|
+
? []
|
|
2275
|
+
: ((): PartRecord[] => {
|
|
2276
|
+
const mask = P.alpha(namedSkirt);
|
|
2277
|
+
const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
|
|
2278
|
+
return bw.filter((q) => overLinks(tail, q).rides);
|
|
2279
|
+
})();
|
|
2280
|
+
let dressDrafted: Tail | null = null;
|
|
2281
|
+
const skirts = namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt];
|
|
2282
|
+
for (const p of skirts) {
|
|
2283
|
+
const [g, r] = gridR(p);
|
|
2284
|
+
const mask = P.alpha(p);
|
|
2285
|
+
const bot = p.y + p.h;
|
|
2286
|
+
const y0 = hip[1] + 20;
|
|
2287
|
+
const { segs, hangs } = skirtFrame(p, mask);
|
|
2288
|
+
if (hangs !== null) notes.push(hangs);
|
|
2193
2289
|
// Issue #364: with config.cloth.bottomwear, the recipe's trees hang from the hip in place of the three keyed chains.
|
|
2194
2290
|
const skirtRecipe = recipeOf('bottomwear');
|
|
2195
2291
|
if (skirtRecipe !== null) {
|
|
2196
2292
|
const prefix = skirts.length === 1 ? 'skirt' : `skirt_${p.name}`;
|
|
2197
|
-
const
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
rootY: y0,
|
|
2201
|
-
tipY: bot - 4,
|
|
2202
|
-
xAt: (y, f) => bandX(mask, y, { frac: f }),
|
|
2203
|
-
fallbackX: (f) => p.x + f * p.w,
|
|
2204
|
-
frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
|
|
2205
|
-
edges: 'both',
|
|
2206
|
-
});
|
|
2293
|
+
const fixed = [...segs];
|
|
2294
|
+
const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
|
|
2295
|
+
if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
|
|
2207
2296
|
for (const t of trees) segs.push(t.chain);
|
|
2208
2297
|
let swing: { bone: string; rest: number } | null = null;
|
|
2209
2298
|
if (skirtRecipe.swing !== undefined) {
|
|
@@ -2236,7 +2325,7 @@ export function proposeWithBasis(
|
|
|
2236
2325
|
p === namedSkirt ? p.from : undefined,
|
|
2237
2326
|
),
|
|
2238
2327
|
);
|
|
2239
|
-
meshes[p.name] = { grid: g, r, segments: segs };
|
|
2328
|
+
meshes[p.name] = { grid: g, r: skirtRecipe.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
2240
2329
|
continue;
|
|
2241
2330
|
}
|
|
2242
2331
|
for (const [tag, f, ph] of [
|
|
@@ -2261,6 +2350,28 @@ export function proposeWithBasis(
|
|
|
2261
2350
|
}
|
|
2262
2351
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
2263
2352
|
}
|
|
2353
|
+
// Issue #416: each rider's mesh takes the dress's spans its pixels over the dress's art fall nearest — a link by its
|
|
2354
|
+
// bone, a fixed segment written out as the dress's mesh writes it — so every vertex weights by the rule the dress's
|
|
2355
|
+
// own art does, and the note names each span with its pixel count.
|
|
2356
|
+
for (const q of riders) {
|
|
2357
|
+
const tail = dressDrafted as Tail | null;
|
|
2358
|
+
if (tail === null) break;
|
|
2359
|
+
const { o } = overLinks(tail, q);
|
|
2360
|
+
const segments: Segment[] = [];
|
|
2361
|
+
const links: string[] = [];
|
|
2362
|
+
const on: string[] = [];
|
|
2363
|
+
tail.spans.forEach((sp, k) => {
|
|
2364
|
+
if (o.counts[k] === 0) return;
|
|
2365
|
+
on.push(`${sp.link ? sp.bone : `${sp.bone}'s fixed segment`} ${o.counts[k]}`);
|
|
2366
|
+
segments.push(sp.link ? sp.bone : [sp.bone, sp.a, sp.b]);
|
|
2367
|
+
if (sp.link) links.push(sp.bone);
|
|
2368
|
+
});
|
|
2369
|
+
const [g, r] = gridR(q);
|
|
2370
|
+
meshes[q.name] = { grid: g, r, segments };
|
|
2371
|
+
notes.push(
|
|
2372
|
+
`${q.name} (${q.from}): lies on the trees of ${tail.part}, which config.cloth."bottomwear".part names — ${o.over} of its ${o.total} px lie over ${tail.part}'s art, nearest its spans ${on.join(', ')} px — so its mesh's segments are those spans and it rides the links ${links.join(', ')} with the dress; no trees of its own are drafted for it, so the skirt carries one set (issue #416)`,
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2264
2375
|
|
|
2265
2376
|
// ---- legs (issue #241)
|
|
2266
2377
|
// Only with joints: a legwear part whose art holds the ankle of a leg whose hip, knee and ankle are all given is
|
|
@@ -3196,7 +3307,45 @@ export interface ChainNotAdvancingFinding {
|
|
|
3196
3307
|
/** Issue #314: two chains on one lattice whose own swing closes or opens an edge between them past check's TEXTURE_STRETCH bar (`src/squeeze.ts`). */
|
|
3197
3308
|
export type SqueezeLintFinding = { kind: 'squeeze' } & SqueezeFinding;
|
|
3198
3309
|
|
|
3199
|
-
|
|
3310
|
+
/**
|
|
3311
|
+
* Issue #400: a hand layer (`handwear-r` or `handwear-l`, un-cut) whose side's wrist keypoint is placed is held to at
|
|
3312
|
+
* least this share of the other hand layer's opaque pixel count (`ceil(HAND_MIN_OF_OTHER * other.opaque_px)`), or, when
|
|
3313
|
+
* the other side has no hand layer and the side's elbow and wrist keypoints are placed, to a square of the forearm's
|
|
3314
|
+
* width (`ceil(width²)`). On issue #400's overlapping-hands decomposition, See-through put 1,923 px into `handwear_l`
|
|
3315
|
+
* and 123 px of finger slivers into `handwear_r` (6.4 % of the other hand, below `ceil(0.25 * 1923) = 481` px); on two
|
|
3316
|
+
* separate hands (`standing` 1,200 / 1,200, `seated` 816 / 816, `lying` 1,120 / 1,120) the two layers are equal.
|
|
3317
|
+
*/
|
|
3318
|
+
export const HAND_MIN_OF_OTHER = 0.25;
|
|
3319
|
+
|
|
3320
|
+
/**
|
|
3321
|
+
* Issue #400: a hand layer holding fewer opaque pixels than the bar derived from the other hand layer or from the
|
|
3322
|
+
* forearm's width, while its side's wrist keypoint says a hand is there.
|
|
3323
|
+
*/
|
|
3324
|
+
export interface NearEmptyHandFinding {
|
|
3325
|
+
kind: 'near-empty-hand';
|
|
3326
|
+
part: string;
|
|
3327
|
+
from: string;
|
|
3328
|
+
side: 'r' | 'l';
|
|
3329
|
+
wrist: KeypointName;
|
|
3330
|
+
wristAt: [number, number];
|
|
3331
|
+
opaquePx: number;
|
|
3332
|
+
bar: number;
|
|
3333
|
+
basis:
|
|
3334
|
+
| { kind: 'other-hand'; otherPart: string; otherFrom: string; otherPx: number; share: number }
|
|
3335
|
+
| { kind: 'forearm-width'; width: number };
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
export type LintFinding =
|
|
3339
|
+
| OffArtFinding
|
|
3340
|
+
| OffLockFinding
|
|
3341
|
+
| HipAboveChestFinding
|
|
3342
|
+
| HipTooHighFinding
|
|
3343
|
+
| ChestNotBetweenFinding
|
|
3344
|
+
| HipNotPastChestFinding
|
|
3345
|
+
| HipNearerNeckFinding
|
|
3346
|
+
| ChainNotAdvancingFinding
|
|
3347
|
+
| SqueezeLintFinding
|
|
3348
|
+
| NearEmptyHandFinding;
|
|
3200
3349
|
|
|
3201
3350
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
3202
3351
|
export interface LintBasis {
|
|
@@ -3216,6 +3365,8 @@ export interface LintResult {
|
|
|
3216
3365
|
missingTorsoBones: string[];
|
|
3217
3366
|
/** Issue #314: the pairs of keyed chains on one mesh the squeeze check could not build a lattice for, and why. Empty when it read every pair or had none. */
|
|
3218
3367
|
squeezeUnread: string[];
|
|
3368
|
+
/** Issue #403: sibling branch pairs on a fork whose cell-boundary weights and spring strengths are unheld. */
|
|
3369
|
+
branchSqueezes: BranchSqueezeFinding[];
|
|
3219
3370
|
/** Present only under `--keypoints`: the rule set that read the torso and the chains. */
|
|
3220
3371
|
basis?: LintBasis;
|
|
3221
3372
|
/** One record per bone of the spec, in its bone order (chain links as `<chain><i>`): which checks read it, or why none did (issue #86). */
|
|
@@ -3423,9 +3574,68 @@ export function lint(
|
|
|
3423
3574
|
}
|
|
3424
3575
|
}
|
|
3425
3576
|
}
|
|
3577
|
+
// Issue #400: when a hand layer holds fewer opaque pixels than the bar derived from the other hand layer, or from the
|
|
3578
|
+
// forearm's width when the other side has no hand layer, while the wrist keypoint says a hand is there.
|
|
3579
|
+
if (joints !== undefined) {
|
|
3580
|
+
const handOf = (s: 'r' | 'l'): PartRecord | null => {
|
|
3581
|
+
const ps = P.byTag(`handwear-${s}`).filter((p) => p.limb === undefined);
|
|
3582
|
+
return ps.length === 1 ? ps[0] : null;
|
|
3583
|
+
};
|
|
3584
|
+
for (const s of ['r', 'l'] as const) {
|
|
3585
|
+
const wrist: KeypointName = `${s}_wrist`;
|
|
3586
|
+
const wristAt = joints.joints[wrist].at;
|
|
3587
|
+
const part = handOf(s);
|
|
3588
|
+
if (wristAt === null || part === null) continue;
|
|
3589
|
+
const other = handOf(s === 'r' ? 'l' : 'r');
|
|
3590
|
+
if (other !== null) {
|
|
3591
|
+
const bar = Math.ceil(HAND_MIN_OF_OTHER * other.opaque_px);
|
|
3592
|
+
if (part.opaque_px < bar) {
|
|
3593
|
+
findings.push({
|
|
3594
|
+
kind: 'near-empty-hand',
|
|
3595
|
+
part: part.name,
|
|
3596
|
+
from: part.from,
|
|
3597
|
+
side: s,
|
|
3598
|
+
wrist,
|
|
3599
|
+
wristAt: [wristAt[0], wristAt[1]],
|
|
3600
|
+
opaquePx: part.opaque_px,
|
|
3601
|
+
bar,
|
|
3602
|
+
basis: { kind: 'other-hand', otherPart: other.name, otherFrom: other.from, otherPx: other.opaque_px, share: HAND_MIN_OF_OTHER },
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
continue;
|
|
3606
|
+
}
|
|
3607
|
+
const elAt = joints.joints[`${s}_elbow`].at;
|
|
3608
|
+
if (elAt === null) continue;
|
|
3609
|
+
const dx = wristAt[0] - elAt[0];
|
|
3610
|
+
const dy = wristAt[1] - elAt[1];
|
|
3611
|
+
const len = edgeLength(dx, dy);
|
|
3612
|
+
if (len === 0) continue;
|
|
3613
|
+
const armMasks = [...P.byTag('topwear'), part].map((p) => P.alpha(p));
|
|
3614
|
+
const art = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < P.W && y < P.H && armMasks.some((m) => m.data[y * P.W + x] === 1);
|
|
3615
|
+
const mid: [number, number] = [(elAt[0] + wristAt[0]) / 2, (elAt[1] + wristAt[1]) / 2];
|
|
3616
|
+
const axis: [number, number] = [dx / len, dy / len];
|
|
3617
|
+
const w = limbWidth({ at: mid, axis }, art, [0, 0, P.W - 1, P.H - 1]) ?? limbWidth({ at: elAt, axis }, art, [0, 0, P.W - 1, P.H - 1]);
|
|
3618
|
+
if (w === null) continue;
|
|
3619
|
+
const bar = Math.ceil(w * w);
|
|
3620
|
+
if (part.opaque_px < bar) {
|
|
3621
|
+
findings.push({
|
|
3622
|
+
kind: 'near-empty-hand',
|
|
3623
|
+
part: part.name,
|
|
3624
|
+
from: part.from,
|
|
3625
|
+
side: s,
|
|
3626
|
+
wrist,
|
|
3627
|
+
wristAt: [wristAt[0], wristAt[1]],
|
|
3628
|
+
opaquePx: part.opaque_px,
|
|
3629
|
+
bar,
|
|
3630
|
+
basis: { kind: 'forearm-width', width: w },
|
|
3631
|
+
});
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3426
3635
|
// Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
|
|
3427
3636
|
// to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
|
|
3428
|
-
const
|
|
3637
|
+
const partImages = new Map(P.recs.filter((p) => P.images.has(p.name)).map((p) => [p.name, { x: p.x, y: p.y, image: P.images.get(p.name) as Raster }]));
|
|
3638
|
+
const sq = chainSqueezes({ bones: spec.bones, meshes: spec.meshes, motion: spec.motion }, partImages);
|
|
3429
3639
|
for (const { mesh, a, b } of sq.read) {
|
|
3430
3640
|
for (const [c, other] of [[a, b], [b, a]]) {
|
|
3431
3641
|
const e = spec.bones.find((x) => 'chain' in x && x.chain === c);
|
|
@@ -3434,10 +3644,11 @@ export function lint(
|
|
|
3434
3644
|
}
|
|
3435
3645
|
for (const f of sq.findings) findings.push({ kind: 'squeeze', ...f });
|
|
3436
3646
|
const squeezeUnread = sq.unread;
|
|
3647
|
+
const branchSq = branchSqueezes({ bones: spec.bones, meshes: spec.meshes, constraints: spec.constraints }, partImages);
|
|
3437
3648
|
const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
|
|
3438
3649
|
return basis === undefined
|
|
3439
|
-
? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, coverage }
|
|
3440
|
-
: { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, basis, coverage };
|
|
3650
|
+
? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, branchSqueezes: branchSq, coverage }
|
|
3651
|
+
: { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, branchSqueezes: branchSq, basis, coverage };
|
|
3441
3652
|
}
|
|
3442
3653
|
|
|
3443
3654
|
/** The bones a finding names: the off-art link; hip and chest for a relation between them; a chain's last link for its tip. */
|
|
@@ -3456,6 +3667,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
|
3456
3667
|
return ['chest'];
|
|
3457
3668
|
case 'squeeze':
|
|
3458
3669
|
return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
|
|
3670
|
+
case 'near-empty-hand':
|
|
3671
|
+
return [];
|
|
3459
3672
|
case 'chain-not-advancing': {
|
|
3460
3673
|
if (!f.point.endsWith(' tip')) return [f.point];
|
|
3461
3674
|
const chain = f.point.slice(0, -' tip'.length);
|
|
@@ -3623,6 +3836,13 @@ export function lintLine(f: LintFinding): string {
|
|
|
3623
3836
|
return `LINT hip at ${pyIntList(f.hip)} is nearer the neck joint ${fmtAt(f.neck)} (${pyFixed(f.dNeck, 1)} px) than the midpoint of the hip joints ${fmtAt(f.hips)} (${pyFixed(f.dHips, 1)} px): a hip at the shoulders`;
|
|
3624
3837
|
case 'squeeze':
|
|
3625
3838
|
return squeezeLine(f);
|
|
3839
|
+
case 'near-empty-hand': {
|
|
3840
|
+
const why =
|
|
3841
|
+
f.basis.kind === 'other-hand'
|
|
3842
|
+
? `${f.basis.share} of ${f.basis.otherPart} (${f.basis.otherFrom}), ${f.basis.otherPx} px`
|
|
3843
|
+
: `forearm width ${pyFixed(f.basis.width, 1)} px squared`;
|
|
3844
|
+
return `LINT ${f.part} (${f.from}) holds ${f.opaquePx} opaque px while ${f.wrist} at ${fmtAt(f.wristAt)} says a hand is there — below the bar of ${f.bar} px (${why}): the hands may overlap in the other layer and leave finger slivers here`;
|
|
3845
|
+
}
|
|
3626
3846
|
case 'chain-not-advancing':
|
|
3627
3847
|
return `LINT ${f.point} at ${pyIntList(f.at)} does not advance from ${f.fromJoint} toward ${f.toJoint}: it falls ${pyFixed(f.t, 1)} px along that line and ${f.previous} ${pyFixed(f.tPrevious, 1)} px, and each must fall further than the one before`;
|
|
3628
3848
|
}
|
|
@@ -3657,6 +3877,23 @@ function squeezeLine(f: SqueezeLintFinding): string {
|
|
|
3657
3877
|
);
|
|
3658
3878
|
}
|
|
3659
3879
|
|
|
3880
|
+
/**
|
|
3881
|
+
* Issue #403: the note line naming a branch tip's squeezed triangle, the bones (`a` and `b` forked from `parent`),
|
|
3882
|
+
* the mechanism (`the weights where two branches' cells meet` and, when present, unequal spring strength on the fork),
|
|
3883
|
+
* and the setting (`split.hold_tip: true`) that holds the tip.
|
|
3884
|
+
*/
|
|
3885
|
+
export function branchSqueezeLine(f: BranchSqueezeFinding): string {
|
|
3886
|
+
const stiff =
|
|
3887
|
+
f.strengthA !== null && f.strengthB !== null && f.strengthA !== f.strengthB
|
|
3888
|
+
? ` and unequal spring strength on the fork (${f.a} ${f.strengthA} vs ${f.b} ${f.strengthB})`
|
|
3889
|
+
: '';
|
|
3890
|
+
return (
|
|
3891
|
+
`LINT ${f.a} and ${f.b} (forked from ${f.parent}) on mesh ${pyRepr(f.mesh)} squeeze their tip: triangle ${f.triangle} across edge ${f.edge[0]}-${f.edge[1]} (tips ${pyFixed(f.tipGap, 1)} px apart) ` +
|
|
3892
|
+
`is squeezed by the weights where two branches' cells meet (vertex ${f.edge[0]} rides ${f.a} ${pyFixed(f.weightA, 5)}, vertex ${f.edge[1]} rides ${f.b} ${pyFixed(f.weightB, 5)} at r=${f.r} on a ${f.grid} px lattice)${stiff} — ` +
|
|
3893
|
+
`set split.hold_tip: true to hold the branch tips (keeps branch strength across the fork and raises r to grid)`
|
|
3894
|
+
);
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3660
3897
|
export interface Comparison {
|
|
3661
3898
|
rows: Array<[string, number]>;
|
|
3662
3899
|
onlyConfig: string[];
|