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.
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;
}
}