@rig-c/parts 1.31.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.ts +12 -7
- package/docs/AUTHORING.md +24 -3
- package/docs/STABILITY.md +5 -4
- package/package.json +2 -2
- package/src/build.ts +98 -10
- package/src/check.ts +223 -6
- package/src/config.ts +148 -8
- package/src/parts.ts +7 -0
- package/src/propose.ts +1 -1
- package/src/rig.ts +213 -10
- package/src/turn.ts +302 -45
package/src/turn.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* one piece. Pure: no file, no clock.
|
|
30
30
|
*/
|
|
31
31
|
import { computeWorldTransforms, toBoneLocalVector, toWorld, type BoneTransform } from './coords.ts';
|
|
32
|
+
import { type Problem, refuseIfAny } from './errors.ts';
|
|
32
33
|
import { pyRound } from './round.ts';
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -165,10 +166,29 @@ export function resolveFaceDeformFractions(overrides?: Readonly<Partial<FaceDefo
|
|
|
165
166
|
return out;
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Issue #472: the rated band of a single face plate — yaw `±15°`, pitch `±12°`.
|
|
171
|
+
* Beyond this band on one plate, the foreshortened far eye runs out of cheek to
|
|
172
|
+
* sit on; going further takes an alternate face set (`assemble.alternates`),
|
|
173
|
+
* whose swap threshold is this band edge.
|
|
174
|
+
*/
|
|
175
|
+
export const FACE_RATED_YAW_BAND = 15;
|
|
176
|
+
export const FACE_RATED_PITCH_BAND = 12;
|
|
177
|
+
export const FACE_RATED_BAND = { yaw: FACE_RATED_YAW_BAND, pitch: FACE_RATED_PITCH_BAND } as const;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Issue #477: allowed `[min, max]` range (in rig px) for an authored ear
|
|
181
|
+
* `derive.depth` in `turn` or `tilt`. Ears sit at or behind the head pivot
|
|
182
|
+
* (`depth <= 0`, where `-14` moves the ears slightly against the face turn); a
|
|
183
|
+
* depth outside `[-50, 0]` is refused by `CONFIG_TURN_EAR_DEPTH_RANGE`.
|
|
184
|
+
*/
|
|
185
|
+
export const EAR_DEPTH_RANGE: readonly [number, number] = [-50, 0];
|
|
186
|
+
|
|
187
|
+
/** `config.turn`: the pivot depth, each carried part's See-through `depth_median`, optional rated band, and optional face surface deform overrides. */
|
|
169
188
|
export interface TurnSpec {
|
|
170
189
|
pivot: number;
|
|
171
190
|
depths: Record<string, number>;
|
|
191
|
+
band?: number | { yaw?: number; pitch?: number };
|
|
172
192
|
deform?: Partial<FaceDeformFractions>;
|
|
173
193
|
}
|
|
174
194
|
|
|
@@ -291,6 +311,13 @@ export interface FaceSurfaceDeformOptions {
|
|
|
291
311
|
opaqueHeight?: number;
|
|
292
312
|
/** Recipe overrides for {@link FACE_DEFORM_FRACTIONS}. */
|
|
293
313
|
overrides?: Readonly<Partial<FaceDeformFractions>> | null;
|
|
314
|
+
/**
|
|
315
|
+
* Issue #472: true when `assemble.alternates` declares an alternate face set
|
|
316
|
+
* for the overflow beyond `±15°` yaw / `±12°` pitch. When false or absent,
|
|
317
|
+
* any `yaw` / `pitch` slider constraints on the rig are preceded by clamping
|
|
318
|
+
* transform constraints holding the dial bones to `[-15, 15]` and `[-12, 12]`.
|
|
319
|
+
*/
|
|
320
|
+
hasAlternateFaceSet?: boolean;
|
|
294
321
|
}
|
|
295
322
|
|
|
296
323
|
interface DeformWeight {
|
|
@@ -328,7 +355,7 @@ interface DeriveSpec {
|
|
|
328
355
|
|
|
329
356
|
interface SliderKey {
|
|
330
357
|
t: number;
|
|
331
|
-
v?: number[]
|
|
358
|
+
v?: number[] | Record<string, number[]>;
|
|
332
359
|
derive?: DeriveSpec;
|
|
333
360
|
}
|
|
334
361
|
|
|
@@ -390,14 +417,16 @@ function buildFaceGrid(width: number, height: number): { points: Array<[number,
|
|
|
390
417
|
/**
|
|
391
418
|
* Promote the `face` region on `head` to a 5×5 grid mesh and populate the
|
|
392
419
|
* `tilt` and `turn` slider animations with the Set B ×2.5 face and hair mesh
|
|
393
|
-
* deform timelines
|
|
394
|
-
* neither `tilt` nor `turn` with slider
|
|
395
|
-
* `motion` is touched.
|
|
420
|
+
* deform timelines, feature field-sampled tracks (#468) and ear/topknot tracks
|
|
421
|
+
* (issue #436). When `motion` holds neither `tilt` nor `turn` with slider
|
|
422
|
+
* `derive` keys, neither `rig` nor `motion` is touched.
|
|
396
423
|
*/
|
|
397
424
|
export function applyFaceSurfaceDeform(
|
|
398
425
|
rig: {
|
|
399
426
|
bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number; inherit?: string }>;
|
|
427
|
+
slots?: Array<{ name: string; bone: string }>;
|
|
400
428
|
skins: { default: Record<string, Record<string, DeformMeshAttachment | DeformRegionAttachment>> };
|
|
429
|
+
constraints?: Array<Record<string, unknown>>;
|
|
401
430
|
invariants?: Record<string, unknown>;
|
|
402
431
|
},
|
|
403
432
|
motion: {
|
|
@@ -413,6 +442,44 @@ export function applyFaceSurfaceDeform(
|
|
|
413
442
|
(rawTilt?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false) ||
|
|
414
443
|
(rawTurn?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false);
|
|
415
444
|
if (!hasDerive) return;
|
|
445
|
+
|
|
446
|
+
// Issue #477: refuse an out-of-range ear derive.depth by name before touching rig or motion.
|
|
447
|
+
const earProblems: Problem[] = [];
|
|
448
|
+
const [earLo, earHi] = EAR_DEPTH_RANGE;
|
|
449
|
+
for (const [animName, anim] of [
|
|
450
|
+
['tilt', rawTilt],
|
|
451
|
+
['turn', rawTurn],
|
|
452
|
+
] as const) {
|
|
453
|
+
if (anim === undefined) continue;
|
|
454
|
+
anim.tracks.forEach((tr, ti) => {
|
|
455
|
+
const isEarTrack = tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l';
|
|
456
|
+
if (!isEarTrack) return;
|
|
457
|
+
tr.keys.forEach((k, ki) => {
|
|
458
|
+
const d = k.derive?.depth;
|
|
459
|
+
if (typeof d === 'number') {
|
|
460
|
+
if (d < earLo || d > earHi) {
|
|
461
|
+
earProblems.push({
|
|
462
|
+
code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
|
|
463
|
+
object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth`,
|
|
464
|
+
detail: `is ${d}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
} else if (d !== undefined && typeof d === 'object') {
|
|
468
|
+
for (const [member, val] of Object.entries(d)) {
|
|
469
|
+
if (typeof val === 'number' && (val < earLo || val > earHi)) {
|
|
470
|
+
earProblems.push({
|
|
471
|
+
code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
|
|
472
|
+
object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth.${member}`,
|
|
473
|
+
detail: `is ${val}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
refuseIfAny(earProblems);
|
|
482
|
+
|
|
416
483
|
const tiltAnim = rawTilt === undefined ? undefined : structuredClone(rawTilt);
|
|
417
484
|
const turnAnim = rawTurn === undefined ? undefined : structuredClone(rawTurn);
|
|
418
485
|
if (tiltAnim !== undefined) motion.animations.tilt = tiltAnim;
|
|
@@ -421,10 +488,63 @@ export function applyFaceSurfaceDeform(
|
|
|
421
488
|
const isOpts =
|
|
422
489
|
options !== undefined &&
|
|
423
490
|
options !== null &&
|
|
424
|
-
('imageWidth' in options ||
|
|
491
|
+
('imageWidth' in options ||
|
|
492
|
+
'imageHeight' in options ||
|
|
493
|
+
'opaqueWidth' in options ||
|
|
494
|
+
'opaqueHeight' in options ||
|
|
495
|
+
'overrides' in options ||
|
|
496
|
+
'hasAlternateFaceSet' in options);
|
|
425
497
|
const opts: FaceSurfaceDeformOptions = isOpts ? (options as FaceSurfaceDeformOptions) : { overrides: options as Readonly<Partial<FaceDeformFractions>> | null | undefined };
|
|
426
498
|
const f = resolveFaceDeformFractions(opts.overrides);
|
|
427
499
|
|
|
500
|
+
// Issue #472: when no alternate face set is declared, clamp dial bones to the single-plate rated band (±15° yaw, ±12° pitch).
|
|
501
|
+
if (!opts.hasAlternateFaceSet && Array.isArray(rig.constraints)) {
|
|
502
|
+
const nextConstraints: Array<Record<string, unknown>> = [];
|
|
503
|
+
const existingNames = new Set(rig.constraints.map((c) => c.name));
|
|
504
|
+
const makeClampConstraint = (name: string, bone: string, prop: unknown, band: number): Record<string, unknown> => {
|
|
505
|
+
const useX = prop === 'x' || prop === 'translatex';
|
|
506
|
+
const key = useX ? 'x' : 'rotate';
|
|
507
|
+
const mixKey = useX ? 'mixX' : 'mixRotate';
|
|
508
|
+
return {
|
|
509
|
+
name,
|
|
510
|
+
type: 'transform',
|
|
511
|
+
source: bone,
|
|
512
|
+
bones: [bone],
|
|
513
|
+
localSource: true,
|
|
514
|
+
localTarget: true,
|
|
515
|
+
clamp: true,
|
|
516
|
+
properties: {
|
|
517
|
+
[key]: {
|
|
518
|
+
offset: -band,
|
|
519
|
+
to: {
|
|
520
|
+
[key]: {
|
|
521
|
+
offset: -band,
|
|
522
|
+
max: band,
|
|
523
|
+
scale: 1,
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
[mixKey]: 1,
|
|
529
|
+
};
|
|
530
|
+
};
|
|
531
|
+
for (const c of rig.constraints) {
|
|
532
|
+
if (c.type === 'slider' && typeof c.bone === 'string') {
|
|
533
|
+
const isYaw = c.name === 'yaw' || c.animation === 'turn';
|
|
534
|
+
const isPitch = c.name === 'pitch' || c.animation === 'tilt';
|
|
535
|
+
if (isYaw && !existingNames.has('yaw_clamp')) {
|
|
536
|
+
existingNames.add('yaw_clamp');
|
|
537
|
+
nextConstraints.push(makeClampConstraint('yaw_clamp', c.bone, c.property, FACE_RATED_YAW_BAND));
|
|
538
|
+
} else if (isPitch && !existingNames.has('pitch_clamp')) {
|
|
539
|
+
existingNames.add('pitch_clamp');
|
|
540
|
+
nextConstraints.push(makeClampConstraint('pitch_clamp', c.bone, c.property, FACE_RATED_PITCH_BAND));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
nextConstraints.push(c);
|
|
544
|
+
}
|
|
545
|
+
rig.constraints = nextConstraints;
|
|
546
|
+
}
|
|
547
|
+
|
|
428
548
|
const faceSlot = rig.skins.default.face;
|
|
429
549
|
if (faceSlot === undefined || faceSlot.face === undefined) return;
|
|
430
550
|
const faceAtt = faceSlot.face;
|
|
@@ -434,13 +554,15 @@ export function applyFaceSurfaceDeform(
|
|
|
434
554
|
let faceRegX = -1;
|
|
435
555
|
let faceRegY = 49;
|
|
436
556
|
|
|
557
|
+
const faceBone = rig.slots?.find((s) => s.name === 'face')?.bone ?? (rig.bones.some((b) => b.name === 'turn_face_head') ? 'turn_face_head' : 'head');
|
|
558
|
+
|
|
437
559
|
if (!('type' in faceAtt)) {
|
|
438
560
|
faceRegX = faceAtt.x;
|
|
439
561
|
faceRegY = faceAtt.y;
|
|
440
562
|
const grid = buildFaceGrid(faceW, faceH);
|
|
441
563
|
const faceWeights: DeformWeight[][] = grid.points.map(([px, py]) => [
|
|
442
564
|
{
|
|
443
|
-
bone:
|
|
565
|
+
bone: faceBone,
|
|
444
566
|
x: pyRound(faceRegX - faceW / 2 + px, 6),
|
|
445
567
|
y: pyRound(faceRegY + faceH / 2 - py, 6),
|
|
446
568
|
weight: 1,
|
|
@@ -531,8 +653,31 @@ export function applyFaceSurfaceDeform(
|
|
|
531
653
|
};
|
|
532
654
|
|
|
533
655
|
const boneMap = new Map(rig.bones.map((b) => [b.name, b]));
|
|
656
|
+
const referencedGroups = new Set<string>();
|
|
657
|
+
for (const anim of [tiltAnim, turnAnim]) {
|
|
658
|
+
for (const tr of anim?.tracks ?? []) {
|
|
659
|
+
if (tr.group !== undefined) referencedGroups.add(tr.group);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const defaultGroups: Readonly<Record<string, readonly string[]>> = {
|
|
663
|
+
features: ['eye_r', 'eye_l', 'brow_r', 'brow_l', 'pupil_r', 'pupil_l', 'eyelash_r', 'eyelash_l', 'eyebrow_r', 'eyebrow_l', 'nose', 'mouth'],
|
|
664
|
+
ears: ['ear_r', 'ear_l'],
|
|
665
|
+
bangs: ['bang_r0', 'bang_c0', 'bang_l0'].map((b) => (boneMap.has(`${b}_ctl`) ? `${b}_ctl` : b)),
|
|
666
|
+
topknot: ['bun_ctl', 'hairpin', 'headwear'],
|
|
667
|
+
};
|
|
668
|
+
for (const g of referencedGroups) {
|
|
669
|
+
if (motion.groups?.[g] === undefined && defaultGroups[g] !== undefined) {
|
|
670
|
+
const members = defaultGroups[g].filter((b) => boneMap.has(b));
|
|
671
|
+
if (members.length > 0) {
|
|
672
|
+
motion.groups ??= {};
|
|
673
|
+
motion.groups[g] = members;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
534
678
|
const evalDeriveKey = (boneName: string, k: SliderKey): number => {
|
|
535
679
|
if (Array.isArray(k.v)) return k.v[0] ?? 0;
|
|
680
|
+
if (k.v !== undefined && typeof k.v === 'object') return k.v[boneName]?.[0] ?? 0;
|
|
536
681
|
const d = k.derive;
|
|
537
682
|
if (d === undefined) return 0;
|
|
538
683
|
const b = boneMap.get(boneName);
|
|
@@ -546,17 +691,18 @@ export function applyFaceSurfaceDeform(
|
|
|
546
691
|
};
|
|
547
692
|
|
|
548
693
|
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',
|
|
694
|
+
bang_r0: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
|
|
695
|
+
bang_r1: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
|
|
696
|
+
bang_c0: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
|
|
697
|
+
bang_c1: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
|
|
698
|
+
bang_l0: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
|
|
699
|
+
bang_l1: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
|
|
700
|
+
bun: boneMap.has('bun_ctl') ? 'bun_ctl' : 'bun',
|
|
556
701
|
};
|
|
557
702
|
|
|
558
703
|
const getBoneDeltaAtTime = (tracks: readonly SliderTrack[], t: number, boneName: string): [number, number] => {
|
|
559
|
-
const
|
|
704
|
+
const baseBone = boneName.replace(/^turn_[^_]+_/, '');
|
|
705
|
+
const driver = mapParent[baseBone] ?? baseBone;
|
|
560
706
|
let dx = 0;
|
|
561
707
|
let dy = 0;
|
|
562
708
|
for (const tr of tracks) {
|
|
@@ -598,16 +744,37 @@ export function applyFaceSurfaceDeform(
|
|
|
598
744
|
const hyEar = Y0 - (16 / 128) * H;
|
|
599
745
|
const earRx = boneMap.get('ear_r')?.x ?? -51;
|
|
600
746
|
const earLx = boneMap.get('ear_l')?.x ?? 47.5;
|
|
747
|
+
const featRx = (24 / 106) * W;
|
|
748
|
+
const featRy = (12 / 128) * H;
|
|
749
|
+
const featMembers = motion.groups?.features ?? [];
|
|
750
|
+
|
|
751
|
+
const minDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, fallback: number): number => {
|
|
752
|
+
if (typeof depth === 'number') return depth > 0 ? depth : fallback;
|
|
753
|
+
if (depth === undefined) return fallback;
|
|
754
|
+
const vals = Object.values(depth).filter((v): v is number => typeof v === 'number' && v > 0);
|
|
755
|
+
return vals.length > 0 ? Math.min(...vals) : fallback;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const memberDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, boneName: string, zRef: number): number => {
|
|
759
|
+
if (typeof depth === 'number') return depth;
|
|
760
|
+
return depth?.[boneName] ?? zRef;
|
|
761
|
+
};
|
|
601
762
|
|
|
602
763
|
if (tiltAnim !== undefined) {
|
|
603
764
|
const origTiltTracks = tiltAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
765
|
+
const refTiltTrack = origTiltTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTiltTracks[0];
|
|
766
|
+
const isLegacyTilt =
|
|
767
|
+
tiltAnim.duration === 2.5 &&
|
|
768
|
+
Math.abs(refTiltTrack?.keys[0]?.derive?.degrees ?? 0) === 25;
|
|
769
|
+
const tiltKeys: Array<{ t: number; deg: number }> = isLegacyTilt
|
|
770
|
+
? [
|
|
771
|
+
{ t: 0, deg: -25 },
|
|
772
|
+
{ t: 0.75, deg: -10 },
|
|
773
|
+
{ t: 1.25, deg: 0 },
|
|
774
|
+
{ t: 2.0, deg: 15 },
|
|
775
|
+
{ t: 2.5, deg: 25 },
|
|
776
|
+
]
|
|
777
|
+
: (refTiltTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
|
|
611
778
|
const tiltDeform: DeformTimelineEntry[] = [
|
|
612
779
|
{
|
|
613
780
|
slot: 'face',
|
|
@@ -657,6 +824,31 @@ export function applyFaceSurfaceDeform(
|
|
|
657
824
|
});
|
|
658
825
|
}
|
|
659
826
|
for (const tr of tiltAnim.tracks) {
|
|
827
|
+
if (tr.group === 'features' && (tr.property === 'translatey' || tr.property === 'scaley') && featMembers.length > 0) {
|
|
828
|
+
const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 34);
|
|
829
|
+
tr.keys = tr.keys.map((k) => {
|
|
830
|
+
const deg = k.derive?.degrees ?? 0;
|
|
831
|
+
if (deg === 0) return { t: k.t, v: tr.property === 'scaley' ? [1] : [0] };
|
|
832
|
+
const vMap: Record<string, number[]> = {};
|
|
833
|
+
for (const bname of featMembers) {
|
|
834
|
+
const b = boneMap.get(bname);
|
|
835
|
+
const bx = b?.x ?? 0;
|
|
836
|
+
const by = b?.y ?? Y0;
|
|
837
|
+
const z = memberDepthOf(k.derive?.depth, bname, zRef);
|
|
838
|
+
const dFrac = zRef > 0 ? z / zRef : 1;
|
|
839
|
+
const uy = Math.max(-1, Math.min(1, (by - Y0) / (H / 2)));
|
|
840
|
+
const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - uy * uy));
|
|
841
|
+
const [, dyD] = evalPitchFaceSurface(bx, by - featRy, effDeg, W, H, X0, Y0, f);
|
|
842
|
+
const [, dyU] = evalPitchFaceSurface(bx, by + featRy, effDeg, W, H, X0, Y0, f);
|
|
843
|
+
if (tr.property === 'translatey') {
|
|
844
|
+
vMap[bname] = [pyRound(0.5 * (dyD + dyU), 4)];
|
|
845
|
+
} else {
|
|
846
|
+
vMap[bname] = [pyRound(Math.max(0.2, 1 + (dyU - dyD) / (2 * featRy)), 4)];
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return { t: k.t, v: vMap };
|
|
850
|
+
});
|
|
851
|
+
}
|
|
660
852
|
if (tr.group === 'ears' && tr.property === 'translatey') {
|
|
661
853
|
tr.keys = tr.keys.map((k) => {
|
|
662
854
|
const deg = k.derive?.degrees ?? 0;
|
|
@@ -674,7 +866,7 @@ export function applyFaceSurfaceDeform(
|
|
|
674
866
|
});
|
|
675
867
|
}
|
|
676
868
|
}
|
|
677
|
-
const refTiltKeys =
|
|
869
|
+
const refTiltKeys = refTiltTrack?.keys ?? [];
|
|
678
870
|
for (const [earBone, earHx] of [
|
|
679
871
|
['ear_r', earRx],
|
|
680
872
|
['ear_l', earLx],
|
|
@@ -696,13 +888,19 @@ export function applyFaceSurfaceDeform(
|
|
|
696
888
|
|
|
697
889
|
if (turnAnim !== undefined) {
|
|
698
890
|
const origTurnTracks = turnAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
891
|
+
const refTurnTrack = origTurnTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTurnTracks[0];
|
|
892
|
+
const isLegacyTurn =
|
|
893
|
+
turnAnim.duration === 3.0 &&
|
|
894
|
+
Math.abs(refTurnTrack?.keys[0]?.derive?.degrees ?? 0) === 30;
|
|
895
|
+
const turnKeys: Array<{ t: number; deg: number }> = isLegacyTurn
|
|
896
|
+
? [
|
|
897
|
+
{ t: 0, deg: -30 },
|
|
898
|
+
{ t: 0.75, deg: -15 },
|
|
899
|
+
{ t: 1.5, deg: 0 },
|
|
900
|
+
{ t: 2.25, deg: 15 },
|
|
901
|
+
{ t: 3.0, deg: 30 },
|
|
902
|
+
]
|
|
903
|
+
: (refTurnTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
|
|
706
904
|
const turnDeform: DeformTimelineEntry[] = [
|
|
707
905
|
{
|
|
708
906
|
slot: 'face',
|
|
@@ -753,25 +951,84 @@ export function applyFaceSurfaceDeform(
|
|
|
753
951
|
),
|
|
754
952
|
});
|
|
755
953
|
}
|
|
756
|
-
const refTurnKeys =
|
|
954
|
+
const refTurnKeys = refTurnTrack?.keys ?? [];
|
|
955
|
+
const earMembers = motion.groups?.ears ?? ['ear_r', 'ear_l'].filter((b) => boneMap.has(b));
|
|
956
|
+
const hasAuthoredEarTurnTx = turnAnim.tracks.some(
|
|
957
|
+
(tr) => tr.property === 'translatex' && (tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l'),
|
|
958
|
+
);
|
|
757
959
|
turnAnim.tracks = turnAnim.tracks.filter(
|
|
758
|
-
(tr) => !(tr.group === '
|
|
960
|
+
(tr) => !(tr.group === 'topknot' && tr.property === 'translatex'),
|
|
759
961
|
);
|
|
760
|
-
for (const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
if (!boneMap.has(earBone)) continue;
|
|
765
|
-
turnAnim.tracks.push({
|
|
766
|
-
bone: earBone,
|
|
767
|
-
property: 'translatex',
|
|
768
|
-
keys: refTurnKeys.map((k) => {
|
|
962
|
+
for (const tr of turnAnim.tracks) {
|
|
963
|
+
if (tr.group === 'features' && (tr.property === 'translatex' || tr.property === 'scalex') && featMembers.length > 0) {
|
|
964
|
+
const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 36);
|
|
965
|
+
tr.keys = tr.keys.map((k) => {
|
|
769
966
|
const deg = k.derive?.degrees ?? 0;
|
|
967
|
+
if (deg === 0) return { t: k.t, v: tr.property === 'scalex' ? [1] : [0] };
|
|
968
|
+
const vMap: Record<string, number[]> = {};
|
|
969
|
+
const s = Math.sign(deg);
|
|
970
|
+
for (const bname of featMembers) {
|
|
971
|
+
const b = boneMap.get(bname);
|
|
972
|
+
const bx = b?.x ?? X0;
|
|
973
|
+
const by = b?.y ?? Y0;
|
|
974
|
+
const z = memberDepthOf(k.derive?.depth, bname, zRef);
|
|
975
|
+
const dFrac = zRef > 0 ? z / zRef : 1;
|
|
976
|
+
const ux = Math.max(-1, Math.min(1, (bx - X0) / (W / 2)));
|
|
977
|
+
const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - ux * ux));
|
|
978
|
+
const [dxL] = evalYawFaceSurface(bx - featRx, by, effDeg, W, H, X0, Y0, f);
|
|
979
|
+
const [dxR] = evalYawFaceSurface(bx + featRx, by, effDeg, W, H, X0, Y0, f);
|
|
980
|
+
const rawDelta = (dxR - dxL) / (2 * featRx);
|
|
981
|
+
const isTrailing = s * (bx - X0) > featRx * 0.5;
|
|
982
|
+
const sx = isTrailing ? 1 + Math.abs(rawDelta) : Math.max(0.2, 1 + rawDelta);
|
|
983
|
+
const dx = isTrailing
|
|
984
|
+
? (s > 0 ? dxR - featRx * (sx - 1) : dxL + featRx * (sx - 1))
|
|
985
|
+
: 0.5 * (dxL + dxR);
|
|
986
|
+
if (tr.property === 'translatex') {
|
|
987
|
+
vMap[bname] = [pyRound(dx, 4)];
|
|
988
|
+
} else {
|
|
989
|
+
vMap[bname] = [pyRound(sx, 4)];
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return { t: k.t, v: vMap };
|
|
993
|
+
});
|
|
994
|
+
} else if (tr.group === 'ears' && tr.property === 'translatex' && earMembers.length > 0) {
|
|
995
|
+
tr.keys = tr.keys.map((k) => {
|
|
996
|
+
if (k.derive === undefined) return k;
|
|
997
|
+
const deg = k.derive.degrees;
|
|
770
998
|
if (deg === 0) return { t: k.t, v: [0] };
|
|
771
|
-
const [
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
999
|
+
const vMap: Record<string, number[]> = {};
|
|
1000
|
+
for (const bname of earMembers) {
|
|
1001
|
+
vMap[bname] = [evalDeriveKey(bname, k)];
|
|
1002
|
+
}
|
|
1003
|
+
return { t: k.t, v: vMap };
|
|
1004
|
+
});
|
|
1005
|
+
} else if ((tr.bone === 'ear_r' || tr.bone === 'ear_l') && tr.property === 'translatex') {
|
|
1006
|
+
const bname = tr.bone;
|
|
1007
|
+
tr.keys = tr.keys.map((k) => {
|
|
1008
|
+
if (k.derive === undefined) return k;
|
|
1009
|
+
const deg = k.derive.degrees;
|
|
1010
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
1011
|
+
return { t: k.t, v: [evalDeriveKey(bname, k)] };
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (!hasAuthoredEarTurnTx) {
|
|
1016
|
+
for (const [earBone, earHx] of [
|
|
1017
|
+
['ear_r', earRx],
|
|
1018
|
+
['ear_l', earLx],
|
|
1019
|
+
] as const) {
|
|
1020
|
+
if (!boneMap.has(earBone)) continue;
|
|
1021
|
+
turnAnim.tracks.push({
|
|
1022
|
+
bone: earBone,
|
|
1023
|
+
property: 'translatex',
|
|
1024
|
+
keys: refTurnKeys.map((k) => {
|
|
1025
|
+
const deg = k.derive?.degrees ?? 0;
|
|
1026
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
1027
|
+
const [dxCheekEdge] = evalYawFaceSurface(earHx, hyEar, deg, W, H, X0, Y0, f);
|
|
1028
|
+
return { t: k.t, v: [pyRound(dxCheekEdge * f.turn_ear_x, 4)] };
|
|
1029
|
+
}),
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
775
1032
|
}
|
|
776
1033
|
for (const [tkBone, tkFrac] of [
|
|
777
1034
|
['bun_ctl', f.turn_bun_counter_x],
|