@rig-c/parts 1.32.0 → 1.34.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 +22 -2
- package/docs/STABILITY.md +4 -1
- package/package.json +2 -2
- package/src/build.ts +9 -6
- package/src/config.ts +171 -4
- package/src/parts.ts +1 -0
- package/src/propose.ts +1 -1
- package/src/rig.ts +24 -2
- package/src/turn.ts +655 -96
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
|
/**
|
|
@@ -108,8 +109,6 @@ export const FACE_DEFORM_FRACTIONS = {
|
|
|
108
109
|
tilt_topknot_y: 0.5,
|
|
109
110
|
/** Forehead bang forward parallax per degree of yaw (`W/deg`; `-3.50 px` at `+15°` on `W = 106`). */
|
|
110
111
|
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
112
|
/** Rear bun counter-parallax per degree of yaw (`W/deg`; `+8.00 px` at `+15°` on `W = 106`). */
|
|
114
113
|
turn_bun_counter_x: 8.0 / 15 / 106,
|
|
115
114
|
/** Rear hairpin counter-parallax per degree of yaw (`W/deg`; `+6.00 px` at `+15°` on `W = 106`). */
|
|
@@ -143,7 +142,6 @@ export const FACE_DEFORM_RANGES: Readonly<Record<FaceDeformKey, readonly [number
|
|
|
143
142
|
tilt_ear_y: [0, 1.5],
|
|
144
143
|
tilt_topknot_y: [0, 1.5],
|
|
145
144
|
turn_bang_parallax_x: [-0.015, 0],
|
|
146
|
-
turn_hair_back_cheek_x: [0, 1.5],
|
|
147
145
|
turn_bun_counter_x: [0, 0.02],
|
|
148
146
|
turn_hairpin_counter_x: [0, 0.02],
|
|
149
147
|
turn_ear_x: [0, 1.5],
|
|
@@ -165,13 +163,93 @@ export function resolveFaceDeformFractions(overrides?: Readonly<Partial<FaceDefo
|
|
|
165
163
|
return out;
|
|
166
164
|
}
|
|
167
165
|
|
|
168
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* Issue #472: the rated band of a single face plate — yaw `±15°`, pitch `±12°`.
|
|
168
|
+
* Beyond this band on one plate, the foreshortened far eye runs out of cheek to
|
|
169
|
+
* sit on; going further takes an alternate face set (`assemble.alternates`),
|
|
170
|
+
* whose swap threshold is this band edge.
|
|
171
|
+
*/
|
|
172
|
+
export const FACE_RATED_YAW_BAND = 15;
|
|
173
|
+
export const FACE_RATED_PITCH_BAND = 12;
|
|
174
|
+
export const FACE_RATED_BAND = { yaw: FACE_RATED_YAW_BAND, pitch: FACE_RATED_PITCH_BAND } as const;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Issue #477: allowed `[min, max]` range (in rig px) for an authored ear
|
|
178
|
+
* `derive.depth` in `turn` or `tilt`. Ears sit at or behind the head pivot
|
|
179
|
+
* (`depth <= 0`, where `-14` moves the ears slightly against the face turn); a
|
|
180
|
+
* depth outside `[-50, 0]` is refused by `CONFIG_TURN_EAR_DEPTH_RANGE`.
|
|
181
|
+
*/
|
|
182
|
+
export const EAR_DEPTH_RANGE: readonly [number, number] = [-50, 0];
|
|
183
|
+
|
|
184
|
+
/** Default part names belonging to the `head` subtree when computing the head-only turn pivot (issue #478). */
|
|
185
|
+
export const DEFAULT_HEAD_TURN_PARTS: readonly string[] = [
|
|
186
|
+
'face',
|
|
187
|
+
'nose',
|
|
188
|
+
'mouth',
|
|
189
|
+
'eyewhite_r',
|
|
190
|
+
'eyewhite_l',
|
|
191
|
+
'iris_r',
|
|
192
|
+
'iris_l',
|
|
193
|
+
'irides_r',
|
|
194
|
+
'irides_l',
|
|
195
|
+
'lash_r',
|
|
196
|
+
'lash_l',
|
|
197
|
+
'eyelash_r',
|
|
198
|
+
'eyelash_l',
|
|
199
|
+
'brow_r',
|
|
200
|
+
'brow_l',
|
|
201
|
+
'eyebrow_r',
|
|
202
|
+
'eyebrow_l',
|
|
203
|
+
'ear_r',
|
|
204
|
+
'ear_l',
|
|
205
|
+
'ears',
|
|
206
|
+
'hair_front',
|
|
207
|
+
'hair_back',
|
|
208
|
+
'hairpin',
|
|
209
|
+
'headwear',
|
|
210
|
+
'bun',
|
|
211
|
+
'topknot',
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
/** `config.turn`: the pivot depth, each carried part's See-through `depth_median`, optional head-only pivot override, optional curved-surface cylinder radius, optional rated band, and optional face surface deform overrides. */
|
|
169
215
|
export interface TurnSpec {
|
|
170
216
|
pivot: number;
|
|
171
217
|
depths: Record<string, number>;
|
|
218
|
+
head?: { pivot?: number; radius?: number };
|
|
219
|
+
surface?: { radius?: number };
|
|
220
|
+
radius?: number;
|
|
221
|
+
band?: number | { yaw?: number; pitch?: number };
|
|
172
222
|
deform?: Partial<FaceDeformFractions>;
|
|
173
223
|
}
|
|
174
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Issue #479: the cylinder radius `R` (in rig px) for the curved-surface face
|
|
227
|
+
* yaw projection — `spec.surface.radius`, `spec.radius`, or `spec.head.radius`
|
|
228
|
+
* when declared, defaulting to half the face plate's opaque width (`W / 2`).
|
|
229
|
+
*/
|
|
230
|
+
export function turnSurfaceRadius(spec: TurnSpec | null | undefined, W: number): number {
|
|
231
|
+
const r = spec?.surface?.radius ?? spec?.radius ?? spec?.head?.radius;
|
|
232
|
+
return typeof r === 'number' && Number.isFinite(r) && r > 0 ? r : W / 2;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Issue #479: horizontal displacement `Δx` of a point at rest offset `x`
|
|
237
|
+
* (`hx - X0`) on a vertical cylinder of radius `radius` turned by `deg`
|
|
238
|
+
* degrees of yaw (`θ = deg · π / 180`), with the rotated surface angle clamped
|
|
239
|
+
* to the visible limb `[-π/2, π/2]`. At the leading limb (`u = -sign(deg)`),
|
|
240
|
+
* `Δx = 0` so the leading outline holds fixed; across the interior (`|u| < 1`),
|
|
241
|
+
* points slide toward the leading side (`-radius · sin θ` at `x = 0`), widening
|
|
242
|
+
* the trailing cheek and foreshortening the leading cheek.
|
|
243
|
+
*/
|
|
244
|
+
export function cylinderYawDx(x: number, deg: number, radius: number): number {
|
|
245
|
+
if (deg === 0 || !(radius > 0)) return 0;
|
|
246
|
+
const theta = (deg * Math.PI) / 180;
|
|
247
|
+
const u = Math.max(-1, Math.min(1, x / radius));
|
|
248
|
+
const phi0 = Math.asin(u);
|
|
249
|
+
const phi1 = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, phi0 - theta));
|
|
250
|
+
return radius * (Math.sin(phi1) - u);
|
|
251
|
+
}
|
|
252
|
+
|
|
175
253
|
/** One carried part: its depth and the fraction of the control's offset it travels. */
|
|
176
254
|
export interface TurnFraction {
|
|
177
255
|
part: string;
|
|
@@ -218,6 +296,48 @@ export function turnPivot(rows: ReadonlyArray<{ depth: number; area: number }>):
|
|
|
218
296
|
return a > 0 ? pyRound(ad / a, TURN_PLACES) : null;
|
|
219
297
|
}
|
|
220
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Issue #478: the head-only turn pivot — either `spec.head.pivot` when
|
|
301
|
+
* declared, or the `opaque_px`-weighted mean of `spec.depths` restricted to the
|
|
302
|
+
* `head` subtree parts (`headParts`). Neck and torso parts are excluded so a
|
|
303
|
+
* front-heavy body does not pull the head's rotation axis forward of the skull.
|
|
304
|
+
*/
|
|
305
|
+
export function headTurnPivot(
|
|
306
|
+
spec: TurnSpec,
|
|
307
|
+
headParts: ReadonlySet<string> = new Set(DEFAULT_HEAD_TURN_PARTS),
|
|
308
|
+
partAreas?: Readonly<Record<string, number>>,
|
|
309
|
+
): number | null {
|
|
310
|
+
if (typeof spec.head?.pivot === 'number' && Number.isFinite(spec.head.pivot)) {
|
|
311
|
+
return pyRound(spec.head.pivot, TURN_PLACES);
|
|
312
|
+
}
|
|
313
|
+
const headEntries = Object.entries(spec.depths).filter(([part, d]) => headParts.has(part) && typeof d === 'number' && Number.isFinite(d));
|
|
314
|
+
const entries = headEntries.length > 0 ? headEntries : Object.entries(spec.depths).filter(([, d]) => typeof d === 'number' && Number.isFinite(d));
|
|
315
|
+
return turnPivot(entries.map(([part, depth]) => ({ depth, area: partAreas?.[part] ?? 1 })));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Issue #478: a head-mounted part's turn fraction `(headPivot − depth) / (headPivot − headNearest)`
|
|
320
|
+
* about the head-only pivot. Parts behind `headPivot` (`hair_back`, rear `hairpin`)
|
|
321
|
+
* receive a negative fraction and move opposite to the face and irises.
|
|
322
|
+
*/
|
|
323
|
+
export function headTurnFraction(
|
|
324
|
+
spec: TurnSpec,
|
|
325
|
+
part: string,
|
|
326
|
+
headParts: ReadonlySet<string> = new Set(DEFAULT_HEAD_TURN_PARTS),
|
|
327
|
+
partAreas?: Readonly<Record<string, number>>,
|
|
328
|
+
): number | null {
|
|
329
|
+
const depth = spec.depths[part];
|
|
330
|
+
if (typeof depth !== 'number' || !Number.isFinite(depth)) return null;
|
|
331
|
+
const pivot = headTurnPivot(spec, headParts, partAreas);
|
|
332
|
+
const headDepths: Record<string, number> = {};
|
|
333
|
+
for (const [p, d] of Object.entries(spec.depths)) {
|
|
334
|
+
if (headParts.has(p) && typeof d === 'number' && Number.isFinite(d)) headDepths[p] = d;
|
|
335
|
+
}
|
|
336
|
+
const nearest = turnNearest(Object.keys(headDepths).length > 0 ? headDepths : spec.depths);
|
|
337
|
+
if (pivot === null || nearest === null || !(pivot > nearest.depth)) return null;
|
|
338
|
+
return turnFraction(pivot, nearest.depth, depth);
|
|
339
|
+
}
|
|
340
|
+
|
|
221
341
|
/** The leaf bone that carries `part`'s share of `bone` under the turn: an identity child of `bone`, so it follows everything `bone` does. */
|
|
222
342
|
export function turnTwin(part: string, bone: string): string {
|
|
223
343
|
return `${TURN_BONE}_${part}_${bone}`;
|
|
@@ -257,7 +377,10 @@ export function evalPitchFaceSurface(
|
|
|
257
377
|
|
|
258
378
|
/**
|
|
259
379
|
* Evaluate the yaw (`turn`) surface deformation field `(dx, dy)` in `head`
|
|
260
|
-
* space at `(hx, hy)` for `deg` degrees of yaw
|
|
380
|
+
* space at `(hx, hy)` for `deg` degrees of yaw (issue #479: cylinder projection
|
|
381
|
+
* of radius `radius`, default `W / 2`, with lower-face jaw taper so the leading
|
|
382
|
+
* jaw edge holds and the trailing cheek widens; `flatYawTable: true` restores
|
|
383
|
+
* the flat yaw table for the Bar 4 negative control or explicit fraction overrides).
|
|
261
384
|
*/
|
|
262
385
|
export function evalYawFaceSurface(
|
|
263
386
|
hx: number,
|
|
@@ -268,16 +391,28 @@ export function evalYawFaceSurface(
|
|
|
268
391
|
X0: number,
|
|
269
392
|
Y0: number,
|
|
270
393
|
f: Readonly<FaceDeformFractions> = FACE_DEFORM_FRACTIONS,
|
|
394
|
+
radius?: number,
|
|
395
|
+
flatYawTable = false,
|
|
271
396
|
): [number, number] {
|
|
272
397
|
if (deg === 0) return [0, 0];
|
|
273
398
|
const s = Math.sign(deg);
|
|
274
399
|
const ux = Math.max(-1.35, Math.min(1.35, (hx - X0) / (W / 2)));
|
|
275
400
|
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
401
|
const fracYPerDeg = unmult(f.turn_cheek_arch_y) * (-s * ux) * Math.max(0, 1 - Math.abs(uy));
|
|
280
|
-
|
|
402
|
+
const dy = FACE_DEFORM_MULT * deg * H * fracYPerDeg;
|
|
403
|
+
if (flatYawTable) {
|
|
404
|
+
const uLead = Math.min(1.2, Math.max(0, -s * ux));
|
|
405
|
+
const uTrail = Math.min(1.2, Math.max(0, s * ux));
|
|
406
|
+
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);
|
|
407
|
+
return [FACE_DEFORM_MULT * deg * W * fracXPerDeg, dy];
|
|
408
|
+
}
|
|
409
|
+
const R = radius !== undefined && radius > 0 ? radius : W / 2;
|
|
410
|
+
const rawDx = cylinderYawDx(hx - X0, deg, R);
|
|
411
|
+
const absUx = Math.abs((hx - X0) / (W / 2));
|
|
412
|
+
const tOutline = Math.max(0, Math.min(1, (absUx - 0.7) / (1.0 - 0.7)));
|
|
413
|
+
const wOutline = 1 - tOutline * tOutline * (3 - 2 * tOutline);
|
|
414
|
+
const dx = rawDx * wOutline;
|
|
415
|
+
return [dx, dy];
|
|
281
416
|
}
|
|
282
417
|
|
|
283
418
|
export interface FaceSurfaceDeformOptions {
|
|
@@ -291,6 +426,37 @@ export interface FaceSurfaceDeformOptions {
|
|
|
291
426
|
opaqueHeight?: number;
|
|
292
427
|
/** Recipe overrides for {@link FACE_DEFORM_FRACTIONS}. */
|
|
293
428
|
overrides?: Readonly<Partial<FaceDeformFractions>> | null;
|
|
429
|
+
/**
|
|
430
|
+
* Issue #472: true when `assemble.alternates` declares an alternate face set
|
|
431
|
+
* for the overflow beyond `±15°` yaw / `±12°` pitch. When false or absent,
|
|
432
|
+
* any `yaw` / `pitch` slider constraints on the rig are preceded by clamping
|
|
433
|
+
* transform constraints holding the dial bones to `[-15, 15]` and `[-12, 12]`.
|
|
434
|
+
*/
|
|
435
|
+
hasAlternateFaceSet?: boolean;
|
|
436
|
+
/** Issue #478: `config.turn` holding `pivot`, `depths` and optional `head.pivot` / `radius`. */
|
|
437
|
+
turn?: TurnSpec | null;
|
|
438
|
+
/** Issue #478: each part's `opaque_px` from `parts.json` for weighting the head-only pivot. */
|
|
439
|
+
partAreas?: Readonly<Record<string, number>>;
|
|
440
|
+
/**
|
|
441
|
+
* Issue #478: when true, refuse by `CONFIG_TURN_REQUIRED` if `turn` is absent
|
|
442
|
+
* or missing `depths` for attached `face`, `hair_front`, or `hair_back`.
|
|
443
|
+
*/
|
|
444
|
+
requireTurn?: boolean;
|
|
445
|
+
/**
|
|
446
|
+
* Issue #478 Bar 4 negative control: when true, flip the back-layer fraction
|
|
447
|
+
* sign so `hair_back` and `hairpin` move with the face instead of against it.
|
|
448
|
+
*/
|
|
449
|
+
flipBackLayerSignMutant?: boolean;
|
|
450
|
+
/**
|
|
451
|
+
* Issue #479: explicit cylinder radius override in rig px (defaults to
|
|
452
|
+
* `turnSurfaceRadius(turn, W)` = `W / 2`).
|
|
453
|
+
*/
|
|
454
|
+
surfaceRadius?: number;
|
|
455
|
+
/**
|
|
456
|
+
* Issue #479 Bar 4 negative control: when true, restore the flat yaw table
|
|
457
|
+
* instead of the cylinder projection.
|
|
458
|
+
*/
|
|
459
|
+
flatYawTableMutant?: boolean;
|
|
294
460
|
}
|
|
295
461
|
|
|
296
462
|
interface DeformWeight {
|
|
@@ -328,7 +494,7 @@ interface DeriveSpec {
|
|
|
328
494
|
|
|
329
495
|
interface SliderKey {
|
|
330
496
|
t: number;
|
|
331
|
-
v?: number[]
|
|
497
|
+
v?: number[] | Record<string, number[]>;
|
|
332
498
|
derive?: DeriveSpec;
|
|
333
499
|
}
|
|
334
500
|
|
|
@@ -390,14 +556,16 @@ function buildFaceGrid(width: number, height: number): { points: Array<[number,
|
|
|
390
556
|
/**
|
|
391
557
|
* Promote the `face` region on `head` to a 5×5 grid mesh and populate the
|
|
392
558
|
* `tilt` and `turn` slider animations with the Set B ×2.5 face and hair mesh
|
|
393
|
-
* deform timelines
|
|
394
|
-
*
|
|
395
|
-
* `motion` is touched.
|
|
559
|
+
* deform timelines, feature field-sampled tracks (#468) and ear/topknot tracks
|
|
560
|
+
* (issues #436, #477, #478). When `motion` holds neither `tilt` nor `turn` with
|
|
561
|
+
* slider `derive` keys, neither `rig` nor `motion` is touched.
|
|
396
562
|
*/
|
|
397
563
|
export function applyFaceSurfaceDeform(
|
|
398
564
|
rig: {
|
|
399
565
|
bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number; inherit?: string }>;
|
|
566
|
+
slots?: Array<{ name: string; bone: string }>;
|
|
400
567
|
skins: { default: Record<string, Record<string, DeformMeshAttachment | DeformRegionAttachment>> };
|
|
568
|
+
constraints?: Array<Record<string, unknown>>;
|
|
401
569
|
invariants?: Record<string, unknown>;
|
|
402
570
|
},
|
|
403
571
|
motion: {
|
|
@@ -413,17 +581,141 @@ export function applyFaceSurfaceDeform(
|
|
|
413
581
|
(rawTilt?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false) ||
|
|
414
582
|
(rawTurn?.tracks?.some((tr) => tr.keys?.some((k) => k.derive !== undefined)) ?? false);
|
|
415
583
|
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
584
|
|
|
421
585
|
const isOpts =
|
|
422
586
|
options !== undefined &&
|
|
423
587
|
options !== null &&
|
|
424
|
-
('imageWidth' in options ||
|
|
588
|
+
('imageWidth' in options ||
|
|
589
|
+
'imageHeight' in options ||
|
|
590
|
+
'opaqueWidth' in options ||
|
|
591
|
+
'opaqueHeight' in options ||
|
|
592
|
+
'overrides' in options ||
|
|
593
|
+
'hasAlternateFaceSet' in options ||
|
|
594
|
+
'turn' in options ||
|
|
595
|
+
'partAreas' in options ||
|
|
596
|
+
'requireTurn' in options ||
|
|
597
|
+
'flipBackLayerSignMutant' in options ||
|
|
598
|
+
'surfaceRadius' in options ||
|
|
599
|
+
'flatYawTableMutant' in options);
|
|
425
600
|
const opts: FaceSurfaceDeformOptions = isOpts ? (options as FaceSurfaceDeformOptions) : { overrides: options as Readonly<Partial<FaceDeformFractions>> | null | undefined };
|
|
601
|
+
|
|
602
|
+
// Issue #477 & #478: refuse an out-of-range ear derive.depth or missing config.turn by name before touching rig or motion.
|
|
603
|
+
const preProblems: Problem[] = [];
|
|
604
|
+
if (opts.requireTurn === true) {
|
|
605
|
+
if (opts.turn === undefined || opts.turn === null) {
|
|
606
|
+
preProblems.push({
|
|
607
|
+
code: 'CONFIG_TURN_REQUIRED',
|
|
608
|
+
object: 'config.turn',
|
|
609
|
+
detail: 'is absent and required when motion.animations_from carries a turn or tilt slider animation with derive keys; copy the turn block propose drafted into config.turn so the head pivot and each head part\'s depth are declared',
|
|
610
|
+
});
|
|
611
|
+
} else {
|
|
612
|
+
for (const reqPart of ['face', 'hair_front', 'hair_back'] as const) {
|
|
613
|
+
if (rig.skins.default[reqPart]?.[reqPart] !== undefined && !(reqPart in opts.turn.depths)) {
|
|
614
|
+
preProblems.push({
|
|
615
|
+
code: 'CONFIG_TURN_REQUIRED',
|
|
616
|
+
object: `config.turn.depths.${reqPart}`,
|
|
617
|
+
detail: `is absent and required when motion.animations_from carries a turn or tilt slider animation with derive keys and "${reqPart}" is attached; include "${reqPart}"'s See-through depth_median in config.turn.depths so the head pivot and fraction are defined`,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const [earLo, earHi] = EAR_DEPTH_RANGE;
|
|
624
|
+
for (const [animName, anim] of [
|
|
625
|
+
['tilt', rawTilt],
|
|
626
|
+
['turn', rawTurn],
|
|
627
|
+
] as const) {
|
|
628
|
+
if (anim === undefined) continue;
|
|
629
|
+
anim.tracks.forEach((tr, ti) => {
|
|
630
|
+
const isEarTrack = tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l';
|
|
631
|
+
if (!isEarTrack) return;
|
|
632
|
+
tr.keys.forEach((k, ki) => {
|
|
633
|
+
const d = k.derive?.depth;
|
|
634
|
+
if (typeof d === 'number') {
|
|
635
|
+
if (d < earLo || d > earHi) {
|
|
636
|
+
preProblems.push({
|
|
637
|
+
code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
|
|
638
|
+
object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth`,
|
|
639
|
+
detail: `is ${d}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
} else if (d !== undefined && typeof d === 'object') {
|
|
643
|
+
for (const [member, val] of Object.entries(d)) {
|
|
644
|
+
if (typeof val === 'number' && (val < earLo || val > earHi)) {
|
|
645
|
+
preProblems.push({
|
|
646
|
+
code: 'CONFIG_TURN_EAR_DEPTH_RANGE',
|
|
647
|
+
object: `animations.${animName}.tracks[${ti}].keys[${ki}].derive.depth.${member}`,
|
|
648
|
+
detail: `is ${val}; an ear depth in [${earLo}, ${earHi}] (at or behind the head pivot) is required`,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
refuseIfAny(preProblems);
|
|
657
|
+
|
|
658
|
+
const tiltAnim = rawTilt === undefined ? undefined : structuredClone(rawTilt);
|
|
659
|
+
const turnAnim = rawTurn === undefined ? undefined : structuredClone(rawTurn);
|
|
660
|
+
if (tiltAnim !== undefined) motion.animations.tilt = tiltAnim;
|
|
661
|
+
if (turnAnim !== undefined) motion.animations.turn = turnAnim;
|
|
662
|
+
|
|
426
663
|
const f = resolveFaceDeformFractions(opts.overrides);
|
|
664
|
+
const hasFlatYawOverride =
|
|
665
|
+
opts.overrides !== undefined &&
|
|
666
|
+
opts.overrides !== null &&
|
|
667
|
+
(opts.overrides.turn_mid_x !== undefined ||
|
|
668
|
+
opts.overrides.turn_lead_compress_x !== undefined ||
|
|
669
|
+
opts.overrides.turn_trail_shift_x !== undefined);
|
|
670
|
+
const useFlatYaw = opts.flatYawTableMutant === true || hasFlatYawOverride;
|
|
671
|
+
|
|
672
|
+
// Issue #472: when no alternate face set is declared, clamp dial bones to the single-plate rated band (±15° yaw, ±12° pitch).
|
|
673
|
+
if (!opts.hasAlternateFaceSet && Array.isArray(rig.constraints)) {
|
|
674
|
+
const nextConstraints: Array<Record<string, unknown>> = [];
|
|
675
|
+
const existingNames = new Set(rig.constraints.map((c) => c.name));
|
|
676
|
+
const makeClampConstraint = (name: string, bone: string, prop: unknown, band: number): Record<string, unknown> => {
|
|
677
|
+
const useX = prop === 'x' || prop === 'translatex';
|
|
678
|
+
const key = useX ? 'x' : 'rotate';
|
|
679
|
+
const mixKey = useX ? 'mixX' : 'mixRotate';
|
|
680
|
+
return {
|
|
681
|
+
name,
|
|
682
|
+
type: 'transform',
|
|
683
|
+
source: bone,
|
|
684
|
+
bones: [bone],
|
|
685
|
+
localSource: true,
|
|
686
|
+
localTarget: true,
|
|
687
|
+
clamp: true,
|
|
688
|
+
properties: {
|
|
689
|
+
[key]: {
|
|
690
|
+
offset: -band,
|
|
691
|
+
to: {
|
|
692
|
+
[key]: {
|
|
693
|
+
offset: -band,
|
|
694
|
+
max: band,
|
|
695
|
+
scale: 1,
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
[mixKey]: 1,
|
|
701
|
+
};
|
|
702
|
+
};
|
|
703
|
+
for (const c of rig.constraints) {
|
|
704
|
+
if (c.type === 'slider' && typeof c.bone === 'string') {
|
|
705
|
+
const isYaw = c.name === 'yaw' || c.animation === 'turn';
|
|
706
|
+
const isPitch = c.name === 'pitch' || c.animation === 'tilt';
|
|
707
|
+
if (isYaw && !existingNames.has('yaw_clamp')) {
|
|
708
|
+
existingNames.add('yaw_clamp');
|
|
709
|
+
nextConstraints.push(makeClampConstraint('yaw_clamp', c.bone, c.property, FACE_RATED_YAW_BAND));
|
|
710
|
+
} else if (isPitch && !existingNames.has('pitch_clamp')) {
|
|
711
|
+
existingNames.add('pitch_clamp');
|
|
712
|
+
nextConstraints.push(makeClampConstraint('pitch_clamp', c.bone, c.property, FACE_RATED_PITCH_BAND));
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
nextConstraints.push(c);
|
|
716
|
+
}
|
|
717
|
+
rig.constraints = nextConstraints;
|
|
718
|
+
}
|
|
427
719
|
|
|
428
720
|
const faceSlot = rig.skins.default.face;
|
|
429
721
|
if (faceSlot === undefined || faceSlot.face === undefined) return;
|
|
@@ -434,13 +726,15 @@ export function applyFaceSurfaceDeform(
|
|
|
434
726
|
let faceRegX = -1;
|
|
435
727
|
let faceRegY = 49;
|
|
436
728
|
|
|
729
|
+
const faceBone = rig.slots?.find((s) => s.name === 'face')?.bone ?? (rig.bones.some((b) => b.name === 'turn_face_head') ? 'turn_face_head' : 'head');
|
|
730
|
+
|
|
437
731
|
if (!('type' in faceAtt)) {
|
|
438
732
|
faceRegX = faceAtt.x;
|
|
439
733
|
faceRegY = faceAtt.y;
|
|
440
734
|
const grid = buildFaceGrid(faceW, faceH);
|
|
441
735
|
const faceWeights: DeformWeight[][] = grid.points.map(([px, py]) => [
|
|
442
736
|
{
|
|
443
|
-
bone:
|
|
737
|
+
bone: faceBone,
|
|
444
738
|
x: pyRound(faceRegX - faceW / 2 + px, 6),
|
|
445
739
|
y: pyRound(faceRegY + faceH / 2 - py, 6),
|
|
446
740
|
weight: 1,
|
|
@@ -470,8 +764,8 @@ export function applyFaceSurfaceDeform(
|
|
|
470
764
|
sumY += w[0]?.y ?? 0;
|
|
471
765
|
}
|
|
472
766
|
if (faceAtt.weights.length > 0) {
|
|
473
|
-
faceRegX = Math.round(sumX / faceAtt.weights.length);
|
|
474
|
-
faceRegY = Math.round(sumY / faceAtt.weights.length);
|
|
767
|
+
faceRegX = Math.round((sumX / faceAtt.weights.length) * 2) / 2;
|
|
768
|
+
faceRegY = Math.round((sumY / faceAtt.weights.length) * 2) / 2;
|
|
475
769
|
}
|
|
476
770
|
}
|
|
477
771
|
|
|
@@ -479,9 +773,13 @@ export function applyFaceSurfaceDeform(
|
|
|
479
773
|
const H = opts.opaqueHeight ?? faceH - 8;
|
|
480
774
|
const X0 = faceRegX;
|
|
481
775
|
const Y0 = faceRegY - 1;
|
|
776
|
+
const cylRadius =
|
|
777
|
+
opts.surfaceRadius !== undefined && Number.isFinite(opts.surfaceRadius) && opts.surfaceRadius > 0
|
|
778
|
+
? opts.surfaceRadius
|
|
779
|
+
: turnSurfaceRadius(opts.turn, W);
|
|
482
780
|
|
|
483
781
|
const boneOrder = new Map<string, number>(rig.bones.map((b, i) => [b.name, i]));
|
|
484
|
-
for (const slotName of ['hair_front', 'hair_back']) {
|
|
782
|
+
for (const slotName of ['hair_front', 'hair_back', 'hairpin']) {
|
|
485
783
|
const slotObj = rig.skins.default[slotName];
|
|
486
784
|
const att = slotObj?.[slotName];
|
|
487
785
|
if (att !== undefined && 'type' in att) {
|
|
@@ -531,8 +829,31 @@ export function applyFaceSurfaceDeform(
|
|
|
531
829
|
};
|
|
532
830
|
|
|
533
831
|
const boneMap = new Map(rig.bones.map((b) => [b.name, b]));
|
|
832
|
+
const referencedGroups = new Set<string>();
|
|
833
|
+
for (const anim of [tiltAnim, turnAnim]) {
|
|
834
|
+
for (const tr of anim?.tracks ?? []) {
|
|
835
|
+
if (tr.group !== undefined) referencedGroups.add(tr.group);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const defaultGroups: Readonly<Record<string, readonly string[]>> = {
|
|
839
|
+
features: ['eye_r', 'eye_l', 'brow_r', 'brow_l', 'pupil_r', 'pupil_l', 'eyelash_r', 'eyelash_l', 'eyebrow_r', 'eyebrow_l', 'nose', 'mouth'],
|
|
840
|
+
ears: ['ear_r', 'ear_l'],
|
|
841
|
+
bangs: ['bang_r0', 'bang_c0', 'bang_l0'].map((b) => (boneMap.has(`${b}_ctl`) ? `${b}_ctl` : b)),
|
|
842
|
+
topknot: ['bun_ctl', 'hairpin', 'headwear'],
|
|
843
|
+
};
|
|
844
|
+
for (const g of referencedGroups) {
|
|
845
|
+
if (motion.groups?.[g] === undefined && defaultGroups[g] !== undefined) {
|
|
846
|
+
const members = defaultGroups[g].filter((b) => boneMap.has(b));
|
|
847
|
+
if (members.length > 0) {
|
|
848
|
+
motion.groups ??= {};
|
|
849
|
+
motion.groups[g] = members;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
534
854
|
const evalDeriveKey = (boneName: string, k: SliderKey): number => {
|
|
535
855
|
if (Array.isArray(k.v)) return k.v[0] ?? 0;
|
|
856
|
+
if (k.v !== undefined && typeof k.v === 'object') return k.v[boneName]?.[0] ?? 0;
|
|
536
857
|
const d = k.derive;
|
|
537
858
|
if (d === undefined) return 0;
|
|
538
859
|
const b = boneMap.get(boneName);
|
|
@@ -546,27 +867,44 @@ export function applyFaceSurfaceDeform(
|
|
|
546
867
|
};
|
|
547
868
|
|
|
548
869
|
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',
|
|
870
|
+
bang_r0: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
|
|
871
|
+
bang_r1: boneMap.has('bang_r0_ctl') ? 'bang_r0_ctl' : 'bang_r0',
|
|
872
|
+
bang_c0: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
|
|
873
|
+
bang_c1: boneMap.has('bang_c0_ctl') ? 'bang_c0_ctl' : 'bang_c0',
|
|
874
|
+
bang_l0: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
|
|
875
|
+
bang_l1: boneMap.has('bang_l0_ctl') ? 'bang_l0_ctl' : 'bang_l0',
|
|
876
|
+
bun: boneMap.has('bun_ctl') ? 'bun_ctl' : 'bun',
|
|
556
877
|
};
|
|
557
878
|
|
|
558
879
|
const getBoneDeltaAtTime = (tracks: readonly SliderTrack[], t: number, boneName: string): [number, number] => {
|
|
559
|
-
const
|
|
880
|
+
const drivers = new Set<string>();
|
|
881
|
+
let cur: string | undefined = boneName;
|
|
882
|
+
const visited = new Set<string>();
|
|
883
|
+
while (cur !== undefined && !visited.has(cur) && cur !== 'head' && cur !== 'turn_face_head' && cur !== 'root') {
|
|
884
|
+
visited.add(cur);
|
|
885
|
+
if (!cur.startsWith('turn_')) {
|
|
886
|
+
drivers.add(mapParent[cur] ?? cur);
|
|
887
|
+
}
|
|
888
|
+
cur = boneMap.get(cur)?.parent;
|
|
889
|
+
}
|
|
890
|
+
if (drivers.size === 0) {
|
|
891
|
+
const stripped = boneName.replace(/^turn_[^_]+_/, '');
|
|
892
|
+
if (stripped !== 'head' && stripped !== 'root') {
|
|
893
|
+
drivers.add(mapParent[stripped] ?? stripped);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
560
896
|
let dx = 0;
|
|
561
897
|
let dy = 0;
|
|
562
|
-
for (const
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
898
|
+
for (const driver of drivers) {
|
|
899
|
+
for (const tr of tracks) {
|
|
900
|
+
const inGroup = tr.group !== undefined && (motion.groups?.[tr.group]?.includes(driver) ?? false);
|
|
901
|
+
if (tr.bone !== driver && !inGroup) continue;
|
|
902
|
+
const key = tr.keys.find((k) => Math.abs(k.t - t) < 1e-6);
|
|
903
|
+
if (key === undefined) continue;
|
|
904
|
+
const val = evalDeriveKey(driver, key);
|
|
905
|
+
if (tr.property === 'translatex') dx += val;
|
|
906
|
+
if (tr.property === 'translatey') dy += val;
|
|
907
|
+
}
|
|
570
908
|
}
|
|
571
909
|
return [dx, dy];
|
|
572
910
|
};
|
|
@@ -586,10 +924,14 @@ export function applyFaceSurfaceDeform(
|
|
|
586
924
|
const faceVerts = getMeshSetup(faceMesh);
|
|
587
925
|
const hairFrontAtt = rig.skins.default.hair_front?.hair_front;
|
|
588
926
|
const hairBackAtt = rig.skins.default.hair_back?.hair_back;
|
|
927
|
+
const hairpinAtt = rig.skins.default.hairpin?.hairpin;
|
|
589
928
|
const hairFrontVerts = hairFrontAtt !== undefined && 'type' in hairFrontAtt ? getMeshSetup(hairFrontAtt) : null;
|
|
590
929
|
const hairBackVerts = hairBackAtt !== undefined && 'type' in hairBackAtt ? getMeshSetup(hairBackAtt) : null;
|
|
930
|
+
const hairpinVerts = hairpinAtt !== undefined && 'type' in hairpinAtt && !boneMap.has('hairpin') ? getMeshSetup(hairpinAtt) : null;
|
|
591
931
|
|
|
592
932
|
const hyCrown = Y0 + (67 / 128) * H;
|
|
933
|
+
const hyCrownStart = Y0 + (24 / 128) * H;
|
|
934
|
+
const hyCrownZero = Y0 + (48 / 128) * H;
|
|
593
935
|
const hyBunSpan = (85 / 128) * H;
|
|
594
936
|
const hyBangBottom = Y0 - (68 / 128) * H;
|
|
595
937
|
const hyBangSpan = (130 / 128) * H;
|
|
@@ -598,16 +940,37 @@ export function applyFaceSurfaceDeform(
|
|
|
598
940
|
const hyEar = Y0 - (16 / 128) * H;
|
|
599
941
|
const earRx = boneMap.get('ear_r')?.x ?? -51;
|
|
600
942
|
const earLx = boneMap.get('ear_l')?.x ?? 47.5;
|
|
943
|
+
const featRx = (24 / 106) * W;
|
|
944
|
+
const featRy = (12 / 128) * H;
|
|
945
|
+
const featMembers = motion.groups?.features ?? [];
|
|
946
|
+
|
|
947
|
+
const minDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, fallback: number): number => {
|
|
948
|
+
if (typeof depth === 'number') return depth > 0 ? depth : fallback;
|
|
949
|
+
if (depth === undefined) return fallback;
|
|
950
|
+
const vals = Object.values(depth).filter((v): v is number => typeof v === 'number' && v > 0);
|
|
951
|
+
return vals.length > 0 ? Math.min(...vals) : fallback;
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const memberDepthOf = (depth: number | Readonly<Record<string, number>> | undefined, boneName: string, zRef: number): number => {
|
|
955
|
+
if (typeof depth === 'number') return depth;
|
|
956
|
+
return depth?.[boneName] ?? zRef;
|
|
957
|
+
};
|
|
601
958
|
|
|
602
959
|
if (tiltAnim !== undefined) {
|
|
603
960
|
const origTiltTracks = tiltAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
961
|
+
const refTiltTrack = origTiltTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTiltTracks[0];
|
|
962
|
+
const isLegacyTilt =
|
|
963
|
+
tiltAnim.duration === 2.5 &&
|
|
964
|
+
Math.abs(refTiltTrack?.keys[0]?.derive?.degrees ?? 0) === 25;
|
|
965
|
+
const tiltKeys: Array<{ t: number; deg: number }> = isLegacyTilt
|
|
966
|
+
? [
|
|
967
|
+
{ t: 0, deg: -25 },
|
|
968
|
+
{ t: 0.75, deg: -10 },
|
|
969
|
+
{ t: 1.25, deg: 0 },
|
|
970
|
+
{ t: 2.0, deg: 15 },
|
|
971
|
+
{ t: 2.5, deg: 25 },
|
|
972
|
+
]
|
|
973
|
+
: (refTiltTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
|
|
611
974
|
const tiltDeform: DeformTimelineEntry[] = [
|
|
612
975
|
{
|
|
613
976
|
slot: 'face',
|
|
@@ -657,6 +1020,31 @@ export function applyFaceSurfaceDeform(
|
|
|
657
1020
|
});
|
|
658
1021
|
}
|
|
659
1022
|
for (const tr of tiltAnim.tracks) {
|
|
1023
|
+
if (tr.group === 'features' && (tr.property === 'translatey' || tr.property === 'scaley') && featMembers.length > 0) {
|
|
1024
|
+
const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 34);
|
|
1025
|
+
tr.keys = tr.keys.map((k) => {
|
|
1026
|
+
const deg = k.derive?.degrees ?? 0;
|
|
1027
|
+
if (deg === 0) return { t: k.t, v: tr.property === 'scaley' ? [1] : [0] };
|
|
1028
|
+
const vMap: Record<string, number[]> = {};
|
|
1029
|
+
for (const bname of featMembers) {
|
|
1030
|
+
const b = boneMap.get(bname);
|
|
1031
|
+
const bx = b?.x ?? 0;
|
|
1032
|
+
const by = b?.y ?? Y0;
|
|
1033
|
+
const z = memberDepthOf(k.derive?.depth, bname, zRef);
|
|
1034
|
+
const dFrac = zRef > 0 ? z / zRef : 1;
|
|
1035
|
+
const uy = Math.max(-1, Math.min(1, (by - Y0) / (H / 2)));
|
|
1036
|
+
const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - uy * uy));
|
|
1037
|
+
const [, dyD] = evalPitchFaceSurface(bx, by - featRy, effDeg, W, H, X0, Y0, f);
|
|
1038
|
+
const [, dyU] = evalPitchFaceSurface(bx, by + featRy, effDeg, W, H, X0, Y0, f);
|
|
1039
|
+
if (tr.property === 'translatey') {
|
|
1040
|
+
vMap[bname] = [pyRound(0.5 * (dyD + dyU), 4)];
|
|
1041
|
+
} else {
|
|
1042
|
+
vMap[bname] = [pyRound(Math.max(0.2, 1 + (dyU - dyD) / (2 * featRy)), 4)];
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return { t: k.t, v: vMap };
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
660
1048
|
if (tr.group === 'ears' && tr.property === 'translatey') {
|
|
661
1049
|
tr.keys = tr.keys.map((k) => {
|
|
662
1050
|
const deg = k.derive?.degrees ?? 0;
|
|
@@ -674,7 +1062,7 @@ export function applyFaceSurfaceDeform(
|
|
|
674
1062
|
});
|
|
675
1063
|
}
|
|
676
1064
|
}
|
|
677
|
-
const refTiltKeys =
|
|
1065
|
+
const refTiltKeys = refTiltTrack?.keys ?? [];
|
|
678
1066
|
for (const [earBone, earHx] of [
|
|
679
1067
|
['ear_r', earRx],
|
|
680
1068
|
['ear_l', earLx],
|
|
@@ -696,19 +1084,191 @@ export function applyFaceSurfaceDeform(
|
|
|
696
1084
|
|
|
697
1085
|
if (turnAnim !== undefined) {
|
|
698
1086
|
const origTurnTracks = turnAnim.tracks.map((tr) => ({ ...tr, keys: [...tr.keys] }));
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1087
|
+
const refTurnTrack = origTurnTracks.find((tr) => tr.keys.some((k) => k.derive !== undefined)) ?? origTurnTracks[0];
|
|
1088
|
+
const isLegacyTurn =
|
|
1089
|
+
turnAnim.duration === 3.0 &&
|
|
1090
|
+
Math.abs(refTurnTrack?.keys[0]?.derive?.degrees ?? 0) === 30;
|
|
1091
|
+
const turnKeys: Array<{ t: number; deg: number }> = isLegacyTurn
|
|
1092
|
+
? [
|
|
1093
|
+
{ t: 0, deg: -30 },
|
|
1094
|
+
{ t: 0.75, deg: -15 },
|
|
1095
|
+
{ t: 1.5, deg: 0 },
|
|
1096
|
+
{ t: 2.25, deg: 15 },
|
|
1097
|
+
{ t: 3.0, deg: 30 },
|
|
1098
|
+
]
|
|
1099
|
+
: (refTurnTrack?.keys ?? []).map((k) => ({ t: k.t, deg: k.derive?.degrees ?? 0 }));
|
|
1100
|
+
const refTurnKeys = refTurnTrack?.keys ?? [];
|
|
1101
|
+
|
|
1102
|
+
// Issue #478: compute head-subtree turn fractions about the head-only pivot.
|
|
1103
|
+
const headParts = new Set<string>(DEFAULT_HEAD_TURN_PARTS);
|
|
1104
|
+
|
|
1105
|
+
const backSign = opts.flipBackLayerSignMutant === true ? -1 : 1;
|
|
1106
|
+
const rawBackFrac = opts.turn != null ? headTurnFraction(opts.turn, 'hair_back', headParts, opts.partAreas) : null;
|
|
1107
|
+
const rawHairpinFrac = opts.turn != null ? headTurnFraction(opts.turn, 'hairpin', headParts, opts.partAreas) : null;
|
|
1108
|
+
const hairpinRidesBack =
|
|
1109
|
+
hairpinAtt !== undefined &&
|
|
1110
|
+
'type' in hairpinAtt &&
|
|
1111
|
+
hairpinAtt.weights.some((infls) => infls.some((w) => w.bone.includes('hairback_')));
|
|
1112
|
+
|
|
1113
|
+
const dxFaceCenterAt = (deg: number): number => FACE_DEFORM_MULT * deg * W * unmult(f.turn_mid_x);
|
|
1114
|
+
const dxBunAt = (deg: number): number =>
|
|
1115
|
+
backSign * FACE_DEFORM_MULT * (deg / 15) * pyRound((f.turn_bun_counter_x * 15 * W) / FACE_DEFORM_MULT, 8);
|
|
1116
|
+
const dxBackAt = (deg: number): number => {
|
|
1117
|
+
if (rawBackFrac !== null) {
|
|
1118
|
+
return backSign * rawBackFrac * dxFaceCenterAt(deg);
|
|
1119
|
+
}
|
|
1120
|
+
return dxBunAt(deg);
|
|
1121
|
+
};
|
|
1122
|
+
const dxHairpinAt = (deg: number): number => {
|
|
1123
|
+
if (rawHairpinFrac !== null && rawHairpinFrac < 0) {
|
|
1124
|
+
return backSign * rawHairpinFrac * dxFaceCenterAt(deg);
|
|
1125
|
+
}
|
|
1126
|
+
if (hairpinRidesBack && rawBackFrac !== null) {
|
|
1127
|
+
const ratio = f.turn_bun_counter_x > 0 ? f.turn_hairpin_counter_x / f.turn_bun_counter_x : 0.75;
|
|
1128
|
+
return dxBackAt(deg) * ratio;
|
|
1129
|
+
}
|
|
1130
|
+
return backSign * FACE_DEFORM_MULT * (deg / 15) * pyRound((f.turn_hairpin_counter_x * 15 * W) / FACE_DEFORM_MULT, 8);
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
const earMembers = motion.groups?.ears ?? ['ear_r', 'ear_l'].filter((b) => boneMap.has(b));
|
|
1134
|
+
const hasAuthoredEarTurnTx = turnAnim.tracks.some(
|
|
1135
|
+
(tr) => tr.property === 'translatex' && (tr.group === 'ears' || tr.bone === 'ear_r' || tr.bone === 'ear_l'),
|
|
1136
|
+
);
|
|
1137
|
+
turnAnim.tracks = turnAnim.tracks.filter(
|
|
1138
|
+
(tr) => !(tr.group === 'topknot' && tr.property === 'translatex'),
|
|
1139
|
+
);
|
|
1140
|
+
for (const tr of turnAnim.tracks) {
|
|
1141
|
+
if (tr.group === 'features' && (tr.property === 'translatex' || tr.property === 'scalex') && featMembers.length > 0) {
|
|
1142
|
+
const zRef = minDepthOf(tr.keys[0]?.derive?.depth, 36);
|
|
1143
|
+
tr.keys = tr.keys.map((k) => {
|
|
1144
|
+
const deg = k.derive?.degrees ?? 0;
|
|
1145
|
+
if (deg === 0) return { t: k.t, v: tr.property === 'scalex' ? [1] : [0] };
|
|
1146
|
+
const vMap: Record<string, number[]> = {};
|
|
1147
|
+
const s = Math.sign(deg);
|
|
1148
|
+
for (const bname of featMembers) {
|
|
1149
|
+
const b = boneMap.get(bname);
|
|
1150
|
+
const bx = b?.x ?? X0;
|
|
1151
|
+
const by = b?.y ?? Y0;
|
|
1152
|
+
const z = memberDepthOf(k.derive?.depth, bname, zRef);
|
|
1153
|
+
const dFrac = zRef > 0 ? z / zRef : 1;
|
|
1154
|
+
const ux = Math.max(-1, Math.min(1, (bx - X0) / (W / 2)));
|
|
1155
|
+
const effDeg = deg * (1 + (dFrac - 1) * Math.max(0, 1 - ux * ux));
|
|
1156
|
+
const [dxL] = evalYawFaceSurface(bx - featRx, by, effDeg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
|
|
1157
|
+
const [dxR] = evalYawFaceSurface(bx + featRx, by, effDeg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
|
|
1158
|
+
const rawDelta = (dxR - dxL) / (2 * featRx);
|
|
1159
|
+
const isTrailing = s * (bx - X0) > featRx * 0.5;
|
|
1160
|
+
let sx: number;
|
|
1161
|
+
if (!useFlatYaw && isTrailing) {
|
|
1162
|
+
const cylL = cylinderYawDx(bx - X0 - featRx * 0.5, effDeg, cylRadius);
|
|
1163
|
+
const cylR = cylinderYawDx(bx - X0 + featRx * 0.5, effDeg, cylRadius);
|
|
1164
|
+
sx = 1 + Math.abs((cylR - cylL) / featRx);
|
|
1165
|
+
} else {
|
|
1166
|
+
sx = isTrailing ? 1 + Math.abs(rawDelta) : Math.max(0.2, 1 + rawDelta);
|
|
1167
|
+
}
|
|
1168
|
+
sx = pyRound(sx, 4);
|
|
1169
|
+
let dx: number;
|
|
1170
|
+
if (useFlatYaw) {
|
|
1171
|
+
dx = isTrailing
|
|
1172
|
+
? (s > 0 ? dxR - featRx * (sx - 1) : dxL + featRx * (sx - 1))
|
|
1173
|
+
: 0.5 * (dxL + dxR);
|
|
1174
|
+
} else {
|
|
1175
|
+
const [dxCenter] = evalYawFaceSurface(bx, by, effDeg, W, H, X0, Y0, f, cylRadius, false);
|
|
1176
|
+
if (Math.abs(deg) > FACE_RATED_YAW_BAND) {
|
|
1177
|
+
const [plateLeftDx] = evalYawFaceSurface(X0 - W / 2, by, deg, W, H, X0, Y0, f, cylRadius, false);
|
|
1178
|
+
const [plateRightDx] = evalYawFaceSurface(X0 + W / 2, by, deg, W, H, X0, Y0, f, cylRadius, false);
|
|
1179
|
+
const plateMinX = X0 - W / 2 + plateLeftDx;
|
|
1180
|
+
const plateMaxX = X0 + W / 2 + plateRightDx;
|
|
1181
|
+
const minDx = Math.ceil((plateMinX - bx + featRx * sx - 1e-9) * 10000) / 10000;
|
|
1182
|
+
const maxDx = Math.floor((plateMaxX - bx - featRx * sx + 1e-9) * 10000) / 10000;
|
|
1183
|
+
dx = Math.max(minDx, Math.min(maxDx, dxCenter));
|
|
1184
|
+
} else {
|
|
1185
|
+
dx = dxCenter;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
if (tr.property === 'translatex') {
|
|
1189
|
+
vMap[bname] = [pyRound(dx, 4)];
|
|
1190
|
+
} else {
|
|
1191
|
+
vMap[bname] = [sx];
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return { t: k.t, v: vMap };
|
|
1195
|
+
});
|
|
1196
|
+
} else if (tr.group === 'ears' && tr.property === 'translatex' && earMembers.length > 0) {
|
|
1197
|
+
tr.keys = tr.keys.map((k) => {
|
|
1198
|
+
if (k.derive === undefined) return k;
|
|
1199
|
+
const deg = k.derive.degrees;
|
|
1200
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
1201
|
+
const vMap: Record<string, number[]> = {};
|
|
1202
|
+
for (const bname of earMembers) {
|
|
1203
|
+
vMap[bname] = [evalDeriveKey(bname, k)];
|
|
1204
|
+
}
|
|
1205
|
+
return { t: k.t, v: vMap };
|
|
1206
|
+
});
|
|
1207
|
+
} else if ((tr.bone === 'ear_r' || tr.bone === 'ear_l') && tr.property === 'translatex') {
|
|
1208
|
+
const bname = tr.bone;
|
|
1209
|
+
tr.keys = tr.keys.map((k) => {
|
|
1210
|
+
if (k.derive === undefined) return k;
|
|
1211
|
+
const deg = k.derive.degrees;
|
|
1212
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
1213
|
+
return { t: k.t, v: [evalDeriveKey(bname, k)] };
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (!hasAuthoredEarTurnTx) {
|
|
1218
|
+
for (const [earBone, earHx] of [
|
|
1219
|
+
['ear_r', earRx],
|
|
1220
|
+
['ear_l', earLx],
|
|
1221
|
+
] as const) {
|
|
1222
|
+
if (!boneMap.has(earBone)) continue;
|
|
1223
|
+
turnAnim.tracks.push({
|
|
1224
|
+
bone: earBone,
|
|
1225
|
+
property: 'translatex',
|
|
1226
|
+
keys: refTurnKeys.map((k) => {
|
|
1227
|
+
const deg = k.derive?.degrees ?? 0;
|
|
1228
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
1229
|
+
const [dxCheekEdge] = evalYawFaceSurface(earHx, hyEar, deg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
|
|
1230
|
+
return { t: k.t, v: [pyRound(dxCheekEdge * f.turn_ear_x, 4)] };
|
|
1231
|
+
}),
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
for (const [tkBone, evalTkDx] of [
|
|
1236
|
+
['bun_ctl', dxBunAt],
|
|
1237
|
+
['hairpin', dxHairpinAt],
|
|
1238
|
+
] as const) {
|
|
1239
|
+
if (!boneMap.has(tkBone)) continue;
|
|
1240
|
+
turnAnim.tracks.push({
|
|
1241
|
+
bone: tkBone,
|
|
1242
|
+
property: 'translatex',
|
|
1243
|
+
keys: refTurnKeys.map((k) => {
|
|
1244
|
+
const deg = k.derive?.degrees ?? 0;
|
|
1245
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
1246
|
+
return { t: k.t, v: [pyRound(evalTkDx(deg), 4)] };
|
|
1247
|
+
}),
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
// Issue #478: cloth chain roots parented to head on hair_back follow hair_back's counter-parallax fraction.
|
|
1251
|
+
for (const b of rig.bones) {
|
|
1252
|
+
if (b.parent === 'head' && b.name.startsWith('hairback_') && b.name.endsWith('_0')) {
|
|
1253
|
+
if (turnAnim.tracks.some((tr) => tr.bone === b.name && tr.property === 'translatex')) continue;
|
|
1254
|
+
turnAnim.tracks.push({
|
|
1255
|
+
bone: b.name,
|
|
1256
|
+
property: 'translatex',
|
|
1257
|
+
keys: refTurnKeys.map((k) => {
|
|
1258
|
+
const deg = k.derive?.degrees ?? 0;
|
|
1259
|
+
if (deg === 0) return { t: k.t, v: [0] };
|
|
1260
|
+
return { t: k.t, v: [pyRound(dxBackAt(deg), 4)] };
|
|
1261
|
+
}),
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
706
1266
|
const turnDeform: DeformTimelineEntry[] = [
|
|
707
1267
|
{
|
|
708
1268
|
slot: 'face',
|
|
709
1269
|
attachment: 'face',
|
|
710
1270
|
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)) },
|
|
1271
|
+
deg === 0 ? { t } : { t, vertices: expandToDeform(faceVerts, (v) => evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f, cylRadius, useFlatYaw)) },
|
|
712
1272
|
),
|
|
713
1273
|
},
|
|
714
1274
|
];
|
|
@@ -722,11 +1282,18 @@ export function applyFaceSurfaceDeform(
|
|
|
722
1282
|
: {
|
|
723
1283
|
t,
|
|
724
1284
|
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,
|
|
1285
|
+
const [dxSurf, dySurf] = evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
|
|
1286
|
+
const [bx, by] = getVertexBoneDelta(v, turnAnim.tracks, t);
|
|
727
1287
|
const uBang = Math.max(0, Math.min(1, (v.hy - hyTurnBangBottom) / hyTurnBangSpan));
|
|
728
1288
|
const bangParallaxX = (deg / 15) * pyRound(f.turn_bang_parallax_x * 15 * W, 8) * uBang;
|
|
729
|
-
|
|
1289
|
+
const hyFrontCrownStart = Y0 + (36 / 128) * H;
|
|
1290
|
+
const hyFrontCrownZero = Y0 + (60 / 128) * H;
|
|
1291
|
+
const tCrown = Math.max(0, Math.min(1, (v.hy - hyFrontCrownStart) / (hyFrontCrownZero - hyFrontCrownStart)));
|
|
1292
|
+
const wCrown = 1 - tCrown * tCrown * (3 - 2 * tCrown);
|
|
1293
|
+
const absUx = Math.abs((v.hx - X0) / (W / 2));
|
|
1294
|
+
const tLat = Math.max(0, Math.min(1, (absUx - 0.96) / (1.28 - 0.96)));
|
|
1295
|
+
const wLat = 1 - tLat * tLat * (3 - 2 * tLat);
|
|
1296
|
+
return [(bangParallaxX + dxSurf) * wCrown * wLat - bx, dySurf * wCrown * wLat - by];
|
|
730
1297
|
}),
|
|
731
1298
|
},
|
|
732
1299
|
),
|
|
@@ -742,51 +1309,43 @@ export function applyFaceSurfaceDeform(
|
|
|
742
1309
|
: {
|
|
743
1310
|
t,
|
|
744
1311
|
vertices: expandToDeform(hairBackVerts, (v) => {
|
|
745
|
-
const [bx, by] = getVertexBoneDelta(v,
|
|
746
|
-
const [
|
|
1312
|
+
const [bx, by] = getVertexBoneDelta(v, turnAnim.tracks, t);
|
|
1313
|
+
const [, dyCheek] = evalYawFaceSurface(v.hx, v.hy, deg, W, H, X0, Y0, f, cylRadius, useFlatYaw);
|
|
747
1314
|
const uBun = Math.max(0, Math.min(1, (v.hy - hyCrown) / hyBunSpan));
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
1315
|
+
const wBun =
|
|
1316
|
+
(v.boneWeights.bun ?? 0) +
|
|
1317
|
+
(v.boneWeights.bun_ctl ?? 0) +
|
|
1318
|
+
(v.boneWeights.turn_hair_back_bun ?? 0) +
|
|
1319
|
+
(v.boneWeights.turn_hair_back_bun_ctl ?? 0);
|
|
1320
|
+
const wTopBun = Math.max(uBun, wBun);
|
|
1321
|
+
const tCrown = Math.max(0, Math.min(1, (v.hy - hyCrownStart) / (hyCrownZero - hyCrownStart)));
|
|
1322
|
+
const wCrown = 1 - tCrown * tCrown * (3 - 2 * tCrown);
|
|
1323
|
+
const tCraniumY = Math.max(0, Math.min(1, (v.hy - (Y0 - (24 / 128) * H)) / ((32 / 128) * H)));
|
|
1324
|
+
const absUx = Math.abs((v.hx - X0) / (W / 2));
|
|
1325
|
+
const tLat = Math.max(0, Math.min(1, (absUx - 0.55) / (0.95 - 0.55)));
|
|
1326
|
+
const wLatCranium = 1 - tCraniumY * (tLat * tLat * (3 - 2 * tLat));
|
|
1327
|
+
const targetDx = (1 - wTopBun) * (dxBackAt(deg) * wCrown * wLatCranium) + wTopBun * dxBunAt(deg);
|
|
1328
|
+
return [targetDx - bx, (1 - wTopBun) * dyCheek * wCrown - by];
|
|
751
1329
|
}),
|
|
752
1330
|
},
|
|
753
1331
|
),
|
|
754
1332
|
});
|
|
755
1333
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
}),
|
|
1334
|
+
if (hairpinVerts !== null) {
|
|
1335
|
+
turnDeform.push({
|
|
1336
|
+
slot: 'hairpin',
|
|
1337
|
+
attachment: 'hairpin',
|
|
1338
|
+
keys: turnKeys.map(({ t, deg }) =>
|
|
1339
|
+
deg === 0
|
|
1340
|
+
? { t }
|
|
1341
|
+
: {
|
|
1342
|
+
t,
|
|
1343
|
+
vertices: expandToDeform(hairpinVerts, (v) => {
|
|
1344
|
+
const [bx, by] = getVertexBoneDelta(v, turnAnim.tracks, t);
|
|
1345
|
+
return [dxHairpinAt(deg) - bx, -by];
|
|
1346
|
+
}),
|
|
1347
|
+
},
|
|
1348
|
+
),
|
|
790
1349
|
});
|
|
791
1350
|
}
|
|
792
1351
|
turnAnim.deform = turnDeform;
|