CharacterKit/MarkingCatalog.cs

Catalog of marking styles and body regions for a character editor. Defines styles, region definitions, placement rules, math to compute marking centers, frames, outward directions, and scale/spacing conversions.

File Access
using System;

namespace CharacterEditor;
public sealed record MarkingStyle(string Id, string Label, int ShaderId, bool Band = false);
public sealed record MarkingRegion(string Id, string Label, bool Head, Vector3 Center, Vector3 Right, Vector3 Up, Vector3 Normal, Vector3 Min, Vector3 Max, float MoveX, float MoveY, float Size = 1, bool BandOnly = false, bool StampOnly = false, float Radius = 2);
public readonly record struct MarkingFrame(Vector3 Center, Vector3 Right, Vector3 Up, Vector3 Normal, int Projection, float Phase);
/// <summary>Surface-specific authoring limits; arbitrary transforms never enter appearance saves.</summary>
public static class MarkingCatalog
{
	public const int Maximum = 6;
	public static IReadOnlyList<MarkingStyle> Styles { get; } = new[]
	{
		new MarkingStyle("none", "None", 0),
		new("crescent", "Crescent", 1),
		new("teardrop", "Teardrop", 2),
		new("star", "Star", 3),
		new("rune", "Rune", 4),
		new("barbed-band", "Barbed wire band", 5, true)
	};
	public static IReadOnlyList<MarkingRegion> Regions => ShoulderSafeRegionDefinitions;

	static readonly IReadOnlyList<MarkingRegion> ShoulderSafeRegionDefinitions = new[]
	{
		new MarkingRegion("face", "Face", true, new(4.6f, 0, 63.3f), Vector3.Left, Vector3.Up, Vector3.Forward, new(-3, -5.5f, 59.3f), new(8, 5.5f, 69.2f), 3, 4, 1.25f, false, true),
		new("left-cheek", "Left cheek", true, new(4, 2.35f, 63.18f), Vector3.Left, Vector3.Up, Vector3.Forward, new(-4, .1f, 60.8f), new(7, 5.5f, 65), 1.4f, 1.3f),
		new("right-cheek", "Right cheek", true, new(4, -2.35f, 63.18f), Vector3.Right, Vector3.Up, Vector3.Forward, new(-4, -5.5f, 60.8f), new(7, -.1f, 65), 1.4f, 1.3f),
		new("forehead", "Forehead", true, new(3.8f, 0, 66.5f), Vector3.Left, Vector3.Up, Vector3.Forward, new(1.8f, -3.7f, 65.2f), new(7, 3.7f, 68.1f), 1.6f, .55f, 1),
		new("chest", "Front torso", false, new(3, 0, 52), Vector3.Left, Vector3.Up, Vector3.Forward, new(-8, -8, 30.5f), new(8, 8, 59.5f), 4.2f, 20, 2.2f, false, true),
		new("back", "Back", false, new(-3, 0, 49), Vector3.Right, Vector3.Up, Vector3.Backward, new(-8, -8, 32.5f), new(8, 8, 59.5f), 4.2f, 15, 2.2f, false, true),
		new("left-arm", "Left arm", false, new(-1.5f, 13.3f, 46), Vector3.Forward, new Vector3(0, -.62f, .785f).Normal, Vector3.Left, new(-7, 6.3f, 36.5f), new(8, 24, 55.5f), 1, 9, 1.5f),
		new("right-arm", "Right arm", false, new(-1.5f, -13.3f, 46), Vector3.Backward, new Vector3(0, .62f, .785f).Normal, Vector3.Right, new(-7, -24, 36.5f), new(8, -6.3f, 55.5f), 1, 9, 1.5f),
		new("left-leg", "Left leg", false, new(1, 6, 20), Vector3.Left, new Vector3(0, -.075f, .997f).Normal, Vector3.Forward, new(-7, 1.5f, 4.7f), new(8, 11.5f, 38.5f), 1.1f, 15, 1.8f, false, false, 2.5f),
		new("right-leg", "Right leg", false, new(1, -6, 20), Vector3.Right, new Vector3(0, .075f, .997f).Normal, Vector3.Forward, new(-7, -11.5f, 4.7f), new(8, -1.5f, 38.5f), 1.1f, 15, 1.8f, false, false, 2.5f),
		new("neck", "Neck choker", true, new(0, 0, 59.3f), Vector3.Forward, new Vector3(.3f, 0, 1).Normal, Vector3.Left, new(-6, -4.5f, 56.8f), new(7, 4.5f, 62.5f), 0, 1.5f, 1, true, false, 2.5f),
		new("torso", "Torso belt", false, new(0, 0, 43.7f), Vector3.Forward, Vector3.Up, Vector3.Left, new(-8, -7.5f, 32.5f), new(8, 7.5f, 50.8f), 0, 10, 1, true, false, 5),
		new("head", "Head band", true, new(0, 0, 66.7f), Vector3.Forward, Vector3.Up, Vector3.Left, new(-6, -5.5f, 58.5f), new(8, 5.5f, 70), 0, 7, 1, true, false, 3.8f)
	};
	public static MarkingStyle Style(string id) => Styles.FirstOrDefault(x => x.Id == id);
	public static MarkingRegion Region(string id) => Regions.FirstOrDefault(x => x.Id == id);
	static bool Compatible(MarkingRegion region, bool band) => band ? (!region.StampOnly && region.Id is not "left-cheek" and not "right-cheek" and not "forehead") : !region.BandOnly;
	public static IEnumerable<MarkingRegion> Locations(string style) => Regions.Where(x => Compatible(x, Style(style)?.Band == true) && (Style(style)?.Band == true || x.Id is not "left-cheek" and not "right-cheek" and not "forehead"));
	// Legacy location IDs stay valid without cluttering the new general Face location.
	public static bool ValidPlacement(string style, string region) => Style(style)is { } design && Region(region)is { } location && Compatible(location, design.Band);
	public static bool OnHead(CharacterMarkingData data) => Region(data.Region).Head;
	public static bool OnMaterial(CharacterMarkingData data, int armSide) => armSide == 1 ? data.Region == "left-arm" : armSide == -1 ? data.Region == "right-arm" : OnBody(data) && data.Region is not "left-arm" and not "right-arm";
	public static bool OnBody(CharacterMarkingData data) => !Region(data.Region).Head || data.Region == "neck";
	public static void ChangeStyle(CharacterMarkingData data, string style)
	{
		if (Style(style)is null)
			return;
		data.Style = style;
		if (!ValidPlacement(style, data.Region))
			ChangeRegion(data, Locations(style).First().Id);
		if (Style(style).Band)
		{
			data.Horizontal = .5f;
			data.Rotation = .5f;
		}
	}

	public static void ChangeRegion(CharacterMarkingData data, string region)
	{
		if (!ValidPlacement(data.Style, region))
			return;
		data.Region = region;
		ResetPosition(data);
	}

	public static void ResetPosition(CharacterMarkingData data)
	{
		data.Horizontal = data.Region == "face" ? .68f : .5f;
		data.Vertical = data.Region == "face" ? .45f : .5f;
	}

	public static Vector3 Center(CharacterMarkingData data)
	{
		var region = Region(data.Region);
		var limits = region.Id switch
		{
			"chest" => (41.5f, 54.5f),
			"back" => (41.2f, 55f),
			"left-arm" or "right-arm" => (38.2f, Style(data.Style).Band ? 52.48f - MathF.Max(0, BandScale(data) - 1) * .55f : 53.2f),
			"left-leg" or "right-leg" => (5.8f, 35.5f),
			"face" => (60.9f, 67.5f),
			"neck" => (58.3f, 60.4f),
			"torso" => (41.2f, 49.5f),
			"head" => (59.9f, 68.5f),
			_ => (region.Center.z - region.MoveY, region.Center.z + region.MoveY)};
		var neutral = region.Center.z;
		if (region.Id is "chest" or "back")
		{
			var theta = (region.Id == "back" ? MathF.PI : 0) + (data.Horizontal - .5f) * 250 * MathF.PI / 180;
			var rotation = (data.Rotation - .5f) * MathF.PI * 2;
			var size = region.Size * StampScale(data);
			var verticalExtent = size * (.45f * MathF.Abs(MathF.Cos(rotation)) + .38f * MathF.Abs(MathF.Sin(rotation)));
			// A whole stamp fits below the deltoid/neck junction, even when rotated or enlarged.
			var safeTop = 56.8f - 4.8f * MathF.Pow(MathF.Abs(MathF.Sin(theta)), 2) - verticalExtent - .1f;
			limits.Item2 = MathF.Min(limits.Item2, safeTop);
			neutral = MathF.Min(neutral, limits.Item1 + (limits.Item2 - limits.Item1) * (region.Id == "chest" ? .81f : .565f));
		}

		var z = data.Vertical < .5f ? limits.Item1 + (neutral - limits.Item1) * data.Vertical * 2 : neutral + (limits.Item2 - neutral) * (data.Vertical - .5f) * 2;
		var center = region.Center + region.Up * ((z - region.Center.z) / region.Up.z);
		if (region.Id is "left-arm" or "right-arm")
		{
			var lower = z < 46;
			var t = lower ? (z - 39) / 7 : (z - 46) / 7.2f;
			center = new(lower ? 1.1f + (-2.6f) * t : -1.5f + .4f * t, MathF.Sign(region.Center.y) * (lower ? 19.9f - 6.6f * t : 13.3f - 4.8f * t), z);
		}

		if (region.Id is "left-leg" or "right-leg")
			center = LegAxis(z, MathF.Sign(region.Center.y));
		if (region.Id == "neck")
			center = new(.3f * (z - 59.3f) + .2f, 0, z);
		if (region.Id is "chest" or "back")
		{
			var theta = (region.Id == "back" ? MathF.PI : 0) + (data.Horizontal - .5f) * 250 * MathF.PI / 180;
			return new(4.3f * MathF.Cos(theta), 6 * MathF.Sin(theta), z);
		}

		if (region.Id == "face")
		{
			var theta = (data.Horizontal - .5f) * 130 * MathF.PI / 180;
			return new(4.6f * MathF.Cos(theta), 4.6f * MathF.Sin(theta), z);
		}

		if (Style(data.Style).Band || IsLimb(region.Id))
			return center;
		if (region.Id is "left-cheek" or "right-cheek")
		{
			var angle = MathF.Atan2(MathF.Abs(region.Center.y), region.Center.x) + (data.Horizontal - .5f) * 2 * (data.Horizontal > .5f ? 30 : 20) * MathF.PI / 180;
			var radius = new Vector2(region.Center.x, region.Center.y).Length;
			return new(radius * MathF.Cos(angle), MathF.Sign(region.Center.y) * radius * MathF.Sin(angle), z);
		}

		return center + region.Right * (data.Horizontal - .5f) * region.MoveX * 2;
	}

	public static bool IsLimb(string id) => id is "left-arm" or "right-arm" or "left-leg" or "right-leg";
	public static MarkingFrame Frame(CharacterMarkingData data)
	{
		var region = Region(data.Region);
		var center = Center(data);
		if (Style(data.Style).Band || IsLimb(region.Id))
		{
			var axis = region.Up;
			if (region.Id is "left-arm" or "right-arm")
				axis = new Vector3(center.z < 46 ? -.3714f : .0556f, MathF.Sign(region.Center.y) * (center.z < 46 ? -.9429f : -.6667f), 1).Normal;
			if (region.Id is "left-leg" or "right-leg")
				axis = (LegAxis(center.z + .1f, MathF.Sign(region.Center.y)) - LegAxis(center.z - .1f, MathF.Sign(region.Center.y))).Normal;
			var right = (Vector3.Forward - axis * axis.x).Normal;
			var normal = Vector3.Cross(axis, right).Normal;
			var phase = region.Id == "left-arm" ? MathF.PI / 2 : region.Id == "right-arm" ? -MathF.PI / 2 : 0;
			return new(center, right, axis, normal, IsLimb(region.Id) ? 2 : 0, phase + (data.Horizontal - .5f) * MathF.PI * 2);
		}

		if (region.Id is "chest" or "back")
			return new(center, new(4.3f, 6, 0), Vector3.Up, new Vector3(center.x / (4.3f * 4.3f), center.y / 36, 0).Normal, 3, MathF.Atan2(center.y / 6, center.x / 4.3f));
		if (region.Id == "face")
			return new(center, Vector3.Left, Vector3.Up, new Vector3(center.x, center.y, 0).Normal, 4, MathF.Atan2(center.y, center.x));
		return new(center, region.Right, region.Up, region.Normal, region.Id is "left-cheek" or "right-cheek" ? 1 : 0, 0);
	}

	public static Vector3 Outward(CharacterMarkingData data)
	{
		var frame = Frame(data);
		if (IsLimb(data.Region))
			return frame.Right * MathF.Cos(frame.Phase) + frame.Normal * MathF.Sin(frame.Phase);
		if (frame.Projection is 1 or 4)
			return new Vector3(frame.Center.x, frame.Center.y, 0).Normal;
		return Region(data.Region).BandOnly ? frame.Right : frame.Normal;
	}

	public static Vector3 Focus(CharacterMarkingData data) => Center(data) + (IsLimb(data.Region) ? Outward(data) * Region(data.Region).Radius : Vector3.Zero);
	static readonly Vector3[] CalibratedLegCenters =
	{
		new(-2.17f, 7.15f, 5.8f),
		new(-2.18f, 7.1f, 7),
		new(-2.13f, 6.7f, 10),
		new(-2.12f, 6.28f, 13),
		new(-1.89f, 6.1f, 16),
		new(-.75f, 5.3f, 19),
		new(-.41f, 4.91f, 22),
		new(-.24f, 4.53f, 25),
		new(0, 4.38f, 28),
		new(-.3f, 4.3f, 35.5f)
	};
	static Vector3 LegAxis(float z, float side)
	{
		var i = 0;
		while (i < CalibratedLegCenters.Length - 2 && z > CalibratedLegCenters[i + 1].z)
			i++;
		var a = CalibratedLegCenters[i];
		var b = CalibratedLegCenters[i + 1];
		var value = a + (b - a) * ((z - a.z) / (b.z - a.z));
		return new(value.x, value.y * side, z);
	}

	public static float StampScale(CharacterMarkingData data) => data.Scale <= .5f ? .5f + data.Scale : 1 + (data.Scale - .5f) * 4;
	public static float BandScale(CharacterMarkingData data) => data.Scale < .5f ? .35f + data.Scale * 1.3f : 1 + (data.Scale - .5f) * 4;
	public static float BandSpacing(CharacterMarkingData data) => data.Spacing < .5f ? .35f + data.Spacing * 1.3f : 1 + (data.Spacing - .5f) * 3;
}