CharacterKit/EarShapePicker.cs

A UI Panel that draws a triangular ear-shape selector and lets the user pick a point inside that triangle. It clamps arbitrary input to the triangle, draws three colored regions and a marker, and exposes Value and OnValueChanged for consumers.

Native Interop
using System;
using Sandbox.UI;

namespace CharacterEditor;

/// <summary>Visible-pointer triangle input, using the same local coordinates and drag state as HairColorSquare.</summary>
public sealed class EarShapeSurface : Panel
{
	public Vector2 Value { get; set; } = new(.5f, 2f / 3f);
	public Action<Vector2> OnValueChanged { get; set; }
	static readonly Vector2 Elf = new(.5f, 0);
	static readonly Vector2 Goblin = new(0, 1);
	static readonly Vector2 Animal = new(1, 1);
	static readonly Vector2 Human = new(.5f, 2f / 3f);

	/// <summary>Project an outside drag onto the closest triangle edge in the displayed dimensions.</summary>
	public static Vector2 ClampToTriangle(Vector2 point, Vector2 size)
	{
		if (!float.IsFinite(point.x) || !float.IsFinite(point.y)) return Human;
		if (point.y >= 0 && point.y <= 1 && point.x >= .5f * (1 - point.y) && point.x <= .5f * (1 + point.y)) return point;
		size = new Vector2(MathF.Max(1, size.x), MathF.Max(1, size.y));
		Vector2 Scale(Vector2 p) => new(p.x * size.x, p.y * size.y);
		Vector2 Project(Vector2 a, Vector2 b)
		{
			var edge = Scale(b - a);
			var offset = Scale(point - a);
			float dot = offset.x * edge.x + offset.y * edge.y;
			float lengthSquared = edge.x * edge.x + edge.y * edge.y;
			return a + (b - a) * Math.Clamp(dot / lengthSquared, 0, 1);
		}
		float Distance(Vector2 p)
		{
			var d = Scale(point - p);
			return d.x * d.x + d.y * d.y;
		}
		var left = Project(Elf, Goblin);
		var bottom = Project(Goblin, Animal);
		var right = Project(Animal, Elf);
		var nearest = Distance(left) <= Distance(bottom) ? left : bottom;
		return Distance(nearest) <= Distance(right) ? nearest : right;
	}

	public override void OnDraw(Painter painter)
	{
		base.OnDraw(painter);
		using var scope = painter.Scope();
		float width = Box.Rect.Width, height = Box.Rect.Height;
		if (width <= 0 || height <= 0) return;
		Vector2 Screen(Vector2 p) => new(p.x * width, p.y * height);
		var elf = Screen(Elf);
		var goblin = Screen(Goblin);
		var animal = Screen(Animal);
		var human = Screen(Human);
		var left = (elf + goblin) * .5f;
		var right = (elf + animal) * .5f;
		var bottom = (goblin + animal) * .5f;
		painter.Stroke = Stroke.None;
		painter.Fill = new Color(.22f, .29f, .28f);
		painter.Quad(elf, right, human, left);
		painter.Fill = new Color(.29f, .27f, .18f);
		painter.Quad(left, human, bottom, goblin);
		painter.Fill = new Color(.31f, .23f, .20f);
		painter.Quad(human, right, animal, bottom);
		painter.Fill = Color.Transparent;
		painter.Stroke = Stroke.Solid(new Color(.68f, .58f, .36f, .75f), ScaleToScreen);
		painter.Triangle(elf, goblin, animal);
		painter.Stroke = Stroke.Solid(new Color(.78f, .70f, .52f, .22f), ScaleToScreen);
		painter.Line(human, left);
		painter.Line(human, right);
		painter.Line(human, bottom);
		painter.Stroke = Stroke.Solid(new Color(.81f, .73f, .57f, .6f), ScaleToScreen);
		painter.Fill = new Color(.09f, .10f, .07f);
		painter.Circle(human, 3 * ScaleToScreen);
		var marker = Screen(ClampToTriangle(Value, new Vector2(width, height)));
		painter.Stroke = Stroke.Solid(new Color(.08f, .09f, .06f), 4 * ScaleToScreen);
		painter.Fill = Color.Transparent;
		painter.Circle(marker, 6 * ScaleToScreen);
		painter.Stroke = Stroke.Solid(new Color(.96f, .86f, .65f), 2 * ScaleToScreen);
		painter.Circle(marker, 6 * ScaleToScreen);
	}

	void Pick()
	{
		var rect = Box.Rect;
		if (rect.Width <= 0 || rect.Height <= 0) return;
		var mouse = MousePosition;
		var position = ClampToTriangle(new Vector2(mouse.x / rect.Width, mouse.y / rect.Height), new Vector2(rect.Width, rect.Height));
		Value = position;
		OnValueChanged?.Invoke(position);
	}

	protected override void OnMouseDown(MousePanelEvent e)
	{
		base.OnMouseDown(e);
		if (e.MouseButton != MouseButtons.Left) return;
		Pick();
		e.StopPropagation();
	}
	protected override void OnMouseMove(MousePanelEvent e)
	{
		base.OnMouseMove(e);
		if (!HasActive || e.MouseButton == MouseButtons.Right) return;
		Pick();
		e.StopPropagation();
	}
	protected override void OnMouseUp(MousePanelEvent e)
	{
		base.OnMouseUp(e);
		if (e.MouseButton == MouseButtons.Left) e.StopPropagation();
	}
}