@rig-c/parts 1.27.0 → 1.28.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/docs/AUTHORING.md +1 -0
- package/docs/STABILITY.md +2 -1
- package/package.json +1 -1
- package/src/check.ts +23 -5
- package/src/config.ts +31 -2
- package/src/coords.ts +1 -1
- package/src/motion.ts +15 -5
- package/src/rig.ts +13 -2
- package/src/turn.ts +665 -1
package/docs/AUTHORING.md
CHANGED
|
@@ -2637,6 +2637,7 @@ See-through with another seed.
|
|
|
2637
2637
|
| <a id="CONFIG_CLOTH_PARTS_UNIQUE"></a>`CONFIG_CLOTH_PARTS_UNIQUE` | a `cloth` recipe sets both `part` and `parts`, or its part list names the same part more than once (issue #437) | set at most one of `part` or `parts`, and list each part once in root-to-tip order |
|
|
2638
2638
|
| <a id="CONFIG_CLOTH_SPRING_STABLE"></a>`CONFIG_CLOTH_SPRING_STABLE` | a `cloth` recipe's strength — declared, or the default 100 / 100 / 50 — whose spring is unstable at a damping the recipe runs at (`damping`, `alternate_damping`, or rig-c's 0.85 when `damping` is left out): k = strength·massInverse·step² at or past 2(1 + damping^(60·step)), step 1/60 and mass 1 (rig-c's defaults; the recipe sets neither). The detail gives k and the bound | a lower strength, or a higher damping |
|
|
2639
2639
|
| <a id="CONFIG_TURN_BONE_FREE"></a>`CONFIG_TURN_BONE_FREE` | the config has `turn` and `bones` declares a bone named `turn`, the control the rig stage adds | rename that bone |
|
|
2640
|
+
| <a id="CONFIG_TURN_DEFORM_RANGE"></a>`CONFIG_TURN_DEFORM_RANGE` | an entry in `turn.deform` lies outside its allowed `[min, max]` interval in `FACE_DEFORM_RANGES` (`src/turn.ts`, issue #436); the detail gives the value found, the interval required and the default fraction | put that `turn.deform.<key>` inside the stated range, or remove it to take the Set B ×2.5 default |
|
|
2640
2641
|
| <a id="CONFIG_PROPOSED_RIG_SCALE"></a>`CONFIG_PROPOSED_RIG_SCALE` | `proposed_rig_scale` (the `scale_rig_per_source` `propose` drafted the rig sections at, issue #354) differs from `assemble.rig_scale`: the coordinates in `bones`, `meshes`, `limbs` and `motion` are in that rig canvas's pixels and do not scale when `assemble.rig_scale` changes | re-run `assemble` and `propose` at the new `assemble.rig_scale`, or restore `assemble.rig_scale` to `proposed_rig_scale` |
|
|
2641
2642
|
|
|
2642
2643
|
### inputs
|
package/docs/STABILITY.md
CHANGED
|
@@ -160,8 +160,9 @@ part name, `[i]` an entry of a list):
|
|
|
160
160
|
- **motion.blink** — required: `t`, `eyes`, `squash`; optional: `brows`, `brow_drop`, `still`
|
|
161
161
|
- **motion.blink.still.<part>** — required: `row`, `bone`
|
|
162
162
|
- **motion.animations_from**, the file it names — required: `spec`, `animations`
|
|
163
|
-
- **turn** — required: `pivot`, `depths`
|
|
163
|
+
- **turn** — required: `pivot`, `depths`; optional: `deform`
|
|
164
164
|
- **turn.depths.<part>** — a See-through depth
|
|
165
|
+
- **turn.deform.<key>** — a per-degree fraction or coupling share (FACE_DEFORM_FRACTIONS, issue #436)
|
|
165
166
|
- **scene** — no field (`{}`; a record or an annotation only)
|
|
166
167
|
- **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `limbs` under `--keypoints` where an arm or a leg is drafted from its joints (issue #349), `stops` when supplied, `motion` under the rules above, `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes`
|
|
167
168
|
<!-- /stable:config -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.0",
|
|
4
4
|
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/check.ts
CHANGED
|
@@ -1045,6 +1045,21 @@ export interface LoopPhysics {
|
|
|
1045
1045
|
in: 'rig.json' | 'motion.json';
|
|
1046
1046
|
}
|
|
1047
1047
|
|
|
1048
|
+
/**
|
|
1049
|
+
* A throwaway copy of `rig` whose accompanying `motion` replaces `animations`
|
|
1050
|
+
* with a synthetic set (`still`, `blink_shut`, `stress ...`, `sweep ...`) omits
|
|
1051
|
+
* any `slider` constraint whose target animation is not in that set.
|
|
1052
|
+
*/
|
|
1053
|
+
function withoutSliders(rig: Record<string, unknown>, animations: Record<string, unknown>): Record<string, unknown> {
|
|
1054
|
+
if (!Array.isArray(rig.constraints)) return rig;
|
|
1055
|
+
const kept = rig.constraints.filter((c) => !(isRecord(c) && c.type === 'slider' && typeof c.animation === 'string' && !(c.animation in animations)));
|
|
1056
|
+
if (kept.length === rig.constraints.length) return rig;
|
|
1057
|
+
const out: Record<string, unknown> = { ...rig };
|
|
1058
|
+
if (kept.length > 0) out.constraints = kept;
|
|
1059
|
+
else delete out.constraints;
|
|
1060
|
+
return out;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1048
1063
|
/**
|
|
1049
1064
|
* The rig spec and motion spec with every physics constraint left out, and
|
|
1050
1065
|
* the constraints it left out; null when there is none, so a rig without one
|
|
@@ -3331,8 +3346,9 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
3331
3346
|
mkdirSync(stressDir, { recursive: true });
|
|
3332
3347
|
const base = stripped === null ? inp.rig : stripped.rig;
|
|
3333
3348
|
const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
|
|
3334
|
-
|
|
3335
|
-
writeFileSync(join(stressDir, '
|
|
3349
|
+
const stressAnims = stressAnimations(plan);
|
|
3350
|
+
writeFileSync(join(stressDir, 'rig.json'), JSON.stringify({ ...withoutSliders(base, stressAnims), images }));
|
|
3351
|
+
writeFileSync(join(stressDir, 'motion.json'), JSON.stringify({ ...(stripped === null ? inp.motion : stripped.motion), animations: stressAnims }));
|
|
3336
3352
|
const sb = rigc(['build', '--rig', join(stressDir, 'rig.json'), '--motion', join(stressDir, 'motion.json'), '--out', join(stressDir, 'build'), '--profile', 'spine']);
|
|
3337
3353
|
if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${STRESS_LINE}'s turned copy)`, sb, buildGateLines(sb.out)));
|
|
3338
3354
|
const sr = rigc(['render', '--candidate', join(stressDir, 'build'), '--fps', String(IDLE_FPS), '--max', String(FOLD_RENDER_MAX), '--geometry', '--out', join(stressDir, 'render')]);
|
|
@@ -3380,7 +3396,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
3380
3396
|
const sets = new Map<string, { under: FrameSet; bare: FrameSet }>();
|
|
3381
3397
|
if (copy.slots.length > 0) {
|
|
3382
3398
|
for (const [file, image] of copy.images) writePng(join(imagesDir, file), image);
|
|
3383
|
-
|
|
3399
|
+
const sweepAnims = isRecord(copy.motion.animations) ? copy.motion.animations : {};
|
|
3400
|
+
writeFileSync(join(sweepDir, 'rig.json'), JSON.stringify({ ...withoutSliders(copy.rig, sweepAnims), images: imagesDir }));
|
|
3384
3401
|
writeFileSync(join(sweepDir, 'motion.json'), JSON.stringify(copy.motion));
|
|
3385
3402
|
const sb = rigc(['build', '--rig', join(sweepDir, 'rig.json'), '--motion', join(sweepDir, 'motion.json'), '--out', join(sweepDir, 'build'), '--profile', 'spine']);
|
|
3386
3403
|
if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${SWEEP_LINE}'s bent copy)`, sb, buildGateLines(sb.out)));
|
|
@@ -3423,7 +3440,6 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
3423
3440
|
try {
|
|
3424
3441
|
mkdirSync(stillDir, { recursive: true });
|
|
3425
3442
|
const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
|
|
3426
|
-
writeFileSync(join(stillDir, 'rig.json'), JSON.stringify({ ...inp.rig, images }));
|
|
3427
3443
|
const eyes = full === null ? [] : partsTagged(full.parts, EYE_TAGS);
|
|
3428
3444
|
const slotBone = new Map((Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => [String(sl.name), String(sl.bone)]));
|
|
3429
3445
|
const shut = full === null ? [] : blinkTracks(inp.motion, eyeBones(inp.rig, eyes.map((p) => slotBone.get(p.name) ?? '')));
|
|
@@ -3437,6 +3453,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
3437
3453
|
tracks: shut.map((b) => ({ ...b.target, property: 'scaley', keys: [{ t: 0, v: [b.closed] }, { t: STILL_DURATION, v: [b.closed] }] })),
|
|
3438
3454
|
};
|
|
3439
3455
|
}
|
|
3456
|
+
const stillRig = { ...withoutSliders(inp.rig, animations), images };
|
|
3457
|
+
writeFileSync(join(stillDir, 'rig.json'), JSON.stringify(stillRig));
|
|
3440
3458
|
writeFileSync(join(stillDir, 'motion.json'), JSON.stringify({ ...inp.motion, animations }));
|
|
3441
3459
|
const sb = rigc(['build', '--rig', join(stillDir, 'rig.json'), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'build'), '--profile', 'spine']);
|
|
3442
3460
|
if (sb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still)', sb, buildGateLines(sb.out)));
|
|
@@ -3460,7 +3478,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
3460
3478
|
}
|
|
3461
3479
|
if (inp.source !== null) {
|
|
3462
3480
|
// The same pose with every slot tinted black, built with the same animations so rigc fits the same grid.
|
|
3463
|
-
writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig(
|
|
3481
|
+
writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig(stillRig)));
|
|
3464
3482
|
const kb = rigc(['build', '--rig', join(stillDir, BLACK_RIG), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'black_build'), '--profile', 'spine']);
|
|
3465
3483
|
if (kb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still, every slot tinted black)', kb, buildGateLines(kb.out)));
|
|
3466
3484
|
const kr = rigc(['render', '--candidate', join(stillDir, 'black_build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'black')]);
|
package/src/config.ts
CHANGED
|
@@ -93,7 +93,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
|
|
|
93
93
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
94
94
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
95
95
|
import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
|
|
96
|
-
import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
96
|
+
import { FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
97
97
|
import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
|
|
98
98
|
|
|
99
99
|
export type Point = [number, number];
|
|
@@ -2975,7 +2975,7 @@ function checkScene(c: Check, v: Json): void {
|
|
|
2975
2975
|
|
|
2976
2976
|
function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>): void {
|
|
2977
2977
|
const at = 'config.turn';
|
|
2978
|
-
const o = c.object(at, v, ['pivot', 'depths'], []);
|
|
2978
|
+
const o = c.object(at, v, ['pivot', 'depths'], ['deform']);
|
|
2979
2979
|
if (o === null) return;
|
|
2980
2980
|
const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
|
|
2981
2981
|
const depths: Record<string, number> = {};
|
|
@@ -2996,6 +2996,35 @@ function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>)
|
|
|
2996
2996
|
}
|
|
2997
2997
|
}
|
|
2998
2998
|
}
|
|
2999
|
+
if ('deform' in o) {
|
|
3000
|
+
const df = o.deform;
|
|
3001
|
+
if (!isPlainObject(df)) {
|
|
3002
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.deform`, `is ${show(df)}; an object of face deform key -> per-degree fraction is required`);
|
|
3003
|
+
} else {
|
|
3004
|
+
const known = Object.keys(FACE_DEFORM_FRACTIONS);
|
|
3005
|
+
for (const [key, val] of Object.entries(df)) {
|
|
3006
|
+
if (isRecordKey(key)) continue;
|
|
3007
|
+
if (key === 'note' || key.endsWith('_note')) {
|
|
3008
|
+
if (typeof val !== 'string') c.fail('CONFIG_FIELD_TYPE', `${at}.deform.${key}`, `is ${show(val)}; an annotation is a string`);
|
|
3009
|
+
continue;
|
|
3010
|
+
}
|
|
3011
|
+
if (!Object.hasOwn(FACE_DEFORM_FRACTIONS, key)) {
|
|
3012
|
+
c.fail('CONFIG_KEY_KNOWN', `${at}.deform.${key}`, `is not a known face deform fraction; expected one of ${known.join(', ')} — ${DOORS}`);
|
|
3013
|
+
continue;
|
|
3014
|
+
}
|
|
3015
|
+
if (c.number(`${at}.deform.${key}`, val)) {
|
|
3016
|
+
const [lo, hi] = FACE_DEFORM_RANGES[key as FaceDeformKey];
|
|
3017
|
+
if (val < lo || val > hi) {
|
|
3018
|
+
c.fail(
|
|
3019
|
+
'CONFIG_TURN_DEFORM_RANGE',
|
|
3020
|
+
`${at}.deform.${key}`,
|
|
3021
|
+
`is ${val}; a per-degree fraction in [${lo}, ${hi}] is required (default ${FACE_DEFORM_FRACTIONS[key as FaceDeformKey]})`,
|
|
3022
|
+
);
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
2999
3028
|
if (pivot !== null && depthsOk) {
|
|
3000
3029
|
const nearest = turnNearest(depths);
|
|
3001
3030
|
if (nearest !== null && !(pivot > nearest.depth)) {
|
package/src/coords.ts
CHANGED
|
@@ -22,5 +22,5 @@
|
|
|
22
22
|
* `flattenRig` goes back through `toWorld`. No rotation matrix is written out
|
|
23
23
|
* by hand in this package.
|
|
24
24
|
*/
|
|
25
|
-
export { computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from 'rig-c/src/transform.ts';
|
|
25
|
+
export { computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toBoneLocalVector, toWorld } from 'rig-c/src/transform.ts';
|
|
26
26
|
export type { BoneTransform } from 'rig-c/src/transform.ts';
|
package/src/motion.ts
CHANGED
|
@@ -145,16 +145,26 @@ export interface MotionSpec {
|
|
|
145
145
|
animations: { idle: { duration: number; loop: true; note: string; tracks: MotionTrack[] } };
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
const FACE_DEFORM_HOOK = new WeakMap<MotionSpec, (merged: MotionSpec & { animations: Record<string, unknown> }) => void>();
|
|
149
|
+
|
|
150
|
+
/** Attach a face-surface-deform hook to `motion` so {@link withAnimations} applies it when `turn`/`tilt` slider animations are merged (issue #436). */
|
|
151
|
+
export function bindFaceSurfaceDeform(motion: MotionSpec, hook: (merged: MotionSpec & { animations: Record<string, unknown> }) => void): void {
|
|
152
|
+
FACE_DEFORM_HOOK.set(motion, hook);
|
|
153
|
+
}
|
|
154
|
+
|
|
148
155
|
/**
|
|
149
156
|
* The motion `motion.json` holds: the idle, and after it every animation the
|
|
150
|
-
* file `config.motion.animations_from` names, as read (issue #183) —
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* bytes it wrote before the field existed.
|
|
157
|
+
* file `config.motion.animations_from` names, as read (issue #183) — plus, when
|
|
158
|
+
* those animations hold `turn` or `tilt` slider `derive` keys, the Set B ×2.5
|
|
159
|
+
* face surface deform (issue #436). With none, `motion` itself, so a config
|
|
160
|
+
* that names no file writes the bytes it wrote before the field existed.
|
|
154
161
|
*/
|
|
155
162
|
export function withAnimations(motion: MotionSpec, extra: Readonly<Record<string, unknown>> | null): MotionSpec | Record<string, unknown> {
|
|
156
163
|
if (extra === null) return motion;
|
|
157
|
-
|
|
164
|
+
const merged = { ...motion, animations: { ...motion.animations, ...extra } };
|
|
165
|
+
const hook = FACE_DEFORM_HOOK.get(motion);
|
|
166
|
+
if (hook !== undefined) hook(merged);
|
|
167
|
+
return merged;
|
|
158
168
|
}
|
|
159
169
|
|
|
160
170
|
/** One sine channel over an idle of `duration` seconds. */
|
package/src/rig.ts
CHANGED
|
@@ -165,13 +165,13 @@ import {
|
|
|
165
165
|
import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
|
|
166
166
|
import { type LocalInfluence, localInfluences } from './localweights.ts';
|
|
167
167
|
import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
|
|
168
|
-
import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
168
|
+
import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
169
169
|
import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
|
|
170
170
|
import { EYE_GROUP_TAGS } from './propose.ts';
|
|
171
171
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
172
172
|
import { pyRound } from './round.ts';
|
|
173
173
|
import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
174
|
-
import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
174
|
+
import { applyFaceSurfaceDeform, TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
175
175
|
import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
|
|
176
176
|
|
|
177
177
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
@@ -1996,6 +1996,17 @@ export function buildRig(
|
|
|
1996
1996
|
// issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
|
|
1997
1997
|
const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
|
|
1998
1998
|
if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
|
|
1999
|
+
const faceImg = outImages.find(([k]) => k === 'face.png')?.[1];
|
|
2000
|
+
const facePart = parts.parts.find((p) => p.name === 'face');
|
|
2001
|
+
bindFaceSurfaceDeform(motion, (merged) => {
|
|
2002
|
+
applyFaceSurfaceDeform(rig, merged, {
|
|
2003
|
+
imageWidth: faceImg?.width,
|
|
2004
|
+
imageHeight: faceImg?.height,
|
|
2005
|
+
opaqueWidth: facePart?.w,
|
|
2006
|
+
opaqueHeight: facePart?.h,
|
|
2007
|
+
overrides: cfg.turn?.deform,
|
|
2008
|
+
});
|
|
2009
|
+
});
|
|
1999
2010
|
return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
|
|
2000
2011
|
}
|
|
2001
2012
|
|
package/src/turn.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* that the depth maps do not carry relief inside a layer, so a part moves as
|
|
29
29
|
* one piece. Pure: no file, no clock.
|
|
30
30
|
*/
|
|
31
|
+
import { computeWorldTransforms, toBoneLocalVector, toWorld, type BoneTransform } from './coords.ts';
|
|
31
32
|
import { pyRound } from './round.ts';
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -67,10 +68,108 @@ export const TURN_BONE = 'turn';
|
|
|
67
68
|
/** The places a pivot and a fraction are written to. */
|
|
68
69
|
export const TURN_PLACES = 6;
|
|
69
70
|
|
|
70
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* The Set B ×2.5 face surface deformation table (issue #436): per-degree
|
|
73
|
+
* fractions of the face plate's opaque width `W` and height `H` (and the
|
|
74
|
+
* dimensionless coupling shares of the hair, ear and topknot parts that sample
|
|
75
|
+
* the same head field), with the sheet's measurements on a `106×128` plate at
|
|
76
|
+
* `+15°` beside each entry.
|
|
77
|
+
*/
|
|
78
|
+
export const FACE_DEFORM_FRACTIONS = {
|
|
79
|
+
/** Mid-face vertical shift per degree of pitch (`H/deg`; `-9.02 px` at `+15°` on `H = 128`). */
|
|
80
|
+
tilt_mid_y: -0.0047,
|
|
81
|
+
/** Lower-face chin vertical squash per degree of pitch (`H/deg`; `+5.04 px` squash, chin tip `-3.98 px` at `+15°` on `H = 128`). */
|
|
82
|
+
tilt_chin_squash_y: 0.002625,
|
|
83
|
+
/** Forehead/crown extra drop per degree of pitch (`H/deg`; crown `-11.28 px` at `+15°` on `H = 128`). */
|
|
84
|
+
tilt_crown_drop_y: -0.001175,
|
|
85
|
+
/** Forehead/jaw perspective horizontal taper per degree of pitch (`W/deg`; `±4.77 px` at `+15°` on `W = 106`). */
|
|
86
|
+
tilt_taper_x: 0.003,
|
|
87
|
+
/** Mid-face and chin horizontal shift per degree of yaw (`W/deg`; `-9.54 px` at `+15°` on `W = 106`). */
|
|
88
|
+
turn_mid_x: -0.006,
|
|
89
|
+
/** Far (leading) cheek inward compression per degree of yaw (`W/deg`; far cheek `+3.75 px` inward at `+15°` on `W = 106`). */
|
|
90
|
+
turn_lead_compress_x: 0.00835,
|
|
91
|
+
/** Near (trailing) cheek shift beyond mid-face per degree of yaw (`W/deg`; near cheek `-10.50 px` at `+15°` on `W = 106`, total narrowing `-14.25 px` = `-13.4% W`). */
|
|
92
|
+
turn_trail_shift_x: -0.0006,
|
|
93
|
+
/** Cheek-arch vertical lift/drop per degree of yaw (`H/deg`; `±1.20 px` at `+15°` on `H = 128`). */
|
|
94
|
+
turn_cheek_arch_y: 0.000625,
|
|
95
|
+
/** Forehead bang base vertical travel per degree of pitch (`H/deg`; `-3.00 px` at `+15°` on `H = 128`). */
|
|
96
|
+
tilt_bang_base_y: -0.0015625,
|
|
97
|
+
/** Share of the pitch vertical surface field carried by `hair_front` (`0.95×` of `dySurf`). */
|
|
98
|
+
tilt_hair_front_y: 0.95,
|
|
99
|
+
/** Share of the pitch vertical surface field carried by lower `hair_back` (`0.85×` of `dySurf`). */
|
|
100
|
+
tilt_hair_back_lower_y: 0.85,
|
|
101
|
+
/** Share of the pitch vertical surface field carried by upper `hair_back` (`0.45×` of `dySurf`). */
|
|
102
|
+
tilt_hair_back_bun_y: 0.45,
|
|
103
|
+
/** Share of the pitch horizontal taper carried at the top of `hair_back` (`0.60×` of `dxSurf`). */
|
|
104
|
+
tilt_hair_back_bun_x: 0.6,
|
|
105
|
+
/** Share of the pitch vertical surface field carried by `ears` (`0.55×` of `dySurf`, `-4.31 px` at `+15°`). */
|
|
106
|
+
tilt_ear_y: 0.55,
|
|
107
|
+
/** Share of the crown pitch vertical field carried by `topknot` (`0.50×` of crown `dySurf`, `-5.64 px` at `+15°`). */
|
|
108
|
+
tilt_topknot_y: 0.5,
|
|
109
|
+
/** Forehead bang forward parallax per degree of yaw (`W/deg`; `-3.50 px` at `+15°` on `W = 106`). */
|
|
110
|
+
turn_bang_parallax_x: -(3.5 / 15) / 106,
|
|
111
|
+
/** Share of the cheek yaw horizontal field carried by lower `hair_back` (`0.90×` of `dxCheek`). */
|
|
112
|
+
turn_hair_back_cheek_x: 0.9,
|
|
113
|
+
/** Rear bun counter-parallax per degree of yaw (`W/deg`; `+8.00 px` at `+15°` on `W = 106`). */
|
|
114
|
+
turn_bun_counter_x: 8.0 / 15 / 106,
|
|
115
|
+
/** Rear hairpin counter-parallax per degree of yaw (`W/deg`; `+6.00 px` at `+15°` on `W = 106`). */
|
|
116
|
+
turn_hairpin_counter_x: 6.0 / 15 / 106,
|
|
117
|
+
/** Share of the cheek-edge yaw horizontal field carried by `ear_r` and `ear_l` (`0.85×` of `dxCheekEdge`, `-8.92 px` / `+3.14 px` at `+15°`). */
|
|
118
|
+
turn_ear_x: 0.85,
|
|
119
|
+
} as const;
|
|
120
|
+
|
|
121
|
+
export type FaceDeformKey = keyof typeof FACE_DEFORM_FRACTIONS;
|
|
122
|
+
export type FaceDeformFractions = Record<FaceDeformKey, number>;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Allowed `[min, max]` range for each key of {@link FACE_DEFORM_FRACTIONS}; an
|
|
126
|
+
* override in `config.turn.deform` outside its stated interval is refused by
|
|
127
|
+
* name (`CONFIG_TURN_DEFORM_RANGE`).
|
|
128
|
+
*/
|
|
129
|
+
export const FACE_DEFORM_RANGES: Readonly<Record<FaceDeformKey, readonly [number, number]>> = {
|
|
130
|
+
tilt_mid_y: [-0.02, 0],
|
|
131
|
+
tilt_chin_squash_y: [0, 0.015],
|
|
132
|
+
tilt_crown_drop_y: [-0.01, 0],
|
|
133
|
+
tilt_taper_x: [0, 0.015],
|
|
134
|
+
turn_mid_x: [-0.02, 0],
|
|
135
|
+
turn_lead_compress_x: [0, 0.025],
|
|
136
|
+
turn_trail_shift_x: [-0.01, 0.01],
|
|
137
|
+
turn_cheek_arch_y: [0, 0.01],
|
|
138
|
+
tilt_bang_base_y: [-0.01, 0],
|
|
139
|
+
tilt_hair_front_y: [0, 1.5],
|
|
140
|
+
tilt_hair_back_lower_y: [0, 1.5],
|
|
141
|
+
tilt_hair_back_bun_y: [0, 1.5],
|
|
142
|
+
tilt_hair_back_bun_x: [0, 1.5],
|
|
143
|
+
tilt_ear_y: [0, 1.5],
|
|
144
|
+
tilt_topknot_y: [0, 1.5],
|
|
145
|
+
turn_bang_parallax_x: [-0.015, 0],
|
|
146
|
+
turn_hair_back_cheek_x: [0, 1.5],
|
|
147
|
+
turn_bun_counter_x: [0, 0.02],
|
|
148
|
+
turn_hairpin_counter_x: [0, 0.02],
|
|
149
|
+
turn_ear_x: [0, 1.5],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** Uneven 5×5 grid columns and rows for the `face` plate mesh (issue #436). */
|
|
153
|
+
export const FACE_DEFORM_GRID_US: readonly number[] = [0, 0.18, 0.5, 0.82, 1];
|
|
154
|
+
export const FACE_DEFORM_GRID_VS: readonly number[] = [0, 0.22, 0.5, 0.78, 1];
|
|
155
|
+
|
|
156
|
+
/** Resolve {@link FACE_DEFORM_FRACTIONS} with optional recipe overrides. */
|
|
157
|
+
export function resolveFaceDeformFractions(overrides?: Readonly<Partial<FaceDeformFractions>> | null): FaceDeformFractions {
|
|
158
|
+
const out: FaceDeformFractions = { ...FACE_DEFORM_FRACTIONS };
|
|
159
|
+
if (overrides !== undefined && overrides !== null) {
|
|
160
|
+
for (const k of Object.keys(FACE_DEFORM_FRACTIONS) as FaceDeformKey[]) {
|
|
161
|
+
const v = overrides[k];
|
|
162
|
+
if (typeof v === 'number') out[k] = v;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `config.turn`: the pivot depth, each carried part's See-through `depth_median`, and optional face surface deform overrides. */
|
|
71
169
|
export interface TurnSpec {
|
|
72
170
|
pivot: number;
|
|
73
171
|
depths: Record<string, number>;
|
|
172
|
+
deform?: Partial<FaceDeformFractions>;
|
|
74
173
|
}
|
|
75
174
|
|
|
76
175
|
/** One carried part: its depth and the fraction of the control's offset it travels. */
|
|
@@ -128,3 +227,568 @@ export function turnTwin(part: string, bone: string): string {
|
|
|
128
227
|
export function turnConstraintName(part: string): string {
|
|
129
228
|
return `${TURN_BONE}_${part}`;
|
|
130
229
|
}
|
|
230
|
+
|
|
231
|
+
const FACE_DEFORM_MULT = 2.5;
|
|
232
|
+
const unmult = (v: number): number => pyRound(v / FACE_DEFORM_MULT, 8);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Evaluate the pitch (`tilt`) surface deformation field `(dx, dy)` in `head`
|
|
236
|
+
* space at `(hx, hy)` for `deg` degrees of pitch.
|
|
237
|
+
*/
|
|
238
|
+
export function evalPitchFaceSurface(
|
|
239
|
+
hx: number,
|
|
240
|
+
hy: number,
|
|
241
|
+
deg: number,
|
|
242
|
+
W: number,
|
|
243
|
+
H: number,
|
|
244
|
+
X0: number,
|
|
245
|
+
Y0: number,
|
|
246
|
+
f: Readonly<FaceDeformFractions> = FACE_DEFORM_FRACTIONS,
|
|
247
|
+
): [number, number] {
|
|
248
|
+
if (deg === 0) return [0, 0];
|
|
249
|
+
const ux = Math.max(-1.35, Math.min(1.35, (hx - X0) / (W / 2)));
|
|
250
|
+
const uy = Math.max(-1.35, Math.min(1.35, (hy - Y0) / (H / 2)));
|
|
251
|
+
const uChin = Math.min(1.2, Math.max(0, -uy));
|
|
252
|
+
const uCrown = Math.min(1.2, Math.max(0, uy));
|
|
253
|
+
const fracYPerDeg = unmult(f.tilt_mid_y) + unmult(f.tilt_chin_squash_y) * Math.pow(uChin, 1.25) + unmult(f.tilt_crown_drop_y) * Math.pow(uCrown, 1.25);
|
|
254
|
+
const fracXPerDeg = unmult(f.tilt_taper_x) * ux * Math.max(-1.1, Math.min(1.1, uy));
|
|
255
|
+
return [FACE_DEFORM_MULT * deg * W * fracXPerDeg, FACE_DEFORM_MULT * deg * H * fracYPerDeg];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Evaluate the yaw (`turn`) surface deformation field `(dx, dy)` in `head`
|
|
260
|
+
* space at `(hx, hy)` for `deg` degrees of yaw.
|
|
261
|
+
*/
|
|
262
|
+
export function evalYawFaceSurface(
|
|
263
|
+
hx: number,
|
|
264
|
+
hy: number,
|
|
265
|
+
deg: number,
|
|
266
|
+
W: number,
|
|
267
|
+
H: number,
|
|
268
|
+
X0: number,
|
|
269
|
+
Y0: number,
|
|
270
|
+
f: Readonly<FaceDeformFractions> = FACE_DEFORM_FRACTIONS,
|
|
271
|
+
): [number, number] {
|
|
272
|
+
if (deg === 0) return [0, 0];
|
|
273
|
+
const s = Math.sign(deg);
|
|
274
|
+
const ux = Math.max(-1.35, Math.min(1.35, (hx - X0) / (W / 2)));
|
|
275
|
+
const uy = Math.max(-1.35, Math.min(1.35, (hy - Y0) / (H / 2)));
|
|
276
|
+
const uLead = Math.min(1.2, Math.max(0, -s * ux));
|
|
277
|
+
const uTrail = Math.min(1.2, Math.max(0, s * ux));
|
|
278
|
+
const fracXPerDeg = unmult(f.turn_mid_x) + unmult(f.turn_lead_compress_x) * Math.pow(uLead, 1.2) + unmult(f.turn_trail_shift_x) * Math.pow(uTrail, 1.2);
|
|
279
|
+
const fracYPerDeg = unmult(f.turn_cheek_arch_y) * (-s * ux) * Math.max(0, 1 - Math.abs(uy));
|
|
280
|
+
return [FACE_DEFORM_MULT * deg * W * fracXPerDeg, FACE_DEFORM_MULT * deg * H * fracYPerDeg];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface FaceSurfaceDeformOptions {
|
|
284
|
+
/** Padded `face.png` width in rig px (defaults to the mesh width or 114). */
|
|
285
|
+
imageWidth?: number;
|
|
286
|
+
/** Padded `face.png` height in rig px (defaults to the mesh height or 136). */
|
|
287
|
+
imageHeight?: number;
|
|
288
|
+
/** Opaque face plate width `W` in rig px (defaults to `imageWidth - 8`). */
|
|
289
|
+
opaqueWidth?: number;
|
|
290
|
+
/** Opaque face plate height `H` in rig px (defaults to `imageHeight - 8`). */
|
|
291
|
+
opaqueHeight?: number;
|
|
292
|
+
/** Recipe overrides for {@link FACE_DEFORM_FRACTIONS}. */
|
|
293
|
+
overrides?: Readonly<Partial<FaceDeformFractions>> | null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface DeformWeight {
|
|
297
|
+
bone: string;
|
|
298
|
+
x: number;
|
|
299
|
+
y: number;
|
|
300
|
+
weight: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface DeformMeshAttachment {
|
|
304
|
+
type: 'mesh';
|
|
305
|
+
image: string;
|
|
306
|
+
width: number;
|
|
307
|
+
height: number;
|
|
308
|
+
uvs: number[];
|
|
309
|
+
triangles: number[];
|
|
310
|
+
hull: number;
|
|
311
|
+
weights: DeformWeight[][];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
interface DeformRegionAttachment {
|
|
315
|
+
image: string;
|
|
316
|
+
x: number;
|
|
317
|
+
y: number;
|
|
318
|
+
rotation?: number;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
interface DeriveSpec {
|
|
322
|
+
kind: 'yaw' | 'pitch';
|
|
323
|
+
degrees: number;
|
|
324
|
+
about?: number;
|
|
325
|
+
depth?: number | Readonly<Record<string, number>>;
|
|
326
|
+
carried?: number | Readonly<Record<string, number>>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
interface SliderKey {
|
|
330
|
+
t: number;
|
|
331
|
+
v?: number[];
|
|
332
|
+
derive?: DeriveSpec;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
interface SliderTrack {
|
|
336
|
+
bone?: string;
|
|
337
|
+
group?: string;
|
|
338
|
+
property: string;
|
|
339
|
+
keys: SliderKey[];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
interface DeformTimelineEntry {
|
|
343
|
+
slot: string;
|
|
344
|
+
attachment: string;
|
|
345
|
+
keys: Array<{ t: number; vertices?: number[] }>;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
interface SliderAnimation {
|
|
349
|
+
duration: number;
|
|
350
|
+
tracks: SliderTrack[];
|
|
351
|
+
deform?: DeformTimelineEntry[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function buildFaceGrid(width: number, height: number): { points: Array<[number, number]>; uvs: number[]; triangles: number[]; hull: number } {
|
|
355
|
+
const us = FACE_DEFORM_GRID_US;
|
|
356
|
+
const vs = FACE_DEFORM_GRID_VS;
|
|
357
|
+
const cols = us.length - 1;
|
|
358
|
+
const rows = vs.length - 1;
|
|
359
|
+
const ring: Array<[number, number]> = [];
|
|
360
|
+
for (let c = 0; c <= cols; c++) ring.push([c, 0]);
|
|
361
|
+
for (let r = 1; r <= rows; r++) ring.push([cols, r]);
|
|
362
|
+
for (let c = cols - 1; c >= 0; c--) ring.push([c, rows]);
|
|
363
|
+
for (let r = rows - 1; r >= 1; r--) ring.push([0, r]);
|
|
364
|
+
const interior: Array<[number, number]> = [];
|
|
365
|
+
for (let r = 1; r < rows; r++) for (let c = 1; c < cols; c++) interior.push([c, r]);
|
|
366
|
+
const all = [...ring, ...interior];
|
|
367
|
+
const idx = new Map<string, number>();
|
|
368
|
+
all.forEach(([c, r], i) => idx.set(`${c},${r}`, i));
|
|
369
|
+
const points: Array<[number, number]> = [];
|
|
370
|
+
const uvs: number[] = [];
|
|
371
|
+
for (const [c, r] of all) {
|
|
372
|
+
const u = us[c];
|
|
373
|
+
const v = vs[r];
|
|
374
|
+
points.push([u * width, v * height]);
|
|
375
|
+
uvs.push(pyRound(u, 6), pyRound(v, 6));
|
|
376
|
+
}
|
|
377
|
+
const triangles: number[] = [];
|
|
378
|
+
for (let r = 0; r < rows; r++) {
|
|
379
|
+
for (let c = 0; c < cols; c++) {
|
|
380
|
+
const tl = idx.get(`${c},${r}`) as number;
|
|
381
|
+
const tr = idx.get(`${c + 1},${r}`) as number;
|
|
382
|
+
const bl = idx.get(`${c},${r + 1}`) as number;
|
|
383
|
+
const br = idx.get(`${c + 1},${r + 1}`) as number;
|
|
384
|
+
triangles.push(tl, bl, br, tl, br, tr);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return { points, uvs, triangles, hull: ring.length };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Promote the `face` region on `head` to a 5×5 grid mesh and populate the
|
|
392
|
+
* `tilt` and `turn` slider animations with the Set B ×2.5 face and hair mesh
|
|
393
|
+
* deform timelines and ear/topknot tracks (issue #436). When `motion` holds
|
|
394
|
+
* neither `tilt` nor `turn` with slider `derive` keys, neither `rig` nor
|
|
395
|
+
* `motion` is touched.
|
|
396
|
+
*/
|
|
397
|
+
export function applyFaceSurfaceDeform(
|
|
398
|
+
rig: {
|
|
399
|
+
bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number; inherit?: string }>;
|
|
400
|
+
skins: { default: Record<string, Record<string, DeformMeshAttachment | DeformRegionAttachment>> };
|
|
401
|
+
invariants?: Record<string, unknown>;
|
|
402
|
+
},
|
|
403
|
+
motion: {
|
|
404
|
+
groups?: Record<string, string[]>;
|
|
405
|
+
animations: Record<string, unknown>;
|
|
406
|
+
},
|
|
407
|
+
options?: FaceSurfaceDeformOptions | Readonly<Partial<FaceDeformFractions>> | null,
|
|
408
|
+
): void {
|
|
409
|
+
const rawTilt = motion.animations.tilt as SliderAnimation | undefined;
|
|
410
|
+
const rawTurn = motion.animations.turn as SliderAnimation | undefined;
|
|
411
|
+
if (rawTilt === undefined && rawTurn === undefined) return;
|
|
412
|
+
const hasDerive =
|
|
413
|
+
(rawTilt?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false) ||
|
|
414
|
+
(rawTurn?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false);
|
|
415
|
+
if (!hasDerive) return;
|
|
416
|
+
const tiltAnim = rawTilt === undefined ? undefined : structuredClone(rawTilt);
|
|
417
|
+
const turnAnim = rawTurn === undefined ? undefined : structuredClone(rawTurn);
|
|
418
|
+
if (tiltAnim !== undefined) motion.animations.tilt = tiltAnim;
|
|
419
|
+
if (turnAnim !== undefined) motion.animations.turn = turnAnim;
|
|
420
|
+
|
|
421
|
+
const isOpts =
|
|
422
|
+
options !== undefined &&
|
|
423
|
+
options !== null &&
|
|
424
|
+
('imageWidth' in options || 'imageHeight' in options || 'opaqueWidth' in options || 'opaqueHeight' in options || 'overrides' in options);
|
|
425
|
+
const opts: FaceSurfaceDeformOptions = isOpts ? (options as FaceSurfaceDeformOptions) : { overrides: options as Readonly<Partial<FaceDeformFractions>> | null | undefined };
|
|
426
|
+
const f = resolveFaceDeformFractions(opts.overrides);
|
|
427
|
+
|
|
428
|
+
const faceSlot = rig.skins.default.face;
|
|
429
|
+
if (faceSlot === undefined || faceSlot.face === undefined) return;
|
|
430
|
+
const faceAtt = faceSlot.face;
|
|
431
|
+
|
|
432
|
+
let faceW = opts.imageWidth ?? 114;
|
|
433
|
+
let faceH = opts.imageHeight ?? 136;
|
|
434
|
+
let faceRegX = -1;
|
|
435
|
+
let faceRegY = 49;
|
|
436
|
+
|
|
437
|
+
if (!('type' in faceAtt)) {
|
|
438
|
+
faceRegX = faceAtt.x;
|
|
439
|
+
faceRegY = faceAtt.y;
|
|
440
|
+
const grid = buildFaceGrid(faceW, faceH);
|
|
441
|
+
const faceWeights: DeformWeight[][] = grid.points.map(([px, py]) => [
|
|
442
|
+
{
|
|
443
|
+
bone: 'head',
|
|
444
|
+
x: pyRound(faceRegX - faceW / 2 + px, 6),
|
|
445
|
+
y: pyRound(faceRegY + faceH / 2 - py, 6),
|
|
446
|
+
weight: 1,
|
|
447
|
+
},
|
|
448
|
+
]);
|
|
449
|
+
faceSlot.face = {
|
|
450
|
+
type: 'mesh',
|
|
451
|
+
image: faceAtt.image,
|
|
452
|
+
width: faceW,
|
|
453
|
+
height: faceH,
|
|
454
|
+
uvs: grid.uvs,
|
|
455
|
+
triangles: grid.triangles,
|
|
456
|
+
hull: grid.hull,
|
|
457
|
+
weights: faceWeights,
|
|
458
|
+
};
|
|
459
|
+
if (rig.invariants !== undefined) {
|
|
460
|
+
if (typeof rig.invariants.meshSlots === 'number') rig.invariants.meshSlots += 1;
|
|
461
|
+
if (typeof rig.invariants.meshTriangles === 'number') rig.invariants.meshTriangles += grid.triangles.length / 3;
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
faceW = faceAtt.width;
|
|
465
|
+
faceH = faceAtt.height;
|
|
466
|
+
let sumX = 0;
|
|
467
|
+
let sumY = 0;
|
|
468
|
+
for (const w of faceAtt.weights) {
|
|
469
|
+
sumX += w[0]?.x ?? 0;
|
|
470
|
+
sumY += w[0]?.y ?? 0;
|
|
471
|
+
}
|
|
472
|
+
if (faceAtt.weights.length > 0) {
|
|
473
|
+
faceRegX = Math.round(sumX / faceAtt.weights.length);
|
|
474
|
+
faceRegY = Math.round(sumY / faceAtt.weights.length);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const W = opts.opaqueWidth ?? faceW - 8;
|
|
479
|
+
const H = opts.opaqueHeight ?? faceH - 8;
|
|
480
|
+
const X0 = faceRegX;
|
|
481
|
+
const Y0 = faceRegY - 1;
|
|
482
|
+
|
|
483
|
+
const boneOrder = new Map<string, number>(rig.bones.map((b, i) => [b.name, i]));
|
|
484
|
+
for (const slotName of ['hair_front', 'hair_back']) {
|
|
485
|
+
const slotObj = rig.skins.default[slotName];
|
|
486
|
+
const att = slotObj?.[slotName];
|
|
487
|
+
if (att !== undefined && 'type' in att) {
|
|
488
|
+
for (const infls of att.weights) {
|
|
489
|
+
infls.sort((a, b) => (boneOrder.get(a.bone) ?? 0) - (boneOrder.get(b.bone) ?? 0));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const tf = computeWorldTransforms(rig.bones);
|
|
495
|
+
const headTf = tf.get('head') as BoneTransform | undefined;
|
|
496
|
+
if (headTf === undefined) return;
|
|
497
|
+
|
|
498
|
+
interface VertexSetup {
|
|
499
|
+
hx: number;
|
|
500
|
+
hy: number;
|
|
501
|
+
boneWeights: Record<string, number>;
|
|
502
|
+
infls: DeformWeight[];
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const getMeshSetup = (att: DeformMeshAttachment): VertexSetup[] =>
|
|
506
|
+
att.weights.map((infls) => {
|
|
507
|
+
let wx = 0;
|
|
508
|
+
let wy = 0;
|
|
509
|
+
const boneWeights: Record<string, number> = {};
|
|
510
|
+
for (const inf of infls) {
|
|
511
|
+
const m = tf.get(inf.bone) as BoneTransform;
|
|
512
|
+
const [x, y] = toWorld(m, inf.x, inf.y);
|
|
513
|
+
wx += x * inf.weight;
|
|
514
|
+
wy += y * inf.weight;
|
|
515
|
+
boneWeights[inf.bone] = (boneWeights[inf.bone] ?? 0) + inf.weight;
|
|
516
|
+
}
|
|
517
|
+
return { hx: wx - headTf.worldX, hy: wy - headTf.worldY, boneWeights, infls };
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const expandToDeform = (verts: readonly VertexSetup[], evalVertex: (v: VertexSetup) => [number, number]): number[] => {
|
|
521
|
+
const out: number[] = [];
|
|
522
|
+
for (const v of verts) {
|
|
523
|
+
const [dx, dy] = evalVertex(v);
|
|
524
|
+
for (const inf of v.infls) {
|
|
525
|
+
const m = tf.get(inf.bone) as BoneTransform;
|
|
526
|
+
const [bx, by] = toBoneLocalVector(m, dx, dy);
|
|
527
|
+
out.push(pyRound(bx, 4), pyRound(by, 4));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const boneMap = new Map(rig.bones.map((b) => [b.name, b]));
|
|
534
|
+
const evalDeriveKey = (boneName: string, k: SliderKey): number => {
|
|
535
|
+
if (Array.isArray(k.v)) return k.v[0] ?? 0;
|
|
536
|
+
const d = k.derive;
|
|
537
|
+
if (d === undefined) return 0;
|
|
538
|
+
const b = boneMap.get(boneName);
|
|
539
|
+
const coord = d.kind === 'yaw' ? (b?.x ?? 0) : (b?.y ?? 0);
|
|
540
|
+
const rad = (d.degrees * Math.PI) / 180;
|
|
541
|
+
const depth = typeof d.depth === 'number' ? d.depth : (d.depth?.[boneName] ?? 0);
|
|
542
|
+
const carried = typeof d.carried === 'number' ? d.carried : (d.carried?.[boneName] ?? 0);
|
|
543
|
+
const val = (coord - (d.about ?? 0)) * (Math.cos(rad) - 1) - (depth - carried) * Math.sin(rad);
|
|
544
|
+
const r = Math.round(val * 1e6) / 1e6;
|
|
545
|
+
return r === 0 ? 0 : r;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const mapParent: Readonly<Record<string, string>> = {
|
|
549
|
+
bang_r0: 'bang_r0_ctl',
|
|
550
|
+
bang_r1: 'bang_r0_ctl',
|
|
551
|
+
bang_c0: 'bang_c0_ctl',
|
|
552
|
+
bang_c1: 'bang_c0_ctl',
|
|
553
|
+
bang_l0: 'bang_l0_ctl',
|
|
554
|
+
bang_l1: 'bang_l0_ctl',
|
|
555
|
+
bun: 'bun_ctl',
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
const getBoneDeltaAtTime = (tracks: readonly SliderTrack[], t: number, boneName: string): [number, number] => {
|
|
559
|
+
const driver = mapParent[boneName] ?? boneName;
|
|
560
|
+
let dx = 0;
|
|
561
|
+
let dy = 0;
|
|
562
|
+
for (const tr of tracks) {
|
|
563
|
+
const inGroup = tr.group !== undefined && (motion.groups?.[tr.group]?.includes(driver) ?? false);
|
|
564
|
+
if (tr.bone !== driver && !inGroup) continue;
|
|
565
|
+
const key = tr.keys.find((k) => Math.abs(k.t - t) < 1e-6);
|
|
566
|
+
if (key === undefined) continue;
|
|
567
|
+
const val = evalDeriveKey(driver, key);
|
|
568
|
+
if (tr.property === 'translatex') dx = val;
|
|
569
|
+
if (tr.property === 'translatey') dy = val;
|
|
570
|
+
}
|
|
571
|
+
return [dx, dy];
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const getVertexBoneDelta = (v: VertexSetup, tracks: readonly SliderTrack[], t: number): [number, number] => {
|
|
575
|
+
let bx = 0;
|
|
576
|
+
let by = 0;
|
|
577
|
+
for (const [bname, w] of Object.entries(v.boneWeights)) {
|
|
578
|
+
const [dx, dy] = getBoneDeltaAtTime(tracks, t, bname);
|
|
579
|
+
bx += dx * w;
|
|
580
|
+
by += dy * w;
|
|
581
|
+
}
|
|
582
|
+
return [bx, by];
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const faceMesh = faceSlot.face as DeformMeshAttachment;
|
|
586
|
+
const faceVerts = getMeshSetup(faceMesh);
|
|
587
|
+
const hairFrontAtt = rig.skins.default.hair_front?.hair_front;
|
|
588
|
+
const hairBackAtt = rig.skins.default.hair_back?.hair_back;
|
|
589
|
+
const hairFrontVerts = hairFrontAtt !== undefined && 'type' in hairFrontAtt ? getMeshSetup(hairFrontAtt) : null;
|
|
590
|
+
const hairBackVerts = hairBackAtt !== undefined && 'type' in hairBackAtt ? getMeshSetup(hairBackAtt) : null;
|
|
591
|
+
|
|
592
|
+
const hyCrown = Y0 + (67 / 128) * H;
|
|
593
|
+
const hyBunSpan = (85 / 128) * H;
|
|
594
|
+
const hyBangBottom = Y0 - (68 / 128) * H;
|
|
595
|
+
const hyBangSpan = (130 / 128) * H;
|
|
596
|
+
const hyTurnBangBottom = Y0 - (28 / 128) * H;
|
|
597
|
+
const hyTurnBangSpan = (80 / 128) * H;
|
|
598
|
+
const hyEar = Y0 - (16 / 128) * H;
|
|
599
|
+
const earRx = boneMap.get('ear_r')?.x ?? -51;
|
|
600
|
+
const earLx = boneMap.get('ear_l')?.x ?? 47.5;
|
|
601
|
+
|
|
602
|
+
if (tiltAnim !== undefined) {
|
|
603
|
+
const origTiltTracks = tiltAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
|
|
604
|
+
const tiltKeys: Array<{ t: number; deg: number }> = [
|
|
605
|
+
{ t: 0, deg: -25 },
|
|
606
|
+
{ t: 0.75, deg: -10 },
|
|
607
|
+
{ t: 1.25, deg: 0 },
|
|
608
|
+
{ t: 2.0, deg: 15 },
|
|
609
|
+
{ t: 2.5, deg: 25 },
|
|
610
|
+
];
|
|
611
|
+
const tiltDeform: DeformTimelineEntry[] = [
|
|
612
|
+
{
|
|
613
|
+
slot: 'face',
|
|
614
|
+
attachment: 'face',
|
|
615
|
+
keys: tiltKeys.map(({ t, deg }) =>
|
|
616
|
+
deg === 0 ? { t } : { t, vertices: expandToDeform(faceVerts, (v) => evalPitchFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f)) },
|
|
617
|
+
),
|
|
618
|
+
},
|
|
619
|
+
];
|
|
620
|
+
if (hairFrontVerts !== null) {
|
|
621
|
+
tiltDeform.push({
|
|
622
|
+
slot: 'hair_front',
|
|
623
|
+
attachment: 'hair_front',
|
|
624
|
+
keys: tiltKeys.map(({ t, deg }) =>
|
|
625
|
+
deg === 0
|
|
626
|
+
? { t }
|
|
627
|
+
: {
|
|
628
|
+
t,
|
|
629
|
+
vertices: expandToDeform(hairFrontVerts, (v) => {
|
|
630
|
+
const [dxSurf, dySurf] = evalPitchFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f);
|
|
631
|
+
const [bx, by] = getVertexBoneDelta(v, origTiltTracks, t);
|
|
632
|
+
const baseBangDy = (deg / 15) * (f.tilt_bang_base_y * 15 * H) * Math.max(0, Math.min(1, (v.hy - hyBangBottom) / hyBangSpan));
|
|
633
|
+
return [dxSurf - bx, baseBangDy + dySurf * f.tilt_hair_front_y - by];
|
|
634
|
+
}),
|
|
635
|
+
},
|
|
636
|
+
),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (hairBackVerts !== null) {
|
|
640
|
+
tiltDeform.push({
|
|
641
|
+
slot: 'hair_back',
|
|
642
|
+
attachment: 'hair_back',
|
|
643
|
+
keys: tiltKeys.map(({ t, deg }) =>
|
|
644
|
+
deg === 0
|
|
645
|
+
? { t }
|
|
646
|
+
: {
|
|
647
|
+
t,
|
|
648
|
+
vertices: expandToDeform(hairBackVerts, (v) => {
|
|
649
|
+
const [bx, by] = getVertexBoneDelta(v, origTiltTracks, t);
|
|
650
|
+
const [dxSurf, dySurf] = evalPitchFaceSurface(v.hx, Math.min(hyCrown, v.hy), deg, W, H, X0, Y0, f);
|
|
651
|
+
const uBun = Math.max(0, Math.min(1, (v.hy - hyCrown) / hyBunSpan));
|
|
652
|
+
const targetDy = (1 - uBun) * (dySurf * f.tilt_hair_back_lower_y) + uBun * (dySurf * f.tilt_hair_back_bun_y);
|
|
653
|
+
return [dxSurf * (1 - pyRound(1 - f.tilt_hair_back_bun_x, 8) * uBun) - bx, targetDy - by];
|
|
654
|
+
}),
|
|
655
|
+
},
|
|
656
|
+
),
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
for (const tr of tiltAnim.tracks) {
|
|
660
|
+
if (tr.group === 'ears' && tr.property === 'translatey') {
|
|
661
|
+
tr.keys = tr.keys.map((k) => {
|
|
662
|
+
const deg = k.derive?.degrees ?? 0;
|
|
663
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
664
|
+
const [, dyEar] = evalPitchFaceSurface(0, hyEar, deg, W, H, X0, Y0, f);
|
|
665
|
+
return { t: k.t, v: [pyRound(dyEar * f.tilt_ear_y, 4)] };
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
if (tr.group === 'topknot' && tr.property === 'translatey') {
|
|
669
|
+
tr.keys = tr.keys.map((k) => {
|
|
670
|
+
const deg = k.derive?.degrees ?? 0;
|
|
671
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
672
|
+
const [, dyCrown] = evalPitchFaceSurface(0, hyCrown, deg, W, H, X0, Y0, f);
|
|
673
|
+
return { t: k.t, v: [pyRound(dyCrown * f.tilt_topknot_y, 4)] };
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const refTiltKeys = origTiltTracks[0]?.keys ?? [];
|
|
678
|
+
for (const [earBone, earHx] of [
|
|
679
|
+
['ear_r', earRx],
|
|
680
|
+
['ear_l', earLx],
|
|
681
|
+
] as const) {
|
|
682
|
+
if (!boneMap.has(earBone)) continue;
|
|
683
|
+
tiltAnim.tracks.push({
|
|
684
|
+
bone: earBone,
|
|
685
|
+
property: 'translatex',
|
|
686
|
+
keys: refTiltKeys.map((k) => {
|
|
687
|
+
const deg = k.derive?.degrees ?? 0;
|
|
688
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
689
|
+
const [dxEar] = evalPitchFaceSurface(earHx, hyEar, deg, W, H, X0, Y0, f);
|
|
690
|
+
return { t: k.t, v: [pyRound(dxEar, 4)] };
|
|
691
|
+
}),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
tiltAnim.deform = tiltDeform;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (turnAnim !== undefined) {
|
|
698
|
+
const origTurnTracks = turnAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
|
|
699
|
+
const turnKeys: Array<{ t: number; deg: number }> = [
|
|
700
|
+
{ t: 0, deg: -30 },
|
|
701
|
+
{ t: 0.75, deg: -15 },
|
|
702
|
+
{ t: 1.5, deg: 0 },
|
|
703
|
+
{ t: 2.25, deg: 15 },
|
|
704
|
+
{ t: 3.0, deg: 30 },
|
|
705
|
+
];
|
|
706
|
+
const turnDeform: DeformTimelineEntry[] = [
|
|
707
|
+
{
|
|
708
|
+
slot: 'face',
|
|
709
|
+
attachment: 'face',
|
|
710
|
+
keys: turnKeys.map(({ t, deg }) =>
|
|
711
|
+
deg === 0 ? { t } : { t, vertices: expandToDeform(faceVerts, (v) => evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f)) },
|
|
712
|
+
),
|
|
713
|
+
},
|
|
714
|
+
];
|
|
715
|
+
if (hairFrontVerts !== null) {
|
|
716
|
+
turnDeform.push({
|
|
717
|
+
slot: 'hair_front',
|
|
718
|
+
attachment: 'hair_front',
|
|
719
|
+
keys: turnKeys.map(({ t, deg }) =>
|
|
720
|
+
deg === 0
|
|
721
|
+
? { t }
|
|
722
|
+
: {
|
|
723
|
+
t,
|
|
724
|
+
vertices: expandToDeform(hairFrontVerts, (v) => {
|
|
725
|
+
const [dxSurf, dySurf] = evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f);
|
|
726
|
+
const [bx, by] = getVertexBoneDelta(v, origTurnTracks, t);
|
|
727
|
+
const uBang = Math.max(0, Math.min(1, (v.hy - hyTurnBangBottom) / hyTurnBangSpan));
|
|
728
|
+
const bangParallaxX = (deg / 15) * pyRound(f.turn_bang_parallax_x * 15 * W, 8) * uBang;
|
|
729
|
+
return [bangParallaxX + dxSurf - bx, dySurf - by];
|
|
730
|
+
}),
|
|
731
|
+
},
|
|
732
|
+
),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (hairBackVerts !== null) {
|
|
736
|
+
turnDeform.push({
|
|
737
|
+
slot: 'hair_back',
|
|
738
|
+
attachment: 'hair_back',
|
|
739
|
+
keys: turnKeys.map(({ t, deg }) =>
|
|
740
|
+
deg === 0
|
|
741
|
+
? { t }
|
|
742
|
+
: {
|
|
743
|
+
t,
|
|
744
|
+
vertices: expandToDeform(hairBackVerts, (v) => {
|
|
745
|
+
const [bx, by] = getVertexBoneDelta(v, origTurnTracks, t);
|
|
746
|
+
const [dxCheek, dyCheek] = evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f);
|
|
747
|
+
const uBun = Math.max(0, Math.min(1, (v.hy - hyCrown) / hyBunSpan));
|
|
748
|
+
const bunCounterX = FACE_DEFORM_MULT * (deg / 15) * pyRound((f.turn_bun_counter_x * 15 * W) / FACE_DEFORM_MULT, 8);
|
|
749
|
+
const targetDx = (1 - uBun) * (dxCheek * f.turn_hair_back_cheek_x) + uBun * bunCounterX;
|
|
750
|
+
return [targetDx - bx, (1 - uBun) * dyCheek - by];
|
|
751
|
+
}),
|
|
752
|
+
},
|
|
753
|
+
),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
const refTurnKeys = origTurnTracks[0]?.keys ?? [];
|
|
757
|
+
turnAnim.tracks = turnAnim.tracks.filter(
|
|
758
|
+
(tr) => !(tr.group === 'ears' && tr.property === 'translatex') && !(tr.group === 'topknot' && tr.property === 'translatex'),
|
|
759
|
+
);
|
|
760
|
+
for (const [earBone, earHx] of [
|
|
761
|
+
['ear_r', earRx],
|
|
762
|
+
['ear_l', earLx],
|
|
763
|
+
] as const) {
|
|
764
|
+
if (!boneMap.has(earBone)) continue;
|
|
765
|
+
turnAnim.tracks.push({
|
|
766
|
+
bone: earBone,
|
|
767
|
+
property: 'translatex',
|
|
768
|
+
keys: refTurnKeys.map((k) => {
|
|
769
|
+
const deg = k.derive?.degrees ?? 0;
|
|
770
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
771
|
+
const [dxCheekEdge] = evalYawFaceSurface(earHx, hyEar, deg, W, H, X0, Y0, f);
|
|
772
|
+
return { t: k.t, v: [pyRound(dxCheekEdge * f.turn_ear_x, 4)] };
|
|
773
|
+
}),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
for (const [tkBone, tkFrac] of [
|
|
777
|
+
['bun_ctl', f.turn_bun_counter_x],
|
|
778
|
+
['hairpin', f.turn_hairpin_counter_x],
|
|
779
|
+
] as const) {
|
|
780
|
+
if (!boneMap.has(tkBone)) continue;
|
|
781
|
+
turnAnim.tracks.push({
|
|
782
|
+
bone: tkBone,
|
|
783
|
+
property: 'translatex',
|
|
784
|
+
keys: refTurnKeys.map((k) => {
|
|
785
|
+
const deg = k.derive?.degrees ?? 0;
|
|
786
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
787
|
+
const tkPx = pyRound((tkFrac * 15 * W) / FACE_DEFORM_MULT, 8);
|
|
788
|
+
return { t: k.t, v: [pyRound(FACE_DEFORM_MULT * (deg / 15) * tkPx, 4)] };
|
|
789
|
+
}),
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
turnAnim.deform = turnDeform;
|
|
793
|
+
}
|
|
794
|
+
}
|