Editor/UI/ArchTextureGroupEditor.cs

Editor UI window and canvas for editing UVs of architecture mesh faces. Provides selection modes (vertex/edge/face/shell), transform controls (scale/offset), unwrapping, relaxing, syncing across linked variations, rendering material preview, and applying or reverting UV changes.

File AccessNative InteropNetworking
using Editor;
using Editor.MeshEditor;
using Sandbox;

namespace Sunless.Architecture;

public static class ArchTextureGroupEditor
{
	public static void Show( Widget parent, MeshFace[][][] variations, Material material, Action<ArchFaceUvSet> applied )
	{
		new ArchTextureGroupWindow( parent, variations, material, applied ).OpenAtCursor();
	}
}

enum ArchUvSelectionMode
{
	Vertex,
	Edge,
	Face,
	Shell
}

readonly record struct ArchUvPoint( int Face, int Corner );

sealed class ArchTextureGroupWindow : Widget
{
	sealed class Controls
	{
		public float UniformScale { get; set; } = 1f;
		public float ScaleU { get; set; } = 1f;
		public float ScaleV { get; set; } = 1f;
		public float OffsetU { get; set; }
		public float OffsetV { get; set; }
	}

	readonly MeshFace[][][] variations;
	readonly Material material;
	readonly MeshFace[] faces;
	readonly Action<ArchFaceUvSet> apply;
	readonly Dictionary<MeshFace, Vector2[]> originalCoordinates;
	readonly Dictionary<MeshFace, Material> originalMaterials;
	readonly Controls controls = new();
	readonly HashSet<ArchUvPoint> selected = new();
	readonly HashSet<int> unwrapped = new();
	ArchUvCanvas canvas;
	Layout variationRow;
	Label selectionLabel;
	Button linkButton;
	ArchUvSelectionMode mode = ArchUvSelectionMode.Shell;
	bool linked = true;
	int activeVariation;
	float appliedUniformScale = 1f;
	float appliedScaleU = 1f;
	float appliedScaleV = 1f;
	float appliedOffsetU;
	float appliedOffsetV;
	bool updatingControls;
	bool accepted;

	MeshFace[] ActiveFaces => variations[activeVariation][0].Where( face => face.IsValid ).ToArray();

	public ArchTextureGroupWindow( Widget parent, MeshFace[][][] variations, Material material, Action<ArchFaceUvSet> applied ) : base( parent )
	{
		this.variations = variations;
		this.material = material;
		apply = applied;
		faces = variations.SelectMany( variation => variation ).SelectMany( group => group ).Distinct().ToArray();
		originalCoordinates = faces.Where( face => face.IsValid ).ToDictionary( face => face, face => face.TextureCoordinates.ToArray() );
		originalMaterials = faces.Where( face => face.IsValid ).ToDictionary( face => face, face => face.Material );

		WindowFlags = WindowFlags.Tool | WindowFlags.Customized | WindowFlags.CloseButton | WindowFlags.WindowTitle;
		WindowTitle = "Context Face Tool";
		DeleteOnClose = true;
		MinimumSize = new Vector2( 820f, 620f );
		Size = new Vector2( 1040f, 760f );

		ApplyMaterial();
		Build();
		ActivateVariation( 0 );
	}

	void Build()
	{
		Layout = Layout.Column();
		Layout.Margin = 10;
		Layout.Spacing = 7;

		var header = Layout.AddRow();
		header.Spacing = 6;
		header.Add( new Label( "Context Face Tool" ) );
		header.Add( new Label.Small( $"{variations.Length} logical variations - {variations.Sum( variation => variation.Length )} linked instances" ), 1 );
		header.Add( new Button( "Relax Stretch", "blur_on" ) { Clicked = RelaxStretch } );
		header.Add( new Button( "Unwrap Connected", "account_tree" ) { Clicked = UnwrapConnected } );

		variationRow = Layout.AddRow();
		variationRow.Spacing = 5;
		BuildVariationButtons();

		var modeRow = Layout.AddRow();
		modeRow.Spacing = 5;
		modeRow.Add( new Label.Small( "SELECT" ) { MinimumWidth = 56 } );
		linkButton = new Button.Primary( "Linked", "link" ) { IsToggle = true, IsChecked = true, Clicked = ToggleLinked };
		modeRow.Add( linkButton );
		modeRow.Add( new Button( "Vertex", "control_point" ) { Clicked = () => SetMode( ArchUvSelectionMode.Vertex ) } );
		modeRow.Add( new Button( "Edge", "timeline" ) { Clicked = () => SetMode( ArchUvSelectionMode.Edge ) } );
		modeRow.Add( new Button( "Face", "crop_square" ) { Clicked = () => SetMode( ArchUvSelectionMode.Face ) } );
		modeRow.Add( new Button.Primary( "Shell", "select_all" ) { Clicked = () => SetMode( ArchUvSelectionMode.Shell ) } );
		selectionLabel = new Label.Small( "" );
		modeRow.Add( selectionLabel, 1 );
		modeRow.Add( new Button( "Align U", "align_horizontal_center" ) { Clicked = () => AlignSelected( true ) } );
		modeRow.Add( new Button( "Align V", "align_vertical_center" ) { Clicked = () => AlignSelected( false ) } );
		modeRow.Add( new Button( "Weld", "join_inner" ) { Clicked = WeldSelected } );

		var transformRow = Layout.AddRow();
		transformRow.Spacing = 5;
		transformRow.Add( new Label.Small( "TRANSFORM" ) { MinimumWidth = 56 } );
		var serialized = controls.GetSerialized();
		serialized.OnPropertyChanged += TransformSelection;
		transformRow.Add( TransformControl( serialized, nameof( Controls.UniformScale ), "S", Theme.Blue, true ) );
		transformRow.Add( TransformControl( serialized, nameof( Controls.ScaleU ), "U", Theme.Red, true ) );
		transformRow.Add( TransformControl( serialized, nameof( Controls.ScaleV ), "V", Theme.Green, true ) );
		transformRow.Add( TransformControl( serialized, nameof( Controls.OffsetU ), "X", Theme.Red, false ) );
		transformRow.Add( TransformControl( serialized, nameof( Controls.OffsetV ), "Y", Theme.Green, false ) );
		transformRow.Add( new Button( "Reset Controls", "restart_alt" ) { Clicked = ResetTransformControls } );
		transformRow.AddStretchCell();

		canvas = new ArchUvCanvas( this, material );
		Layout.Add( canvas, 1 );

		var footer = Layout.AddRow();
		footer.Add( new Label.Small( "Drag selected UVs - mouse wheel zooms - middle mouse pans - Shift adds to selection" ), 1 );
		footer.Add( new Button( "Frame", "fit_screen" ) { Clicked = () => canvas.FrameUv() } );
		footer.Add( new Button( "Cancel", "close" ) { Clicked = Destroy } );
		footer.Add( new Button.Primary( "Apply to Model & Palette", "check" ) { Clicked = ApplyAndClose } );
	}

	FloatControlWidget TransformControl( SerializedObject serialized, string property, string label, Color colour, bool positive )
	{
		var control = new FloatControlWidget( serialized.GetProperty( property ) )
		{
			Label = label,
			HighlightColor = colour,
			FixedWidth = 92f,
			FixedHeight = Theme.RowHeight
		};

		control.MakeRanged( positive ? new Vector2( 0.05f, 20f ) : new Vector2( -32f, 32f ), positive ? 0.01f : 0.0025f, positive, false );
		return control;
	}

	void BuildVariationButtons()
	{
		variationRow.Clear( true );
		variationRow.Add( new Label.Small( "VARIATION" ) { MinimumWidth = 56 } );

		for ( var index = 0; index < variations.Length; index++ )
		{
			var captured = index;
			var count = variations[index].Length;
			var button = index == activeVariation
				? new Button.Primary( $"{index + 1} - {count}x", "texture" )
				: new Button( $"{index + 1} - {count}x", "texture" );
			button.Clicked = () => ActivateVariation( captured );
			variationRow.Add( button );
		}

		variationRow.AddStretchCell();
	}

	void ActivateVariation( int index )
	{
		if ( index < 0 || index >= variations.Length )
			return;

		SyncVariation( activeVariation );
		activeVariation = index;
		BuildVariationButtons();

		if ( unwrapped.Add( index ) )
		{
			UnwrapConnected();
		}

		SetMode( ArchUvSelectionMode.Shell );
		canvas.FrameUv();
		SelectRepresentativeInScene();
	}

	void SetMode( ArchUvSelectionMode next )
	{
		mode = next;
		selected.Clear();

		if ( mode == ArchUvSelectionMode.Shell )
		{
			SelectEveryPoint();
		}

		ResetTransformControls();
		RefreshSelection();
	}

	void ToggleLinked()
	{
		linked = linkButton.IsChecked;
		linkButton.Text = linked ? "Linked" : "Individual";
		linkButton.Icon = linked ? "link" : "link_off";
		RefreshSelection();
	}

	void SelectRepresentativeInScene()
	{
		if ( SceneEditorSession.Active is null )
			return;

		SceneEditorSession.Active.Selection.Clear();
		foreach ( var face in ActiveFaces )
		{
			SceneEditorSession.Active.Selection.Add( face );
		}
	}

	void ApplyMaterial()
	{
		if ( material is null )
			return;

		foreach ( var face in faces.Where( face => face.IsValid ) )
		{
			face.Material = material;
		}
	}

	public IReadOnlyList<MeshFace> CurrentFaces()
	{
		return ActiveFaces;
	}

	public IReadOnlyCollection<ArchUvPoint> SelectedPoints()
	{
		return selected;
	}

	public ArchUvSelectionMode SelectionMode()
	{
		return mode;
	}

	public Vector2 Uv( ArchUvPoint point )
	{
		var active = ActiveFaces;
		if ( point.Face < 0 || point.Face >= active.Length )
			return Vector2.Zero;

		var coordinates = active[point.Face].TextureCoordinates;
		return point.Corner >= 0 && point.Corner < coordinates.Length ? coordinates[point.Corner] : Vector2.Zero;
	}

	public void SelectPoint( ArchUvPoint point, bool additive )
	{
		if ( !additive )
		{
			selected.Clear();
		}

		selected.Add( point );
		if ( linked )
		{
			ExpandCoincidentPoints();
		}
		ResetTransformControls();
		RefreshSelection();
	}

	public void SelectEdge( ArchUvPoint first, ArchUvPoint second, bool additive )
	{
		if ( !additive )
		{
			selected.Clear();
		}

		selected.Add( first );
		selected.Add( second );
		if ( linked )
		{
			ExpandCoincidentPoints();
		}
		ResetTransformControls();
		RefreshSelection();
	}

	public void SelectFace( int faceIndex, bool additive )
	{
		if ( !additive )
		{
			selected.Clear();
		}

		var active = ActiveFaces;
		if ( faceIndex >= 0 && faceIndex < active.Length )
		{
			for ( var corner = 0; corner < active[faceIndex].TextureCoordinates.Length; corner++ )
			{
				selected.Add( new ArchUvPoint( faceIndex, corner ) );
			}
		}

		if ( linked )
		{
			ExpandCoincidentPoints();
		}
		ResetTransformControls();
		RefreshSelection();
	}

	void SelectEveryPoint()
	{
		var active = ActiveFaces;
		for ( var face = 0; face < active.Length; face++ )
		{
			for ( var corner = 0; corner < active[face].TextureCoordinates.Length; corner++ )
			{
				selected.Add( new ArchUvPoint( face, corner ) );
			}
		}
	}

	void ExpandCoincidentPoints()
	{
		var positions = selected.Select( Uv ).ToArray();
		var active = ActiveFaces;

		for ( var face = 0; face < active.Length; face++ )
		{
			var coordinates = active[face].TextureCoordinates;
			for ( var corner = 0; corner < coordinates.Length; corner++ )
			{
				if ( positions.Any( position => position.Distance( coordinates[corner] ) < 0.00001f ) )
				{
					selected.Add( new ArchUvPoint( face, corner ) );
				}
			}
		}
	}

	public void MoveSelected( Vector2 delta )
	{
		TransformSelected( coordinate => coordinate + delta );
	}

	void TransformSelection( SerializedProperty property )
	{
		if ( updatingControls || selected.Count == 0 )
			return;

		var centre = SelectionCentre();

		switch ( property.Name )
		{
			case nameof( Controls.UniformScale ):
				var uniformRatio = MathF.Max( controls.UniformScale, 0.001f ) / MathF.Max( appliedUniformScale, 0.001f );
				TransformSelected( coordinate => centre + (coordinate - centre) * uniformRatio );
				appliedUniformScale = controls.UniformScale;
				break;
			case nameof( Controls.ScaleU ):
				var ratioU = MathF.Max( controls.ScaleU, 0.001f ) / MathF.Max( appliedScaleU, 0.001f );
				TransformSelected( coordinate => new Vector2( centre.x + (coordinate.x - centre.x) * ratioU, coordinate.y ) );
				appliedScaleU = controls.ScaleU;
				break;
			case nameof( Controls.ScaleV ):
				var ratioV = MathF.Max( controls.ScaleV, 0.001f ) / MathF.Max( appliedScaleV, 0.001f );
				TransformSelected( coordinate => new Vector2( coordinate.x, centre.y + (coordinate.y - centre.y) * ratioV ) );
				appliedScaleV = controls.ScaleV;
				break;
			case nameof( Controls.OffsetU ):
				var deltaU = controls.OffsetU - appliedOffsetU;
				TransformSelected( coordinate => coordinate + new Vector2( deltaU, 0f ) );
				appliedOffsetU = controls.OffsetU;
				break;
			case nameof( Controls.OffsetV ):
				var deltaV = controls.OffsetV - appliedOffsetV;
				TransformSelected( coordinate => coordinate + new Vector2( 0f, deltaV ) );
				appliedOffsetV = controls.OffsetV;
				break;
		}
	}

	void TransformSelected( Func<Vector2, Vector2> transform )
	{
		var active = ActiveFaces;
		foreach ( var group in selected.GroupBy( point => point.Face ) )
		{
			if ( group.Key < 0 || group.Key >= active.Length )
				continue;

			var coordinates = active[group.Key].TextureCoordinates;
			foreach ( var point in group )
			{
				if ( point.Corner >= 0 && point.Corner < coordinates.Length )
				{
					coordinates[point.Corner] = transform( coordinates[point.Corner] );
				}
			}
			active[group.Key].TextureCoordinates = coordinates;
		}

		SyncVariation( activeVariation );
		canvas?.Update();
	}

	Vector2 SelectionCentre()
	{
		return selected.Count == 0
			? Vector2.Zero
			: selected.Select( Uv ).Aggregate( Vector2.Zero, ( sum, coordinate ) => sum + coordinate ) / selected.Count;
	}

	void AlignSelected( bool alignU )
	{
		if ( selected.Count < 2 )
			return;

		var centre = SelectionCentre();
		TransformSelected( coordinate => alignU ? coordinate.WithX( centre.x ) : coordinate.WithY( centre.y ) );
	}

	void WeldSelected()
	{
		if ( selected.Count < 2 )
			return;

		var centre = SelectionCentre();
		TransformSelected( _ => centre );
	}

	void UnwrapConnected()
	{
		var active = ActiveFaces;
		var coordinates = ArchConnectedUvUnwrapper.Unwrap( active );

		for ( var face = 0; face < active.Length && face < coordinates.Length; face++ )
		{
			if ( active[face].IsValid && coordinates[face].Length == active[face].TextureCoordinates.Length )
			{
				active[face].TextureCoordinates = coordinates[face];
			}
		}

		SyncVariation( activeVariation );
		selected.Clear();
		SelectEveryPoint();
		ResetTransformControls();
		RefreshSelection();
		canvas?.FrameUv();
	}

	void RelaxStretch()
	{
		ArchUvRelaxer.Relax( ActiveFaces, selected, linked );
		SyncVariation( activeVariation );
		canvas?.Update();
	}

	void ResetTransformControls()
	{
		updatingControls = true;
		controls.UniformScale = 1f;
		controls.ScaleU = 1f;
		controls.ScaleV = 1f;
		controls.OffsetU = 0f;
		controls.OffsetV = 0f;
		appliedUniformScale = 1f;
		appliedScaleU = 1f;
		appliedScaleV = 1f;
		appliedOffsetU = 0f;
		appliedOffsetV = 0f;
		updatingControls = false;
	}

	void RefreshSelection()
	{
		selectionLabel.Text = $"{mode} - {selected.Count} {(linked ? "linked" : "individual")} UV {(selected.Count == 1 ? "point" : "points")}";
		canvas?.Update();
	}

	void SyncVariation( int index )
	{
		if ( index < 0 || index >= variations.Length || variations[index].Length < 2 )
			return;

		var representative = variations[index][0];

		foreach ( var linked in variations[index].Skip( 1 ) )
		{
			if ( linked.Length != representative.Length )
				continue;

			for ( var faceIndex = 0; faceIndex < representative.Length; faceIndex++ )
			{
				var source = representative[faceIndex];
				var target = linked[faceIndex];

				if ( !source.IsValid || !target.IsValid || source.TextureCoordinates.Length != target.TextureCoordinates.Length )
					continue;

				target.Material = source.Material;
				target.TextureCoordinates = source.TextureCoordinates.ToArray();
			}
		}
	}

	void ApplyAndClose()
	{
		for ( var index = 0; index < variations.Length; index++ )
		{
			SyncVariation( index );
		}

		var mapping = new ArchFaceUvSet
		{
			Variations = variations.Select( variation => new ArchFaceUvVariation
			{
				Signature = ArchFaceUvMappings.Signature( variation[0] ),
				Faces = variation[0].Select( face => new ArchFaceUvFace
				{
					Coordinates = face.TextureCoordinates.ToList()
				} ).ToList()
			} ).ToList()
		};

		accepted = true;
		apply?.Invoke( mapping );
		Destroy();
	}

	public void OpenAtCursor()
	{
		Position = global::Editor.Application.CursorPosition - new Vector2( 160f, 40f );
		Show();
		ConstrainToScreen();
	}

	public override void OnDestroyed()
	{
		if ( !accepted )
		{
			foreach ( var pair in originalCoordinates.Where( pair => pair.Key.IsValid ) )
			{
				var face = pair.Key;
				face.TextureCoordinates = pair.Value;
			}
			foreach ( var pair in originalMaterials.Where( pair => pair.Key.IsValid ) )
			{
				var face = pair.Key;
				face.Material = pair.Value;
			}
		}
		base.OnDestroyed();
	}
}

sealed class ArchUvCanvas : Widget
{
	readonly ArchTextureGroupWindow window;
	Pixmap materialImage;
	Vector2 viewMinimum;
	Vector2 viewSize = Vector2.One;
	Vector2 lastMouse;
	bool dragging;
	bool panning;

	public ArchUvCanvas( ArchTextureGroupWindow window, Material material ) : base( window )
	{
		this.window = window;
		MouseTracking = true;
		FocusMode = FocusMode.Click;
		MinimumHeight = 420f;
		RenderMaterial( material );
	}

	public void FrameUv()
	{
		var coordinates = window.CurrentFaces().SelectMany( face => face.TextureCoordinates ).ToArray();
		if ( coordinates.Length == 0 )
		{
			viewMinimum = Vector2.Zero;
			viewSize = Vector2.One;
			return;
		}

		var minimum = coordinates.Aggregate( coordinates[0], Vector2.Min );
		var maximum = coordinates.Aggregate( coordinates[0], Vector2.Max );
		var size = maximum - minimum;
		size = new Vector2( MathF.Max( size.x, 0.1f ), MathF.Max( size.y, 0.1f ) );
		var padding = size * 0.18f;
		viewMinimum = minimum - padding;
		viewSize = size + padding * 2f;
		MatchAspect();
		Update();
	}

	void MatchAspect()
	{
		if ( Width <= 1f || Height <= 1f )
			return;

		var canvasAspect = Width / Height;
		var viewAspect = viewSize.x / MathF.Max( viewSize.y, 0.001f );
		if ( viewAspect < canvasAspect )
		{
			var width = viewSize.y * canvasAspect;
			viewMinimum.x -= (width - viewSize.x) * 0.5f;
			viewSize.x = width;
		}
		else
		{
			var height = viewSize.x / canvasAspect;
			viewMinimum.y -= (height - viewSize.y) * 0.5f;
			viewSize.y = height;
		}
	}

	Vector2 ToPixel( Vector2 uv )
	{
		var normal = (uv - viewMinimum) / viewSize;
		return new Vector2( normal.x * Width, normal.y * Height );
	}

	Vector2 ToUv( Vector2 pixel )
	{
		return viewMinimum + new Vector2( pixel.x / MathF.Max( Width, 1f ), pixel.y / MathF.Max( Height, 1f ) ) * viewSize;
	}

	protected override void OnResize()
	{
		base.OnResize();
		MatchAspect();
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );
		lastMouse = e.LocalPosition;

		if ( e.Button == MouseButtons.Middle )
		{
			panning = true;
			return;
		}

		if ( e.Button != MouseButtons.Left )
			return;

		SelectAt( e.LocalPosition, e.HasShift || e.HasCtrl );
		dragging = window.SelectedPoints().Count > 0;
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		base.OnMouseReleased( e );
		if ( e.Button == MouseButtons.Middle )
		{
			panning = false;
		}
		if ( e.Button == MouseButtons.Left )
		{
			dragging = false;
		}
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );
		var previous = lastMouse;
		lastMouse = e.LocalPosition;

		if ( panning && (e.ButtonState & MouseButtons.Middle) != 0 )
		{
			viewMinimum -= ToUv( e.LocalPosition ) - ToUv( previous );
			Update();
			return;
		}

		if ( dragging && (e.ButtonState & MouseButtons.Left) != 0 )
		{
			window.MoveSelected( ToUv( e.LocalPosition ) - ToUv( previous ) );
		}
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		base.OnMouseWheel( e );
		var anchor = ToUv( e.Position );
		var factor = e.Delta > 0f ? 0.86f : 1.16f;
		var nextSize = viewSize * factor;
		var ratio = nextSize / viewSize;
		viewMinimum = anchor - (anchor - viewMinimum) * ratio;
		viewSize = nextSize;
		Update();
	}

	void SelectAt( Vector2 pixel, bool additive )
	{
		var faces = window.CurrentFaces();
		var mode = window.SelectionMode();

		if ( mode == ArchUvSelectionMode.Shell )
			return;

		if ( mode == ArchUvSelectionMode.Vertex )
		{
			var hit = Points( faces )
				.OrderBy( point => ToPixel( point.Uv ).Distance( pixel ) )
				.FirstOrDefault();
			if ( hit.Uv.Distance( ToUv( pixel ) ) <= viewSize.Length * 0.025f )
			{
				window.SelectPoint( hit.Point, additive );
			}
			return;
		}

		if ( mode == ArchUvSelectionMode.Edge )
		{
			var closest = Edges( faces )
				.Select( edge => (edge, distance: SegmentDistance( pixel, ToPixel( edge.AUv ), ToPixel( edge.BUv ) )) )
				.OrderBy( candidate => candidate.distance )
				.FirstOrDefault();
			if ( closest.distance <= 10f )
			{
				window.SelectEdge( closest.edge.A, closest.edge.B, additive );
			}
			return;
		}

		for ( var face = faces.Count - 1; face >= 0; face-- )
		{
			var polygon = faces[face].TextureCoordinates.Select( ToPixel ).ToArray();
			if ( Contains( polygon, pixel ) )
			{
				window.SelectFace( face, additive );
				return;
			}
		}
	}

	IEnumerable<(ArchUvPoint Point, Vector2 Uv)> Points( IReadOnlyList<MeshFace> faces )
	{
		for ( var face = 0; face < faces.Count; face++ )
		{
			var coordinates = faces[face].TextureCoordinates;
			for ( var corner = 0; corner < coordinates.Length; corner++ )
			{
				yield return (new ArchUvPoint( face, corner ), coordinates[corner]);
			}
		}
	}

	IEnumerable<(ArchUvPoint A, ArchUvPoint B, Vector2 AUv, Vector2 BUv)> Edges( IReadOnlyList<MeshFace> faces )
	{
		for ( var face = 0; face < faces.Count; face++ )
		{
			var coordinates = faces[face].TextureCoordinates;
			for ( var corner = 0; corner < coordinates.Length; corner++ )
			{
				var next = (corner + 1) % coordinates.Length;
				yield return (new ArchUvPoint( face, corner ), new ArchUvPoint( face, next ), coordinates[corner], coordinates[next]);
			}
		}
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( Color.FromRgb( 0x171719 ) );
		Paint.DrawRect( LocalRect, 4 );
		DrawTexture();
		DrawGrid();

		var selected = window.SelectedPoints();
		var faces = window.CurrentFaces();
		for ( var face = 0; face < faces.Count; face++ )
		{
			var coordinates = faces[face].TextureCoordinates;
			var polygon = coordinates.Select( ToPixel ).ToArray();
			var faceSelected = Enumerable.Range( 0, coordinates.Length ).All( corner => selected.Contains( new ArchUvPoint( face, corner ) ) );
			Paint.SetBrush( (faceSelected ? Theme.Primary : Color.White).WithAlpha( faceSelected ? 0.18f : 0.06f ) );
			Paint.SetPen( faceSelected ? Color.Yellow : Color.White.WithAlpha( 0.78f ), faceSelected ? 2.4f : 1.3f );
			Paint.DrawPolygon( polygon );

			for ( var corner = 0; corner < coordinates.Length; corner++ )
			{
				var point = new ArchUvPoint( face, corner );
				var chosen = selected.Contains( point );
				Paint.SetPen( chosen ? Color.Yellow : Color.White.WithAlpha( 0.82f ), 1f );
				Paint.SetBrush( chosen ? Color.Yellow : Color.FromRgb( 0x28282C ) );
				Paint.DrawCircle( ToPixel( coordinates[corner] ), chosen ? 4.2f : 2.6f );
			}
		}
	}

	void DrawTexture()
	{
		if ( materialImage is null )
			return;

		var firstX = (int)MathF.Floor( viewMinimum.x );
		var lastX = (int)MathF.Ceiling( viewMinimum.x + viewSize.x );
		var firstY = (int)MathF.Floor( viewMinimum.y );
		var lastY = (int)MathF.Ceiling( viewMinimum.y + viewSize.y );

		for ( var y = firstY; y < lastY; y++ )
		{
			for ( var x = firstX; x < lastX; x++ )
			{
				var start = ToPixel( new Vector2( x, y ) );
				var end = ToPixel( new Vector2( x + 1f, y + 1f ) );
				Paint.Draw( Rect.FromPoints( start, end ), materialImage, 0.72f );
			}
		}
	}

	void DrawGrid()
	{
		var step = viewSize.Length < 1f ? 0.0625f : viewSize.Length < 4f ? 0.25f : 1f;
		var firstX = MathF.Floor( viewMinimum.x / step ) * step;
		var lastX = viewMinimum.x + viewSize.x;
		var firstY = MathF.Floor( viewMinimum.y / step ) * step;
		var lastY = viewMinimum.y + viewSize.y;

		Paint.SetPen( Color.White.WithAlpha( step >= 1f ? 0.16f : 0.07f ), 1f );
		for ( var x = firstX; x <= lastX; x += step )
		{
			Paint.DrawLine( ToPixel( new Vector2( x, viewMinimum.y ) ), ToPixel( new Vector2( x, viewMinimum.y + viewSize.y ) ) );
		}
		for ( var y = firstY; y <= lastY; y += step )
		{
			Paint.DrawLine( ToPixel( new Vector2( viewMinimum.x, y ) ), ToPixel( new Vector2( viewMinimum.x + viewSize.x, y ) ) );
		}
	}

	void RenderMaterial( Material material )
	{
		var texture = material?.FirstTexture;
		if ( texture is null )
			return;

		var world = new SceneWorld();
		var camera = new SceneCamera
		{
			BackgroundColor = Color.Black,
			Ortho = true,
			Rotation = Rotation.FromPitch( 90 ),
			Position = Vector3.Up * 200,
			OrthoHeight = 100,
			World = world
		};
		var light = new SceneSpotLight( world )
		{
			Radius = 4000,
			LightColor = Color.White * 0.7f,
			Position = new Vector3( 0, 0, 100 ),
			ConeOuter = 89,
			ConeInner = 75,
			QuadraticAttenuation = 5f,
			ShadowsEnabled = true,
			Rotation = Rotation.From( 90, 0, 0 )
		};
		var model = Model.Load( "models/dev/plane_blend.vmdl" );
		var sceneObject = new SceneObject( world, model )
		{
			Transform = new Transform
			{
				Position = Vector3.Zero,
				Rotation = Rotation.From( 0, 180, 0 ),
				Scale = new Vector3( 1, texture.Size.x / texture.Size.y, 1 )
			}
		};
		sceneObject.SetMaterialOverride( material );
		materialImage = new Pixmap( texture.Size );
		if ( !camera.RenderToPixmap( materialImage ) )
		{
			materialImage = null;
		}
		world.Delete();
		camera.Dispose();
	}

	static float SegmentDistance( Vector2 point, Vector2 start, Vector2 end )
	{
		var edge = end - start;
		var lengthSquared = edge.LengthSquared;
		if ( lengthSquared < 0.0001f )
			return point.Distance( start );
		var amount = Math.Clamp( (point - start).Dot( edge ) / lengthSquared, 0f, 1f );
		return point.Distance( start + edge * amount );
	}

	static bool Contains( IReadOnlyList<Vector2> polygon, Vector2 point )
	{
		var inside = false;
		for ( int current = 0, previous = polygon.Count - 1; current < polygon.Count; previous = current++ )
		{
			var a = polygon[current];
			var b = polygon[previous];
			if ( (a.y > point.y) != (b.y > point.y) && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x )
			{
				inside = !inside;
			}
		}
		return inside;
	}
}

static class ArchConnectedUvUnwrapper
{
	const float Tolerance = 0.001f;
	readonly record struct PrismSide( int Face, Vector2 A, Vector2 B );

	public static Vector2[][] Unwrap( MeshFace[] faces )
	{
		var positions = faces.Select( FacePositions ).ToArray();
		var result = faces.Select( face => new Vector2[face.TextureCoordinates.Length] ).ToArray();
		if ( faces.Length == 0 )
			return result;

		if ( !TryUnwrapVerticalPrism( positions, result ) )
		{
			var seed = Enumerable.Range( 0, faces.Length ).OrderByDescending( index => Area( positions[index] ) ).First();
			ProjectSeed( positions[seed], result[seed] );
			var processed = new HashSet<int> { seed };
			var waiting = new Queue<int>( Enumerable.Range( 0, faces.Length ).Where( index => index != seed ) );
			var attempts = 0;

			while ( waiting.Count > 0 && attempts < faces.Length * faces.Length )
			{
				var face = waiting.Dequeue();
				if ( TryAttach( face, processed, positions, result ) )
				{
					processed.Add( face );
					attempts = 0;
				}
				else
				{
					waiting.Enqueue( face );
					attempts++;
				}
			}

			foreach ( var face in waiting.Distinct() )
			{
				ProjectSeed( positions[face], result[face] );
			}
		}

		var density = Density( faces, positions );
		var existingCentre = faces.SelectMany( face => face.TextureCoordinates ).DefaultIfEmpty( Vector2.Zero ).Aggregate( Vector2.Zero, ( sum, uv ) => sum + uv ) / Math.Max( 1, faces.Sum( face => face.TextureCoordinates.Length ) );
		var generated = result.SelectMany( coordinates => coordinates ).ToArray();
		var generatedCentre = generated.DefaultIfEmpty( Vector2.Zero ).Aggregate( Vector2.Zero, ( sum, uv ) => sum + uv ) / Math.Max( 1, generated.Length );

		for ( var face = 0; face < result.Length; face++ )
		{
			for ( var corner = 0; corner < result[face].Length; corner++ )
			{
				result[face][corner] = existingCentre + (result[face][corner] - generatedCentre) * density;
			}
		}

		return result;
	}

	static bool TryUnwrapVerticalPrism( Vector3[][] positions, Vector2[][] result )
	{
		var all = positions.SelectMany( face => face ).ToArray();
		if ( all.Length < 8 )
			return false;

		var low = all.Min( point => point.z );
		var high = all.Max( point => point.z );
		if ( high - low < Tolerance )
			return false;

		var sides = new List<PrismSide>();
		var top = new List<int>();
		var bottom = new List<int>();

		for ( var face = 0; face < positions.Length; face++ )
		{
			if ( positions[face].All( point => MathF.Abs( point.z - high ) < Tolerance ) )
			{
				top.Add( face );
				continue;
			}
			if ( positions[face].All( point => MathF.Abs( point.z - low ) < Tolerance ) )
			{
				bottom.Add( face );
				continue;
			}

			var footprint = UniqueFootprint( positions[face] );
			if ( footprint.Count != 2 )
				return false;
			sides.Add( new PrismSide( face, footprint[0], footprint[1] ) );
		}

		if ( sides.Count < 3 || top.Count == 0 || bottom.Count == 0 )
			return false;

		var ordered = OrderSides( sides );
		if ( ordered.Count != sides.Count )
			return false;

		var cursor = 0f;
		foreach ( var side in ordered )
		{
			var length = side.A.Distance( side.B );
			for ( var corner = 0; corner < positions[side.Face].Length; corner++ )
			{
				var point = positions[side.Face][corner];
				var footprint = new Vector2( point.x, point.y );
				var u = footprint.Distance( side.A ) <= footprint.Distance( side.B ) ? cursor : cursor + length;
				result[side.Face][corner] = new Vector2( u, point.z - low );
			}
			cursor += length;
		}

		var footprintPoints = sides.SelectMany( side => new[] { side.A, side.B } ).ToArray();
		var minimum = footprintPoints.Aggregate( footprintPoints[0], Vector2.Min );
		var maximum = footprintPoints.Aggregate( footprintPoints[0], Vector2.Max );
		var size = maximum - minimum;
		var padding = MathF.Max( 0.5f, MathF.Max( high - low, MathF.Max( size.x, size.y ) ) * 0.12f );
		var topOffset = new Vector2( 0f, high - low + padding );
		var bottomOffset = new Vector2( size.x + padding, high - low + padding );

		ProjectCaps( top, positions, result, minimum, topOffset );
		ProjectCaps( bottom, positions, result, minimum, bottomOffset );
		return true;
	}

	static List<Vector2> UniqueFootprint( IEnumerable<Vector3> positions )
	{
		var result = new List<Vector2>();
		foreach ( var position in positions )
		{
			var point = new Vector2( position.x, position.y );
			if ( !result.Any( existing => existing.Distance( point ) < Tolerance ) )
			{
				result.Add( point );
			}
		}
		return result;
	}

	static List<PrismSide> OrderSides( IReadOnlyList<PrismSide> sides )
	{
		var remaining = sides.ToList();
		var first = remaining.OrderByDescending( side => side.A.Distance( side.B ) ).First();
		remaining.Remove( first );
		var ordered = new List<PrismSide> { first };
		var cursor = first.B;

		while ( remaining.Count > 0 )
		{
			var next = remaining.FirstOrDefault( side => side.A.Distance( cursor ) < Tolerance || side.B.Distance( cursor ) < Tolerance );
			if ( next == default )
				break;
			remaining.Remove( next );
			if ( next.B.Distance( cursor ) < Tolerance )
			{
				next = new PrismSide( next.Face, next.B, next.A );
			}
			ordered.Add( next );
			cursor = next.B;
		}

		return ordered;
	}

	static void ProjectCaps( IEnumerable<int> caps, Vector3[][] positions, Vector2[][] result, Vector2 minimum, Vector2 offset )
	{
		foreach ( var face in caps )
		{
			for ( var corner = 0; corner < positions[face].Length; corner++ )
			{
				var point = positions[face][corner];
				result[face][corner] = new Vector2( point.x, point.y ) - minimum + offset;
			}
		}
	}

	static Vector3[] FacePositions( MeshFace face )
	{
		if ( !face.IsValid )
			return Array.Empty<Vector3>();
		return face.Component.Mesh.GetFaceVertices( face.Handle )
			.Select( vertex => face.Transform.PointToWorld( face.Component.Mesh.GetVertexPosition( vertex ) ) )
			.ToArray();
	}

	static void ProjectSeed( Vector3[] positions, Vector2[] target )
	{
		if ( positions.Length < 3 || target.Length != positions.Length )
			return;
		var origin = positions[0];
		var axisU = (positions[1] - origin).Normal;
		var normal = axisU.Cross( (positions[2] - origin).Normal ).Normal;
		var axisV = normal.Cross( axisU );
		for ( var corner = 0; corner < positions.Length; corner++ )
		{
			var relative = positions[corner] - origin;
			target[corner] = new Vector2( relative.Dot( axisU ), relative.Dot( axisV ) );
		}
	}

	static bool TryAttach( int face, HashSet<int> processed, Vector3[][] positions, Vector2[][] result )
	{
		foreach ( var neighbour in processed )
		{
			if ( TrySharedEdge( positions[face], positions[neighbour], out var a, out var b, out var neighbourA, out var neighbourB ) )
			{
				Attach( positions[face], result[face], a, b, result[neighbour][neighbourA], result[neighbour][neighbourB], result[neighbour] );
				return true;
			}
		}
		return false;
	}

	static bool TrySharedEdge( Vector3[] face, Vector3[] neighbour, out int a, out int b, out int neighbourA, out int neighbourB )
	{
		for ( a = 0; a < face.Length; a++ )
		{
			b = (a + 1) % face.Length;
			for ( neighbourA = 0; neighbourA < neighbour.Length; neighbourA++ )
			{
				neighbourB = (neighbourA + 1) % neighbour.Length;
				if ( face[a].Distance( neighbour[neighbourB] ) < Tolerance && face[b].Distance( neighbour[neighbourA] ) < Tolerance )
					return true;
				if ( face[a].Distance( neighbour[neighbourA] ) < Tolerance && face[b].Distance( neighbour[neighbourB] ) < Tolerance )
					return true;
			}
		}
		b = neighbourA = neighbourB = -1;
		return false;
	}

	static void Attach( Vector3[] positions, Vector2[] target, int a, int b, Vector2 uvA, Vector2 uvB, Vector2[] neighbour )
	{
		target[a] = uvA;
		target[b] = uvB;
		var edge3 = positions[b] - positions[a];
		var edge2 = uvB - uvA;
		var edgeDirection = edge2.Normal;
		var perpendicular = new Vector2( -edgeDirection.y, edgeDirection.x );
		var neighbourCentre = neighbour.Aggregate( Vector2.Zero, ( sum, uv ) => sum + uv ) / Math.Max( 1, neighbour.Length );
		var neighbourSide = MathF.Sign( edge2.x * (neighbourCentre.y - uvA.y) - edge2.y * (neighbourCentre.x - uvA.x) );
		if ( neighbourSide == 0f )
			neighbourSide = 1;

		var normal = edge3.Cross( positions.First( position => position.Distance( positions[a] ) > Tolerance && position.Distance( positions[b] ) > Tolerance ) - positions[a] ).Normal;
		var localV = normal.Cross( edge3.Normal );
		var scale = edge2.Length / MathF.Max( edge3.Length, 0.0001f );

		for ( var corner = 0; corner < positions.Length; corner++ )
		{
			if ( corner == a || corner == b )
				continue;
			var relative = positions[corner] - positions[a];
			var u = relative.Dot( edge3.Normal ) * scale;
			var v = MathF.Abs( relative.Dot( localV ) ) * scale;
			target[corner] = uvA + edgeDirection * u - perpendicular * neighbourSide * v;
		}
	}

	static float Density( MeshFace[] faces, Vector3[][] positions )
	{
		var ratios = new List<float>();
		for ( var face = 0; face < faces.Length; face++ )
		{
			var coordinates = faces[face].TextureCoordinates;
			for ( var corner = 0; corner < Math.Min( coordinates.Length, positions[face].Length ); corner++ )
			{
				var next = (corner + 1) % coordinates.Length;
				if ( next >= positions[face].Length )
					continue;
				var worldLength = positions[face][corner].Distance( positions[face][next] );
				var uvLength = coordinates[corner].Distance( coordinates[next] );
				if ( worldLength > 0.001f && uvLength > 0.00001f )
				{
					ratios.Add( uvLength / worldLength );
				}
			}
		}
		if ( ratios.Count == 0 )
			return 1f;
		ratios.Sort();
		return ratios[ratios.Count / 2];
	}

	static float Area( Vector3[] positions )
	{
		if ( positions.Length < 3 )
			return 0f;
		var area = 0f;
		for ( var corner = 1; corner < positions.Length - 1; corner++ )
		{
			area += (positions[corner] - positions[0]).Cross( positions[corner + 1] - positions[0] ).Length * 0.5f;
		}
		return area;
	}
}

static class ArchUvRelaxer
{
	sealed class Node
	{
		public Vector2 Position { get; set; }
		public List<ArchUvPoint> Points { get; } = new();
	}

	readonly record struct Edge( int A, int B, float WorldLength );

	public static void Relax( MeshFace[] faces, IReadOnlyCollection<ArchUvPoint> selection, bool linked )
	{
		if ( faces.Length == 0 || selection.Count == 0 )
			return;

		var nodes = new List<Node>();
		var pointNodes = new Dictionary<ArchUvPoint, int>();
		BuildNodes( faces, nodes, pointNodes, linked );
		var edges = BuildEdges( faces, pointNodes );
		var selectedNodes = selection.Where( pointNodes.ContainsKey ).Select( point => pointNodes[point] ).ToHashSet();
		if ( selectedNodes.Count == 0 || edges.Count == 0 )
			return;

		var density = TextureDensity( faces );
		var centre = selectedNodes.Select( index => nodes[index].Position ).Aggregate( Vector2.Zero, ( sum, position ) => sum + position ) / selectedNodes.Count;
		var allSelected = selectedNodes.Count == nodes.Count;

		for ( var iteration = 0; iteration < 72; iteration++ )
		{
			var movement = new Vector2[nodes.Count];
			var weights = new int[nodes.Count];

			foreach ( var edge in edges )
			{
				var delta = nodes[edge.B].Position - nodes[edge.A].Position;
				var length = delta.Length;
				if ( length < 0.000001f )
					continue;

				var correction = delta / length * (length - edge.WorldLength * density);
				var selectA = selectedNodes.Contains( edge.A );
				var selectB = selectedNodes.Contains( edge.B );

				if ( selectA )
				{
					movement[edge.A] += correction * (selectB ? 0.5f : 1f);
					weights[edge.A]++;
				}
				if ( selectB )
				{
					movement[edge.B] -= correction * (selectA ? 0.5f : 1f);
					weights[edge.B]++;
				}
			}

			foreach ( var node in selectedNodes )
			{
				if ( weights[node] > 0 )
				{
					nodes[node].Position += movement[node] / weights[node] * 0.72f;
				}
			}

			if ( allSelected )
			{
				var movedCentre = selectedNodes.Select( index => nodes[index].Position ).Aggregate( Vector2.Zero, ( sum, position ) => sum + position ) / selectedNodes.Count;
				foreach ( var node in selectedNodes )
				{
					nodes[node].Position += centre - movedCentre;
				}
			}
		}

		foreach ( var node in nodes )
		{
			foreach ( var point in node.Points )
			{
				var coordinates = faces[point.Face].TextureCoordinates;
				coordinates[point.Corner] = node.Position;
				faces[point.Face].TextureCoordinates = coordinates;
			}
		}
	}

	static void BuildNodes( MeshFace[] faces, List<Node> nodes, Dictionary<ArchUvPoint, int> pointNodes, bool linked )
	{
		for ( var face = 0; face < faces.Length; face++ )
		{
			var coordinates = faces[face].TextureCoordinates;
			for ( var corner = 0; corner < coordinates.Length; corner++ )
			{
				var node = linked ? nodes.FindIndex( candidate => candidate.Position.Distance( coordinates[corner] ) < 0.00001f ) : -1;
				if ( node < 0 )
				{
					node = nodes.Count;
					nodes.Add( new Node { Position = coordinates[corner] } );
				}
				var point = new ArchUvPoint( face, corner );
				nodes[node].Points.Add( point );
				pointNodes[point] = node;
			}
		}
	}

	static List<Edge> BuildEdges( MeshFace[] faces, IReadOnlyDictionary<ArchUvPoint, int> pointNodes )
	{
		var edges = new Dictionary<(int A, int B), List<float>>();
		for ( var face = 0; face < faces.Length; face++ )
		{
			var vertices = faces[face].Component.Mesh.GetFaceVertices( faces[face].Handle );
			for ( var corner = 0; corner < vertices.Length; corner++ )
			{
				var next = (corner + 1) % vertices.Length;
				var a = pointNodes[new ArchUvPoint( face, corner )];
				var b = pointNodes[new ArchUvPoint( face, next )];
				if ( a == b )
					continue;
				var key = a < b ? (a, b) : (b, a);
				var start = faces[face].Transform.PointToWorld( faces[face].Component.Mesh.GetVertexPosition( vertices[corner] ) );
				var end = faces[face].Transform.PointToWorld( faces[face].Component.Mesh.GetVertexPosition( vertices[next] ) );
				if ( !edges.TryGetValue( key, out var lengths ) )
				{
					lengths = new List<float>();
					edges[key] = lengths;
				}
				lengths.Add( start.Distance( end ) );
			}
		}

		return edges.Select( pair => new Edge( pair.Key.A, pair.Key.B, pair.Value.Average() ) ).ToList();
	}

	static float TextureDensity( MeshFace[] faces )
	{
		var ratios = new List<float>();
		for ( var face = 0; face < faces.Length; face++ )
		{
			var coordinates = faces[face].TextureCoordinates;
			var vertices = faces[face].Component.Mesh.GetFaceVertices( faces[face].Handle );
			for ( var corner = 0; corner < Math.Min( coordinates.Length, vertices.Length ); corner++ )
			{
				var next = (corner + 1) % coordinates.Length;
				if ( next >= vertices.Length )
					continue;
				var start = faces[face].Transform.PointToWorld( faces[face].Component.Mesh.GetVertexPosition( vertices[corner] ) );
				var end = faces[face].Transform.PointToWorld( faces[face].Component.Mesh.GetVertexPosition( vertices[next] ) );
				var worldLength = start.Distance( end );
				var uvLength = coordinates[corner].Distance( coordinates[next] );
				if ( worldLength > 0.001f && uvLength > 0.00001f )
				{
					ratios.Add( uvLength / worldLength );
				}
			}
		}
		if ( ratios.Count == 0 )
			return 1f;
		ratios.Sort();
		return ratios[ratios.Count / 2];
	}
}

public static class ArchFaceUvMappings
{
	public static MeshFace[][][] Variations( ArchTool tool, ArchLayerPalettePicker.MaterialIndex index )
	{
		var groups = ArchAudit.PhysicallyConnected( index.Faces )
			.Where( group => group.Count > 0 )
			.OrderBy( ShapeMetric )
			.ToList();

		if ( groups.Count == 0 )
			return Array.Empty<MeshFace[][]>();

		var split = Math.Max( 1, (groups.Count + 1) / 2 );
		var logical = groups.Count == 1
			? new[] { groups }
			: new[] { groups.Take( split ).ToList(), groups.Skip( split ).ToList() };

		return logical
			.Select( variation => variation
				.Select( group => group
					.OrderBy( face => face.Piece )
					.ThenBy( face => face.HandleIndex )
					.Select( face => MeshFaceFor( tool, face ) )
					.Where( face => face.IsValid && face.TextureCoordinates.Length >= 3 )
					.Distinct()
					.ToArray() )
				.Where( group => group.Length > 0 )
				.ToArray() )
			.Where( variation => variation.Length > 0 )
			.ToArray();
	}

	public static void ApplyStored( ArchTool tool )
	{
		foreach ( var node in Nodes( tool.LayerTree ) )
		{
			var palette = ArchLayerPalettePicker.PaletteOf( node.Payload );
			if ( palette?.FaceMappings is null || palette.FaceMappings.Count == 0 )
				continue;

			foreach ( var index in ArchLayerPalettePicker.Scan( tool, node, palette ) )
			{
				if ( palette.FaceMappings.TryGetValue( index.Context, out var mapping ) )
				{
					Apply( mapping, Variations( tool, index ) );
				}
			}
		}
	}

	public static void Apply( ArchFaceUvSet mapping, MeshFace[][][] variations )
	{
		if ( mapping?.Variations is null )
			return;

		foreach ( var variation in variations )
		{
			var stored = mapping.Variations.FirstOrDefault( candidate => candidate.Signature == Signature( variation[0] ) );
			if ( stored?.Faces is null || stored.Faces.Count != variation[0].Length )
				continue;

			foreach ( var instance in variation )
			{
				if ( instance.Length != stored.Faces.Count )
					continue;

				for ( var face = 0; face < instance.Length; face++ )
				{
					var coordinates = stored.Faces[face].Coordinates?.ToArray();
					if ( instance[face].IsValid && coordinates?.Length == instance[face].TextureCoordinates.Length )
					{
						instance[face].TextureCoordinates = coordinates;
					}
				}
			}
		}
	}

	public static string Signature( IEnumerable<MeshFace> faces )
	{
		return string.Join( "|", faces
			.Where( face => face.IsValid )
			.Select( face => $"{face.TextureCoordinates.Length}:{MathF.Round( Area( face ) * 4f ) / 4f:0.##}" )
			.OrderBy( value => value ) );
	}

	static float ShapeMetric( IEnumerable<ArchFaceDetail> faces )
	{
		var corners = faces.SelectMany( face => face.Corners ).ToArray();
		if ( corners.Length == 0 )
			return 0f;

		var minimum = corners.Aggregate( corners[0], ( value, corner ) => Vector3.Min( value, corner ) );
		var maximum = corners.Aggregate( corners[0], ( value, corner ) => Vector3.Max( value, corner ) );
		var sides = new[] { maximum.x - minimum.x, maximum.y - minimum.y, maximum.z - minimum.z }
			.OrderByDescending( side => side )
			.ToArray();
		return sides[0] * 10000f + sides[1] * 100f + sides[2];
	}

	static MeshFace MeshFaceFor( ArchTool tool, ArchFaceDetail face )
	{
		if ( !tool.BuiltMeshes.TryGetValue( face.LayerId, out var components ) )
			return default;

		var component = components.FirstOrDefault( candidate => ArchAudit.Path( candidate.GameObject ) == face.Piece );
		if ( !component.IsValid() || component.Mesh is null )
			return default;

		var handle = component.Mesh.FaceHandleFromIndex( face.HandleIndex );
		return handle.IsValid ? new MeshFace( component, handle ) : default;
	}

	static IEnumerable<ArchLayerNode> Nodes( ArchLayerTree tree )
	{
		foreach ( var root in tree.Domains.SelectMany( domain => domain.Children ) )
		{
			foreach ( var node in Nodes( root ) )
			{
				yield return node;
			}
		}
	}

	static IEnumerable<ArchLayerNode> Nodes( ArchLayerNode root )
	{
		if ( root.Payload is not null )
			yield return root;

		foreach ( var child in root.Children )
		{
			foreach ( var node in Nodes( child ) )
			{
				yield return node;
			}
		}
	}

	static float Area( MeshFace face )
	{
		var vertices = face.Component.Mesh.GetFaceVertices( face.Handle );
		if ( vertices.Length < 3 )
			return 0f;

		var origin = face.Component.Mesh.GetVertexPosition( vertices[0] );
		var area = 0f;
		for ( var corner = 1; corner < vertices.Length - 1; corner++ )
		{
			var a = face.Component.Mesh.GetVertexPosition( vertices[corner] ) - origin;
			var b = face.Component.Mesh.GetVertexPosition( vertices[corner + 1] ) - origin;
			area += a.Cross( b ).Length * 0.5f;
		}
		return area;
	}
}