@rig-c/parts 1.35.0 → 1.37.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 +5 -1
- package/docs/AUTHORING.md +22 -3
- package/docs/STABILITY.md +1 -1
- package/package.json +1 -1
- package/src/assemble.ts +591 -12
- package/src/build.ts +66 -19
- package/src/config.ts +195 -21
- package/src/limbs.ts +92 -0
- package/src/propose.ts +128 -15
- package/src/rig.ts +88 -3
- package/src/tags.ts +15 -6
package/src/build.ts
CHANGED
|
@@ -78,6 +78,7 @@ import { encodeGif } from './gif.ts';
|
|
|
78
78
|
import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
|
|
79
79
|
import type { PaletteError } from './palette.ts';
|
|
80
80
|
import { type LayerSet, readLayers } from './layers.ts';
|
|
81
|
+
import { poseBoneEntriesForDrawn } from './limbs.ts';
|
|
81
82
|
import { type AlternatePartsRecord, type PartRecord, readParts, type RecompositeRecord, type SingleRunPad, tagOf, writeParts } from './parts.ts';
|
|
82
83
|
import { encodePngBytes, readPng, writePng } from './raster/png.ts';
|
|
83
84
|
import type { Raster } from './raster/types.ts';
|
|
@@ -444,24 +445,69 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
444
445
|
const altCuts: Cut[] = rawCuts.map((c) => ({ ...c, draw: remapDraw(c.draw) }));
|
|
445
446
|
const altUnder = alt.cuts !== undefined ? readUnders(input.config, altCuts) : altCuts.map(() => null);
|
|
446
447
|
const altHeadBox = alt.head_box ?? (altRuns.single ? singleRunHeadBox(altSrc.width, altSrc.height, fields.resolution) : fields.headBox);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
448
|
+
let altBones = fields.bones;
|
|
449
|
+
if (Object.keys(altLimbs).length > 0 && fields.bones !== undefined && (altSkeleton !== undefined || alt.drawn !== undefined)) {
|
|
450
|
+
const coveredLimbBones = new Set(Object.values(altLimbs).flatMap((l) => l.segments.map((sg) => sg.bone)));
|
|
451
|
+
const limbDrawn: Record<string, number> = {};
|
|
452
|
+
if (altSkeleton !== undefined) {
|
|
453
|
+
const deg = (a: readonly [number, number], b: readonly [number, number]): number =>
|
|
454
|
+
(Math.atan2(-(b[1] - a[1]), b[0] - a[0]) * 180) / Math.PI;
|
|
455
|
+
const skPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
|
|
456
|
+
['sleeve_r0', altSkeleton.r_shoulder, altSkeleton.r_elbow],
|
|
457
|
+
['sleeve_r1', altSkeleton.r_elbow, altSkeleton.r_wrist],
|
|
458
|
+
['arm_r0', altSkeleton.r_shoulder, altSkeleton.r_elbow],
|
|
459
|
+
['arm_r1', altSkeleton.r_elbow, altSkeleton.r_wrist],
|
|
460
|
+
['sleeve_l0', altSkeleton.l_shoulder, altSkeleton.l_elbow],
|
|
461
|
+
['sleeve_l1', altSkeleton.l_elbow, altSkeleton.l_wrist],
|
|
462
|
+
['arm_l0', altSkeleton.l_shoulder, altSkeleton.l_elbow],
|
|
463
|
+
['arm_l1', altSkeleton.l_elbow, altSkeleton.l_wrist],
|
|
464
|
+
['leg_r0', altSkeleton.r_hip, altSkeleton.r_knee],
|
|
465
|
+
['leg_r1', altSkeleton.r_knee, altSkeleton.r_ankle],
|
|
466
|
+
['leg_l0', altSkeleton.l_hip, altSkeleton.l_knee],
|
|
467
|
+
['leg_l1', altSkeleton.l_knee, altSkeleton.l_ankle],
|
|
468
|
+
];
|
|
469
|
+
for (const [bName, a, b] of skPairs) {
|
|
470
|
+
if (alt.parts === undefined || coveredLimbBones.has(bName)) limbDrawn[bName] = deg(a, b);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (alt.drawn !== undefined) {
|
|
474
|
+
for (const [bName, degVal] of Object.entries(alt.drawn)) limbDrawn[bName] = degVal;
|
|
475
|
+
}
|
|
476
|
+
altBones = poseBoneEntriesForDrawn(fields.bones, limbDrawn);
|
|
477
|
+
}
|
|
478
|
+
let altRes: ReturnType<typeof assemble>;
|
|
479
|
+
try {
|
|
480
|
+
altRes = assemble({
|
|
481
|
+
source: altSrc,
|
|
482
|
+
full: altRuns.full,
|
|
483
|
+
head: altRuns.head,
|
|
484
|
+
resolution: fields.resolution,
|
|
485
|
+
headBox: altHeadBox,
|
|
486
|
+
rigScale: fields.rigScale,
|
|
487
|
+
plan: altPlan,
|
|
488
|
+
extend: altExtend,
|
|
489
|
+
patches: altPatches,
|
|
490
|
+
cuts: altCuts,
|
|
491
|
+
under: altUnder,
|
|
492
|
+
...(Object.keys(altLimbs).length > 0 ? { limbs: altLimbs, bones: altBones } : {}),
|
|
493
|
+
...(fields.scene === undefined ? {} : { scene: fields.scene }),
|
|
494
|
+
...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
|
|
495
|
+
...(fields.headFaceUnderfill === undefined ? {} : { headFaceUnderfill: fields.headFaceUnderfill }),
|
|
496
|
+
seamRule: input.seam,
|
|
497
|
+
projectRule: input.project,
|
|
498
|
+
});
|
|
499
|
+
} catch (err) {
|
|
500
|
+
if (err instanceof PartsError) {
|
|
501
|
+
for (const p of err.problems) {
|
|
502
|
+
altProblems.push({
|
|
503
|
+
...p,
|
|
504
|
+
object: `${p.object} (alternate set "${alt.name}")`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
throw err;
|
|
510
|
+
}
|
|
465
511
|
const keptImages = altRes.images
|
|
466
512
|
.filter(({ record }) => coveredSet.has(record.name))
|
|
467
513
|
.map(({ record, image }) => ({ name: `${alt.name}_${record.name}`, image }));
|
|
@@ -524,10 +570,11 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
524
570
|
const pad = result.parts.single_run_pad;
|
|
525
571
|
if (pad !== undefined) log(singleRunPadLine(input.full, pad));
|
|
526
572
|
for (const p of result.parts.parts) {
|
|
573
|
+
const underfillSuffix = p.from === 'head:face' && (result.headFaceUnderfillPx ?? 0) > 0 ? ` underfill=${result.headFaceUnderfillPx}px(full:face)` : '';
|
|
527
574
|
log(
|
|
528
575
|
` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
|
|
529
576
|
`op=${String(p.opaque_px).padStart(6)} vis=${String(p.visible_px).padStart(6)} src=${String(p.source_px_taken).padStart(6)}/${String(p.projected_core_px).padStart(6)} ` +
|
|
530
|
-
`unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}`,
|
|
577
|
+
`unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}${underfillSuffix}`,
|
|
531
578
|
);
|
|
532
579
|
}
|
|
533
580
|
// Issue #256: one line per part from an author-made layer, so it never reads as See-through's. None without one.
|
package/src/config.ts
CHANGED
|
@@ -92,7 +92,7 @@ import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING
|
|
|
92
92
|
import { GRID, MAX_SIDE } from './contour.ts';
|
|
93
93
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
94
94
|
import { edgeLength } from './length.ts';
|
|
95
|
-
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
95
|
+
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, scaledPoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
96
96
|
import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
|
|
97
97
|
import { EAR_DEPTH_RANGE, FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, FACE_RATED_PITCH_BAND, FACE_RATED_YAW_BAND, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
98
98
|
import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
|
|
@@ -267,6 +267,8 @@ export interface Assemble {
|
|
|
267
267
|
patches?: Patch[];
|
|
268
268
|
cuts?: Cut[];
|
|
269
269
|
alternates?: AlternateSetConfig[];
|
|
270
|
+
/** Issue #492: composite full:face under head:face on the head canvas to fill transparent holes inside the face silhouette. */
|
|
271
|
+
head_face_underfill?: boolean;
|
|
270
272
|
}
|
|
271
273
|
|
|
272
274
|
export interface SingleBone {
|
|
@@ -1140,7 +1142,7 @@ export interface PaintConfig {
|
|
|
1140
1142
|
export interface EarlyConfig {
|
|
1141
1143
|
key: string;
|
|
1142
1144
|
seethrough: SeeThrough;
|
|
1143
|
-
assemble: { rig_scale: number };
|
|
1145
|
+
assemble: { rig_scale: number; head_face_underfill?: boolean };
|
|
1144
1146
|
}
|
|
1145
1147
|
|
|
1146
1148
|
/** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
|
|
@@ -1199,8 +1201,13 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1199
1201
|
if (door === 'layers') {
|
|
1200
1202
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1201
1203
|
if ('assemble' in t) {
|
|
1202
|
-
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
|
|
1203
|
-
if (a !== null
|
|
1204
|
+
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill']);
|
|
1205
|
+
if (a !== null) {
|
|
1206
|
+
if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
|
|
1207
|
+
if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
|
|
1208
|
+
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.head_face_underfill', `is ${show(a.head_face_underfill)}; true or false is required`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1204
1211
|
}
|
|
1205
1212
|
}
|
|
1206
1213
|
if (door === 'assemble') {
|
|
@@ -1210,19 +1217,23 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1210
1217
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1211
1218
|
// Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
|
|
1212
1219
|
let limbs: LimbNames | null = null;
|
|
1220
|
+
let names: { bones: Set<string>; chains: Map<string, number>; parents: Map<string, string> } | null = null;
|
|
1213
1221
|
if ('limbs' in t) {
|
|
1214
1222
|
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');
|
|
1215
|
-
|
|
1223
|
+
names = 'bones' in t ? checkBones(c, t.bones) : null;
|
|
1216
1224
|
limbs = checkLimbs(c, t.limbs, made, names);
|
|
1217
1225
|
}
|
|
1218
1226
|
if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
|
|
1219
|
-
checkAlternates(c, t.assemble.alternates, made, limbs, null);
|
|
1227
|
+
checkAlternates(c, t.assemble.alternates, made, limbs, names?.bones ?? null, names?.chains ?? null, t.regions, t.meshes);
|
|
1220
1228
|
}
|
|
1221
1229
|
}
|
|
1222
1230
|
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
1223
1231
|
const pose = base === null ? null : poseBeside(c, t.generation, base);
|
|
1224
1232
|
refuseIfAny(c.problems);
|
|
1225
|
-
|
|
1233
|
+
const config = raw as PaintConfig | EarlyConfig | AssembleConfig;
|
|
1234
|
+
const controlSkel = resolveControlSkeleton(t.generation, pose);
|
|
1235
|
+
if (controlSkel !== undefined) controlSkeletons.set(config, controlSkel);
|
|
1236
|
+
return { config, pose };
|
|
1226
1237
|
}
|
|
1227
1238
|
|
|
1228
1239
|
export function loadEarlyConfig(path: string, door: 'paint'): PaintConfig;
|
|
@@ -1242,6 +1253,40 @@ export function loadPaintConfig(path: string): { config: PaintConfig; pose: Cont
|
|
|
1242
1253
|
return { config: config as PaintConfig, pose };
|
|
1243
1254
|
}
|
|
1244
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* Issue #490: the control skeleton `config.generation.control.skeleton` names,
|
|
1258
|
+
* with its points on the `generation.latent` canvas (`[w, h]`, x right, y down).
|
|
1259
|
+
*/
|
|
1260
|
+
export interface ControlSkeleton {
|
|
1261
|
+
latent: readonly [number, number];
|
|
1262
|
+
points: PosePoints;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const controlSkeletons = new WeakMap<object, ControlSkeleton>();
|
|
1266
|
+
|
|
1267
|
+
function resolveControlSkeleton(gen: Json, pose: ControlPose | null): ControlSkeleton | undefined {
|
|
1268
|
+
if (!isPlainObject(gen) || !isPlainObject(gen.control)) return undefined;
|
|
1269
|
+
const l = gen.latent;
|
|
1270
|
+
if (!(Array.isArray(l) && l.length === 2 && typeof l[0] === 'number' && typeof l[1] === 'number' && l[0] > 0 && l[1] > 0)) return undefined;
|
|
1271
|
+
const latent: readonly [number, number] = [l[0], l[1]];
|
|
1272
|
+
if (pose !== null) return { latent, points: pose.points };
|
|
1273
|
+
const skel = gen.control.skeleton;
|
|
1274
|
+
if (isSkeletonName(skel)) return { latent, points: scaledPoints(skel, latent[0], latent[1]) };
|
|
1275
|
+
return undefined;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Return the {@link ControlSkeleton} resolved for a loaded or parsed config
|
|
1280
|
+
* (`loadEarlyConfig`, `loadConfig`, `parseEarlyConfig`, `parseConfig`), or
|
|
1281
|
+
* computed from an inline `generation.control.skeleton` built-in when present.
|
|
1282
|
+
*/
|
|
1283
|
+
export function controlSkeletonOf(cfg: object): ControlSkeleton | undefined {
|
|
1284
|
+
const cached = controlSkeletons.get(cfg);
|
|
1285
|
+
if (cached !== undefined) return cached;
|
|
1286
|
+
const gen = (cfg as { generation?: unknown }).generation;
|
|
1287
|
+
return resolveControlSkeleton(gen, null);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1245
1290
|
/** The pose file a checked `generation` block's control names, read relative to `base`; null for a built-in, an absent control, or a block already refused. */
|
|
1246
1291
|
function poseBeside(c: Check, gen: Json, base: string): ControlPose | null {
|
|
1247
1292
|
if (!isPlainObject(gen) || !isPlainObject(gen.control)) return null;
|
|
@@ -1368,7 +1413,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1368
1413
|
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>() };
|
|
1369
1414
|
const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
|
|
1370
1415
|
if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
|
|
1371
|
-
checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones);
|
|
1416
|
+
checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones, names.chains, t.regions, t.meshes);
|
|
1372
1417
|
}
|
|
1373
1418
|
if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
|
|
1374
1419
|
if ('regions' in t) checkRegions(c, t.regions, names.bones);
|
|
@@ -1387,9 +1432,12 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1387
1432
|
checkFaceAnimationBand(c, animations, hasAltFace, t.turn, attached);
|
|
1388
1433
|
checkAnimationSetKeys(c, animations, t.assemble, new Set<string>([...parts, ...patches, ...cuts, ...(limbs?.parts.keys() ?? [])]));
|
|
1389
1434
|
}
|
|
1390
|
-
|
|
1435
|
+
const pose = base === null ? null : poseBeside(c, t.generation, base);
|
|
1391
1436
|
refuseIfAny(c.problems);
|
|
1392
|
-
|
|
1437
|
+
const config = raw as CharacterConfig;
|
|
1438
|
+
const controlSkel = resolveControlSkeleton(t.generation, pose);
|
|
1439
|
+
if (controlSkel !== undefined) controlSkeletons.set(config, controlSkel);
|
|
1440
|
+
return { config, animations };
|
|
1393
1441
|
}
|
|
1394
1442
|
|
|
1395
1443
|
/**
|
|
@@ -1790,13 +1838,16 @@ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
|
|
|
1790
1838
|
*/
|
|
1791
1839
|
function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
|
|
1792
1840
|
const required = sectionRequired(door, 'assemble');
|
|
1793
|
-
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
|
|
1841
|
+
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill']);
|
|
1794
1842
|
if (a === null) return { parts: [], patches: [], cuts: [] };
|
|
1795
1843
|
if (required.includes('plan') && !('plan' in a)) {
|
|
1796
1844
|
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');
|
|
1797
1845
|
}
|
|
1798
1846
|
const p = 'config.assemble';
|
|
1799
1847
|
if ('rig_scale' in a) c.number(`${p}.rig_scale`, a.rig_scale, 'positive');
|
|
1848
|
+
if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
|
|
1849
|
+
c.fail('CONFIG_FIELD_TYPE', `${p}.head_face_underfill`, `is ${show(a.head_face_underfill)}; true or false is required`);
|
|
1850
|
+
}
|
|
1800
1851
|
const parts: string[] = [];
|
|
1801
1852
|
if ('plan' in a && c.array(`${p}.plan`, a.plan, true)) {
|
|
1802
1853
|
const seenPart = new Map<string, number>();
|
|
@@ -1902,10 +1953,18 @@ function checkAlternates(
|
|
|
1902
1953
|
made: { parts: string[]; patches: string[]; cuts: string[] },
|
|
1903
1954
|
limbs: LimbNames | null,
|
|
1904
1955
|
bones: Set<string> | null = null,
|
|
1956
|
+
chains: Map<string, number> | null = null,
|
|
1957
|
+
regions: Json = undefined,
|
|
1958
|
+
meshes: Json = undefined,
|
|
1905
1959
|
): void {
|
|
1906
1960
|
const p = 'config.assemble.alternates';
|
|
1907
1961
|
if (!c.array(p, v, true)) return;
|
|
1908
|
-
const allSlots = new Set<string>([
|
|
1962
|
+
const allSlots = new Set<string>([
|
|
1963
|
+
...made.parts.filter((n) => !(limbs?.consumed.has(n) ?? false)),
|
|
1964
|
+
...made.patches,
|
|
1965
|
+
...made.cuts,
|
|
1966
|
+
...(limbs?.parts.keys() ?? []),
|
|
1967
|
+
]);
|
|
1909
1968
|
const seenNames = new Map<string, number>();
|
|
1910
1969
|
(v as Json[]).forEach((entry, i) => {
|
|
1911
1970
|
const at = `${p}[${i}]`;
|
|
@@ -1934,29 +1993,135 @@ function checkAlternates(
|
|
|
1934
1993
|
}
|
|
1935
1994
|
if ('head_box' in e) checkHeadBox(c, `${at}.head_box`, e.head_box);
|
|
1936
1995
|
const altCuts = 'cuts' in e ? checkCuts(c, e.cuts, made.parts, [], `${at}.cuts`) : [];
|
|
1996
|
+
let cutsConsumedLimb = false;
|
|
1997
|
+
if (limbs !== null && 'cuts' in e && Array.isArray(e.cuts)) {
|
|
1998
|
+
e.cuts.forEach((ce, ci) => {
|
|
1999
|
+
if (!isPlainObject(ce) || typeof ce.from !== 'string') return;
|
|
2000
|
+
const byLimb = limbs.consumed.get(ce.from);
|
|
2001
|
+
if (byLimb !== undefined) {
|
|
2002
|
+
cutsConsumedLimb = true;
|
|
2003
|
+
const segList = (limbs.chains.get(byLimb)?.parts ?? []).map((s) => `"${s}"`).join(', ');
|
|
2004
|
+
c.fail(
|
|
2005
|
+
'CONFIG_LIMB_SET_JOINTS',
|
|
2006
|
+
`${at}.cuts[${ci}].from`,
|
|
2007
|
+
`cuts "${ce.from}", which config.limbs.${byLimb} cuts at the base's joints into ${segList}; a gesture set over a limb chain is cut at the base's joints and may not declare its own polygon cuts for that layer`,
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
1937
2012
|
const validParts = new Set<string>([...allSlots, ...altCuts]);
|
|
2013
|
+
const seenParts = new Set<string>();
|
|
1938
2014
|
if ('parts' in e && c.array(`${at}.parts`, e.parts, true)) {
|
|
1939
|
-
const seen = new Set<string>();
|
|
1940
2015
|
(e.parts as Json[]).forEach((pn, k) => {
|
|
1941
2016
|
if (!c.string(`${at}.parts[${k}]`, pn)) return;
|
|
1942
|
-
|
|
2017
|
+
const byLimb = limbs?.consumed.get(pn);
|
|
2018
|
+
if (byLimb !== undefined) {
|
|
2019
|
+
const segList = (limbs?.chains.get(byLimb)?.parts ?? []).map((s) => `"${s}"`).join(', ');
|
|
2020
|
+
c.fail(
|
|
2021
|
+
'CONFIG_LIMB_SET_JOINTS',
|
|
2022
|
+
`${at}.parts[${k}]`,
|
|
2023
|
+
`names "${pn}", which config.limbs.${byLimb} cuts at the base's joints into ${segList}; a gesture set covers the limb chain's segment parts at the base's joints, not the unsplit layer`,
|
|
2024
|
+
);
|
|
2025
|
+
} else if (!validParts.has(pn)) {
|
|
1943
2026
|
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`);
|
|
1944
|
-
} else if (
|
|
2027
|
+
} else if (seenParts.has(pn)) {
|
|
1945
2028
|
c.fail('CONFIG_PART_UNIQUE', `${at}.parts[${k}]`, `repeats "${pn}"; each covered part is named once`);
|
|
1946
|
-
} else
|
|
2029
|
+
} else seenParts.add(pn);
|
|
1947
2030
|
});
|
|
1948
2031
|
}
|
|
2032
|
+
const seenHides = new Set<string>();
|
|
1949
2033
|
if ('hides' in e && c.array(`${at}.hides`, e.hides, false)) {
|
|
1950
|
-
const seen = new Set<string>();
|
|
1951
2034
|
(e.hides as Json[]).forEach((hn, k) => {
|
|
1952
2035
|
if (!c.string(`${at}.hides[${k}]`, hn)) return;
|
|
1953
|
-
|
|
2036
|
+
const byLimb = limbs?.consumed.get(hn);
|
|
2037
|
+
if (byLimb !== undefined) {
|
|
2038
|
+
const segList = (limbs?.chains.get(byLimb)?.parts ?? []).map((s) => `"${s}"`).join(', ');
|
|
2039
|
+
c.fail(
|
|
2040
|
+
'CONFIG_LIMB_SET_JOINTS',
|
|
2041
|
+
`${at}.hides[${k}]`,
|
|
2042
|
+
`names "${hn}", which config.limbs.${byLimb} cuts at the base's joints into ${segList}; a gesture set covers the limb chain's segment parts at the base's joints, not the unsplit layer`,
|
|
2043
|
+
);
|
|
2044
|
+
} else if (!allSlots.has(hn)) {
|
|
1954
2045
|
c.fail('CONFIG_NAME_RESOLVES', `${at}.hides[${k}]`, `is ${show(hn)}; a slot of the base rig is required`);
|
|
1955
|
-
} else if (
|
|
2046
|
+
} else if (seenHides.has(hn)) {
|
|
1956
2047
|
c.fail('CONFIG_PART_UNIQUE', `${at}.hides[${k}]`, `repeats "${hn}"; each hidden slot is named once`);
|
|
1957
|
-
} else
|
|
2048
|
+
} else seenHides.add(hn);
|
|
1958
2049
|
});
|
|
1959
2050
|
}
|
|
2051
|
+
if ('parts' in e && limbs !== null) {
|
|
2052
|
+
for (const [limbName, chain] of limbs.chains) {
|
|
2053
|
+
const coveredSegs = chain.parts.filter((sp) => seenParts.has(sp) && !seenHides.has(sp));
|
|
2054
|
+
const hiddenSegs = chain.parts.filter((sp) => seenHides.has(sp));
|
|
2055
|
+
if ((coveredSegs.length > 0 || hiddenSegs.length > 0) && coveredSegs.length !== chain.parts.length) {
|
|
2056
|
+
c.fail(
|
|
2057
|
+
'CONFIG_LIMB_SET_JOINTS',
|
|
2058
|
+
`${at}.parts`,
|
|
2059
|
+
`covers [${coveredSegs.map((s) => `"${s}"`).join(', ')}]${hiddenSegs.length > 0 ? ` (and hides [${hiddenSegs.map((s) => `"${s}"`).join(', ')}])` : ''} of config.limbs.${limbName}, which cuts at the base's joints into [${chain.parts.map((s) => `"${s}"`).join(', ')}]; a gesture set over a limb chain must cover all ${chain.parts.length} of its segment parts so it is cut at the base's joints`,
|
|
2060
|
+
);
|
|
2061
|
+
} else if (coveredSegs.length === chain.parts.length && !cutsConsumedLimb && 'cuts' in e && Array.isArray(e.cuts) && e.cuts.length > 0) {
|
|
2062
|
+
c.fail(
|
|
2063
|
+
'CONFIG_LIMB_SET_JOINTS',
|
|
2064
|
+
`${at}.cuts`,
|
|
2065
|
+
`declares custom polygon cuts on a gesture set covering config.limbs.${limbName}; a gesture set is cut at the base's joints and declares no cuts of its own`,
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
if ('parts' in e && 'drawn' in e && isPlainObject(e.drawn)) {
|
|
2071
|
+
const activeParts = [...seenParts].filter((pn) => !seenHides.has(pn));
|
|
2072
|
+
const coveredBones = new Set<string>();
|
|
2073
|
+
let allPartsResolved = activeParts.length > 0;
|
|
2074
|
+
for (const pn of activeParts) {
|
|
2075
|
+
const limbSeg = limbs?.parts.get(pn);
|
|
2076
|
+
if (limbSeg !== undefined) {
|
|
2077
|
+
coveredBones.add(limbSeg.bone);
|
|
2078
|
+
} else if (isPlainObject(regions) && typeof regions[pn] === 'string') {
|
|
2079
|
+
coveredBones.add(regions[pn]);
|
|
2080
|
+
} else if (isPlainObject(meshes) && isPlainObject(meshes[pn])) {
|
|
2081
|
+
const m = meshes[pn] as Record<string, Json>;
|
|
2082
|
+
if (Array.isArray(m.segments)) {
|
|
2083
|
+
for (const s of m.segments) {
|
|
2084
|
+
if (typeof s === 'string') {
|
|
2085
|
+
const links = chains?.get(s);
|
|
2086
|
+
if (links !== undefined) {
|
|
2087
|
+
for (let lk = 0; lk < links; lk++) coveredBones.add(`${s}${lk}`);
|
|
2088
|
+
} else {
|
|
2089
|
+
coveredBones.add(s);
|
|
2090
|
+
}
|
|
2091
|
+
} else if (Array.isArray(s) && typeof s[0] === 'string') {
|
|
2092
|
+
coveredBones.add(s[0]);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
if (isPlainObject(m.contour)) {
|
|
2097
|
+
if (Array.isArray(m.contour.regions)) {
|
|
2098
|
+
for (const rg of m.contour.regions) if (isPlainObject(rg) && typeof rg.bone === 'string') coveredBones.add(rg.bone);
|
|
2099
|
+
}
|
|
2100
|
+
if (isPlainObject(m.contour.bust)) {
|
|
2101
|
+
coveredBones.add(typeof m.contour.bust.bone_r === 'string' ? m.contour.bust.bone_r : DEFAULT_BUST_BONE_R);
|
|
2102
|
+
coveredBones.add(typeof m.contour.bust.bone_l === 'string' ? m.contour.bust.bone_l : DEFAULT_BUST_BONE_L);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
if (isPlainObject(m.auto) && Array.isArray(m.auto.regions)) {
|
|
2106
|
+
for (const rg of m.auto.regions) if (isPlainObject(rg) && typeof rg.bone === 'string') coveredBones.add(rg.bone);
|
|
2107
|
+
}
|
|
2108
|
+
} else {
|
|
2109
|
+
allPartsResolved = false;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
if (allPartsResolved) {
|
|
2113
|
+
const coveredList = [...coveredBones].map((b) => `"${b}"`).join(', ') || 'none';
|
|
2114
|
+
for (const bName of Object.keys(e.drawn)) {
|
|
2115
|
+
if ((bones === null || bones.has(bName)) && !coveredBones.has(bName)) {
|
|
2116
|
+
c.fail(
|
|
2117
|
+
'CONFIG_SET_COVERS_BONE',
|
|
2118
|
+
`${at}.drawn.${bName}`,
|
|
2119
|
+
`names "${bName}", which alternate set "${String(e.name ?? '')}" does not cover (covered bones: ${coveredList}); a subset set may state drawn angles only for the bones its covered parts ride`,
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
1960
2125
|
});
|
|
1961
2126
|
}
|
|
1962
2127
|
|
|
@@ -2593,6 +2758,7 @@ function checkRegions(c: Check, v: Json, bones: Set<string>): void {
|
|
|
2593
2758
|
interface LimbNames {
|
|
2594
2759
|
parts: Map<string, { bone: string; at: string }>;
|
|
2595
2760
|
consumed: Map<string, string>;
|
|
2761
|
+
chains: Map<string, { parts: string[]; bones: string[]; froms: string[] }>;
|
|
2596
2762
|
}
|
|
2597
2763
|
|
|
2598
2764
|
/**
|
|
@@ -2618,7 +2784,7 @@ interface LimbNames {
|
|
|
2618
2784
|
* resolved. Returns the limb parts and the parts the limbs take.
|
|
2619
2785
|
*/
|
|
2620
2786
|
function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[]; cuts: string[] }, names: { bones: Set<string>; parents: Map<string, string> } | null): LimbNames {
|
|
2621
|
-
const out: LimbNames = { parts: new Map(), consumed: new Map() };
|
|
2787
|
+
const out: LimbNames = { parts: new Map(), consumed: new Map(), chains: new Map() };
|
|
2622
2788
|
if (!isPlainObject(v)) {
|
|
2623
2789
|
c.fail('CONFIG_FIELD_TYPE', 'config.limbs', `is ${show(v)}; an object of limb name -> {"segments": [...]} is required`);
|
|
2624
2790
|
return out;
|
|
@@ -2663,11 +2829,13 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
|
|
|
2663
2829
|
continue;
|
|
2664
2830
|
}
|
|
2665
2831
|
const segs = o.segments as Json[];
|
|
2832
|
+
const segParts: Array<string | null> = [];
|
|
2666
2833
|
const froms: Array<string | null> = [];
|
|
2667
2834
|
const bones: Array<string | null> = [];
|
|
2668
2835
|
segs.forEach((entry, i) => {
|
|
2669
2836
|
const sat = `${at}.segments[${i}]`;
|
|
2670
2837
|
const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap', 'edge', 'cap_shape', 'cut_band']);
|
|
2838
|
+
segParts.push(null);
|
|
2671
2839
|
froms.push(null);
|
|
2672
2840
|
bones.push(null);
|
|
2673
2841
|
if (e === null) return;
|
|
@@ -2683,6 +2851,7 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
|
|
|
2683
2851
|
} else if (firstAt.has(name)) c.fail('CONFIG_PART_UNIQUE', `${sat}.part`, `names the part "${name}" again (first at ${firstAt.get(name)})`);
|
|
2684
2852
|
else {
|
|
2685
2853
|
firstAt.set(name, sat);
|
|
2854
|
+
segParts[i] = name;
|
|
2686
2855
|
if ('bone' in e && typeof e.bone === 'string') out.parts.set(name, { bone: e.bone, at: `config.limbs.${limb} segment ${i}` });
|
|
2687
2856
|
}
|
|
2688
2857
|
}
|
|
@@ -2727,6 +2896,11 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
|
|
|
2727
2896
|
}
|
|
2728
2897
|
}
|
|
2729
2898
|
});
|
|
2899
|
+
out.chains.set(limb, {
|
|
2900
|
+
parts: segParts.filter((q): q is string => q !== null),
|
|
2901
|
+
bones: bones.filter((q): q is string => q !== null),
|
|
2902
|
+
froms: [...new Set(froms.filter((q): q is string => q !== null))],
|
|
2903
|
+
});
|
|
2730
2904
|
limbFroms.set(limb, new Set(froms.filter((f): f is string => f !== null)));
|
|
2731
2905
|
froms.forEach((f, i) => {
|
|
2732
2906
|
if (f === null || i === 0 || froms[i - 1] === f) return;
|
package/src/limbs.ts
CHANGED
|
@@ -125,6 +125,98 @@ export function bonePlaces(bones: readonly BoneEntry[]): Map<string, BonePlace>
|
|
|
125
125
|
return out;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Issue #476: pose `bones` (in rig pixels, y down) so every bone named in
|
|
130
|
+
* `drawn` takes that world angle (in Spine degrees, CCW, y up) while keeping
|
|
131
|
+
* its base length and parent-to-child joint offsets. Used by `assemble` when
|
|
132
|
+
* an alternate set covers a limb chain at its own `drawn` angles, so `cutLimbs`
|
|
133
|
+
* cuts the set's painting at the base's joints posed to `drawn`.
|
|
134
|
+
*/
|
|
135
|
+
export function poseBoneEntriesForDrawn(bones: readonly BoneEntry[], drawn: Readonly<Record<string, number>>): BoneEntry[] {
|
|
136
|
+
if (Object.keys(drawn).length === 0) return [...bones];
|
|
137
|
+
interface FlatNode {
|
|
138
|
+
name: string;
|
|
139
|
+
parent: string;
|
|
140
|
+
at0: Pt;
|
|
141
|
+
end0: Pt | null;
|
|
142
|
+
theta0: number;
|
|
143
|
+
length: number;
|
|
144
|
+
inherit?: string;
|
|
145
|
+
}
|
|
146
|
+
const flat = new Map<string, FlatNode>();
|
|
147
|
+
for (const b of bones) {
|
|
148
|
+
if ('chain' in b) {
|
|
149
|
+
b.points.forEach((p, k) => {
|
|
150
|
+
const name = `${b.chain}${k}`;
|
|
151
|
+
const parent = k === 0 ? b.parent : `${b.chain}${k - 1}`;
|
|
152
|
+
const at0: Pt = [p[0], p[1]];
|
|
153
|
+
const end0: Pt = k + 1 < b.points.length ? [b.points[k + 1][0], b.points[k + 1][1]] : [b.tip[0], b.tip[1]];
|
|
154
|
+
const dx = end0[0] - at0[0];
|
|
155
|
+
const dy = end0[1] - at0[1];
|
|
156
|
+
const theta0 = (Math.atan2(-dy, dx) * 180) / Math.PI;
|
|
157
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
158
|
+
flat.set(name, { name, parent, at0, end0, theta0, length, inherit: b.link_inherit?.[name] });
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
const at0: Pt = [b.at[0], b.at[1]];
|
|
162
|
+
const end0: Pt | null = b.tip === undefined ? null : [b.tip[0], b.tip[1]];
|
|
163
|
+
const dx = end0 === null ? 0 : end0[0] - at0[0];
|
|
164
|
+
const dy = end0 === null ? 0 : end0[1] - at0[1];
|
|
165
|
+
const theta0 = end0 === null ? 0 : (Math.atan2(-dy, dx) * 180) / Math.PI;
|
|
166
|
+
const length = end0 === null ? 0 : Math.sqrt(dx * dx + dy * dy);
|
|
167
|
+
flat.set(b.name, { name: b.name, parent: b.parent, at0, end0, theta0, length, inherit: b.inherit });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const posed = new Map<string, { at: Pt; end: Pt | null; theta: number }>();
|
|
171
|
+
const solve = (name: string): { at: Pt; end: Pt | null; theta: number } => {
|
|
172
|
+
const cached = posed.get(name);
|
|
173
|
+
if (cached !== undefined) return cached;
|
|
174
|
+
const node = flat.get(name);
|
|
175
|
+
if (node === undefined) return { at: [0, 0], end: null, theta: 0 };
|
|
176
|
+
let at: Pt = [node.at0[0], node.at0[1]];
|
|
177
|
+
let dParent = 0;
|
|
178
|
+
const pNode = flat.get(node.parent);
|
|
179
|
+
if (pNode !== undefined) {
|
|
180
|
+
const pPosed = solve(node.parent);
|
|
181
|
+
dParent = pPosed.theta - pNode.theta0;
|
|
182
|
+
const rad = (dParent * Math.PI) / 180;
|
|
183
|
+
const cos = Math.cos(rad);
|
|
184
|
+
const sin = Math.sin(rad);
|
|
185
|
+
const dx0 = node.at0[0] - pNode.at0[0];
|
|
186
|
+
const dy0 = node.at0[1] - pNode.at0[1];
|
|
187
|
+
at = [pPosed.at[0] + dx0 * cos + dy0 * sin, pPosed.at[1] - dx0 * sin + dy0 * cos];
|
|
188
|
+
}
|
|
189
|
+
const inheritsRot = node.inherit !== 'noRotationOrReflection' && node.inherit !== 'onlyTranslation';
|
|
190
|
+
const theta = drawn[name] !== undefined ? drawn[name] : node.theta0 + (inheritsRot ? dParent : 0);
|
|
191
|
+
let end: Pt | null = null;
|
|
192
|
+
if (node.end0 !== null) {
|
|
193
|
+
const rad = (theta * Math.PI) / 180;
|
|
194
|
+
end = [at[0] + node.length * Math.cos(rad), at[1] - node.length * Math.sin(rad)];
|
|
195
|
+
}
|
|
196
|
+
const res = { at, end, theta };
|
|
197
|
+
posed.set(name, res);
|
|
198
|
+
return res;
|
|
199
|
+
};
|
|
200
|
+
for (const name of flat.keys()) solve(name);
|
|
201
|
+
return bones.map((b): BoneEntry => {
|
|
202
|
+
if ('chain' in b) {
|
|
203
|
+
const points = b.points.map((_, k): Pt => {
|
|
204
|
+
const s = solve(`${b.chain}${k}`);
|
|
205
|
+
return [s.at[0], s.at[1]];
|
|
206
|
+
});
|
|
207
|
+
const last = solve(`${b.chain}${b.points.length - 1}`);
|
|
208
|
+
const tip: Pt = last.end === null ? [b.tip[0], b.tip[1]] : [last.end[0], last.end[1]];
|
|
209
|
+
return { ...b, points, tip };
|
|
210
|
+
}
|
|
211
|
+
const s = solve(b.name);
|
|
212
|
+
return {
|
|
213
|
+
...b,
|
|
214
|
+
at: [s.at[0], s.at[1]],
|
|
215
|
+
...(b.tip === undefined || s.end === null ? {} : { tip: [s.end[0], s.end[1]] }),
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
128
220
|
/** A limb's polyline: its segments' bone origins in order, then the last bone's end when it has one. */
|
|
129
221
|
export function limbPolyline(limb: Limb, places: ReadonlyMap<string, BonePlace>): Pt[] {
|
|
130
222
|
const pts = limb.segments.map((s) => (places.get(s.bone) as BonePlace).at);
|