@rig-c/parts 1.28.0 → 1.30.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 +30 -10
- package/docs/STABILITY.md +2 -1
- package/package.json +1 -1
- package/src/assemble.ts +286 -13
- package/src/cloth.ts +297 -29
- package/src/config.ts +129 -4
- package/src/diagnostics.ts +1 -0
- package/src/limbs.ts +221 -1
- package/src/propose.ts +712 -40
package/src/propose.ts
CHANGED
|
@@ -127,7 +127,8 @@
|
|
|
127
127
|
import { existsSync } from 'node:fs';
|
|
128
128
|
import { join } from 'node:path';
|
|
129
129
|
import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
|
|
130
|
-
import { type BoneEntry, type ConfigConstraint, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
130
|
+
import { type BoneEntry, type ConfigConstraint, type ConfigStop, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
131
|
+
import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
|
|
131
132
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
132
133
|
import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
|
|
133
134
|
import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
@@ -146,9 +147,9 @@ import { configRoles, type RoleOf } from './structure.ts';
|
|
|
146
147
|
import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
|
|
147
148
|
import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
|
|
148
149
|
import { edgeLength } from './length.ts';
|
|
149
|
-
import { limbWidth } from './limbs.ts';
|
|
150
|
+
import { bonePlaces, limbCrossing, limbPolyline, limbWidth } from './limbs.ts';
|
|
150
151
|
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';
|
|
152
|
+
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
153
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
153
154
|
|
|
154
155
|
// ---------------------------------------------------------------------------
|
|
@@ -397,6 +398,7 @@ function konst(name: string, value: number): BasisConstant {
|
|
|
397
398
|
* plan part and which as the piece (issue #230).
|
|
398
399
|
*/
|
|
399
400
|
export function proposalAssemble(P: PartSet): Record<string, unknown> {
|
|
401
|
+
P = P.beforeLimbs();
|
|
400
402
|
// A patch is not a plan entry; it goes back as the patches entry it came
|
|
401
403
|
// from, so the loader holds it to its own rule (a region, never a mesh).
|
|
402
404
|
// Its box, alpha and draw are the record's own box and placeholders the
|
|
@@ -492,6 +494,7 @@ export type TurnRuns = Partial<Record<'full' | 'head', LayerSet>>;
|
|
|
492
494
|
* span away.
|
|
493
495
|
*/
|
|
494
496
|
export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null; notes: string[] } {
|
|
497
|
+
P = P.beforeLimbs();
|
|
495
498
|
const problems: Problem[] = [];
|
|
496
499
|
const read: Array<{ part: string; run: 'full' | 'head'; tag: string; base: string | null; own: number; area: number; order: number; piece: boolean }> = [];
|
|
497
500
|
const patches: string[] = [];
|
|
@@ -629,6 +632,7 @@ export class PartSet {
|
|
|
629
632
|
readonly recs: readonly PartRecord[];
|
|
630
633
|
readonly images: ReadonlyMap<string, Raster>;
|
|
631
634
|
private readonly masks = new Map<string, Mask>();
|
|
635
|
+
private preLimbs: PartSet | null = null;
|
|
632
636
|
|
|
633
637
|
constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
|
|
634
638
|
[this.W, this.H] = file.rig_size;
|
|
@@ -664,6 +668,170 @@ export class PartSet {
|
|
|
664
668
|
return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
|
|
665
669
|
}
|
|
666
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Issue #440: limb records (`r.limb !== undefined`) that cannot be reassembled
|
|
673
|
+
* into their pre-limb source parts (`r.limb.of`) — a limb part handed to
|
|
674
|
+
* `propose` on its own or with an incomplete segment chain (fewer than two
|
|
675
|
+
* segments, a segment index missing from `0..n-1`, a lower segment without
|
|
676
|
+
* its `cap` record, or the source part `of` still present beside the limb
|
|
677
|
+
* parts). When every limb in `parts.json` is complete, returns `[]` and
|
|
678
|
+
* {@link beforeLimbs} reconstructs the pre-limb part set.
|
|
679
|
+
*/
|
|
680
|
+
standaloneLimbParts(): PartRecord[] {
|
|
681
|
+
const limbed = this.recs.filter((r) => r.limb !== undefined);
|
|
682
|
+
if (limbed.length === 0) return [];
|
|
683
|
+
const nonLimbNames = new Set(this.recs.filter((r) => r.limb === undefined).map((r) => r.name));
|
|
684
|
+
const byLimb = new Map<string, PartRecord[]>();
|
|
685
|
+
for (const r of limbed) {
|
|
686
|
+
const l = r.limb as NonNullable<PartRecord['limb']>;
|
|
687
|
+
byLimb.set(l.name, [...(byLimb.get(l.name) ?? []), r]);
|
|
688
|
+
}
|
|
689
|
+
const badLimbs = new Set<string>();
|
|
690
|
+
const badOf = new Set<string>();
|
|
691
|
+
for (const [name, segs] of byLimb) {
|
|
692
|
+
const sorted = [...segs].sort((a, b) => (a.limb?.segment ?? 0) - (b.limb?.segment ?? 0));
|
|
693
|
+
const complete =
|
|
694
|
+
sorted.length >= 2 &&
|
|
695
|
+
sorted.every((s, i) => {
|
|
696
|
+
const l = s.limb as NonNullable<PartRecord['limb']>;
|
|
697
|
+
return l.segment === i && (i === 0 ? l.cap === undefined : l.cap !== undefined) && !nonLimbNames.has(l.of);
|
|
698
|
+
});
|
|
699
|
+
if (!complete) {
|
|
700
|
+
badLimbs.add(name);
|
|
701
|
+
for (const s of segs) badOf.add((s.limb as NonNullable<PartRecord['limb']>).of);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return limbed.filter((r) => {
|
|
705
|
+
const l = r.limb as NonNullable<PartRecord['limb']>;
|
|
706
|
+
return badLimbs.has(l.name) || badOf.has(l.of);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Issue #440: when `parts.json` came from an `assemble` run with `config.limbs`
|
|
712
|
+
* (every limb complete, {@link standaloneLimbParts} empty), reconstruct the
|
|
713
|
+
* pre-limb part set so `propose`, `draftTurn`, `checkProposal` and
|
|
714
|
+
* `checkBasis` read the whole layer each limb took in a single run. Without
|
|
715
|
+
* limb records (or when a standalone limb part will be refused by
|
|
716
|
+
* `PROPOSE_LIMB_PARTS`), returns `this`.
|
|
717
|
+
*/
|
|
718
|
+
beforeLimbs(): PartSet {
|
|
719
|
+
if (this.preLimbs !== null) return this.preLimbs;
|
|
720
|
+
if (!this.recs.some((r) => r.limb !== undefined) || this.standaloneLimbParts().length > 0) {
|
|
721
|
+
this.preLimbs = this;
|
|
722
|
+
return this;
|
|
723
|
+
}
|
|
724
|
+
const segsByOf = new Map<string, PartRecord[]>();
|
|
725
|
+
const segByLimb = new Map<string, PartRecord>();
|
|
726
|
+
for (const r of this.recs) {
|
|
727
|
+
if (r.limb === undefined) continue;
|
|
728
|
+
segsByOf.set(r.limb.of, [...(segsByOf.get(r.limb.of) ?? []), r]);
|
|
729
|
+
segByLimb.set(`${r.limb.name}:${r.limb.segment}`, r);
|
|
730
|
+
}
|
|
731
|
+
const buildOf = (ofName: string): { rec: PartRecord; raster: Raster } => {
|
|
732
|
+
const segs = segsByOf.get(ofName) as PartRecord[];
|
|
733
|
+
const union = new Uint8Array(this.W * this.H);
|
|
734
|
+
for (const s of segs) {
|
|
735
|
+
const m = this.alpha(s);
|
|
736
|
+
const l = s.limb as NonNullable<PartRecord['limb']>;
|
|
737
|
+
const up = l.segment > 0 ? segByLimb.get(`${l.name}:${l.segment - 1}`) : undefined;
|
|
738
|
+
const dropUpperCap = up !== undefined && up.limb?.of !== ofName && (l.cap?.upper_px ?? 0) > 0 && (l.cap?.layer_px ?? 0) === 0 ? this.alpha(up) : null;
|
|
739
|
+
const box = l.cap?.box;
|
|
740
|
+
for (let y = s.y; y < s.y + s.h; y++) {
|
|
741
|
+
for (let x = s.x; x < s.x + s.w; x++) {
|
|
742
|
+
const p = y * this.W + x;
|
|
743
|
+
if (!m.data[p]) continue;
|
|
744
|
+
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;
|
|
745
|
+
union[p] = 1;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
let x0 = this.W;
|
|
750
|
+
let y0 = this.H;
|
|
751
|
+
let x1 = -1;
|
|
752
|
+
let y1 = -1;
|
|
753
|
+
let opaquePx = 0;
|
|
754
|
+
for (let y = 0; y < this.H; y++) {
|
|
755
|
+
for (let x = 0; x < this.W; x++) {
|
|
756
|
+
if (!union[y * this.W + x]) continue;
|
|
757
|
+
if (x < x0) x0 = x;
|
|
758
|
+
if (x > x1) x1 = x;
|
|
759
|
+
if (y < y0) y0 = y;
|
|
760
|
+
if (y > y1) y1 = y;
|
|
761
|
+
opaquePx++;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const first = segs[0];
|
|
765
|
+
if (x1 < x0) {
|
|
766
|
+
x0 = first.x;
|
|
767
|
+
y0 = first.y;
|
|
768
|
+
x1 = first.x + first.w - 1;
|
|
769
|
+
y1 = first.y + first.h - 1;
|
|
770
|
+
}
|
|
771
|
+
const w = x1 - x0 + 1;
|
|
772
|
+
const h = y1 - y0 + 1;
|
|
773
|
+
const raster = newRaster(w, h);
|
|
774
|
+
for (let y = 0; y < h; y++) {
|
|
775
|
+
for (let x = 0; x < w; x++) {
|
|
776
|
+
if (union[(y0 + y) * this.W + x0 + x]) raster.data[(y * w + x) * 4 + 3] = 255;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const sum = (f: (r: PartRecord) => number): number => segs.reduce((acc, r) => acc + f(r), 0);
|
|
780
|
+
const hasVis = segs.every((r) => r.visible_px !== undefined && r.occluded_px !== undefined);
|
|
781
|
+
const rec: PartRecord = {
|
|
782
|
+
name: ofName,
|
|
783
|
+
from: first.from,
|
|
784
|
+
...(first.cut_from === undefined ? {} : { cut_from: first.cut_from }),
|
|
785
|
+
x: x0,
|
|
786
|
+
y: y0,
|
|
787
|
+
w,
|
|
788
|
+
h,
|
|
789
|
+
opaque_px: opaquePx,
|
|
790
|
+
...(hasVis ? { visible_px: sum((r) => r.visible_px as number), occluded_px: sum((r) => r.occluded_px as number) } : {}),
|
|
791
|
+
projected_core_px: sum((r) => r.projected_core_px),
|
|
792
|
+
source_px_taken: sum((r) => r.source_px_taken),
|
|
793
|
+
...(segs.some((r) => r.visible_not_projected_px !== undefined) ? { visible_not_projected_px: sum((r) => r.visible_not_projected_px ?? 0) } : {}),
|
|
794
|
+
refused_drift_px: sum((r) => r.refused_drift_px),
|
|
795
|
+
merged_px: sum((r) => r.merged_px),
|
|
796
|
+
seam_override_px: sum((r) => r.seam_override_px),
|
|
797
|
+
};
|
|
798
|
+
return { rec, raster };
|
|
799
|
+
};
|
|
800
|
+
const recs: PartRecord[] = [];
|
|
801
|
+
const images = new Map<string, Raster>();
|
|
802
|
+
const emittedOf = new Set<string>();
|
|
803
|
+
for (const r of this.recs) {
|
|
804
|
+
if (r.limb === undefined) {
|
|
805
|
+
recs.push(r);
|
|
806
|
+
const im = this.images.get(r.name);
|
|
807
|
+
if (im !== undefined) images.set(r.name, im);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const seg0 = segByLimb.get(`${r.limb.name}:0`) as PartRecord;
|
|
811
|
+
const rootOf = (seg0.limb as NonNullable<PartRecord['limb']>).of;
|
|
812
|
+
if (emittedOf.has(rootOf)) continue;
|
|
813
|
+
const groupLimbs = new Set(
|
|
814
|
+
this.recs.filter((q) => q.limb !== undefined && q.limb.segment === 0 && q.limb.of === rootOf).map((q) => (q.limb as NonNullable<PartRecord['limb']>).name),
|
|
815
|
+
);
|
|
816
|
+
const minSegByOf = new Map<string, number>();
|
|
817
|
+
for (const q of this.recs) {
|
|
818
|
+
if (q.limb === undefined || !groupLimbs.has(q.limb.name)) continue;
|
|
819
|
+
const prev = minSegByOf.get(q.limb.of);
|
|
820
|
+
if (prev === undefined || q.limb.segment < prev) minSegByOf.set(q.limb.of, q.limb.segment);
|
|
821
|
+
}
|
|
822
|
+
const orderedOfs = [...minSegByOf.entries()].sort((a, b) => a[1] - b[1]).map(([n]) => n);
|
|
823
|
+
for (const ofName of orderedOfs) {
|
|
824
|
+
if (emittedOf.has(ofName)) continue;
|
|
825
|
+
emittedOf.add(ofName);
|
|
826
|
+
const built = buildOf(ofName);
|
|
827
|
+
recs.push(built.rec);
|
|
828
|
+
images.set(ofName, built.raster);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
this.preLimbs = new PartSet({ rig_size: [this.W, this.H], scale_rig_per_source: this.rigScale, ghost_px: {}, parts: recs }, images);
|
|
832
|
+
return this.preLimbs;
|
|
833
|
+
}
|
|
834
|
+
|
|
667
835
|
/** The See-through parts: every part but the `painting:` patches. */
|
|
668
836
|
layered(): PartRecord[] {
|
|
669
837
|
return this.recs.filter((p) => splitFrom(p.from)[0] !== PAINTING_RUN);
|
|
@@ -1226,8 +1394,10 @@ export function proposeWithBasis(
|
|
|
1226
1394
|
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
1395
|
})),
|
|
1228
1396
|
);
|
|
1229
|
-
// Issue #349: limbs are drafted from the parts before their cut —
|
|
1230
|
-
|
|
1397
|
+
// Issue #349, #440: limbs are drafted from the parts before their cut — a complete assemble with config.limbs
|
|
1398
|
+
// reconstructs its pre-limb parts here in the same run; a standalone or incomplete limb part handed to propose
|
|
1399
|
+
// on its own is refused by name.
|
|
1400
|
+
const limbed = P.standaloneLimbParts();
|
|
1231
1401
|
refuseIfAny(
|
|
1232
1402
|
limbed.length === 0
|
|
1233
1403
|
? []
|
|
@@ -1235,10 +1405,11 @@ export function proposeWithBasis(
|
|
|
1235
1405
|
{
|
|
1236
1406
|
code: 'PROPOSE_LIMB_PARTS',
|
|
1237
1407
|
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
|
|
1408
|
+
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
1409
|
},
|
|
1240
1410
|
],
|
|
1241
1411
|
);
|
|
1412
|
+
P = P.beforeLimbs();
|
|
1242
1413
|
const bones: BoneEntry[] = [];
|
|
1243
1414
|
const records: BoneBasis[] = [];
|
|
1244
1415
|
/** Issue #349: the limbs `--keypoints` drafts, and the parts they take (not made, so not given a mesh or a region). */
|
|
@@ -1403,7 +1574,7 @@ export function proposeWithBasis(
|
|
|
1403
1574
|
const T = (p: PartRecord | readonly PartRecord[], tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
|
|
1404
1575
|
const partNames = Array.isArray(p) ? p.map((x) => x.name) : [(p as PartRecord).name];
|
|
1405
1576
|
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))];
|
|
1577
|
+
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
1578
|
for (const t of trees) {
|
|
1408
1579
|
C(
|
|
1409
1580
|
t.chain,
|
|
@@ -1673,27 +1844,45 @@ export function proposeWithBasis(
|
|
|
1673
1844
|
]);
|
|
1674
1845
|
}
|
|
1675
1846
|
}
|
|
1676
|
-
const
|
|
1847
|
+
const massesBySide = new Map<'r' | 'l', BackHairMass[]>(activeSides.map(([s]) => [s, backHairMasses(unionMask, s, axis, y0, shoulderLine, g)]));
|
|
1848
|
+
const useMasses = rawRcp.chains === undefined;
|
|
1677
1849
|
const all: TreeChain[] = [];
|
|
1678
1850
|
let n = 0;
|
|
1679
1851
|
const partNamesLabel = list.map((q) => q.name).join(' -> ');
|
|
1680
1852
|
for (const [side] of activeSides) {
|
|
1681
1853
|
const prefix = prefixOf(side);
|
|
1682
|
-
const
|
|
1854
|
+
const sideMasses = massesBySide.get(side) ?? [];
|
|
1855
|
+
if (useMasses && sideMasses.length === 0) continue;
|
|
1856
|
+
const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
|
|
1857
|
+
const frame = `${partNamesLabel}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
|
|
1858
|
+
const trees = T(list, 'back hair', sideRcp, {
|
|
1683
1859
|
prefix,
|
|
1684
1860
|
parent: 'head',
|
|
1685
1861
|
rootY: eyeY,
|
|
1686
1862
|
tipY: bot - 4,
|
|
1687
1863
|
xAt: (y, f) => bandX(unionMask, y, { frac: f, side, axis }),
|
|
1688
1864
|
fallbackX: () => axis,
|
|
1689
|
-
frame
|
|
1865
|
+
frame,
|
|
1690
1866
|
edges: side,
|
|
1867
|
+
masses: useMasses
|
|
1868
|
+
? sideMasses.map((m): TreeMassInput => ({
|
|
1869
|
+
rootY: m.minY <= y0 ? eyeY : m.minY,
|
|
1870
|
+
tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
|
|
1871
|
+
xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
|
|
1872
|
+
fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
|
|
1873
|
+
frame,
|
|
1874
|
+
}))
|
|
1875
|
+
: undefined,
|
|
1691
1876
|
});
|
|
1692
|
-
const made = treeConstraints(trees,
|
|
1877
|
+
const made = treeConstraints(trees, sideRcp, null);
|
|
1693
1878
|
clothConstraints.push(...made);
|
|
1694
1879
|
n += made.length;
|
|
1695
1880
|
all.push(...trees);
|
|
1696
1881
|
}
|
|
1882
|
+
const rcp: ClothRecipe = {
|
|
1883
|
+
...rawRcp,
|
|
1884
|
+
chains: useMasses ? Math.max(1, ...(activeSides.map(([s]) => (massesBySide.get(s) ?? []).length))) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
|
|
1885
|
+
};
|
|
1697
1886
|
const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
|
|
1698
1887
|
for (const t of all) {
|
|
1699
1888
|
const line = [...t.points, t.tip];
|
|
@@ -1778,9 +1967,11 @@ export function proposeWithBasis(
|
|
|
1778
1967
|
} else if (recipeOf('back hair') !== null && (backHairParts === null || backHairSingleSame)) {
|
|
1779
1968
|
// Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
|
|
1780
1969
|
// 4 px above the part's bottom, in place of the two keyed chains.
|
|
1781
|
-
// Issue #390:
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1970
|
+
// Issue #390: an explicit tree count per side that exceeds `floor(width / grid)` at the root row or roots a tree
|
|
1971
|
+
// outside the 15 px dilated art is refused by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
|
|
1972
|
+
// Issue #445: when `chains` is omitted, the tree count on each side is the number of visible hair masses on that
|
|
1973
|
+
// side (connected components below the root row, merged within one grid cell), one tree per mass, rooted at that
|
|
1974
|
+
// mass's top row and inside its columns.
|
|
1784
1975
|
const rawRcp = recipeOf('back hair') as ClothRecipe;
|
|
1785
1976
|
const mask = P.alpha(p);
|
|
1786
1977
|
const dilated = dilate(mask, 31);
|
|
@@ -1835,30 +2026,66 @@ export function proposeWithBasis(
|
|
|
1835
2026
|
]);
|
|
1836
2027
|
}
|
|
1837
2028
|
}
|
|
1838
|
-
const
|
|
2029
|
+
const shoulderLine = neckY + 0.5 * fh;
|
|
2030
|
+
const rMasses = backHairMasses(mask, 'r', axis, y0, shoulderLine, g);
|
|
2031
|
+
const lMasses = backHairMasses(mask, 'l', axis, y0, shoulderLine, g);
|
|
2032
|
+
const massesBySide = new Map<'r' | 'l', BackHairMass[]>([
|
|
2033
|
+
['r', rMasses],
|
|
2034
|
+
['l', lMasses],
|
|
2035
|
+
]);
|
|
2036
|
+
const useMasses = rawRcp.chains === undefined;
|
|
1839
2037
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
1840
2038
|
const all: TreeChain[] = [];
|
|
1841
2039
|
let n = 0;
|
|
1842
2040
|
for (const [side] of SIDES) {
|
|
1843
2041
|
const prefix = prefixOf(side);
|
|
1844
|
-
const
|
|
2042
|
+
const sideMasses = massesBySide.get(side) ?? [];
|
|
2043
|
+
if (useMasses && sideMasses.length === 0) continue;
|
|
2044
|
+
const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
|
|
2045
|
+
const frame = `${p.name}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
|
|
2046
|
+
const trees = T(p, 'back hair', sideRcp, {
|
|
1845
2047
|
prefix,
|
|
1846
2048
|
parent: 'head',
|
|
1847
2049
|
rootY: eyeY,
|
|
1848
2050
|
tipY: bot - 4,
|
|
1849
2051
|
xAt: (y, f) => bandX(mask, y, { frac: f, side, axis }),
|
|
1850
2052
|
fallbackX: () => axis,
|
|
1851
|
-
frame
|
|
2053
|
+
frame,
|
|
1852
2054
|
edges: side,
|
|
2055
|
+
masses: useMasses
|
|
2056
|
+
? sideMasses.map((m): TreeMassInput => ({
|
|
2057
|
+
rootY: m.minY <= y0 ? eyeY : m.minY,
|
|
2058
|
+
tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
|
|
2059
|
+
xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
|
|
2060
|
+
fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
|
|
2061
|
+
frame,
|
|
2062
|
+
}))
|
|
2063
|
+
: undefined,
|
|
1853
2064
|
});
|
|
1854
2065
|
for (const t of trees) segs.push(t.chain);
|
|
1855
|
-
const made = treeConstraints(trees,
|
|
2066
|
+
const made = treeConstraints(trees, sideRcp, null);
|
|
1856
2067
|
clothConstraints.push(...made);
|
|
1857
2068
|
n += made.length;
|
|
1858
2069
|
all.push(...trees);
|
|
1859
2070
|
}
|
|
1860
|
-
|
|
1861
|
-
|
|
2071
|
+
const sameSideCounts = !useMasses || rMasses.length === lMasses.length;
|
|
2072
|
+
const noteRcp: ClothRecipe = {
|
|
2073
|
+
...rawRcp,
|
|
2074
|
+
chains: useMasses ? (sameSideCounts ? rMasses.length : rMasses.length + lMasses.length) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
|
|
2075
|
+
};
|
|
2076
|
+
notes.push(
|
|
2077
|
+
clothNote(
|
|
2078
|
+
p.name,
|
|
2079
|
+
'back hair',
|
|
2080
|
+
all,
|
|
2081
|
+
noteRcp,
|
|
2082
|
+
n,
|
|
2083
|
+
sameSideCounts
|
|
2084
|
+
? ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'
|
|
2085
|
+
: ` (${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`,
|
|
2086
|
+
),
|
|
2087
|
+
);
|
|
2088
|
+
meshes[p.name] = { grid: g, r: rawRcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
1862
2089
|
// Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
|
|
1863
2090
|
const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
|
|
1864
2091
|
for (const t of all) {
|
|
@@ -2574,17 +2801,118 @@ export function proposeWithBasis(
|
|
|
2574
2801
|
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
2802
|
};
|
|
2576
2803
|
};
|
|
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
|
-
|
|
2804
|
+
// 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.
|
|
2805
|
+
const skirtInput = (p: PartRecord, mask: Mask, prefix: string, r: ClothRecipe, bot = p.y + p.h, emitNotes = true): TreeInput => {
|
|
2806
|
+
const y0 = hip[1] + 20;
|
|
2807
|
+
const y0Int = Math.max(0, pyInt(y0));
|
|
2808
|
+
const botInt = Math.min(mask.height, pyInt(bot));
|
|
2809
|
+
const n = r.chains ?? RECIPE_BACK_HAIR_CHAINS;
|
|
2810
|
+
const into = r.split === undefined ? 1 : r.split.into;
|
|
2811
|
+
const traceLastOpaqueY = (f: number): number => {
|
|
2812
|
+
let lastY = -1;
|
|
2813
|
+
for (let y = y0Int; y < botInt; y++) {
|
|
2814
|
+
const bx = bandX(mask, y, { frac: f }) ?? p.x + f * p.w;
|
|
2815
|
+
const rx = Math.max(0, Math.min(mask.width - 1, pyRound(bx)));
|
|
2816
|
+
if (mask.data[y * mask.width + rx] !== 0) {
|
|
2817
|
+
lastY = y;
|
|
2818
|
+
} else if (lastY >= y0Int) {
|
|
2819
|
+
let gapContinues = false;
|
|
2820
|
+
for (let yy = y + 1; yy <= Math.min(botInt - 1, y + 12); yy++) {
|
|
2821
|
+
if (mask.data[yy * mask.width + rx] !== 0) {
|
|
2822
|
+
gapContinues = true;
|
|
2823
|
+
break;
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
if (!gapContinues) break;
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
return lastY;
|
|
2830
|
+
};
|
|
2831
|
+
const shares: number[] = [];
|
|
2832
|
+
for (let k = 0; k < n; k++) {
|
|
2833
|
+
shares.push((k + 0.5) / n);
|
|
2834
|
+
if (into > 1) {
|
|
2835
|
+
for (let b = 0; b < into; b++) shares.push((k + (b + 0.5) / into) / n);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
const uniformBottom = shares.every((f) => {
|
|
2839
|
+
const lastY = traceLastOpaqueY(f);
|
|
2840
|
+
if (lastY >= botInt - 4) return true;
|
|
2841
|
+
if (lastY < botInt - 36 || lastY < y0Int + 4) return false;
|
|
2842
|
+
const txBot = Math.max(0, Math.min(mask.width - 1, pyRound(bandX(mask, bot - 4, { frac: f }) ?? p.x + f * p.w)));
|
|
2843
|
+
for (let yy = Math.max(y0Int, botInt - 16); yy < botInt; yy++) {
|
|
2844
|
+
if (mask.data[yy * mask.width + txBot] !== 0) return true;
|
|
2845
|
+
}
|
|
2846
|
+
return false;
|
|
2847
|
+
});
|
|
2848
|
+
const notedEmpty = new Set<number>();
|
|
2849
|
+
const cappedXAt = (maxY: number) => (y: number, f: number): number | null => {
|
|
2850
|
+
const yi = Math.min(maxY, Math.max(0, pyRound(y)));
|
|
2851
|
+
const a = Math.max(0, pyInt(y - 12));
|
|
2852
|
+
const b = Math.min(maxY + 1, pyInt(y + 12));
|
|
2853
|
+
let x0 = mask.width;
|
|
2854
|
+
let x1 = -1;
|
|
2855
|
+
for (let yy = a; yy < b; yy++) {
|
|
2856
|
+
for (let x = 0; x < mask.width; x++) {
|
|
2857
|
+
if (mask.data[yy * mask.width + x] !== 0) {
|
|
2858
|
+
if (x < x0) x0 = x;
|
|
2859
|
+
if (x > x1) x1 = x;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
const raw = x1 < x0 ? (bandX(mask, yi, { frac: f }) ?? p.x + f * p.w) : x0 + f * (x1 - x0);
|
|
2864
|
+
const rx = Math.max(0, Math.min(mask.width - 1, pyRound(raw)));
|
|
2865
|
+
if (mask.data[yi * mask.width + rx] !== 0) return raw;
|
|
2866
|
+
let bestX: number | null = null;
|
|
2867
|
+
let bestDist = mask.width + 1;
|
|
2868
|
+
for (let x = 0; x < mask.width; x++) {
|
|
2869
|
+
if (mask.data[yi * mask.width + x] !== 0) {
|
|
2870
|
+
const d = Math.abs(x - rx);
|
|
2871
|
+
if (d < bestDist) {
|
|
2872
|
+
bestDist = d;
|
|
2873
|
+
bestX = x;
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
return bestX ?? raw;
|
|
2878
|
+
};
|
|
2879
|
+
return {
|
|
2880
|
+
prefix,
|
|
2881
|
+
parent: 'hip',
|
|
2882
|
+
rootY: y0,
|
|
2883
|
+
tipY: bot - 4,
|
|
2884
|
+
xAt: (y, f) => bandX(mask, y, { frac: f }),
|
|
2885
|
+
fallbackX: (f) => p.x + f * p.w,
|
|
2886
|
+
frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
|
|
2887
|
+
edges: 'both',
|
|
2888
|
+
columnTip: (k, f) => {
|
|
2889
|
+
const col0 = Math.floor(p.x + (k / n) * p.w);
|
|
2890
|
+
const col1 = Math.max(col0, Math.ceil(p.x + ((k + 1) / n) * p.w) - 1);
|
|
2891
|
+
let colMaxY = -1;
|
|
2892
|
+
for (let y = y0Int; y < botInt; y++) {
|
|
2893
|
+
for (let x = col0; x <= col1; x++) {
|
|
2894
|
+
if (mask.data[y * mask.width + x] !== 0) colMaxY = y;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
const fc = (k + 0.5) / n;
|
|
2898
|
+
const trunkLastY = traceLastOpaqueY(fc);
|
|
2899
|
+
if (colMaxY < y0Int + 4 || trunkLastY < y0Int + 4) {
|
|
2900
|
+
if (emitNotes && !notedEmpty.has(k)) {
|
|
2901
|
+
notedEmpty.add(k);
|
|
2902
|
+
notes.push(
|
|
2903
|
+
`${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`,
|
|
2904
|
+
);
|
|
2905
|
+
}
|
|
2906
|
+
return null;
|
|
2907
|
+
}
|
|
2908
|
+
if (uniformBottom) return { tipY: bot - 4 };
|
|
2909
|
+
const rawLastY = traceLastOpaqueY(f);
|
|
2910
|
+
const lastY = rawLastY >= y0Int + 4 ? rawLastY : trunkLastY;
|
|
2911
|
+
const tipY = Math.min(bot - 4, Math.max(y0Int + 4, lastY - 3));
|
|
2912
|
+
return { tipY, xAt: cappedXAt(lastY) };
|
|
2913
|
+
},
|
|
2914
|
+
};
|
|
2915
|
+
};
|
|
2588
2916
|
// Issue #416: a bottomwear part lying on the trees of the dress config.cloth.bottomwear.part names (a ruffle hem
|
|
2589
2917
|
// See-through returned on its own) rides the links it overlaps, as a layer wrapped round a tail does (issue #391),
|
|
2590
2918
|
// 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 +2974,12 @@ export function proposeWithBasis(
|
|
|
2646
2974
|
const prefix = 'skirt';
|
|
2647
2975
|
const partNamesLabel = list.map((q) => q.name).join(' -> ');
|
|
2648
2976
|
const trees = T(list, 'bottomwear', dressRecipe, {
|
|
2649
|
-
...skirtInput(p0, unionMask, prefix),
|
|
2977
|
+
...skirtInput(p0, unionMask, prefix, dressRecipe, bot),
|
|
2650
2978
|
tipY: bot - 4,
|
|
2651
2979
|
frame: `${partNamesLabel}'s art (no art in the band: that share of ${p0.name}'s box width)`,
|
|
2652
2980
|
});
|
|
2653
2981
|
let swing: { bone: string; rest: number } | null = null;
|
|
2654
|
-
if (dressRecipe.swing !== undefined) {
|
|
2982
|
+
if (dressRecipe.swing !== undefined && trees.length > 0) {
|
|
2655
2983
|
const cx = or(bandX(unionMask, y0, { frac: 0.5 }), p0.x + 0.5 * p0.w);
|
|
2656
2984
|
const first = trees[0].points.length > 1 ? trees[0].points[1][1] : trees[0].tip[1];
|
|
2657
2985
|
const at: [number, number] = [cx, y0];
|
|
@@ -2739,7 +3067,7 @@ export function proposeWithBasis(
|
|
|
2739
3067
|
? []
|
|
2740
3068
|
: ((): PartRecord[] => {
|
|
2741
3069
|
const mask = P.alpha(namedSkirt);
|
|
2742
|
-
const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
|
|
3070
|
+
const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt', dressRecipe, namedSkirt.y + namedSkirt.h, false), dressRecipe));
|
|
2743
3071
|
return bw.filter((q) => !multiPartClaimed.has(q.name) && overLinks(tail, q).rides);
|
|
2744
3072
|
})();
|
|
2745
3073
|
let dressDrafted: Tail | null = null;
|
|
@@ -2756,11 +3084,11 @@ export function proposeWithBasis(
|
|
|
2756
3084
|
if (skirtRecipe !== null) {
|
|
2757
3085
|
const prefix = skirts.length === 1 ? 'skirt' : `skirt_${p.name}`;
|
|
2758
3086
|
const fixed = [...segs];
|
|
2759
|
-
const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
|
|
3087
|
+
const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix, skirtRecipe));
|
|
2760
3088
|
if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
|
|
2761
3089
|
for (const t of trees) segs.push(t.chain);
|
|
2762
3090
|
let swing: { bone: string; rest: number } | null = null;
|
|
2763
|
-
if (skirtRecipe.swing !== undefined) {
|
|
3091
|
+
if (skirtRecipe.swing !== undefined && trees.length > 0) {
|
|
2764
3092
|
// One link long, straight down from the centre of the root row: the hip is drafted with no tip, so it is
|
|
2765
3093
|
// unturned and the swing's setup rotation in its frame is its own direction, -90 degrees (Spine, y up).
|
|
2766
3094
|
const cx = or(bandX(mask, y0, { frac: 0.5 }), p.x + 0.5 * p.w);
|
|
@@ -3144,7 +3472,10 @@ function artDistance(m: Mask, at: readonly [number, number]): number {
|
|
|
3144
3472
|
* carries one: a part left to the rule beside parts placed from joints would
|
|
3145
3473
|
* be read by two rules at once. Sides come from the joints, not from the eye
|
|
3146
3474
|
* axis, so a figure seen from behind or lying across the axis is paired by
|
|
3147
|
-
* where its wrists are.
|
|
3475
|
+
* where its wrists are. Issue #458: when pre-separated `handwear-r` and
|
|
3476
|
+
* `handwear-l` parts cross so that `<s>_wrist` lies on the opaque art of its
|
|
3477
|
+
* own side's `handwear-<s>` part and also within 15 px of the opposite side's
|
|
3478
|
+
* `handwear-<other>` part, it resolves to its own side's part.
|
|
3148
3479
|
*
|
|
3149
3480
|
* Issue #199: a wrist on no part within that tolerance is measured to every
|
|
3150
3481
|
* part by the same Chebyshev distance ({@link artDistance}). With
|
|
@@ -3171,7 +3502,18 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
|
|
|
3171
3502
|
const near = new Map(hw.map((p) => [p, dilate(P.alpha(p), 2 * WRIST_ON_PART_PX + 1)]));
|
|
3172
3503
|
const arms: JointArm[] = [];
|
|
3173
3504
|
for (const a of complete) {
|
|
3174
|
-
const
|
|
3505
|
+
const raw = hw.filter((p) => isOn(near.get(p) as Mask, a.wrist[0], a.wrist[1]));
|
|
3506
|
+
const ownTag = `handwear-${a.side}`;
|
|
3507
|
+
const oppTag = `handwear-${a.side === 'r' ? 'l' : 'r'}`;
|
|
3508
|
+
const own =
|
|
3509
|
+
raw.length > 1 &&
|
|
3510
|
+
raw.every((p) => {
|
|
3511
|
+
const t = splitFrom(p.from)[1];
|
|
3512
|
+
return t === ownTag || t === oppTag;
|
|
3513
|
+
})
|
|
3514
|
+
? raw.filter((p) => splitFrom(p.from)[1] === ownTag && isOn(P.alpha(p), a.wrist[0], a.wrist[1]))
|
|
3515
|
+
: [];
|
|
3516
|
+
const on = own.length === 1 ? own : raw;
|
|
3175
3517
|
if (on.length > 1) return { arms: null, why: `${a.side}_wrist at ${fmtAt(a.wrist)} lies on the art of ${on.length} handwear parts (${on.map((p) => p.name).join(', ')}), so it does not say which sleeve it ends` };
|
|
3176
3518
|
if (on.length === 1) {
|
|
3177
3519
|
arms.push({ ...a, part: on[0], snap: null });
|
|
@@ -3800,6 +4142,62 @@ export interface NearEmptyHandFinding {
|
|
|
3800
4142
|
| { kind: 'forearm-width'; width: number };
|
|
3801
4143
|
}
|
|
3802
4144
|
|
|
4145
|
+
/**
|
|
4146
|
+
* Issue #445: back-hair cloth trees on one side of the eye axis whose count does not match the side's visible hair
|
|
4147
|
+
* masses or whose link or tip leaves its mass's column range.
|
|
4148
|
+
*/
|
|
4149
|
+
export interface BackHairMassesFinding {
|
|
4150
|
+
kind: 'back-hair-masses';
|
|
4151
|
+
mesh: string;
|
|
4152
|
+
side: 'r' | 'l';
|
|
4153
|
+
trunks: string[];
|
|
4154
|
+
masses: Array<{ minX: number; maxX: number; minY: number; maxY: number }>;
|
|
4155
|
+
outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }>;
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
/**
|
|
4159
|
+
* Issue #439: both arms overlap in one 8-connected component of a single handwear layer (`handwear-r` or `handwear-l`),
|
|
4160
|
+
* resolved by declaring `config.seethrough.arm_split` (`x` or `line`, and `order`).
|
|
4161
|
+
*/
|
|
4162
|
+
export interface OverlappingArmsFinding {
|
|
4163
|
+
kind: 'overlapping-arms';
|
|
4164
|
+
part: string;
|
|
4165
|
+
from: string;
|
|
4166
|
+
rAt: [number, number];
|
|
4167
|
+
lAt: [number, number];
|
|
4168
|
+
opaquePx: number;
|
|
4169
|
+
}
|
|
4170
|
+
|
|
4171
|
+
/**
|
|
4172
|
+
* Issue #446: two limbs sharing one part (`from`) cross in an X at `at` with no `config.limbs.<limb>.split`
|
|
4173
|
+
* declared on the covering limb, so `nearestLimb` cuts a straight line through the crossing.
|
|
4174
|
+
*/
|
|
4175
|
+
export interface CrossedLimbsFinding {
|
|
4176
|
+
kind: 'crossed-limbs';
|
|
4177
|
+
part: string;
|
|
4178
|
+
from: string;
|
|
4179
|
+
a: string;
|
|
4180
|
+
b: string;
|
|
4181
|
+
at: [number, number];
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
/**
|
|
4185
|
+
* Issue #447: a stop's contact point `<chain>_touch` (`at` in `against`'s local frame) lies above the first stopped
|
|
4186
|
+
* link's root (`links[0]` at `[x0, y0]`) in rig pixels (`ty < y0`, i.e. higher Spine y), so `<chain>_rest` aims
|
|
4187
|
+
* upward into the upper half-plane (`|rest| > 90°`) and `|rest| + outward > 180°` wraps past ±180° (`RIG_STOP_BOUNDS`).
|
|
4188
|
+
*/
|
|
4189
|
+
export interface StopTouchAboveRootFinding {
|
|
4190
|
+
kind: 'stop-touch-above-root';
|
|
4191
|
+
chain: string;
|
|
4192
|
+
firstLink: string;
|
|
4193
|
+
rootAt: [number, number];
|
|
4194
|
+
against: string;
|
|
4195
|
+
at: [number, number];
|
|
4196
|
+
touchAt: [number, number];
|
|
4197
|
+
rest: number;
|
|
4198
|
+
outward: number;
|
|
4199
|
+
}
|
|
4200
|
+
|
|
3803
4201
|
export type LintFinding =
|
|
3804
4202
|
| OffArtFinding
|
|
3805
4203
|
| OffLockFinding
|
|
@@ -3810,7 +4208,11 @@ export type LintFinding =
|
|
|
3810
4208
|
| HipNearerNeckFinding
|
|
3811
4209
|
| ChainNotAdvancingFinding
|
|
3812
4210
|
| SqueezeLintFinding
|
|
3813
|
-
| NearEmptyHandFinding
|
|
4211
|
+
| NearEmptyHandFinding
|
|
4212
|
+
| BackHairMassesFinding
|
|
4213
|
+
| OverlappingArmsFinding
|
|
4214
|
+
| CrossedLimbsFinding
|
|
4215
|
+
| StopTouchAboveRootFinding;
|
|
3814
4216
|
|
|
3815
4217
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
3816
4218
|
export interface LintBasis {
|
|
@@ -3910,7 +4312,7 @@ export function coverageOutcome(c: BoneCoverage): CoverageOutcome {
|
|
|
3910
4312
|
*/
|
|
3911
4313
|
export function lint(
|
|
3912
4314
|
P: PartSet,
|
|
3913
|
-
spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[] },
|
|
4315
|
+
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>>; stops?: readonly ConfigStop[] },
|
|
3914
4316
|
joints?: RigJoints,
|
|
3915
4317
|
): LintResult {
|
|
3916
4318
|
const { byName } = expand(spec.bones);
|
|
@@ -4097,6 +4499,243 @@ export function lint(
|
|
|
4097
4499
|
}
|
|
4098
4500
|
}
|
|
4099
4501
|
}
|
|
4502
|
+
// Issue #439: when both arms overlap in one 8-connected component of a single handwear layer, name the overlap and
|
|
4503
|
+
// the config.seethrough.arm_split declaration that separates them into two layers.
|
|
4504
|
+
if (joints !== undefined && joints.joints.r_wrist.at !== null && joints.joints.l_wrist.at !== null) {
|
|
4505
|
+
const hwParts = [...P.byTag('handwear-r', undefined, true), ...P.byTag('handwear-l', undefined, true)].filter((p) => p.limb === undefined);
|
|
4506
|
+
if (hwParts.length === 1) {
|
|
4507
|
+
const part = hwParts[0];
|
|
4508
|
+
const rAt: [number, number] = [joints.joints.r_wrist.at[0], joints.joints.r_wrist.at[1]];
|
|
4509
|
+
const lAt: [number, number] = [joints.joints.l_wrist.at[0], joints.joints.l_wrist.at[1]];
|
|
4510
|
+
{
|
|
4511
|
+
const cc = connectedComponents(P.alpha(part), 8);
|
|
4512
|
+
const compsNear = (pt: readonly [number, number]): Set<number> => {
|
|
4513
|
+
const hit = new Set<number>();
|
|
4514
|
+
const px = pyInt(pt[0]);
|
|
4515
|
+
const py = pyInt(pt[1]);
|
|
4516
|
+
const x0 = Math.max(0, px - WRIST_ON_PART_PX);
|
|
4517
|
+
const x1 = Math.min(P.W - 1, px + WRIST_ON_PART_PX);
|
|
4518
|
+
const y0 = Math.max(0, py - WRIST_ON_PART_PX);
|
|
4519
|
+
const y1 = Math.min(P.H - 1, py + WRIST_ON_PART_PX);
|
|
4520
|
+
for (let y = y0; y <= y1; y++) {
|
|
4521
|
+
for (let x = x0; x <= x1; x++) {
|
|
4522
|
+
const l = cc.labels[y * P.W + x];
|
|
4523
|
+
if (l > 0) hit.add(l);
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
return hit;
|
|
4527
|
+
};
|
|
4528
|
+
const rComps = compsNear(rAt);
|
|
4529
|
+
const lComps = compsNear(lAt);
|
|
4530
|
+
const shared = [...rComps].find((l) => lComps.has(l));
|
|
4531
|
+
if (shared !== undefined) {
|
|
4532
|
+
findings.push({
|
|
4533
|
+
kind: 'overlapping-arms',
|
|
4534
|
+
part: part.name,
|
|
4535
|
+
from: part.from,
|
|
4536
|
+
rAt,
|
|
4537
|
+
lAt,
|
|
4538
|
+
opaquePx: cc.stats[shared].area,
|
|
4539
|
+
});
|
|
4540
|
+
}
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
// Issue #445: back-hair cloth trees (trunks `hairback_..._<r|l><k>_` hung from head) are held to the visible hair
|
|
4545
|
+
// masses on each side of the eye axis — one tree per mass, every link and tip inside that mass's column range.
|
|
4546
|
+
const bhParts = P.headFirst('back hair').filter((p) => spec.meshes[p.name] !== undefined);
|
|
4547
|
+
if (bhParts.length > 0) {
|
|
4548
|
+
try {
|
|
4549
|
+
const [fx0, fy0, fw, fh] = faceBoxOf(P).box;
|
|
4550
|
+
const ewR = P.byTag('eyewhite-r')[0] ?? null;
|
|
4551
|
+
const ewL = P.byTag('eyewhite-l')[0] ?? null;
|
|
4552
|
+
const axis = ewR !== null && ewL !== null ? (center(ewR)[0] + center(ewL)[0]) / 2 : fx0 + fw / 2;
|
|
4553
|
+
const eyeYs = [ewR, ewL].filter((x): x is PartRecord => x !== null).map((x) => center(x)[1]);
|
|
4554
|
+
const eyeY = eyeYs.length > 0 ? npMean(eyeYs) : fy0 + 0.5 * fh;
|
|
4555
|
+
const chin = fy0 + fh;
|
|
4556
|
+
const neckp = P.headFirst('neck');
|
|
4557
|
+
const neckY = joints?.joints.neck.at?.[1] ?? (neckp.length > 0 ? (chin + neckp[0].y + neckp[0].h) / 2 : chin + 0.12 * fh);
|
|
4558
|
+
const shoulderLine = neckY + 0.5 * fh;
|
|
4559
|
+
const y0 = pyRound(eyeY);
|
|
4560
|
+
const seenGroups = new Set<string>();
|
|
4561
|
+
for (const part of bhParts) {
|
|
4562
|
+
const m = spec.meshes[part.name];
|
|
4563
|
+
const segStrings = m.segments.filter((sp): sp is string => typeof sp === 'string');
|
|
4564
|
+
const trunkMatch = segStrings.map((sp) => /^(hairback_(?:.+_)?)(r|l)(\d+)_$/.exec(sp)).find((x) => x !== null) ?? null;
|
|
4565
|
+
if (trunkMatch === null) continue;
|
|
4566
|
+
const basePrefix = trunkMatch[1];
|
|
4567
|
+
if (seenGroups.has(basePrefix)) continue;
|
|
4568
|
+
seenGroups.add(basePrefix);
|
|
4569
|
+
const groupParts = bhParts.filter((q) =>
|
|
4570
|
+
spec.meshes[q.name].segments.some((sp) => typeof sp === 'string' && sp.startsWith(basePrefix) && /^(r|l)\d+_$/.test(sp.slice(basePrefix.length))),
|
|
4571
|
+
);
|
|
4572
|
+
const mask = groupParts.length === 1 ? P.alpha(part) : newMask(P.W, P.H);
|
|
4573
|
+
if (groupParts.length > 1) {
|
|
4574
|
+
for (const q of groupParts) {
|
|
4575
|
+
const mq = P.alpha(q);
|
|
4576
|
+
for (let i = 0; i < mask.data.length; i++) if (mq.data[i]) mask.data[i] = 1;
|
|
4577
|
+
}
|
|
4578
|
+
}
|
|
4579
|
+
const m0 = spec.meshes[groupParts[0].name];
|
|
4580
|
+
const g = 'grid' in m0 ? m0.grid : partGrid(groupParts[0])[0];
|
|
4581
|
+
for (const side of ['r', 'l'] as const) {
|
|
4582
|
+
const trunkRe = new RegExp(`^${basePrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${side}(\\d+)_$`);
|
|
4583
|
+
const sideTrunks = spec.bones
|
|
4584
|
+
.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)))
|
|
4585
|
+
.sort((a, b) => Number(trunkRe.exec(a.chain)![1]) - Number(trunkRe.exec(b.chain)![1]));
|
|
4586
|
+
if (sideTrunks.length === 0) continue;
|
|
4587
|
+
const masses = backHairMasses(mask, side, axis, y0, shoulderLine, g);
|
|
4588
|
+
const outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }> = [];
|
|
4589
|
+
const sideMinX = masses.length > 0 ? Math.min(...masses.map((x) => x.minX)) : 0;
|
|
4590
|
+
const sideMaxX = masses.length > 0 ? Math.max(...masses.map((x) => x.maxX)) : P.W - 1;
|
|
4591
|
+
sideTrunks.forEach((tr, k) => {
|
|
4592
|
+
const target = k < masses.length ? masses[k] : null;
|
|
4593
|
+
const minX = target !== null ? target.minX : sideMinX;
|
|
4594
|
+
const maxX = target !== null ? target.maxX : sideMaxX;
|
|
4595
|
+
const treeBase = tr.chain.slice(0, -1);
|
|
4596
|
+
const treeRe = new RegExp(`^${treeBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[a-z]*_$`);
|
|
4597
|
+
const treeChains = spec.bones.filter((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && treeRe.test(b.chain));
|
|
4598
|
+
for (const c of treeChains) {
|
|
4599
|
+
c.points.forEach((pt, idx) => {
|
|
4600
|
+
const px = pyInt(pt[0]);
|
|
4601
|
+
const py = pyInt(pt[1]);
|
|
4602
|
+
if (px < minX || px > maxX) outside.push({ point: `${c.chain}${idx}`, at: [px, py], minX, maxX });
|
|
4603
|
+
});
|
|
4604
|
+
const tx = pyInt(c.tip[0]);
|
|
4605
|
+
const ty = pyInt(c.tip[1]);
|
|
4606
|
+
if (tx < minX || tx > maxX) outside.push({ point: `${c.chain} tip`, at: [tx, ty], minX, maxX });
|
|
4607
|
+
}
|
|
4608
|
+
});
|
|
4609
|
+
if (sideTrunks.length !== masses.length || outside.length > 0) {
|
|
4610
|
+
findings.push({
|
|
4611
|
+
kind: 'back-hair-masses',
|
|
4612
|
+
mesh: groupParts.map((q) => q.name).join(' -> '),
|
|
4613
|
+
side,
|
|
4614
|
+
trunks: sideTrunks.map((t) => t.chain),
|
|
4615
|
+
masses: masses.map((x) => ({ minX: x.minX, maxX: x.maxX, minY: x.minY, maxY: x.maxY })),
|
|
4616
|
+
outside,
|
|
4617
|
+
});
|
|
4618
|
+
}
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
} catch (err) {
|
|
4622
|
+
if (!(err instanceof PartsError)) throw err;
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
// Issue #446: when two limbs sharing one part cross in an X without a per-limb split declared on the covering limb,
|
|
4626
|
+
// nearest-bone splitting cuts a straight line through the crossing.
|
|
4627
|
+
{
|
|
4628
|
+
const baseP = P.beforeLimbs();
|
|
4629
|
+
const places = bonePlaces(spec.bones);
|
|
4630
|
+
if (spec.limbs !== undefined) {
|
|
4631
|
+
const limbNames = Object.keys(spec.limbs);
|
|
4632
|
+
const fromParts = [...new Set(limbNames.flatMap((l) => spec.limbs![l].segments.map((sg) => sg.from)))];
|
|
4633
|
+
for (const src of fromParts) {
|
|
4634
|
+
const on = limbNames.filter((l) => spec.limbs![l].segments.some((sg) => sg.from === src));
|
|
4635
|
+
if (on.length < 2 || on.some((l) => spec.limbs![l].split !== undefined)) continue;
|
|
4636
|
+
const valid = on.filter((l) => spec.limbs![l].segments.every((sg) => places.has(sg.bone)));
|
|
4637
|
+
if (valid.length < 2) continue;
|
|
4638
|
+
const rec = baseP.recs.find((r) => r.name === src);
|
|
4639
|
+
for (let i = 0; i < valid.length; i++) {
|
|
4640
|
+
const polyA = limbPolyline(spec.limbs[valid[i]], places);
|
|
4641
|
+
for (let j = i + 1; j < valid.length; j++) {
|
|
4642
|
+
const polyB = limbPolyline(spec.limbs[valid[j]], places);
|
|
4643
|
+
const cross = limbCrossing(polyA, polyB);
|
|
4644
|
+
if (cross !== null) {
|
|
4645
|
+
findings.push({
|
|
4646
|
+
kind: 'crossed-limbs',
|
|
4647
|
+
part: src,
|
|
4648
|
+
from: rec?.from ?? '',
|
|
4649
|
+
a: valid[i],
|
|
4650
|
+
b: valid[j],
|
|
4651
|
+
at: cross,
|
|
4652
|
+
});
|
|
4653
|
+
}
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
}
|
|
4657
|
+
} else {
|
|
4658
|
+
const legR = spec.bones.find((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.chain === 'leg_r' && b.points.length >= 2);
|
|
4659
|
+
const legL = spec.bones.find((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.chain === 'leg_l' && b.points.length >= 2);
|
|
4660
|
+
if (legR !== undefined && legL !== undefined) {
|
|
4661
|
+
const polyR: Array<[number, number]> = [...legR.points.map((p): [number, number] => [p[0], p[1]]), [legR.tip[0], legR.tip[1]]];
|
|
4662
|
+
const polyL: Array<[number, number]> = [...legL.points.map((p): [number, number] => [p[0], p[1]]), [legL.tip[0], legL.tip[1]]];
|
|
4663
|
+
const cross = limbCrossing(polyR, polyL);
|
|
4664
|
+
if (cross !== null) {
|
|
4665
|
+
const lwParts = baseP.byTag('legwear');
|
|
4666
|
+
const shared = lwParts.find((p: PartRecord) => {
|
|
4667
|
+
const near = dilate(baseP.alpha(p), 2 * WRIST_ON_PART_PX + 1);
|
|
4668
|
+
return isOn(near, legR.tip[0], legR.tip[1]) && isOn(near, legL.tip[0], legL.tip[1]);
|
|
4669
|
+
});
|
|
4670
|
+
if (shared !== undefined) {
|
|
4671
|
+
findings.push({
|
|
4672
|
+
kind: 'crossed-limbs',
|
|
4673
|
+
part: shared.name,
|
|
4674
|
+
from: shared.from,
|
|
4675
|
+
a: 'leg_r',
|
|
4676
|
+
b: 'leg_l',
|
|
4677
|
+
at: cross,
|
|
4678
|
+
});
|
|
4679
|
+
}
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4683
|
+
}
|
|
4684
|
+
// Issue #447: when a stop's contact point `<chain>_touch` (`at` in `against`'s local frame) lies above the first
|
|
4685
|
+
// stopped link's root (`links[0]`) in rig pixels (`ty < y0`, i.e. higher Spine y), `<chain>_rest` aims into the
|
|
4686
|
+
// upper half-plane (`|rest| > 90°`) and `|rest| + outward > 180°` wraps past ±180° (`RIG_STOP_BOUNDS`).
|
|
4687
|
+
if (spec.stops !== undefined && spec.stops.length > 0) {
|
|
4688
|
+
const boneEnds = new Map<string, Point>();
|
|
4689
|
+
const chainLinks = new Map<string, string[]>();
|
|
4690
|
+
for (const e of spec.bones) {
|
|
4691
|
+
if ('chain' in e) {
|
|
4692
|
+
const names = e.points.map((_, k) => `${e.chain}${k}`);
|
|
4693
|
+
chainLinks.set(e.chain, names);
|
|
4694
|
+
e.points.forEach((_, k) => {
|
|
4695
|
+
boneEnds.set(names[k], k + 1 < e.points.length ? e.points[k + 1] : e.tip);
|
|
4696
|
+
});
|
|
4697
|
+
} else if (e.tip !== undefined) {
|
|
4698
|
+
boneEnds.set(e.name, e.tip);
|
|
4699
|
+
}
|
|
4700
|
+
}
|
|
4701
|
+
for (const s of spec.stops) {
|
|
4702
|
+
const links = s.links ?? chainLinks.get(s.chain) ?? [];
|
|
4703
|
+
if (links.length === 0) continue;
|
|
4704
|
+
const firstLink = links[0];
|
|
4705
|
+
const rootBone = byName.get(firstLink);
|
|
4706
|
+
const againstBone = byName.get(s.against);
|
|
4707
|
+
if (rootBone === undefined || againstBone === undefined) continue;
|
|
4708
|
+
const againstEnd = boneEnds.get(s.against);
|
|
4709
|
+
let againstRot = 0;
|
|
4710
|
+
if (againstEnd !== undefined) {
|
|
4711
|
+
const dx = againstEnd[0] - againstBone.x;
|
|
4712
|
+
const dy = cropToSpineY(againstEnd[1], P.H) - cropToSpineY(againstBone.y, P.H);
|
|
4713
|
+
if (dx !== 0 || dy !== 0) againstRot = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
4714
|
+
}
|
|
4715
|
+
const frame = computeExactFrameTransforms([
|
|
4716
|
+
{ name: s.against, x: againstBone.x, y: cropToSpineY(againstBone.y, P.H), rotation: againstRot },
|
|
4717
|
+
]).get(s.against)!;
|
|
4718
|
+
const [wx, wy] = toWorld(frame, s.at[0], s.at[1]);
|
|
4719
|
+
const touchAt: [number, number] = [pyRound(wx * 10) / 10, pyRound(cropToSpineY(wy, P.H) * 10) / 10];
|
|
4720
|
+
const aimDx = wx - rootBone.x;
|
|
4721
|
+
const aimDy = wy - cropToSpineY(rootBone.y, P.H);
|
|
4722
|
+
if (aimDx === 0 && aimDy === 0) continue;
|
|
4723
|
+
const rest = pyRound(normaliseDegrees((Math.atan2(aimDy, aimDx) * 180) / Math.PI) * 100) / 100;
|
|
4724
|
+
if (touchAt[1] < rootBone.y || Math.abs(rest) + s.outward > 180) {
|
|
4725
|
+
findings.push({
|
|
4726
|
+
kind: 'stop-touch-above-root',
|
|
4727
|
+
chain: s.chain,
|
|
4728
|
+
firstLink,
|
|
4729
|
+
rootAt: [rootBone.x, rootBone.y],
|
|
4730
|
+
against: s.against,
|
|
4731
|
+
at: [s.at[0], s.at[1]],
|
|
4732
|
+
touchAt,
|
|
4733
|
+
rest,
|
|
4734
|
+
outward: s.outward,
|
|
4735
|
+
});
|
|
4736
|
+
}
|
|
4737
|
+
}
|
|
4738
|
+
}
|
|
4100
4739
|
// Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
|
|
4101
4740
|
// to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
|
|
4102
4741
|
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 +4772,23 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
|
4133
4772
|
case 'squeeze':
|
|
4134
4773
|
return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
|
|
4135
4774
|
case 'near-empty-hand':
|
|
4775
|
+
case 'overlapping-arms':
|
|
4776
|
+
case 'crossed-limbs':
|
|
4777
|
+
case 'stop-touch-above-root':
|
|
4136
4778
|
return [];
|
|
4779
|
+
case 'back-hair-masses': {
|
|
4780
|
+
const out = new Set<string>();
|
|
4781
|
+
for (const o of f.outside) {
|
|
4782
|
+
if (!o.point.endsWith(' tip')) out.add(o.point);
|
|
4783
|
+
else {
|
|
4784
|
+
const chain = o.point.slice(0, -' tip'.length);
|
|
4785
|
+
const e = bones.find((b) => 'chain' in b && b.chain === chain);
|
|
4786
|
+
if (e !== undefined && 'chain' in e) out.add(`${chain}${e.points.length - 1}`);
|
|
4787
|
+
}
|
|
4788
|
+
}
|
|
4789
|
+
if (out.size === 0) for (const t of f.trunks) out.add(`${t}0`);
|
|
4790
|
+
return [...out];
|
|
4791
|
+
}
|
|
4137
4792
|
case 'chain-not-advancing': {
|
|
4138
4793
|
if (!f.point.endsWith(' tip')) return [f.point];
|
|
4139
4794
|
const chain = f.point.slice(0, -' tip'.length);
|
|
@@ -4308,6 +4963,23 @@ export function lintLine(f: LintFinding): string {
|
|
|
4308
4963
|
: `forearm width ${pyFixed(f.basis.width, 1)} px squared`;
|
|
4309
4964
|
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
4965
|
}
|
|
4966
|
+
case 'overlapping-arms':
|
|
4967
|
+
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`;
|
|
4968
|
+
case 'crossed-limbs':
|
|
4969
|
+
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`;
|
|
4970
|
+
case 'stop-touch-above-root':
|
|
4971
|
+
return `LINT stop on ${f.chain} (${f.firstLink} at ${fmtAt(f.rootAt)}) against ${f.against} (at ${fmtAt(f.at)}) places ${f.chain}_touch at ${fmtAt(f.touchAt)}, above ${f.firstLink}'s root (rest ${pyFixed(f.rest, 2)} deg, |rest| + outward ${pyFixed(Math.abs(f.rest) + f.outward, 2)} deg): stop against the descending limb bone below ${f.firstLink}, set links to start at a link below ${f.chain}_touch, or omit the stop on a rising limb`;
|
|
4972
|
+
case 'back-hair-masses': {
|
|
4973
|
+
const massDesc =
|
|
4974
|
+
f.masses.length === 0
|
|
4975
|
+
? 'none'
|
|
4976
|
+
: f.masses.map((m, i) => `mass ${i} columns x=${m.minX}..${m.maxX}, y=${m.minY}..${m.maxY}`).join('; ');
|
|
4977
|
+
const outDesc =
|
|
4978
|
+
f.outside.length === 0
|
|
4979
|
+
? ''
|
|
4980
|
+
: `; outside mass columns: ${f.outside.map((o) => `${o.point} at ${pyIntList(o.at)} (outside x=${o.minX}..${o.maxX})`).join(', ')}`;
|
|
4981
|
+
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`;
|
|
4982
|
+
}
|
|
4311
4983
|
case 'chain-not-advancing':
|
|
4312
4984
|
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
4985
|
}
|