@rig-c/parts 1.27.0 → 1.29.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/docs/AUTHORING.md +15 -5
- package/docs/STABILITY.md +4 -2
- package/package.json +1 -1
- package/src/assemble.ts +286 -13
- package/src/check.ts +23 -5
- package/src/cloth.ts +297 -29
- package/src/config.ts +160 -6
- package/src/coords.ts +1 -1
- package/src/diagnostics.ts +1 -0
- package/src/limbs.ts +221 -1
- package/src/motion.ts +15 -5
- package/src/propose.ts +618 -37
- package/src/rig.ts +13 -2
- package/src/turn.ts +665 -1
package/src/propose.ts
CHANGED
|
@@ -146,9 +146,9 @@ import { configRoles, type RoleOf } from './structure.ts';
|
|
|
146
146
|
import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
|
|
147
147
|
import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
|
|
148
148
|
import { edgeLength } from './length.ts';
|
|
149
|
-
import { limbWidth } from './limbs.ts';
|
|
149
|
+
import { bonePlaces, limbCrossing, limbPolyline, limbWidth } from './limbs.ts';
|
|
150
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, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
|
|
151
|
+
import { backHairMasses, type BackHairMass, RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput, type TreeMassInput } from './cloth.ts';
|
|
152
152
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
153
153
|
|
|
154
154
|
// ---------------------------------------------------------------------------
|
|
@@ -397,6 +397,7 @@ function konst(name: string, value: number): BasisConstant {
|
|
|
397
397
|
* plan part and which as the piece (issue #230).
|
|
398
398
|
*/
|
|
399
399
|
export function proposalAssemble(P: PartSet): Record<string, unknown> {
|
|
400
|
+
P = P.beforeLimbs();
|
|
400
401
|
// A patch is not a plan entry; it goes back as the patches entry it came
|
|
401
402
|
// from, so the loader holds it to its own rule (a region, never a mesh).
|
|
402
403
|
// Its box, alpha and draw are the record's own box and placeholders the
|
|
@@ -492,6 +493,7 @@ export type TurnRuns = Partial<Record<'full' | 'head', LayerSet>>;
|
|
|
492
493
|
* span away.
|
|
493
494
|
*/
|
|
494
495
|
export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null; notes: string[] } {
|
|
496
|
+
P = P.beforeLimbs();
|
|
495
497
|
const problems: Problem[] = [];
|
|
496
498
|
const read: Array<{ part: string; run: 'full' | 'head'; tag: string; base: string | null; own: number; area: number; order: number; piece: boolean }> = [];
|
|
497
499
|
const patches: string[] = [];
|
|
@@ -629,6 +631,7 @@ export class PartSet {
|
|
|
629
631
|
readonly recs: readonly PartRecord[];
|
|
630
632
|
readonly images: ReadonlyMap<string, Raster>;
|
|
631
633
|
private readonly masks = new Map<string, Mask>();
|
|
634
|
+
private preLimbs: PartSet | null = null;
|
|
632
635
|
|
|
633
636
|
constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
|
|
634
637
|
[this.W, this.H] = file.rig_size;
|
|
@@ -664,6 +667,170 @@ export class PartSet {
|
|
|
664
667
|
return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
|
|
665
668
|
}
|
|
666
669
|
|
|
670
|
+
/**
|
|
671
|
+
* Issue #440: limb records (`r.limb !== undefined`) that cannot be reassembled
|
|
672
|
+
* into their pre-limb source parts (`r.limb.of`) — a limb part handed to
|
|
673
|
+
* `propose` on its own or with an incomplete segment chain (fewer than two
|
|
674
|
+
* segments, a segment index missing from `0..n-1`, a lower segment without
|
|
675
|
+
* its `cap` record, or the source part `of` still present beside the limb
|
|
676
|
+
* parts). When every limb in `parts.json` is complete, returns `[]` and
|
|
677
|
+
* {@link beforeLimbs} reconstructs the pre-limb part set.
|
|
678
|
+
*/
|
|
679
|
+
standaloneLimbParts(): PartRecord[] {
|
|
680
|
+
const limbed = this.recs.filter((r) => r.limb !== undefined);
|
|
681
|
+
if (limbed.length === 0) return [];
|
|
682
|
+
const nonLimbNames = new Set(this.recs.filter((r) => r.limb === undefined).map((r) => r.name));
|
|
683
|
+
const byLimb = new Map<string, PartRecord[]>();
|
|
684
|
+
for (const r of limbed) {
|
|
685
|
+
const l = r.limb as NonNullable<PartRecord['limb']>;
|
|
686
|
+
byLimb.set(l.name, [...(byLimb.get(l.name) ?? []), r]);
|
|
687
|
+
}
|
|
688
|
+
const badLimbs = new Set<string>();
|
|
689
|
+
const badOf = new Set<string>();
|
|
690
|
+
for (const [name, segs] of byLimb) {
|
|
691
|
+
const sorted = [...segs].sort((a, b) => (a.limb?.segment ?? 0) - (b.limb?.segment ?? 0));
|
|
692
|
+
const complete =
|
|
693
|
+
sorted.length >= 2 &&
|
|
694
|
+
sorted.every((s, i) => {
|
|
695
|
+
const l = s.limb as NonNullable<PartRecord['limb']>;
|
|
696
|
+
return l.segment === i && (i === 0 ? l.cap === undefined : l.cap !== undefined) && !nonLimbNames.has(l.of);
|
|
697
|
+
});
|
|
698
|
+
if (!complete) {
|
|
699
|
+
badLimbs.add(name);
|
|
700
|
+
for (const s of segs) badOf.add((s.limb as NonNullable<PartRecord['limb']>).of);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return limbed.filter((r) => {
|
|
704
|
+
const l = r.limb as NonNullable<PartRecord['limb']>;
|
|
705
|
+
return badLimbs.has(l.name) || badOf.has(l.of);
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Issue #440: when `parts.json` came from an `assemble` run with `config.limbs`
|
|
711
|
+
* (every limb complete, {@link standaloneLimbParts} empty), reconstruct the
|
|
712
|
+
* pre-limb part set so `propose`, `draftTurn`, `checkProposal` and
|
|
713
|
+
* `checkBasis` read the whole layer each limb took in a single run. Without
|
|
714
|
+
* limb records (or when a standalone limb part will be refused by
|
|
715
|
+
* `PROPOSE_LIMB_PARTS`), returns `this`.
|
|
716
|
+
*/
|
|
717
|
+
beforeLimbs(): PartSet {
|
|
718
|
+
if (this.preLimbs !== null) return this.preLimbs;
|
|
719
|
+
if (!this.recs.some((r) => r.limb !== undefined) || this.standaloneLimbParts().length > 0) {
|
|
720
|
+
this.preLimbs = this;
|
|
721
|
+
return this;
|
|
722
|
+
}
|
|
723
|
+
const segsByOf = new Map<string, PartRecord[]>();
|
|
724
|
+
const segByLimb = new Map<string, PartRecord>();
|
|
725
|
+
for (const r of this.recs) {
|
|
726
|
+
if (r.limb === undefined) continue;
|
|
727
|
+
segsByOf.set(r.limb.of, [...(segsByOf.get(r.limb.of) ?? []), r]);
|
|
728
|
+
segByLimb.set(`${r.limb.name}:${r.limb.segment}`, r);
|
|
729
|
+
}
|
|
730
|
+
const buildOf = (ofName: string): { rec: PartRecord; raster: Raster } => {
|
|
731
|
+
const segs = segsByOf.get(ofName) as PartRecord[];
|
|
732
|
+
const union = new Uint8Array(this.W * this.H);
|
|
733
|
+
for (const s of segs) {
|
|
734
|
+
const m = this.alpha(s);
|
|
735
|
+
const l = s.limb as NonNullable<PartRecord['limb']>;
|
|
736
|
+
const up = l.segment > 0 ? segByLimb.get(`${l.name}:${l.segment - 1}`) : undefined;
|
|
737
|
+
const dropUpperCap = up !== undefined && up.limb?.of !== ofName && (l.cap?.upper_px ?? 0) > 0 && (l.cap?.layer_px ?? 0) === 0 ? this.alpha(up) : null;
|
|
738
|
+
const box = l.cap?.box;
|
|
739
|
+
for (let y = s.y; y < s.y + s.h; y++) {
|
|
740
|
+
for (let x = s.x; x < s.x + s.w; x++) {
|
|
741
|
+
const p = y * this.W + x;
|
|
742
|
+
if (!m.data[p]) continue;
|
|
743
|
+
if (dropUpperCap !== null && box !== undefined && x >= box[0] && x < box[0] + box[2] && y >= box[1] && y < box[1] + box[3] && dropUpperCap.data[p]) continue;
|
|
744
|
+
union[p] = 1;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
let x0 = this.W;
|
|
749
|
+
let y0 = this.H;
|
|
750
|
+
let x1 = -1;
|
|
751
|
+
let y1 = -1;
|
|
752
|
+
let opaquePx = 0;
|
|
753
|
+
for (let y = 0; y < this.H; y++) {
|
|
754
|
+
for (let x = 0; x < this.W; x++) {
|
|
755
|
+
if (!union[y * this.W + x]) continue;
|
|
756
|
+
if (x < x0) x0 = x;
|
|
757
|
+
if (x > x1) x1 = x;
|
|
758
|
+
if (y < y0) y0 = y;
|
|
759
|
+
if (y > y1) y1 = y;
|
|
760
|
+
opaquePx++;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const first = segs[0];
|
|
764
|
+
if (x1 < x0) {
|
|
765
|
+
x0 = first.x;
|
|
766
|
+
y0 = first.y;
|
|
767
|
+
x1 = first.x + first.w - 1;
|
|
768
|
+
y1 = first.y + first.h - 1;
|
|
769
|
+
}
|
|
770
|
+
const w = x1 - x0 + 1;
|
|
771
|
+
const h = y1 - y0 + 1;
|
|
772
|
+
const raster = newRaster(w, h);
|
|
773
|
+
for (let y = 0; y < h; y++) {
|
|
774
|
+
for (let x = 0; x < w; x++) {
|
|
775
|
+
if (union[(y0 + y) * this.W + x0 + x]) raster.data[(y * w + x) * 4 + 3] = 255;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const sum = (f: (r: PartRecord) => number): number => segs.reduce((acc, r) => acc + f(r), 0);
|
|
779
|
+
const hasVis = segs.every((r) => r.visible_px !== undefined && r.occluded_px !== undefined);
|
|
780
|
+
const rec: PartRecord = {
|
|
781
|
+
name: ofName,
|
|
782
|
+
from: first.from,
|
|
783
|
+
...(first.cut_from === undefined ? {} : { cut_from: first.cut_from }),
|
|
784
|
+
x: x0,
|
|
785
|
+
y: y0,
|
|
786
|
+
w,
|
|
787
|
+
h,
|
|
788
|
+
opaque_px: opaquePx,
|
|
789
|
+
...(hasVis ? { visible_px: sum((r) => r.visible_px as number), occluded_px: sum((r) => r.occluded_px as number) } : {}),
|
|
790
|
+
projected_core_px: sum((r) => r.projected_core_px),
|
|
791
|
+
source_px_taken: sum((r) => r.source_px_taken),
|
|
792
|
+
...(segs.some((r) => r.visible_not_projected_px !== undefined) ? { visible_not_projected_px: sum((r) => r.visible_not_projected_px ?? 0) } : {}),
|
|
793
|
+
refused_drift_px: sum((r) => r.refused_drift_px),
|
|
794
|
+
merged_px: sum((r) => r.merged_px),
|
|
795
|
+
seam_override_px: sum((r) => r.seam_override_px),
|
|
796
|
+
};
|
|
797
|
+
return { rec, raster };
|
|
798
|
+
};
|
|
799
|
+
const recs: PartRecord[] = [];
|
|
800
|
+
const images = new Map<string, Raster>();
|
|
801
|
+
const emittedOf = new Set<string>();
|
|
802
|
+
for (const r of this.recs) {
|
|
803
|
+
if (r.limb === undefined) {
|
|
804
|
+
recs.push(r);
|
|
805
|
+
const im = this.images.get(r.name);
|
|
806
|
+
if (im !== undefined) images.set(r.name, im);
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
const seg0 = segByLimb.get(`${r.limb.name}:0`) as PartRecord;
|
|
810
|
+
const rootOf = (seg0.limb as NonNullable<PartRecord['limb']>).of;
|
|
811
|
+
if (emittedOf.has(rootOf)) continue;
|
|
812
|
+
const groupLimbs = new Set(
|
|
813
|
+
this.recs.filter((q) => q.limb !== undefined && q.limb.segment === 0 && q.limb.of === rootOf).map((q) => (q.limb as NonNullable<PartRecord['limb']>).name),
|
|
814
|
+
);
|
|
815
|
+
const minSegByOf = new Map<string, number>();
|
|
816
|
+
for (const q of this.recs) {
|
|
817
|
+
if (q.limb === undefined || !groupLimbs.has(q.limb.name)) continue;
|
|
818
|
+
const prev = minSegByOf.get(q.limb.of);
|
|
819
|
+
if (prev === undefined || q.limb.segment < prev) minSegByOf.set(q.limb.of, q.limb.segment);
|
|
820
|
+
}
|
|
821
|
+
const orderedOfs = [...minSegByOf.entries()].sort((a, b) => a[1] - b[1]).map(([n]) => n);
|
|
822
|
+
for (const ofName of orderedOfs) {
|
|
823
|
+
if (emittedOf.has(ofName)) continue;
|
|
824
|
+
emittedOf.add(ofName);
|
|
825
|
+
const built = buildOf(ofName);
|
|
826
|
+
recs.push(built.rec);
|
|
827
|
+
images.set(ofName, built.raster);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
this.preLimbs = new PartSet({ rig_size: [this.W, this.H], scale_rig_per_source: this.rigScale, ghost_px: {}, parts: recs }, images);
|
|
831
|
+
return this.preLimbs;
|
|
832
|
+
}
|
|
833
|
+
|
|
667
834
|
/** The See-through parts: every part but the `painting:` patches. */
|
|
668
835
|
layered(): PartRecord[] {
|
|
669
836
|
return this.recs.filter((p) => splitFrom(p.from)[0] !== PAINTING_RUN);
|
|
@@ -1226,8 +1393,10 @@ export function proposeWithBasis(
|
|
|
1226
1393
|
detail: `share one layer and none of them records cut_from, so which is the plan part and which the piece an assemble.cuts entry took cannot be read off parts.json (it is in draw order, and a piece drawn "back" comes before its base); re-run assemble (1.6.0+) to record cut_from`,
|
|
1227
1394
|
})),
|
|
1228
1395
|
);
|
|
1229
|
-
// Issue #349: limbs are drafted from the parts before their cut —
|
|
1230
|
-
|
|
1396
|
+
// Issue #349, #440: limbs are drafted from the parts before their cut — a complete assemble with config.limbs
|
|
1397
|
+
// reconstructs its pre-limb parts here in the same run; a standalone or incomplete limb part handed to propose
|
|
1398
|
+
// on its own is refused by name.
|
|
1399
|
+
const limbed = P.standaloneLimbParts();
|
|
1231
1400
|
refuseIfAny(
|
|
1232
1401
|
limbed.length === 0
|
|
1233
1402
|
? []
|
|
@@ -1235,10 +1404,11 @@ export function proposeWithBasis(
|
|
|
1235
1404
|
{
|
|
1236
1405
|
code: 'PROPOSE_LIMB_PARTS',
|
|
1237
1406
|
object: `parts ${limbed.map((r) => `"${r.name}"`).join(', ')}`,
|
|
1238
|
-
detail: `were cut at their joints by config.limbs (from ${[...new Set(limbed.map((r) => `"${(r.limb as NonNullable<PartRecord['limb']>).of}"`))].join(', ')}), and propose drafts the limbs from the
|
|
1407
|
+
detail: `were cut at their joints by config.limbs (from ${[...new Set(limbed.map((r) => `"${(r.limb as NonNullable<PartRecord['limb']>).of}"`))].join(', ')}), and propose drafts the limbs from the whole layer each limb takes; hand propose the complete assemble output (every segment of each limb, with its cap record) so the source part can be reconstructed in one run, or assemble with config.limbs left out — or read the config's bones with propose --from-config`,
|
|
1239
1408
|
},
|
|
1240
1409
|
],
|
|
1241
1410
|
);
|
|
1411
|
+
P = P.beforeLimbs();
|
|
1242
1412
|
const bones: BoneEntry[] = [];
|
|
1243
1413
|
const records: BoneBasis[] = [];
|
|
1244
1414
|
/** Issue #349: the limbs `--keypoints` drafts, and the parts they take (not made, so not given a mesh or a region). */
|
|
@@ -1403,7 +1573,7 @@ export function proposeWithBasis(
|
|
|
1403
1573
|
const T = (p: PartRecord | readonly PartRecord[], tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
|
|
1404
1574
|
const partNames = Array.isArray(p) ? p.map((x) => x.name) : [(p as PartRecord).name];
|
|
1405
1575
|
const trees = draftTrees(input, r);
|
|
1406
|
-
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))];
|
|
1576
|
+
const constants = [konst('cloth_chains', input.masses !== undefined ? input.masses.length : (r.chains ?? RECIPE_BACK_HAIR_CHAINS)), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
|
|
1407
1577
|
for (const t of trees) {
|
|
1408
1578
|
C(
|
|
1409
1579
|
t.chain,
|
|
@@ -1673,27 +1843,45 @@ export function proposeWithBasis(
|
|
|
1673
1843
|
]);
|
|
1674
1844
|
}
|
|
1675
1845
|
}
|
|
1676
|
-
const
|
|
1846
|
+
const massesBySide = new Map<'r' | 'l', BackHairMass[]>(activeSides.map(([s]) => [s, backHairMasses(unionMask, s, axis, y0, shoulderLine, g)]));
|
|
1847
|
+
const useMasses = rawRcp.chains === undefined;
|
|
1677
1848
|
const all: TreeChain[] = [];
|
|
1678
1849
|
let n = 0;
|
|
1679
1850
|
const partNamesLabel = list.map((q) => q.name).join(' -> ');
|
|
1680
1851
|
for (const [side] of activeSides) {
|
|
1681
1852
|
const prefix = prefixOf(side);
|
|
1682
|
-
const
|
|
1853
|
+
const sideMasses = massesBySide.get(side) ?? [];
|
|
1854
|
+
if (useMasses && sideMasses.length === 0) continue;
|
|
1855
|
+
const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
|
|
1856
|
+
const frame = `${partNamesLabel}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
|
|
1857
|
+
const trees = T(list, 'back hair', sideRcp, {
|
|
1683
1858
|
prefix,
|
|
1684
1859
|
parent: 'head',
|
|
1685
1860
|
rootY: eyeY,
|
|
1686
1861
|
tipY: bot - 4,
|
|
1687
1862
|
xAt: (y, f) => bandX(unionMask, y, { frac: f, side, axis }),
|
|
1688
1863
|
fallbackX: () => axis,
|
|
1689
|
-
frame
|
|
1864
|
+
frame,
|
|
1690
1865
|
edges: side,
|
|
1866
|
+
masses: useMasses
|
|
1867
|
+
? sideMasses.map((m): TreeMassInput => ({
|
|
1868
|
+
rootY: m.minY <= y0 ? eyeY : m.minY,
|
|
1869
|
+
tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
|
|
1870
|
+
xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
|
|
1871
|
+
fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
|
|
1872
|
+
frame,
|
|
1873
|
+
}))
|
|
1874
|
+
: undefined,
|
|
1691
1875
|
});
|
|
1692
|
-
const made = treeConstraints(trees,
|
|
1876
|
+
const made = treeConstraints(trees, sideRcp, null);
|
|
1693
1877
|
clothConstraints.push(...made);
|
|
1694
1878
|
n += made.length;
|
|
1695
1879
|
all.push(...trees);
|
|
1696
1880
|
}
|
|
1881
|
+
const rcp: ClothRecipe = {
|
|
1882
|
+
...rawRcp,
|
|
1883
|
+
chains: useMasses ? Math.max(1, ...(activeSides.map(([s]) => (massesBySide.get(s) ?? []).length))) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
|
|
1884
|
+
};
|
|
1697
1885
|
const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
|
|
1698
1886
|
for (const t of all) {
|
|
1699
1887
|
const line = [...t.points, t.tip];
|
|
@@ -1778,9 +1966,11 @@ export function proposeWithBasis(
|
|
|
1778
1966
|
} else if (recipeOf('back hair') !== null && (backHairParts === null || backHairSingleSame)) {
|
|
1779
1967
|
// Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
|
|
1780
1968
|
// 4 px above the part's bottom, in place of the two keyed chains.
|
|
1781
|
-
// Issue #390:
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1969
|
+
// Issue #390: an explicit tree count per side that exceeds `floor(width / grid)` at the root row or roots a tree
|
|
1970
|
+
// outside the 15 px dilated art is refused by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
|
|
1971
|
+
// Issue #445: when `chains` is omitted, the tree count on each side is the number of visible hair masses on that
|
|
1972
|
+
// side (connected components below the root row, merged within one grid cell), one tree per mass, rooted at that
|
|
1973
|
+
// mass's top row and inside its columns.
|
|
1784
1974
|
const rawRcp = recipeOf('back hair') as ClothRecipe;
|
|
1785
1975
|
const mask = P.alpha(p);
|
|
1786
1976
|
const dilated = dilate(mask, 31);
|
|
@@ -1835,30 +2025,66 @@ export function proposeWithBasis(
|
|
|
1835
2025
|
]);
|
|
1836
2026
|
}
|
|
1837
2027
|
}
|
|
1838
|
-
const
|
|
2028
|
+
const shoulderLine = neckY + 0.5 * fh;
|
|
2029
|
+
const rMasses = backHairMasses(mask, 'r', axis, y0, shoulderLine, g);
|
|
2030
|
+
const lMasses = backHairMasses(mask, 'l', axis, y0, shoulderLine, g);
|
|
2031
|
+
const massesBySide = new Map<'r' | 'l', BackHairMass[]>([
|
|
2032
|
+
['r', rMasses],
|
|
2033
|
+
['l', lMasses],
|
|
2034
|
+
]);
|
|
2035
|
+
const useMasses = rawRcp.chains === undefined;
|
|
1839
2036
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
1840
2037
|
const all: TreeChain[] = [];
|
|
1841
2038
|
let n = 0;
|
|
1842
2039
|
for (const [side] of SIDES) {
|
|
1843
2040
|
const prefix = prefixOf(side);
|
|
1844
|
-
const
|
|
2041
|
+
const sideMasses = massesBySide.get(side) ?? [];
|
|
2042
|
+
if (useMasses && sideMasses.length === 0) continue;
|
|
2043
|
+
const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
|
|
2044
|
+
const frame = `${p.name}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
|
|
2045
|
+
const trees = T(p, 'back hair', sideRcp, {
|
|
1845
2046
|
prefix,
|
|
1846
2047
|
parent: 'head',
|
|
1847
2048
|
rootY: eyeY,
|
|
1848
2049
|
tipY: bot - 4,
|
|
1849
2050
|
xAt: (y, f) => bandX(mask, y, { frac: f, side, axis }),
|
|
1850
2051
|
fallbackX: () => axis,
|
|
1851
|
-
frame
|
|
2052
|
+
frame,
|
|
1852
2053
|
edges: side,
|
|
2054
|
+
masses: useMasses
|
|
2055
|
+
? sideMasses.map((m): TreeMassInput => ({
|
|
2056
|
+
rootY: m.minY <= y0 ? eyeY : m.minY,
|
|
2057
|
+
tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
|
|
2058
|
+
xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
|
|
2059
|
+
fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
|
|
2060
|
+
frame,
|
|
2061
|
+
}))
|
|
2062
|
+
: undefined,
|
|
1853
2063
|
});
|
|
1854
2064
|
for (const t of trees) segs.push(t.chain);
|
|
1855
|
-
const made = treeConstraints(trees,
|
|
2065
|
+
const made = treeConstraints(trees, sideRcp, null);
|
|
1856
2066
|
clothConstraints.push(...made);
|
|
1857
2067
|
n += made.length;
|
|
1858
2068
|
all.push(...trees);
|
|
1859
2069
|
}
|
|
1860
|
-
|
|
1861
|
-
|
|
2070
|
+
const sameSideCounts = !useMasses || rMasses.length === lMasses.length;
|
|
2071
|
+
const noteRcp: ClothRecipe = {
|
|
2072
|
+
...rawRcp,
|
|
2073
|
+
chains: useMasses ? (sameSideCounts ? rMasses.length : rMasses.length + lMasses.length) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
|
|
2074
|
+
};
|
|
2075
|
+
notes.push(
|
|
2076
|
+
clothNote(
|
|
2077
|
+
p.name,
|
|
2078
|
+
'back hair',
|
|
2079
|
+
all,
|
|
2080
|
+
noteRcp,
|
|
2081
|
+
n,
|
|
2082
|
+
sameSideCounts
|
|
2083
|
+
? ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'
|
|
2084
|
+
: ` (${rMasses.length} on the r side and ${lMasses.length} on the l side of the eye axis, one per visible hair mass), from head, rooted on each mass's top row and tipped 4 px above its bottom`,
|
|
2085
|
+
),
|
|
2086
|
+
);
|
|
2087
|
+
meshes[p.name] = { grid: g, r: rawRcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
1862
2088
|
// Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
|
|
1863
2089
|
const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
|
|
1864
2090
|
for (const t of all) {
|
|
@@ -2574,17 +2800,118 @@ export function proposeWithBasis(
|
|
|
2574
2800
|
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`,
|
|
2575
2801
|
};
|
|
2576
2802
|
};
|
|
2577
|
-
// Where the recipe's trees hang on a skirt: from 20 px below the hip to 4 px above the
|
|
2578
|
-
const skirtInput = (p: PartRecord, mask: Mask, prefix: string): TreeInput =>
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2803
|
+
// Where the recipe's trees hang on a skirt: from 20 px below the hip to 4 px above the cloth's bottom under each column, across its art.
|
|
2804
|
+
const skirtInput = (p: PartRecord, mask: Mask, prefix: string, r: ClothRecipe, bot = p.y + p.h, emitNotes = true): TreeInput => {
|
|
2805
|
+
const y0 = hip[1] + 20;
|
|
2806
|
+
const y0Int = Math.max(0, pyInt(y0));
|
|
2807
|
+
const botInt = Math.min(mask.height, pyInt(bot));
|
|
2808
|
+
const n = r.chains ?? RECIPE_BACK_HAIR_CHAINS;
|
|
2809
|
+
const into = r.split === undefined ? 1 : r.split.into;
|
|
2810
|
+
const traceLastOpaqueY = (f: number): number => {
|
|
2811
|
+
let lastY = -1;
|
|
2812
|
+
for (let y = y0Int; y < botInt; y++) {
|
|
2813
|
+
const bx = bandX(mask, y, { frac: f }) ?? p.x + f * p.w;
|
|
2814
|
+
const rx = Math.max(0, Math.min(mask.width - 1, pyRound(bx)));
|
|
2815
|
+
if (mask.data[y * mask.width + rx] !== 0) {
|
|
2816
|
+
lastY = y;
|
|
2817
|
+
} else if (lastY >= y0Int) {
|
|
2818
|
+
let gapContinues = false;
|
|
2819
|
+
for (let yy = y + 1; yy <= Math.min(botInt - 1, y + 12); yy++) {
|
|
2820
|
+
if (mask.data[yy * mask.width + rx] !== 0) {
|
|
2821
|
+
gapContinues = true;
|
|
2822
|
+
break;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
if (!gapContinues) break;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
return lastY;
|
|
2829
|
+
};
|
|
2830
|
+
const shares: number[] = [];
|
|
2831
|
+
for (let k = 0; k < n; k++) {
|
|
2832
|
+
shares.push((k + 0.5) / n);
|
|
2833
|
+
if (into > 1) {
|
|
2834
|
+
for (let b = 0; b < into; b++) shares.push((k + (b + 0.5) / into) / n);
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
const uniformBottom = shares.every((f) => {
|
|
2838
|
+
const lastY = traceLastOpaqueY(f);
|
|
2839
|
+
if (lastY >= botInt - 4) return true;
|
|
2840
|
+
if (lastY < botInt - 36 || lastY < y0Int + 4) return false;
|
|
2841
|
+
const txBot = Math.max(0, Math.min(mask.width - 1, pyRound(bandX(mask, bot - 4, { frac: f }) ?? p.x + f * p.w)));
|
|
2842
|
+
for (let yy = Math.max(y0Int, botInt - 16); yy < botInt; yy++) {
|
|
2843
|
+
if (mask.data[yy * mask.width + txBot] !== 0) return true;
|
|
2844
|
+
}
|
|
2845
|
+
return false;
|
|
2846
|
+
});
|
|
2847
|
+
const notedEmpty = new Set<number>();
|
|
2848
|
+
const cappedXAt = (maxY: number) => (y: number, f: number): number | null => {
|
|
2849
|
+
const yi = Math.min(maxY, Math.max(0, pyRound(y)));
|
|
2850
|
+
const a = Math.max(0, pyInt(y - 12));
|
|
2851
|
+
const b = Math.min(maxY + 1, pyInt(y + 12));
|
|
2852
|
+
let x0 = mask.width;
|
|
2853
|
+
let x1 = -1;
|
|
2854
|
+
for (let yy = a; yy < b; yy++) {
|
|
2855
|
+
for (let x = 0; x < mask.width; x++) {
|
|
2856
|
+
if (mask.data[yy * mask.width + x] !== 0) {
|
|
2857
|
+
if (x < x0) x0 = x;
|
|
2858
|
+
if (x > x1) x1 = x;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
const raw = x1 < x0 ? (bandX(mask, yi, { frac: f }) ?? p.x + f * p.w) : x0 + f * (x1 - x0);
|
|
2863
|
+
const rx = Math.max(0, Math.min(mask.width - 1, pyRound(raw)));
|
|
2864
|
+
if (mask.data[yi * mask.width + rx] !== 0) return raw;
|
|
2865
|
+
let bestX: number | null = null;
|
|
2866
|
+
let bestDist = mask.width + 1;
|
|
2867
|
+
for (let x = 0; x < mask.width; x++) {
|
|
2868
|
+
if (mask.data[yi * mask.width + x] !== 0) {
|
|
2869
|
+
const d = Math.abs(x - rx);
|
|
2870
|
+
if (d < bestDist) {
|
|
2871
|
+
bestDist = d;
|
|
2872
|
+
bestX = x;
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
return bestX ?? raw;
|
|
2877
|
+
};
|
|
2878
|
+
return {
|
|
2879
|
+
prefix,
|
|
2880
|
+
parent: 'hip',
|
|
2881
|
+
rootY: y0,
|
|
2882
|
+
tipY: bot - 4,
|
|
2883
|
+
xAt: (y, f) => bandX(mask, y, { frac: f }),
|
|
2884
|
+
fallbackX: (f) => p.x + f * p.w,
|
|
2885
|
+
frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
|
|
2886
|
+
edges: 'both',
|
|
2887
|
+
columnTip: (k, f) => {
|
|
2888
|
+
const col0 = Math.floor(p.x + (k / n) * p.w);
|
|
2889
|
+
const col1 = Math.max(col0, Math.ceil(p.x + ((k + 1) / n) * p.w) - 1);
|
|
2890
|
+
let colMaxY = -1;
|
|
2891
|
+
for (let y = y0Int; y < botInt; y++) {
|
|
2892
|
+
for (let x = col0; x <= col1; x++) {
|
|
2893
|
+
if (mask.data[y * mask.width + x] !== 0) colMaxY = y;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
const fc = (k + 0.5) / n;
|
|
2897
|
+
const trunkLastY = traceLastOpaqueY(fc);
|
|
2898
|
+
if (colMaxY < y0Int + 4 || trunkLastY < y0Int + 4) {
|
|
2899
|
+
if (emitNotes && !notedEmpty.has(k)) {
|
|
2900
|
+
notedEmpty.add(k);
|
|
2901
|
+
notes.push(
|
|
2902
|
+
`${p.name} (${p === namedSkirt ? p.from : 'bottomwear'}): column ${k} of ${n} (x=${col0}..${col1}, share ${roundTo(fc, 6)}) holds no cloth below the root row y=${y0Int} — no tree drafted there`,
|
|
2903
|
+
);
|
|
2904
|
+
}
|
|
2905
|
+
return null;
|
|
2906
|
+
}
|
|
2907
|
+
if (uniformBottom) return { tipY: bot - 4 };
|
|
2908
|
+
const rawLastY = traceLastOpaqueY(f);
|
|
2909
|
+
const lastY = rawLastY >= y0Int + 4 ? rawLastY : trunkLastY;
|
|
2910
|
+
const tipY = Math.min(bot - 4, Math.max(y0Int + 4, lastY - 3));
|
|
2911
|
+
return { tipY, xAt: cappedXAt(lastY) };
|
|
2912
|
+
},
|
|
2913
|
+
};
|
|
2914
|
+
};
|
|
2588
2915
|
// Issue #416: a bottomwear part lying on the trees of the dress config.cloth.bottomwear.part names (a ruffle hem
|
|
2589
2916
|
// See-through returned on its own) rides the links it overlaps, as a layer wrapped round a tail does (issue #391),
|
|
2590
2917
|
// and gets no trees of its own: one skirt carries one set of trees. The dress's mesh is the tail — its fixed
|
|
@@ -2646,12 +2973,12 @@ export function proposeWithBasis(
|
|
|
2646
2973
|
const prefix = 'skirt';
|
|
2647
2974
|
const partNamesLabel = list.map((q) => q.name).join(' -> ');
|
|
2648
2975
|
const trees = T(list, 'bottomwear', dressRecipe, {
|
|
2649
|
-
...skirtInput(p0, unionMask, prefix),
|
|
2976
|
+
...skirtInput(p0, unionMask, prefix, dressRecipe, bot),
|
|
2650
2977
|
tipY: bot - 4,
|
|
2651
2978
|
frame: `${partNamesLabel}'s art (no art in the band: that share of ${p0.name}'s box width)`,
|
|
2652
2979
|
});
|
|
2653
2980
|
let swing: { bone: string; rest: number } | null = null;
|
|
2654
|
-
if (dressRecipe.swing !== undefined) {
|
|
2981
|
+
if (dressRecipe.swing !== undefined && trees.length > 0) {
|
|
2655
2982
|
const cx = or(bandX(unionMask, y0, { frac: 0.5 }), p0.x + 0.5 * p0.w);
|
|
2656
2983
|
const first = trees[0].points.length > 1 ? trees[0].points[1][1] : trees[0].tip[1];
|
|
2657
2984
|
const at: [number, number] = [cx, y0];
|
|
@@ -2739,7 +3066,7 @@ export function proposeWithBasis(
|
|
|
2739
3066
|
? []
|
|
2740
3067
|
: ((): PartRecord[] => {
|
|
2741
3068
|
const mask = P.alpha(namedSkirt);
|
|
2742
|
-
const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
|
|
3069
|
+
const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt', dressRecipe, namedSkirt.y + namedSkirt.h, false), dressRecipe));
|
|
2743
3070
|
return bw.filter((q) => !multiPartClaimed.has(q.name) && overLinks(tail, q).rides);
|
|
2744
3071
|
})();
|
|
2745
3072
|
let dressDrafted: Tail | null = null;
|
|
@@ -2756,11 +3083,11 @@ export function proposeWithBasis(
|
|
|
2756
3083
|
if (skirtRecipe !== null) {
|
|
2757
3084
|
const prefix = skirts.length === 1 ? 'skirt' : `skirt_${p.name}`;
|
|
2758
3085
|
const fixed = [...segs];
|
|
2759
|
-
const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
|
|
3086
|
+
const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix, skirtRecipe));
|
|
2760
3087
|
if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
|
|
2761
3088
|
for (const t of trees) segs.push(t.chain);
|
|
2762
3089
|
let swing: { bone: string; rest: number } | null = null;
|
|
2763
|
-
if (skirtRecipe.swing !== undefined) {
|
|
3090
|
+
if (skirtRecipe.swing !== undefined && trees.length > 0) {
|
|
2764
3091
|
// One link long, straight down from the centre of the root row: the hip is drafted with no tip, so it is
|
|
2765
3092
|
// unturned and the swing's setup rotation in its frame is its own direction, -90 degrees (Spine, y up).
|
|
2766
3093
|
const cx = or(bandX(mask, y0, { frac: 0.5 }), p.x + 0.5 * p.w);
|
|
@@ -3800,6 +4127,45 @@ export interface NearEmptyHandFinding {
|
|
|
3800
4127
|
| { kind: 'forearm-width'; width: number };
|
|
3801
4128
|
}
|
|
3802
4129
|
|
|
4130
|
+
/**
|
|
4131
|
+
* Issue #445: back-hair cloth trees on one side of the eye axis whose count does not match the side's visible hair
|
|
4132
|
+
* masses or whose link or tip leaves its mass's column range.
|
|
4133
|
+
*/
|
|
4134
|
+
export interface BackHairMassesFinding {
|
|
4135
|
+
kind: 'back-hair-masses';
|
|
4136
|
+
mesh: string;
|
|
4137
|
+
side: 'r' | 'l';
|
|
4138
|
+
trunks: string[];
|
|
4139
|
+
masses: Array<{ minX: number; maxX: number; minY: number; maxY: number }>;
|
|
4140
|
+
outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }>;
|
|
4141
|
+
}
|
|
4142
|
+
|
|
4143
|
+
/**
|
|
4144
|
+
* Issue #439: both arms overlap in one 8-connected component of a single handwear layer (`handwear-r` or `handwear-l`),
|
|
4145
|
+
* resolved by declaring `config.seethrough.arm_split` (`x` or `line`, and `order`).
|
|
4146
|
+
*/
|
|
4147
|
+
export interface OverlappingArmsFinding {
|
|
4148
|
+
kind: 'overlapping-arms';
|
|
4149
|
+
part: string;
|
|
4150
|
+
from: string;
|
|
4151
|
+
rAt: [number, number];
|
|
4152
|
+
lAt: [number, number];
|
|
4153
|
+
opaquePx: number;
|
|
4154
|
+
}
|
|
4155
|
+
|
|
4156
|
+
/**
|
|
4157
|
+
* Issue #446: two limbs sharing one part (`from`) cross in an X at `at` with no `config.limbs.<limb>.split`
|
|
4158
|
+
* declared on the covering limb, so `nearestLimb` cuts a straight line through the crossing.
|
|
4159
|
+
*/
|
|
4160
|
+
export interface CrossedLimbsFinding {
|
|
4161
|
+
kind: 'crossed-limbs';
|
|
4162
|
+
part: string;
|
|
4163
|
+
from: string;
|
|
4164
|
+
a: string;
|
|
4165
|
+
b: string;
|
|
4166
|
+
at: [number, number];
|
|
4167
|
+
}
|
|
4168
|
+
|
|
3803
4169
|
export type LintFinding =
|
|
3804
4170
|
| OffArtFinding
|
|
3805
4171
|
| OffLockFinding
|
|
@@ -3810,7 +4176,10 @@ export type LintFinding =
|
|
|
3810
4176
|
| HipNearerNeckFinding
|
|
3811
4177
|
| ChainNotAdvancingFinding
|
|
3812
4178
|
| SqueezeLintFinding
|
|
3813
|
-
| NearEmptyHandFinding
|
|
4179
|
+
| NearEmptyHandFinding
|
|
4180
|
+
| BackHairMassesFinding
|
|
4181
|
+
| OverlappingArmsFinding
|
|
4182
|
+
| CrossedLimbsFinding;
|
|
3814
4183
|
|
|
3815
4184
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
3816
4185
|
export interface LintBasis {
|
|
@@ -3910,7 +4279,7 @@ export function coverageOutcome(c: BoneCoverage): CoverageOutcome {
|
|
|
3910
4279
|
*/
|
|
3911
4280
|
export function lint(
|
|
3912
4281
|
P: PartSet,
|
|
3913
|
-
spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[] },
|
|
4282
|
+
spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[]; limbs?: Readonly<Record<string, Limb>> },
|
|
3914
4283
|
joints?: RigJoints,
|
|
3915
4284
|
): LintResult {
|
|
3916
4285
|
const { byName } = expand(spec.bones);
|
|
@@ -4097,6 +4466,188 @@ export function lint(
|
|
|
4097
4466
|
}
|
|
4098
4467
|
}
|
|
4099
4468
|
}
|
|
4469
|
+
// Issue #439: when both arms overlap in one 8-connected component of a single handwear layer, name the overlap and
|
|
4470
|
+
// the config.seethrough.arm_split declaration that separates them into two layers.
|
|
4471
|
+
if (joints !== undefined && joints.joints.r_wrist.at !== null && joints.joints.l_wrist.at !== null) {
|
|
4472
|
+
const hwParts = [...P.byTag('handwear-r', undefined, true), ...P.byTag('handwear-l', undefined, true)].filter((p) => p.limb === undefined);
|
|
4473
|
+
if (hwParts.length === 1) {
|
|
4474
|
+
const part = hwParts[0];
|
|
4475
|
+
const rAt: [number, number] = [joints.joints.r_wrist.at[0], joints.joints.r_wrist.at[1]];
|
|
4476
|
+
const lAt: [number, number] = [joints.joints.l_wrist.at[0], joints.joints.l_wrist.at[1]];
|
|
4477
|
+
{
|
|
4478
|
+
const cc = connectedComponents(P.alpha(part), 8);
|
|
4479
|
+
const compsNear = (pt: readonly [number, number]): Set<number> => {
|
|
4480
|
+
const hit = new Set<number>();
|
|
4481
|
+
const px = pyInt(pt[0]);
|
|
4482
|
+
const py = pyInt(pt[1]);
|
|
4483
|
+
const x0 = Math.max(0, px - WRIST_ON_PART_PX);
|
|
4484
|
+
const x1 = Math.min(P.W - 1, px + WRIST_ON_PART_PX);
|
|
4485
|
+
const y0 = Math.max(0, py - WRIST_ON_PART_PX);
|
|
4486
|
+
const y1 = Math.min(P.H - 1, py + WRIST_ON_PART_PX);
|
|
4487
|
+
for (let y = y0; y <= y1; y++) {
|
|
4488
|
+
for (let x = x0; x <= x1; x++) {
|
|
4489
|
+
const l = cc.labels[y * P.W + x];
|
|
4490
|
+
if (l > 0) hit.add(l);
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
return hit;
|
|
4494
|
+
};
|
|
4495
|
+
const rComps = compsNear(rAt);
|
|
4496
|
+
const lComps = compsNear(lAt);
|
|
4497
|
+
const shared = [...rComps].find((l) => lComps.has(l));
|
|
4498
|
+
if (shared !== undefined) {
|
|
4499
|
+
findings.push({
|
|
4500
|
+
kind: 'overlapping-arms',
|
|
4501
|
+
part: part.name,
|
|
4502
|
+
from: part.from,
|
|
4503
|
+
rAt,
|
|
4504
|
+
lAt,
|
|
4505
|
+
opaquePx: cc.stats[shared].area,
|
|
4506
|
+
});
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4510
|
+
}
|
|
4511
|
+
// Issue #445: back-hair cloth trees (trunks `hairback_..._<r|l><k>_` hung from head) are held to the visible hair
|
|
4512
|
+
// masses on each side of the eye axis — one tree per mass, every link and tip inside that mass's column range.
|
|
4513
|
+
const bhParts = P.headFirst('back hair').filter((p) => spec.meshes[p.name] !== undefined);
|
|
4514
|
+
if (bhParts.length > 0) {
|
|
4515
|
+
try {
|
|
4516
|
+
const [fx0, fy0, fw, fh] = faceBoxOf(P).box;
|
|
4517
|
+
const ewR = P.byTag('eyewhite-r')[0] ?? null;
|
|
4518
|
+
const ewL = P.byTag('eyewhite-l')[0] ?? null;
|
|
4519
|
+
const axis = ewR !== null && ewL !== null ? (center(ewR)[0] + center(ewL)[0]) / 2 : fx0 + fw / 2;
|
|
4520
|
+
const eyeYs = [ewR, ewL].filter((x): x is PartRecord => x !== null).map((x) => center(x)[1]);
|
|
4521
|
+
const eyeY = eyeYs.length > 0 ? npMean(eyeYs) : fy0 + 0.5 * fh;
|
|
4522
|
+
const chin = fy0 + fh;
|
|
4523
|
+
const neckp = P.headFirst('neck');
|
|
4524
|
+
const neckY = joints?.joints.neck.at?.[1] ?? (neckp.length > 0 ? (chin + neckp[0].y + neckp[0].h) / 2 : chin + 0.12 * fh);
|
|
4525
|
+
const shoulderLine = neckY + 0.5 * fh;
|
|
4526
|
+
const y0 = pyRound(eyeY);
|
|
4527
|
+
const seenGroups = new Set<string>();
|
|
4528
|
+
for (const part of bhParts) {
|
|
4529
|
+
const m = spec.meshes[part.name];
|
|
4530
|
+
const segStrings = m.segments.filter((sp): sp is string => typeof sp === 'string');
|
|
4531
|
+
const trunkMatch = segStrings.map((sp) => /^(hairback_(?:.+_)?)(r|l)(\d+)_$/.exec(sp)).find((x) => x !== null) ?? null;
|
|
4532
|
+
if (trunkMatch === null) continue;
|
|
4533
|
+
const basePrefix = trunkMatch[1];
|
|
4534
|
+
if (seenGroups.has(basePrefix)) continue;
|
|
4535
|
+
seenGroups.add(basePrefix);
|
|
4536
|
+
const groupParts = bhParts.filter((q) =>
|
|
4537
|
+
spec.meshes[q.name].segments.some((sp) => typeof sp === 'string' && sp.startsWith(basePrefix) && /^(r|l)\d+_$/.test(sp.slice(basePrefix.length))),
|
|
4538
|
+
);
|
|
4539
|
+
const mask = groupParts.length === 1 ? P.alpha(part) : newMask(P.W, P.H);
|
|
4540
|
+
if (groupParts.length > 1) {
|
|
4541
|
+
for (const q of groupParts) {
|
|
4542
|
+
const mq = P.alpha(q);
|
|
4543
|
+
for (let i = 0; i < mask.data.length; i++) if (mq.data[i]) mask.data[i] = 1;
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
const m0 = spec.meshes[groupParts[0].name];
|
|
4547
|
+
const g = 'grid' in m0 ? m0.grid : partGrid(groupParts[0])[0];
|
|
4548
|
+
for (const side of ['r', 'l'] as const) {
|
|
4549
|
+
const trunkRe = new RegExp(`^${basePrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${side}(\\d+)_$`);
|
|
4550
|
+
const sideTrunks = spec.bones
|
|
4551
|
+
.filter((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.parent === 'head' && trunkRe.test(b.chain) && groupParts.some((q) => spec.meshes[q.name].segments.includes(b.chain)))
|
|
4552
|
+
.sort((a, b) => Number(trunkRe.exec(a.chain)![1]) - Number(trunkRe.exec(b.chain)![1]));
|
|
4553
|
+
if (sideTrunks.length === 0) continue;
|
|
4554
|
+
const masses = backHairMasses(mask, side, axis, y0, shoulderLine, g);
|
|
4555
|
+
const outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }> = [];
|
|
4556
|
+
const sideMinX = masses.length > 0 ? Math.min(...masses.map((x) => x.minX)) : 0;
|
|
4557
|
+
const sideMaxX = masses.length > 0 ? Math.max(...masses.map((x) => x.maxX)) : P.W - 1;
|
|
4558
|
+
sideTrunks.forEach((tr, k) => {
|
|
4559
|
+
const target = k < masses.length ? masses[k] : null;
|
|
4560
|
+
const minX = target !== null ? target.minX : sideMinX;
|
|
4561
|
+
const maxX = target !== null ? target.maxX : sideMaxX;
|
|
4562
|
+
const treeBase = tr.chain.slice(0, -1);
|
|
4563
|
+
const treeRe = new RegExp(`^${treeBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[a-z]*_$`);
|
|
4564
|
+
const treeChains = spec.bones.filter((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && treeRe.test(b.chain));
|
|
4565
|
+
for (const c of treeChains) {
|
|
4566
|
+
c.points.forEach((pt, idx) => {
|
|
4567
|
+
const px = pyInt(pt[0]);
|
|
4568
|
+
const py = pyInt(pt[1]);
|
|
4569
|
+
if (px < minX || px > maxX) outside.push({ point: `${c.chain}${idx}`, at: [px, py], minX, maxX });
|
|
4570
|
+
});
|
|
4571
|
+
const tx = pyInt(c.tip[0]);
|
|
4572
|
+
const ty = pyInt(c.tip[1]);
|
|
4573
|
+
if (tx < minX || tx > maxX) outside.push({ point: `${c.chain} tip`, at: [tx, ty], minX, maxX });
|
|
4574
|
+
}
|
|
4575
|
+
});
|
|
4576
|
+
if (sideTrunks.length !== masses.length || outside.length > 0) {
|
|
4577
|
+
findings.push({
|
|
4578
|
+
kind: 'back-hair-masses',
|
|
4579
|
+
mesh: groupParts.map((q) => q.name).join(' -> '),
|
|
4580
|
+
side,
|
|
4581
|
+
trunks: sideTrunks.map((t) => t.chain),
|
|
4582
|
+
masses: masses.map((x) => ({ minX: x.minX, maxX: x.maxX, minY: x.minY, maxY: x.maxY })),
|
|
4583
|
+
outside,
|
|
4584
|
+
});
|
|
4585
|
+
}
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
} catch (err) {
|
|
4589
|
+
if (!(err instanceof PartsError)) throw err;
|
|
4590
|
+
}
|
|
4591
|
+
}
|
|
4592
|
+
// Issue #446: when two limbs sharing one part cross in an X without a per-limb split declared on the covering limb,
|
|
4593
|
+
// nearest-bone splitting cuts a straight line through the crossing.
|
|
4594
|
+
{
|
|
4595
|
+
const baseP = P.beforeLimbs();
|
|
4596
|
+
const places = bonePlaces(spec.bones);
|
|
4597
|
+
if (spec.limbs !== undefined) {
|
|
4598
|
+
const limbNames = Object.keys(spec.limbs);
|
|
4599
|
+
const fromParts = [...new Set(limbNames.flatMap((l) => spec.limbs![l].segments.map((sg) => sg.from)))];
|
|
4600
|
+
for (const src of fromParts) {
|
|
4601
|
+
const on = limbNames.filter((l) => spec.limbs![l].segments.some((sg) => sg.from === src));
|
|
4602
|
+
if (on.length < 2 || on.some((l) => spec.limbs![l].split !== undefined)) continue;
|
|
4603
|
+
const valid = on.filter((l) => spec.limbs![l].segments.every((sg) => places.has(sg.bone)));
|
|
4604
|
+
if (valid.length < 2) continue;
|
|
4605
|
+
const rec = baseP.recs.find((r) => r.name === src);
|
|
4606
|
+
for (let i = 0; i < valid.length; i++) {
|
|
4607
|
+
const polyA = limbPolyline(spec.limbs[valid[i]], places);
|
|
4608
|
+
for (let j = i + 1; j < valid.length; j++) {
|
|
4609
|
+
const polyB = limbPolyline(spec.limbs[valid[j]], places);
|
|
4610
|
+
const cross = limbCrossing(polyA, polyB);
|
|
4611
|
+
if (cross !== null) {
|
|
4612
|
+
findings.push({
|
|
4613
|
+
kind: 'crossed-limbs',
|
|
4614
|
+
part: src,
|
|
4615
|
+
from: rec?.from ?? '',
|
|
4616
|
+
a: valid[i],
|
|
4617
|
+
b: valid[j],
|
|
4618
|
+
at: cross,
|
|
4619
|
+
});
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
}
|
|
4624
|
+
} else {
|
|
4625
|
+
const legR = spec.bones.find((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.chain === 'leg_r' && b.points.length >= 2);
|
|
4626
|
+
const legL = spec.bones.find((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.chain === 'leg_l' && b.points.length >= 2);
|
|
4627
|
+
if (legR !== undefined && legL !== undefined) {
|
|
4628
|
+
const polyR: Array<[number, number]> = [...legR.points.map((p): [number, number] => [p[0], p[1]]), [legR.tip[0], legR.tip[1]]];
|
|
4629
|
+
const polyL: Array<[number, number]> = [...legL.points.map((p): [number, number] => [p[0], p[1]]), [legL.tip[0], legL.tip[1]]];
|
|
4630
|
+
const cross = limbCrossing(polyR, polyL);
|
|
4631
|
+
if (cross !== null) {
|
|
4632
|
+
const lwParts = baseP.byTag('legwear');
|
|
4633
|
+
const shared = lwParts.find((p: PartRecord) => {
|
|
4634
|
+
const near = dilate(baseP.alpha(p), 2 * WRIST_ON_PART_PX + 1);
|
|
4635
|
+
return isOn(near, legR.tip[0], legR.tip[1]) && isOn(near, legL.tip[0], legL.tip[1]);
|
|
4636
|
+
});
|
|
4637
|
+
if (shared !== undefined) {
|
|
4638
|
+
findings.push({
|
|
4639
|
+
kind: 'crossed-limbs',
|
|
4640
|
+
part: shared.name,
|
|
4641
|
+
from: shared.from,
|
|
4642
|
+
a: 'leg_r',
|
|
4643
|
+
b: 'leg_l',
|
|
4644
|
+
at: cross,
|
|
4645
|
+
});
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4100
4651
|
// Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
|
|
4101
4652
|
// to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
|
|
4102
4653
|
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 }]));
|
|
@@ -4133,7 +4684,22 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
|
4133
4684
|
case 'squeeze':
|
|
4134
4685
|
return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
|
|
4135
4686
|
case 'near-empty-hand':
|
|
4687
|
+
case 'overlapping-arms':
|
|
4688
|
+
case 'crossed-limbs':
|
|
4136
4689
|
return [];
|
|
4690
|
+
case 'back-hair-masses': {
|
|
4691
|
+
const out = new Set<string>();
|
|
4692
|
+
for (const o of f.outside) {
|
|
4693
|
+
if (!o.point.endsWith(' tip')) out.add(o.point);
|
|
4694
|
+
else {
|
|
4695
|
+
const chain = o.point.slice(0, -' tip'.length);
|
|
4696
|
+
const e = bones.find((b) => 'chain' in b && b.chain === chain);
|
|
4697
|
+
if (e !== undefined && 'chain' in e) out.add(`${chain}${e.points.length - 1}`);
|
|
4698
|
+
}
|
|
4699
|
+
}
|
|
4700
|
+
if (out.size === 0) for (const t of f.trunks) out.add(`${t}0`);
|
|
4701
|
+
return [...out];
|
|
4702
|
+
}
|
|
4137
4703
|
case 'chain-not-advancing': {
|
|
4138
4704
|
if (!f.point.endsWith(' tip')) return [f.point];
|
|
4139
4705
|
const chain = f.point.slice(0, -' tip'.length);
|
|
@@ -4308,6 +4874,21 @@ export function lintLine(f: LintFinding): string {
|
|
|
4308
4874
|
: `forearm width ${pyFixed(f.basis.width, 1)} px squared`;
|
|
4309
4875
|
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`;
|
|
4310
4876
|
}
|
|
4877
|
+
case 'overlapping-arms':
|
|
4878
|
+
return `LINT ${f.part} (${f.from}) holds both arms in one ${f.opaquePx} px component (r_wrist at ${fmtAt(f.rAt)}, l_wrist at ${fmtAt(f.lAt)}): declare config.seethrough.arm_split (x or line, and order: ["handwear-r", "handwear-l"] or ["handwear-l", "handwear-r"]) to separate the overlapping arms into two layers`;
|
|
4879
|
+
case 'crossed-limbs':
|
|
4880
|
+
return `LINT ${f.a} and ${f.b} on ${f.part}${f.from === '' ? '' : ` (${f.from})`} cross at ${fmtAt(f.at)} with no per-limb split: nearest-bone splitting cuts a straight line through the crossing; declare config.limbs.<covering>.split (line: [[x, y], ...] or edge: "painting") on the covering limb so the seam follows the limb`;
|
|
4881
|
+
case 'back-hair-masses': {
|
|
4882
|
+
const massDesc =
|
|
4883
|
+
f.masses.length === 0
|
|
4884
|
+
? 'none'
|
|
4885
|
+
: f.masses.map((m, i) => `mass ${i} columns x=${m.minX}..${m.maxX}, y=${m.minY}..${m.maxY}`).join('; ');
|
|
4886
|
+
const outDesc =
|
|
4887
|
+
f.outside.length === 0
|
|
4888
|
+
? ''
|
|
4889
|
+
: `; outside mass columns: ${f.outside.map((o) => `${o.point} at ${pyIntList(o.at)} (outside x=${o.minX}..${o.maxX})`).join(', ')}`;
|
|
4890
|
+
return `LINT ${f.trunks.join(', ')} on mesh ${pyRepr(f.mesh)} (side ${f.side}): ${f.trunks.length} tree(s) for ${f.masses.length} visible hair mass(es) (${massDesc})${outDesc} — draft one tree per visible hair mass, rooted at that mass's top row and inside its columns`;
|
|
4891
|
+
}
|
|
4311
4892
|
case 'chain-not-advancing':
|
|
4312
4893
|
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`;
|
|
4313
4894
|
}
|