@rig-c/parts 1.30.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.ts +40 -7
- package/docs/AUTHORING.md +5 -3
- package/docs/STABILITY.md +4 -3
- package/package.json +1 -1
- package/src/build.ts +214 -6
- package/src/check.ts +6 -4
- package/src/cloth.ts +60 -8
- package/src/config.ts +85 -16
- package/src/parts.ts +53 -12
- package/src/propose.ts +52 -27
- package/src/rig.ts +118 -2
package/src/cloth.ts
CHANGED
|
@@ -107,11 +107,13 @@ export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* 📐 The fallback tree count when `draftTrees` is called without `chains` or
|
|
110
|
-
* `masses` (issues #390, #445). When `chains` is omitted on
|
|
110
|
+
* `masses` (issues #390, #445, #461). When `chains` is omitted on
|
|
111
111
|
* `config.cloth."back hair"`, `propose` derives the count per side from the
|
|
112
|
-
* visible hair masses ({@link backHairMasses}):
|
|
113
|
-
* back-hair part's alpha below the root row (merged
|
|
114
|
-
* grid cell),
|
|
112
|
+
* visible hair masses ({@link backHairMasses}, {@link backHairMassTrees}):
|
|
113
|
+
* connected components of the back-hair part's alpha below the root row (merged
|
|
114
|
+
* when they touch within one grid cell), with `max(1, floor(w / (4 * grid)) + 1)`
|
|
115
|
+
* trees per mass across its root-row width `w`, rooted at that mass's own top
|
|
116
|
+
* row and inside its columns.
|
|
115
117
|
*/
|
|
116
118
|
export const RECIPE_BACK_HAIR_CHAINS = 2;
|
|
117
119
|
|
|
@@ -185,13 +187,16 @@ export interface TreeChain {
|
|
|
185
187
|
/** Which outer sides of the piece are edges: both (a skirt, a lock), or one side of the eye axis (back hair). */
|
|
186
188
|
export type EdgeSides = 'both' | 'r' | 'l';
|
|
187
189
|
|
|
188
|
-
/** Issue #445: one visible hair mass's own frame for a
|
|
190
|
+
/** Issue #445, #461: one visible hair mass's own frame for a tree rooted at that mass's top row. */
|
|
189
191
|
export interface TreeMassInput {
|
|
190
192
|
rootY: number;
|
|
191
193
|
tipY: number;
|
|
192
194
|
xAt: (y: number, frac: number) => number | null;
|
|
193
195
|
fallbackX: (frac: number) => number;
|
|
194
196
|
frame: string;
|
|
197
|
+
/** Issue #461: tree index within this mass (0-based, default 0) and total trees in this mass (default 1). */
|
|
198
|
+
treeInMass?: number;
|
|
199
|
+
treesInMass?: number;
|
|
195
200
|
}
|
|
196
201
|
|
|
197
202
|
export interface TreeColumnTip {
|
|
@@ -213,7 +218,7 @@ export interface TreeInput {
|
|
|
213
218
|
/** What the cell's frame is, for the rules: "skirt's art", "the r side of back's art". */
|
|
214
219
|
frame: string;
|
|
215
220
|
edges: EdgeSides;
|
|
216
|
-
/** Issue #445: when present, one entry per
|
|
221
|
+
/** Issue #445: when present, one entry per drafted back-hair tree; tree k sits inside `masses[k]` rather than cell [k/n, (k+1)/n] of the whole side. */
|
|
217
222
|
masses?: readonly TreeMassInput[];
|
|
218
223
|
/**
|
|
219
224
|
* Issue #438: when present, resolves the tip row (and an optional `xAt` bounded to that column's cloth rows) for
|
|
@@ -267,7 +272,9 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
|
267
272
|
};
|
|
268
273
|
for (const k of activeTrees) {
|
|
269
274
|
const mass = input.masses?.[k];
|
|
270
|
-
const
|
|
275
|
+
const tInMass = mass?.treeInMass ?? 0;
|
|
276
|
+
const nInMass = mass?.treesInMass ?? 1;
|
|
277
|
+
const fc = mass === undefined ? (k + 0.5) / n : (tInMass + 0.5) / nInMass;
|
|
271
278
|
const trunkCol = trunkCols.get(k) ?? null;
|
|
272
279
|
const branchCols: Array<TreeColumnTip | null> = [];
|
|
273
280
|
if (into > 1 && mass === undefined && input.columnTip !== undefined) {
|
|
@@ -314,7 +321,7 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
|
314
321
|
out.push(trunk);
|
|
315
322
|
if (into === 1) continue;
|
|
316
323
|
for (let b = 0; b < into; b++) {
|
|
317
|
-
const fb = mass === undefined ? (k + (b + 0.5) / into) / n : (b + 0.5) / into;
|
|
324
|
+
const fb = mass === undefined ? (k + (b + 0.5) / into) / n : (tInMass + (b + 0.5) / into) / nInMass;
|
|
318
325
|
const bCol = branchCols[b] ?? trunkCol;
|
|
319
326
|
const bTipY = bCol?.tipY ?? trunkTipY;
|
|
320
327
|
const bRows = bTipY === trunkTipY ? rows : linkRows(input.rootY, bTipY, r.links, ratio);
|
|
@@ -764,3 +771,48 @@ export function backHairMasses(mask: Mask, side: 'r' | 'l', axis: number, y0: nu
|
|
|
764
771
|
out.sort((a, b) => a.minX - b.minX);
|
|
765
772
|
return out;
|
|
766
773
|
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* 📐 The horizontal influence reach of one back-hair tree in mesh grid cells: 4
|
|
777
|
+
* (2 grid cells on each side of the trunk, issue #461).
|
|
778
|
+
*
|
|
779
|
+
* Under the segment weight rule `w = 1 / (d + r)^2` (`src/weights.ts`) with
|
|
780
|
+
* `partGrid`'s falloff `r ≈ 0.42 * grid` and cutoff `MIN_WEIGHT = 0.03`, a
|
|
781
|
+
* tree's weight relative to `d = 0` (`(r / (d + r))^2`) drops to `MIN_WEIGHT`
|
|
782
|
+
* at `d = (1 / sqrt(0.03) - 1) * r = 4.7735 * 0.42 * grid = 2.0049 * grid ≈ 2 * grid`
|
|
783
|
+
* on each side of the trunk — a horizontal span of `4 * grid` per tree. Beyond
|
|
784
|
+
* that span a single tree leaves outer columns weighted to the static `head`
|
|
785
|
+
* segment while adjacent columns move with the tree, stretching the triangles
|
|
786
|
+
* across the boundary.
|
|
787
|
+
*/
|
|
788
|
+
export const BACK_HAIR_TREE_REACH_CELLS = 4;
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Opaque pixel count of a visible hair mass `m` along its own root row `m.minY`
|
|
792
|
+
* (issue #461).
|
|
793
|
+
*/
|
|
794
|
+
export function backHairMassRootWidth(m: BackHairMass): number {
|
|
795
|
+
const W = m.mask.width;
|
|
796
|
+
const H = m.mask.height;
|
|
797
|
+
if (m.minY < 0 || m.minY >= H) return 0;
|
|
798
|
+
let w = 0;
|
|
799
|
+
for (let x = m.minX; x <= m.maxX; x++) {
|
|
800
|
+
if (m.mask.data[m.minY * W + x] !== 0) w++;
|
|
801
|
+
}
|
|
802
|
+
return w > 0 ? w : m.maxX - m.minX + 1;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Number of trees placed inside one visible hair mass `m` on a `grid` px mesh
|
|
807
|
+
* (issue #461): `max(1, floor(w / (4 * grid)) + 1)` where `w` is the mass's
|
|
808
|
+
* root-row opaque width ({@link backHairMassRootWidth}).
|
|
809
|
+
*
|
|
810
|
+
* A narrow mass (`w < 4 * grid`) gets 1 tree; a wide undivided tail spanning
|
|
811
|
+
* `4 * grid <= w < 8 * grid` gets 2 trees; `8 * grid <= w < 12 * grid` gets 3.
|
|
812
|
+
*/
|
|
813
|
+
export function backHairMassTrees(m: BackHairMass, grid: number): number {
|
|
814
|
+
const step = Math.max(1, BACK_HAIR_TREE_REACH_CELLS * grid);
|
|
815
|
+
const w = backHairMassRootWidth(m);
|
|
816
|
+
return Math.max(1, Math.floor(w / step) + 1);
|
|
817
|
+
}
|
|
818
|
+
|
package/src/config.ts
CHANGED
|
@@ -245,6 +245,17 @@ export interface Cut {
|
|
|
245
245
|
under_fill?: 'colour';
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
export interface AlternateSetConfig {
|
|
249
|
+
name: string;
|
|
250
|
+
source?: string;
|
|
251
|
+
full?: string;
|
|
252
|
+
head?: string;
|
|
253
|
+
parts?: string[];
|
|
254
|
+
hides?: string[];
|
|
255
|
+
head_box?: [number, number, number, number];
|
|
256
|
+
cuts?: Cut[];
|
|
257
|
+
}
|
|
258
|
+
|
|
248
259
|
export interface Assemble {
|
|
249
260
|
/** Rig pixels per source pixel. */
|
|
250
261
|
rig_scale: number;
|
|
@@ -252,6 +263,7 @@ export interface Assemble {
|
|
|
252
263
|
extend_below_crop?: Extend[];
|
|
253
264
|
patches?: Patch[];
|
|
254
265
|
cuts?: Cut[];
|
|
266
|
+
alternates?: AlternateSetConfig[];
|
|
255
267
|
}
|
|
256
268
|
|
|
257
269
|
export interface SingleBone {
|
|
@@ -1112,7 +1124,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1112
1124
|
if (door === 'layers') {
|
|
1113
1125
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1114
1126
|
if ('assemble' in t) {
|
|
1115
|
-
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts']);
|
|
1127
|
+
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
|
|
1116
1128
|
if (a !== null && 'rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
|
|
1117
1129
|
}
|
|
1118
1130
|
}
|
|
@@ -1122,10 +1134,14 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1122
1134
|
checkProposedRigScale(c, t);
|
|
1123
1135
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1124
1136
|
// Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
|
|
1137
|
+
let limbs: LimbNames | null = null;
|
|
1125
1138
|
if ('limbs' in t) {
|
|
1126
1139
|
if (!('bones' in t)) c.fail('CONFIG_FIELD_PRESENT', 'config.bones', 'is absent; config.limbs names bones, and each joint is its bone\'s origin, so assemble reads the bones with the limbs');
|
|
1127
1140
|
const names = 'bones' in t ? checkBones(c, t.bones) : null;
|
|
1128
|
-
checkLimbs(c, t.limbs, made, names);
|
|
1141
|
+
limbs = checkLimbs(c, t.limbs, made, names);
|
|
1142
|
+
}
|
|
1143
|
+
if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
|
|
1144
|
+
checkAlternates(c, t.assemble.alternates, made, limbs);
|
|
1129
1145
|
}
|
|
1130
1146
|
}
|
|
1131
1147
|
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
@@ -1276,6 +1292,9 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1276
1292
|
checkProposedRigScale(c, t);
|
|
1277
1293
|
const names = 'bones' in t ? checkBones(c, t.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
|
|
1278
1294
|
const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
|
|
1295
|
+
if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
|
|
1296
|
+
checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs);
|
|
1297
|
+
}
|
|
1279
1298
|
if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
|
|
1280
1299
|
if ('regions' in t) checkRegions(c, t.regions, names.bones);
|
|
1281
1300
|
if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions, limbs);
|
|
@@ -1602,6 +1621,17 @@ function checkGeneration(c: Check, v: Json): void {
|
|
|
1602
1621
|
}
|
|
1603
1622
|
}
|
|
1604
1623
|
|
|
1624
|
+
function checkHeadBox(c: Check, path: string, b: Json): void {
|
|
1625
|
+
const ints = Array.isArray(b) && b.length === 4 && b.every((n) => typeof n === 'number' && Number.isInteger(n));
|
|
1626
|
+
if (!ints) c.fail('CONFIG_FIELD_TYPE', path, `is ${show(b)}; [x0, y0, x1, y1] in integer source pixels is required`);
|
|
1627
|
+
else {
|
|
1628
|
+
const [x0, y0, x1, y1] = b as number[];
|
|
1629
|
+
if (!(x1 > x0 && y1 > y0 && x1 - x0 === y1 - y0)) {
|
|
1630
|
+
c.fail('CONFIG_HEAD_BOX_SQUARE', path, `is ${x1 - x0}x${y1 - y0}; a non-empty square is required (the head run is fed a square crop)`);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1605
1635
|
function checkSeeThrough(c: Check, v: Json): void {
|
|
1606
1636
|
const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box', 'arm_split']);
|
|
1607
1637
|
if (s === null) return;
|
|
@@ -1610,17 +1640,7 @@ function checkSeeThrough(c: Check, v: Json): void {
|
|
|
1610
1640
|
if ('steps' in s) c.int(`${p}.steps`, s.steps, 1);
|
|
1611
1641
|
if ('seed' in s) c.int(`${p}.seed`, s.seed, 0);
|
|
1612
1642
|
if ('offload' in s && typeof s.offload !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${p}.offload`, `is ${show(s.offload)}; true or false is required`);
|
|
1613
|
-
if ('head_box' in s) {
|
|
1614
|
-
const b = s.head_box;
|
|
1615
|
-
const ints = Array.isArray(b) && b.length === 4 && b.every((n) => typeof n === 'number' && Number.isInteger(n));
|
|
1616
|
-
if (!ints) c.fail('CONFIG_FIELD_TYPE', `${p}.head_box`, `is ${show(b)}; [x0, y0, x1, y1] in integer source pixels is required`);
|
|
1617
|
-
else {
|
|
1618
|
-
const [x0, y0, x1, y1] = b as number[];
|
|
1619
|
-
if (!(x1 > x0 && y1 > y0 && x1 - x0 === y1 - y0)) {
|
|
1620
|
-
c.fail('CONFIG_HEAD_BOX_SQUARE', `${p}.head_box`, `is ${x1 - x0}x${y1 - y0}; a non-empty square is required (the head run is fed a square crop)`);
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1643
|
+
if ('head_box' in s) checkHeadBox(c, `${p}.head_box`, s.head_box);
|
|
1624
1644
|
if ('arm_split' in s) checkArmSplit(c, `${p}.arm_split`, s.arm_split, res);
|
|
1625
1645
|
}
|
|
1626
1646
|
|
|
@@ -1689,7 +1709,7 @@ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
|
|
|
1689
1709
|
*/
|
|
1690
1710
|
function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
|
|
1691
1711
|
const required = sectionRequired(door, 'assemble');
|
|
1692
|
-
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts']);
|
|
1712
|
+
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
|
|
1693
1713
|
if (a === null) return { parts: [], patches: [], cuts: [] };
|
|
1694
1714
|
if (required.includes('plan') && !('plan' in a)) {
|
|
1695
1715
|
c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
|
|
@@ -1743,8 +1763,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
|
|
|
1743
1763
|
* one polygon. `into` is a new part name: not a plan part, not a patch, not
|
|
1744
1764
|
* another cut's. Returns the names the cuts make, in order.
|
|
1745
1765
|
*/
|
|
1746
|
-
function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string[] {
|
|
1747
|
-
const p = 'config.assemble.cuts';
|
|
1766
|
+
function checkCuts(c: Check, v: Json, plan: string[], patches: string[], p = 'config.assemble.cuts'): string[] {
|
|
1748
1767
|
const names: string[] = [];
|
|
1749
1768
|
if (!c.array(p, v, false)) return names;
|
|
1750
1769
|
const seen = new Map<string, number>();
|
|
@@ -1796,6 +1815,56 @@ function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string
|
|
|
1796
1815
|
return names;
|
|
1797
1816
|
}
|
|
1798
1817
|
|
|
1818
|
+
function checkAlternates(
|
|
1819
|
+
c: Check,
|
|
1820
|
+
v: Json,
|
|
1821
|
+
made: { parts: string[]; patches: string[]; cuts: string[] },
|
|
1822
|
+
limbs: LimbNames | null,
|
|
1823
|
+
): void {
|
|
1824
|
+
const p = 'config.assemble.alternates';
|
|
1825
|
+
if (!c.array(p, v, true)) return;
|
|
1826
|
+
const allSlots = new Set<string>([...made.parts, ...made.patches, ...made.cuts, ...(limbs?.parts.keys() ?? [])]);
|
|
1827
|
+
const seenNames = new Map<string, number>();
|
|
1828
|
+
(v as Json[]).forEach((entry, i) => {
|
|
1829
|
+
const at = `${p}[${i}]`;
|
|
1830
|
+
const e = c.object(at, entry, ['name'], ['source', 'full', 'head', 'parts', 'hides', 'head_box', 'cuts']);
|
|
1831
|
+
if (e === null) return;
|
|
1832
|
+
if ('name' in e && c.string(`${at}.name`, e.name)) {
|
|
1833
|
+
if (!partNameOk(e.name)) c.fail('CONFIG_PART_NAME', `${at}.name`, `names the set ${show(e.name)}; a set name is a file prefix, so it may not be empty, start with "." or hold a slash`);
|
|
1834
|
+
if (seenNames.has(e.name)) c.fail('CONFIG_PART_UNIQUE', `${at}.name`, `names the set "${e.name}" again (first at alternates[${seenNames.get(e.name)}])`);
|
|
1835
|
+
else seenNames.set(e.name, i);
|
|
1836
|
+
}
|
|
1837
|
+
for (const k of ['source', 'full', 'head'] as const) {
|
|
1838
|
+
if (k in e) c.string(`${at}.${k}`, e[k]);
|
|
1839
|
+
}
|
|
1840
|
+
if ('head_box' in e) checkHeadBox(c, `${at}.head_box`, e.head_box);
|
|
1841
|
+
const altCuts = 'cuts' in e ? checkCuts(c, e.cuts, made.parts, [], `${at}.cuts`) : [];
|
|
1842
|
+
const validParts = new Set<string>([...allSlots, ...altCuts]);
|
|
1843
|
+
if ('parts' in e && c.array(`${at}.parts`, e.parts, true)) {
|
|
1844
|
+
const seen = new Set<string>();
|
|
1845
|
+
(e.parts as Json[]).forEach((pn, k) => {
|
|
1846
|
+
if (!c.string(`${at}.parts[${k}]`, pn)) return;
|
|
1847
|
+
if (!validParts.has(pn)) {
|
|
1848
|
+
c.fail('CONFIG_NAME_RESOLVES', `${at}.parts[${k}]`, `is ${show(pn)}; a slot of the base rig (or a part made by this alternate's cuts) is required`);
|
|
1849
|
+
} else if (seen.has(pn)) {
|
|
1850
|
+
c.fail('CONFIG_PART_UNIQUE', `${at}.parts[${k}]`, `repeats "${pn}"; each covered part is named once`);
|
|
1851
|
+
} else seen.add(pn);
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
if ('hides' in e && c.array(`${at}.hides`, e.hides, false)) {
|
|
1855
|
+
const seen = new Set<string>();
|
|
1856
|
+
(e.hides as Json[]).forEach((hn, k) => {
|
|
1857
|
+
if (!c.string(`${at}.hides[${k}]`, hn)) return;
|
|
1858
|
+
if (!allSlots.has(hn)) {
|
|
1859
|
+
c.fail('CONFIG_NAME_RESOLVES', `${at}.hides[${k}]`, `is ${show(hn)}; a slot of the base rig is required`);
|
|
1860
|
+
} else if (seen.has(hn)) {
|
|
1861
|
+
c.fail('CONFIG_PART_UNIQUE', `${at}.hides[${k}]`, `repeats "${hn}"; each hidden slot is named once`);
|
|
1862
|
+
} else seen.add(hn);
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1799
1868
|
/**
|
|
1800
1869
|
* `assemble.patches`: each entry `{name, box, alpha, draw}`, refused field by
|
|
1801
1870
|
* field. What the config alone cannot know — whether the box lies inside the
|
package/src/parts.ts
CHANGED
|
@@ -241,6 +241,23 @@ export interface RecompositeRecord {
|
|
|
241
241
|
holes: HoleRecord[];
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
export interface AlternateSetMap {
|
|
245
|
+
slots: Record<string, string>;
|
|
246
|
+
hides?: string[];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface AlternatePartsRecord {
|
|
250
|
+
name: string;
|
|
251
|
+
parts?: string[];
|
|
252
|
+
hides?: string[];
|
|
253
|
+
slots: Record<string, string>;
|
|
254
|
+
records: PartRecord[];
|
|
255
|
+
ghost_px: Record<string, number>;
|
|
256
|
+
recomposite: RecompositeRecord;
|
|
257
|
+
scene?: SceneRecord;
|
|
258
|
+
single_run_pad?: SingleRunPad;
|
|
259
|
+
}
|
|
260
|
+
|
|
244
261
|
export interface PartsFile {
|
|
245
262
|
rig_size: [number, number];
|
|
246
263
|
scale_rig_per_source: number;
|
|
@@ -252,6 +269,8 @@ export interface PartsFile {
|
|
|
252
269
|
scene?: SceneRecord;
|
|
253
270
|
/** Written only for one run whose file is not the resolution square (issue #366): where the file sat on the run's square; absent otherwise. */
|
|
254
271
|
single_run_pad?: SingleRunPad;
|
|
272
|
+
/** Written only when alternate painting sets were assembled (issue #454); absent otherwise. */
|
|
273
|
+
alternates?: AlternatePartsRecord[];
|
|
255
274
|
}
|
|
256
275
|
|
|
257
276
|
/**
|
|
@@ -286,8 +305,8 @@ export interface SceneRecord {
|
|
|
286
305
|
figure_mask: string;
|
|
287
306
|
}
|
|
288
307
|
|
|
289
|
-
const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad'] as const;
|
|
290
|
-
const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad');
|
|
308
|
+
const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad', 'alternates'] as const;
|
|
309
|
+
const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad' && k !== 'alternates');
|
|
291
310
|
export const SINGLE_RUN_PAD_KEYS = ['file_size', 'side', 'left', 'top'] as const;
|
|
292
311
|
export const SCENE_KEYS = ['figure_alpha_above', 'figure_px', 'figure_mask'] as const;
|
|
293
312
|
export const RECOMPOSITE_KEYS = ['mean_abs', 'within_limit', 'within_share', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed', 'holes'] as const;
|
|
@@ -346,24 +365,43 @@ export function tagOf(from: string): string | null {
|
|
|
346
365
|
return f === null || f.run === 'painting' ? null : f.tag;
|
|
347
366
|
}
|
|
348
367
|
|
|
368
|
+
function serializePartList(parts: readonly PartRecord[]): Array<Record<string, unknown>> {
|
|
369
|
+
return parts.map((p) =>
|
|
370
|
+
Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function serializeRecomposite(r: RecompositeRecord): Record<string, unknown> {
|
|
375
|
+
return {
|
|
376
|
+
...Object.fromEntries(RECOMPOSITE_KEYS.filter((k) => k !== 'holes').map((k) => [k, r[k]])),
|
|
377
|
+
holes: r.holes.map((h) => ({ ...Object.fromEntries(HOLE_KEYS.filter((k) => k !== 'borders').map((k) => [k, h[k]])), borders: h.borders.map((b) => ({ part: b.part, px: b.px })) })),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
349
381
|
export function serializeParts(file: PartsFile): string {
|
|
350
382
|
const ordered = {
|
|
351
383
|
rig_size: file.rig_size,
|
|
352
384
|
scale_rig_per_source: file.scale_rig_per_source,
|
|
353
|
-
parts: file.parts
|
|
354
|
-
Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
|
|
355
|
-
),
|
|
385
|
+
parts: serializePartList(file.parts),
|
|
356
386
|
ghost_px: Object.fromEntries(Object.keys(file.ghost_px).sort().map((k) => [k, file.ghost_px[k]])),
|
|
357
|
-
...(file.recomposite === undefined
|
|
387
|
+
...(file.recomposite === undefined ? {} : { recomposite: serializeRecomposite(file.recomposite) }),
|
|
388
|
+
...(file.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (file.scene as SceneRecord)[k]])) }),
|
|
389
|
+
...(file.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (file.single_run_pad as SingleRunPad)[k]])) }),
|
|
390
|
+
...(file.alternates === undefined
|
|
358
391
|
? {}
|
|
359
392
|
: {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
393
|
+
alternates: file.alternates.map((a) => ({
|
|
394
|
+
name: a.name,
|
|
395
|
+
...(a.parts === undefined ? {} : { parts: a.parts }),
|
|
396
|
+
...(a.hides === undefined ? {} : { hides: a.hides }),
|
|
397
|
+
slots: a.slots,
|
|
398
|
+
records: serializePartList(a.records),
|
|
399
|
+
ghost_px: Object.fromEntries(Object.keys(a.ghost_px).sort().map((k) => [k, a.ghost_px[k]])),
|
|
400
|
+
recomposite: serializeRecomposite(a.recomposite),
|
|
401
|
+
...(a.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (a.scene as SceneRecord)[k]])) }),
|
|
402
|
+
...(a.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (a.single_run_pad as SingleRunPad)[k]])) }),
|
|
403
|
+
})),
|
|
364
404
|
}),
|
|
365
|
-
...(file.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (file.scene as SceneRecord)[k]])) }),
|
|
366
|
-
...(file.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (file.single_run_pad as SingleRunPad)[k]])) }),
|
|
367
405
|
};
|
|
368
406
|
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
369
407
|
}
|
|
@@ -596,6 +634,9 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
596
634
|
const names = Array.isArray(o.parts) ? new Set(o.parts.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : undefined)).filter((n): n is string => typeof n === 'string')) : new Set<string>();
|
|
597
635
|
checkRecomposite(o.recomposite, path, size as [number, number], names, fail);
|
|
598
636
|
}
|
|
637
|
+
if ('alternates' in o && !Array.isArray(o.alternates)) {
|
|
638
|
+
fail('PARTS_FIELD_TYPE', `${path} field "alternates"`, `is ${show(o.alternates)}; an array of alternate set records is required`);
|
|
639
|
+
}
|
|
599
640
|
refuseIfAny(problems);
|
|
600
641
|
return raw as PartsFile;
|
|
601
642
|
}
|
package/src/propose.ts
CHANGED
|
@@ -149,7 +149,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
|
|
|
149
149
|
import { edgeLength } from './length.ts';
|
|
150
150
|
import { bonePlaces, limbCrossing, limbPolyline, limbWidth } from './limbs.ts';
|
|
151
151
|
import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.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
|
+
import { backHairMasses, type BackHairMass, backHairMassTrees, 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';
|
|
153
153
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
154
154
|
|
|
155
155
|
// ---------------------------------------------------------------------------
|
|
@@ -1849,12 +1849,29 @@ export function proposeWithBasis(
|
|
|
1849
1849
|
const all: TreeChain[] = [];
|
|
1850
1850
|
let n = 0;
|
|
1851
1851
|
const partNamesLabel = list.map((q) => q.name).join(' -> ');
|
|
1852
|
+
const sideTreeCounts: number[] = [];
|
|
1852
1853
|
for (const [side] of activeSides) {
|
|
1853
1854
|
const prefix = prefixOf(side);
|
|
1854
1855
|
const sideMasses = massesBySide.get(side) ?? [];
|
|
1855
1856
|
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
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 sideMassInputs: TreeMassInput[] | undefined = useMasses
|
|
1859
|
+
? sideMasses.flatMap((m): TreeMassInput[] => {
|
|
1860
|
+
const count = backHairMassTrees(m, g);
|
|
1861
|
+
return Array.from({ length: count }, (_, t): TreeMassInput => ({
|
|
1862
|
+
rootY: m.minY <= y0 ? eyeY : m.minY,
|
|
1863
|
+
tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
|
|
1864
|
+
xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
|
|
1865
|
+
fallbackX: m.minY <= y0 && sideMasses.length === 1 && count === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
|
|
1866
|
+
frame,
|
|
1867
|
+
treeInMass: t,
|
|
1868
|
+
treesInMass: count,
|
|
1869
|
+
}));
|
|
1870
|
+
})
|
|
1871
|
+
: undefined;
|
|
1872
|
+
const sideCount = sideMassInputs !== undefined ? sideMassInputs.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit));
|
|
1873
|
+
sideTreeCounts.push(sideCount);
|
|
1874
|
+
const sideRcp: ClothRecipe = { ...rawRcp, chains: sideCount };
|
|
1858
1875
|
const trees = T(list, 'back hair', sideRcp, {
|
|
1859
1876
|
prefix,
|
|
1860
1877
|
parent: 'head',
|
|
@@ -1864,15 +1881,7 @@ export function proposeWithBasis(
|
|
|
1864
1881
|
fallbackX: () => axis,
|
|
1865
1882
|
frame,
|
|
1866
1883
|
edges: side,
|
|
1867
|
-
masses:
|
|
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,
|
|
1884
|
+
masses: sideMassInputs,
|
|
1876
1885
|
});
|
|
1877
1886
|
const made = treeConstraints(trees, sideRcp, null);
|
|
1878
1887
|
clothConstraints.push(...made);
|
|
@@ -1881,7 +1890,7 @@ export function proposeWithBasis(
|
|
|
1881
1890
|
}
|
|
1882
1891
|
const rcp: ClothRecipe = {
|
|
1883
1892
|
...rawRcp,
|
|
1884
|
-
chains: useMasses ? Math.max(1, ...
|
|
1893
|
+
chains: useMasses ? Math.max(1, ...sideTreeCounts) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
|
|
1885
1894
|
};
|
|
1886
1895
|
const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
|
|
1887
1896
|
for (const t of all) {
|
|
@@ -2037,12 +2046,32 @@ export function proposeWithBasis(
|
|
|
2037
2046
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
2038
2047
|
const all: TreeChain[] = [];
|
|
2039
2048
|
let n = 0;
|
|
2049
|
+
const treeCountBySide = new Map<'r' | 'l', number>([
|
|
2050
|
+
['r', 0],
|
|
2051
|
+
['l', 0],
|
|
2052
|
+
]);
|
|
2040
2053
|
for (const [side] of SIDES) {
|
|
2041
2054
|
const prefix = prefixOf(side);
|
|
2042
2055
|
const sideMasses = massesBySide.get(side) ?? [];
|
|
2043
2056
|
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
2057
|
const frame = `${p.name}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
|
|
2058
|
+
const sideMassInputs: TreeMassInput[] | undefined = useMasses
|
|
2059
|
+
? sideMasses.flatMap((m): TreeMassInput[] => {
|
|
2060
|
+
const count = backHairMassTrees(m, g);
|
|
2061
|
+
return Array.from({ length: count }, (_, t): TreeMassInput => ({
|
|
2062
|
+
rootY: m.minY <= y0 ? eyeY : m.minY,
|
|
2063
|
+
tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
|
|
2064
|
+
xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
|
|
2065
|
+
fallbackX: m.minY <= y0 && sideMasses.length === 1 && count === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
|
|
2066
|
+
frame,
|
|
2067
|
+
treeInMass: t,
|
|
2068
|
+
treesInMass: count,
|
|
2069
|
+
}));
|
|
2070
|
+
})
|
|
2071
|
+
: undefined;
|
|
2072
|
+
const sideCount = sideMassInputs !== undefined ? sideMassInputs.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit));
|
|
2073
|
+
treeCountBySide.set(side, sideCount);
|
|
2074
|
+
const sideRcp: ClothRecipe = { ...rawRcp, chains: sideCount };
|
|
2046
2075
|
const trees = T(p, 'back hair', sideRcp, {
|
|
2047
2076
|
prefix,
|
|
2048
2077
|
parent: 'head',
|
|
@@ -2052,15 +2081,7 @@ export function proposeWithBasis(
|
|
|
2052
2081
|
fallbackX: () => axis,
|
|
2053
2082
|
frame,
|
|
2054
2083
|
edges: side,
|
|
2055
|
-
masses:
|
|
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,
|
|
2084
|
+
masses: sideMassInputs,
|
|
2064
2085
|
});
|
|
2065
2086
|
for (const t of trees) segs.push(t.chain);
|
|
2066
2087
|
const made = treeConstraints(trees, sideRcp, null);
|
|
@@ -2068,11 +2089,14 @@ export function proposeWithBasis(
|
|
|
2068
2089
|
n += made.length;
|
|
2069
2090
|
all.push(...trees);
|
|
2070
2091
|
}
|
|
2071
|
-
const
|
|
2092
|
+
const rTreeCount = treeCountBySide.get('r') ?? 0;
|
|
2093
|
+
const lTreeCount = treeCountBySide.get('l') ?? 0;
|
|
2094
|
+
const sameSideCounts = !useMasses || rTreeCount === lTreeCount;
|
|
2072
2095
|
const noteRcp: ClothRecipe = {
|
|
2073
2096
|
...rawRcp,
|
|
2074
|
-
chains: useMasses ? (sameSideCounts ?
|
|
2097
|
+
chains: useMasses ? (sameSideCounts ? rTreeCount : rTreeCount + lTreeCount) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
|
|
2075
2098
|
};
|
|
2099
|
+
const massReason = rTreeCount === rMasses.length && lTreeCount === lMasses.length ? 'one per visible hair mass' : 'across the visible hair masses by root-row width';
|
|
2076
2100
|
notes.push(
|
|
2077
2101
|
clothNote(
|
|
2078
2102
|
p.name,
|
|
@@ -2082,7 +2106,7 @@ export function proposeWithBasis(
|
|
|
2082
2106
|
n,
|
|
2083
2107
|
sameSideCounts
|
|
2084
2108
|
? ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'
|
|
2085
|
-
: ` (${
|
|
2109
|
+
: ` (${rTreeCount} on the r side and ${lTreeCount} on the l side of the eye axis, ${massReason}), from head, rooted on each mass's top row and tipped 4 px above its bottom`,
|
|
2086
2110
|
),
|
|
2087
2111
|
);
|
|
2088
2112
|
meshes[p.name] = { grid: g, r: rawRcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
@@ -4585,11 +4609,12 @@ export function lint(
|
|
|
4585
4609
|
.sort((a, b) => Number(trunkRe.exec(a.chain)![1]) - Number(trunkRe.exec(b.chain)![1]));
|
|
4586
4610
|
if (sideTrunks.length === 0) continue;
|
|
4587
4611
|
const masses = backHairMasses(mask, side, axis, y0, shoulderLine, g);
|
|
4612
|
+
const massSlots = masses.flatMap((x) => Array.from({ length: backHairMassTrees(x, g) }, () => x));
|
|
4588
4613
|
const outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }> = [];
|
|
4589
4614
|
const sideMinX = masses.length > 0 ? Math.min(...masses.map((x) => x.minX)) : 0;
|
|
4590
4615
|
const sideMaxX = masses.length > 0 ? Math.max(...masses.map((x) => x.maxX)) : P.W - 1;
|
|
4591
4616
|
sideTrunks.forEach((tr, k) => {
|
|
4592
|
-
const target = k <
|
|
4617
|
+
const target = k < massSlots.length ? massSlots[k] : null;
|
|
4593
4618
|
const minX = target !== null ? target.minX : sideMinX;
|
|
4594
4619
|
const maxX = target !== null ? target.maxX : sideMaxX;
|
|
4595
4620
|
const treeBase = tr.chain.slice(0, -1);
|
|
@@ -4606,7 +4631,7 @@ export function lint(
|
|
|
4606
4631
|
if (tx < minX || tx > maxX) outside.push({ point: `${c.chain} tip`, at: [tx, ty], minX, maxX });
|
|
4607
4632
|
}
|
|
4608
4633
|
});
|
|
4609
|
-
if (sideTrunks.length !==
|
|
4634
|
+
if (sideTrunks.length !== massSlots.length || outside.length > 0) {
|
|
4610
4635
|
findings.push({
|
|
4611
4636
|
kind: 'back-hair-masses',
|
|
4612
4637
|
mesh: groupParts.map((q) => q.name).join(' -> '),
|