@rig-c/parts 1.29.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 +21 -8
- 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 +148 -32
- package/src/rig.ts +118 -2
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
|
@@ -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';
|
|
@@ -148,7 +149,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
|
|
|
148
149
|
import { edgeLength } from './length.ts';
|
|
149
150
|
import { bonePlaces, limbCrossing, limbPolyline, limbWidth } from './limbs.ts';
|
|
150
151
|
import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.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
|
+
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';
|
|
152
153
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
153
154
|
|
|
154
155
|
// ---------------------------------------------------------------------------
|
|
@@ -1848,12 +1849,29 @@ export function proposeWithBasis(
|
|
|
1848
1849
|
const all: TreeChain[] = [];
|
|
1849
1850
|
let n = 0;
|
|
1850
1851
|
const partNamesLabel = list.map((q) => q.name).join(' -> ');
|
|
1852
|
+
const sideTreeCounts: number[] = [];
|
|
1851
1853
|
for (const [side] of activeSides) {
|
|
1852
1854
|
const prefix = prefixOf(side);
|
|
1853
1855
|
const sideMasses = massesBySide.get(side) ?? [];
|
|
1854
1856
|
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
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 };
|
|
1857
1875
|
const trees = T(list, 'back hair', sideRcp, {
|
|
1858
1876
|
prefix,
|
|
1859
1877
|
parent: 'head',
|
|
@@ -1863,15 +1881,7 @@ export function proposeWithBasis(
|
|
|
1863
1881
|
fallbackX: () => axis,
|
|
1864
1882
|
frame,
|
|
1865
1883
|
edges: side,
|
|
1866
|
-
masses:
|
|
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,
|
|
1884
|
+
masses: sideMassInputs,
|
|
1875
1885
|
});
|
|
1876
1886
|
const made = treeConstraints(trees, sideRcp, null);
|
|
1877
1887
|
clothConstraints.push(...made);
|
|
@@ -1880,7 +1890,7 @@ export function proposeWithBasis(
|
|
|
1880
1890
|
}
|
|
1881
1891
|
const rcp: ClothRecipe = {
|
|
1882
1892
|
...rawRcp,
|
|
1883
|
-
chains: useMasses ? Math.max(1, ...
|
|
1893
|
+
chains: useMasses ? Math.max(1, ...sideTreeCounts) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
|
|
1884
1894
|
};
|
|
1885
1895
|
const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
|
|
1886
1896
|
for (const t of all) {
|
|
@@ -2036,12 +2046,32 @@ export function proposeWithBasis(
|
|
|
2036
2046
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
2037
2047
|
const all: TreeChain[] = [];
|
|
2038
2048
|
let n = 0;
|
|
2049
|
+
const treeCountBySide = new Map<'r' | 'l', number>([
|
|
2050
|
+
['r', 0],
|
|
2051
|
+
['l', 0],
|
|
2052
|
+
]);
|
|
2039
2053
|
for (const [side] of SIDES) {
|
|
2040
2054
|
const prefix = prefixOf(side);
|
|
2041
2055
|
const sideMasses = massesBySide.get(side) ?? [];
|
|
2042
2056
|
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
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 };
|
|
2045
2075
|
const trees = T(p, 'back hair', sideRcp, {
|
|
2046
2076
|
prefix,
|
|
2047
2077
|
parent: 'head',
|
|
@@ -2051,15 +2081,7 @@ export function proposeWithBasis(
|
|
|
2051
2081
|
fallbackX: () => axis,
|
|
2052
2082
|
frame,
|
|
2053
2083
|
edges: side,
|
|
2054
|
-
masses:
|
|
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,
|
|
2084
|
+
masses: sideMassInputs,
|
|
2063
2085
|
});
|
|
2064
2086
|
for (const t of trees) segs.push(t.chain);
|
|
2065
2087
|
const made = treeConstraints(trees, sideRcp, null);
|
|
@@ -2067,11 +2089,14 @@ export function proposeWithBasis(
|
|
|
2067
2089
|
n += made.length;
|
|
2068
2090
|
all.push(...trees);
|
|
2069
2091
|
}
|
|
2070
|
-
const
|
|
2092
|
+
const rTreeCount = treeCountBySide.get('r') ?? 0;
|
|
2093
|
+
const lTreeCount = treeCountBySide.get('l') ?? 0;
|
|
2094
|
+
const sameSideCounts = !useMasses || rTreeCount === lTreeCount;
|
|
2071
2095
|
const noteRcp: ClothRecipe = {
|
|
2072
2096
|
...rawRcp,
|
|
2073
|
-
chains: useMasses ? (sameSideCounts ?
|
|
2097
|
+
chains: useMasses ? (sameSideCounts ? rTreeCount : rTreeCount + lTreeCount) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
|
|
2074
2098
|
};
|
|
2099
|
+
const massReason = rTreeCount === rMasses.length && lTreeCount === lMasses.length ? 'one per visible hair mass' : 'across the visible hair masses by root-row width';
|
|
2075
2100
|
notes.push(
|
|
2076
2101
|
clothNote(
|
|
2077
2102
|
p.name,
|
|
@@ -2081,7 +2106,7 @@ export function proposeWithBasis(
|
|
|
2081
2106
|
n,
|
|
2082
2107
|
sameSideCounts
|
|
2083
2108
|
? ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'
|
|
2084
|
-
: ` (${
|
|
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`,
|
|
2085
2110
|
),
|
|
2086
2111
|
);
|
|
2087
2112
|
meshes[p.name] = { grid: g, r: rawRcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
@@ -3471,7 +3496,10 @@ function artDistance(m: Mask, at: readonly [number, number]): number {
|
|
|
3471
3496
|
* carries one: a part left to the rule beside parts placed from joints would
|
|
3472
3497
|
* be read by two rules at once. Sides come from the joints, not from the eye
|
|
3473
3498
|
* axis, so a figure seen from behind or lying across the axis is paired by
|
|
3474
|
-
* where its wrists are.
|
|
3499
|
+
* where its wrists are. Issue #458: when pre-separated `handwear-r` and
|
|
3500
|
+
* `handwear-l` parts cross so that `<s>_wrist` lies on the opaque art of its
|
|
3501
|
+
* own side's `handwear-<s>` part and also within 15 px of the opposite side's
|
|
3502
|
+
* `handwear-<other>` part, it resolves to its own side's part.
|
|
3475
3503
|
*
|
|
3476
3504
|
* Issue #199: a wrist on no part within that tolerance is measured to every
|
|
3477
3505
|
* part by the same Chebyshev distance ({@link artDistance}). With
|
|
@@ -3498,7 +3526,18 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
|
|
|
3498
3526
|
const near = new Map(hw.map((p) => [p, dilate(P.alpha(p), 2 * WRIST_ON_PART_PX + 1)]));
|
|
3499
3527
|
const arms: JointArm[] = [];
|
|
3500
3528
|
for (const a of complete) {
|
|
3501
|
-
const
|
|
3529
|
+
const raw = hw.filter((p) => isOn(near.get(p) as Mask, a.wrist[0], a.wrist[1]));
|
|
3530
|
+
const ownTag = `handwear-${a.side}`;
|
|
3531
|
+
const oppTag = `handwear-${a.side === 'r' ? 'l' : 'r'}`;
|
|
3532
|
+
const own =
|
|
3533
|
+
raw.length > 1 &&
|
|
3534
|
+
raw.every((p) => {
|
|
3535
|
+
const t = splitFrom(p.from)[1];
|
|
3536
|
+
return t === ownTag || t === oppTag;
|
|
3537
|
+
})
|
|
3538
|
+
? raw.filter((p) => splitFrom(p.from)[1] === ownTag && isOn(P.alpha(p), a.wrist[0], a.wrist[1]))
|
|
3539
|
+
: [];
|
|
3540
|
+
const on = own.length === 1 ? own : raw;
|
|
3502
3541
|
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` };
|
|
3503
3542
|
if (on.length === 1) {
|
|
3504
3543
|
arms.push({ ...a, part: on[0], snap: null });
|
|
@@ -4166,6 +4205,23 @@ export interface CrossedLimbsFinding {
|
|
|
4166
4205
|
at: [number, number];
|
|
4167
4206
|
}
|
|
4168
4207
|
|
|
4208
|
+
/**
|
|
4209
|
+
* Issue #447: a stop's contact point `<chain>_touch` (`at` in `against`'s local frame) lies above the first stopped
|
|
4210
|
+
* link's root (`links[0]` at `[x0, y0]`) in rig pixels (`ty < y0`, i.e. higher Spine y), so `<chain>_rest` aims
|
|
4211
|
+
* upward into the upper half-plane (`|rest| > 90°`) and `|rest| + outward > 180°` wraps past ±180° (`RIG_STOP_BOUNDS`).
|
|
4212
|
+
*/
|
|
4213
|
+
export interface StopTouchAboveRootFinding {
|
|
4214
|
+
kind: 'stop-touch-above-root';
|
|
4215
|
+
chain: string;
|
|
4216
|
+
firstLink: string;
|
|
4217
|
+
rootAt: [number, number];
|
|
4218
|
+
against: string;
|
|
4219
|
+
at: [number, number];
|
|
4220
|
+
touchAt: [number, number];
|
|
4221
|
+
rest: number;
|
|
4222
|
+
outward: number;
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4169
4225
|
export type LintFinding =
|
|
4170
4226
|
| OffArtFinding
|
|
4171
4227
|
| OffLockFinding
|
|
@@ -4179,7 +4235,8 @@ export type LintFinding =
|
|
|
4179
4235
|
| NearEmptyHandFinding
|
|
4180
4236
|
| BackHairMassesFinding
|
|
4181
4237
|
| OverlappingArmsFinding
|
|
4182
|
-
| CrossedLimbsFinding
|
|
4238
|
+
| CrossedLimbsFinding
|
|
4239
|
+
| StopTouchAboveRootFinding;
|
|
4183
4240
|
|
|
4184
4241
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
4185
4242
|
export interface LintBasis {
|
|
@@ -4279,7 +4336,7 @@ export function coverageOutcome(c: BoneCoverage): CoverageOutcome {
|
|
|
4279
4336
|
*/
|
|
4280
4337
|
export function lint(
|
|
4281
4338
|
P: PartSet,
|
|
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
|
|
4339
|
+
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[] },
|
|
4283
4340
|
joints?: RigJoints,
|
|
4284
4341
|
): LintResult {
|
|
4285
4342
|
const { byName } = expand(spec.bones);
|
|
@@ -4552,11 +4609,12 @@ export function lint(
|
|
|
4552
4609
|
.sort((a, b) => Number(trunkRe.exec(a.chain)![1]) - Number(trunkRe.exec(b.chain)![1]));
|
|
4553
4610
|
if (sideTrunks.length === 0) continue;
|
|
4554
4611
|
const masses = backHairMasses(mask, side, axis, y0, shoulderLine, g);
|
|
4612
|
+
const massSlots = masses.flatMap((x) => Array.from({ length: backHairMassTrees(x, g) }, () => x));
|
|
4555
4613
|
const outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }> = [];
|
|
4556
4614
|
const sideMinX = masses.length > 0 ? Math.min(...masses.map((x) => x.minX)) : 0;
|
|
4557
4615
|
const sideMaxX = masses.length > 0 ? Math.max(...masses.map((x) => x.maxX)) : P.W - 1;
|
|
4558
4616
|
sideTrunks.forEach((tr, k) => {
|
|
4559
|
-
const target = k <
|
|
4617
|
+
const target = k < massSlots.length ? massSlots[k] : null;
|
|
4560
4618
|
const minX = target !== null ? target.minX : sideMinX;
|
|
4561
4619
|
const maxX = target !== null ? target.maxX : sideMaxX;
|
|
4562
4620
|
const treeBase = tr.chain.slice(0, -1);
|
|
@@ -4573,7 +4631,7 @@ export function lint(
|
|
|
4573
4631
|
if (tx < minX || tx > maxX) outside.push({ point: `${c.chain} tip`, at: [tx, ty], minX, maxX });
|
|
4574
4632
|
}
|
|
4575
4633
|
});
|
|
4576
|
-
if (sideTrunks.length !==
|
|
4634
|
+
if (sideTrunks.length !== massSlots.length || outside.length > 0) {
|
|
4577
4635
|
findings.push({
|
|
4578
4636
|
kind: 'back-hair-masses',
|
|
4579
4637
|
mesh: groupParts.map((q) => q.name).join(' -> '),
|
|
@@ -4648,6 +4706,61 @@ export function lint(
|
|
|
4648
4706
|
}
|
|
4649
4707
|
}
|
|
4650
4708
|
}
|
|
4709
|
+
// Issue #447: when a stop's contact point `<chain>_touch` (`at` in `against`'s local frame) lies above the first
|
|
4710
|
+
// stopped link's root (`links[0]`) in rig pixels (`ty < y0`, i.e. higher Spine y), `<chain>_rest` aims into the
|
|
4711
|
+
// upper half-plane (`|rest| > 90°`) and `|rest| + outward > 180°` wraps past ±180° (`RIG_STOP_BOUNDS`).
|
|
4712
|
+
if (spec.stops !== undefined && spec.stops.length > 0) {
|
|
4713
|
+
const boneEnds = new Map<string, Point>();
|
|
4714
|
+
const chainLinks = new Map<string, string[]>();
|
|
4715
|
+
for (const e of spec.bones) {
|
|
4716
|
+
if ('chain' in e) {
|
|
4717
|
+
const names = e.points.map((_, k) => `${e.chain}${k}`);
|
|
4718
|
+
chainLinks.set(e.chain, names);
|
|
4719
|
+
e.points.forEach((_, k) => {
|
|
4720
|
+
boneEnds.set(names[k], k + 1 < e.points.length ? e.points[k + 1] : e.tip);
|
|
4721
|
+
});
|
|
4722
|
+
} else if (e.tip !== undefined) {
|
|
4723
|
+
boneEnds.set(e.name, e.tip);
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
for (const s of spec.stops) {
|
|
4727
|
+
const links = s.links ?? chainLinks.get(s.chain) ?? [];
|
|
4728
|
+
if (links.length === 0) continue;
|
|
4729
|
+
const firstLink = links[0];
|
|
4730
|
+
const rootBone = byName.get(firstLink);
|
|
4731
|
+
const againstBone = byName.get(s.against);
|
|
4732
|
+
if (rootBone === undefined || againstBone === undefined) continue;
|
|
4733
|
+
const againstEnd = boneEnds.get(s.against);
|
|
4734
|
+
let againstRot = 0;
|
|
4735
|
+
if (againstEnd !== undefined) {
|
|
4736
|
+
const dx = againstEnd[0] - againstBone.x;
|
|
4737
|
+
const dy = cropToSpineY(againstEnd[1], P.H) - cropToSpineY(againstBone.y, P.H);
|
|
4738
|
+
if (dx !== 0 || dy !== 0) againstRot = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
4739
|
+
}
|
|
4740
|
+
const frame = computeExactFrameTransforms([
|
|
4741
|
+
{ name: s.against, x: againstBone.x, y: cropToSpineY(againstBone.y, P.H), rotation: againstRot },
|
|
4742
|
+
]).get(s.against)!;
|
|
4743
|
+
const [wx, wy] = toWorld(frame, s.at[0], s.at[1]);
|
|
4744
|
+
const touchAt: [number, number] = [pyRound(wx * 10) / 10, pyRound(cropToSpineY(wy, P.H) * 10) / 10];
|
|
4745
|
+
const aimDx = wx - rootBone.x;
|
|
4746
|
+
const aimDy = wy - cropToSpineY(rootBone.y, P.H);
|
|
4747
|
+
if (aimDx === 0 && aimDy === 0) continue;
|
|
4748
|
+
const rest = pyRound(normaliseDegrees((Math.atan2(aimDy, aimDx) * 180) / Math.PI) * 100) / 100;
|
|
4749
|
+
if (touchAt[1] < rootBone.y || Math.abs(rest) + s.outward > 180) {
|
|
4750
|
+
findings.push({
|
|
4751
|
+
kind: 'stop-touch-above-root',
|
|
4752
|
+
chain: s.chain,
|
|
4753
|
+
firstLink,
|
|
4754
|
+
rootAt: [rootBone.x, rootBone.y],
|
|
4755
|
+
against: s.against,
|
|
4756
|
+
at: [s.at[0], s.at[1]],
|
|
4757
|
+
touchAt,
|
|
4758
|
+
rest,
|
|
4759
|
+
outward: s.outward,
|
|
4760
|
+
});
|
|
4761
|
+
}
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4651
4764
|
// Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
|
|
4652
4765
|
// to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
|
|
4653
4766
|
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 }]));
|
|
@@ -4686,6 +4799,7 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
|
4686
4799
|
case 'near-empty-hand':
|
|
4687
4800
|
case 'overlapping-arms':
|
|
4688
4801
|
case 'crossed-limbs':
|
|
4802
|
+
case 'stop-touch-above-root':
|
|
4689
4803
|
return [];
|
|
4690
4804
|
case 'back-hair-masses': {
|
|
4691
4805
|
const out = new Set<string>();
|
|
@@ -4878,6 +4992,8 @@ export function lintLine(f: LintFinding): string {
|
|
|
4878
4992
|
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
4993
|
case 'crossed-limbs':
|
|
4880
4994
|
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`;
|
|
4995
|
+
case 'stop-touch-above-root':
|
|
4996
|
+
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`;
|
|
4881
4997
|
case 'back-hair-masses': {
|
|
4882
4998
|
const massDesc =
|
|
4883
4999
|
f.masses.length === 0
|
package/src/rig.ts
CHANGED
|
@@ -166,7 +166,7 @@ import { type ContourChainRibs, type ContourReport, contourMesh, type ContourReg
|
|
|
166
166
|
import { type LocalInfluence, localInfluences } from './localweights.ts';
|
|
167
167
|
import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
|
|
168
168
|
import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
169
|
-
import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
|
|
169
|
+
import { type AlternateSetMap, PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
|
|
170
170
|
import { EYE_GROUP_TAGS } from './propose.ts';
|
|
171
171
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
172
172
|
import { pyRound } from './round.ts';
|
|
@@ -896,6 +896,20 @@ export interface AutoMeshReport {
|
|
|
896
896
|
motion_report?: unknown;
|
|
897
897
|
}
|
|
898
898
|
|
|
899
|
+
export const SETS_SPEC = 'rig-parts-sets/1';
|
|
900
|
+
export const SETS_FILE = 'sets.json';
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* `sets.json` (issue #454): the machine-readable map from each alternate
|
|
904
|
+
* painting set to its `<set>_<part>` attachment on each slot it covers and
|
|
905
|
+
* the slots it hides. Written beside `rig.json` only when `parts.json` carries
|
|
906
|
+
* `alternates`.
|
|
907
|
+
*/
|
|
908
|
+
export interface AttachmentSetsFile {
|
|
909
|
+
spec: typeof SETS_SPEC;
|
|
910
|
+
sets: Record<string, AlternateSetMap>;
|
|
911
|
+
}
|
|
912
|
+
|
|
899
913
|
export interface RigOutput {
|
|
900
914
|
rig: RigSpec;
|
|
901
915
|
motion: MotionSpec;
|
|
@@ -915,6 +929,8 @@ export interface RigOutput {
|
|
|
915
929
|
faceAxis: FaceAxis;
|
|
916
930
|
/** The turn control's parts (issue #251), in parts.json order; null when the config has no `turn`. */
|
|
917
931
|
turn: TurnRow[] | null;
|
|
932
|
+
/** `sets.json` (issue #454), present only when `parts.alternates` is non-empty. */
|
|
933
|
+
sets?: AttachmentSetsFile;
|
|
918
934
|
}
|
|
919
935
|
|
|
920
936
|
/**
|
|
@@ -1966,6 +1982,93 @@ export function buildRig(
|
|
|
1966
1982
|
});
|
|
1967
1983
|
}
|
|
1968
1984
|
refuseIfAny(problems);
|
|
1985
|
+
// ---- alternate painting sets (issue #454) -------------------------------
|
|
1986
|
+
// Only when `parts.alternates` is present: each alternate set's `<set>_<part>`
|
|
1987
|
+
// attachments are added to `skins.default[<part>]` on the existing bones and
|
|
1988
|
+
// slots before `applyStops` and `applyTurn`, so stopped links and turn twins
|
|
1989
|
+
// carry the alternate attachments alongside the base ones.
|
|
1990
|
+
let sets: AttachmentSetsFile | undefined;
|
|
1991
|
+
if (parts.alternates !== undefined && parts.alternates.length > 0) {
|
|
1992
|
+
const setEntries: Record<string, AlternateSetMap> = {};
|
|
1993
|
+
for (const alt of parts.alternates) {
|
|
1994
|
+
for (const p of alt.records) {
|
|
1995
|
+
const attName = `${alt.name}_${p.name}`;
|
|
1996
|
+
const rawImg = images.get(attName);
|
|
1997
|
+
if (rawImg === undefined) continue;
|
|
1998
|
+
const img = pad(rawImg, PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
|
|
1999
|
+
const file = `${attName}.png`;
|
|
2000
|
+
outImages.push([file, img]);
|
|
2001
|
+
const mesh = cfg.meshes[p.name];
|
|
2002
|
+
if (mesh === undefined) {
|
|
2003
|
+
const bone = B.get(cfg.regions[p.name]) as Bone;
|
|
2004
|
+
skin[p.name][attName] = region(file, bone.name, p.x + p.w / 2, p.y + p.h / 2);
|
|
2005
|
+
continue;
|
|
2006
|
+
}
|
|
2007
|
+
const fullSegs = meshSegments.get(p.name) as Segment[];
|
|
2008
|
+
const baseAtt = skin[p.name]?.[p.name];
|
|
2009
|
+
const baseBones = baseAtt !== undefined && 'type' in baseAtt ? new Set(baseAtt.weights.flatMap((v) => v.map((e) => e.bone))) : null;
|
|
2010
|
+
const filteredSegs = baseBones !== null && baseBones.size > 0 ? fullSegs.filter((s) => baseBones.has(s.bone)) : fullSegs;
|
|
2011
|
+
const segs = filteredSegs.length > 0 ? filteredSegs : fullSegs;
|
|
2012
|
+
const allArt = alphaAbove(img, ART_ALPHA);
|
|
2013
|
+
const strayVal = 'auto' in mesh ? mesh.auto.source.stray : ('contour' in mesh ? mesh.contour.stray : mesh.stray);
|
|
2014
|
+
const stray = strayVal === undefined ? null : latticeStray(allArt, strayVal);
|
|
2015
|
+
const art = stray === null ? allArt : stray.art;
|
|
2016
|
+
const grid = 'grid' in mesh ? mesh.grid : 28;
|
|
2017
|
+
const r = 'r' in mesh ? mesh.r : 80;
|
|
2018
|
+
const lm = latticeMesh(attName, art, grid, maxLoopPasses);
|
|
2019
|
+
if ('code' in lm) {
|
|
2020
|
+
skin[p.name][attName] = region(file, segs[0].bone, p.x + p.w / 2, p.y + p.h / 2);
|
|
2021
|
+
continue;
|
|
2022
|
+
}
|
|
2023
|
+
const ox = p.x - PAD;
|
|
2024
|
+
const oy = p.y - PAD;
|
|
2025
|
+
const w = img.width;
|
|
2026
|
+
const h = img.height;
|
|
2027
|
+
const limits: InfluenceLimits = meshLimits('influences' in mesh ? mesh.influences : undefined);
|
|
2028
|
+
const segsAt = dressSegsAt(p, segs);
|
|
2029
|
+
const exponent = mesh.exponent ?? DISTANCE_EXPONENT;
|
|
2030
|
+
const rawShares: Influence[][] = [];
|
|
2031
|
+
for (const [vx0, vy0] of lm.vertices) {
|
|
2032
|
+
const wx = vx0 + ox;
|
|
2033
|
+
const wy = vy0 + oy;
|
|
2034
|
+
const vSegs = segsAt(wy);
|
|
2035
|
+
let shares = influences([wx, wy], vSegs, r, limits, exponent);
|
|
2036
|
+
if (shares.length === 0) {
|
|
2037
|
+
shares = influences([wx, wy], vSegs, Infinity, { maxInfluences: 1, minWeight: 0 }, exponent);
|
|
2038
|
+
}
|
|
2039
|
+
rawShares.push(limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares));
|
|
2040
|
+
}
|
|
2041
|
+
const weights: WeightEntry[][] = [];
|
|
2042
|
+
for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
|
|
2043
|
+
const wx = vx0 + ox;
|
|
2044
|
+
const wy = vy0 + oy;
|
|
2045
|
+
const ent: WeightEntry[] = rawShares[vi].map(({ bone, weight }) => {
|
|
2046
|
+
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
2047
|
+
return { bone, x: places(x), y: places(y), weight };
|
|
2048
|
+
});
|
|
2049
|
+
weights.push(ent);
|
|
2050
|
+
}
|
|
2051
|
+
const uvs: number[] = [];
|
|
2052
|
+
for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
2053
|
+
skin[p.name][attName] = { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights };
|
|
2054
|
+
}
|
|
2055
|
+
const slotMap: Record<string, string> = {};
|
|
2056
|
+
for (const [slotName, att] of Object.entries(alt.slots)) slotMap[slotName] = att;
|
|
2057
|
+
const hideList: string[] = [];
|
|
2058
|
+
for (const h of alt.hides ?? []) {
|
|
2059
|
+
if (!hideList.includes(h)) hideList.push(h);
|
|
2060
|
+
if (stills[h] !== undefined && !hideList.includes(`${h}${STILL_SUFFIX}`)) hideList.push(`${h}${STILL_SUFFIX}`);
|
|
2061
|
+
}
|
|
2062
|
+
for (const slotName of Object.keys(alt.slots)) {
|
|
2063
|
+
if (stills[slotName] !== undefined && !hideList.includes(`${slotName}${STILL_SUFFIX}`)) hideList.push(`${slotName}${STILL_SUFFIX}`);
|
|
2064
|
+
}
|
|
2065
|
+
setEntries[alt.name] = {
|
|
2066
|
+
slots: slotMap,
|
|
2067
|
+
...(hideList.length > 0 ? { hides: hideList } : {}),
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
sets = { spec: SETS_SPEC, sets: setEntries };
|
|
2071
|
+
}
|
|
1969
2072
|
// ---- leg stops (issue #404) --------------------------------------------
|
|
1970
2073
|
// Only when the config has `stops`: each link after the first (and any bone hanging from a stopped link) hangs
|
|
1971
2074
|
// from the drawn link `<link>_d` above it, and slots and weights on a stopped link bind `<link>_d`.
|
|
@@ -2007,7 +2110,20 @@ export function buildRig(
|
|
|
2007
2110
|
overrides: cfg.turn?.deform,
|
|
2008
2111
|
});
|
|
2009
2112
|
});
|
|
2010
|
-
return {
|
|
2113
|
+
return {
|
|
2114
|
+
rig,
|
|
2115
|
+
motion,
|
|
2116
|
+
meshReport,
|
|
2117
|
+
images: outImages,
|
|
2118
|
+
controls,
|
|
2119
|
+
meshKeyed,
|
|
2120
|
+
idleKeys,
|
|
2121
|
+
loopPasses,
|
|
2122
|
+
autoMotion,
|
|
2123
|
+
faceAxis,
|
|
2124
|
+
turn: turned?.rows ?? null,
|
|
2125
|
+
...(sets === undefined ? {} : { sets }),
|
|
2126
|
+
};
|
|
2011
2127
|
}
|
|
2012
2128
|
|
|
2013
2129
|
/**
|