Character deformation utility for the character editor, it computes a fixed set of local deformation volumes (edits) from CharacterAppearanceData and creates or updates GameObjects with ModelDeformer components to drive head/body shape changes.
using System;
namespace CharacterEditor;
/// <summary>Stable native rest-space volumes; shape edits update coefficients without resetting merged skeletons.</summary>
public static class CharacterDeformation
{
public const int VolumeCount = 64;
readonly record struct Edit(string Name, Vector3 Center, float Radius, Vector3 MaskScale, Vector3 Scale, Vector3 Translation, float Pitch = 0, float Roll = 0);
// Native orthographic front/profile captures locate the vermilion separately
// from surrounding skin. Resource identity, not display name, selects anatomy.
readonly record struct MouthAnatomy(float SurfaceX, float PhiltrumZ, float PhiltrumHeight, float ColumellaX, float ColumellaZ, float UpperZ, float UpperHeight, float LowerZ, float LowerHeight);
static MouthAnatomy MouthFor(string skin) => skin?.ToLowerInvariant() switch
{
"models/citizen_clothes/body/human01.clothing" => new(5.8f, 62.35f, 0.28f, 5.85f, 62.82f, 61.88f, 0.26f, 61.38f, 0.21f),
"models/citizen_clothes/body/human02.clothing" => new(5.5f, 62.3f, 0.22f, 5.7f, 62.65f, 61.82f, 0.25f, 61.27f, 0.23f),
"models/citizen_clothes/body/human04.clothing" => new(5.5f, 62.17f, 0.21f, 5.75f, 62.58f, 61.76f, 0.21f, 61.30f, 0.23f),
"models/citizen_clothes/body/human05.clothing" => new(5.7f, 62.26f, 0.23f, 5.85f, 62.67f, 61.79f, 0.20f, 61.43f, 0.16f),
"models/citizen_clothes/body/human06.clothing" => new(5.75f, 62.35f, 0.28f, 5.8f, 62.82f, 61.87f, 0.26f, 61.39f, 0.22f),
"models/citizen_clothes/body/human07.clothing" => new(5.7f, 62.26f, 0.23f, 5.75f, 62.72f, 61.80f, 0.20f, 61.42f, 0.21f),
_ => new(5.8f, 62.35f, 0.28f, 5.85f, 62.82f, 61.80f, 0.22f, 61.43f, 0.16f)};
// Rest-space side landmarks run down and forward from below the ear to the chin.
// Pitch aligns the narrow masks with that slope rather than a horizontal neck band.
readonly record struct JawAnatomy(float AngleZ, float MiddleZ, float FrontZ, float Width);
static JawAnatomy JawFor(string skin) => skin?.ToLowerInvariant() switch
{
"models/citizen_clothes/body/human01.clothing" => new(61.65f, 61.1f, 60.4f, 1),
"models/citizen_clothes/body/human02.clothing" => new(61.65f, 61.1f, 60.4f, 0.93f),
"models/citizen_clothes/body/human04.clothing" => new(61.65f, 61.1f, 60.4f, 0.92f),
"models/citizen_clothes/body/human05.clothing" or "models/citizen_clothes/body/human07.clothing" => new(61.65f, 61.2f, 60.4f, 0.97f),
_ => new(61.65f, 61.1f, 60.4f, 1)};
static Edit[] Edits(CharacterAppearanceData a)
{
a = PhotoFace.ApplyOffsets(a);
var headWeights = a.BaseShape?.Normalized();
var regionWeights = new Dictionary<string, float[]>();
float Blend(Func<string, float> f) => headWeights is null ? f(a.SkinPath) : AppearanceCatalog.Skins.Select((s, i) => f(s.ResourcePath) * headWeights[i]).Sum();
float BlendRegion(string region, Func<string, float> f)
{
if (a.BaseShape is null)
return Blend(f);
if (!regionWeights.TryGetValue(region, out var weights))
regionWeights[region] = weights = FaceRegions.Weights(a.BaseShape, region);
return AppearanceCatalog.Skins.Select((s, i) => f(s.ResourcePath) * weights[i]).Sum();
}
float Signed(float value) => (value - 0.5f) * 2;
Vector3 Scale(Vector3 maximum, float value) => Vector3.One + (maximum - Vector3.One) * Signed(value);
float Extended(float value, float maximum) => Signed(value) * (value > 0.5f ? maximum : 1);
float Range(float value, float negative, float positive) => Signed(value) * (value < 0.5f ? negative : positive);
Vector3 AmountScale(Vector3 maximum, float amount) => Vector3.One + (maximum - Vector3.One) * amount;
// Preserve the neutral shape; map the new endpoints to the old 30% and 90%.
var chest = Signed(a.ChestSize) * (a.ChestSize < 0.5f ? 0.4f : 0.8f);
Vector3 Build(Vector3 soft, Vector3 athletic) => Vector3.One + ((a.Fitness < 0.5f ? soft : athletic) - Vector3.One) * MathF.Abs(Signed(a.Fitness));
// A gentle resize range and broader mask keep the already-tapered rim from collapsing or hitting the finite boundary.
var earScale = Scale(new(1.35f, 1.35f, 1.35f), a.EarSize);
var earPivotCorrection = 0.65f / 1.05f * (earScale.y - 1);
var maleAmount = UnifiedHuman.MaleAmount(a);
var female = a.BaseShape is not null || AppearanceCatalog.IsFemale(a);
var contour = a.BaseShape is null ? Extended(a.BodyFeatureSize, female ? 1.3f : 1) : Extended(a.BaseShape.BreastSize, 1.3f) * (1 - maleAmount);
var butt = Extended(a.ButtSize, 1.3f);
// Face 4 needs a lower, taller cap encompassing its nostril wing rather than the patch above it.
var smallWings = BlendRegion("nose", s => string.Equals(s, "models/citizen_clothes/body/human04.clothing", StringComparison.OrdinalIgnoreCase) ? 1 : 0);
Vector3 WingCenter(float side) => Vector3.Lerp(new(5.45f, side * .85f, 63.1f), new(5.5f, side * .6f, 62.82f), smallWings);
var wingMask = Vector3.Lerp(new(.7f, .6f, .48f), new(.65f, .45f, .46f), smallWings);
var mouth = new MouthAnatomy(BlendRegion("mouth", s => MouthFor(s).SurfaceX), BlendRegion("mouth", s => MouthFor(s).PhiltrumZ), BlendRegion("mouth", s => MouthFor(s).PhiltrumHeight), BlendRegion("nose", s => MouthFor(s).ColumellaX), BlendRegion("nose", s => MouthFor(s).ColumellaZ), BlendRegion("mouth", s => MouthFor(s).UpperZ), BlendRegion("mouth", s => MouthFor(s).UpperHeight), BlendRegion("mouth", s => MouthFor(s).LowerZ), BlendRegion("mouth", s => MouthFor(s).LowerHeight));
var jaw = new JawAnatomy(BlendRegion("jaw", s => JawFor(s).AngleZ), BlendRegion("jaw", s => JawFor(s).MiddleZ), BlendRegion("jaw", s => JawFor(s).FrontZ), BlendRegion("jaw", s => JawFor(s).Width));
var cheekbones = Extended(a.CheekboneWidth, 1.35f);
var chinProjection = Range(a.ChinSize, 1.12f, 1.45f);
var chinHeight = Range(a.ChinHeight, 1.8f, 1.2f);
var mouthProjection = Extended(a.MouthProjection, 1.4f);
var upperLip = Extended(a.UpperLipFullness, 1.4f);
var lowerLip = Extended(a.LowerLipFullness, 1.4f);
var brow = Range(a.BrowProminence, 1.12f, 1.4f);
var philtrumLipClearance = mouth.UpperZ - mouth.LowerZ < 0.4f ? 0.04f : 0;
// Raise only the upper mask boundary; retain the accepted lower lip reach.
var philtrumUpperExtension = MathF.Min(0.18f, mouth.ColumellaZ - mouth.PhiltrumZ - 0.24f);
var headScale = Scale(new(1.06f, 1.08f, 1.025f), a.HeadShape);
// Rest-space underside landmarks differ from the chin tip and anterior
// throat. Fit this cap to each head, then follow the preceding head scale.
var submentalZ = BlendRegion("jaw", s => s?.ToLowerInvariant() switch
{
"models/citizen_clothes/body/human01.clothing" => 59.37f,
"models/citizen_clothes/body/human02.clothing" => 59.92f,
"models/citizen_clothes/body/human04.clothing" => 59.74f,
"models/citizen_clothes/body/human05.clothing" => 59.43f,
"models/citizen_clothes/body/human06.clothing" => 59.5f,
"models/citizen_clothes/body/human07.clothing" => 59.59f,
_ => 59.35f
});
var jawWidth = Extended(a.JawWidth, 2);
Edit JawEdit(string name, Vector3 center, Vector3 mask, float translation, float pitch)
{
// The head operation precedes the jaw. Follow its scaled landmarks so a
// smaller head cannot move the ear/neck into a mask fitted to neutral.
center = new(center.x * headScale.x, center.y * headScale.y, 63 + (center.z - 63) * headScale.z);
mask *= headScale;
// The stronger positive contour needs a little more vertical clearance
// from the neck on very small heads. Minimum and neutral masks stay exact.
mask.z *= 1 - 0.08f * MathF.Max(0, 1 - 2 * a.HeadShape) * MathF.Max(0, Signed(a.JawWidth));
return new(name, center, 1, mask, Vector3.One, new(0, translation * jawWidth, 0), pitch);
}
Vector3 EarCenter(float side) => new(0.15f * headScale.x, side * 3.65f * headScale.y, 63 + 1.05f * headScale.z);
var earMask = new Vector3(1.15f * headScale.x, 0.5f * headScale.y, 1.6f * headScale.z);
var result = new[]
{
new Edit("Neck size", new(-0.25f, 0, 58), 1, new(4.2f, 4.2f, 2.9f), Scale(new(1.18f, 1.4f, 1), a.NeckSize), Vector3.Zero),
// Preserve the accepted old shoulder-height operation as its own control.
new Edit("Left collarbone height", new(-1.1f, 4.2f, 55.3f), 1, new(3.8f, 4, 2.4f), Scale(new(1.12f, 1.04f, 1.3f), a.CollarboneHeight), new(0, 0, 0.1f * Signed(a.CollarboneHeight))),
new Edit("Right collarbone height", new(-1.1f, -4.2f, 55.3f), 1, new(3.8f, 4, 2.4f), Scale(new(1.12f, 1.04f, 1.3f), a.CollarboneHeight), new(0, 0, 0.1f * Signed(a.CollarboneHeight))),
// Upper trapezius lies behind the clavicle and slopes from neck to shoulder.
new Edit("Left trapezius", new(-1.8f, 4.3f, 57.6f + .3f * maleAmount), 1, new(3, 4, 1.25f), Vector3.One, new(-0.18f * Signed(a.TrapeziusSize), 0, 0.28f * Signed(a.TrapeziusSize)), 0, -28),
new Edit("Right trapezius", new(-1.8f, -4.3f, 57.6f + .3f * maleAmount), 1, new(3, 4, 1.25f), Vector3.One, new(-0.18f * Signed(a.TrapeziusSize), 0, 0.28f * Signed(a.TrapeziusSize)), 0, 28),
new Edit("Waist size", new(0, 0, 37), 17, new(1, 1, 0.8f), Scale(new(1.15f, 1.15f, 1), a.WaistSize), Vector3.Zero),
new Edit("Chest size", new(0, 0, 51.5f), 1, new(7, 11.5f, 7.5f), AmountScale(new(1.18f, 1.25f, 1), chest), Vector3.Zero),
new Edit("Left chest shoulder", new(0, 8, 53), 1, new(5.5f, 5, 5.5f), Vector3.One, new(0, 0.18f * chest, 0)),
new Edit("Right chest shoulder", new(0, -8, 53), 1, new(5.5f, 5, 5.5f), Vector3.One, new(0, -0.18f * chest, 0)),
new Edit("Build midsection", new(0, 0, 36), 15, new(1, 1, 0.75f), Build(new(1.16f, 1.18f, 1), new(0.94f, 0.95f, 1)), Vector3.Zero),
new Edit("Build chest", new(1, 0, 51), 1, new(7, 10.5f, 7), Build(new(1.035f, 1.06f, 1), new(1.2f, 1.14f, 1.025f)), Vector3.Zero),
new Edit("Build left arm", new(0, 11.5f, 49), 1, new(5, 5, 7), Build(new(1.015f, 1.04f, 1.02f), new(1.45f, 1.22f, 1.2f)), Vector3.Zero),
new Edit("Build right arm", new(0, -11.5f, 49), 1, new(5, 5, 7), Build(new(1.015f, 1.04f, 1.02f), new(1.45f, 1.22f, 1.2f)), Vector3.Zero),
new Edit("Build left shoulder", new(-0.5f, 8, 53), 1, new(5, 4.5f, 5), Build(new(1.02f, 1.02f, 1), new(1.3f, 1.25f, 1.15f)), Vector3.Zero),
new Edit("Build right shoulder", new(-0.5f, -8, 53), 1, new(5, 4.5f, 5), Build(new(1.02f, 1.02f, 1), new(1.3f, 1.25f, 1.15f)), Vector3.Zero),
new Edit("Build left forearm", new(0.05f, 17.4f, 41.6f), 1, new(3.1f, 4.4f, 3.8f), Build(Vector3.One, new(1.22f, 1.1f, 1.06f)), Vector3.Zero),
new Edit("Build right forearm", new(0.05f, -17.4f, 41.6f), 1, new(3.1f, 4.4f, 3.8f), Build(Vector3.One, new(1.22f, 1.1f, 1.06f)), Vector3.Zero),
new Edit("Build left calf", new(-2, 6.25f, 13), 1, new(3.4f, 2.8f, 6.5f), Build(Vector3.One, new(1.22f, 1.2f, 1)), Vector3.Zero),
new Edit("Build right calf", new(-2, -6.25f, 13), 1, new(3.4f, 2.8f, 6.5f), Build(Vector3.One, new(1.22f, 1.2f, 1)), Vector3.Zero),
new Edit("Build left thigh", new(0, 4, 22), 11, new(0.8f, 0.7f, 1), Build(new(1.06f, 1.08f, 1), new(1.12f, 1.12f, 1)), Vector3.Zero),
new Edit("Build right thigh", new(0, -4, 22), 11, new(0.8f, 0.7f, 1), Build(new(1.06f, 1.08f, 1), new(1.12f, 1.12f, 1)), Vector3.Zero),
// These surfaces remain covered by the native underwear body groups.
new Edit("Left body contour", female ? new(4, 3.1f, 49.8f) : new(4, 0.65f, 34), 1, female ? new(3.5f, 3, 3.5f) : new(2.7f, 2.5f, 3), AmountScale(female ? new(1.28f, 1.12f, 1.12f) : Vector3.One, contour), new((female ? 0.12f : 0.23f) * contour, 0, 0)),
new Edit("Right body contour", female ? new(4, -3.1f, 49.8f) : new(4, -0.65f, 34), 1, female ? new(3.5f, 3, 3.5f) : new(2.7f, 2.5f, 3), AmountScale(female ? new(1.28f, 1.12f, 1.12f) : Vector3.One, contour), new((female ? 0.12f : 0.23f) * contour, 0, 0)),
new Edit("Left butt size", new(-3.5f, 3, 33.5f), 1, new(4.5f, 3.8f, 5), AmountScale(new(1.22f, 1.08f, 1.05f), butt), new(-0.15f * butt, 0, 0)),
new Edit("Right butt size", new(-3.5f, -3, 33.5f), 1, new(4.5f, 3.8f, 5), AmountScale(new(1.22f, 1.08f, 1.05f), butt), new(-0.15f * butt, 0, 0)),
// Front-only abdominal cap: the back, chest and pelvis are outside the mask.
new Edit("Belly size", new(8, 0, 41), 1, new(8, 9, 7.5f), Vector3.One, new(0.55f * a.BellySize, 0, 0)),
// Length and width only: soles keep their original height and legs stay outside.
new Edit("Left foot size", new(0, 7.8f, 1.8f), 1, new(9, 4, 4.2f), Scale(new(1.22f, 1.18f, 1), a.FootSize), Vector3.Zero),
new Edit("Right foot size", new(0, -7.8f, 1.8f), 1, new(9, 4, 4.2f), Scale(new(1.22f, 1.18f, 1), a.FootSize), Vector3.Zero),
new Edit("Head shape", new(0, 0, 63), 10, Vector3.One, Scale(new(1.06f, 1.08f, 1.025f), a.HeadShape), Vector3.Zero),
// +X is forward on the native human rig. Translation changes profile depth
// without radial inflation's boundary clamp or volume-preserving squash.
// Tight ellipsoids end above the upper lip and in front of the throat.
// Projection and width share each feature's mask, rather than a jaw/face sphere.
new Edit("Nose projection", new(6.2f, 0, 63.65f), 1, new(1.55f, 1.35f, 1.15f), Vector3.One, new(0.32f * Signed(a.NoseSize), 0, 0)),
new Edit("Nose width", new(6.2f, 0, 63.65f), 1, new(1.55f, 1.35f, 1.15f), Scale(new(1, 1.28f, 1), a.NoseWidth), Vector3.Zero),
new Edit("Nose tip height", new(6.2f, 0, 63.65f), 1, new(1.55f, 1.35f, 1.15f), Vector3.One, new(0, 0, 0.3f * Signed(a.NoseHeight))),
new Edit("Chin projection", new(5.1f, 0, 60.25f), 1, new(1.25f, 2.05f, 0.8f), Vector3.One, new(0.24f * chinProjection, 0, 0)),
new Edit("Chin width", new(5.1f, 0, 60.25f), 1, new(1.25f, 2.05f, 0.8f), Scale(new(1, 1.18f, 1), a.ChinWidth), Vector3.Zero),
new Edit("Chin height", new(5.1f, 0, 60), 1, new(1.5f, 2.05f, 1), Vector3.One, new(0, 0, 0.3f * chinHeight)),
new Edit("Submental fullness", new(3.65f * headScale.x, 0, 63 + (submentalZ - 63) * headScale.z), 1, new(1.5f * headScale.x, 2.35f * headScale.y, 0.75f * headScale.z), Vector3.One, new(0, 0, -Range(a.SubmentalFullness, 0.25f, 0.35f))),
// Lateral translation follows the mandibular rim without inflating the neck.
// The posterior section climbs toward the ear; the front remains below the lips.
JawEdit("Left jaw angle", new(1.9f, 2.7f * jaw.Width, jaw.AngleZ), new(1.1f, 1.1f, 0.6f), 0.085f, 42),
JawEdit("Right jaw angle", new(1.9f, -2.7f * jaw.Width, jaw.AngleZ), new(1.1f, 1.1f, 0.6f), -0.085f, 42),
JawEdit("Left jaw width", new(3, 2.25f * jaw.Width, jaw.MiddleZ), new(2.1f, 1.6f, 0.7f), 0.07f, 26),
JawEdit("Right jaw width", new(3, -2.25f * jaw.Width, jaw.MiddleZ), new(2.1f, 1.6f, 0.7f), -0.07f, 26),
JawEdit("Left jaw front", new(4.35f, 1.4f * jaw.Width, jaw.FrontZ), new(1.55f, 1.7f, 0.65f), 0.045f, 10),
JawEdit("Right jaw front", new(4.35f, -1.4f * jaw.Width, jaw.FrontZ), new(1.55f, 1.7f, 0.65f), -0.045f, 10),
new Edit("Left cheek fullness", new(4, 0.8f, 62), 1, new(3.6f, 5, 1.5f), Scale(new(1, 1.06f, 1), a.CheekFullness), new(0, 0.0096f * Signed(a.CheekFullness), 0)),
new Edit("Right cheek fullness", new(4, -0.8f, 62), 1, new(3.6f, 5, 1.5f), Scale(new(1, 1.06f, 1), a.CheekFullness), new(0, -0.0096f * Signed(a.CheekFullness), 0)),
// The alae (nostril wings) sit beside and below the tip; keep the lip outside.
new Edit("Left nostril width", new(5.5f, 0.85f, 62.95f), 1, new(0.65f, 0.7f, 0.35f), Vector3.One, new(0, 0.2f * Signed(a.NostrilWidth), 0)),
new Edit("Right nostril width", new(5.5f, -0.85f, 62.95f), 1, new(0.65f, 0.7f, 0.35f), Vector3.One, new(0, -0.2f * Signed(a.NostrilWidth), 0)),
// Fullness changes the wing's rounded cap in depth/height, independent of spacing.
new Edit("Left nostril-wing fullness", WingCenter(1), 1, wingMask, Scale(Vector3.Lerp(new(1.35f, 1.05f, 1.3f), new(1.28f, 1.05f, 1.24f), smallWings), a.NostrilFullness), new((.1f - .02f * smallWings) * Signed(a.NostrilFullness), 0, 0)),
new Edit("Right nostril-wing fullness", WingCenter(-1), 1, wingMask, Scale(Vector3.Lerp(new(1.35f, 1.05f, 1.3f), new(1.28f, 1.05f, 1.24f), smallWings), a.NostrilFullness), new((.1f - .02f * smallWings) * Signed(a.NostrilFullness), 0, 0)),
new Edit("Forehead width", new(3, 0, 65.8f), 1, new(3.8f, 5, 1.2f), Scale(new(1, 1.16f, 1), a.ForeheadWidth), Vector3.Zero),
new Edit("Left cheekbone width", new(3.8f, 2.4f, 63.05f), 1, new(2.3f, 1.7f, 1.1f), Vector3.One, new(0, 0.08f * cheekbones, 0)),
new Edit("Right cheekbone width", new(3.8f, -2.4f, 63.05f), 1, new(2.3f, 1.7f, 1.1f), Vector3.One, new(0, -0.08f * cheekbones, 0)),
// Include the forward, inward lower lobe while retaining a narrow fade into the scalp.
// Translate the scale operation's pivot inward while keeping the whole-ear/lobe mask.
// Shape the upper helix in native rest space before resizing. The inner mask
// boundary stays outside the skull, and no lateral translation moves the root.
new Edit("Left ear taper", EarCenter(1), 1, earMask, new(1 - 0.4f * a.EarTaper, 1, 1 + 0.35f * a.EarTaper), new(-0.3f * a.EarTaper, 0, 0.35f * a.EarTaper)),
new Edit("Right ear taper", EarCenter(-1), 1, earMask, new(1 - 0.4f * a.EarTaper, 1, 1 + 0.35f * a.EarTaper), new(-0.3f * a.EarTaper, 0, 0.35f * a.EarTaper)),
new Edit("Left ear size", new(0.35f, 3.5f, 63.15f), 1, new(2.15f, 1.05f, 3.15f), earScale, new(0, earPivotCorrection, 0)),
new Edit("Right ear size", new(0.35f, -3.5f, 63.15f), 1, new(2.15f, 1.05f, 3.15f), earScale, new(0, -earPivotCorrection, 0)),
new Edit("Left eye size", new(4.7f, 1.65f, 64.5f), 1, new(1.6f, 1.25f, 0.7f), Scale(new(1.12f, 1.18f, 1.18f), a.EyeSize), Vector3.Zero),
new Edit("Right eye size", new(4.7f, -1.65f, 64.5f), 1, new(1.6f, 1.25f, 0.7f), Scale(new(1.12f, 1.18f, 1.18f), a.EyeSize), Vector3.Zero),
new Edit("Brow prominence", new(4.6f, 0, 65.2f), 1, new(1.9f, 3.9f, 0.65f), Vector3.One, new(0.08f * brow, 0, 0)),
// Separate small masks for the philtrum and each lip. Mouth width/projection
// share a lip envelope ending below the nose and above the chin.
new Edit("Philtrum width", new(mouth.SurfaceX - 0.05f, 0, (mouth.PhiltrumZ + mouth.UpperZ) / 2 - 0.08f + philtrumLipClearance + philtrumUpperExtension / 2), 1, new(0.75f, 0.9f, (mouth.PhiltrumZ - mouth.UpperZ) / 2 + 0.2f - philtrumLipClearance + philtrumUpperExtension / 2), Scale(new(1, 1.28f, 1), a.PhiltrumWidth), Vector3.Zero),
new Edit("Mouth width", new(5, 0, 61.8f), 1, new(1.5f, 2.5f, 0.7f), Scale(new(1, 1.2f, 1), a.MouthWidth), Vector3.Zero),
new Edit("Mouth projection", new(5, 0, 61.8f), 1, new(1.5f, 2.5f, 0.7f), Vector3.One, new(0.12f * mouthProjection, 0, 0)),
new Edit("Upper lip fullness", new(mouth.SurfaceX, 0, mouth.UpperZ), 1, new(0.7f, 1.85f, mouth.UpperHeight), AmountScale(new(1, 1, 1.4f), upperLip), new(0.08f * upperLip, 0, 0)),
new Edit("Lower lip fullness", new(mouth.SurfaceX - 0.07f, 0, mouth.LowerZ), 1, new(0.7f, 1.85f, mouth.LowerHeight), AmountScale(new(1, 1, 1.4f), lowerLip), new(0.08f * lowerLip, 0, 0)),
new Edit("Columella width", new(mouth.ColumellaX, 0, mouth.ColumellaZ), 1, new(0.65f, 0.5f, 0.19f), Scale(new(1, 1.32f, 1), a.ColumellaWidth), Vector3.Zero)
};
if (a.BaseShape is null)
return result;
var bulge = Signed(a.BaseShape.BulgeSize) * maleAmount;
return result.Concat(new[] { new Edit("Left bulge", new(4, .65f, 34), 1, new(2.7f, 2.5f, 3), Vector3.One, new(.23f * bulge, 0, 0)), new Edit("Right bulge", new(4, -.65f, 34), 1, new(2.7f, 2.5f, 3), Vector3.One, new(.23f * bulge, 0, 0)) }).ToArray();
}
public static bool PropagatesToClothing(string volumeName, CharacterAppearanceData appearance) => !((appearance.BaseShape is not null || AppearanceCatalog.IsFemale(appearance)) && (appearance.BaseShape?.BreastSize ?? appearance.BodyFeatureSize) < 0.5f && (volumeName is "Left body contour" or "Right body contour"));
public static GameObject Create(SkinnedModelRenderer body, CharacterAppearanceData appearance)
{
var root = new GameObject
{
Parent = body.GameObject,
Name = "Local human shape volumes",
NetworkMode = NetworkMode.Never
};
root.LocalTransform = Transform.Zero;
root.Flags |= GameObjectFlags.NotSaved;
var edits = Edits(appearance);
for (var i = 0; i < edits.Length; i++)
{
var edit = edits[i];
var go = new GameObject
{
Parent = root,
Name = edit.Name,
NetworkMode = NetworkMode.Never
};
go.LocalPosition = edit.Center;
go.LocalRotation = Rotation.FromPitch(edit.Pitch) * Rotation.FromRoll(edit.Roll);
go.LocalScale = edit.MaskScale;
var deform = go.Components.Create<ModelDeformer>();
var volume = deform.SceneVolume;
volume.Sphere = new()
{
Center = Vector3.Zero,
Radius = edit.Radius
};
deform.SceneVolume = volume;
deform.Operation = ModelDeformer.OperationType.Transform;
deform.Weight = 1;
deform.Falloff = edit.Name.Contains("jaw") ? 1 : 0.65f;
deform.TransformBlend = 0.65f;
deform.NormalMode = ModelDeformer.NormalModeType.Accurate;
deform.ApplyToBoneMergedChildren = PropagatesToClothing(edit.Name, appearance);
deform.Priority = i * 10;
deform.ScaleFactor = edit.Scale;
deform.Translation = edit.Translation;
// A transform with identity coefficients has no visual effect. Keep its
// authoring component, but remove it from native rendering until edited.
deform.Enabled = edit.Scale != Vector3.One || edit.Translation != Vector3.Zero;
}
return root;
}
public static GameObject Update(GameObject root, SkinnedModelRenderer body, CharacterAppearanceData appearance)
{
var volumes = root.IsValid() ? root.GetComponentsInChildren<ModelDeformer>(true, true).OrderBy(x => x.Priority).ToArray() : Array.Empty<ModelDeformer>();
if (volumes.Length != (appearance.BaseShape is null ? VolumeCount : VolumeCount + 2) || volumes.Any(x => x.Target != body))
{
if (root.IsValid())
root.Destroy();
return Create(body, appearance);
}
var edits = Edits(appearance);
for (var i = 0; i < edits.Length; i++)
{
// Untouched controls retain both their components and exact coefficients.
// Keep existing native components in sync when an authored mask is revised.
if (volumes[i].GameObject.LocalPosition != edits[i].Center)
volumes[i].GameObject.LocalPosition = edits[i].Center;
if (volumes[i].GameObject.Name != edits[i].Name)
volumes[i].GameObject.Name = edits[i].Name;
if (volumes[i].GameObject.LocalScale != edits[i].MaskScale)
volumes[i].GameObject.LocalScale = edits[i].MaskScale;
var rotation = Rotation.FromPitch(edits[i].Pitch) * Rotation.FromRoll(edits[i].Roll);
if (volumes[i].GameObject.LocalRotation != rotation)
volumes[i].GameObject.LocalRotation = rotation;
if (volumes[i].SceneVolume.Sphere.Radius != edits[i].Radius)
{
var volume = volumes[i].SceneVolume;
volume.Sphere = new()
{
Center = Vector3.Zero,
Radius = edits[i].Radius
};
volumes[i].SceneVolume = volume;
}
var falloff = edits[i].Name.Contains("jaw") ? 1f : 0.65f;
if (volumes[i].Falloff != falloff)
volumes[i].Falloff = falloff;
if (volumes[i].ScaleFactor != edits[i].Scale)
volumes[i].ScaleFactor = edits[i].Scale;
if (volumes[i].Translation != edits[i].Translation)
volumes[i].Translation = edits[i].Translation;
volumes[i].ApplyToBoneMergedChildren = PropagatesToClothing(edits[i].Name, appearance);
var enabled = edits[i].Scale != Vector3.One || edits[i].Translation != Vector3.Zero;
if (volumes[i].Enabled != enabled)
volumes[i].Enabled = enabled;
}
return root;
}
}