@rig-c/parts 1.24.0 → 1.26.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/README.md +1 -1
- package/cli.ts +22 -6
- package/docs/AUTHORING.md +76 -36
- package/docs/MIGRATION.md +1 -1
- package/docs/STABILITY.md +10 -8
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +1 -1
- package/src/assemble.ts +142 -29
- package/src/automesh.ts +2 -1
- package/src/build.ts +23 -9
- package/src/check.ts +227 -44
- package/src/cloth.ts +22 -7
- package/src/config.ts +161 -12
- package/src/headbox.ts +68 -34
- package/src/limbs.ts +171 -0
- package/src/propose.ts +301 -49
- package/src/rig.ts +186 -18
- package/src/scene.ts +87 -7
- 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
|
|
@@ -143,8 +146,9 @@ import { configRoles, type RoleOf } from './structure.ts';
|
|
|
143
146
|
import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
|
|
144
147
|
import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
|
|
145
148
|
import { edgeLength } from './length.ts';
|
|
146
|
-
import {
|
|
147
|
-
import {
|
|
149
|
+
import { limbWidth } from './limbs.ts';
|
|
150
|
+
import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
|
|
151
|
+
import { RECIPE_BACK_HAIR_CHAINS, 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';
|
|
148
152
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
149
153
|
|
|
150
154
|
// ---------------------------------------------------------------------------
|
|
@@ -274,20 +278,23 @@ export interface Proposal {
|
|
|
274
278
|
constraints?: ConfigConstraint[];
|
|
275
279
|
/** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
|
|
276
280
|
turn?: TurnSpec;
|
|
281
|
+
/** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) its coordinates were drafted at (issue #354). */
|
|
282
|
+
proposed_rig_scale: number;
|
|
277
283
|
notes: string[];
|
|
278
284
|
}
|
|
279
285
|
|
|
280
286
|
/**
|
|
281
287
|
* Every section of a proposal that is a config section, in the order the
|
|
282
288
|
* proposal writes them: what an author copies into the config (issue #375).
|
|
283
|
-
* `notes` is the proposal's own and is copied nowhere.
|
|
284
|
-
* only on request — `limbs` under `--keypoints`, `
|
|
285
|
-
* — and a `limbs` block left behind is refused
|
|
286
|
-
* every part it cuts. {@link checkProposal} reads
|
|
287
|
-
* loader by this list, the type below refuses a
|
|
288
|
-
* hold, and the selftest's `TY18` holds every
|
|
289
|
+
* `notes` is the proposal's own and is copied nowhere. Three of them are written
|
|
290
|
+
* only on request — `limbs` under `--keypoints`, `constraints` under `--cloth`,
|
|
291
|
+
* `turn` under `--full`/`--head` — and a `limbs` block left behind is refused
|
|
292
|
+
* at the next step, by the name of every part it cuts. {@link checkProposal} reads
|
|
293
|
+
* the proposal through the config loader by this list, the type below refuses a
|
|
294
|
+
* {@link Proposal} key it does not hold, and the selftest's `TY18` holds every
|
|
295
|
+
* paste sentence in the documents to it.
|
|
289
296
|
*/
|
|
290
|
-
export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
|
|
297
|
+
export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn', 'proposed_rig_scale'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
|
|
291
298
|
|
|
292
299
|
/** A {@link Proposal} key that is neither `notes` nor in {@link PROPOSAL_CONFIG_SECTIONS}; `never` while the list is whole. */
|
|
293
300
|
type UnlistedProposalSection = Exclude<keyof Proposal, 'notes' | (typeof PROPOSAL_CONFIG_SECTIONS)[number]>;
|
|
@@ -410,7 +417,7 @@ export function proposalAssemble(P: PartSet): Record<string, unknown> {
|
|
|
410
417
|
const planned = run.filter((r) => r.cut_from === undefined && firstOf.get(r.from) === r.name);
|
|
411
418
|
const pieces = run.filter((r) => r.cut_from !== undefined || firstOf.get(r.from) !== r.name);
|
|
412
419
|
return {
|
|
413
|
-
rig_scale:
|
|
420
|
+
rig_scale: P.rigScale,
|
|
414
421
|
plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
|
|
415
422
|
patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
|
|
416
423
|
...(pieces.length === 0
|
|
@@ -607,7 +614,7 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
|
|
|
607
614
|
}
|
|
608
615
|
turn = { pivot: turn.pivot, depths };
|
|
609
616
|
}
|
|
610
|
-
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
|
|
617
|
+
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), proposed_rig_scale: p.proposed_rig_scale, notes: [...p.notes, ...draft.notes] };
|
|
611
618
|
}
|
|
612
619
|
|
|
613
620
|
// ---------------------------------------------------------------------------
|
|
@@ -618,12 +625,14 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
|
|
|
618
625
|
export class PartSet {
|
|
619
626
|
readonly W: number;
|
|
620
627
|
readonly H: number;
|
|
628
|
+
readonly rigScale: number;
|
|
621
629
|
readonly recs: readonly PartRecord[];
|
|
622
630
|
readonly images: ReadonlyMap<string, Raster>;
|
|
623
631
|
private readonly masks = new Map<string, Mask>();
|
|
624
632
|
|
|
625
633
|
constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
|
|
626
634
|
[this.W, this.H] = file.rig_size;
|
|
635
|
+
this.rigScale = file.scale_rig_per_source;
|
|
627
636
|
this.recs = file.parts;
|
|
628
637
|
this.images = images;
|
|
629
638
|
}
|
|
@@ -1373,7 +1382,7 @@ export function proposeWithBasis(
|
|
|
1373
1382
|
const tails: Tail[] = [];
|
|
1374
1383
|
const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
|
|
1375
1384
|
const trees = draftTrees(input, r);
|
|
1376
|
-
const constants = [konst('cloth_chains', r.chains), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
|
|
1385
|
+
const constants = [konst('cloth_chains', r.chains ?? RECIPE_BACK_HAIR_CHAINS), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
|
|
1377
1386
|
for (const t of trees) {
|
|
1378
1387
|
C(
|
|
1379
1388
|
t.chain,
|
|
@@ -1530,13 +1539,69 @@ export function proposeWithBasis(
|
|
|
1530
1539
|
} else if (recipeOf('back hair') !== null) {
|
|
1531
1540
|
// Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
|
|
1532
1541
|
// 4 px above the part's bottom, in place of the two keyed chains.
|
|
1533
|
-
|
|
1542
|
+
// Issue #390: the tree count per side derives from the tail's opaque width at the root row (`floor(width / grid)`
|
|
1543
|
+
// on each side, and every tree root inside the 15 px dilated art); a declared count that does not fit is refused
|
|
1544
|
+
// by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
|
|
1545
|
+
const rawRcp = recipeOf('back hair') as ClothRecipe;
|
|
1534
1546
|
const mask = P.alpha(p);
|
|
1547
|
+
const dilated = dilate(mask, 31);
|
|
1548
|
+
const y0 = pyRound(eyeY);
|
|
1549
|
+
const sideOpaqueWidth = (s: 'r' | 'l'): number => {
|
|
1550
|
+
if (y0 < 0 || y0 >= mask.height) return 0;
|
|
1551
|
+
let w = 0;
|
|
1552
|
+
for (let x = 0; x < mask.width; x++) {
|
|
1553
|
+
if ((s === 'r' ? x < axis : x >= axis) && mask.data[y0 * mask.width + x]) w++;
|
|
1554
|
+
}
|
|
1555
|
+
return w;
|
|
1556
|
+
};
|
|
1557
|
+
const prefixOf = (s: 'r' | 'l'): string => (P.headFirst('back hair').length === 1 ? `hairback_${s}` : `hairback_${p.name}_${s}`);
|
|
1558
|
+
const offRootsAt = (cnt: number, s: 'r' | 'l'): Array<{ bone: string; at: Point; alpha: number }> => {
|
|
1559
|
+
const pref = prefixOf(s);
|
|
1560
|
+
const im = P.images.get(p.name) as Raster;
|
|
1561
|
+
const off: Array<{ bone: string; at: Point; alpha: number }> = [];
|
|
1562
|
+
for (let k = 0; k < cnt; k++) {
|
|
1563
|
+
const fc = (k + 0.5) / cnt;
|
|
1564
|
+
const rx = pyRound(bandX(mask, eyeY, { frac: fc, side: s, axis }) ?? axis);
|
|
1565
|
+
if (!isOn(dilated, rx, y0)) {
|
|
1566
|
+
const lx = rx - p.x;
|
|
1567
|
+
const ly = y0 - p.y;
|
|
1568
|
+
const alpha = lx >= 0 && lx < p.w && ly >= 0 && ly < p.h ? im.data[(ly * p.w + lx) * 4 + 3] : 0;
|
|
1569
|
+
off.push({ bone: `${pref}${k}_0`, at: [rx, y0], alpha });
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return off;
|
|
1573
|
+
};
|
|
1574
|
+
const wR = sideOpaqueWidth('r');
|
|
1575
|
+
const wL = sideOpaqueWidth('l');
|
|
1576
|
+
const widthCap = Math.max(1, Math.min(Math.floor(wR / g), Math.floor(wL / g)));
|
|
1577
|
+
let maxFit = 1;
|
|
1578
|
+
for (let cnt = widthCap; cnt >= 1; cnt--) {
|
|
1579
|
+
if (offRootsAt(cnt, 'r').length === 0 && offRootsAt(cnt, 'l').length === 0) {
|
|
1580
|
+
maxFit = cnt;
|
|
1581
|
+
break;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
if (rawRcp.chains !== undefined) {
|
|
1585
|
+
const offRoots = [...offRootsAt(rawRcp.chains, 'r'), ...offRootsAt(rawRcp.chains, 'l')];
|
|
1586
|
+
if (rawRcp.chains > widthCap || offRoots.length > 0) {
|
|
1587
|
+
throw new PartsError([
|
|
1588
|
+
{
|
|
1589
|
+
code: 'PROPOSE_CLOTH_BACK_HAIR_CHAINS',
|
|
1590
|
+
object: 'config.cloth."back hair".chains',
|
|
1591
|
+
detail:
|
|
1592
|
+
`declares ${rawRcp.chains} tree(s) per side on "${p.name}", and at the root row y=${y0} the tail is ${wR} px wide on the r side and ${wL} px on the l side on a ${g} px mesh grid (at most ${maxFit} tree(s) per side fit inside the art` +
|
|
1593
|
+
(offRoots.length > 0 ? `; off the art: ${offRoots.map((o) => `${o.bone} at [${o.at[0]}, ${o.at[1]}] (alpha ${o.alpha})`).join(', ')}` : '') +
|
|
1594
|
+
`) — set chains to at most ${maxFit}, or leave chains out to derive it from the tail's width`,
|
|
1595
|
+
},
|
|
1596
|
+
]);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
|
|
1535
1600
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
1536
1601
|
const all: TreeChain[] = [];
|
|
1537
1602
|
let n = 0;
|
|
1538
1603
|
for (const [side] of SIDES) {
|
|
1539
|
-
const prefix =
|
|
1604
|
+
const prefix = prefixOf(side);
|
|
1540
1605
|
const trees = T(p, 'back hair', rcp, {
|
|
1541
1606
|
prefix,
|
|
1542
1607
|
parent: 'head',
|
|
@@ -1554,7 +1619,7 @@ export function proposeWithBasis(
|
|
|
1554
1619
|
all.push(...trees);
|
|
1555
1620
|
}
|
|
1556
1621
|
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'));
|
|
1557
|
-
meshes[p.name] = { grid: g, r, segments: segs };
|
|
1622
|
+
meshes[p.name] = { grid: g, r: rcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
1558
1623
|
// Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
|
|
1559
1624
|
const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
|
|
1560
1625
|
for (const t of all) {
|
|
@@ -1728,7 +1793,8 @@ export function proposeWithBasis(
|
|
|
1728
1793
|
tracks.push({ chain: name, amps: [0.8, 1.6, 2.6, 3.6], period: 4.0, phase: 0.16, lag: 0.07 });
|
|
1729
1794
|
k++;
|
|
1730
1795
|
}
|
|
1731
|
-
|
|
1796
|
+
const frontRcp = recipeOf('front hair');
|
|
1797
|
+
meshes[p.name] = { grid: g, r: frontRcp?.split?.hold_tip === true && k > 0 ? Math.max(r, g) : r, segments: segs };
|
|
1732
1798
|
}
|
|
1733
1799
|
|
|
1734
1800
|
// ---- accessories: rigid body + pendant chain where the part narrows, or one pendulum chain per hanging strand
|
|
@@ -2114,9 +2180,10 @@ export function proposeWithBasis(
|
|
|
2114
2180
|
throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
|
|
2115
2181
|
};
|
|
2116
2182
|
const q = P.recs.find((x) => x.name === name);
|
|
2117
|
-
if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part whose art hangs below the hip (a dress) is required`);
|
|
2118
|
-
|
|
2119
|
-
|
|
2183
|
+
if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part or its cut skirt piece whose art hangs below the hip (a dress) is required`);
|
|
2184
|
+
// An explicit name may select a topwear cut piece; automatic tag rules still exclude pieces.
|
|
2185
|
+
const [run, tag] = splitFrom(q.from);
|
|
2186
|
+
if (run === PAINTING_RUN || tag !== 'topwear') {
|
|
2120
2187
|
return refuse(
|
|
2121
2188
|
`names "${name}", from ${q.from}; ${splitFrom(q.from)[1] === 'bottomwear' ? 'a bottomwear part takes the recipe by its tag already — leave part out' : "only a topwear part takes the skirt's recipe by name (a skirt See-through returned inside topwear, a dress)"}`,
|
|
2122
2189
|
);
|
|
@@ -2156,8 +2223,8 @@ export function proposeWithBasis(
|
|
|
2156
2223
|
['chest', rnd(along(hip, trunk.u, artExtent(mask, p, hip, trunk.u)[0])), rnd(along(hip, trunk.u, -20))],
|
|
2157
2224
|
['hip', rnd(along(hip, trunk.u, -20)), rnd(along(hip, trunk.u, 100))],
|
|
2158
2225
|
];
|
|
2159
|
-
if (bw.length > 0 && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
|
|
2160
|
-
// A bodice
|
|
2226
|
+
if ((bw.length > 0 || namedSkirt?.cut_from === p.name) && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
|
|
2227
|
+
// A bodice above bottomwear, including its explicitly named cut skirt: robe chains would fall off the art.
|
|
2161
2228
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
2162
2229
|
notes.push(`${p.name} ends at y=${bot} (hip ${pyFixed(hip[1], 0)}): no robe chains -- add a sash chain by hand if one hangs`);
|
|
2163
2230
|
continue;
|
|
@@ -2204,12 +2271,8 @@ export function proposeWithBasis(
|
|
|
2204
2271
|
}
|
|
2205
2272
|
|
|
2206
2273
|
// ---- skirt (bottomwear, and the topwear part config.cloth.bottomwear.part names: issue #402)
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
const [g, r] = gridR(p);
|
|
2210
|
-
const mask = P.alpha(p);
|
|
2211
|
-
const bot = p.y + p.h;
|
|
2212
|
-
const y0 = hip[1] + 20;
|
|
2274
|
+
// The fixed segments of a skirt's mesh, and the note when it hangs from the waist (pushed by the drafting loop only).
|
|
2275
|
+
const skirtFrame = (p: PartRecord, mask: Mask): { segs: Segment[]; hangs: string | null } => {
|
|
2213
2276
|
let segs: Segment[] = [
|
|
2214
2277
|
['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
|
|
2215
2278
|
['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
|
|
@@ -2231,26 +2294,67 @@ export function proposeWithBasis(
|
|
|
2231
2294
|
const down: readonly [number, number] = trunk === null ? [0, 1] : trunk.u;
|
|
2232
2295
|
const artTop = artExtent(mask, p, hip, down)[0];
|
|
2233
2296
|
const chestAt = (chest[0] - hip[0]) * down[0] + (chest[1] - hip[1]) * down[1];
|
|
2234
|
-
if (artTop
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2297
|
+
if (artTop < chestAt) return { segs, hangs: null };
|
|
2298
|
+
return {
|
|
2299
|
+
segs: segs.filter((s) => typeof s === 'string' || s[0] !== 'chest'),
|
|
2300
|
+
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`,
|
|
2301
|
+
};
|
|
2302
|
+
};
|
|
2303
|
+
// 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.
|
|
2304
|
+
const skirtInput = (p: PartRecord, mask: Mask, prefix: string): TreeInput => ({
|
|
2305
|
+
prefix,
|
|
2306
|
+
parent: 'hip',
|
|
2307
|
+
rootY: hip[1] + 20,
|
|
2308
|
+
tipY: p.y + p.h - 4,
|
|
2309
|
+
xAt: (y, f) => bandX(mask, y, { frac: f }),
|
|
2310
|
+
fallbackX: (f) => p.x + f * p.w,
|
|
2311
|
+
frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
|
|
2312
|
+
edges: 'both',
|
|
2313
|
+
});
|
|
2314
|
+
// Issue #416: a bottomwear part lying on the trees of the dress config.cloth.bottomwear.part names (a ruffle hem
|
|
2315
|
+
// See-through returned on its own) rides the links it overlaps, as a layer wrapped round a tail does (issue #391),
|
|
2316
|
+
// and gets no trees of its own: one skirt carries one set of trees. The dress's mesh is the tail — its fixed
|
|
2317
|
+
// segments and every tree link, in segment order — and the links are read off the overlap, never off the part's
|
|
2318
|
+
// name. Which parts ride is read here, before any bone is named, off the trees as the recipe places them (a tree's
|
|
2319
|
+
// points do not depend on its prefix); a part over no link keeps its own trees below. Only with the field.
|
|
2320
|
+
const dressTail = (p: PartRecord, segs: readonly Segment[], trees: readonly TreeChain[]): Tail => {
|
|
2321
|
+
const spans: TailSpan[] = [];
|
|
2322
|
+
for (const s of segs) if (typeof s !== 'string') spans.push({ bone: s[0], a: s[1], b: s[2], link: false });
|
|
2323
|
+
for (const t of trees) {
|
|
2324
|
+
const line = [...t.points, t.tip];
|
|
2325
|
+
t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true }));
|
|
2239
2326
|
}
|
|
2327
|
+
return { part: p.name, mask: P.alpha(p), spans };
|
|
2328
|
+
};
|
|
2329
|
+
const overLinks = (tail: Tail, q: PartRecord): { o: TailOverlap; rides: boolean } => {
|
|
2330
|
+
const o = tailOverlap(tail, P.alpha(q), q);
|
|
2331
|
+
return { o, rides: tail.spans.some((sp, k) => sp.link && o.counts[k] > 0) };
|
|
2332
|
+
};
|
|
2333
|
+
const dressRecipe = recipeOf('bottomwear');
|
|
2334
|
+
const riders: PartRecord[] =
|
|
2335
|
+
namedSkirt === null || dressRecipe === null
|
|
2336
|
+
? []
|
|
2337
|
+
: ((): PartRecord[] => {
|
|
2338
|
+
const mask = P.alpha(namedSkirt);
|
|
2339
|
+
const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
|
|
2340
|
+
return bw.filter((q) => overLinks(tail, q).rides);
|
|
2341
|
+
})();
|
|
2342
|
+
let dressDrafted: Tail | null = null;
|
|
2343
|
+
const skirts = namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt];
|
|
2344
|
+
for (const p of skirts) {
|
|
2345
|
+
const [g, r] = gridR(p);
|
|
2346
|
+
const mask = P.alpha(p);
|
|
2347
|
+
const bot = p.y + p.h;
|
|
2348
|
+
const y0 = hip[1] + 20;
|
|
2349
|
+
const { segs, hangs } = skirtFrame(p, mask);
|
|
2350
|
+
if (hangs !== null) notes.push(hangs);
|
|
2240
2351
|
// Issue #364: with config.cloth.bottomwear, the recipe's trees hang from the hip in place of the three keyed chains.
|
|
2241
2352
|
const skirtRecipe = recipeOf('bottomwear');
|
|
2242
2353
|
if (skirtRecipe !== null) {
|
|
2243
2354
|
const prefix = skirts.length === 1 ? 'skirt' : `skirt_${p.name}`;
|
|
2244
|
-
const
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
rootY: y0,
|
|
2248
|
-
tipY: bot - 4,
|
|
2249
|
-
xAt: (y, f) => bandX(mask, y, { frac: f }),
|
|
2250
|
-
fallbackX: (f) => p.x + f * p.w,
|
|
2251
|
-
frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
|
|
2252
|
-
edges: 'both',
|
|
2253
|
-
});
|
|
2355
|
+
const fixed = [...segs];
|
|
2356
|
+
const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
|
|
2357
|
+
if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
|
|
2254
2358
|
for (const t of trees) segs.push(t.chain);
|
|
2255
2359
|
let swing: { bone: string; rest: number } | null = null;
|
|
2256
2360
|
if (skirtRecipe.swing !== undefined) {
|
|
@@ -2283,7 +2387,7 @@ export function proposeWithBasis(
|
|
|
2283
2387
|
p === namedSkirt ? p.from : undefined,
|
|
2284
2388
|
),
|
|
2285
2389
|
);
|
|
2286
|
-
meshes[p.name] = { grid: g, r, segments: segs };
|
|
2390
|
+
meshes[p.name] = { grid: g, r: skirtRecipe.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
2287
2391
|
continue;
|
|
2288
2392
|
}
|
|
2289
2393
|
for (const [tag, f, ph] of [
|
|
@@ -2308,6 +2412,28 @@ export function proposeWithBasis(
|
|
|
2308
2412
|
}
|
|
2309
2413
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
2310
2414
|
}
|
|
2415
|
+
// Issue #416: each rider's mesh takes the dress's spans its pixels over the dress's art fall nearest — a link by its
|
|
2416
|
+
// bone, a fixed segment written out as the dress's mesh writes it — so every vertex weights by the rule the dress's
|
|
2417
|
+
// own art does, and the note names each span with its pixel count.
|
|
2418
|
+
for (const q of riders) {
|
|
2419
|
+
const tail = dressDrafted as Tail | null;
|
|
2420
|
+
if (tail === null) break;
|
|
2421
|
+
const { o } = overLinks(tail, q);
|
|
2422
|
+
const segments: Segment[] = [];
|
|
2423
|
+
const links: string[] = [];
|
|
2424
|
+
const on: string[] = [];
|
|
2425
|
+
tail.spans.forEach((sp, k) => {
|
|
2426
|
+
if (o.counts[k] === 0) return;
|
|
2427
|
+
on.push(`${sp.link ? sp.bone : `${sp.bone}'s fixed segment`} ${o.counts[k]}`);
|
|
2428
|
+
segments.push(sp.link ? sp.bone : [sp.bone, sp.a, sp.b]);
|
|
2429
|
+
if (sp.link) links.push(sp.bone);
|
|
2430
|
+
});
|
|
2431
|
+
const [g, r] = gridR(q);
|
|
2432
|
+
meshes[q.name] = { grid: g, r, segments };
|
|
2433
|
+
notes.push(
|
|
2434
|
+
`${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)`,
|
|
2435
|
+
);
|
|
2436
|
+
}
|
|
2311
2437
|
|
|
2312
2438
|
// ---- legs (issue #241)
|
|
2313
2439
|
// Only with joints: a legwear part whose art holds the ankle of a leg whose hip, knee and ankle are all given is
|
|
@@ -2451,7 +2577,7 @@ export function proposeWithBasis(
|
|
|
2451
2577
|
}
|
|
2452
2578
|
const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
|
|
2453
2579
|
return {
|
|
2454
|
-
proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, notes },
|
|
2580
|
+
proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes },
|
|
2455
2581
|
basis: { frame, bones: records },
|
|
2456
2582
|
};
|
|
2457
2583
|
}
|
|
@@ -3243,7 +3369,45 @@ export interface ChainNotAdvancingFinding {
|
|
|
3243
3369
|
/** 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`). */
|
|
3244
3370
|
export type SqueezeLintFinding = { kind: 'squeeze' } & SqueezeFinding;
|
|
3245
3371
|
|
|
3246
|
-
|
|
3372
|
+
/**
|
|
3373
|
+
* Issue #400: a hand layer (`handwear-r` or `handwear-l`, un-cut) whose side's wrist keypoint is placed is held to at
|
|
3374
|
+
* least this share of the other hand layer's opaque pixel count (`ceil(HAND_MIN_OF_OTHER * other.opaque_px)`), or, when
|
|
3375
|
+
* the other side has no hand layer and the side's elbow and wrist keypoints are placed, to a square of the forearm's
|
|
3376
|
+
* width (`ceil(width²)`). On issue #400's overlapping-hands decomposition, See-through put 1,923 px into `handwear_l`
|
|
3377
|
+
* and 123 px of finger slivers into `handwear_r` (6.4 % of the other hand, below `ceil(0.25 * 1923) = 481` px); on two
|
|
3378
|
+
* separate hands (`standing` 1,200 / 1,200, `seated` 816 / 816, `lying` 1,120 / 1,120) the two layers are equal.
|
|
3379
|
+
*/
|
|
3380
|
+
export const HAND_MIN_OF_OTHER = 0.25;
|
|
3381
|
+
|
|
3382
|
+
/**
|
|
3383
|
+
* Issue #400: a hand layer holding fewer opaque pixels than the bar derived from the other hand layer or from the
|
|
3384
|
+
* forearm's width, while its side's wrist keypoint says a hand is there.
|
|
3385
|
+
*/
|
|
3386
|
+
export interface NearEmptyHandFinding {
|
|
3387
|
+
kind: 'near-empty-hand';
|
|
3388
|
+
part: string;
|
|
3389
|
+
from: string;
|
|
3390
|
+
side: 'r' | 'l';
|
|
3391
|
+
wrist: KeypointName;
|
|
3392
|
+
wristAt: [number, number];
|
|
3393
|
+
opaquePx: number;
|
|
3394
|
+
bar: number;
|
|
3395
|
+
basis:
|
|
3396
|
+
| { kind: 'other-hand'; otherPart: string; otherFrom: string; otherPx: number; share: number }
|
|
3397
|
+
| { kind: 'forearm-width'; width: number };
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
export type LintFinding =
|
|
3401
|
+
| OffArtFinding
|
|
3402
|
+
| OffLockFinding
|
|
3403
|
+
| HipAboveChestFinding
|
|
3404
|
+
| HipTooHighFinding
|
|
3405
|
+
| ChestNotBetweenFinding
|
|
3406
|
+
| HipNotPastChestFinding
|
|
3407
|
+
| HipNearerNeckFinding
|
|
3408
|
+
| ChainNotAdvancingFinding
|
|
3409
|
+
| SqueezeLintFinding
|
|
3410
|
+
| NearEmptyHandFinding;
|
|
3247
3411
|
|
|
3248
3412
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
3249
3413
|
export interface LintBasis {
|
|
@@ -3263,6 +3427,8 @@ export interface LintResult {
|
|
|
3263
3427
|
missingTorsoBones: string[];
|
|
3264
3428
|
/** 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. */
|
|
3265
3429
|
squeezeUnread: string[];
|
|
3430
|
+
/** Issue #403: sibling branch pairs on a fork whose cell-boundary weights and spring strengths are unheld. */
|
|
3431
|
+
branchSqueezes: BranchSqueezeFinding[];
|
|
3266
3432
|
/** Present only under `--keypoints`: the rule set that read the torso and the chains. */
|
|
3267
3433
|
basis?: LintBasis;
|
|
3268
3434
|
/** 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). */
|
|
@@ -3470,9 +3636,68 @@ export function lint(
|
|
|
3470
3636
|
}
|
|
3471
3637
|
}
|
|
3472
3638
|
}
|
|
3639
|
+
// Issue #400: when a hand layer holds fewer opaque pixels than the bar derived from the other hand layer, or from the
|
|
3640
|
+
// forearm's width when the other side has no hand layer, while the wrist keypoint says a hand is there.
|
|
3641
|
+
if (joints !== undefined) {
|
|
3642
|
+
const handOf = (s: 'r' | 'l'): PartRecord | null => {
|
|
3643
|
+
const ps = P.byTag(`handwear-${s}`).filter((p) => p.limb === undefined);
|
|
3644
|
+
return ps.length === 1 ? ps[0] : null;
|
|
3645
|
+
};
|
|
3646
|
+
for (const s of ['r', 'l'] as const) {
|
|
3647
|
+
const wrist: KeypointName = `${s}_wrist`;
|
|
3648
|
+
const wristAt = joints.joints[wrist].at;
|
|
3649
|
+
const part = handOf(s);
|
|
3650
|
+
if (wristAt === null || part === null) continue;
|
|
3651
|
+
const other = handOf(s === 'r' ? 'l' : 'r');
|
|
3652
|
+
if (other !== null) {
|
|
3653
|
+
const bar = Math.ceil(HAND_MIN_OF_OTHER * other.opaque_px);
|
|
3654
|
+
if (part.opaque_px < bar) {
|
|
3655
|
+
findings.push({
|
|
3656
|
+
kind: 'near-empty-hand',
|
|
3657
|
+
part: part.name,
|
|
3658
|
+
from: part.from,
|
|
3659
|
+
side: s,
|
|
3660
|
+
wrist,
|
|
3661
|
+
wristAt: [wristAt[0], wristAt[1]],
|
|
3662
|
+
opaquePx: part.opaque_px,
|
|
3663
|
+
bar,
|
|
3664
|
+
basis: { kind: 'other-hand', otherPart: other.name, otherFrom: other.from, otherPx: other.opaque_px, share: HAND_MIN_OF_OTHER },
|
|
3665
|
+
});
|
|
3666
|
+
}
|
|
3667
|
+
continue;
|
|
3668
|
+
}
|
|
3669
|
+
const elAt = joints.joints[`${s}_elbow`].at;
|
|
3670
|
+
if (elAt === null) continue;
|
|
3671
|
+
const dx = wristAt[0] - elAt[0];
|
|
3672
|
+
const dy = wristAt[1] - elAt[1];
|
|
3673
|
+
const len = edgeLength(dx, dy);
|
|
3674
|
+
if (len === 0) continue;
|
|
3675
|
+
const armMasks = [...P.byTag('topwear'), part].map((p) => P.alpha(p));
|
|
3676
|
+
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);
|
|
3677
|
+
const mid: [number, number] = [(elAt[0] + wristAt[0]) / 2, (elAt[1] + wristAt[1]) / 2];
|
|
3678
|
+
const axis: [number, number] = [dx / len, dy / len];
|
|
3679
|
+
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]);
|
|
3680
|
+
if (w === null) continue;
|
|
3681
|
+
const bar = Math.ceil(w * w);
|
|
3682
|
+
if (part.opaque_px < bar) {
|
|
3683
|
+
findings.push({
|
|
3684
|
+
kind: 'near-empty-hand',
|
|
3685
|
+
part: part.name,
|
|
3686
|
+
from: part.from,
|
|
3687
|
+
side: s,
|
|
3688
|
+
wrist,
|
|
3689
|
+
wristAt: [wristAt[0], wristAt[1]],
|
|
3690
|
+
opaquePx: part.opaque_px,
|
|
3691
|
+
bar,
|
|
3692
|
+
basis: { kind: 'forearm-width', width: w },
|
|
3693
|
+
});
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3473
3697
|
// Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
|
|
3474
3698
|
// to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
|
|
3475
|
-
const
|
|
3699
|
+
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 }]));
|
|
3700
|
+
const sq = chainSqueezes({ bones: spec.bones, meshes: spec.meshes, motion: spec.motion }, partImages);
|
|
3476
3701
|
for (const { mesh, a, b } of sq.read) {
|
|
3477
3702
|
for (const [c, other] of [[a, b], [b, a]]) {
|
|
3478
3703
|
const e = spec.bones.find((x) => 'chain' in x && x.chain === c);
|
|
@@ -3481,10 +3706,11 @@ export function lint(
|
|
|
3481
3706
|
}
|
|
3482
3707
|
for (const f of sq.findings) findings.push({ kind: 'squeeze', ...f });
|
|
3483
3708
|
const squeezeUnread = sq.unread;
|
|
3709
|
+
const branchSq = branchSqueezes({ bones: spec.bones, meshes: spec.meshes, constraints: spec.constraints }, partImages);
|
|
3484
3710
|
const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
|
|
3485
3711
|
return basis === undefined
|
|
3486
|
-
? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, coverage }
|
|
3487
|
-
: { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, basis, coverage };
|
|
3712
|
+
? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, branchSqueezes: branchSq, coverage }
|
|
3713
|
+
: { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, branchSqueezes: branchSq, basis, coverage };
|
|
3488
3714
|
}
|
|
3489
3715
|
|
|
3490
3716
|
/** 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. */
|
|
@@ -3503,6 +3729,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
|
3503
3729
|
return ['chest'];
|
|
3504
3730
|
case 'squeeze':
|
|
3505
3731
|
return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
|
|
3732
|
+
case 'near-empty-hand':
|
|
3733
|
+
return [];
|
|
3506
3734
|
case 'chain-not-advancing': {
|
|
3507
3735
|
if (!f.point.endsWith(' tip')) return [f.point];
|
|
3508
3736
|
const chain = f.point.slice(0, -' tip'.length);
|
|
@@ -3670,6 +3898,13 @@ export function lintLine(f: LintFinding): string {
|
|
|
3670
3898
|
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`;
|
|
3671
3899
|
case 'squeeze':
|
|
3672
3900
|
return squeezeLine(f);
|
|
3901
|
+
case 'near-empty-hand': {
|
|
3902
|
+
const why =
|
|
3903
|
+
f.basis.kind === 'other-hand'
|
|
3904
|
+
? `${f.basis.share} of ${f.basis.otherPart} (${f.basis.otherFrom}), ${f.basis.otherPx} px`
|
|
3905
|
+
: `forearm width ${pyFixed(f.basis.width, 1)} px squared`;
|
|
3906
|
+
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`;
|
|
3907
|
+
}
|
|
3673
3908
|
case 'chain-not-advancing':
|
|
3674
3909
|
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`;
|
|
3675
3910
|
}
|
|
@@ -3704,6 +3939,23 @@ function squeezeLine(f: SqueezeLintFinding): string {
|
|
|
3704
3939
|
);
|
|
3705
3940
|
}
|
|
3706
3941
|
|
|
3942
|
+
/**
|
|
3943
|
+
* Issue #403: the note line naming a branch tip's squeezed triangle, the bones (`a` and `b` forked from `parent`),
|
|
3944
|
+
* the mechanism (`the weights where two branches' cells meet` and, when present, unequal spring strength on the fork),
|
|
3945
|
+
* and the setting (`split.hold_tip: true`) that holds the tip.
|
|
3946
|
+
*/
|
|
3947
|
+
export function branchSqueezeLine(f: BranchSqueezeFinding): string {
|
|
3948
|
+
const stiff =
|
|
3949
|
+
f.strengthA !== null && f.strengthB !== null && f.strengthA !== f.strengthB
|
|
3950
|
+
? ` and unequal spring strength on the fork (${f.a} ${f.strengthA} vs ${f.b} ${f.strengthB})`
|
|
3951
|
+
: '';
|
|
3952
|
+
return (
|
|
3953
|
+
`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) ` +
|
|
3954
|
+
`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} — ` +
|
|
3955
|
+
`set split.hold_tip: true to hold the branch tips (keeps branch strength across the fork and raises r to grid)`
|
|
3956
|
+
);
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3707
3959
|
export interface Comparison {
|
|
3708
3960
|
rows: Array<[string, number]>;
|
|
3709
3961
|
onlyConfig: string[];
|