@rig-c/parts 1.25.0 → 1.26.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/README.md +1 -1
- package/cli.ts +20 -5
- package/docs/AUTHORING.md +54 -35
- package/docs/MIGRATION.md +1 -1
- package/docs/STABILITY.md +7 -7
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +1 -1
- package/src/assemble.ts +117 -23
- package/src/build.ts +23 -9
- package/src/check.ts +220 -40
- package/src/cloth.ts +17 -5
- package/src/config.ts +58 -10
- package/src/headbox.ts +68 -34
- package/src/propose.ts +81 -19
- package/src/rig.ts +74 -6
- package/src/scene.ts +87 -7
- package/src/squeeze.ts +1 -1
package/src/check.ts
CHANGED
|
@@ -2017,12 +2017,24 @@ export const FOLD_RENDER_MAX = 16;
|
|
|
2017
2017
|
/** The fold render, under `--out`; removed before the stage returns. */
|
|
2018
2018
|
const FOLD_SCRATCH = '_folds';
|
|
2019
2019
|
|
|
2020
|
-
/** One
|
|
2020
|
+
/** One side of a mesh's fold reading (with physics on or off): rigc's figures and its printed line. */
|
|
2021
|
+
export interface FoldSide {
|
|
2022
|
+
/** How many frames show a folded triangle. */
|
|
2023
|
+
frames: number;
|
|
2024
|
+
/** The frame with the most, the earliest on a tie (rigc's `worst`). */
|
|
2025
|
+
worst_frame: number;
|
|
2026
|
+
/** How many triangles are folded on that frame. */
|
|
2027
|
+
worst_triangles: number;
|
|
2028
|
+
/** rigc's printed line for the mesh, verbatim (`rigc render --folds`). */
|
|
2029
|
+
line: string;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/** One mesh that folds in one animation: rigc's figures, with physics on and off side by side, its physics constraints when only physics folds it, the declaration that takes it if any, and rigc's own line. */
|
|
2021
2033
|
export interface FoldRow {
|
|
2022
2034
|
animation: string;
|
|
2023
2035
|
slot: string;
|
|
2024
2036
|
attachment: string;
|
|
2025
|
-
/** How many frames show a folded triangle. */
|
|
2037
|
+
/** How many frames show a folded triangle (the active reading, physics on when the rig has physics). */
|
|
2026
2038
|
frames: number;
|
|
2027
2039
|
/** The frame with the most, the earliest on a tie (rigc's `worst`). */
|
|
2028
2040
|
worst_frame: number;
|
|
@@ -2030,6 +2042,12 @@ export interface FoldRow {
|
|
|
2030
2042
|
worst_triangles: number;
|
|
2031
2043
|
/** How many triangles the mesh has. */
|
|
2032
2044
|
triangles: number;
|
|
2045
|
+
/** The reading with physics on, or null when the mesh folds only with physics off. */
|
|
2046
|
+
physics_on: FoldSide | null;
|
|
2047
|
+
/** The reading with physics off, or null when the mesh folds only with physics on. */
|
|
2048
|
+
physics_off: FoldSide | null;
|
|
2049
|
+
/** When the mesh folds only with physics on (`physics_on !== null && physics_off === null`), the physics constraints on its bones (`<name> on <bone>`); empty otherwise. */
|
|
2050
|
+
physics_constraints: string[];
|
|
2033
2051
|
/** The `may_fold` entry's why when the requirements file declares this slot and animation; null when nothing does. */
|
|
2034
2052
|
declared: string | null;
|
|
2035
2053
|
/** rigc's printed line for the mesh, verbatim (`rigc render --folds`). */
|
|
@@ -2042,7 +2060,7 @@ export interface FoldsLine {
|
|
|
2042
2060
|
/** Every animation, the idle included, in motion.json's order: each one read. */
|
|
2043
2061
|
animations: string[];
|
|
2044
2062
|
fps: number;
|
|
2045
|
-
/** How many physics constraints the
|
|
2063
|
+
/** How many physics constraints the rig declares ({@link withoutPhysics}) — stepped in `physics_on` and left out in `physics_off`; 0 when the rig has none. */
|
|
2046
2064
|
physics_left_out: number;
|
|
2047
2065
|
/** Every mesh that folds in an animation read, in that animation's order and rigc's mesh order. */
|
|
2048
2066
|
folding: FoldRow[];
|
|
@@ -2130,26 +2148,118 @@ export function readFoldSets(dir: string, named: readonly string[], fps: number,
|
|
|
2130
2148
|
}
|
|
2131
2149
|
|
|
2132
2150
|
/**
|
|
2133
|
-
* The
|
|
2134
|
-
*
|
|
2135
|
-
*
|
|
2136
|
-
* matches a problem of its own — a declaration that excepts nothing.
|
|
2151
|
+
* The physics constraints on the bones a mesh attachment is bound to (or their
|
|
2152
|
+
* ancestors, or `<bone>` when a stopped link is bound as `<bone>_d`), in
|
|
2153
|
+
* {@link withoutPhysics} order, formatted `"<name> on <bone>"`.
|
|
2137
2154
|
*/
|
|
2138
|
-
export function
|
|
2155
|
+
export function meshPhysicsConstraints(rig: Record<string, unknown>, slot: string, attachment: string, physics: readonly LoopPhysics[]): string[] {
|
|
2156
|
+
if (physics.length === 0) return [];
|
|
2157
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
2158
|
+
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
2159
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
2160
|
+
const skin = isRecord(skins.default) ? skins.default : {};
|
|
2161
|
+
const onSlot = isRecord(skin[slot]) ? skin[slot] : {};
|
|
2162
|
+
const att = isRecord(onSlot[attachment]) ? onSlot[attachment] : null;
|
|
2163
|
+
const bound = new Set<string>();
|
|
2164
|
+
const addWithAncestors = (start: string): void => {
|
|
2165
|
+
for (let b: string | null = start; b !== null && !bound.has(b); b = parent.get(b) ?? null) {
|
|
2166
|
+
bound.add(b);
|
|
2167
|
+
if (b.endsWith('_d')) bound.add(b.slice(0, -2));
|
|
2168
|
+
}
|
|
2169
|
+
};
|
|
2170
|
+
if (att !== null && Array.isArray(att.weights)) {
|
|
2171
|
+
for (const v of att.weights) {
|
|
2172
|
+
if (!Array.isArray(v)) continue;
|
|
2173
|
+
for (const w of v) if (isRecord(w) && typeof w.bone === 'string' && typeof w.weight === 'number' && w.weight > 0) addWithAncestors(w.bone);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
if (bound.size === 0) {
|
|
2177
|
+
const slotRec = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((s) => s.name === slot);
|
|
2178
|
+
if (slotRec !== undefined && typeof slotRec.bone === 'string') addWithAncestors(slotRec.bone);
|
|
2179
|
+
}
|
|
2180
|
+
const direct = physics.filter((p) => bound.has(p.bone));
|
|
2181
|
+
return (direct.length > 0 ? direct : physics).map((p) => `${p.name} on ${p.bone}`);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/**
|
|
2185
|
+
* The fold line (issues #320, #333, #363) from the reading: posed with physics
|
|
2186
|
+
* stepping on the render clock (`readings`, and `readingsWithoutPhysics`
|
|
2187
|
+
* beside it when the rig declares physics constraints), every folding mesh a
|
|
2188
|
+
* row reporting `physics_on` and `physics_off` side by side, a row no
|
|
2189
|
+
* `may_fold` entry takes a `CHECK_ANIMATIONS_DO_NOT_FOLD` problem naming the
|
|
2190
|
+
* animation, the slot, the physics constraint(s) on its bones and the worst
|
|
2191
|
+
* frame when the fold appears only with physics on, and rigc's line, and an
|
|
2192
|
+
* entry no row matches a problem of its own — a declaration that excepts
|
|
2193
|
+
* nothing.
|
|
2194
|
+
*/
|
|
2195
|
+
export function foldsLine(
|
|
2196
|
+
named: readonly string[],
|
|
2197
|
+
fps: number,
|
|
2198
|
+
physicsLeftOut: number,
|
|
2199
|
+
readings: ReadonlyMap<string, readonly FoldingMesh[]>,
|
|
2200
|
+
mayFold: readonly MayFold[],
|
|
2201
|
+
requirementsPath: string | null,
|
|
2202
|
+
problems: Problem[],
|
|
2203
|
+
readingsWithoutPhysics: ReadonlyMap<string, readonly FoldingMesh[]> = readings,
|
|
2204
|
+
rig: Record<string, unknown> = {},
|
|
2205
|
+
physicsList: readonly LoopPhysics[] = [],
|
|
2206
|
+
): FoldsLine {
|
|
2207
|
+
const toSide = (m: FoldingMesh | undefined): FoldSide | null =>
|
|
2208
|
+
m === undefined ? null : { frames: m.frames, worst_frame: m.worstFrame, worst_triangles: m.worstTriangles, line: m.line };
|
|
2139
2209
|
const folding: FoldRow[] = [];
|
|
2140
2210
|
for (const name of named) {
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2211
|
+
const onList = readings.get(name) ?? [];
|
|
2212
|
+
const offList = readingsWithoutPhysics.get(name) ?? [];
|
|
2213
|
+
const seen = new Set<string>();
|
|
2214
|
+
const keys: Array<{ slot: string; attachment: string }> = [];
|
|
2215
|
+
for (const m of [...onList, ...offList]) {
|
|
2216
|
+
const k = `${m.slot}\0${m.attachment}`;
|
|
2217
|
+
if (!seen.has(k)) {
|
|
2218
|
+
seen.add(k);
|
|
2219
|
+
keys.push({ slot: m.slot, attachment: m.attachment });
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
for (const { slot, attachment } of keys) {
|
|
2223
|
+
const on = onList.find((m) => m.slot === slot && m.attachment === attachment);
|
|
2224
|
+
const off = offList.find((m) => m.slot === slot && m.attachment === attachment);
|
|
2225
|
+
const primary = (on ?? off) as FoldingMesh;
|
|
2226
|
+
const entry = mayFold.find((e) => e.slot === slot && e.animation === name);
|
|
2227
|
+
const onlyPhysicsOn = on !== undefined && off === undefined;
|
|
2228
|
+
const constraints = onlyPhysicsOn ? meshPhysicsConstraints(rig, slot, attachment, physicsList) : [];
|
|
2229
|
+
folding.push({
|
|
2230
|
+
animation: name,
|
|
2231
|
+
slot,
|
|
2232
|
+
attachment,
|
|
2233
|
+
frames: primary.frames,
|
|
2234
|
+
worst_frame: primary.worstFrame,
|
|
2235
|
+
worst_triangles: primary.worstTriangles,
|
|
2236
|
+
triangles: primary.triangleCount,
|
|
2237
|
+
physics_on: toSide(on),
|
|
2238
|
+
physics_off: toSide(off),
|
|
2239
|
+
physics_constraints: constraints,
|
|
2240
|
+
declared: entry === undefined ? null : entry.why,
|
|
2241
|
+
line: primary.line,
|
|
2242
|
+
});
|
|
2144
2243
|
}
|
|
2145
2244
|
}
|
|
2146
|
-
const physics =
|
|
2245
|
+
const physics =
|
|
2246
|
+
physicsLeftOut === 0
|
|
2247
|
+
? ''
|
|
2248
|
+
: `, with its ${physicsLeftOut} physics constraint(s) on and off`;
|
|
2147
2249
|
for (const r of folding) {
|
|
2148
2250
|
if (r.declared !== null) continue;
|
|
2251
|
+
const sideClause =
|
|
2252
|
+
r.physics_on !== null && r.physics_off === null && physicsLeftOut > 0
|
|
2253
|
+
? ` (physics on: ${r.physics_on.frames} frame(s), worst frame ${r.physics_on.worst_frame} (${r.physics_on.worst_triangles}/${r.triangles} triangle(s)); physics off: 0 frame(s); physics constraint(s) on mesh "${r.attachment}": ${r.physics_constraints.join(', ')})`
|
|
2254
|
+
: r.physics_on !== null && r.physics_off !== null && physicsLeftOut > 0
|
|
2255
|
+
? ` (physics on: ${r.physics_on.frames} frame(s), worst frame ${r.physics_on.worst_frame}; physics off: ${r.physics_off.frames} frame(s), worst frame ${r.physics_off.worst_frame})`
|
|
2256
|
+
: r.physics_on === null && r.physics_off !== null && physicsLeftOut > 0
|
|
2257
|
+
? ` (physics on: 0 frame(s); physics off: ${r.physics_off.frames} frame(s), worst frame ${r.physics_off.worst_frame})`
|
|
2258
|
+
: '';
|
|
2149
2259
|
problems.push({
|
|
2150
2260
|
code: `CHECK_${FOLD_LINE}`,
|
|
2151
2261
|
object: `animation "${r.animation}", mesh "${r.attachment}" (slot "${r.slot}")`,
|
|
2152
|
-
detail: `rigc render --folds at ${fps} fps${physics}: ${r.line}; no triangle turned over against the setup pose is required — a fold the art means is declared in the --requirements file's may_fold, { "slot": "${r.slot}", "animation": "${r.animation}", "why": … }`,
|
|
2262
|
+
detail: `rigc render --folds at ${fps} fps${physics}${sideClause}: ${r.line}; no triangle turned over against the setup pose is required — a fold the art means is declared in the --requirements file's may_fold, { "slot": "${r.slot}", "animation": "${r.animation}", "why": … }`,
|
|
2153
2263
|
});
|
|
2154
2264
|
}
|
|
2155
2265
|
const declaredNotFolding = mayFold.filter((e) => !folding.some((r) => r.slot === e.slot && r.animation === e.animation)).map((e) => ({ slot: e.slot, animation: e.animation }));
|
|
@@ -3101,26 +3211,46 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
3101
3211
|
written.push('motion_heat.png');
|
|
3102
3212
|
}
|
|
3103
3213
|
|
|
3104
|
-
// 3a. every animation, the idle included, read for folds by rigc (issues #320, #333) — only when there is one; at
|
|
3105
|
-
// the idle's rate whatever --requirements says, so a requirements file moves only what it declares (CK78),
|
|
3214
|
+
// 3a. every animation, the idle included, read for folds by rigc (issues #320, #333, #363) — only when there is one; at
|
|
3215
|
+
// the idle's rate whatever --requirements says, so a requirements file moves only what it declares (CK78), with physics on and off
|
|
3106
3216
|
if (animationNames.length > 0) {
|
|
3107
3217
|
const fps = IDLE_FPS;
|
|
3108
3218
|
try {
|
|
3109
|
-
// Issue #348: with a foot to read, the
|
|
3219
|
+
// Issue #348: with a foot to read, the keyed render writes each animation's geometry as well.
|
|
3110
3220
|
const geometry = feet.length > 0 ? ['--geometry'] : [];
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3221
|
+
if (stripped === null) {
|
|
3222
|
+
const fr = rigc(['render', '--candidate', buildDir, '--folds', ...geometry, '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', foldDir]);
|
|
3223
|
+
if (fr.status !== 0) refuseIfAny(rigcFailed(`render --folds${geometry.map((g) => ` ${g}`).join('')} --fps ${fps}`, fr, fr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
3224
|
+
const readings = readFoldSets(foldDir, animationNames, fps, fr.out);
|
|
3225
|
+
folds = foldsLine(animationNames, fps, 0, readings, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems, readings, inp.rig, []);
|
|
3226
|
+
if (feet.length > 0) {
|
|
3227
|
+
const sets = readJson(join(foldDir, 'frames.json'), []);
|
|
3228
|
+
const listed = sets !== null && Array.isArray(sets.sets) ? sets.sets.filter(isRecord) : [];
|
|
3229
|
+
const levelReadings = animationNames.map((name) => {
|
|
3230
|
+
const dir = join(foldDir, String((listed.find((x) => x.animation === name) as Record<string, unknown>).dir));
|
|
3231
|
+
return { animation: name, set: readFrameSet(dir), geometryPath: join(dir, GEOMETRY_FILE) };
|
|
3232
|
+
});
|
|
3233
|
+
level = levelLine(feet, inp.rig, levelReadings, fps, 0, levelProblems);
|
|
3234
|
+
}
|
|
3235
|
+
} else {
|
|
3236
|
+
const onDir = join(foldDir, 'on');
|
|
3237
|
+
const offDir = join(foldDir, 'off');
|
|
3238
|
+
const frOn = rigc(['render', '--candidate', buildDir, '--folds', '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', onDir]);
|
|
3239
|
+
if (frOn.status !== 0) refuseIfAny(rigcFailed(`render --folds --fps ${fps}`, frOn, frOn.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
3240
|
+
const frOff = rigc(['render', '--candidate', keyedBuild('the animations with their physics constraint(s) left out'), '--folds', ...geometry, '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', offDir]);
|
|
3241
|
+
if (frOff.status !== 0) refuseIfAny(rigcFailed(`render --folds${geometry.map((g) => ` ${g}`).join('')} --fps ${fps} (physics constraint(s) left out)`, frOff, frOff.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
3242
|
+
const readingsOn = readFoldSets(onDir, animationNames, fps, frOn.out);
|
|
3243
|
+
const readingsOff = readFoldSets(offDir, animationNames, fps, frOff.out);
|
|
3244
|
+
folds = foldsLine(animationNames, fps, stripped.physics.length, readingsOn, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems, readingsOff, inp.rig, stripped.physics);
|
|
3245
|
+
if (feet.length > 0) {
|
|
3246
|
+
const sets = readJson(join(offDir, 'frames.json'), []);
|
|
3247
|
+
const listed = sets !== null && Array.isArray(sets.sets) ? sets.sets.filter(isRecord) : [];
|
|
3248
|
+
const levelReadings = animationNames.map((name) => {
|
|
3249
|
+
const dir = join(offDir, String((listed.find((x) => x.animation === name) as Record<string, unknown>).dir));
|
|
3250
|
+
return { animation: name, set: readFrameSet(dir), geometryPath: join(dir, GEOMETRY_FILE) };
|
|
3251
|
+
});
|
|
3252
|
+
level = levelLine(feet, inp.rig, levelReadings, fps, stripped.physics.length, levelProblems);
|
|
3253
|
+
}
|
|
3124
3254
|
}
|
|
3125
3255
|
} finally {
|
|
3126
3256
|
rmSync(foldDir, { recursive: true, force: true });
|
|
@@ -4066,12 +4196,17 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
4066
4196
|
} else if (!ok) {
|
|
4067
4197
|
failed = true;
|
|
4068
4198
|
// Issue #262: a root the breath carries is named with its carrier, not blamed on the chain.
|
|
4199
|
+
// Issue #351: when the breath carries the root and the chain is also below the bar, or when the mesh is weighted
|
|
4200
|
+
// off its chain without a breath carrier, name that too instead of only blaming the chain's amplitudes.
|
|
4069
4201
|
const carry = breathCarrier(inp.rig, inp.motion, p.name, geometryPath, frames, { art, box: p, stage: inp.stage, shown, toward: rises ? 'up' : 'down', root: t.root, tip: t.tip });
|
|
4070
4202
|
if (carry !== null) rows[rows.length - 1] = { ...rows[rows.length - 1], carried_by: carry.bone, breath_rig_px: round3(carry.breath), root_without_breath_rig_px: round3(carry.root), tip_without_breath_rig_px: round3(carry.tip) };
|
|
4203
|
+
const offChain = carry === null && round3(t.tip) > 0 ? offChainClause(inp.rig, inp.motion, p.name) : null;
|
|
4071
4204
|
const why =
|
|
4072
4205
|
carry === null
|
|
4073
|
-
?
|
|
4074
|
-
:
|
|
4206
|
+
? `the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain${offChain === null ? '' : `; ${offChain}`}`
|
|
4207
|
+
: carry.passes
|
|
4208
|
+
? `the root travels with the breath (${round3(t.root)} px of ${round3(carry.breath)} px): it is carried by \`${carry.bone}\`, which moves it more than the tip; with \`${carry.keyed}\`'s translate held still the root travels ${round3(carry.root)} rig px and the tip ${round3(carry.tip)}, which the bar passes (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}) — bind the root to the bone the part hangs from, not to \`${carry.bone}\``
|
|
4209
|
+
: `the root travels with the breath (${round3(t.root)} px of ${round3(carry.breath)} px): it is carried by \`${carry.bone}\`, which moves it more than the tip; with \`${carry.keyed}\`'s translate held still the root travels ${round3(carry.root)} rig px and the tip ${round3(carry.tip)}, which is still below the bar (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}, < ${TIP_RATIO_FLOOR}) — drop the segment on \`${carry.bone}\` from \`meshes.${p.name}.segments\` (or bind the root to the bone the part hangs from, not to \`${carry.bone}\`) and grow the chain's amplitudes toward the tip`;
|
|
4075
4210
|
problems.push({
|
|
4076
4211
|
code: 'CHECK_TIP_OVER_ROOT',
|
|
4077
4212
|
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
|
|
@@ -4088,6 +4223,52 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
4088
4223
|
return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR, ...(excepted.rests.length === 0 ? {} : { rest_ratio_ceiling: TIP_REST_RATIO_CEILING }) };
|
|
4089
4224
|
}
|
|
4090
4225
|
|
|
4226
|
+
/**
|
|
4227
|
+
* When a failing weighted mesh rides a chain (`chainsCarrying`) and also carries positive weight on a bone off that
|
|
4228
|
+
* chain (neither a link nor under one, as {@link stressPlan} reads it), name the off-chain bones and their vertex
|
|
4229
|
+
* counts so `CHECK_TIP_OVER_ROOT` does not only blame the chain's amplitudes (issue #351).
|
|
4230
|
+
*/
|
|
4231
|
+
function offChainClause(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string): string | null {
|
|
4232
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
4233
|
+
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
4234
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
4235
|
+
const skin = isRecord(skins.default) ? skins.default : {};
|
|
4236
|
+
const onSlot = skin[part];
|
|
4237
|
+
const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
|
|
4238
|
+
const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
|
|
4239
|
+
if (weighted.length === 0) return null;
|
|
4240
|
+
const { found } = chainsCarrying(rig, motion, part);
|
|
4241
|
+
if (found.length === 0) return null;
|
|
4242
|
+
const links = new Set(found.flat());
|
|
4243
|
+
const onChain = (b: string): boolean => {
|
|
4244
|
+
const seen = new Set<string>();
|
|
4245
|
+
for (let x: string | null = b; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
|
|
4246
|
+
if (links.has(x)) return true;
|
|
4247
|
+
seen.add(x);
|
|
4248
|
+
}
|
|
4249
|
+
return false;
|
|
4250
|
+
};
|
|
4251
|
+
const off = new Map<string, { vertices: number; largest: number }>();
|
|
4252
|
+
let of = 0;
|
|
4253
|
+
for (const a of weighted) {
|
|
4254
|
+
for (const v of a.weights) {
|
|
4255
|
+
of++;
|
|
4256
|
+
const ws = (Array.isArray(v) ? v : []).filter(isRecord).filter((w) => typeof w.bone === 'string' && typeof w.weight === 'number') as Array<{ bone: string; weight: number }>;
|
|
4257
|
+
for (const w of ws) {
|
|
4258
|
+
if (onChain(w.bone) || w.weight <= 0) continue;
|
|
4259
|
+
const e = off.get(w.bone) ?? { vertices: 0, largest: 0 };
|
|
4260
|
+
e.vertices++;
|
|
4261
|
+
e.largest = Math.max(e.largest, w.weight);
|
|
4262
|
+
off.set(w.bone, e);
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
if (off.size === 0) return null;
|
|
4267
|
+
const list = [...off].map(([bone, e]) => `"${bone}" (${e.vertices} of ${of} vertices, largest weight ${round3(e.largest)})`).join(', ');
|
|
4268
|
+
const drop = off.size === 1 ? `"${[...off.keys()][0]}"` : 'each of those bones';
|
|
4269
|
+
return `it is weighted off the chain to ${list} — drop the segment on ${drop} in \`meshes.${part}.segments\`, or grow the chain's amplitudes toward the tip`;
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4091
4272
|
/**
|
|
4092
4273
|
* The bones a part's attachments are bound to in the default skin (issue #231): a mesh's weights, or its slot's
|
|
4093
4274
|
* bone for an attachment that carries no weights.
|
|
@@ -4109,17 +4290,16 @@ function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
|
|
|
4109
4290
|
const BREATH_PROPS: readonly string[] = ['translate', 'translatex', 'translatey'];
|
|
4110
4291
|
|
|
4111
4292
|
/**
|
|
4112
|
-
* The bone a failing part's root travels with, when that is the breath's (
|
|
4293
|
+
* The bone a failing part's root travels with, when that is the breath's (issues #262, #351). The breath is every idle
|
|
4113
4294
|
* track that translates a bone ({@link BREATH_PROPS}), each read on its own: `K` the bone it keys. Its motion is
|
|
4114
4295
|
* `K`'s world origin less where `K`'s parent carries `K`'s setup position, frame by frame — what the translate keys
|
|
4115
4296
|
* add — and the breath's travel is that, less its frame-0 value, at its largest. The part (one weighted mesh; a
|
|
4116
4297
|
* region is carried whole and cannot stretch) is posed again with that motion taken out of every vertex in
|
|
4117
4298
|
* proportion to its weight on `K` and the bones under it, and its halves measured as {@link halfTravels} measures
|
|
4118
|
-
* them. The root travels with the breath when the breath adds more to the root's travel than to the tip's and
|
|
4119
|
-
*
|
|
4120
|
-
* (
|
|
4121
|
-
*
|
|
4122
|
-
* above (the sleeve's links ride `chest`, which is the carrier). Null otherwise.
|
|
4299
|
+
* them. The root travels with the breath when the breath adds more to the root's travel than to the tip's and the tip
|
|
4300
|
+
* moves above 0 at the line's precision (round3); `passes` records whether, held still, the halves pass the bar
|
|
4301
|
+
* (tip >= {@link TIP_RATIO_FLOOR} x root). The carrier named is the bone the part is bound to ({@link boundBones}) at
|
|
4302
|
+
* or under `K` that no other such bone is above (the sleeve's links ride `chest`, which is the carrier). Null otherwise.
|
|
4123
4303
|
*/
|
|
4124
4304
|
function breathCarrier(
|
|
4125
4305
|
rig: Record<string, unknown>,
|
|
@@ -4128,7 +4308,7 @@ function breathCarrier(
|
|
|
4128
4308
|
geometryPath: string,
|
|
4129
4309
|
frames: number,
|
|
4130
4310
|
m: { art: ArtMask; box: { x: number; y: number; w: number; h: number }; stage: StageBox; shown: ReadonlyArray<PosedAttachment | null>; toward: 'down' | 'up'; root: number; tip: number },
|
|
4131
|
-
): { bone: string; keyed: string; breath: number; root: number; tip: number } | null {
|
|
4311
|
+
): { bone: string; keyed: string; breath: number; root: number; tip: number; passes: boolean } | null {
|
|
4132
4312
|
const anims = isRecord(motion.animations) ? motion.animations : {};
|
|
4133
4313
|
const idle = isRecord(anims.idle) ? anims.idle : {};
|
|
4134
4314
|
const keyed = [...new Set((Array.isArray(idle.tracks) ? idle.tracks : []).filter(isRecord).filter((t) => typeof t.bone === 'string' && typeof t.property === 'string' && BREATH_PROPS.includes(t.property)).map((t) => String(t.bone)))];
|
|
@@ -4186,8 +4366,8 @@ function breathCarrier(
|
|
|
4186
4366
|
const held = m.shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: f.posed.map((v, j) => v - share[j >> 1] * delta[i][j & 1]) }));
|
|
4187
4367
|
const t = halfTravels(m.art, m.box, m.stage, held, m.toward);
|
|
4188
4368
|
if (t.root === null || t.tip === null) continue;
|
|
4189
|
-
if (!(m.root - t.root > m.tip - t.tip) || round3(t.tip) === 0
|
|
4190
|
-
return { bone: carrier, keyed: k, breath, root: t.root, tip: t.tip };
|
|
4369
|
+
if (!(m.root - t.root > Math.max(0, m.tip - t.tip)) || round3(t.tip) === 0) continue;
|
|
4370
|
+
return { bone: carrier, keyed: k, breath, root: t.root, tip: t.tip, passes: t.tip >= TIP_RATIO_FLOOR * t.root };
|
|
4191
4371
|
}
|
|
4192
4372
|
return null;
|
|
4193
4373
|
}
|
package/src/cloth.ts
CHANGED
|
@@ -80,9 +80,9 @@ export interface ClothStrength {
|
|
|
80
80
|
edge: number;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/** One target's recipe, as `config.cloth.<tag>` declares it. */
|
|
83
|
+
/** One target's recipe, as `config.cloth.<tag>` declares it (`chains` may be omitted on `"back hair"`, where it is derived from the tail's width at the root row). */
|
|
84
84
|
export interface ClothRecipe {
|
|
85
|
-
chains
|
|
85
|
+
chains?: number;
|
|
86
86
|
links: number;
|
|
87
87
|
split?: { at: number; into: number; hold_tip?: boolean };
|
|
88
88
|
length_ratio?: number;
|
|
@@ -95,12 +95,24 @@ export interface ClothRecipe {
|
|
|
95
95
|
gravity: [number, number];
|
|
96
96
|
translate?: number;
|
|
97
97
|
swing?: { scales: number[] };
|
|
98
|
-
/** `bottomwear` only
|
|
98
|
+
/** `bottomwear` only: a `topwear` part or its named cut skirt piece the skirt's trees are drafted on. */
|
|
99
99
|
part?: string;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* 📐 The recipe's default maximum back-hair tree count per side when `chains`
|
|
106
|
+
* is omitted on `config.cloth."back hair"` (issue #390): 2 trees per side when
|
|
107
|
+
* the tail's opaque width at the root row holds at least two mesh-grid cells
|
|
108
|
+
* (`floor(width / grid) >= 2`, `fixtures/cloth.ts`: 48 px on a 14 px grid) and
|
|
109
|
+
* every tree root lies inside the art, and fewer when the tail at the root row
|
|
110
|
+
* is narrower or a root falls off the art (`examples/wind`: 45 px on the r side
|
|
111
|
+
* and 61 px on the l side at y=162 on a 28 px grid, with inner roots at
|
|
112
|
+
* [323, 162] and [496, 162] on alpha 0 -> 1 tree per side).
|
|
113
|
+
*/
|
|
114
|
+
export const RECIPE_BACK_HAIR_CHAINS = 2;
|
|
115
|
+
|
|
104
116
|
/**
|
|
105
117
|
* 📐 The default ratio of a path's last link to its first: 0.5, the recipe's
|
|
106
118
|
* own. Measured on the cloth fixture (fixtures/cloth.ts, rig-c 2.47.1, the
|
|
@@ -206,7 +218,7 @@ const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
|
206
218
|
|
|
207
219
|
/** The trees of one piece, in tree order, each trunk before its branches. */
|
|
208
220
|
export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
209
|
-
const n = r.chains;
|
|
221
|
+
const n = r.chains ?? RECIPE_BACK_HAIR_CHAINS;
|
|
210
222
|
const ratio = r.length_ratio ?? RECIPE_LENGTH_RATIO;
|
|
211
223
|
const rows = linkRows(input.rootY, input.tipY, r.links, ratio);
|
|
212
224
|
const at = r.split === undefined ? r.links : r.split.at;
|
|
@@ -260,7 +272,7 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
|
260
272
|
depths: [],
|
|
261
273
|
last: [],
|
|
262
274
|
};
|
|
263
|
-
const edge = r.split?.hold_tip === true ? false : outerSide(k, b);
|
|
275
|
+
const edge = r.split?.hold_tip === true && r.strength === undefined ? false : outerSide(k, b);
|
|
264
276
|
for (let j = trunkLinks; j < r.links; j++) {
|
|
265
277
|
ch.points.push(j === trunkLinks ? trunk.tip : pt(rows[j], fb));
|
|
266
278
|
ch.why.push(j === trunkLinks ? `the fork: the trunk ${trunk.chain}'s tip` : `${rowRule(j)}, ${pyRound(fb, 6)} of the way across ${input.frame} in the 24-row band there (branch ${b} of ${into} of tree ${k})`);
|
package/src/config.ts
CHANGED
|
@@ -256,6 +256,8 @@ export interface ChainBones {
|
|
|
256
256
|
parent: string;
|
|
257
257
|
points: Point[];
|
|
258
258
|
tip: Point;
|
|
259
|
+
/** Expanded link name -> rig-c inherit mode (issue #369); absent links keep normal inheritance. */
|
|
260
|
+
link_inherit?: Record<string, RigBoneInherit>;
|
|
259
261
|
}
|
|
260
262
|
|
|
261
263
|
export type BoneEntry = SingleBone | ChainBones;
|
|
@@ -725,6 +727,8 @@ export interface CharacterConfig {
|
|
|
725
727
|
scene?: SceneSpec;
|
|
726
728
|
/** The cloth and hair recipe `propose --cloth` drafts from (issue #364, `src/cloth.ts`): read by propose only, so no byte of any rig output moves with it. */
|
|
727
729
|
cloth?: ClothRecipes;
|
|
730
|
+
/** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) `propose` drafted the rig sections at (issue #354): absent on a hand-authored config, held equal to `assemble.rig_scale` when present. */
|
|
731
|
+
proposed_rig_scale?: number;
|
|
728
732
|
}
|
|
729
733
|
|
|
730
734
|
/** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
|
|
@@ -1029,9 +1033,11 @@ export interface AssembleConfig {
|
|
|
1029
1033
|
/** Issue #349: read with `bones` (the joints are the bones' origins) when present. */
|
|
1030
1034
|
limbs?: Record<string, Limb>;
|
|
1031
1035
|
bones?: BoneEntry[];
|
|
1036
|
+
/** Issue #354: the `rig_scale` `propose` drafted the rig sections at, held equal to `assemble.rig_scale` when present. */
|
|
1037
|
+
proposed_rig_scale?: number;
|
|
1032
1038
|
}
|
|
1033
1039
|
|
|
1034
|
-
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth'] as const;
|
|
1040
|
+
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth', 'proposed_rig_scale'] as const;
|
|
1035
1041
|
|
|
1036
1042
|
/**
|
|
1037
1043
|
* The one partial entry point. It validates exactly what its door requires —
|
|
@@ -1081,6 +1087,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1081
1087
|
if (door === 'assemble') {
|
|
1082
1088
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1083
1089
|
const made = 'assemble' in t ? checkAssemble(c, t.assemble, door) : { parts: [], patches: [], cuts: [] };
|
|
1090
|
+
checkProposedRigScale(c, t);
|
|
1084
1091
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1085
1092
|
// Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
|
|
1086
1093
|
if ('limbs' in t) {
|
|
@@ -1234,6 +1241,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1234
1241
|
if ('generation' in t) checkGeneration(c, t.generation);
|
|
1235
1242
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1236
1243
|
const { parts, patches, cuts } = 'assemble' in t ? checkAssemble(c, t.assemble, 'full') : { parts: [], patches: [], cuts: [] };
|
|
1244
|
+
checkProposedRigScale(c, t);
|
|
1237
1245
|
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>() };
|
|
1238
1246
|
const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
|
|
1239
1247
|
if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
|
|
@@ -1252,6 +1260,29 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1252
1260
|
return { config: raw as CharacterConfig, animations };
|
|
1253
1261
|
}
|
|
1254
1262
|
|
|
1263
|
+
/**
|
|
1264
|
+
* Issue #354: `propose` records `parts.json`'s `scale_rig_per_source` on the
|
|
1265
|
+
* proposal as `proposed_rig_scale`, and pasting the proposal into `config.json`
|
|
1266
|
+
* carries it. When present, `assemble`, `rig` and `build` compare it against
|
|
1267
|
+
* `assemble.rig_scale` and refuse a mismatch: the bones, mesh spacings, segment
|
|
1268
|
+
* coordinates and motion amplitudes are in the proposed rig canvas's pixels and
|
|
1269
|
+
* do not scale when `assemble.rig_scale` changes. A hand-authored config with
|
|
1270
|
+
* no `proposed_rig_scale` is not refused.
|
|
1271
|
+
*/
|
|
1272
|
+
function checkProposedRigScale(c: Check, t: Record<string, Json>): void {
|
|
1273
|
+
if (!('proposed_rig_scale' in t)) return;
|
|
1274
|
+
if (!c.number('config.proposed_rig_scale', t.proposed_rig_scale, 'positive')) return;
|
|
1275
|
+
const a = isPlainObject(t.assemble) ? t.assemble : null;
|
|
1276
|
+
if (a === null || !('rig_scale' in a) || !(typeof a.rig_scale === 'number' && Number.isFinite(a.rig_scale) && a.rig_scale > 0)) return;
|
|
1277
|
+
if (t.proposed_rig_scale !== a.rig_scale) {
|
|
1278
|
+
c.fail(
|
|
1279
|
+
'CONFIG_PROPOSED_RIG_SCALE',
|
|
1280
|
+
'config.proposed_rig_scale',
|
|
1281
|
+
`is ${t.proposed_rig_scale}, and config.assemble.rig_scale is ${a.rig_scale} — the bones, meshes, limbs and motion were proposed at rig_scale ${t.proposed_rig_scale} and are in that rig canvas's pixels, so they do not scale when assemble.rig_scale changes; re-run assemble and propose at rig_scale ${a.rig_scale} and paste the new proposal, or restore config.assemble.rig_scale to ${t.proposed_rig_scale}`,
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1255
1286
|
function isPlainObject(v: Json): v is Record<string, Json> {
|
|
1256
1287
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
1257
1288
|
}
|
|
@@ -1404,9 +1435,9 @@ export interface SkeletonSections {
|
|
|
1404
1435
|
/**
|
|
1405
1436
|
* A `proposal.json` as `propose` writes it — `bones`, `meshes`, `regions`,
|
|
1406
1437
|
* `motion` and `notes` (strings), `limbs` when `propose --keypoints` drafted
|
|
1407
|
-
* them (issue #349)
|
|
1438
|
+
* them (issue #349), `constraints` when `propose --cloth` did (issue #364;
|
|
1408
1439
|
* both read here since issue #372, whose `wind` example tracks such a
|
|
1409
|
-
* proposal) — under the full loader's own rules for those sections (issue
|
|
1440
|
+
* proposal), and `proposed_rig_scale` (issue #354) — under the full loader's own rules for those sections (issue
|
|
1410
1441
|
* #85). There is no plan to hold the meshes, regions and limbs against, so
|
|
1411
1442
|
* the coverage rule (every plan part exactly one of a mesh or a region) is
|
|
1412
1443
|
* not run, and a limb segment's `from` is held to no plan part (the parts
|
|
@@ -1414,9 +1445,9 @@ export interface SkeletonSections {
|
|
|
1414
1445
|
* both before writing (`checkProposal`). A key the loader would not take is
|
|
1415
1446
|
* refused by name, as in a config. Refusals name the fields `proposal.…`.
|
|
1416
1447
|
*/
|
|
1417
|
-
export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[] } {
|
|
1448
|
+
export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number } {
|
|
1418
1449
|
const c = new Check();
|
|
1419
|
-
const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints', 'stops']);
|
|
1450
|
+
const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints', 'stops', 'proposed_rig_scale']);
|
|
1420
1451
|
if (top !== null) {
|
|
1421
1452
|
const names = 'bones' in top ? checkBones(c, top.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
|
|
1422
1453
|
if ('limbs' in top) {
|
|
@@ -1430,10 +1461,11 @@ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: R
|
|
|
1430
1461
|
if ('motion' in top) checkMotion(c, top.motion, names.bones, names.chains, 'regions' in top ? top.regions : undefined);
|
|
1431
1462
|
if ('constraints' in top) checkConstraints(c, top.constraints, names.bones, names.parents);
|
|
1432
1463
|
if ('stops' in top) checkStops(c, top.stops, names.bones, names.chains);
|
|
1464
|
+
if ('proposed_rig_scale' in top) c.number('config.proposed_rig_scale', top.proposed_rig_scale, 'positive');
|
|
1433
1465
|
if ('notes' in top && c.array('config.notes', top.notes, false)) (top.notes as Json[]).forEach((n, i) => c.string(`config.notes[${i}]`, n, false));
|
|
1434
1466
|
}
|
|
1435
1467
|
refuseIfAny(c.problems.map((p) => ({ ...p, object: p.object.replace(/^config\b/, 'proposal') })));
|
|
1436
|
-
return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[] };
|
|
1468
|
+
return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number };
|
|
1437
1469
|
}
|
|
1438
1470
|
|
|
1439
1471
|
/**
|
|
@@ -1761,7 +1793,7 @@ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string
|
|
|
1761
1793
|
const at = `config.bones[${i}]`;
|
|
1762
1794
|
const isChain = typeof entry === 'object' && entry !== null && 'chain' in entry;
|
|
1763
1795
|
if (isChain) {
|
|
1764
|
-
const b = c.object(at, entry, ['chain', 'parent', 'points', 'tip'], []);
|
|
1796
|
+
const b = c.object(at, entry, ['chain', 'parent', 'points', 'tip'], ['link_inherit']);
|
|
1765
1797
|
if (b === null) return;
|
|
1766
1798
|
if ('parent' in b && c.string(`${at}.parent`, b.parent) && !bones.has(b.parent)) {
|
|
1767
1799
|
c.fail('CONFIG_NAME_RESOLVES', `${at}.parent`, `names "${b.parent}", which is not "${ROOT_BONE}" or a bone declared above it; parents come first`);
|
|
@@ -1779,6 +1811,21 @@ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string
|
|
|
1779
1811
|
else if (typeof b.parent === 'string' && !parents.has(`${name}0`)) parents.set(`${name}0`, b.parent);
|
|
1780
1812
|
});
|
|
1781
1813
|
chains.set(name, points.length);
|
|
1814
|
+
if ('link_inherit' in b) {
|
|
1815
|
+
const path = `${at}.link_inherit`;
|
|
1816
|
+
const links = points.map((_, k) => `${name}${k}`);
|
|
1817
|
+
if (!isPlainObject(b.link_inherit)) {
|
|
1818
|
+
c.fail('CONFIG_FIELD_TYPE', path, `is ${show(b.link_inherit)}; an object mapping this chain's expanded link names to rig-c inherit modes is required`);
|
|
1819
|
+
} else {
|
|
1820
|
+
for (const [link, mode] of Object.entries(b.link_inherit)) {
|
|
1821
|
+
const field = `${path}.${link}`;
|
|
1822
|
+
if (!links.includes(link)) c.fail('CONFIG_NAME_RESOLVES', field, `names "${link}", which is not a link of chain "${name}"; its declared links are ${links.map((n) => `"${n}"`).join(', ')}`);
|
|
1823
|
+
if (c.string(field, mode) && !(RIG_BONE_INHERIT as readonly string[]).includes(mode)) {
|
|
1824
|
+
c.fail('CONFIG_BONE_INHERIT', field, `is ${show(mode)}; one of rig-c's inherit modes, spelled as its table spells them, is required — ${RIG_BONE_INHERIT.join(', ')} (rig-c's RigBone.inherit; nothing here folds a case or maps a name)`);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1782
1829
|
}
|
|
1783
1830
|
return;
|
|
1784
1831
|
}
|
|
@@ -2766,7 +2813,7 @@ function attachedParts(meshes: Json, regions: Json): Set<string> {
|
|
|
2766
2813
|
* - `part` on a tag other than `bottomwear` (`CONFIG_CLOTH_PART_ON_BOTTOMWEAR`):
|
|
2767
2814
|
* the field hands the skirt's recipe to a part of another tag (issue #402),
|
|
2768
2815
|
* and only the skirt has a recipe that part can take; whether the part
|
|
2769
|
-
* exists, and is a `topwear`
|
|
2816
|
+
* exists, and is a `topwear` part or its cut piece hanging below the hip, is read off the assembled parts
|
|
2770
2817
|
* by `propose` (`PROPOSE_CLOTH_PART`);
|
|
2771
2818
|
* - a strength, declared or the recipe's default, whose spring is unstable at
|
|
2772
2819
|
* a damping the recipe runs at — k = strength·massInverse·step² at or past
|
|
@@ -2795,8 +2842,9 @@ function checkCloth(c: Check, v: Json): void {
|
|
|
2795
2842
|
for (const tag of CLOTH_TARGETS) {
|
|
2796
2843
|
if (!(tag in v)) continue;
|
|
2797
2844
|
const path = `${at}.${JSON.stringify(tag)}`;
|
|
2798
|
-
const r = c.object(path, v[tag], ['
|
|
2845
|
+
const r = c.object(path, v[tag], ['links', 'wind', 'gravity'], ['chains', 'split', 'length_ratio', 'strength', 'damping', 'alternate_damping', 'translate', 'swing', 'part']);
|
|
2799
2846
|
if (r === null) continue;
|
|
2847
|
+
if (tag !== 'back hair' && !('chains' in r)) c.fail('CONFIG_FIELD_PRESENT', `${path}.chains`, 'is absent and required');
|
|
2800
2848
|
const chains = 'chains' in r && c.int(`${path}.chains`, r.chains, 1) ? (r.chains as number) : null;
|
|
2801
2849
|
const links = 'links' in r && c.int(`${path}.links`, r.links, 1) ? (r.links as number) : null;
|
|
2802
2850
|
if ('split' in r) {
|
|
@@ -2851,7 +2899,7 @@ function checkCloth(c: Check, v: Json): void {
|
|
|
2851
2899
|
}
|
|
2852
2900
|
if ('part' in r) {
|
|
2853
2901
|
if (tag !== 'bottomwear') {
|
|
2854
|
-
c.fail('CONFIG_CLOTH_PART_ON_BOTTOMWEAR', `${path}.part`, `is declared on "${tag}"; part hands the skirt's recipe (bottomwear) to a topwear part
|
|
2902
|
+
c.fail('CONFIG_CLOTH_PART_ON_BOTTOMWEAR', `${path}.part`, `is declared on "${tag}"; part hands the skirt's recipe (bottomwear) to a topwear part or its cut skirt piece (a dress) — leave it out here`);
|
|
2855
2903
|
} else c.string(`${path}.part`, r.part);
|
|
2856
2904
|
}
|
|
2857
2905
|
if ('swing' in r) {
|