Code/CharacterKit/ReferenceFacePanel.razor

A Razor UI panel for importing reference photos to generate a base face. It lets the user add up to five image URLs, runs a background FacePhotoBridge analyzer per image, shows statuses and thumbnails, and invokes PhotoFaceFit.Generate to create a custom PhotoFace tied to the current CharacterAppearanceData.

NetworkingFile Access
@using Sandbox

@using Sandbox.UI

@using System

@namespace CharacterEditor

@inherits Panel

<root>

 <div class="optionheading">Make a face from photos</div>

 <div class="optiondescription">Add up to five direct image links of the same person. Combine a front photo with an angled or side photo and a relaxed expression. Front views guide widths; side views guide the nose, lips and chin. The result is an approximate starting shape; refine it in Shape afterwards.</div>

 <div class="optiondescription">Images are read by the background analyzer on this device. Some image hosts block this access. Photos and links are not included in saved characters.</div>

 <TextEntry class="photo-url" Value=@Url OnTextEdited=@((string value)=>{Url=value;}) Placeholder="https://…/portrait.jpg" MaxLength=@(2048) />

 <button class="creatorbutton add-photo" onclick=@AddReference>Load reference</button>
 @if(Rows.Count>0){<button class="creatorbutton quiet refresh-photos" onclick=@RefreshReferences>Refresh analyses</button>}
 @if(!string.IsNullOrEmpty(Message)){<div class="optiondescription">@Message</div>}

 @foreach(var row in Rows)

 {

  <div class="photo-reference">

   @if(row.Measurement is not null){<img class="photo-thumb" [email protected] />}

   <div class="photo-reference-text"><div class="fieldlabel">Reference @(Rows.IndexOf(row)+1)</div><div class="optiondescription">@row.Status</div><button class=@($"creatorbutton quiet remove-photo-{Rows.IndexOf(row)}") onclick=@(()=>Remove(row))>Remove</button></div>

  </div>

 }

 <div class="optiondescription">@(Rows.Any(x=>x.Measurement?.FrontUsable==true)?"Front widths: covered.":"Add a front view for face widths.") @(Rows.Any(x=>x.Measurement?.Yaw>=45)?"Profile depth: covered.":Rows.Any(x=>x.Measurement?.ProfileUsable==true)?"Some depth from angled views; a clearer side view helps.":"Add an angled or side view for nose and chin depth.")</div>

 <div class="fieldlabel">Starting heads</div>
 <div class="optiondescription">Choose the foundation to keep while fitting. Selected skin uses that skin's original head family; it does not guess from the photos. Current blend preserves your chosen balance of male and female heads.</div>
 @foreach(var choice in new[]{("skin","Selected skin"),("female","Female heads"),("male","Male heads"),("current","Current blend")})
 {
  <button class=@($"creatorbutton {(Basis==choice.Item1?"primary":"quiet")}") onclick=@(()=>Basis=choice.Item1)>@choice.Item2</button>
 }
 <div class="fieldlabel">Name your base face</div>
 <TextEntry class="photo-name" Value=@FaceName OnTextEdited=@((string value)=>{FaceName=value;}) MaxLength=@(40) Placeholder="My photo face" />
 <button class="creatorbutton primary generate-photo-face" onclick=@Generate>Generate base face</button>

 <div class="optiondescription">Generation preserves the starting balance of male and female heads while fitting proportions. It adds a custom face at full influence; existing face influences are set to zero. Your Shape edits remain applied on top.</div>
</root>

@code {
	public CharacterAppearanceData Appearance { get; set; }
	public Action<PhotoFace> Generated { get; set; }
	public string AnalyzerPage { get; set; } = FacePhotoBridge.Page;

	public sealed class Reference
	{
		public string Url, Status;
		public FaceMeasurement Measurement;
	}

	readonly List<Reference> Rows = new();
	string Url = "", FaceName = "My photo face", Message;
	string Basis = "skin";
	FacePhotoBridge Bridge;
	Reference Active;
	public void AddUrl(string url)
	{
		Url = url;
		AddReference();
	}

	void AddReference()
	{
		if (Rows.Count >= 5)
		{
			Message = "You can use up to five references. Remove one to add another.";
			return;
		}

		var value = Url.Trim();
		if (!FacePhotoBridge.ValidUrl(value))
		{
			Message = "Enter a direct HTTPS link to a JPEG, PNG or WebP image.";
			return;
		}

		if (Rows.Any(x => x.Url == value))
		{
			Message = "That reference is already in the list.";
			return;
		}

		Rows.Add(new() { Url = value, Status = "Waiting…" });
		Url = "";
		Message = null;
	}

	void Remove(Reference row)
	{
		if (Active == row)
		{
			Bridge?.Dispose();
			Bridge = null;
			Active = null;
		}

		Rows.Remove(row);
		Message = null;
	}

	public void RefreshReferences()
	{
		Bridge?.Dispose();
		Bridge = null;
		Active = null;
		Message = null;
		foreach (var row in Rows)
		{
			row.Measurement = null;
			row.Status = "Waiting…";
		}
	}

	public override void Tick()
	{
		base.Tick();
		if (Bridge is null)
		{
			Active = Rows.FirstOrDefault(x => x.Status == "Waiting…");
			if (Active is not null)
				try
				{
					Bridge = new(this, Active.Url, AnalyzerPage);
				}
				catch (Exception e)
				{
					Active.Status = e.Message;
					Active = null;
				}
		}

		if (Bridge is null)
			return;
		Bridge.Tick();
		Active.Status = Bridge.Status;
		if (Bridge.Complete)
		{
			Active.Measurement = Bridge.Result;
			if (Active.Measurement is not null)
				Active.Status = "Ready · " + Active.Measurement.Description;
			Bridge.Dispose();
			Bridge = null;
			Active = null;
		}
	}

	void Generate()
	{
		if (Bridge is not null || Rows.Any(x => x.Status == "Waiting…"))
		{
			Message = "Wait for the references to finish loading first.";
			return;
		}

		var good = Rows.Where(x => x.Measurement is not null).ToArray();
		if (good.Length == 0)
		{
			Message = "Load at least one usable reference first.";
			return;
		}

		if (good.Length != Rows.Count)
		{
			Message = "Remove the failed references or replace them before generating.";
			return;
		}

		if (Appearance.BaseShape?.PhotoFaces.Count >= 4)
		{
			Message = "You can keep four custom faces in a character. Remove one in Face & body first.";
			return;
		}

		try
		{
			UnifiedHuman.Enable(Appearance);
			Generated?.Invoke(PhotoFaceFit.Generate(FaceName, good.Select(x => x.Measurement).ToArray(), PhotoFaceFit.StartingHeads(Appearance, Basis)));
		}
		catch (Exception e)
		{
			Message = e.Message;
		}
	}

	public override void Delete(bool immediate = false)
	{
		Bridge?.Dispose();
		base.Delete(immediate);
	}

	protected override int BuildHash() => HashCode.Combine(Url, FaceName, Message, Basis, string.Join("|", Rows.Select(x => x.Url + x.Status)));
}