@rig-c/parts 1.24.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 +24 -3
- 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 +7 -4
- package/src/cloth.ts +6 -3
- package/src/config.ts +107 -6
- package/src/limbs.ts +171 -0
- package/src/propose.ts +221 -31
- package/src/rig.ts +112 -12
- package/src/squeeze.ts +161 -1
package/src/limbs.ts
CHANGED
|
@@ -81,6 +81,17 @@
|
|
|
81
81
|
* cap's disc and the limb's width stay where the bone's origin puts them;
|
|
82
82
|
* only the line that splits the pixels between the two pieces moves, and
|
|
83
83
|
* the cap takes the disc's pixels on the upper side of that line.
|
|
84
|
+
* - **Overlapping hands** (issue #400, {@link assignOverlappingHands}). When
|
|
85
|
+
* two forearm segments (`joint: "elbow"`, with a wrist end) share a part,
|
|
86
|
+
* each hand's keypoint region is the disc of radius half its forearm's
|
|
87
|
+
* length (`L / 2`) about its wrist, and its cuff is the line across the
|
|
88
|
+
* forearm at `L - L / 2 = L / 2` from the elbow, where that disc meets the
|
|
89
|
+
* bone's axis. When the two hand discs overlap, the overlapping hand region
|
|
90
|
+
* goes to the front piece (the limb `config.limbs` declares later, which
|
|
91
|
+
* `limbOrder` draws in front) in one piece, and the back piece ends at its
|
|
92
|
+
* cuff; any 4-connected island on a forearm piece that leaves its own hand's
|
|
93
|
+
* keypoint region is transferred to the other forearm piece, so no slivers
|
|
94
|
+
* are cut out of the front hand for the back arm.
|
|
84
95
|
*/
|
|
85
96
|
import type { BoneEntry, Limb } from './config.ts';
|
|
86
97
|
|
|
@@ -429,3 +440,163 @@ export function capBox(at: Pt, radius: number): [number, number, number, number]
|
|
|
429
440
|
export function inDisc(at: Pt, radius: number, px: number, py: number): boolean {
|
|
430
441
|
return (px + 0.5 - at[0]) ** 2 + (py + 0.5 - at[1]) ** 2 <= radius * radius;
|
|
431
442
|
}
|
|
443
|
+
|
|
444
|
+
/** A forearm segment on a shared part, for {@link assignOverlappingHands} (issue #400). */
|
|
445
|
+
export interface ForearmShare {
|
|
446
|
+
/** The limb's index in `Object.keys(limbs)`. */
|
|
447
|
+
limb: number;
|
|
448
|
+
/** The forearm segment's index in `limbs[limb].segments`. */
|
|
449
|
+
seg: number;
|
|
450
|
+
/** The elbow bone origin, in rig px. */
|
|
451
|
+
elbow: Pt;
|
|
452
|
+
/** The wrist bone end (`tip`), in rig px. */
|
|
453
|
+
wrist: Pt;
|
|
454
|
+
/** Whether pixel `(x, y)` on `limb` falls on this forearm segment (`segmentOf === seg`). */
|
|
455
|
+
isSeg: (x: number, y: number) => boolean;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Issue #400: when two forearm segments (`back` and `front`, in `config.limbs`
|
|
460
|
+
* order so `front` is drawn over `back`) share a part, each hand's keypoint
|
|
461
|
+
* region is the disc of radius `r = L / 2` about its wrist (`L` the forearm
|
|
462
|
+
* bone's length), and `back`'s cuff is the line across the forearm at
|
|
463
|
+
* `L - r = L / 2` from `back.elbow`. When the two wrist discs overlap, `back`'s
|
|
464
|
+
* pixels past its cuff (or inside `front`'s hand disc) go to `front`'s forearm
|
|
465
|
+
* in one piece so `back` ends at its cuff; and any 4-connected island on a
|
|
466
|
+
* forearm piece that leaves its own hand's keypoint region goes to the other
|
|
467
|
+
* forearm piece. Mutates `own` and `segOverride` in place.
|
|
468
|
+
*/
|
|
469
|
+
export function assignOverlappingHands(back: ForearmShare, front: ForearmShare, W: number, H: number, own: Int32Array, segOverride: Int32Array): void {
|
|
470
|
+
const dx0 = back.wrist[0] - back.elbow[0];
|
|
471
|
+
const dy0 = back.wrist[1] - back.elbow[1];
|
|
472
|
+
const L0 = Math.sqrt(dx0 * dx0 + dy0 * dy0);
|
|
473
|
+
const dx1 = front.wrist[0] - front.elbow[0];
|
|
474
|
+
const dy1 = front.wrist[1] - front.elbow[1];
|
|
475
|
+
const L1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
|
476
|
+
if (L0 === 0 || L1 === 0) return;
|
|
477
|
+
const r0 = L0 / 2;
|
|
478
|
+
const r1 = L1 / 2;
|
|
479
|
+
const u0x = dx0 / L0;
|
|
480
|
+
const u0y = dy0 / L0;
|
|
481
|
+
const cuff0 = L0 - r0;
|
|
482
|
+
const proj0 = (x: number, y: number): number => (x + 0.5 - back.elbow[0]) * u0x + (y + 0.5 - back.elbow[1]) * u0y;
|
|
483
|
+
const onSeg = (f: ForearmShare, p: number): boolean => own[p] === f.limb && (segOverride[p] === f.seg || (segOverride[p] === -1 && f.isSeg(p % W, Math.floor(p / W))));
|
|
484
|
+
const wristsOverlap = (back.wrist[0] - front.wrist[0]) ** 2 + (back.wrist[1] - front.wrist[1]) ** 2 <= (r0 + r1) ** 2;
|
|
485
|
+
if (wristsOverlap) {
|
|
486
|
+
let cuffHasArt = false;
|
|
487
|
+
for (let y = 0; y < H && !cuffHasArt; y++) {
|
|
488
|
+
for (let x = 0; x < W && !cuffHasArt; x++) {
|
|
489
|
+
if (onSeg(back, y * W + x) && proj0(x, y) <= cuff0) cuffHasArt = true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (cuffHasArt) {
|
|
493
|
+
for (let y = 0; y < H; y++) {
|
|
494
|
+
for (let x = 0; x < W; x++) {
|
|
495
|
+
const p = y * W + x;
|
|
496
|
+
if (onSeg(back, p) && (proj0(x, y) > cuff0 || inDisc(front.wrist, r1, x, y)) && !inDisc(back.elbow, r0 / 2, x, y)) {
|
|
497
|
+
own[p] = front.limb;
|
|
498
|
+
segOverride[p] = front.seg;
|
|
499
|
+
} else if (own[p] === back.limb && inDisc(front.wrist, r1, x, y) && !inDisc(back.elbow, r0, x, y)) {
|
|
500
|
+
own[p] = front.limb;
|
|
501
|
+
segOverride[p] = front.seg;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const componentsOf = (f: ForearmShare): number[][] => {
|
|
508
|
+
const seen = new Uint8Array(W * H);
|
|
509
|
+
const comps: number[][] = [];
|
|
510
|
+
for (let p = 0; p < W * H; p++) {
|
|
511
|
+
if (seen[p] === 1 || !onSeg(f, p)) continue;
|
|
512
|
+
seen[p] = 1;
|
|
513
|
+
const comp: number[] = [p];
|
|
514
|
+
for (let h = 0; h < comp.length; h++) {
|
|
515
|
+
const q = comp[h];
|
|
516
|
+
const x = q % W;
|
|
517
|
+
const y = (q - x) / W;
|
|
518
|
+
for (const [nx, ny] of [
|
|
519
|
+
[x - 1, y],
|
|
520
|
+
[x + 1, y],
|
|
521
|
+
[x, y - 1],
|
|
522
|
+
[x, y + 1],
|
|
523
|
+
]) {
|
|
524
|
+
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
|
525
|
+
const np = ny * W + nx;
|
|
526
|
+
if (seen[np] === 0 && onSeg(f, np)) {
|
|
527
|
+
seen[np] = 1;
|
|
528
|
+
comp.push(np);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
comps.push(comp);
|
|
533
|
+
}
|
|
534
|
+
return comps;
|
|
535
|
+
};
|
|
536
|
+
const elbowDist2 = (comp: readonly number[], elbow: Pt): number => {
|
|
537
|
+
let best = Infinity;
|
|
538
|
+
for (const p of comp) {
|
|
539
|
+
const x = p % W;
|
|
540
|
+
const y = (p - x) / W;
|
|
541
|
+
const d = (x + 0.5 - elbow[0]) ** 2 + (y + 0.5 - elbow[1]) ** 2;
|
|
542
|
+
if (d < best) best = d;
|
|
543
|
+
}
|
|
544
|
+
return best;
|
|
545
|
+
};
|
|
546
|
+
const backComps = componentsOf(back);
|
|
547
|
+
if (backComps.length > 1) {
|
|
548
|
+
let main = 0;
|
|
549
|
+
let mainD = elbowDist2(backComps[0], back.elbow);
|
|
550
|
+
for (let i = 1; i < backComps.length; i++) {
|
|
551
|
+
const d = elbowDist2(backComps[i], back.elbow);
|
|
552
|
+
if (d < mainD) {
|
|
553
|
+
mainD = d;
|
|
554
|
+
main = i;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
backComps.forEach((comp, i) => {
|
|
558
|
+
if (i === main) return;
|
|
559
|
+
const leavesBack = comp.some((p) => !inDisc(back.wrist, r0, p % W, Math.floor(p / W)));
|
|
560
|
+
const inFront = comp.some((p) => inDisc(front.wrist, r1, p % W, Math.floor(p / W)));
|
|
561
|
+
if (wristsOverlap || leavesBack || inFront) {
|
|
562
|
+
for (const p of comp) {
|
|
563
|
+
own[p] = front.limb;
|
|
564
|
+
segOverride[p] = front.seg;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const frontComps = componentsOf(front);
|
|
570
|
+
if (frontComps.length > 1) {
|
|
571
|
+
let main = 0;
|
|
572
|
+
let mainD = elbowDist2(frontComps[0], front.elbow);
|
|
573
|
+
for (let i = 1; i < frontComps.length; i++) {
|
|
574
|
+
const d = elbowDist2(frontComps[i], front.elbow);
|
|
575
|
+
if (d < mainD) {
|
|
576
|
+
mainD = d;
|
|
577
|
+
main = i;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
frontComps.forEach((comp, i) => {
|
|
581
|
+
if (i === main) return;
|
|
582
|
+
const leavesFront = comp.some((p) => !inDisc(front.wrist, r1, p % W, Math.floor(p / W)));
|
|
583
|
+
const touchesBack = comp.some((p) => {
|
|
584
|
+
const x = p % W;
|
|
585
|
+
const y = (p - x) / W;
|
|
586
|
+
return (
|
|
587
|
+
(x > 0 && onSeg(back, p - 1)) ||
|
|
588
|
+
(x + 1 < W && onSeg(back, p + 1)) ||
|
|
589
|
+
(y > 0 && onSeg(back, p - W)) ||
|
|
590
|
+
(y + 1 < H && onSeg(back, p + W))
|
|
591
|
+
);
|
|
592
|
+
});
|
|
593
|
+
if (leavesFront && touchesBack) {
|
|
594
|
+
for (const p of comp) {
|
|
595
|
+
own[p] = back.limb;
|
|
596
|
+
segOverride[p] = back.seg;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
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_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
|
// ---------------------------------------------------------------------------
|
|
@@ -1554,7 +1558,7 @@ export function proposeWithBasis(
|
|
|
1554
1558
|
all.push(...trees);
|
|
1555
1559
|
}
|
|
1556
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'));
|
|
1557
|
-
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 };
|
|
1558
1562
|
// Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
|
|
1559
1563
|
const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
|
|
1560
1564
|
for (const t of all) {
|
|
@@ -1728,7 +1732,8 @@ export function proposeWithBasis(
|
|
|
1728
1732
|
tracks.push({ chain: name, amps: [0.8, 1.6, 2.6, 3.6], period: 4.0, phase: 0.16, lag: 0.07 });
|
|
1729
1733
|
k++;
|
|
1730
1734
|
}
|
|
1731
|
-
|
|
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 };
|
|
1732
1737
|
}
|
|
1733
1738
|
|
|
1734
1739
|
// ---- accessories: rigid body + pendant chain where the part narrows, or one pendulum chain per hanging strand
|
|
@@ -2204,12 +2209,8 @@ export function proposeWithBasis(
|
|
|
2204
2209
|
}
|
|
2205
2210
|
|
|
2206
2211
|
// ---- 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;
|
|
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 } => {
|
|
2213
2214
|
let segs: Segment[] = [
|
|
2214
2215
|
['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
|
|
2215
2216
|
['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
|
|
@@ -2231,26 +2232,67 @@ export function proposeWithBasis(
|
|
|
2231
2232
|
const down: readonly [number, number] = trunk === null ? [0, 1] : trunk.u;
|
|
2232
2233
|
const artTop = artExtent(mask, p, hip, down)[0];
|
|
2233
2234
|
const chestAt = (chest[0] - hip[0]) * down[0] + (chest[1] - hip[1]) * down[1];
|
|
2234
|
-
if (artTop
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
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 }));
|
|
2239
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);
|
|
2240
2289
|
// Issue #364: with config.cloth.bottomwear, the recipe's trees hang from the hip in place of the three keyed chains.
|
|
2241
2290
|
const skirtRecipe = recipeOf('bottomwear');
|
|
2242
2291
|
if (skirtRecipe !== null) {
|
|
2243
2292
|
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
|
-
});
|
|
2293
|
+
const fixed = [...segs];
|
|
2294
|
+
const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
|
|
2295
|
+
if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
|
|
2254
2296
|
for (const t of trees) segs.push(t.chain);
|
|
2255
2297
|
let swing: { bone: string; rest: number } | null = null;
|
|
2256
2298
|
if (skirtRecipe.swing !== undefined) {
|
|
@@ -2283,7 +2325,7 @@ export function proposeWithBasis(
|
|
|
2283
2325
|
p === namedSkirt ? p.from : undefined,
|
|
2284
2326
|
),
|
|
2285
2327
|
);
|
|
2286
|
-
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 };
|
|
2287
2329
|
continue;
|
|
2288
2330
|
}
|
|
2289
2331
|
for (const [tag, f, ph] of [
|
|
@@ -2308,6 +2350,28 @@ export function proposeWithBasis(
|
|
|
2308
2350
|
}
|
|
2309
2351
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
2310
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
|
+
}
|
|
2311
2375
|
|
|
2312
2376
|
// ---- legs (issue #241)
|
|
2313
2377
|
// Only with joints: a legwear part whose art holds the ankle of a leg whose hip, knee and ankle are all given is
|
|
@@ -3243,7 +3307,45 @@ export interface ChainNotAdvancingFinding {
|
|
|
3243
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`). */
|
|
3244
3308
|
export type SqueezeLintFinding = { kind: 'squeeze' } & SqueezeFinding;
|
|
3245
3309
|
|
|
3246
|
-
|
|
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;
|
|
3247
3349
|
|
|
3248
3350
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
3249
3351
|
export interface LintBasis {
|
|
@@ -3263,6 +3365,8 @@ export interface LintResult {
|
|
|
3263
3365
|
missingTorsoBones: string[];
|
|
3264
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. */
|
|
3265
3367
|
squeezeUnread: string[];
|
|
3368
|
+
/** Issue #403: sibling branch pairs on a fork whose cell-boundary weights and spring strengths are unheld. */
|
|
3369
|
+
branchSqueezes: BranchSqueezeFinding[];
|
|
3266
3370
|
/** Present only under `--keypoints`: the rule set that read the torso and the chains. */
|
|
3267
3371
|
basis?: LintBasis;
|
|
3268
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). */
|
|
@@ -3470,9 +3574,68 @@ export function lint(
|
|
|
3470
3574
|
}
|
|
3471
3575
|
}
|
|
3472
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
|
+
}
|
|
3473
3635
|
// Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
|
|
3474
3636
|
// to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
|
|
3475
|
-
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);
|
|
3476
3639
|
for (const { mesh, a, b } of sq.read) {
|
|
3477
3640
|
for (const [c, other] of [[a, b], [b, a]]) {
|
|
3478
3641
|
const e = spec.bones.find((x) => 'chain' in x && x.chain === c);
|
|
@@ -3481,10 +3644,11 @@ export function lint(
|
|
|
3481
3644
|
}
|
|
3482
3645
|
for (const f of sq.findings) findings.push({ kind: 'squeeze', ...f });
|
|
3483
3646
|
const squeezeUnread = sq.unread;
|
|
3647
|
+
const branchSq = branchSqueezes({ bones: spec.bones, meshes: spec.meshes, constraints: spec.constraints }, partImages);
|
|
3484
3648
|
const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
|
|
3485
3649
|
return basis === undefined
|
|
3486
|
-
? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, coverage }
|
|
3487
|
-
: { 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 };
|
|
3488
3652
|
}
|
|
3489
3653
|
|
|
3490
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. */
|
|
@@ -3503,6 +3667,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
|
3503
3667
|
return ['chest'];
|
|
3504
3668
|
case 'squeeze':
|
|
3505
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 [];
|
|
3506
3672
|
case 'chain-not-advancing': {
|
|
3507
3673
|
if (!f.point.endsWith(' tip')) return [f.point];
|
|
3508
3674
|
const chain = f.point.slice(0, -' tip'.length);
|
|
@@ -3670,6 +3836,13 @@ export function lintLine(f: LintFinding): string {
|
|
|
3670
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`;
|
|
3671
3837
|
case 'squeeze':
|
|
3672
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
|
+
}
|
|
3673
3846
|
case 'chain-not-advancing':
|
|
3674
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`;
|
|
3675
3848
|
}
|
|
@@ -3704,6 +3877,23 @@ function squeezeLine(f: SqueezeLintFinding): string {
|
|
|
3704
3877
|
);
|
|
3705
3878
|
}
|
|
3706
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
|
+
|
|
3707
3897
|
export interface Comparison {
|
|
3708
3898
|
rows: Array<[string, number]>;
|
|
3709
3899
|
onlyConfig: string[];
|