@rig-c/parts 1.24.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 +22 -6
- package/docs/AUTHORING.md +76 -36
- package/docs/MIGRATION.md +1 -1
- package/docs/STABILITY.md +10 -8
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +1 -1
- package/src/assemble.ts +142 -29
- package/src/automesh.ts +2 -1
- package/src/build.ts +23 -9
- package/src/check.ts +227 -44
- package/src/cloth.ts +22 -7
- package/src/config.ts +161 -12
- package/src/headbox.ts +68 -34
- package/src/limbs.ts +171 -0
- package/src/propose.ts +301 -49
- package/src/rig.ts +186 -18
- package/src/scene.ts +87 -7
- package/src/squeeze.ts +161 -1
package/src/check.ts
CHANGED
|
@@ -155,7 +155,8 @@ import {
|
|
|
155
155
|
type SeamPair,
|
|
156
156
|
stretchRequirementLine,
|
|
157
157
|
} from './requirements.ts';
|
|
158
|
-
import { RIG_BONE_INHERIT } from 'rig-c/src/rig.ts';
|
|
158
|
+
import { RIG_BONE_INHERIT, type RigSpec as RigcRigSpec } from 'rig-c/src/rig.ts';
|
|
159
|
+
import { expandStops } from 'rig-c/src/stop.ts';
|
|
159
160
|
import { alphaComposite } from './raster/composite.ts';
|
|
160
161
|
import { readPng, writePng } from './raster/png.ts';
|
|
161
162
|
import { newFloatImage, newRaster, type Raster } from './raster/types.ts';
|
|
@@ -1068,9 +1069,11 @@ export function withoutPhysics(rig: Record<string, unknown>, motion: Record<stri
|
|
|
1068
1069
|
const table = isRecord(motion.physics) ? motion.physics : null;
|
|
1069
1070
|
if (table !== null) for (const [name, e] of Object.entries(table)) physics.push({ name, bone: isRecord(e) ? String(e.bone) : '', in: 'motion.json' });
|
|
1070
1071
|
if (physics.length === 0) return null;
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
|
|
1072
|
+
const expanded = Array.isArray(rig.stops) ? (expandStops(rig as unknown as RigcRigSpec, 'rig.json') as unknown as Record<string, unknown>) : rig;
|
|
1073
|
+
const r: Record<string, unknown> = { ...expanded };
|
|
1074
|
+
delete r.stops;
|
|
1075
|
+
if (Array.isArray(r.constraints)) {
|
|
1076
|
+
const kept = r.constraints.filter((c) => !(isRecord(c) && c.type === 'physics'));
|
|
1074
1077
|
if (kept.length > 0) r.constraints = kept;
|
|
1075
1078
|
else delete r.constraints;
|
|
1076
1079
|
}
|
|
@@ -2014,12 +2017,24 @@ export const FOLD_RENDER_MAX = 16;
|
|
|
2014
2017
|
/** The fold render, under `--out`; removed before the stage returns. */
|
|
2015
2018
|
const FOLD_SCRATCH = '_folds';
|
|
2016
2019
|
|
|
2017
|
-
/** 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. */
|
|
2018
2033
|
export interface FoldRow {
|
|
2019
2034
|
animation: string;
|
|
2020
2035
|
slot: string;
|
|
2021
2036
|
attachment: string;
|
|
2022
|
-
/** 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). */
|
|
2023
2038
|
frames: number;
|
|
2024
2039
|
/** The frame with the most, the earliest on a tie (rigc's `worst`). */
|
|
2025
2040
|
worst_frame: number;
|
|
@@ -2027,6 +2042,12 @@ export interface FoldRow {
|
|
|
2027
2042
|
worst_triangles: number;
|
|
2028
2043
|
/** How many triangles the mesh has. */
|
|
2029
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[];
|
|
2030
2051
|
/** The `may_fold` entry's why when the requirements file declares this slot and animation; null when nothing does. */
|
|
2031
2052
|
declared: string | null;
|
|
2032
2053
|
/** rigc's printed line for the mesh, verbatim (`rigc render --folds`). */
|
|
@@ -2039,7 +2060,7 @@ export interface FoldsLine {
|
|
|
2039
2060
|
/** Every animation, the idle included, in motion.json's order: each one read. */
|
|
2040
2061
|
animations: string[];
|
|
2041
2062
|
fps: number;
|
|
2042
|
-
/** 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. */
|
|
2043
2064
|
physics_left_out: number;
|
|
2044
2065
|
/** Every mesh that folds in an animation read, in that animation's order and rigc's mesh order. */
|
|
2045
2066
|
folding: FoldRow[];
|
|
@@ -2127,26 +2148,118 @@ export function readFoldSets(dir: string, named: readonly string[], fps: number,
|
|
|
2127
2148
|
}
|
|
2128
2149
|
|
|
2129
2150
|
/**
|
|
2130
|
-
* The
|
|
2131
|
-
*
|
|
2132
|
-
*
|
|
2133
|
-
|
|
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>"`.
|
|
2154
|
+
*/
|
|
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.
|
|
2134
2194
|
*/
|
|
2135
|
-
export function foldsLine(
|
|
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 };
|
|
2136
2209
|
const folding: FoldRow[] = [];
|
|
2137
2210
|
for (const name of named) {
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
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
|
+
});
|
|
2141
2243
|
}
|
|
2142
2244
|
}
|
|
2143
|
-
const physics =
|
|
2245
|
+
const physics =
|
|
2246
|
+
physicsLeftOut === 0
|
|
2247
|
+
? ''
|
|
2248
|
+
: `, with its ${physicsLeftOut} physics constraint(s) on and off`;
|
|
2144
2249
|
for (const r of folding) {
|
|
2145
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
|
+
: '';
|
|
2146
2259
|
problems.push({
|
|
2147
2260
|
code: `CHECK_${FOLD_LINE}`,
|
|
2148
2261
|
object: `animation "${r.animation}", mesh "${r.attachment}" (slot "${r.slot}")`,
|
|
2149
|
-
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": … }`,
|
|
2150
2263
|
});
|
|
2151
2264
|
}
|
|
2152
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 }));
|
|
@@ -3098,26 +3211,46 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
3098
3211
|
written.push('motion_heat.png');
|
|
3099
3212
|
}
|
|
3100
3213
|
|
|
3101
|
-
// 3a. every animation, the idle included, read for folds by rigc (issues #320, #333) — only when there is one; at
|
|
3102
|
-
// 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
|
|
3103
3216
|
if (animationNames.length > 0) {
|
|
3104
3217
|
const fps = IDLE_FPS;
|
|
3105
3218
|
try {
|
|
3106
|
-
// 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.
|
|
3107
3220
|
const geometry = feet.length > 0 ? ['--geometry'] : [];
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
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
|
+
}
|
|
3121
3254
|
}
|
|
3122
3255
|
} finally {
|
|
3123
3256
|
rmSync(foldDir, { recursive: true, force: true });
|
|
@@ -4063,12 +4196,17 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
4063
4196
|
} else if (!ok) {
|
|
4064
4197
|
failed = true;
|
|
4065
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.
|
|
4066
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 });
|
|
4067
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;
|
|
4068
4204
|
const why =
|
|
4069
4205
|
carry === null
|
|
4070
|
-
?
|
|
4071
|
-
:
|
|
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`;
|
|
4072
4210
|
problems.push({
|
|
4073
4211
|
code: 'CHECK_TIP_OVER_ROOT',
|
|
4074
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' : ''}`,
|
|
@@ -4085,6 +4223,52 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
4085
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 }) };
|
|
4086
4224
|
}
|
|
4087
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
|
+
|
|
4088
4272
|
/**
|
|
4089
4273
|
* The bones a part's attachments are bound to in the default skin (issue #231): a mesh's weights, or its slot's
|
|
4090
4274
|
* bone for an attachment that carries no weights.
|
|
@@ -4106,17 +4290,16 @@ function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
|
|
|
4106
4290
|
const BREATH_PROPS: readonly string[] = ['translate', 'translatex', 'translatey'];
|
|
4107
4291
|
|
|
4108
4292
|
/**
|
|
4109
|
-
* 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
|
|
4110
4294
|
* track that translates a bone ({@link BREATH_PROPS}), each read on its own: `K` the bone it keys. Its motion is
|
|
4111
4295
|
* `K`'s world origin less where `K`'s parent carries `K`'s setup position, frame by frame — what the translate keys
|
|
4112
4296
|
* add — and the breath's travel is that, less its frame-0 value, at its largest. The part (one weighted mesh; a
|
|
4113
4297
|
* region is carried whole and cannot stretch) is posed again with that motion taken out of every vertex in
|
|
4114
4298
|
* proportion to its weight on `K` and the bones under it, and its halves measured as {@link halfTravels} measures
|
|
4115
|
-
* them. The root travels with the breath when the breath adds more to the root's travel than to the tip's and
|
|
4116
|
-
*
|
|
4117
|
-
* (
|
|
4118
|
-
*
|
|
4119
|
-
* 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.
|
|
4120
4303
|
*/
|
|
4121
4304
|
function breathCarrier(
|
|
4122
4305
|
rig: Record<string, unknown>,
|
|
@@ -4125,7 +4308,7 @@ function breathCarrier(
|
|
|
4125
4308
|
geometryPath: string,
|
|
4126
4309
|
frames: number,
|
|
4127
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 },
|
|
4128
|
-
): { 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 {
|
|
4129
4312
|
const anims = isRecord(motion.animations) ? motion.animations : {};
|
|
4130
4313
|
const idle = isRecord(anims.idle) ? anims.idle : {};
|
|
4131
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)))];
|
|
@@ -4183,8 +4366,8 @@ function breathCarrier(
|
|
|
4183
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]) }));
|
|
4184
4367
|
const t = halfTravels(m.art, m.box, m.stage, held, m.toward);
|
|
4185
4368
|
if (t.root === null || t.tip === null) continue;
|
|
4186
|
-
if (!(m.root - t.root > m.tip - t.tip) || round3(t.tip) === 0
|
|
4187
|
-
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 };
|
|
4188
4371
|
}
|
|
4189
4372
|
return null;
|
|
4190
4373
|
}
|
package/src/cloth.ts
CHANGED
|
@@ -48,6 +48,9 @@
|
|
|
48
48
|
* hip, by the skirt's own rule, in place of the robe chains the topwear rule
|
|
49
49
|
* drafts; its art above the hip stays on the chest. The proposer never picks
|
|
50
50
|
* such a part by itself: it names a candidate, and the author declares it.
|
|
51
|
+
* A `bottomwear` part lying on that part's tree links (a hem returned as a
|
|
52
|
+
* layer of its own) rides them, as a layer on a tail does ({@link tailOverlap}),
|
|
53
|
+
* and gets no trees of its own (issue #416): one skirt, one set of trees.
|
|
51
54
|
* - **Swing** (`bottomwear` only). `swing.scales` declares one shared pendulum:
|
|
52
55
|
* a bone `<prefix>swing` under the attachment at the centre of the root row,
|
|
53
56
|
* one link long, with physics; each tree's first link takes the swing's
|
|
@@ -77,11 +80,11 @@ export interface ClothStrength {
|
|
|
77
80
|
edge: number;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
/** 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). */
|
|
81
84
|
export interface ClothRecipe {
|
|
82
|
-
chains
|
|
85
|
+
chains?: number;
|
|
83
86
|
links: number;
|
|
84
|
-
split?: { at: number; into: number };
|
|
87
|
+
split?: { at: number; into: number; hold_tip?: boolean };
|
|
85
88
|
length_ratio?: number;
|
|
86
89
|
strength?: ClothStrength;
|
|
87
90
|
damping?: number;
|
|
@@ -92,12 +95,24 @@ export interface ClothRecipe {
|
|
|
92
95
|
gravity: [number, number];
|
|
93
96
|
translate?: number;
|
|
94
97
|
swing?: { scales: number[] };
|
|
95
|
-
/** `bottomwear` only
|
|
98
|
+
/** `bottomwear` only: a `topwear` part or its named cut skirt piece the skirt's trees are drafted on. */
|
|
96
99
|
part?: string;
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
|
|
100
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
|
+
|
|
101
116
|
/**
|
|
102
117
|
* 📐 The default ratio of a path's last link to its first: 0.5, the recipe's
|
|
103
118
|
* own. Measured on the cloth fixture (fixtures/cloth.ts, rig-c 2.47.1, the
|
|
@@ -203,7 +218,7 @@ const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
|
203
218
|
|
|
204
219
|
/** The trees of one piece, in tree order, each trunk before its branches. */
|
|
205
220
|
export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
206
|
-
const n = r.chains;
|
|
221
|
+
const n = r.chains ?? RECIPE_BACK_HAIR_CHAINS;
|
|
207
222
|
const ratio = r.length_ratio ?? RECIPE_LENGTH_RATIO;
|
|
208
223
|
const rows = linkRows(input.rootY, input.tipY, r.links, ratio);
|
|
209
224
|
const at = r.split === undefined ? r.links : r.split.at;
|
|
@@ -257,7 +272,7 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
|
257
272
|
depths: [],
|
|
258
273
|
last: [],
|
|
259
274
|
};
|
|
260
|
-
const edge = outerSide(k, b);
|
|
275
|
+
const edge = r.split?.hold_tip === true && r.strength === undefined ? false : outerSide(k, b);
|
|
261
276
|
for (let j = trunkLinks; j < r.links; j++) {
|
|
262
277
|
ch.points.push(j === trunkLinks ? trunk.tip : pt(rows[j], fb));
|
|
263
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})`);
|
|
@@ -383,7 +398,7 @@ export function clothNote(part: string, tag: ClothTarget, trees: readonly TreeCh
|
|
|
383
398
|
const defaults: string[] = [];
|
|
384
399
|
if (r.length_ratio === undefined) defaults.push(`length_ratio ${RECIPE_LENGTH_RATIO}`);
|
|
385
400
|
if (r.strength === undefined) defaults.push(`strength ${RECIPE_STRENGTH.trunk}/${RECIPE_STRENGTH.branch}/${RECIPE_STRENGTH.edge}`);
|
|
386
|
-
const shape = r.split === undefined ? `${r.links} link(s) each` : `a ${r.split.at}-link trunk forking into ${r.split.into} branches, ${r.links} links root to tip`;
|
|
401
|
+
const shape = r.split === undefined ? `${r.links} link(s) each` : `a ${r.split.at}-link trunk forking into ${r.split.into} branches${r.split.hold_tip === true ? ' (tip held)' : ''}, ${r.links} links root to tip`;
|
|
387
402
|
return (
|
|
388
403
|
`${part} (${via === undefined ? tag : `${via}, which config.cloth."${tag}".part names`}): config.cloth."${tag}" -> ${r.chains} tree(s)${extra}, ${shape} (${trees.map((t) => t.chain).join(', ')}); ` +
|
|
389
404
|
`${constraints} constraint(s), physics on every link, wind ${r.wind[0]} -> ${r.wind[1]} and gravity ${r.gravity[0]} -> ${r.gravity[1]} root to tip (multipliers of the consumer's environment; no direction is drafted), no idle key` +
|